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 4bf327282..2a9502e4e 100644 --- a/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts +++ b/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts @@ -235,7 +235,7 @@ const handleLiveSubscription = async ({ // the freshly-fetched one — the convergent equivalent of the old // `previous_attributes.current_period_start` signal. On renewal, reconcile // the seat quantity and claim down to the actual member count. The reconcile - // itself no-ops for non-seat/unlimited plans and non-ACTIVE subscriptions. + // itself no-ops for non-seat/unlimited plans and inactive subscriptions. const previousPeriodEnd = organisation.subscription?.periodEnd ?? null; const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime(); 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 0880a3a5a..1183e0d35 100644 --- a/packages/ee/server-only/stripe/update-subscription-item-quantity.ts +++ b/packages/ee/server-only/stripe/update-subscription-item-quantity.ts @@ -3,6 +3,7 @@ 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'; @@ -138,9 +139,10 @@ export const syncMemberCountWithStripeSeatPlan = async ( prorationBehaviour: billsForNewSeats ? 'always_invoice' : 'none', }); - // 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. + // Advance the high-water mark when billing for new seats; it is reset to the + // actual member count when the billing period rolls over. Re-adds and shrinks + // deliberately leave it untouched so a seat already paid for this period is + // never re-charged. if (billsForNewSeats) { await prisma.organisationClaim.update({ where: { @@ -180,6 +182,12 @@ export const reconcileSeatBasedPlans = async (organisationId: string) => { const { subscription, organisationClaim } = organisation; + // Stripe rejects quantity updates on canceled subscriptions. PAST_DUE is + // still live and a no-proration sync is safe, so it's allowed through. + if (subscription.status === SubscriptionStatus.INACTIVE) { + return; + } + // Unlimited seats — nothing to meter. if (organisationClaim.memberCount === 0) { return; diff --git a/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts index 432f559be..7ff4b6629 100644 --- a/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts +++ b/packages/lib/jobs/definitions/internal/sync-organisation-seats.ts @@ -2,7 +2,7 @@ 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_ID = 'internal.sync-organisation-seats'; const SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA = z.object({ organisationId: z.string(), diff --git a/packages/trpc/server/admin-router/delete-organisation-member.ts b/packages/trpc/server/admin-router/delete-organisation-member.ts index eace66acf..df66ac024 100644 --- a/packages/trpc/server/admin-router/delete-organisation-member.ts +++ b/packages/trpc/server/admin-router/delete-organisation-member.ts @@ -92,7 +92,7 @@ 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', + name: 'internal.sync-organisation-seats', payload: { organisationId }, }); diff --git a/packages/trpc/server/organisation-router/delete-organisation-members.ts b/packages/trpc/server/organisation-router/delete-organisation-members.ts index 2b5d317a2..6a436ddff 100644 --- a/packages/trpc/server/organisation-router/delete-organisation-members.ts +++ b/packages/trpc/server/organisation-router/delete-organisation-members.ts @@ -165,7 +165,7 @@ 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', + name: 'internal.sync-organisation-seats', payload: { organisationId }, }); diff --git a/packages/trpc/server/organisation-router/leave-organisation.ts b/packages/trpc/server/organisation-router/leave-organisation.ts index 0a20a1492..466870f41 100644 --- a/packages/trpc/server/organisation-router/leave-organisation.ts +++ b/packages/trpc/server/organisation-router/leave-organisation.ts @@ -76,7 +76,7 @@ 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', + name: 'internal.sync-organisation-seats', payload: { organisationId }, });