fix: make better

This commit is contained in:
David Nguyen
2026-06-18 14:28:41 +10:00
parent d1d94f5e46
commit 70cdef5a6d
9 changed files with 168 additions and 71 deletions
@@ -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);
}
};
@@ -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',
);
};
@@ -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 = {
+2
View File
@@ -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;
@@ -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',
);
};
@@ -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<typeof SYNC_ORGANISATION_SEATS_JOB_DEFINITION_SCHEMA>;
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
>;
@@ -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: {
@@ -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',
@@ -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: {