DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our plan change cancelled your old subscription first, so closing the Checkout tab left you on nothing

A plan change in our app goes through Stripe Checkout, because that is also how a first purchase works and one flow is better than two. The old version of it had five lines that read perfectly sensibly:

// Cancel any existing active/trialing subscriptions in parallel
await Promise.all(
  live
    .filter(sub => sub.status === 'active' || sub.status === 'trialing')
    .map(sub => stripe.subscriptions.cancel(sub.id)),
)
Enter fullscreen mode Exit fullscreen mode

Cancel what the customer has, then send them to Checkout for what they want. Clean, and wrong, because the second half of that sentence is not guaranteed to happen. A customer who clicked Change plan, read the Stripe page, thought better of it and closed the tab had just lost the plan they were already paying for. Nobody declined anything. Nobody asked for a cancellation. The only thing that happened was a page being closed, and by then the money side of their account was already in the past tense.

The fix inverts the order: cancel nothing up front, and let the new subscription carry a note of what it supersedes.

The whole mechanism is one metadata key

/** Metadata key on a new subscription: the comma-separated IDs it replaces. */
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(',') } : {}
}
Enter fullscreen mode Exit fullscreen mode

Checkout stamps that on the subscription it creates. Every subscription still live on the customer goes on the list, paid up or not, so a past-due one stops retrying as well. If there is nothing to replace, the key is absent rather than empty, so a first purchase carries no trace of the mechanism at all.

The webhook does the cancelling, once the new subscription is actually paying. Abandoning Checkout now changes precisely nothing: no new subscription exists, so no list of replacements exists, so nothing gets cancelled, and the customer still has the plan they walked in with.

Two live subscriptions, briefly

The cost of that ordering is a window where the customer has two live subscriptions: the new paid one, and the old one that has not been cancelled yet. Stripe will happily tell you about both, in events that can arrive in either order, more than once each.

So the webhook stopped trusting the event it was handed. It asks what is true now:

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)
  }
}
Enter fullscreen mode Exit fullscreen mode

Every subscription event now routes through that, and the event's own payload is used for one thing only: the customer ID, plus the subscription object in the case where nothing is paying any more and we need to know what stopped.

The interesting function is the one that decides which of two live subscriptions the account follows:

export function currentSubscription(live) {
  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
}
Enter fullscreen mode Exit fullscreen mode

Read it as a rule rather than as code: the account follows a paying subscription that no other paying subscription claims to replace. Newest wins if somehow there are several, which there should not be.

That single rule covers the cases I would otherwise have written four separate branches for:

  • The new subscription is paid, the old one is still live. The old one is named in the new one's metadata, so it is excluded, and the account is on the new plan immediately.
  • An event for the old subscription arrives late, after the change. It is still replaced, so it is still excluded, and the late event cannot drag the account back to the previous plan.
  • The new subscription exists but is unpaid. It is not paying, so it is not a candidate at all, and the customer keeps the plan they had. Charging has not succeeded, so nothing is granted.
  • The last subscription ends. Nothing is paying, nothing is current, and the account falls back to the trial.

The tests for the webhook read almost as a list of those:

✓ moves the venue to the new plan and only then cancels the old one
✓ keeps the new plan when the old one is deleted, rather than dropping to the trial
✓ ignores a late event for the old subscription once the new one pays
✓ leaves the old plan in place while the new subscription is unpaid
✓ drops to the trial when the last subscription ends
Enter fullscreen mode Exit fullscreen mode

Cancelling something twice

supersededBy returns only the subscriptions that are still live and are named by the current one, so a webhook delivered twice finds the second time that there is nothing left to cancel. That handles the common case by construction, but not the race where two deliveries overlap, and Stripe's own retry behaviour makes overlapping deliveries normal rather than exotic.

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
  }
}
Enter fullscreen mode Exit fullscreen mode

A failed cancel is re-read rather than assumed. If the subscription is already cancelled, the error was somebody else doing our job and it is swallowed. Anything else rethrows, which the route turns into a 500, which makes Stripe retry the event later. Two tests cover exactly that pair: one takes a second delivery in its stride, and one insists we retry when the cancel genuinely failed.

Deciding that from the error's shape rather than from a fresh read would have been shorter, and it would have meant classifying Stripe error codes correctly forever. A single extra API call is a better deal.

Reading the plan off metadata, not the price

One thing holds the whole arrangement together. The plan is stamped on the subscription's metadata, not derived from its price:

export function planTypeOf(sub: Pick<Stripe.Subscription, 'metadata'>): PaidPlanType {
  return parsePaidPlanType(sub.metadata?.plan_type) ?? 'pro'
}
Enter fullscreen mode Exit fullscreen mode

We build Checkout line items from inline price_data in the visitor's own currency, so there are no Stripe Products or Prices to map back from. A subscription might be 36.99 euros or several thousand yen, and no lookup table can turn an amount into a plan without becoming wrong the next time a rate changes. The metadata reads identically in every currency, and the webhook's currency tests exist to prove it:

✓ provisions a euro Ultimate subscription as Ultimate
✓ provisions a yen Pro subscription as Pro, whatever its amount
✓ follows a plan change on a forint subscription
✓ downgrades a canceled krona subscription to the trial
Enter fullscreen mode Exit fullscreen mode

The fallback to pro is deliberate: a paid subscription with no readable plan should grant the cheaper paid plan, never the dearer one and never nothing.

Two refusals in the route

Removing the up-front cancel left the checkout route with less to do and two things it now has to refuse.

The plan you already pay for. With the cancel gone, buying your current plan again would just create a second subscription at the same price, so the route answers 409 "You're already on this plan." A past-due subscriber is explicitly allowed through, because for them buying again is the repair, and there is a test with that in its name: "lets a past-due subscriber buy their plan again, to replace the one that isn't paid".

A currency we cannot quote. Stripe holds a customer to one currency while a subscription lives, so a plan change has to be priced in the live subscription's currency. If that currency is not one we price in, which can only happen if somebody made a subscription by hand in the Stripe dashboard, the route stops with a 409 and an email address rather than guessing. It used to decide this before cancelling anything, and the comment explaining why no longer needs to exist, because nothing is cancelled anywhere in the route.

Seeing it from outside

Plan changes live behind a login, so there is no URL I can hand you for this one. What is public is the pricing it is built on: pub-trivia.app/pricing renders in your own currency, and the note under the cards tells you which one, which is the figure a plan change would be quoted in afterwards. The terms say the rule in prose, including that a plan stays in the currency it started in.

If you want to watch the mechanism, the free tier at pub-trivia.app/signup needs no card, and the plans screen will offer you a Checkout session you are welcome to close. That used to be the destructive path. Now it is the no-op it always looked like.

Top comments (1)

Collapse
 
paulhartness profile image
Paul Hartness •

Putting past-due subscriptions on the replaces list is a nice touch. Otherwise the old one keeps retrying a card while the customer is already paying on the new plan, and they get a failed-payment email for something they've left. Cancelling only once the new one is paid also means an abandoned Checkout costs nobody anything, which is how it should feel.