Back to blog
Coding

Subscription Billing Edge Cases That Break Homegrown Systems

6 min read

Subscription billing looks simple until you build it: charge a card every month, adjust for plan changes, done. Then a customer upgrades at 11:59 PM on the last day of their cycle, or downgrades twice in the same billing period, or their card is declined for "insufficient funds" instead of being permanently dead — and the naive implementation either overcharges, undercharges, or silently drops the invoice. These aren't rare; they're the default shape of a billing system at scale.

Proration: the math nobody explains up front

Proration calculates partial-period charges or credits when a subscription changes mid-cycle, so a customer pays (or is credited) for exactly the days they had access to a given tier (Dodo Payments). The formula is simple in isolation — (days_remaining / days_in_cycle) * price_difference — but production systems rarely see one clean change per cycle.

Sequential upgrades in one cycle. A customer who upgrades on day 5, then upgrades again on day 20 of a 30-day cycle creates three distinct billing segments, each needing its own rate applied to the usage ledger at a precise timestamp (Kinde). A system that only supports "one plan change per cycle" will either reject the second change or silently miscalculate it.

Boundary timing. If a customer upgrades at 11:59 PM on the last day of their cycle, they should be charged the new rate starting the next cycle, not get a one-day proration sliver — off-by-one errors at cycle boundaries are a common production bug (Flexprice).

Failed proration charges. If the prorated charge for an upgrade fails, the system has to decide: does the user keep access to the higher tier while payment is retried, or get rolled back? Most homegrown systems don't have an answer until it happens in production (Flexprice).

Warning

Subscription upgrades on Google Play were found in June 2025 awarding extra days of service instead of monetary proration credit — customers who upgraded mid-cycle received less value than the plan terms described. Even platform-level billing infrastructure ships proration bugs (Flexprice).

Upgrades vs. downgrades: different defaults, different risk

Most SaaS businesses default to immediate proration for upgrades — customers expect instant access to the higher tier — and next-cycle billing for downgrades, since deferring a downgrade avoids refund logic entirely (Flexprice). This asymmetry is intentional: an upgrade is a "give the customer what they're paying more for now" problem, while a downgrade is a "reduce future billing, don't claw back the current period" problem.

Scenario Common default Why
Upgrade mid-cycle Prorate + invoice immediately Customer expects access now
Downgrade mid-cycle Apply at next renewal Avoids refund/credit complexity
Failed proration invoice Varies — no universal default Access-vs-payment tradeoff unresolved industry-wide
Multiple changes same cycle Segment into sub-periods Each segment billed at its own rate

Stripe's proration_behavior: always_invoice setting bills a customer immediately for a same-cycle change by calculating proration and generating an invoice right after the switch — but if that invoice payment fails, there's no built-in rollback to the old plan (Stripe docs via Medium). That gap — failed payment, no rollback — is exactly the kind of edge case that only surfaces once real customers hit it.

Timezone bugs: the silent date-shift

Charges scheduled for "the first of the month" can fire a day early or late depending on server timezone handling relative to the customer's local time — a quirk that has bitten enough billing systems that Stripe explicitly documents it (DEV Community). Stripe's own recommendation: run subscription timestamps in UTC rather than local time, because internal timestamps are always UTC and mixing in local-timezone values produces charges that don't land where you expect.

A related, less obvious fix: set trial-period end times to 4 or 5 AM UTC instead of midnight. Midnight boundaries are where daylight-saving transitions and timezone rounding errors concentrate, so nudging the cutoff a few hours away from midnight avoids an entire category of "trial ended a day early/late" support tickets (DEV Community).

Timezone-aware billing boundaries, done properly, take roughly two engineering sprints to implement — not because the math is hard, but because every edge case (leap years, DST transitions, 31-day vs. 28-day months, simultaneous renewal-and-plan-change race conditions) needs its own test (DEV Community).

Usage-based billing compounds everything

43% of SaaS companies now combine subscription and usage-based components in a single plan, which makes proration exponentially more complex than a flat per-day calculation — a mid-cycle plan change now has to reconcile a usage ledger against two different rate tiers for the same billing period, not just split a flat fee (Flexprice).

Example: hybrid plan change mid-cycle
Day 1-14:  Plan A base $29 + $0.02/unit  (1,200 units used)
Day 15:    Upgrade to Plan B base $79 + $0.015/unit
Day 15-30: Plan B base (prorated) + $0.015/unit (800 units used)

Invoice must reconcile:
  - Plan A prorated base (14/30 days) + 1,200 * $0.02
  - Plan B prorated base (16/30 days) + 800 * $0.015
  - NOT: blended rate across all 2,000 units

Failed payments: soft declines are not dead cards

A soft decline means the card itself is valid but the specific charge failed — insufficient funds, a daily spending limit, or a fraud-system flag — as distinct from a hard decline (lost, stolen, "do not honor"), which should stop retries entirely to avoid dispute fees (GR4VY). Treating every decline the same way — one retry schedule for all failures — either wastes retries on dead cards or gives up too early on recoverable ones.

Recovery rates vary sharply by decline reason: processing errors recover at 80–90%, insufficient funds at 70–80%, but a generic "card declined" recovers only 40–60% (GR4VY). Retry timing matters too — insufficient-funds failures should wait 2–3 days (often to clear a payday cycle), while technical timeouts should retry immediately, ideally through a different processor (Slicker).

Card networks also cap retry volume: Visa limits merchants to 15 retries per 30 days for a single card-merchant pair, and Mastercard is stricter at 10 — breaching these triggers fines starting at $25 per transaction (Beast Insights). A billing system that retries naively on a fixed schedule without tracking attempt counts per card can rack up network fines before anyone notices.

Building it vs. buying it

The recurring theme across every edge case above is that each one is individually simple and collectively enormous. A team can implement correct proration in a day; implementing correct proration plus timezone-safe scheduling plus usage-ledger reconciliation plus decline-aware dunning plus card-network retry limits is months of edge-case discovery, most of it found in production incidents rather than design docs. This is why most teams past early MVP stage move billing logic onto Stripe Billing, Chargebee, Recurly, or similar platforms rather than maintaining it in-house — not because the core math is hard, but because the edge-case surface area is large and mostly invisible until it fires (Chargebee).

Actionable takeaway

If you're building or auditing a subscription billing system, walk through five specific scenarios before calling it production-ready: (1) two plan changes in the same billing cycle, (2) a plan change in the final hour of a cycle, (3) a failed proration invoice — does access get revoked or not, (4) a soft-decline card retried on a schedule that respects Visa/Mastercard's 15/10-attempt caps, and (5) all subscription timestamps stored and scheduled in UTC with trial-end times offset from midnight. Each of these has shipped as a real bug at a company you've heard of — test for them explicitly rather than discovering them from a support queue.


Sources: Flexprice, Dodo Payments, Kinde, Chargebee, DEV Community, Stripe/Medium, GR4VY, Slicker, Beast Insights

Get new posts as they publish

No spam — just the next post, straight to your inbox.

Keep reading

Discussion