mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: rework stripe webhooks into idempotent subscription sync (#2977)
Replace per-event webhook handlers with a single sync function that fetches the current state from Stripe and converges the local subscription, claim, and organisation type. - Create organisations upfront before checkout, restricted as "pending payment" until the first payment syncs - Add rate-limited subscription sync route, triggered on checkout success so the UI doesn't wait on webhooks - Surface pending payment state in banner, billing table, and limits
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { SUPPORT_EMAIL } from '@documenso/lib/constants/app';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { cn } from '@documenso/ui/lib/utils';
|
||||
@@ -53,9 +54,9 @@ export const OrganisationBillingBanner = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const subscriptionStatus = organisation?.subscription?.status;
|
||||
const bannerVariant = getBannerVariant(organisation);
|
||||
|
||||
if (!organisation || subscriptionStatus === undefined || subscriptionStatus === SubscriptionStatus.ACTIVE) {
|
||||
if (!organisation || bannerVariant === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -63,27 +64,28 @@ export const OrganisationBillingBanner = () => {
|
||||
<>
|
||||
<div
|
||||
className={cn({
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'bg-destructive text-destructive-foreground': subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
'bg-yellow-200 text-yellow-900 dark:bg-yellow-400': bannerVariant === 'PAST_DUE',
|
||||
'bg-destructive text-destructive-foreground':
|
||||
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||
})}
|
||||
>
|
||||
<div className="mx-auto flex max-w-screen-xl items-center justify-center gap-x-4 px-4 py-2 font-medium text-sm">
|
||||
<div className="flex items-center">
|
||||
<AlertTriangle className="mr-2.5 h-5 w-5" />
|
||||
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => <Trans>Payment overdue</Trans>)
|
||||
.with(SubscriptionStatus.INACTIVE, () => <Trans>Restricted Access</Trans>)
|
||||
{match(bannerVariant)
|
||||
.with('PAST_DUE', () => <Trans>Payment overdue</Trans>)
|
||||
.with('INACTIVE', () => <Trans>Restricted Access</Trans>)
|
||||
.with('PENDING_PAYMENT', () => <Trans>Payment required</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
className={cn({
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500':
|
||||
subscriptionStatus === SubscriptionStatus.PAST_DUE,
|
||||
'text-yellow-900 hover:bg-yellow-100 dark:hover:bg-yellow-500': bannerVariant === 'PAST_DUE',
|
||||
'text-destructive-foreground hover:bg-destructive hover:text-white':
|
||||
subscriptionStatus === SubscriptionStatus.INACTIVE,
|
||||
bannerVariant === 'INACTIVE' || bannerVariant === 'PENDING_PAYMENT',
|
||||
})}
|
||||
disabled={isPending}
|
||||
onClick={() => setIsOpen(true)}
|
||||
@@ -95,8 +97,8 @@ export const OrganisationBillingBanner = () => {
|
||||
</div>
|
||||
|
||||
<Dialog open={isOpen} onOpenChange={(value) => !isPending && setIsOpen(value)}>
|
||||
{match(subscriptionStatus)
|
||||
.with(SubscriptionStatus.PAST_DUE, () => (
|
||||
{match(bannerVariant)
|
||||
.with('PAST_DUE', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => {
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.with(SubscriptionStatus.INACTIVE, () => (
|
||||
.with('INACTIVE', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
@@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => {
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.otherwise(() => null)}
|
||||
.with('PENDING_PAYMENT', () => (
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans>Payment required</Trans>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogDescription>
|
||||
<Trans>This organisation is awaiting payment. Complete checkout to unlock it.</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Alert variant="neutral">
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
If there is any issue with your subscription, please contact us at{' '}
|
||||
<a href={`mailto:${SUPPORT_EMAIL}`}>{SUPPORT_EMAIL}</a>.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && (
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button asChild>
|
||||
<Link to={`/o/${organisation.url}/settings/billing`}>
|
||||
<Trans>Manage Billing</Trans>
|
||||
</Link>
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
))
|
||||
.exhaustive()}
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT';
|
||||
|
||||
const getBannerVariant = (organisation: ReturnType<typeof useOptionalCurrentOrganisation>): BannerVariant | null => {
|
||||
if (!organisation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return 'PENDING_PAYMENT';
|
||||
}
|
||||
|
||||
const subscriptionStatus = organisation.subscription?.status;
|
||||
|
||||
if (subscriptionStatus === SubscriptionStatus.PAST_DUE) {
|
||||
return 'PAST_DUE';
|
||||
}
|
||||
|
||||
if (subscriptionStatus === SubscriptionStatus.INACTIVE) {
|
||||
return 'INACTIVE';
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useSession } from '@documenso/lib/client-only/providers/session';
|
||||
import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app';
|
||||
import { formatAvatarUrl } from '@documenso/lib/utils/avatars';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { canExecuteOrganisationAction } from '@documenso/lib/utils/organisations';
|
||||
import { AvatarWithText } from '@documenso/ui/primitives/avatar';
|
||||
import { Badge } from '@documenso/ui/primitives/badge';
|
||||
@@ -21,8 +22,8 @@ export const UserBillingOrganisationsTable = () => {
|
||||
return organisations.filter((org) => canExecuteOrganisationAction('MANAGE_BILLING', org.currentOrganisationRole));
|
||||
}, [organisations]);
|
||||
|
||||
const getSubscriptionStatusDisplay = (status: SubscriptionStatus | undefined) => {
|
||||
return match(status)
|
||||
const getSubscriptionStatusDisplay = (organisation: (typeof billingOrganisations)[number]) => {
|
||||
return match(organisation.subscription?.status)
|
||||
.with(SubscriptionStatus.ACTIVE, () => ({
|
||||
label: t({ message: `Active`, context: `Subscription status` }),
|
||||
variant: 'default' as const,
|
||||
@@ -35,10 +36,19 @@ export const UserBillingOrganisationsTable = () => {
|
||||
label: t({ message: `Inactive`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}))
|
||||
.otherwise(() => ({
|
||||
.otherwise(() => {
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return {
|
||||
label: t({ message: `Free (Pending)`, context: `Subscription status` }),
|
||||
variant: 'warning' as const,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: t({ message: `Free`, context: `Subscription status` }),
|
||||
variant: 'neutral' as const,
|
||||
}));
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const columns = useMemo(() => {
|
||||
@@ -62,9 +72,7 @@ export const UserBillingOrganisationsTable = () => {
|
||||
header: t`Subscription Status`,
|
||||
accessorKey: 'subscription',
|
||||
cell: ({ row }) => {
|
||||
const subscription = row.original.subscription;
|
||||
const status = subscription?.status;
|
||||
const { label, variant } = getSubscriptionStatusDisplay(status);
|
||||
const { label, variant } = getSubscriptionStatusDisplay(row.original);
|
||||
|
||||
return <Badge variant={variant}>{label}</Badge>;
|
||||
},
|
||||
|
||||
@@ -6,6 +6,8 @@ import { useLingui } from '@lingui/react';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { Loader } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import type Stripe from 'stripe';
|
||||
import { match, P } from 'ts-pattern';
|
||||
|
||||
@@ -23,12 +25,51 @@ export default function TeamsSettingBillingPage() {
|
||||
|
||||
const organisation = useCurrentOrganisation();
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const { data: subscriptionQuery, isLoading: isLoadingSubscription } =
|
||||
trpc.enterprise.billing.subscription.get.useQuery({
|
||||
organisationId: organisation.id,
|
||||
});
|
||||
|
||||
if (isLoadingSubscription || !subscriptionQuery) {
|
||||
const { mutateAsync: syncSubscription, isPending: isSyncingSubscription } =
|
||||
trpc.enterprise.billing.subscription.sync.useMutation();
|
||||
|
||||
const hasTriggeredCheckoutSyncRef = useRef(false);
|
||||
|
||||
const isCheckoutSuccess = searchParams.get('success') === 'true';
|
||||
|
||||
/**
|
||||
* Eagerly sync the subscription from Stripe when returning from a successful
|
||||
* checkout, since the webhook may not have arrived yet.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isCheckoutSuccess || hasTriggeredCheckoutSyncRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasTriggeredCheckoutSyncRef.current = true;
|
||||
|
||||
void syncSubscription({ organisationId: organisation.id })
|
||||
.catch(() => {
|
||||
// Non-fatal, webhooks will converge the subscription state shortly.
|
||||
})
|
||||
.finally(() => {
|
||||
void utils.enterprise.billing.invalidate();
|
||||
|
||||
setSearchParams(
|
||||
(params) => {
|
||||
params.delete('success');
|
||||
|
||||
return params;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
});
|
||||
}, [isCheckoutSuccess, organisation.id]);
|
||||
|
||||
if (isLoadingSubscription || !subscriptionQuery || isSyncingSubscription) {
|
||||
return (
|
||||
<div className="flex items-center justify-center rounded-lg py-32">
|
||||
<Loader className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT, PAID_PLAN_LIMITS } from '@documenso/ee/server-only/limits/constants';
|
||||
import { LimitsProvider } from '@documenso/ee/server-only/limits/provider/client';
|
||||
import { useOptionalCurrentOrganisation } from '@documenso/lib/client-only/providers/organisation';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { TrpcProvider } from '@documenso/trpc/react';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
@@ -21,7 +22,11 @@ export default function Layout() {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (organisation?.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) {
|
||||
const isRestricted =
|
||||
(organisation.subscription && organisation.subscription.status === SubscriptionStatus.INACTIVE) ||
|
||||
isOrganisationPendingPayment(organisation);
|
||||
|
||||
if (isRestricted) {
|
||||
return {
|
||||
quota: {
|
||||
documents: 0,
|
||||
@@ -42,7 +47,7 @@ export default function Layout() {
|
||||
remaining: PAID_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount: DEFAULT_MINIMUM_ENVELOPE_ITEM_COUNT,
|
||||
};
|
||||
}, [organisation?.subscription]);
|
||||
}, [organisation]);
|
||||
|
||||
if (!team) {
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
|
||||
import { DateTime } from 'luxon';
|
||||
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
|
||||
};
|
||||
}
|
||||
|
||||
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
|
||||
if (isOrganisationPendingPayment(organisation)) {
|
||||
return {
|
||||
quota: INACTIVE_PLAN_LIMITS,
|
||||
remaining: INACTIVE_PLAN_LIMITS,
|
||||
maximumEnvelopeItemCount,
|
||||
};
|
||||
}
|
||||
|
||||
// Allow unlimited documents for users with an unlimited documents claim.
|
||||
// This also allows "free" claim users without subscriptions if they have this flag.
|
||||
if (organisation.organisationClaim.flags.unlimitedDocuments) {
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type Stripe from 'stripe';
|
||||
|
||||
export type CreateCheckoutSessionOptions = {
|
||||
customerId: string;
|
||||
priceId: string;
|
||||
returnUrl: string;
|
||||
subscriptionMetadata?: Stripe.Metadata;
|
||||
};
|
||||
|
||||
export const createCheckoutSession = async ({
|
||||
customerId,
|
||||
priceId,
|
||||
returnUrl,
|
||||
subscriptionMetadata,
|
||||
}: CreateCheckoutSessionOptions) => {
|
||||
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
customer: customerId,
|
||||
mode: 'subscription',
|
||||
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
|
||||
success_url: `${returnUrl}?success=true`,
|
||||
cancel_url: `${returnUrl}?canceled=true`,
|
||||
billing_address_collection: 'required',
|
||||
subscription_data: {
|
||||
metadata: subscriptionMetadata,
|
||||
},
|
||||
});
|
||||
|
||||
if (!session.url) {
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, type Prisma, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||
|
||||
export type SyncStripeCustomerSubscriptionOptions = {
|
||||
customerId: string;
|
||||
|
||||
/**
|
||||
* When true, the organisationClaim will not be synced.
|
||||
*
|
||||
* Used by the admin sync route to update only the Subscription
|
||||
* row while leaving claim entitlements untouched.
|
||||
*/
|
||||
bypassClaimUpdate?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
|
||||
*
|
||||
* Fetches the current truth from Stripe and writes it locally, regardless of which
|
||||
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
|
||||
* any number of times.
|
||||
*
|
||||
* This function never creates organisations.
|
||||
*/
|
||||
export const syncStripeCustomerSubscription = async ({
|
||||
customerId,
|
||||
bypassClaimUpdate = false,
|
||||
}: SyncStripeCustomerSubscriptionOptions) => {
|
||||
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
|
||||
// limit on list endpoints, so the product is fetched separately when needed.
|
||||
const stripeSubscriptions = await stripe.subscriptions.list({
|
||||
customer: customerId,
|
||||
status: 'all',
|
||||
limit: 100,
|
||||
});
|
||||
|
||||
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
|
||||
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
|
||||
);
|
||||
|
||||
if (liveSubscriptions.length > 1) {
|
||||
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
|
||||
|
||||
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const liveSubscription = liveSubscriptions[0];
|
||||
|
||||
if (!liveSubscription) {
|
||||
await handleNoLiveSubscription({ organisation });
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await handleLiveSubscription({
|
||||
organisation,
|
||||
subscription: liveSubscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
});
|
||||
};
|
||||
|
||||
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
|
||||
include: { organisationClaim: true; subscription: true };
|
||||
}>;
|
||||
|
||||
type HandleNoLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
};
|
||||
|
||||
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
|
||||
// Individuals get their subscription deleted so they can return to the free plan.
|
||||
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
if (organisation.subscription) {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive if a row exists.
|
||||
if (organisation.subscription) {
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: organisation.subscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
type HandleLiveSubscriptionOptions = {
|
||||
organisation: OrganisationWithClaimAndSubscription;
|
||||
subscription: Stripe.Subscription;
|
||||
customerId: string;
|
||||
bypassClaimUpdate: boolean;
|
||||
};
|
||||
|
||||
const handleLiveSubscription = async ({
|
||||
organisation,
|
||||
subscription,
|
||||
customerId,
|
||||
bypassClaimUpdate,
|
||||
}: HandleLiveSubscriptionOptions) => {
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
|
||||
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
}
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
const shouldUpdateClaim =
|
||||
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual/free plan.
|
||||
// Never demote an ORGANISATION back to PERSONAL.
|
||||
const shouldMigrateOrganisationType =
|
||||
organisation.type === OrganisationType.PERSONAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
claim.id !== INTERNAL_CLAIM_ID.FREE;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.upsert({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
create: {
|
||||
organisationId: organisation.id,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscriptionItem.price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
if (shouldUpdateClaim) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldMigrateOrganisationType) {
|
||||
await tx.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
// Use the expanded product when available to avoid an extra API call.
|
||||
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
|
||||
return priceId.product.metadata.claimId || null;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -2,17 +2,29 @@ import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { onSubscriptionCreated } from './on-subscription-created';
|
||||
import { onSubscriptionDeleted } from './on-subscription-deleted';
|
||||
import { onSubscriptionUpdated } from './on-subscription-updated';
|
||||
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Events that trigger a sync of the customer's subscription state.
|
||||
*
|
||||
* The event payload is never trusted beyond extracting the customer ID,
|
||||
* the sync function fetches the current truth from Stripe.
|
||||
*/
|
||||
const SYNCED_EVENT_TYPES: string[] = [
|
||||
'customer.subscription.created',
|
||||
'customer.subscription.updated',
|
||||
'customer.subscription.deleted',
|
||||
'checkout.session.completed',
|
||||
'invoice.payment_succeeded',
|
||||
'invoice.payment_failed',
|
||||
];
|
||||
|
||||
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
try {
|
||||
const isBillingEnabled = IS_BILLING_ENABLED();
|
||||
@@ -60,41 +72,23 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
|
||||
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
|
||||
/**
|
||||
* Notes:
|
||||
* - Dropped invoice.payment_succeeded
|
||||
* - Dropped invoice.payment_failed
|
||||
* - Dropped checkout-session.completed
|
||||
*/
|
||||
return await match(event.type)
|
||||
.with('customer.subscription.created', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await onSubscriptionCreated({ subscription });
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.updated', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
message: 'Webhook received',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
|
||||
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
|
||||
|
||||
await onSubscriptionUpdated({ subscription, previousAttributes });
|
||||
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
|
||||
|
||||
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
|
||||
status: 200,
|
||||
});
|
||||
})
|
||||
.with('customer.subscription.deleted', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
const subscription = event.data.object as Stripe.Subscription;
|
||||
|
||||
await onSubscriptionDeleted({ subscription });
|
||||
if (!customerId) {
|
||||
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
@@ -103,8 +97,10 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
})
|
||||
.otherwise(() => {
|
||||
}
|
||||
|
||||
await syncStripeCustomerSubscription({ customerId });
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: true,
|
||||
@@ -112,17 +108,9 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 200 },
|
||||
);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
|
||||
if (err instanceof Response) {
|
||||
const message = await err.json();
|
||||
console.error(message);
|
||||
|
||||
return err;
|
||||
}
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
import {
|
||||
createOrganisation,
|
||||
createOrganisationClaimUpsertData,
|
||||
} from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
|
||||
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, type SubscriptionClaim, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
import { extractStripeClaim } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionCreatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
|
||||
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
|
||||
* potentially created.
|
||||
*/
|
||||
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const subscriptionItem = subscription.items.data[0];
|
||||
const claim = await extractStripeClaim(subscriptionItem.price);
|
||||
|
||||
// Todo: logging
|
||||
if (!claim) {
|
||||
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisationCreateData = subscription.metadata?.organisationCreateData;
|
||||
|
||||
// A new subscription can be for an existing organisation or a new one.
|
||||
const organisationId = organisationCreateData
|
||||
? await handleOrganisationCreate({
|
||||
customerId,
|
||||
claim,
|
||||
unknownCreateData: organisationCreateData,
|
||||
})
|
||||
: await handleOrganisationUpdate({
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
await prisma.subscription.upsert({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
create: {
|
||||
organisationId,
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
update: {
|
||||
status,
|
||||
customerId,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
type HandleOrganisationCreateOptions = {
|
||||
customerId: string;
|
||||
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
|
||||
unknownCreateData: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the creation of an organisation.
|
||||
*/
|
||||
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
|
||||
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
|
||||
|
||||
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
|
||||
|
||||
if (!parseResult.success) {
|
||||
console.error('Invalid organisation create flow data');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'Invalid organisation create flow data',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
organisationCreateFlowData = parseResult.data;
|
||||
|
||||
const createdOrganisation = await createOrganisation({
|
||||
name: organisationCreateFlowData.organisationName,
|
||||
userId: organisationCreateFlowData.userId,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
customerId,
|
||||
claim,
|
||||
});
|
||||
|
||||
return createdOrganisation.id;
|
||||
};
|
||||
|
||||
type HandleOrganisationUpdateOptions = {
|
||||
customerId: string;
|
||||
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Handles the updating an exist organisation claims.
|
||||
*/
|
||||
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
// Todo: logging
|
||||
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
|
||||
console.error('Organisation already has an active subscription');
|
||||
|
||||
// This should never happen
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation already has an active subscription`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
|
||||
|
||||
// Keep the organisation as personal if the claim is for an individual.
|
||||
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
newOrganisationType = OrganisationType.PERSONAL;
|
||||
}
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: newOrganisationType,
|
||||
organisationClaim: {
|
||||
update: {
|
||||
originalSubscriptionClaimId: claim.id,
|
||||
...createOrganisationClaimUpsertData(claim),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return organisation.id;
|
||||
};
|
||||
@@ -1,90 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import type { Stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { SubscriptionStatus } from '@prisma/client';
|
||||
import { extractStripeClaimId } from './on-subscription-updated';
|
||||
|
||||
export type OnSubscriptionDeletedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
};
|
||||
|
||||
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
|
||||
const existingSubscription = await prisma.subscription.findUnique({
|
||||
where: {
|
||||
planId: subscription.id,
|
||||
},
|
||||
include: {
|
||||
organisation: {
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// If the subscription doesn't exist, we don't need to do anything.
|
||||
if (!existingSubscription) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
|
||||
|
||||
// Individuals get their subscription deleted so they can return to the
|
||||
// free plan.
|
||||
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.delete({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: existingSubscription.organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
|
||||
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// For all other cases, mark the subscription as inactive since
|
||||
// they should still have a "Personal" account.
|
||||
await prisma.subscription.update({
|
||||
where: {
|
||||
id: existingSubscription.id,
|
||||
},
|
||||
data: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts the claim ID from the Stripe subscription.
|
||||
*
|
||||
* Returns `null` if no claim ID found.
|
||||
*/
|
||||
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
|
||||
const deletedItem = subscription.items.data[0];
|
||||
|
||||
if (!deletedItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return await extractStripeClaimId(deletedItem.price);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,194 +0,0 @@
|
||||
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { match } from 'ts-pattern';
|
||||
|
||||
export type OnSubscriptionUpdatedOptions = {
|
||||
subscription: Stripe.Subscription;
|
||||
previousAttributes: Partial<Stripe.Subscription> | null;
|
||||
/**
|
||||
* When true, the organisationClaim will not be synced.
|
||||
*
|
||||
* Used by the admin sync route to update only the Subscription
|
||||
* row while leaving claim entitlements untouched.
|
||||
*/
|
||||
bypassClaimUpdate?: boolean;
|
||||
};
|
||||
|
||||
type StripeWebhookResponse = {
|
||||
success: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export const onSubscriptionUpdated = async ({
|
||||
subscription,
|
||||
previousAttributes,
|
||||
bypassClaimUpdate = false,
|
||||
}: OnSubscriptionUpdatedOptions) => {
|
||||
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
|
||||
|
||||
// Todo: logging
|
||||
if (subscription.items.data.length !== 1) {
|
||||
console.error('No support for multiple items');
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: 'No support for multiple items',
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
customerId,
|
||||
},
|
||||
include: {
|
||||
organisationClaim: true,
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Organisation not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
organisation.subscription &&
|
||||
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
|
||||
organisation.subscription.planId !== subscription.id
|
||||
) {
|
||||
console.error('[WARNING]: Organisation might have two subscriptions');
|
||||
}
|
||||
|
||||
const previousItem = previousAttributes?.items?.data[0];
|
||||
const updatedItem = subscription.items.data[0];
|
||||
|
||||
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
|
||||
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
|
||||
|
||||
if (!updatedSubscriptionClaim) {
|
||||
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
|
||||
|
||||
throw Response.json(
|
||||
{
|
||||
success: false,
|
||||
message: `Subscription claim on ${updatedItem.price.id} not found`,
|
||||
} satisfies StripeWebhookResponse,
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
|
||||
|
||||
const status = match(subscription.status)
|
||||
.with('active', () => SubscriptionStatus.ACTIVE)
|
||||
.with('trialing', () => SubscriptionStatus.ACTIVE)
|
||||
.with('past_due', () => SubscriptionStatus.PAST_DUE)
|
||||
.otherwise(() => SubscriptionStatus.INACTIVE);
|
||||
|
||||
const periodEnd =
|
||||
subscription.status === 'trialing' && subscription.trial_end
|
||||
? new Date(subscription.trial_end * 1000)
|
||||
: new Date(subscription.current_period_end * 1000);
|
||||
|
||||
// Migrate the organisation type if it is no longer an individual plan.
|
||||
if (
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
|
||||
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
|
||||
organisation.type === OrganisationType.PERSONAL
|
||||
) {
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
type: OrganisationType.ORGANISATION,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.subscription.update({
|
||||
where: {
|
||||
organisationId: organisation.id,
|
||||
},
|
||||
data: {
|
||||
status: status,
|
||||
planId: subscription.id,
|
||||
priceId: subscription.items.data[0].price.id,
|
||||
periodEnd,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
},
|
||||
});
|
||||
|
||||
// Override current organisation claim if new one is found.
|
||||
// Skipped when bypassClaimUpdate is set.
|
||||
if (!bypassClaimUpdate && newClaimFound) {
|
||||
await tx.organisationClaim.update({
|
||||
where: {
|
||||
id: organisation.organisationClaim.id,
|
||||
},
|
||||
data: {
|
||||
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
|
||||
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
* The order of priority is:
|
||||
* 1. Price metadata
|
||||
* 2. Product metadata
|
||||
*
|
||||
* @returns The claimId or null if no claimId is found.
|
||||
*/
|
||||
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
|
||||
if (priceId.metadata.claimId) {
|
||||
return priceId.metadata.claimId;
|
||||
}
|
||||
|
||||
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
|
||||
|
||||
const product = await stripe.products.retrieve(productId);
|
||||
|
||||
return product.metadata.claimId || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks the price metadata for a claimId, if it is missing it will fetch
|
||||
* and check the product metadata for a claimId.
|
||||
*
|
||||
*/
|
||||
export const extractStripeClaim = async (priceId: Stripe.Price) => {
|
||||
const claimId = await extractStripeClaimId(priceId);
|
||||
|
||||
if (!claimId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
|
||||
where: { id: claimId },
|
||||
});
|
||||
|
||||
if (!subscriptionClaim) {
|
||||
console.error(`Subscription claim ${claimId} not found`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return subscriptionClaim;
|
||||
};
|
||||
@@ -72,6 +72,14 @@ export const reportSenderRateLimit = createRateLimit({
|
||||
window: '7d',
|
||||
});
|
||||
|
||||
// ---- Billing ----
|
||||
|
||||
export const syncSubscriptionRateLimit = createRateLimit({
|
||||
action: 'billing.sync-subscription',
|
||||
max: 10,
|
||||
window: '15m',
|
||||
});
|
||||
|
||||
// ---- API (Tier 4 - Standard) ----
|
||||
|
||||
export const apiV1RateLimit = createRateLimit({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { ZOrganisationNameSchema } from '@documenso/trpc/server/organisation-router/create-organisation.types';
|
||||
import type { SubscriptionClaim } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -179,10 +178,3 @@ export const internalClaims: InternalClaims = {
|
||||
name: 'Early Adopter',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const ZStripeOrganisationCreateMetadataSchema = z.object({
|
||||
organisationName: ZOrganisationNameSchema,
|
||||
userId: z.number(),
|
||||
});
|
||||
|
||||
export type StripeOrganisationCreateMetadata = z.infer<typeof ZStripeOrganisationCreateMetadataSchema>;
|
||||
|
||||
@@ -1,19 +1,9 @@
|
||||
import type { Subscription } from '@documenso/prisma/generated/zod/modelSchema/SubscriptionSchema';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
|
||||
import { IS_BILLING_ENABLED } from '../constants/app';
|
||||
import { AppError, AppErrorCode } from '../errors/app-error';
|
||||
import type { StripeOrganisationCreateMetadata } from '../types/subscription';
|
||||
|
||||
export const generateStripeOrganisationCreateMetadata = (organisationName: string, userId: number) => {
|
||||
const metadata: StripeOrganisationCreateMetadata = {
|
||||
organisationName,
|
||||
userId,
|
||||
};
|
||||
|
||||
return {
|
||||
organisationCreateData: JSON.stringify(metadata),
|
||||
};
|
||||
};
|
||||
import { INTERNAL_CLAIM_ID } from '../types/subscription';
|
||||
|
||||
/**
|
||||
* Throws an error if billing is enabled and no subscription is found.
|
||||
@@ -33,3 +23,36 @@ export const validateIfSubscriptionIsRequired = (subscription?: Subscription | n
|
||||
|
||||
return subscription;
|
||||
};
|
||||
|
||||
type PendingPaymentOrganisation = {
|
||||
type: OrganisationType;
|
||||
subscription?: unknown;
|
||||
organisationClaim: {
|
||||
originalSubscriptionClaimId: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the organisation was created ahead of a paid checkout and is still awaiting
|
||||
* its first successful payment.
|
||||
*
|
||||
* Such organisations have no subscription row and still carry the copied "free" claim,
|
||||
* and must be treated as restricted until the Stripe webhook sync activates them.
|
||||
*
|
||||
* Always returns false when billing is disabled (self-hosted).
|
||||
*/
|
||||
export const isOrganisationPendingPayment = (organisation: PendingPaymentOrganisation) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (organisation.type !== OrganisationType.ORGANISATION) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (organisation.subscription) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.FREE;
|
||||
};
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { onSubscriptionUpdated } from '@documenso/ee/server-only/stripe/webhook/on-subscription-updated';
|
||||
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { Stripe, stripe } from '@documenso/lib/server-only/stripe';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { adminProcedure } from '../trpc';
|
||||
@@ -24,9 +23,6 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
|
||||
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: { id: organisationId },
|
||||
include: {
|
||||
subscription: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
@@ -35,47 +31,14 @@ export const syncOrganisationSubscriptionRoute = adminProcedure
|
||||
});
|
||||
}
|
||||
|
||||
if (!organisation.subscription) {
|
||||
if (!organisation.customerId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Organisation has no subscription to sync',
|
||||
message: 'Organisation has no Stripe customer to sync from',
|
||||
});
|
||||
}
|
||||
|
||||
let stripeSubscription: Stripe.Subscription;
|
||||
|
||||
try {
|
||||
stripeSubscription = await stripe.subscriptions.retrieve(organisation.subscription.planId, {
|
||||
expand: ['items.data.price.product'],
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Stripe.errors.StripeInvalidRequestError && error.code === 'resource_missing') {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Subscription not found on Stripe',
|
||||
});
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const stripeCustomerId =
|
||||
typeof stripeSubscription.customer === 'string' ? stripeSubscription.customer : stripeSubscription.customer.id;
|
||||
|
||||
if (organisation.customerId !== stripeCustomerId) {
|
||||
ctx.logger.error({
|
||||
message: 'Organisation customerId does not match Stripe subscription customer',
|
||||
organisationId,
|
||||
localCustomerId: organisation.customerId,
|
||||
stripeCustomerId,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Organisation customerId mismatch: local=${organisation.customerId ?? 'null'}, Stripe=${stripeCustomerId}`,
|
||||
});
|
||||
}
|
||||
|
||||
await onSubscriptionUpdated({
|
||||
subscription: stripeSubscription,
|
||||
previousAttributes: null,
|
||||
await syncStripeCustomerSubscription({
|
||||
customerId: organisation.customerId,
|
||||
bypassClaimUpdate: !syncClaims,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { getPlansRoute } from './get-plans';
|
||||
import { getSubscriptionRoute } from './get-subscription';
|
||||
import { linkOrganisationAccountRoute } from './link-organisation-account';
|
||||
import { manageSubscriptionRoute } from './manage-subscription';
|
||||
import { syncSubscriptionRoute } from './sync-subscription';
|
||||
import { updateOrganisationAuthenticationPortalRoute } from './update-organisation-authentication-portal';
|
||||
import { updateOrganisationEmailRoute } from './update-organisation-email';
|
||||
import { verifyOrganisationEmailDomainRoute } from './verify-organisation-email-domain';
|
||||
@@ -48,6 +49,7 @@ export const enterpriseRouter = router({
|
||||
get: getSubscriptionRoute,
|
||||
create: createSubscriptionRoute,
|
||||
manage: manageSubscriptionRoute,
|
||||
sync: syncSubscriptionRoute,
|
||||
},
|
||||
invoices: {
|
||||
get: getInvoicesRoute,
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { syncStripeCustomerSubscription } from '@documenso/ee/server-only/stripe/sync-stripe-customer-subscription';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import { ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP } from '@documenso/lib/constants/organisations';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { assertRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limit-middleware';
|
||||
import { syncSubscriptionRateLimit } from '@documenso/lib/server-only/rate-limit/rate-limits';
|
||||
import { buildOrganisationWhereQuery } from '@documenso/lib/utils/organisations';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZSyncSubscriptionRequestSchema, ZSyncSubscriptionResponseSchema } from './sync-subscription.types';
|
||||
|
||||
export const syncSubscriptionRoute = authenticatedProcedure
|
||||
.input(ZSyncSubscriptionRequestSchema)
|
||||
.output(ZSyncSubscriptionResponseSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const { organisationId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
const userId = ctx.user.id;
|
||||
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const rateLimitResult = await syncSubscriptionRateLimit.check({
|
||||
ip: ctx.metadata.requestMetadata.ipAddress ?? 'unknown',
|
||||
identifier: `${userId}`,
|
||||
});
|
||||
|
||||
assertRateLimit(rateLimitResult);
|
||||
|
||||
const organisation = await prisma.organisation.findFirst({
|
||||
where: buildOrganisationWhereQuery({
|
||||
organisationId,
|
||||
userId,
|
||||
roles: ORGANISATION_MEMBER_ROLE_PERMISSIONS_MAP.MANAGE_BILLING,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!organisation) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
if (!organisation.customerId) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Organisation has no billing customer',
|
||||
});
|
||||
}
|
||||
|
||||
await syncStripeCustomerSubscription({
|
||||
customerId: organisation.customerId,
|
||||
}).catch((error) => {
|
||||
ctx.logger.error({
|
||||
msg: 'Failed to sync the subscription from Stripe',
|
||||
error,
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Failed to sync the subscription from Stripe',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZSyncSubscriptionRequestSchema = z.object({
|
||||
organisationId: z.string().describe('The organisation to sync the subscription for'),
|
||||
});
|
||||
|
||||
export const ZSyncSubscriptionResponseSchema = z.void();
|
||||
|
||||
export type TSyncSubscriptionRequest = z.infer<typeof ZSyncSubscriptionRequestSchema>;
|
||||
export type TSyncSubscriptionResponse = z.infer<typeof ZSyncSubscriptionResponseSchema>;
|
||||
@@ -5,9 +5,8 @@ import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { createOrganisation } from '@documenso/lib/server-only/organisation/create-organisation';
|
||||
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
|
||||
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
|
||||
import { generateStripeOrganisationCreateMetadata } from '@documenso/lib/utils/billing';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { OrganisationType } from '@prisma/client';
|
||||
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
|
||||
import { authenticatedProcedure } from '../trpc';
|
||||
import { ZCreateOrganisationRequestSchema, ZCreateOrganisationResponseSchema } from './create-organisation.types';
|
||||
|
||||
@@ -43,18 +42,67 @@ export const createOrganisationRoute = authenticatedProcedure
|
||||
}
|
||||
}
|
||||
|
||||
// Create checkout session for payment.
|
||||
// Create the organisation upfront, then redirect to checkout for payment.
|
||||
// The webhook sync will attach the real subscription and claim after payment.
|
||||
if (IS_BILLING_ENABLED() && priceId) {
|
||||
const pendingOrganisation = await prisma.organisation.findFirst({
|
||||
where: {
|
||||
ownerUserId: user.id,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
OR: [
|
||||
{
|
||||
subscription: {
|
||||
is: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
subscription: {
|
||||
status: SubscriptionStatus.INACTIVE,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
if (pendingOrganisation) {
|
||||
throw new AppError(AppErrorCode.LIMIT_EXCEEDED, {
|
||||
message: 'You have a pending organisation awaiting payment. Complete or remove it before creating a new one.',
|
||||
});
|
||||
}
|
||||
|
||||
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
|
||||
|
||||
const organisation = await createOrganisation({
|
||||
userId: user.id,
|
||||
name,
|
||||
type: OrganisationType.ORGANISATION,
|
||||
claim: freeSubscriptionClaim,
|
||||
});
|
||||
|
||||
let customerId = organisation.customerId;
|
||||
|
||||
if (!customerId) {
|
||||
const customer = await createCustomer({
|
||||
email: user.email,
|
||||
name: user.name || user.email,
|
||||
});
|
||||
|
||||
customerId = customer.id;
|
||||
|
||||
await prisma.organisation.update({
|
||||
where: {
|
||||
id: organisation.id,
|
||||
},
|
||||
data: {
|
||||
customerId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const checkoutUrl = await createCheckoutSession({
|
||||
priceId,
|
||||
customerId: customer.id,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/settings/organisations`,
|
||||
subscriptionMetadata: generateStripeOrganisationCreateMetadata(name, user.id),
|
||||
customerId,
|
||||
returnUrl: `${NEXT_PUBLIC_WEBAPP_URL()}/o/${organisation.url}/settings/billing`,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user