Files
documenso/packages/ee/server-only/stripe/sync-stripe-customer-subscription.ts
T
David Nguyen d92aa6ee93 fix: stuff
2026-06-19 14:39:08 +10:00

298 lines
9.0 KiB
TypeScript

import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { getSubscriptionClaim } from '@documenso/lib/server-only/subscription/get-subscription-claim';
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 { reconcileSeatBasedPlans } from './update-subscription-item-quantity';
const LIVE_SUBSCRIPTION_STATUSES: Stripe.Subscription.Status[] = ['active', 'trialing', 'past_due'];
export type SyncStripeCustomerSubscriptionOptions = {
customerId: string;
/**
* When true, the organisationClaim will not be synced.
*
* Used by the admin sync route to update only the Subscription
* row while leaving claim entitlements untouched.
*/
bypassClaimUpdate?: boolean;
};
/**
* Idempotent, convergent sync of a Stripe customer's subscription state into the local database.
*
* Fetches the current truth from Stripe and writes it locally, regardless of which
* webhook event (or manual trigger) initiated the sync. Safe to run at any time,
* any number of times.
*
* This function never creates organisations.
*/
export const syncStripeCustomerSubscription = async ({
customerId,
bypassClaimUpdate = false,
}: SyncStripeCustomerSubscriptionOptions) => {
// Note: `data.items.data.price.product` would exceed Stripe's 4-level expansion
// limit on list endpoints, so the product is fetched separately when needed.
const stripeSubscriptions = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const liveSubscriptions = stripeSubscriptions.data.filter((subscription) =>
LIVE_SUBSCRIPTION_STATUSES.includes(subscription.status),
);
if (liveSubscriptions.length > 1) {
console.error(`Customer ${customerId} has ${liveSubscriptions.length} live subscriptions, expected at most 1`);
throw new Error(`Customer ${customerId} has multiple live subscriptions`);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
console.error(`Organisation not found for customer ${customerId}, nothing to sync`);
return;
}
const liveSubscription = liveSubscriptions[0];
if (!liveSubscription) {
await handleNoLiveSubscription({ organisation });
return;
}
await handleLiveSubscription({
organisation,
subscription: liveSubscription,
customerId,
bypassClaimUpdate,
});
};
type OrganisationWithClaimAndSubscription = Prisma.OrganisationGetPayload<{
include: { organisationClaim: true; subscription: true };
}>;
type HandleNoLiveSubscriptionOptions = {
organisation: OrganisationWithClaimAndSubscription;
};
const handleNoLiveSubscription = async ({ organisation }: HandleNoLiveSubscriptionOptions) => {
// Individuals get their subscription deleted so they can return to the free plan.
if (organisation.organisationClaim.originalSubscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await prisma.$transaction(async (tx) => {
if (organisation.subscription) {
await tx.subscription.delete({
where: {
id: organisation.subscription.id,
},
});
}
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
},
});
});
return;
}
// For all other cases, mark the subscription as inactive if a row exists.
if (organisation.subscription) {
await prisma.subscription.update({
where: {
id: organisation.subscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
}
};
type HandleLiveSubscriptionOptions = {
organisation: OrganisationWithClaimAndSubscription;
subscription: Stripe.Subscription;
customerId: string;
bypassClaimUpdate: boolean;
};
const handleLiveSubscription = async ({
organisation,
subscription,
customerId,
bypassClaimUpdate,
}: HandleLiveSubscriptionOptions) => {
if (subscription.items.data.length !== 1) {
console.error(`No support for multiple subscription items on subscription ${subscription.id}`);
throw new Error(`No support for multiple subscription items on subscription ${subscription.id}`);
}
const subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
if (!claim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw new Error(`Subscription claim on ${subscriptionItem.price.id} not found`);
}
const status = match(subscription.status)
.with('active', () => SubscriptionStatus.ACTIVE)
.with('trialing', () => SubscriptionStatus.ACTIVE)
.with('past_due', () => SubscriptionStatus.PAST_DUE)
.otherwise(() => SubscriptionStatus.INACTIVE);
const periodEnd =
subscription.status === 'trialing' && subscription.trial_end
? new Date(subscription.trial_end * 1000)
: new Date(subscription.current_period_end * 1000);
const shouldUpdateClaim =
!bypassClaimUpdate && organisation.organisationClaim.originalSubscriptionClaimId !== claim.id;
// Migrate the organisation type if it is no longer an individual/free plan.
// Never demote an ORGANISATION back to PERSONAL.
const shouldMigrateOrganisationType =
organisation.type === OrganisationType.PERSONAL &&
claim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
claim.id !== INTERNAL_CLAIM_ID.FREE;
await prisma.$transaction(async (tx) => {
await tx.subscription.upsert({
where: {
organisationId: organisation.id,
},
create: {
organisationId: organisation.id,
status,
customerId,
planId: subscription.id,
priceId: subscriptionItem.price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status,
customerId,
planId: subscription.id,
priceId: subscriptionItem.price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
if (shouldUpdateClaim) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
});
}
if (shouldMigrateOrganisationType) {
await tx.organisation.update({
where: {
id: organisation.id,
},
data: {
type: OrganisationType.ORGANISATION,
},
});
}
});
// 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 reconcileSeatBasedPlans(organisation.id);
}
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
* The order of priority is:
* 1. Price metadata
* 2. Product metadata
*
* @returns The claimId or null if no claimId is found.
*/
export const extractStripeClaimId = async (priceId: Stripe.Price) => {
if (priceId.metadata.claimId) {
return priceId.metadata.claimId;
}
// Use the expanded product when available to avoid an extra API call.
if (typeof priceId.product !== 'string' && 'metadata' in priceId.product) {
return priceId.product.metadata.claimId || null;
}
const productId = typeof priceId.product === 'string' ? priceId.product : priceId.product.id;
const product = await stripe.products.retrieve(productId);
return product.metadata.claimId || null;
};
/**
* Checks the price metadata for a claimId, if it is missing it will fetch
* and check the product metadata for a claimId.
*
*/
export const extractStripeClaim = async (priceId: Stripe.Price) => {
const claimId = await extractStripeClaimId(priceId);
if (!claimId) {
return null;
}
const subscriptionClaim = await prisma.subscriptionClaim.findFirst({
where: { id: claimId },
});
if (!subscriptionClaim) {
console.error(`Subscription claim ${claimId} not found`);
return null;
}
return subscriptionClaim;
};