The Stripe quickstart gets you to "a customer can subscribe" in an afternoon. Card form, Checkout session, a customer.subscription.created webhook, a row in your database. It feels finished. It is about 30% finished. The subscription billing edge cases that make up the other 70% — proration on a mid-cycle plan change, a renewal charge that silently fails, a cancellation that should leave access intact until the period ends — none of those are in the quickstart, and every one of them shows up in production.

I've built and maintained this layer on real recurring-revenue systems, and the lesson that took longest to fully internalize is uncomfortable: subscription billing is a state machine you don't own. Stripe owns the truth. Your job is to faithfully mirror its state, not to compute billing reality yourself. The teams that get burned are the ones who treated billing as a feature they finished rather than a system they maintain.

This article is the messy middle. It assumes you already have basic Stripe subscriptions working and you're now hitting the hard cases. It is not a "how to integrate Checkout" tutorial. It is the list of things that break after launch, why each one is subtle, and how I actually handle it.

A note on scope: this is about the business edge cases. The mechanics of receiving webhooks reliably — signature verification, idempotency keys, queues, retries — I covered separately in Stripe webhooks in production and idempotency keys for retried writes. I'll cross-link those rather than rebuild them. Everything here assumes your webhook handler is already idempotent.

The Subscription State Machine You Don't Own

Start here, because it reframes everything below. A Stripe subscription is a finite state machine, and the states are not yours to invent:

  • incomplete — created, but the first payment hasn't succeeded yet.
  • incomplete_expired — the first payment was never completed within ~23 hours; the subscription is dead.
  • trialing — in a trial period, no charge yet.
  • active — paid and current.
  • past_due — a renewal charge failed and Stripe is retrying.
  • canceled — ended.
  • unpaid — retries were exhausted and you configured Stripe to leave it in unpaid rather than cancel.
  • paused — a configurable state if you use pause-collection.

Here's the anti-pattern that causes more billing bugs than anything else:

// ANTI-PATTERN: caching a boolean at signup and trusting it forever
async function onSignup(userId: string) {
  await db.update(users).set({ isSubscribed: true }).where(eq(users.id, userId));
  // Now access control reads users.isSubscribed.
  // What happens when their card expires in March? Nothing. They keep access.
}

Enter fullscreen mode Exit fullscreen mode

That isSubscribed: true is a lie the moment a renewal fails, a customer cancels, or a chargeback lands. You set it once at the happiest moment in the lifecycle and never hear about the unhappy ones. Three months later someone with a long-dead card still has full access, and you find out when you reconcile revenue.

The correct model: you store the subscription status as Stripe reports it, updated by webhooks, and your access check reads that status — not a boolean you decided.

type SubStatus =
  | "incomplete"
  | "incomplete_expired"
  | "trialing"
  | "active"
  | "past_due"
  | "canceled"
  | "unpaid"
  | "paused";

// One row per subscription, mirrored from Stripe. Stripe is the source of truth.
interface SubscriptionRecord {
  stripeSubscriptionId: string;
  userId: string;
  status: SubStatus;
  currentPeriodEnd: Date; // when the paid period actually ends
  cancelAtPeriodEnd: boolean; // they hit "cancel" but paid through the period
  priceId: string; // which plan
}

// Access control reads the mirrored status, never a cached boolean.
function hasAccess(sub: SubscriptionRecord): boolean {
  // active and trialing obviously have access.
  // past_due is a judgment call — usually you grant a grace period (see dunning).
  if (sub.status === "active" || sub.status === "trialing") return true;
  if (sub.status === "past_due") return sub.currentPeriodEnd > new Date(); // grace
  return false;
}

Enter fullscreen mode Exit fullscreen mode

Every section below is, at bottom, a transition in this machine. Once you see billing as "mirror Stripe's state faithfully," the edge cases stop being surprises and become states you already have a handler for.

Plan Changes Mid-Cycle: Proration Will Surprise You

A customer on the €29/month plan upgrades to €99/month on day 10 of their cycle. What happens?

By default, Stripe prorates immediately. It credits the unused portion of the €29 plan (~20 days) and charges the prorated portion of the €99 plan for the rest of the cycle — and depending on your settings, it can attempt that net amount as an invoice right now, mid-cycle. The customer expected "I'll pay the new price next month." Instead a €40-ish charge lands today. That surprise generates support tickets and disputes.

You control this with proration_behavior when you update the subscription item:

import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

// UPGRADE: charge the prorated difference now. The customer wants the bigger
// plan immediately, so charging immediately is defensible — but make it explicit
// in the UI ("you'll be charged €40.33 today for the rest of this cycle").
async function upgrade(subscriptionItemId: string, newPriceId: string) {
  await stripe.subscriptionItems.update(subscriptionItemId, {
    price: newPriceId,
    proration_behavior: "always_invoice", // create + attempt the proration invoice now
  });
}

// DOWNGRADE: do NOT refund or credit immediately. Schedule it for period end.
// They already paid for this cycle's higher tier — let them keep it until renewal.
async function downgrade(subscriptionId: string, itemId: string, newPriceId: string) {
  const schedule = await stripe.subscriptionSchedules.create({
    from_subscription: subscriptionId,
  });
  // ...then update the schedule so the new (cheaper) price starts at the next phase.
  // The customer keeps the higher tier until currentPeriodEnd, then drops to the new one.
  return schedule;
}

Enter fullscreen mode Exit fullscreen mode

The deliberate choice is asymmetric and it matters:

  • Upgrades: prorate now. The customer asked for more, immediately. Charging the difference now is honest — just show the exact amount before they confirm.
  • Downgrades: schedule at period end. Refunding or crediting the difference the instant someone downgrades invites abuse (upgrade, use the premium feature, downgrade for a refund) and confuses your revenue numbers. They paid for this period at the higher tier; let it ride until renewal, then drop them. Use proration_behavior: "none" combined with a schedule, or set the change to take effect at current_period_end.

The subtlety people miss: proration credits don't always come back as cash. They land on the customer's Stripe balance and get applied to the next invoice. So a downgrade-with-credit doesn't refund a card — it just makes the next invoice smaller, which is rarely what an angry customer demanding "my money back" means. Decide your policy and say it in the UI before they click.

Failed Payments and Dunning: The Silent Revenue Leak

This is the single biggest leak in subscription revenue, and almost nobody builds for it until they've lost real money. Here is the fact that reframes it: most subscription churn is involuntary. It isn't customers deciding to quit. It's expired cards, insufficient funds, a bank declining a foreign transaction, a card reissued after a breach. The customer never chose to leave — their payment method just stopped working, and if you do nothing, they're gone.

When a renewal charge fails, the subscription goes to past_due (not canceled — that's later). Stripe's Smart Retries then attempt the charge again on a schedule tuned to maximize recovery. During that window you run a dunning sequence: a series of "your payment failed, please update your card" emails that escalate.

The states you need to handle distinctly:

  • past_due — a renewal failed; retries are in progress. Do not cut off access yet. This person almost certainly still wants the product; their card just expired. Cutting them off mid-retry-window turns a recoverable lapse into a real cancellation. Grant a grace period.
  • unpaid — retries exhausted and you configured Stripe to land here instead of cancelling. Access should be restricted now, but the record (and their history) stays so they can recover with one click.
  • canceled — the terminal state if you configured retries to end in cancellation.

You choose between unpaid and canceled as the end-of-retry behaviour in your Stripe billing settings. I prefer unpaid — it keeps the customer attached and makes win-back a single "update card" action instead of a fresh signup.

The dunning sequence itself is driven entirely by webhooks, not by a timer you run:

// Webhook handler reacts to Stripe's retry lifecycle. Your handler is already
// idempotent (see the Stripe-webhooks article) — this is the billing logic on top.
async function onInvoicePaymentFailed(invoice: Stripe.Invoice): Promise<void> {
  const attempt = invoice.attempt_count; // 1, 2, 3... Stripe tracks the retry number
  // VERSION CAVEAT: newer API versions moved the subscription ref to
  // `invoice.parent.subscription_details.subscription`; on older pinned
  // versions it's the top-level `invoice.subscription`. Read per your version.
  const sub = await getSubscription(invoice.subscription as string);

  // Status is already past_due (Stripe set it). We send escalating dunning email.
  switch (attempt) {
    case 1:
      await sendDunningEmail(sub.userId, "soft"); // "we couldn't charge your card"
      break;
    case 2:
      await sendDunningEmail(sub.userId, "reminder");
      break;
    default:
      await sendDunningEmail(sub.userId, "final"); // "access ends soon"
  }
  // Access is still granted during the grace window — hasAccess() returns true
  // for past_due while currentPeriodEnd hasn't passed.
}

async function onSubscriptionUnpaidOrCanceled(sub: Stripe.Subscription): Promise<void> {
  // Retries exhausted. NOW restrict access — but keep the record for one-click win-back.
  await mirrorStatus(sub.id, sub.status as SubStatus);
  await sendWinBackEmail(sub.metadata.userId);
}

Enter fullscreen mode Exit fullscreen mode

The lesson: failed payments are a recovery problem, not a cancellation event. Treat past_due as "still a customer, temporarily broken" and you recover a large share of revenue that naive implementations write off.

Cancellation: Almost Always End-of-Period, Not Immediate

When a customer clicks "Cancel," what should happen?

The naive answer — revoke access right now — is almost always wrong. They paid through the end of the current period. Cutting them off the instant they cancel means you're keeping money for service you're refusing to deliver. That's the kind of thing that turns into a chargeback.

The right default is cancel_at_period_end:

// Cancel at period end — they keep access until the period they paid for runs out.
async function cancelSubscription(subscriptionId: string) {
  await stripe.subscriptions.update(subscriptionId, {
    cancel_at_period_end: true, // status stays "active"; cancels at currentPeriodEnd
  });
}

// Immediate cancellation — only for specific cases (fraud, refund-and-revoke, abuse).
async function cancelImmediately(subscriptionId: string) {
  await stripe.subscriptions.cancel(subscriptionId); // ends now
}

Enter fullscreen mode Exit fullscreen mode

A few consequences that trip people up:

  • The status stays active after cancel_at_period_end. Stripe does not immediately move it to canceled. It sets cancel_at_period_end: true and the status flips to canceled only when the period actually ends. Your access check must read cancelAtPeriodEnd separately from status — both matter.
  • Reactivation before period end is free and frictionless. A customer who cancels on the 5th and changes their mind on the 20th just sets cancel_at_period_end: false again. No new charge, no re-signup. If you revoked access on the 5th, you threw away an easy save.
  • Keep their data after cancel. Don't delete the account on cancellation — downgrade it. Both the Stripe webhooks article and hard experience say keep at least 30 days of data post-cancellation, because a meaningful fraction come back.

The single discriminator between "cancel now" and "cancel at period end" is whether they've been refunded. If you refund the remaining period, revoking immediately is fair. If you don't, they keep what they paid for.

Trials: The No-Card Trial Is a Different Animal

Trials look simple and then aren't. Two flavors, two failure modes.

Trial with a card on file. The card is collected up front; at trial end Stripe attempts the first real charge. The trap: that charge can fail. The subscription doesn't go active — it goes incomplete or past_due, depending on configuration. If your code assumed "trial ended → they're a paying customer now," you just granted full paid access to someone whose card was declined. Handle customer.subscription.updated and read the real status; don't infer "paid" from "trial ended."

Trial without a card. No payment method collected, lower friction, higher signups — and a conversion cliff at trial end. When the trial expires with no card, Stripe can't charge anything. You handle customer.subscription.trial_will_end (fires ~3 days before) to prompt for a card, and then react to whatever state the subscription lands in when the trial actually ends.

async function onTrialWillEnd(sub: Stripe.Subscription): Promise<void> {
  const hasCard = sub.default_payment_method != null;
  if (!hasCard) {
    // No-card trial about to lapse — this is your conversion moment.
    await sendAddCardPrompt(sub.metadata.userId);
  }
}

Enter fullscreen mode Exit fullscreen mode

Two more trial edge cases worth a guard:

  • Don't double-grant trials to repeat signups. A user who took a 14-day trial, churned, and signs up again under a new email should not get another free 14 days indefinitely. Track trial eligibility on something stable (Stripe customer, verified email, payment fingerprint) — not just "this is a new account."
  • Trial-to-paid timing is a webhook, not a setTimeout. Never schedule "convert this trial in 14 days" with your own timer. Let Stripe end the trial and tell you via webhook. Your timer and Stripe's clock will drift; Stripe's clock is the one the customer is billed against.

Refunds, Credits, and Chargebacks Are Three Different Things

People lump these together. They have different mechanics and different access consequences.

  • Refund — money goes back to the card. Partial refunds are normal (refund 10 of 30 unused days). A refund does not, by itself, cancel the subscription or revoke access — those are separate decisions you make.
  • Account credit / balance — money stays in your system as a customer balance applied to future invoices. This is what proration downgrades usually produce. It's not cash back; it's a smaller next invoice. Say so explicitly.
  • Chargeback / dispute — the customer (or their bank) reverses the charge through the card network. This is not a refund you issued. It arrives as a charge.dispute.created webhook, it costs you a non-refundable dispute fee on top of the reversed amount, and it demands an access decision: a dispute usually means revoke access and treat the relationship as adversarial until resolved.
async function onDisputeCreated(dispute: Stripe.Dispute): Promise<void> {
  // A chargeback is a webhook + a fee + an access decision — not a refund.
  const sub = await findSubscriptionByCharge(dispute.charge as string);
  if (sub) {
    await revokeAccess(sub.userId); // adversarial: pull access pending resolution
    await flagAccount(sub.userId, "dispute"); // require resolution before re-subscribe
  }
}

Enter fullscreen mode Exit fullscreen mode

The edge case to plan for: re-subscribing after a dispute. Someone who charged back and then tries to subscribe again is either a confused legitimate customer or someone gaming you. Don't auto-welcome them back into a fresh trial; route them through a flag that requires the prior dispute be resolved first.

slug="e-commerce"
text="Recurring billing on an EU product means proration, dunning, cancellation states, VAT per-invoice, and webhook ordering all at once. Wiring this layer correctly — so it doesn't leak revenue or anger customers — is exactly what I build."
/>

VAT on Subscriptions Is Per-Invoice, Not Per-Customer

If you sell into the EU, tax is not a property of the customer you compute once — it's a property of each invoice. Three things make subscription VAT harder than one-off VAT:

  • Rates change. A member state adjusts its VAT rate and every future invoice in that country must use the new rate. You can't bake the rate into the subscription at signup.
  • Customers move. A customer who signed up in Germany and relocates to Portugal changes the applicable rate. The invoice computed at renewal must reflect where they are now.
  • Reverse charge for B2B. A business customer with a valid VAT number in another member state shifts the tax liability to them (reverse charge) — you charge net, they self-account. But "has a valid VAT number" has to be checked, and VAT numbers go invalid.

The mechanics of validating those numbers — VIES, caching its flaky SOAP API, the three-state result, the reverse-charge logic — I covered in depth in EU VAT validation in Next.js. For subscriptions, the one rule to carry over here is: compute tax at invoice time, per invoice, from the customer's current details — never cache a tax decision made at signup. Stripe Tax can do a lot of this for you, but whether you use it or roll your own, the per-invoice principle is the same one as the state machine: the truth is recomputed at the moment of billing, not frozen earlier.

Webhook Ordering: Events Arrive Out of Order and More Than Once

The last edge case is the one that connects all the others back to the opening principle. Your billing logic is driven by webhooks, and webhooks are not a clean ordered stream. They arrive more than once, and they arrive out of order. You can receive customer.subscription.updated before the customer.subscription.created you were waiting for. You can get a payment_failed and its retry's payment_succeeded close enough together that they race.

The general mechanics of surviving this — idempotent handlers, deduplicating by event ID, queueing — live in the Stripe webhooks and idempotency keys articles. I won't rebuild them. The billing-specific consequence is what matters here:

Never derive subscription state from the type of event you received. Derive it from the object in the event. A naive handler does if (event.type === 'subscription.updated') setStatus('active') — and breaks the moment an out-of-order or stale event lands. The robust handler reads event.data.object.status and mirrors that value, ignoring what the event is named. Even better, on any consequential event, re-fetch the subscription from Stripe's API and mirror that, because the event payload is a snapshot from when it was generated and may already be stale by the time you process it.

// ANTI-PATTERN: inferring state from the event type
if (event.type === "customer.subscription.updated") {
  await setStatus(subId, "active"); // wrong — an old/out-of-order event corrupts state
}

// CORRECT: mirror the authoritative status from the object, ignore event ordering.
async function mirrorFromEvent(event: Stripe.Event): Promise<void> {
  const sub = event.data.object as Stripe.Subscription;
  // Optionally re-fetch for the freshest truth, since the payload can be stale:
  const fresh = await stripe.subscriptions.retrieve(sub.id);
  // VERSION CAVEAT: as of API 2025-03-31.basil, `current_period_end` moved off the
  // subscription onto each item — read `fresh.items.data[0].current_period_end`.
  // On older pinned versions it's still top-level. Check the version you've pinned.
  const periodEnd = fresh.items.data[0].current_period_end; // (top-level on older versions)
  await mirrorStatus(fresh.id, fresh.status as SubStatus, {
    currentPeriodEnd: new Date(periodEnd * 1000),
    cancelAtPeriodEnd: fresh.cancel_at_period_end,
  });
}

Enter fullscreen mode Exit fullscreen mode

Mirror the source of truth. That single discipline makes out-of-order and duplicate events harmless: applying the same authoritative status twice, or applying a stale one and then a fresh one, converges on the right answer instead of corrupting it.

Takeaways

  1. Billing is a state machine you don't own. Stripe holds the truth. Your job is to mirror its state via webhooks, never to compute billing reality yourself. Store the status; don't cache a boolean.
  2. isSubscribed = true at signup is the canonical anti-pattern. It's a lie the moment a card expires, a customer cancels, or a chargeback lands. Read the live, mirrored status in your access check.
  3. Prorate upgrades now, schedule downgrades for period end. The asymmetry is deliberate — it's honest to the customer and it doesn't invite refund-gaming.
  4. Most churn is involuntary. past_due is a recovery problem, not a cancellation. Grace period plus a dunning sequence wins back revenue that naive code throws away.
  5. "Cancel" means cancel_at_period_end. They paid through the period; let them keep access, allow free reactivation, and keep their data after the terminal state.
  6. Refunds, credits, and chargebacks are three different things with three different access decisions — and a re-subscribe after a dispute needs a guard, not a fresh trial.
  7. Compute VAT per-invoice from current details, never frozen at signup — same per-billing-moment principle as the state machine.
  8. Derive state from the event's object, not its type. Webhooks arrive duplicated and out of order; mirroring the authoritative status makes that harmless.

The teams that get burned by subscription billing aren't the ones who picked the wrong library. They're the ones who shipped the happy path, called billing "done," and never built the machine to maintain its state. If you're putting recurring revenue on an EU product and you'd rather not discover these edge cases one support ticket at a time, this is the layer I get right end-to-end on e-commerce and SaaS projects.