diff --git a/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx b/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx index caff600bf..9bae2122e 100644 --- a/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx +++ b/apps/remix/app/components/general/organisations/organisation-billing-banner.tsx @@ -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 = () => { <>
- {match(subscriptionStatus) - .with(SubscriptionStatus.PAST_DUE, () => Payment overdue) - .with(SubscriptionStatus.INACTIVE, () => Restricted Access) + {match(bannerVariant) + .with('PAST_DUE', () => Payment overdue) + .with('INACTIVE', () => Restricted Access) + .with('PENDING_PAYMENT', () => Payment required) .exhaustive()}
!isPending && setIsOpen(value)}> - {match(subscriptionStatus) - .with(SubscriptionStatus.PAST_DUE, () => ( + {match(bannerVariant) + .with('PAST_DUE', () => ( @@ -117,7 +119,7 @@ export const OrganisationBillingBanner = () => { )} )) - .with(SubscriptionStatus.INACTIVE, () => ( + .with('INACTIVE', () => ( @@ -153,8 +155,66 @@ export const OrganisationBillingBanner = () => { )} )) - .otherwise(() => null)} + .with('PENDING_PAYMENT', () => ( + + + + Payment required + + + + This organisation is awaiting payment. Complete checkout to unlock it. + + + + + + + If there is any issue with your subscription, please contact us at{' '} + {SUPPORT_EMAIL}. + + + + + {canExecuteOrganisationAction('MANAGE_BILLING', organisation.currentOrganisationRole) && ( + + + + + + )} + + )) + .exhaustive()} ); }; + +type BannerVariant = 'PAST_DUE' | 'INACTIVE' | 'PENDING_PAYMENT'; + +const getBannerVariant = (organisation: ReturnType): 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; +}; diff --git a/apps/remix/app/components/tables/user-billing-organisations-table.tsx b/apps/remix/app/components/tables/user-billing-organisations-table.tsx index 9cf3aebfc..468909b9a 100644 --- a/apps/remix/app/components/tables/user-billing-organisations-table.tsx +++ b/apps/remix/app/components/tables/user-billing-organisations-table.tsx @@ -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(() => ({ - label: t({ message: `Free`, context: `Subscription status` }), - variant: 'neutral' as const, - })); + .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 {label}; }, diff --git a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx index 652e35255..650bcd3e8 100644 --- a/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx +++ b/apps/remix/app/routes/_authenticated+/o.$orgUrl.settings.billing.tsx @@ -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 (
diff --git a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx index 1e6a92110..6d40357b2 100644 --- a/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx +++ b/apps/remix/app/routes/_authenticated+/t.$teamUrl+/_layout.tsx @@ -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 ( diff --git a/packages/ee/server-only/limits/server.ts b/packages/ee/server-only/limits/server.ts index 3164ee51f..f13da2cd6 100644 --- a/packages/ee/server-only/limits/server.ts +++ b/packages/ee/server-only/limits/server.ts @@ -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) { diff --git a/packages/ee/server-only/stripe/create-checkout-session.ts b/packages/ee/server-only/stripe/create-checkout-session.ts index 04c846055..4f04f3543 100644 --- a/packages/ee/server-only/stripe/create-checkout-session.ts +++ b/packages/ee/server-only/stripe/create-checkout-session.ts @@ -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) { diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts new file mode 100644 index 000000000..29a4d7b7f --- /dev/null +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -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; +}; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index b8b321e86..03f07daf3 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -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 => { try { const isBillingEnabled = IS_BILLING_ENABLED(); @@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise => { 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; + if (!SYNCED_EVENT_TYPES.includes(event.type)) { + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); + } - await onSubscriptionCreated({ subscription }); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + const eventObject = event.data.object as { customer?: string | Stripe.Customer | null }; - 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; + const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id; - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const previousAttributes = event.data.previous_attributes as Partial | null; + if (!customerId) { + console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`); - await onSubscriptionUpdated({ subscription, previousAttributes }); + return Response.json( + { + success: true, + message: 'Webhook received', + } satisfies StripeWebhookResponse, + { status: 200 }, + ); + } - 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 syncStripeCustomerSubscription({ customerId }); - await onSubscriptionDeleted({ subscription }); - - return Response.json( - { - success: true, - message: 'Webhook received', - } satisfies StripeWebhookResponse, - { status: 200 }, - ); - }) - .otherwise(() => { - return Response.json( - { - success: true, - message: 'Webhook received', - } satisfies StripeWebhookResponse, - { status: 200 }, - ); - }); + return Response.json( + { + success: true, + message: 'Webhook received', + } 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, diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts b/packages/ee/server-only/stripe/webhook/on-subscription-created.ts deleted file mode 100644 index 655bdbbf8..000000000 --- a/packages/ee/server-only/stripe/webhook/on-subscription-created.ts +++ /dev/null @@ -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; - 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; -}; - -/** - * 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; -}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts b/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts deleted file mode 100644 index 4bb78fe7b..000000000 --- a/packages/ee/server-only/stripe/webhook/on-subscription-deleted.ts +++ /dev/null @@ -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; - } -}; diff --git a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts b/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts deleted file mode 100644 index ad8692b03..000000000 --- a/packages/ee/server-only/stripe/webhook/on-subscription-updated.ts +++ /dev/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 | 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; -}; diff --git a/packages/lib/server-only/rate-limit/rate-limits.ts b/packages/lib/server-only/rate-limit/rate-limits.ts index a42c0f1a6..46233354c 100644 --- a/packages/lib/server-only/rate-limit/rate-limits.ts +++ b/packages/lib/server-only/rate-limit/rate-limits.ts @@ -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({ diff --git a/packages/lib/types/subscription.ts b/packages/lib/types/subscription.ts index 5cd3878ee..9ce0ea5ef 100644 --- a/packages/lib/types/subscription.ts +++ b/packages/lib/types/subscription.ts @@ -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; diff --git a/packages/lib/utils/billing.ts b/packages/lib/utils/billing.ts index be3fda96b..426ff43f3 100644 --- a/packages/lib/utils/billing.ts +++ b/packages/lib/utils/billing.ts @@ -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; +}; diff --git a/packages/trpc/server/admin-router/sync-organisation-subscription.ts b/packages/trpc/server/admin-router/sync-organisation-subscription.ts index ae9f264b2..aa709206a 100644 --- a/packages/trpc/server/admin-router/sync-organisation-subscription.ts +++ b/packages/trpc/server/admin-router/sync-organisation-subscription.ts @@ -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, }); }); diff --git a/packages/trpc/server/enterprise-router/router.ts b/packages/trpc/server/enterprise-router/router.ts index 85b1c7b5e..817753b41 100644 --- a/packages/trpc/server/enterprise-router/router.ts +++ b/packages/trpc/server/enterprise-router/router.ts @@ -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, diff --git a/packages/trpc/server/enterprise-router/sync-subscription.ts b/packages/trpc/server/enterprise-router/sync-subscription.ts new file mode 100644 index 000000000..384cdc1a5 --- /dev/null +++ b/packages/trpc/server/enterprise-router/sync-subscription.ts @@ -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', + }); + }); + }); diff --git a/packages/trpc/server/enterprise-router/sync-subscription.types.ts b/packages/trpc/server/enterprise-router/sync-subscription.types.ts new file mode 100644 index 000000000..a81703d56 --- /dev/null +++ b/packages/trpc/server/enterprise-router/sync-subscription.types.ts @@ -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; +export type TSyncSubscriptionResponse = z.infer; diff --git a/packages/trpc/server/organisation-router/create-organisation.ts b/packages/trpc/server/organisation-router/create-organisation.ts index 63853b1d8..6aa8331e1 100644 --- a/packages/trpc/server/organisation-router/create-organisation.ts +++ b/packages/trpc/server/organisation-router/create-organisation.ts @@ -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 customer = await createCustomer({ - email: user.email, - name: user.name || user.email, + 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 {