From 8ede30ca8746fe45a450608f916df6dd1f256bbf Mon Sep 17 00:00:00 2001 From: Thibault Le Ouay Ducasse Date: Wed, 9 Sep 2026 09:19:20 +0200 Subject: [PATCH] stripe: fix bug --- packages/api/src/router/stripe/index.ts | 13 ++++++++++++- packages/api/src/router/stripe/webhook.ts | 11 ++++++++++- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/packages/api/src/router/stripe/index.ts b/packages/api/src/router/stripe/index.ts index cf92edc8..3e09f742 100644 --- a/packages/api/src/router/stripe/index.ts +++ b/packages/api/src/router/stripe/index.ts @@ -210,11 +210,22 @@ export const stripeRouter = createTRPCRouter({ }); } + // Classify before mutating Stripe. An item on a price neither table + // knows throws, and throwing *after* the update would leave the + // customer re-priced and billed while the workspace kept the old plan + // — a split the webhook cannot repair either, since it throws on the + // same item. + buildFromSubscriptionOrThrow(current); + // Only the plan item is listed, so Stripe leaves every other item - // untouched and the addons survive the plan change. + // untouched and the addons survive the plan change. Clearing + // `cancel_at_period_end` resumes a subscription the customer had + // scheduled to cancel — choosing a paid plan says they mean to keep + // paying. const updated = await stripe.subscriptions.update(current.id, { items: [{ id: planItem.id, price: priceId }], proration_behavior: "create_prorations", + cancel_at_period_end: false, }); const built = buildFromSubscriptionOrThrow(updated); diff --git a/packages/api/src/router/stripe/webhook.ts b/packages/api/src/router/stripe/webhook.ts index 7f8d2142..958411bf 100644 --- a/packages/api/src/router/stripe/webhook.ts +++ b/packages/api/src/router/stripe/webhook.ts @@ -170,6 +170,16 @@ export const webhookRouter = createTRPCRouter({ }); } + // A replayed or late `checkout.session.completed` can name a subscription + // that a newer one has already superseded. Writing it would move the + // workspace back to the older plan while the newer subscription keeps + // billing, so leave the workspace to that subscription's own events. + const { active, current } = await getCurrentSubscription(customerId); + + if (current && current.created > subscription.created) { + return; + } + const built = buildFromSubscriptionOrThrow(subscription); if (!built) { console.error("Invalid plan"); @@ -182,7 +192,6 @@ export const webhookRouter = createTRPCRouter({ // Checkout always opens a new subscription, so anything else still active // predates it and would keep billing. Retire it here instead of waiting // for an unrelated `customer.subscription.updated` to come along. - const { active } = await getCurrentSubscription(customerId); await cancelSupersededSubscriptions(active, subscription); await updateWorkspacePlan({ -- 2.51.2