My first teardown was about one-time checkouts β the /checkout route bugs that show up
right before launch. This one is about the thing that bites a month later: subscriptions.
One-time payments fail loudly. Subscriptions fail quietly. The charge that doesn't happen,
the cancellation that doesn't revoke access, the trial that never ends β none of it throws
an error. You find out when you read your Stripe dashboard and your app's user table and
notice they disagree. By then a chunk of your "active" users have been using the product
for free for weeks.
Here are the recurring-billing bugs I find in almost every pre-launch SaaS, with the bad
version and the fix. If you're wiring up Stripe Billing, read it with your subscription
code open.
1. Granting access on payment, but never taking it away
This is the big one. You handle checkout.session.completed, flip a flag, and ship.
// the only handler that exists
case 'checkout.session.completed':
await db.user.update({ where: { id }, data: { isPro: true } })
isPro: true goes in. Nothing ever sets it back to false. The user cancels, their card
expires, they dispute the charge β and they stay Pro forever, because the only event you
listened for was the happy one.
Access has to track the current subscription state, not the moment money first arrived.
At minimum you also handle the events that end access:
case 'customer.subscription.deleted': // canceled / ended
case 'customer.subscription.updated': // could be past_due, unpaid, paused
const sub = event.data.object
const active = ['active', 'trialing'].includes(sub.status)
await db.user.update({
where: { stripeCustomerId: sub.customer },
data: { isPro: active },
})
The rule: every grant needs a revoke on the same key.
2. Storing a boolean instead of syncing the real status
isPro: true/false feels clean, but a subscription isn't a boolean. Stripe has at least
seven states β trialing, active, past_due, canceled, unpaid, paused,
incomplete β and your access rules differ across them. A user who is past_due should
probably keep access for a few days while dunning retries; a user who is canceled should
not.
Store the actual status and the period end, and let your access check read them:
// what you persist from the webhook
{ stripeStatus: sub.status, currentPeriodEnd: sub.current_period_end }
// the access check, in one place
function hasAccess(user) {
if (['active', 'trialing'].includes(user.stripeStatus)) return true
// grace window: still in the paid period you already billed for
if (user.stripeStatus === 'past_due' && user.currentPeriodEnd > now()) return true
return false
}
When in doubt, Stripe is the source of truth β your database is a cache of it.
3. The trial that never actually ends
Free trials are where access logic goes to die. Two common shapes, both broken:
- You start a trial with no payment method on file, so when it ends there's nothing to
charge β the subscription goes
incompleteand the user just... keeps the trial UI. - You compute "is the trial over?" on the client, or off a
trialEndsAtyou set once and never reconcile with Stripe.
Let Stripe run the trial and tell you when it converts or fails:
await stripe.subscriptions.create({
customer,
items: [{ price }],
trial_period_days: 14,
// force a decision at trial end instead of a silent dangling sub
trial_settings: { end_behavior: { missing_payment_method: 'cancel' } },
})
Then drive access off the webhook status (#2), not a date you stored at signup.
4. No handler for invoice.payment_failed
A renewal fails β expired card, insufficient funds, a bank decline. If you don't listen
for it, one of two things happens: the user silently keeps full access while paying you
nothing, or (worse, if you did wire up a revoke) they get kicked out mid-month with no
warning and no idea why. Both are bad. Both are invisible until someone complains.
case 'invoice.payment_failed':
// don't cut them off on attempt 1 β Stripe will retry on your dunning schedule
await notifyUser(customer, 'Your payment didn't go through β update your card')
// access is governed by subscription.status (now 'past_due'), per #2
Turn on Stripe's automatic retries (Smart Retries) and a couple of dunning emails. Most
"failed" payments succeed on retry once the customer updates their card β but only if you
gave them the chance.
5. Plan changes with no proration logic
User upgrades from $9 to $29 mid-cycle. What do they pay today? If your "change plan"
button just creates a new subscription, you've now got two active subscriptions billing
the same customer. If it swaps the price with no proration setting, the behavior is
whatever the default happens to be β which is rarely what you'd have chosen.
Update the existing subscription item and be explicit about proration:
await stripe.subscriptions.update(subId, {
items: [{ id: itemId, price: newPrice }],
proration_behavior: 'create_prorations', // charge/credit the difference now
})
One subscription per customer, one explicit proration decision. Test the downgrade path
too β that's the one that quietly hands out credits you didn't mean to give.
6. Webhooks that aren't idempotent or ordered
Stripe sends events at-least-once, and not always in the order you'd expect. The same
subscription.updated can arrive twice; a deleted can land before a late updated. If
your handler isn't idempotent, a duplicate flips state back and forth. If you trust
ordering, a stale event overwrites a newer one.
// idempotency: ignore events you've already processed
if (await db.processedEvent.find(event.id)) return res.sendStatus(200)
// ordering: only apply if this event is newer than what you have
if (event.data.object.created < user.lastEventAt) return res.sendStatus(200)
Record the event ID, gate on a timestamp, and reconcile against the Stripe object when in
doubt. (And verify the webhook signature on the raw body β that's bug #4 from the first
teardown, and it applies here too.)
The 5-minute subscription self-check
- When a user cancels or their card fails, does anything actually revoke their access?
- Is access driven by the live subscription status, or a boolean you set once at signup?
- Can a trial end with no payment method and leave the user in limbo?
- Do you have a handler for
invoice.payment_failedβ and does it warn the user before cutting them off? - If a
subscription.updatedevent arrives twice, or out of order, does your state stay correct?
If any of those makes you pause, that's the one to fix before you turn on billing.
I do fixed-scope pre-launch payment reviews for small SaaS teams: I map your checkout and
billing flow, find the launch-blockers, and send back a prioritized fix list β no code
changes, just findings you can act on. The checklist and example code I work from is open
here: github.com/galakurpi/stripe-prelaunch-security-checklist
If you're about to switch billing on and want a second set of eyes, I'll do a free
5-minute look at your flow first so you can see how I work before paying for anything.
Happy to answer Stripe Billing questions in the comments.












