Our quiz app has two paid plans. Changing between them used to be three steps in this order:
- Cancel the customer's current subscription.
- Open a Stripe Checkout session for the new plan.
- Wait for the webhook to grant it.
Step 3 is optional from the customer's side. They can close the tab, lose signal in the cellar bar they were sitting in, or decide at the card form that they will think about it. The subscription they were paying for an hour ago is already gone, and nothing in the flow brings it back. The venue drops to the trial limits in the middle of a Tuesday.
That is the bug I want to walk through, because the fix is not "add a rollback". It is a reordering that makes the bad state unreachable, and it leaves behind a window where one customer legitimately has two live subscriptions at once.
The reorder
Nothing is canceled when Checkout opens. The route that creates the session lists what the customer already has, and stamps those ids onto the subscription Checkout is about to create:
const live = await liveSubscriptions(stripeCustomerId)
// ...
subscription_data: { metadata: { ...metadata, ...replacementMetadata(live) } },
replacementMetadata is five lines:
export const REPLACES_KEY = 'replaces_subscriptions'
export function replacementMetadata(live: Pick<Stripe.Subscription, 'id'>[]): Record<string, string> {
return live.length > 0 ? { [REPLACES_KEY]: live.map((sub) => sub.id).join(',') } : {}
}
A comma-joined list of ids in a metadata string. Stripe metadata values are strings, so a list is a string with commas in it, and the reader splits and trims. It is not elegant and it does not need to be: the alternative is a row in our own database that has to be kept in step with Stripe, and the whole point of this design is to stop keeping two systems in step.
Now the customer who closes the tab has changed nothing. Their old subscription was never touched. The failure mode that cost them their plan does not exist, because the only thing that can take the old plan away is the new one being paid for.
The window
Between Checkout completing and the old subscription being canceled, Stripe thinks this customer has two subscriptions, both active. Our webhook gets events for both. Which one does the venue follow?
The honest answer at that moment is "whichever event arrives last", which is not an answer at all. Webhook deliveries are not ordered, they can be duplicated, and a retry of an old delivery can land after a newer one. So the webhook stopped reading the subscription from the event body as though it were the truth:
async function syncCustomer(customerId: string, ended: Stripe.Subscription | null): Promise<void> {
const live = await liveSubscriptions(customerId)
const current = currentSubscription(live)
if (current) {
await handleSubscriptionActive(current)
for (const old of supersededBy(current, live)) await cancelReplaced(old)
} else if (ended) {
await handleSubscriptionDeleted(ended)
}
}
The event is now only a trigger. It says "something about this customer changed", and the handler goes and asks Stripe what the customer looks like now. The subscription in the event payload is never written to the database; it is used only to answer "and if nothing is paying, what ended?".
currentSubscription is the rule that resolves the window:
export function currentSubscription<T extends Pick<Stripe.Subscription, 'id' | 'status' | 'metadata' | 'created'>>(
live: T[],
): T | null {
const paying = live.filter(isPaying)
const replaced = new Set(paying.flatMap(replacedIds))
const candidates = paying.filter((sub) => !replaced.has(sub.id))
candidates.sort((a, b) => b.created - a.created)
return candidates[0] ?? null
}
Read it as a sentence: the subscription the venue follows is a paying one that no other paying subscription claims to replace. Newest wins if somehow there are several. During the window both subscriptions are paying, but the new one names the old one in its metadata, so the old one is excluded and the new plan is the one that applies immediately. The customer gets what they just bought in the same second they bought it, not when the cancel finishes.
Making the cancel safe to run twice
Once the new subscription is current, the ones it replaces are canceled:
async function cancelReplaced(sub: Stripe.Subscription): Promise<void> {
try {
await stripe.subscriptions.cancel(sub.id)
} catch (error) {
const now = await stripe.subscriptions.retrieve(sub.id).catch(() => null)
if (now?.status === 'canceled') return
throw error
}
}
Two deliveries of the same event can race here, and canceling an already-canceled subscription is an error from Stripe. Swallowing every error would hide a real failure, so the catch re-reads the subscription and only forgives the one case where the end state is already what we wanted. If it is anything else, it throws, the handler returns 500, and Stripe retries, which is the behaviour we want for a transient database or network problem.
supersededBy has the same shape of defence: it filters against the subscriptions that are still live, so a second delivery finds nothing left to cancel rather than trying again.
The one case that still needs a human
Stripe holds a customer to a single currency while a subscription of theirs is alive. Our prices are quoted in the visitor's own currency, so a customer who signed up in euros stays in euros for a plan change, which is what the checkout route does:
const currency = pricingCurrency(visitorCurrency(request.headers), live[0]?.currency)
If that resolves to nothing, meaning the customer is locked to a currency the new plan has no price in, the route refuses with a 409 and a message naming our support address rather than failing in a way that looks like a declined card. One real limit, stated plainly, and a path that goes to a person.
There is also a plain guard against buying the plan you are on, which would otherwise charge twice:
if (live.some(sub => isPaying(sub) && planTypeOf(sub) === planType)) {
return NextResponse.json({ error: "You're already on this plan." }, { status: 409 })
}
What this is like to test
All of this is pure functions over subscription shapes, which is the other reason the ids live in Stripe metadata rather than in our tables. Four billing test files, 1,137 lines, 88 tests, and not one of them opens a socket:
Test Files 4 passed (4)
Tests 88 passed (88)
Duration 791ms
A plan change is a thing you cannot afford to get wrong twice, and it is also a thing nobody wants to exercise by hand against a real card. Making the decision a function of "here are the customer's subscriptions, which one counts" meant the window with two live subscriptions could be written down as a test case instead of discovered in production.
What a reader can check
The prices those plans are built from are on our pricing page, quoted in the currency of whatever country you are reading from, including VAT where it applies. The thing a plan change actually buys is described on the hosts and venues page: Pro is one host account, Ultimate is up to five plus simultaneous sessions, which is the upgrade this whole mechanism exists to make safe on a Tuesday afternoon.
The general lesson is older than Stripe. In any flow where you take something away and then give something back, the gap between the two is a state your user can be stranded in. Reverse the order, make the removal a consequence of the addition succeeding, and accept the short window where both exist. Then write down the rule that says which one wins, because that rule is the whole design, and it is much easier to defend as nine lines of a function than as an ordering assumption about somebody else's webhooks.
Top comments (0)