diff --git a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts index d0aece3af..9bc4dbb3f 100644 --- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -5,8 +5,7 @@ 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'; - -import { reconcileSeatsWithMemberCount } from './update-subscription-item-quantity'; +import { reconcileOrganisationClaimWithStripeSubscriptionQuantity } from './update-subscription-item-quantity'; const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due']; @@ -242,7 +241,7 @@ const handleLiveSubscription = async ({ const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime(); if (hasPeriodAdvanced && !bypassClaimUpdate) { - await reconcileSeatsWithMemberCount(organisation.id); + await reconcileOrganisationClaimWithStripeSubscriptionQuantity(organisation.id); } }; diff --git a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts index 7a1fd92aa..eab39982f 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -3,7 +3,6 @@ import { stripe } from '@documenso/lib/server-only/stripe'; import { appLog } from '@documenso/lib/utils/debugger'; import { prisma } from '@documenso/prisma'; import type { OrganisationClaim, Subscription } from '@prisma/client'; -import { SubscriptionStatus } from '@prisma/client'; import type Stripe from 'stripe'; import { isPriceSeatsBased } from './is-price-seats-based'; @@ -96,42 +95,29 @@ export const assertMemberCountWithinCap = async ( }; /** - * Syncs the organisation's member count with the Stripe subscription quantity. + * Syncs the Stripe subscription quantity with the organisation's member count. * - * For seat-based plans, `organisationClaim.memberCount` is the paid seat - * high-water mark for the current billing period and always mirrors the - * Stripe quantity. + * This is a Stripe <-> Database sync operation. * - * - Mode `grow`: will skip if the new count is within the paid - * high-water mark (the seat is already paid for); anything above the mark - * is invoiced immediately. - * - Mode `reconcile`: writes the actual member count with no prorations in - * either direction (renewal-time true-up). - * - * No-ops for plans that are not seats-based and for organisations with - * unlimited seats (`organisationClaim.memberCount === 0`). + * Note: `organisationClaim.memberCount` is the paid seat high-water mark for the + * current billing period — the highest count we've already billed for. * * @param subscription - The subscription to sync the member count with. * @param organisationClaim - The organisation claim. * @param quantity - The new total member count to sync. - * @param mode - Whether this is a grow operation or a renewal reconcile. + * @param mode - The member-count change that triggered the sync. */ export const syncMemberCountWithStripeSeatPlan = async ( subscription: Subscription, organisationClaim: OrganisationClaim, quantity: number, - mode: 'grow' | 'reconcile', + mode: 'grow' | 'shrink', ) => { - // Early return if the organisation has unlimited seats. + // Unlimited seats — nothing to meter. if (organisationClaim.memberCount === 0) { return; } - // Early return if the new count is less than the paid high-water mark for grow mode. - if (mode === 'grow' && quantity <= organisationClaim.memberCount) { - return; - } - const isSeatsBased = await isPriceSeatsBased(subscription.priceId); // Only seat-based plans support seat syncing. @@ -139,80 +125,87 @@ export const syncMemberCountWithStripeSeatPlan = async ( return; } - appLog('BILLING', `Updating seat based plan (${mode})`); + // Whether to immediately invoice for new seats if the quantity is greater than + // the high-water mark. + const billsForNewSeats = mode === 'grow' && quantity > organisationClaim.memberCount; + + appLog('BILLING', `Syncing seat based plan (${mode}, quantity ${quantity})`); await updateSubscriptionItemQuantity({ priceId: subscription.priceId, subscriptionId: subscription.planId, quantity, - prorationBehaviour: mode === 'grow' ? 'always_invoice' : 'none', + prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none', }); - // The claim mirrors the Stripe quantity (the paid seat high-water mark). - // This write is the only place the mark advances on grow — the - // subscription webhook's claim overwrite preserves the already-billed - // Stripe quantity but never advances it. - await prisma.organisationClaim.update({ - where: { - id: organisationClaim.id, - }, - data: { - memberCount: quantity, - }, - }); + // Advance the high-water mark when billing for new seats reset it to the + // actual count on reconcile. Re-adds and shrinks deliberately leave it so a + // seat already paid for this period is never re-charged. + if (billsForNewSeats) { + await prisma.organisationClaim.update({ + where: { + id: organisationClaim.id, + }, + data: { + memberCount: quantity, + }, + }); + } }; /** - * Reconciles the Stripe seat quantity and organisation claim with the actual - * member count at the start of a new billing period. - + * Pulls stripe subscription quantity and reconciles it with the organisation claim. + * + * This is a Stripe -> Database sync operation. */ -export const reconcileSeatsWithMemberCount = async (organisationId: string) => { - const organisation = await prisma.organisation.findUnique({ +export const reconcileOrganisationClaimWithStripeSubscriptionQuantity = async (organisationId: string) => { + const organisation = await prisma.organisation.findFirst({ where: { id: organisationId, }, include: { - subscription: true, organisationClaim: true, + subscription: true, }, }); + console.log('Reconciling organisation claim with stripe subscription quantity'); + if (!organisation || !organisation.subscription) { - appLog('BILLING', 'Reconcile skipped: organisation or subscription not found'); - + console.log('No organisation or subscription found'); return; } - // Only ACTIVE subscriptions reconcile. INACTIVE (canceled) subscriptions - // cannot have their quantity updated in Stripe, and skipping PAST_DUE is - // deliberate: drift heals at the first renewal after recovery. - if (organisation.subscription.status !== SubscriptionStatus.ACTIVE) { - appLog('BILLING', 'Reconcile skipped: subscription not active'); + const { subscription, organisationClaim } = organisation; + const isSeatsBased = await isPriceSeatsBased(subscription.priceId); + + // Only seat-based plans support seat syncing. + if (!isSeatsBased) { + console.log('Not a seats-based plan'); return; } - const memberCount = await prisma.organisationMember.count({ - where: { - organisationId, - }, - }); + const stripeSubscription = await stripe.subscriptions.retrieve(subscription.planId); - // An organisation always has at least its owner. Guarding zero protects - // more than the Stripe quantity: writing 0 to the claim would flip - // memberCount to the unlimited sentinel and permanently exempt the - // organisation from seat billing. - if (memberCount === 0) { - appLog('BILLING', 'Reconcile skipped: organisation has no members'); + // Get the highest quantity item in the subscription. There should only ever be one + // but this is a safeguard. + const stripeSeatsQuantity = + stripeSubscription.items.data.length > 0 + ? Math.max(...stripeSubscription.items.data.map((item) => item.quantity ?? 0)) + : 0; - return; + console.log('Stripe seats quantity', stripeSeatsQuantity); + + // Never sync a quantity of 0 since that gives unlimited seats. + if (stripeSeatsQuantity > 0) { + await prisma.organisationClaim.update({ + where: { + id: organisationClaim.id, + }, + data: { + memberCount: stripeSeatsQuantity, + }, + }); } - - await syncMemberCountWithStripeSeatPlan( - organisation.subscription, - organisation.organisationClaim, - memberCount, - 'reconcile', - ); }; diff --git a/packages/ee/server-only/stripe/webhook/handler.ts b/packages/ee/server-only/stripe/webhook/handler.ts index 03f07daf3..538187c0f 100644 --- a/packages/ee/server-only/stripe/webhook/handler.ts +++ b/packages/ee/server-only/stripe/webhook/handler.ts @@ -2,7 +2,6 @@ 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 { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription'; type StripeWebhookResponse = { diff --git a/packages/lib/jobs/client.ts b/packages/lib/jobs/client.ts index 083c28424..2b1c249b2 100644 --- a/packages/lib/jobs/client.ts +++ b/packages/lib/jobs/client.ts @@ -26,6 +26,7 @@ import { SEAL_DOCUMENT_JOB_DEFINITION } from './definitions/internal/seal-docume import { SEAL_DOCUMENT_SWEEP_JOB_DEFINITION } from './definitions/internal/seal-document-sweep'; import { SEND_SIGNING_REMINDERS_SWEEP_JOB_DEFINITION } from './definitions/internal/send-signing-reminders-sweep'; import { SYNC_EMAIL_DOMAINS_JOB_DEFINITION } from './definitions/internal/sync-email-domains'; +import { SYNC_ORGANISATION_SEATS_JOB_DEFINITION } from './definitions/internal/sync-organisation-seats'; /** * The `as const` assertion is load bearing as it provides the correct level of type inference for @@ -59,6 +60,7 @@ export const jobsClient = new JobClient([ SYNC_EMAIL_DOMAINS_JOB_DEFINITION, ADMIN_DELETE_ORGANISATION_JOB_DEFINITION, CANCEL_ORGANISATION_SUBSCRIPTION_JOB_DEFINITION, + SYNC_ORGANISATION_SEATS_JOB_DEFINITION, ] as const); export const jobs = jobsClient; diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts new file mode 100644 index 000000000..9010e4d86 --- /dev/null +++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.handler.ts @@ -0,0 +1,54 @@ +import { syncMemberCountWithStripeSeatPlan } from '@documenso/ee/server-only/stripe/update-subscription-item-quantity'; +import { prisma } from '@documenso/prisma'; +import { SubscriptionStatus } from '@prisma/client'; +import { IS_BILLING_ENABLED } from '../../../constants/app'; +import type { JobRunIO } from '../../client/_internal/job'; +import type { TSyncOrganisationSeatsJobDefinition } from './sync-organisation-seats'; + +export const run = async ({ payload }: { payload: TSyncOrganisationSeatsJobDefinition; io: JobRunIO }) => { + const { organisationId } = payload; + + if (!IS_BILLING_ENABLED()) { + return; + } + + const organisation = await prisma.organisation.findUnique({ + where: { + id: organisationId, + }, + include: { + subscription: true, + organisationClaim: true, + }, + }); + + if (!organisation || !organisation.subscription) { + return; + } + + // Skip canceled/terminal subscriptions — Stripe rejects quantity updates on a + // canceled subscription. PAST_DUE is still live and a no-proration shrink is + // safe, so it's allowed through. + if (organisation.subscription.status === SubscriptionStatus.INACTIVE) { + return; + } + + const memberCount = await prisma.organisationMember.count({ + where: { + organisationId, + }, + }); + + // An organisation always retains its owner; guarding zero avoids writing the + // unlimited sentinel to the claim. + if (memberCount === 0) { + return; + } + + await syncMemberCountWithStripeSeatPlan( + organisation.subscription, + organisation.organisationClaim, + memberCount, + 'shrink', + ); +}; diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts new file mode 100644 index 000000000..432f559be --- /dev/null +++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +import type { JobDefinition } from '../../client/_internal/job'; + +const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID = 'sync.organisation-seats'; + +const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({ + organisationId: z.string(), +}); + +export type TSyncOrganisationSeatsJobDefinition = z.infer; + +export const SYNC_ORGANISATION_SEATS_JOB_DEFINITION = { + id: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + name: 'Sync Organisation Seats', + version: '1.0.0', + trigger: { + name: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + schema: SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA, + }, + handler: async ({ payload, io }) => { + const handler = await import('./sync-organisation-seats.handler'); + + await handler.run({ payload, io }); + }, +} as const satisfies JobDefinition< + typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_ID, + TSyncOrganisationSeatsJobDefinition +>; diff --git a/packages/trpc/server/admin-router/delete-organisation-member.ts b/packages/trpc/server/admin-router/delete-organisation-member.ts index 6c27b384f..eace66acf 100644 --- a/packages/trpc/server/admin-router/delete-organisation-member.ts +++ b/packages/trpc/server/admin-router/delete-organisation-member.ts @@ -89,6 +89,13 @@ export const deleteAdminOrganisationMemberRoute = adminProcedure }); }); + // A member was removed — queue a seat sync to true the Stripe quantity down + // to the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'sync.organisation-seats', + payload: { organisationId }, + }); + await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: { diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index 7d411955a..b11697eea 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -104,6 +104,13 @@ export const deleteOrganisationMembers = async ({ }); }); + // Members were removed — queue a seat sync to true the Stripe quantity down to + // the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'sync.organisation-seats', + payload: { organisationId }, + }); + for (const member of membersToDelete) { await jobs.triggerJob({ name: 'send.organisation-member-left.email', diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index 2eb9a6e97..6b8cad5ec 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -65,6 +65,13 @@ export const leaveOrganisationRoute = authenticatedProcedure }); }); + // A member was removed — queue a seat sync to true the Stripe quantity down + // to the new count (no proration, no credit). + await jobs.triggerJob({ + name: 'sync.organisation-seats', + payload: { organisationId }, + }); + await jobs.triggerJob({ name: 'send.organisation-member-left.email', payload: {