mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 04:12:06 +10:00
298 lines
9.0 KiB
TypeScript
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;
|
|
};
|