mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
fix: make better
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user