Subscription Edge Cases That Break Billing
12 Aug 2026Subscription billing looks like a solved problem until a customer upgrades mid-cycle, their card fails on renewal, and your system doesn’t know whether they’re a paying customer anymore. These edge cases are where most homegrown billing code actually breaks.
Proration on a plan change
The question that trips people up: when someone upgrades from $20/mo to $50/mo on day 15 of a 30-day cycle, what do they pay right now?
The standard answer is proration — credit the unused portion of the old plan, charge the prorated cost of the new one:
Unused credit: $20 * (15/30) = $10.00 credit
New plan cost: $50 * (15/30) = $25.00 charge
Due today: $15.00
If you’re on Stripe, proration_behavior: 'create_prorations' on the subscription update does this for you — don’t hand-roll the math unless you have a reason to. The edge case within the edge case: downgrades. Most products don’t refund a downgrade mid-cycle, they apply it at the next renewal instead, because an immediate downgrade proration can produce a negative invoice that has to be issued as a credit, and credits are where refund disputes live. Decide which behavior you want and set proration_behavior: 'none' with an explicit “changes apply next cycle” if you’d rather avoid mid-cycle math entirely.
Failed payments: don’t cut access on the first failure
A single declined card is normal — expired card, bank flagged the charge, insufficient funds that clear the next day. If you revoke access on the first failed renewal, you’ll churn customers who would have paid on retry.
Use a dunning sequence instead:
| Attempt | When | Action |
|---|---|---|
| 1 | Day 0 (renewal date) | Charge fails, email sent, access stays on |
| 2 | Day 3 | Retry charge, email sent |
| 3 | Day 7 | Retry charge, final warning email |
| — | Day 10 | Access suspended, not deleted |
Stripe’s Smart Retries handles the retry schedule for you if you’re using Billing; if you’re rolling your own, replicate roughly this cadence — three attempts over a week before you cut anything off. Never delete the account or its data on payment failure. People come back after a card update; deleting their workspace to punish a bounced charge is how you turn a billing hiccup into a permanent lost customer.
Trial-to-paid conversion timing
If a trial requires a card upfront, the charge should fire at the exact moment the trial ends — not a batch job that runs once a day and might charge someone 20 hours late. A late charge after they thought they cancelled in time generates the worst kind of support ticket: the “you charged me after I cancelled” one, even when you didn’t.
If the trial doesn’t require a card upfront, be explicit about what happens at trial end. Silent downgrade to a free tier is safer than silent account suspension — don’t lock someone out of data they can’t see the reason for.
Mid-cycle cancellation
Does canceling on day 15 of a paid cycle end access immediately, or at day 30? Most SaaS lets access run through the period already paid for — canceling stops the next renewal, not current access. This is both the friendlier choice and the one customers expect; an immediate cutoff on a period they already paid for reads as a refund dispute waiting to happen.
The state that actually matters: entitlement, not subscription status
Don’t gate features by checking subscription.status === 'active' directly against Stripe on every request — build a local entitled_until timestamp that you update from webhooks, and check that instead. It survives Stripe API outages, it’s fast, and it naturally handles “cancelled but still paid through end of period” without a special case. Every other edge case in this post ends up being a variation on getting this one field right.