fix: rework stripe webhooks into idempotent subscription sync (#2977)

Replace per-event webhook handlers with a single sync function that
fetches the current state from Stripe and converges the local
subscription, claim, and organisation type.

- Create organisations upfront before checkout, restricted as
  "pending payment" until the first payment syncs
- Add rate-limited subscription sync route, triggered on checkout
  success so the UI doesn't wait on webhooks
- Surface pending payment state in banner, billing table, and limits
This commit is contained in:
Lucas Smith
2026-06-12 16:01:03 +10:00
committed by GitHub
parent b84b87cea6
commit 3887aa67c8
19 changed files with 667 additions and 664 deletions
+10
View File
@@ -1,5 +1,6 @@
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { isOrganisationPendingPayment } from '@documenso/lib/utils/billing';
import { prisma } from '@documenso/prisma';
import { DocumentSource, EnvelopeType, SubscriptionStatus } from '@prisma/client';
import { DateTime } from 'luxon';
@@ -69,6 +70,15 @@ export const getServerLimits = async ({ userId, teamId }: GetServerLimitsOptions
};
}
// Early return for organisations created ahead of a paid checkout that are still awaiting payment.
if (isOrganisationPendingPayment(organisation)) {
return {
quota: INACTIVE_PLAN_LIMITS,
remaining: INACTIVE_PLAN_LIMITS,
maximumEnvelopeItemCount,
};
}
// Allow unlimited documents for users with an unlimited documents claim.
// This also allows "free" claim users without subscriptions if they have this flag.
if (organisation.organisationClaim.flags.unlimitedDocuments) {
@@ -1,20 +1,13 @@
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { stripe } from '@documenso/lib/server-only/stripe';
import type Stripe from 'stripe';
export type CreateCheckoutSessionOptions = {
customerId: string;
priceId: string;
returnUrl: string;
subscriptionMetadata?: Stripe.Metadata;
};
export const createCheckoutSession = async ({
customerId,
priceId,
returnUrl,
subscriptionMetadata,
}: CreateCheckoutSessionOptions) => {
export const createCheckoutSession = async ({ customerId, priceId, returnUrl }: CreateCheckoutSessionOptions) => {
const session = await stripe.checkout.sessions.create({
customer: customerId,
mode: 'subscription',
@@ -27,9 +20,6 @@ export const createCheckoutSession = async ({
success_url: `${returnUrl}?success=true`,
cancel_url: `${returnUrl}?canceled=true`,
billing_address_collection: 'required',
subscription_data: {
metadata: subscriptionMetadata,
},
});
if (!session.url) {
@@ -0,0 +1,283 @@
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';
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,
},
});
}
});
};
/**
* 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;
};
@@ -2,17 +2,29 @@ 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 { match } from 'ts-pattern';
import { onSubscriptionCreated } from './on-subscription-created';
import { onSubscriptionDeleted } from './on-subscription-deleted';
import { onSubscriptionUpdated } from './on-subscription-updated';
import { syncStripeCustomerSubscription } from '../sync-stripe-customer-subscription';
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Events that trigger a sync of the customer's subscription state.
*
* The event payload is never trusted beyond extracting the customer ID,
* the sync function fetches the current truth from Stripe.
*/
const SYNCED_EVENT_TYPES: string[] = [
'customer.subscription.created',
'customer.subscription.updated',
'customer.subscription.deleted',
'checkout.session.completed',
'invoice.payment_succeeded',
'invoice.payment_failed',
];
export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
try {
const isBillingEnabled = IS_BILLING_ENABLED();
@@ -60,69 +72,45 @@ export const stripeWebhookHandler = async (req: Request): Promise<Response> => {
const event = stripe.webhooks.constructEvent(payload, signature, webhookSecret);
/**
* Notes:
* - Dropped invoice.payment_succeeded
* - Dropped invoice.payment_failed
* - Dropped checkout-session.completed
*/
return await match(event.type)
.with('customer.subscription.created', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
if (!SYNCED_EVENT_TYPES.includes(event.type)) {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
await onSubscriptionCreated({ subscription });
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const eventObject = event.data.object as { customer?: string | Stripe.Customer | null };
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
status: 200,
});
})
.with('customer.subscription.updated', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
const customerId = typeof eventObject.customer === 'string' ? eventObject.customer : eventObject.customer?.id;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const previousAttributes = event.data.previous_attributes as Partial<Stripe.Subscription> | null;
if (!customerId) {
console.error(`No customer found on ${event.type} event ${event.id}, nothing to sync`);
await onSubscriptionUpdated({ subscription, previousAttributes });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
}
return Response.json({ success: true, message: 'Webhook received' } satisfies StripeWebhookResponse, {
status: 200,
});
})
.with('customer.subscription.deleted', async () => {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const subscription = event.data.object as Stripe.Subscription;
await syncStripeCustomerSubscription({ customerId });
await onSubscriptionDeleted({ subscription });
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
})
.otherwise(() => {
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
});
return Response.json(
{
success: true,
message: 'Webhook received',
} satisfies StripeWebhookResponse,
{ status: 200 },
);
} catch (err) {
console.error(err);
if (err instanceof Response) {
const message = await err.json();
console.error(message);
return err;
}
return Response.json(
{
success: false,
@@ -1,214 +0,0 @@
import {
createOrganisation,
createOrganisationClaimUpsertData,
} from '@documenso/lib/server-only/organisation/create-organisation';
import type { Stripe } from '@documenso/lib/server-only/stripe';
import type { StripeOrganisationCreateMetadata } from '@documenso/lib/types/subscription';
import { INTERNAL_CLAIM_ID, ZStripeOrganisationCreateMetadataSchema } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, type SubscriptionClaim, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
import { extractStripeClaim } from './on-subscription-updated';
export type OnSubscriptionCreatedOptions = {
subscription: Stripe.Subscription;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
/**
* Todo: We might want to pull this into a job so we can do steps. Since if organisation creation passes but
* fails after this would be automatically rerun by Stripe, which means duplicate organisations can be
* potentially created.
*/
export const onSubscriptionCreated = async ({ subscription }: OnSubscriptionCreatedOptions) => {
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const subscriptionItem = subscription.items.data[0];
const claim = await extractStripeClaim(subscriptionItem.price);
// Todo: logging
if (!claim) {
console.error(`Subscription claim on ${subscriptionItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${subscriptionItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisationCreateData = subscription.metadata?.organisationCreateData;
// A new subscription can be for an existing organisation or a new one.
const organisationId = organisationCreateData
? await handleOrganisationCreate({
customerId,
claim,
unknownCreateData: organisationCreateData,
})
: await handleOrganisationUpdate({
customerId,
claim,
});
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);
await prisma.subscription.upsert({
where: {
organisationId,
},
create: {
organisationId,
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
update: {
status,
customerId,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
};
type HandleOrganisationCreateOptions = {
customerId: string;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
unknownCreateData: string;
};
/**
* Handles the creation of an organisation.
*/
const handleOrganisationCreate = async ({ customerId, claim, unknownCreateData }: HandleOrganisationCreateOptions) => {
let organisationCreateFlowData: StripeOrganisationCreateMetadata | null = null;
const parseResult = ZStripeOrganisationCreateMetadataSchema.safeParse(JSON.parse(unknownCreateData));
if (!parseResult.success) {
console.error('Invalid organisation create flow data');
throw Response.json(
{
success: false,
message: 'Invalid organisation create flow data',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
organisationCreateFlowData = parseResult.data;
const createdOrganisation = await createOrganisation({
name: organisationCreateFlowData.organisationName,
userId: organisationCreateFlowData.userId,
type: OrganisationType.ORGANISATION,
customerId,
claim,
});
return createdOrganisation.id;
};
type HandleOrganisationUpdateOptions = {
customerId: string;
claim: Omit<SubscriptionClaim, 'createdAt' | 'updatedAt'>;
};
/**
* Handles the updating an exist organisation claims.
*/
const handleOrganisationUpdate = async ({ customerId, claim }: HandleOrganisationUpdateOptions) => {
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
subscription: true,
organisationClaim: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
// Todo: logging
if (organisation.subscription && organisation.subscription.status !== SubscriptionStatus.INACTIVE) {
console.error('Organisation already has an active subscription');
// This should never happen
throw Response.json(
{
success: false,
message: `Organisation already has an active subscription`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
let newOrganisationType: OrganisationType = OrganisationType.ORGANISATION;
// Keep the organisation as personal if the claim is for an individual.
if (organisation.type === OrganisationType.PERSONAL && claim.id === INTERNAL_CLAIM_ID.INDIVIDUAL) {
newOrganisationType = OrganisationType.PERSONAL;
}
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: newOrganisationType,
organisationClaim: {
update: {
originalSubscriptionClaimId: claim.id,
...createOrganisationClaimUpsertData(claim),
},
},
},
});
return organisation.id;
};
@@ -1,90 +0,0 @@
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import type { 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 { SubscriptionStatus } from '@prisma/client';
import { extractStripeClaimId } from './on-subscription-updated';
export type OnSubscriptionDeletedOptions = {
subscription: Stripe.Subscription;
};
export const onSubscriptionDeleted = async ({ subscription }: OnSubscriptionDeletedOptions) => {
const existingSubscription = await prisma.subscription.findUnique({
where: {
planId: subscription.id,
},
include: {
organisation: {
include: {
organisationClaim: true,
},
},
},
});
// If the subscription doesn't exist, we don't need to do anything.
if (!existingSubscription) {
return;
}
const subscriptionClaimId = await extractClaimIdFromStripeSubscription(subscription);
// Individuals get their subscription deleted so they can return to the
// free plan.
if (subscriptionClaimId === INTERNAL_CLAIM_ID.INDIVIDUAL) {
const freeSubscriptionClaim = await getSubscriptionClaim(INTERNAL_CLAIM_ID.FREE);
await prisma.$transaction(async (tx) => {
await tx.subscription.delete({
where: {
id: existingSubscription.id,
},
});
await tx.organisationClaim.update({
where: {
id: existingSubscription.organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: INTERNAL_CLAIM_ID.FREE,
...createOrganisationClaimUpsertData(freeSubscriptionClaim),
},
});
});
return;
}
// For all other cases, mark the subscription as inactive since
// they should still have a "Personal" account.
await prisma.subscription.update({
where: {
id: existingSubscription.id,
},
data: {
status: SubscriptionStatus.INACTIVE,
},
});
};
/**
* Extracts the claim ID from the Stripe subscription.
*
* Returns `null` if no claim ID found.
*/
const extractClaimIdFromStripeSubscription = async (subscription: Stripe.Subscription) => {
const deletedItem = subscription.items.data[0];
if (!deletedItem) {
return null;
}
try {
return await extractStripeClaimId(deletedItem.price);
} catch (error) {
console.error(error);
return null;
}
};
@@ -1,194 +0,0 @@
import { createOrganisationClaimUpsertData } from '@documenso/lib/server-only/organisation/create-organisation';
import { type Stripe, stripe } from '@documenso/lib/server-only/stripe';
import { INTERNAL_CLAIM_ID } from '@documenso/lib/types/subscription';
import { prisma } from '@documenso/prisma';
import { OrganisationType, SubscriptionStatus } from '@prisma/client';
import { match } from 'ts-pattern';
export type OnSubscriptionUpdatedOptions = {
subscription: Stripe.Subscription;
previousAttributes: Partial<Stripe.Subscription> | null;
/**
* 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;
};
type StripeWebhookResponse = {
success: boolean;
message: string;
};
export const onSubscriptionUpdated = async ({
subscription,
previousAttributes,
bypassClaimUpdate = false,
}: OnSubscriptionUpdatedOptions) => {
const customerId = typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
// Todo: logging
if (subscription.items.data.length !== 1) {
console.error('No support for multiple items');
throw Response.json(
{
success: false,
message: 'No support for multiple items',
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const organisation = await prisma.organisation.findFirst({
where: {
customerId,
},
include: {
organisationClaim: true,
subscription: true,
},
});
if (!organisation) {
throw Response.json(
{
success: false,
message: `Organisation not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
if (
organisation.subscription &&
organisation.subscription.status !== SubscriptionStatus.INACTIVE &&
organisation.subscription.planId !== subscription.id
) {
console.error('[WARNING]: Organisation might have two subscriptions');
}
const previousItem = previousAttributes?.items?.data[0];
const updatedItem = subscription.items.data[0];
const previousSubscriptionClaimId = previousItem ? await extractStripeClaimId(previousItem.price) : null;
const updatedSubscriptionClaim = await extractStripeClaim(updatedItem.price);
if (!updatedSubscriptionClaim) {
console.error(`Subscription claim on ${updatedItem.price.id} not found`);
throw Response.json(
{
success: false,
message: `Subscription claim on ${updatedItem.price.id} not found`,
} satisfies StripeWebhookResponse,
{ status: 500 },
);
}
const newClaimFound = previousSubscriptionClaimId !== updatedSubscriptionClaim.id;
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);
// Migrate the organisation type if it is no longer an individual plan.
if (
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.INDIVIDUAL &&
updatedSubscriptionClaim.id !== INTERNAL_CLAIM_ID.FREE &&
organisation.type === OrganisationType.PERSONAL
) {
await prisma.organisation.update({
where: {
id: organisation.id,
},
data: {
type: OrganisationType.ORGANISATION,
},
});
}
await prisma.$transaction(async (tx) => {
await tx.subscription.update({
where: {
organisationId: organisation.id,
},
data: {
status: status,
planId: subscription.id,
priceId: subscription.items.data[0].price.id,
periodEnd,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
},
});
// Override current organisation claim if new one is found.
// Skipped when bypassClaimUpdate is set.
if (!bypassClaimUpdate && newClaimFound) {
await tx.organisationClaim.update({
where: {
id: organisation.organisationClaim.id,
},
data: {
originalSubscriptionClaimId: updatedSubscriptionClaim.id,
...createOrganisationClaimUpsertData(updatedSubscriptionClaim),
},
});
}
});
};
/**
* 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;
}
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;
};