fix(billing): serialize reconciliation and record accepted cancellations

This commit is contained in:
2026-09-08 16:32:11 +03:00
parent 57061b5a5d
commit 1c24336b6a
9 changed files with 676 additions and 42 deletions
+20 -11
View File
@@ -1,11 +1,7 @@
// Turning Stripe state into funnel events.
//
// These four events are derived from a before/after comparison inside the sync
// that already re-reads every subscription a customer has, rather than from the
// webhook event types. That is deliberate: webhooks arrive out of order and get
// replayed, and `customer.subscription.updated` fires for changes that mean
// nothing here. Comparing the row we are about to overwrite with the row we are
// writing is order-independent, and the dedupe keys make a replay a no-op.
// Turning Stripe state and accepted cancellations into funnel events.
// Sync compares before/after state; in-app cancellation also records acceptance
// because its local claim can hide that transition. Shared cycle keys make both
// paths and replayed webhooks count the same cancellation once.
import type { BillingSubscriptionStatus } from '@prisma/client';
import { eventKey, recordEvent } from '@/lib/analytics/record';
@@ -37,6 +33,19 @@ function cycleMarker(currentPeriodEnd: Date | null): string {
return String(currentPeriodEnd ? currentPeriodEnd.getTime() : 0);
}
/** Shared by accepted in-app cancellations and sync; recordEvent logs write failures. */
export async function recordSubscriptionCancellation(params: {
userId: string;
subscriptionId: string;
currentPeriodEnd: Date | null;
}): Promise<void> {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${params.subscriptionId}:${cycleMarker(params.currentPeriodEnd)}`,
userId: params.userId,
});
}
export async function recordSubscriptionTransition(params: {
userId: string;
subscriptionId: string;
@@ -71,10 +80,10 @@ export async function recordSubscriptionTransition(params: {
const startedCanceling = after.cancelAtPeriodEnd && !before.cancelAtPeriodEnd;
const becameCanceled = after.status === 'CANCELED' && before.status !== 'CANCELED';
if (startedCanceling || becameCanceled) {
await recordEvent({
name: 'SUBSCRIPTION_CANCELED',
dedupeKey: `SUBSCRIPTION_CANCELED:${subscriptionId}:${cycle}`,
await recordSubscriptionCancellation({
userId,
subscriptionId,
currentPeriodEnd: after.currentPeriodEnd,
});
}
+61 -23
View File
@@ -911,10 +911,17 @@ function hasEntitledPrice(subscription: Stripe.Subscription, configuredPriceId:
}
export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscription) {
return recordSyncedSubscription(await writeStripeSubscriptionToUser(subscription, db));
}
async function writeStripeSubscriptionToUser(
subscription: Stripe.Subscription,
client: Prisma.TransactionClient
) {
const customerId =
typeof subscription.customer === 'string' ? subscription.customer : subscription.customer.id;
const user = await db.user.findUnique({
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -964,7 +971,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
// which is cleared again as soon as the subscription goes back to active.
const hasAccess = hasEntitledPrice && hasActiveSubscription(mappedStatus);
const updated = await db.user.update({
const updated = await client.user.update({
where: { id: user.id },
data: {
stripeSubscriptionId: subscription.id,
@@ -986,7 +993,7 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
},
});
await recordSubscriptionTransition({
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: subscription.id,
before: {
@@ -1000,9 +1007,19 @@ export async function syncStripeSubscriptionToUser(subscription: Stripe.Subscrip
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: effectiveCurrentPeriodEnd,
},
});
};
return updated;
return { updated, transition };
}
async function recordSyncedSubscription(
result: Awaited<ReturnType<typeof writeStripeSubscriptionToUser>>
) {
if (!result) return null;
// Analytics uses its own connection. Run it after commit, not while a billing
// transaction holds a connection and other syncs are queued on its advisory lock.
await recordSubscriptionTransition(result.transition);
return result.updated;
}
// A single Stripe customer can own several subscriptions at once (e.g. after
@@ -1055,28 +1072,49 @@ export function selectAuthoritativeSubscription(
// Source-of-truth sync: instead of trusting a single subscription from a webhook
// event body (which may be an OLD subscription being deleted while a NEWER one is
// active), re-list ALL of the customer's subscriptions from Stripe and sync the
// authoritative one. This is order-independent and self-healing.
// authoritative one. The customer lock covers the Stripe read as well as the mirror
// write: locking only after the read would still let a delayed older response win.
export async function syncStripeCustomerSubscriptions(customerId: string) {
const stripe = getStripe();
const { data: subscriptions } = await stripe.subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const result = await db.$transaction(
async (tx) => {
// Two-key advisory locks occupy a separate namespace from the one-key
// cancellation locks. Cancellation releases its lock before calling sync.
await tx.$executeRaw`
SELECT pg_advisory_xact_lock(hashtext('stripe-subscription-sync'), hashtext(${customerId}))
`;
const { data: subscriptions } = await getStripe().subscriptions.list({
customer: customerId,
status: 'all',
limit: 100,
});
const authoritative = selectAuthoritativeSubscription(subscriptions);
if (!authoritative) {
return markSubscriptionCanceledByCustomerId(customerId);
}
return syncStripeSubscriptionToUser(authoritative);
const authoritative = selectAuthoritativeSubscription(subscriptions);
return authoritative
? writeStripeSubscriptionToUser(authoritative, tx)
: writeSubscriptionCanceledByCustomerId(customerId, undefined, tx);
},
// Bound lock and connection occupancy. A slow Stripe call or lock wait fails
// this sync; writes through the expired transaction cannot overwrite a newer sync.
{ maxWait: 10_000, timeout: 30_000 }
);
return recordSyncedSubscription(result);
}
export async function markSubscriptionCanceledByCustomerId(
customerId: string,
options?: { currentPeriodEnd?: Date | null; endedAt?: Date | null }
) {
const user = await db.user.findUnique({
return recordSyncedSubscription(
await writeSubscriptionCanceledByCustomerId(customerId, options, db)
);
}
async function writeSubscriptionCanceledByCustomerId(
customerId: string,
options: { currentPeriodEnd?: Date | null; endedAt?: Date | null } | undefined,
client: Prisma.TransactionClient
) {
const user = await client.user.findUnique({
where: { stripeCustomerId: customerId },
select: {
id: true,
@@ -1098,7 +1136,7 @@ export async function markSubscriptionCanceledByCustomerId(
// date, which is also what the cancellation copy in settings promises.
const preservedTrialEnd = user.trialEndsAt ?? null;
const updated = await db.user.update({
const updated = await client.user.update({
where: { id: user.id },
data: {
subscriptionStatus: BillingSubscriptionStatus.CANCELED,
@@ -1116,7 +1154,7 @@ export async function markSubscriptionCanceledByCustomerId(
// uses the period end being cleared here, which is the same one the earlier
// "cancel at period end" write carried, so a customer who cancelled through the
// portal and then reached the end of their term produces one cancellation, not two.
await recordSubscriptionTransition({
const transition: Parameters<typeof recordSubscriptionTransition>[0] = {
userId: user.id,
subscriptionId: user.stripeSubscriptionId ?? user.id,
before: {
@@ -1130,9 +1168,9 @@ export async function markSubscriptionCanceledByCustomerId(
trialEndsAt: preservedTrialEnd,
currentPeriodEnd: options?.currentPeriodEnd ?? user.stripeCurrentPeriodEnd ?? null,
},
});
};
return updated;
return { updated, transition };
}
/**
+9
View File
@@ -10,6 +10,7 @@ import {
voidOpenSubscriptionInvoices,
} from '@/lib/billing';
import { logError } from '@/lib/logger';
import { recordSubscriptionCancellation } from '@/lib/analytics/billing-events';
export {
CANCELLATION_NOTE_MAX_LENGTH,
@@ -175,6 +176,14 @@ export async function cancelSubscription(params: {
const periodEndUnix = getSubscriptionPeriodEnd(original);
const periodEnd = periodEndUnix ? new Date(periodEndUnix * 1000) : user.stripeCurrentPeriodEnd;
// The paid claim already set the local flag, and another subscription may
// drive customer sync. Record acceptance directly with the same cycle key.
await recordSubscriptionCancellation({
userId: params.userId,
subscriptionId,
currentPeriodEnd: periodEnd,
});
await db.$transaction(async (tx) => {
// The paid mirror's claim does not cover other subscriptions. Serialize every
// reason write and reuse only a row written during this request, so a resumed