mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 17:35:05 +10:00
fix: update stripe team member billing
This commit is contained in:
@@ -6,6 +6,8 @@ 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';
|
||||
|
||||
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
|
||||
|
||||
export type SyncStripeCustomerSubscriptionOptions = {
|
||||
@@ -229,6 +231,19 @@ const handleLiveSubscription = async ({
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Detect a billing-period roll by comparing the persisted period end with
|
||||
// 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.
|
||||
const previousPeriodEnd = organisation.subscription?.periodEnd ?? null;
|
||||
|
||||
const hasPeriodAdvanced = previousPeriodEnd !== null && periodEnd.getTime() > previousPeriodEnd.getTime();
|
||||
|
||||
if (hasPeriodAdvanced && !bypassClaimUpdate) {
|
||||
await reconcileSeatsWithMemberCount(organisation.id);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
@@ -11,12 +12,14 @@ export type UpdateSubscriptionItemQuantityOptions = {
|
||||
subscriptionId: string;
|
||||
quantity: number;
|
||||
priceId: string;
|
||||
prorationBehaviour: 'always_invoice' | 'none';
|
||||
};
|
||||
|
||||
export const updateSubscriptionItemQuantity = async ({
|
||||
subscriptionId,
|
||||
quantity,
|
||||
priceId,
|
||||
prorationBehaviour,
|
||||
}: UpdateSubscriptionItemQuantityOptions) => {
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
@@ -26,7 +29,6 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
throw new Error('Subscription does not contain required item');
|
||||
}
|
||||
|
||||
const hasYearlyItem = items.find((item) => item.price.recurring?.interval === 'year');
|
||||
const oldQuantity = items[0].quantity;
|
||||
|
||||
if (oldQuantity === quantity) {
|
||||
@@ -38,13 +40,12 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
id: item.id,
|
||||
quantity,
|
||||
})),
|
||||
proration_behavior: prorationBehaviour,
|
||||
// Need to "off_session" updates since adding 3DS will have payments
|
||||
// not pass through for these immediate invoices.
|
||||
off_session: true,
|
||||
};
|
||||
|
||||
// Only invoice immediately when changing the quantity of yearly item.
|
||||
if (hasYearlyItem) {
|
||||
subscriptionUpdatePayload.proration_behavior = 'always_invoice';
|
||||
}
|
||||
|
||||
await stripe.subscriptions.update(subscriptionId, subscriptionUpdatePayload);
|
||||
};
|
||||
|
||||
@@ -55,15 +56,19 @@ export const updateSubscriptionItemQuantity = async ({
|
||||
* via Stripe rather than enforcing a hard cap. A `memberCount` of `0` on the
|
||||
* organisation claim represents unlimited seats.
|
||||
*
|
||||
* Organisations without a subscription (e.g. after being downgraded to the
|
||||
* free plan) can pass `null`, in which case the claim cap is enforced
|
||||
* directly without the seats-based exemption.
|
||||
*
|
||||
* Should only be called from grow paths (invite/add). Reducing operations
|
||||
* must never be gated by this check.
|
||||
*
|
||||
* @param subscription - The organisation's Stripe subscription.
|
||||
* @param subscription - The organisation's Stripe subscription, if any.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The proposed total member + pending invite count.
|
||||
* @param quantity - The proposed total member count.
|
||||
*/
|
||||
export const assertMemberCountWithinCap = async (
|
||||
subscription: Subscription,
|
||||
subscription: Subscription | null,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
) => {
|
||||
@@ -75,10 +80,12 @@ export const assertMemberCountWithinCap = async (
|
||||
}
|
||||
|
||||
// Seats-based plans don't have a hard cap; Stripe meters the usage.
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
if (subscription) {
|
||||
const isSeatsBased = await isPriceSeatsBased(subscription.priceId);
|
||||
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
if (isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (quantity > maximumMemberCount) {
|
||||
@@ -91,40 +98,60 @@ export const assertMemberCountWithinCap = async (
|
||||
/**
|
||||
* Syncs the organisation's member count with the Stripe subscription quantity.
|
||||
*
|
||||
* No-ops for plans that are not seats-based, and for organisations with
|
||||
* unlimited seats (`organisationClaim.memberCount === 0`). Safe to call from
|
||||
* both grow and shrink paths.
|
||||
* For seat-based plans, `organisationClaim.memberCount` is the paid seat
|
||||
* high-water mark for the current billing period and always mirrors the
|
||||
* Stripe quantity.
|
||||
*
|
||||
* - 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`).
|
||||
*
|
||||
* @param subscription - The subscription to sync the member count with.
|
||||
* @param organisationClaim - The organisation claim.
|
||||
* @param quantity - The new total member + pending invite count to sync.
|
||||
* @param quantity - The new total member count to sync.
|
||||
* @param mode - Whether this is a grow operation or a renewal reconcile.
|
||||
*/
|
||||
export const syncMemberCountWithStripeSeatPlan = async (
|
||||
subscription: Subscription,
|
||||
organisationClaim: OrganisationClaim,
|
||||
quantity: number,
|
||||
mode: 'grow' | 'reconcile',
|
||||
) => {
|
||||
// Infinite seats means no sync needed.
|
||||
// Early return if the organisation has unlimited seats.
|
||||
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.
|
||||
if (!isSeatsBased) {
|
||||
return;
|
||||
}
|
||||
|
||||
appLog('BILLING', 'Updating seat based plan');
|
||||
appLog('BILLING', `Updating seat based plan (${mode})`);
|
||||
|
||||
await updateSubscriptionItemQuantity({
|
||||
priceId: subscription.priceId,
|
||||
subscriptionId: subscription.planId,
|
||||
quantity,
|
||||
prorationBehaviour: mode === 'grow' ? 'always_invoice' : 'none',
|
||||
});
|
||||
|
||||
// This should be automatically updated after the Stripe webhook is fired
|
||||
// but we just manually adjust it here as well to avoid any race conditions.
|
||||
// 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,
|
||||
@@ -134,3 +161,67 @@ export const syncMemberCountWithStripeSeatPlan = async (
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconciles the Stripe seat quantity and organisation claim with the actual
|
||||
* member count at the start of a new billing period.
|
||||
*
|
||||
* Called from the `customer.subscription.updated` webhook when the billing
|
||||
* period advances. The renewal invoice has already been generated at the
|
||||
* previous (high-water) quantity by then — the reconciled count takes effect
|
||||
* on the next invoice (accepted trade-off: removed seats bill for exactly
|
||||
* one extra period).
|
||||
*
|
||||
* Runs with no prorations in either direction: no credits when shrinking,
|
||||
* no retroactive charges when healing upward drift (e.g. unbilled SSO
|
||||
* portal joins or lost grow races).
|
||||
*/
|
||||
export const reconcileSeatsWithMemberCount = async (organisationId: string) => {
|
||||
const organisation = await prisma.organisation.findUnique({
|
||||
where: {
|
||||
id: organisationId,
|
||||
},
|
||||
include: {
|
||||
subscription: true,
|
||||
organisationClaim: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!organisation || !organisation.subscription) {
|
||||
appLog('BILLING', 'Reconcile skipped: organisation or subscription not 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');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const memberCount = await prisma.organisationMember.count({
|
||||
where: {
|
||||
organisationId,
|
||||
},
|
||||
});
|
||||
|
||||
// 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');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await syncMemberCountWithStripeSeatPlan(
|
||||
organisation.subscription,
|
||||
organisation.organisationClaim,
|
||||
memberCount,
|
||||
'reconcile',
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user