Design a Subscription Billing System
Design a Subscription / Recurring Billing System
This is Chargebee's literal product, and every payments-adjacent staff interview eventually corners you into some version of it: "design the thing that charges customers every month." Candidates who've only used a subscription product reach for a cron job and an UPDATE subscriptions SET next_bill_date = ..., which demos fine and then does two very expensive things in production — it charges a customer twice the first time the billing job is retried, and it either overcharges or undercharges every single mid-cycle upgrade because the day-count math was done in the wrong order. The staff-level differentiator is treating a subscription as a small, explicit state machine driven by a billing cycle clock, where every transition (trial ending, a card declining, a plan change, a cancellation) is a named, testable event — not an ad-hoc field mutation. This kata builds that machine yourself, in Java and Go, from an empty file: the lifecycle (Trialing → Active → PastDue → Canceled/Expired, plus Paused), the integer-cents proration math for a mid-cycle upgrade, the dunning retry ladder for a failed card, and an idempotent billing run that survives being re-triggered without double-charging — the exact failure mode this kata's sibling, Design an Idempotent Payment API, exists to prevent. It also connects directly to the recurring-billing requirements named in Designing a Payment System, which this kata implements rather than cites.
1. The Trap
You're asked to bill customers monthly for a SaaS product. The obvious first cut: a next_bill_date column and a nightly cron job.
-- "obviously correct" schema
CREATE TABLE subscriptions (id TEXT PRIMARY KEY, plan_price_cents BIGINT, next_bill_date DATE);
-- nightly job, pseudocode
for sub in subscriptions where next_bill_date <= today():
charge(sub.customer, sub.plan_price_cents) -- call the payment gateway
sub.next_bill_date = today() + 30 -- persist the new due date
This works exactly until the job is retried. Maybe the gateway call took 12 seconds and the job's own health-check killed and restarted it. Maybe a Kubernetes pod got rescheduled mid-run. Maybe an on-call engineer, staring at a job that "looks stuck," reruns it by hand. In every one of those cases the loop above starts over from the same query — where next_bill_date <= today() — and this subscription is STILL in that result set, because the crash landed after charge() returned but before the UPDATE that advances next_bill_date committed. The job charges the customer a second time for a cycle it already billed. Run the exact scenario later in this kata's reference implementation and the measured result is $98.00 captured for a single $49.00 cycle — not a rare race, a deterministic consequence of "charge, then separately persist that you charged" having a gap in the middle.
A second, quieter version of the same trap shows up the first time a customer upgrades mid-cycle. The intuitive formula for "how much credit do they get for the unused days" is (price / total_days) * days_remaining — divide first, to get a "daily rate," then multiply. On a $49.00 plan over a 30-day cycle with 20 days remaining, that's (4900 / 30) * 20. In integer arithmetic, 4900 / 30 truncates to 163 before you ever multiply it by anything — the true daily rate, 163.33 cents, already lost a third of a cent, and now that loss is baked into every day of the credit. The measured drift in this kata is a very real 6 cents on a single proration, always in the same direction. Six cents looks like nothing until you remember a real subscription billing system runs this formula on every plan change, every day, for millions of subscribers — a small, structural, one-directional bias is a line item on a P&L, not noise a monitoring dashboard will ever flag.
That's the trap: a subscription is not "a row with a due date." It is a state machine plus a clock plus money math, and the two most innocent-looking parts of it — "if it's due, bill it" and "divide the price by the days" — are exactly the two places naive code silently loses or fabricates money.
2. Scope it like a senior
Before writing a line of billing code, pin the contract down out loud:
- What states can a subscription be in, and what triggers each transition?
Trialing(free, not yet charged) →Active(on a successful charge) →PastDue(on a failed charge) → back toActive(a later retry succeeds) or on toCanceled(retries exhausted). Independently,Active↔Paused(a customer-initiated hold, never billed while paused), and any non-terminal state can move toCanceleddirectly. A fixed-term, non-renewing plan additionally reachesExpiredon its own instead of ever being charged again. This is the whole shape of the system — get it explicit before anything else. - What's the billing cycle anchor? Anniversary billing (each subscription renews N days after ITS OWN start date) versus calendar billing (everyone on a plan renews on the 1st of the month, prorated on sign-up). This kata builds anniversary billing — simpler day-count math, no "what happens on Feb 30th" edge case — and treats calendar billing as a named alternative in the trade-off table.
- What happens on a mid-cycle plan change? Immediate effect (upgrade/downgrade takes effect NOW, prorated true-up invoiced immediately) versus deferred (takes effect at the next renewal, no proration needed at all). This kata builds immediate-with-proration, because it's the harder, more interesting case — and the one that actually shows up in interviews.
- Can callers (or the scheduler) retry a billing run? Yes, and they will — a job that times out waiting on a slow payment gateway, or a scheduler that redelivers a task after an ambiguous failure, is the normal case for any system that talks to a third party over the network. So a billing run must be idempotent: rerunning it for a subscription already billed for its current cycle must not charge again.
- What's the dunning policy for a failed card? A fixed retry schedule (retry at +1, +3, +5, +7 days), then cancel if every retry fails. Exponential backoff is a named alternative, covered in the trade-off table.
- How is money represented? Integer minor units (cents) — same non-negotiable as every other fintech kata in this guide. The proration formula's entire correctness rests on doing the multiply before the divide, which only even means something once you've already ruled out floats.
Answer: an explicit six-state subscription lifecycle, anniversary billing with immediate-effect prorated plan changes, a fixed dunning retry ladder, integer-cents proration, and an idempotent billing run — the same "retries will happen, design for them" posture as every real payments system in this guide.
3. Reason to the design
Simplest thing that could work is the trap itself: a next_bill_date column, a cron loop that charges whatever's due and advances the date in a separate step. Why it fails, beyond the double-charge bug: (1) there's no explicit notion of STATE — "is this subscription trialing, active, or being dunned" has to be reverse-engineered from a pile of nullable columns; (2) a failed charge has nowhere to go — either it's silently ignored (customer keeps the product for free) or it's retried with no schedule, no cap, and no record of how many times; (3) "advance the date" and "confirm the charge" are two separate writes with a gap between them, which is precisely where the double-charge bug lives.
First iteration: introduce an explicit state enum — Trialing, Active, PastDue, Paused, Canceled, Expired — and make every mutation a NAMED transition function (renew(), pause(), cancel()) instead of a raw field write. This buys you a system you can reason about ("what states can reach PastDue? only Active or Trialing, on a failed charge") but it still doesn't fix the double-charge bug, because the bug isn't about which states exist — it's about WHEN the "have I already billed this cycle" fact gets durably recorded relative to the external charge call.
The idempotency insight (this is the exact mechanism Design an Idempotent Payment API builds in isolation — here you apply it to a recurring job): give every billing ATTEMPT a natural, deterministic key derived from facts that don't change across a retry — subscriptionId + currentPeriodEnd + failedAttempts. The period being closed and the attempt number ARE the operation's identity; a random request UUID would be a different key on every retry and would protect nothing. Route every charge THROUGH that key, and enforce the dedup at the payment gateway itself (mirroring how Stripe's real idempotency keys work), not just in your own bookkeeping — because the ambiguous case is specifically "did the external charge happen or not," and only the gateway that received the original call can answer that with certainty. A retry that reuses the same key gets the ORIGINAL outcome replayed, with no new money moving. A genuinely NEW attempt (a different failedAttempts value, because a real failure already occurred and was recorded) gets a fresh key and is billed for real — which is exactly the distinction dunning retries need: a REDELIVERY of the same attempt must not double-charge, but a NEW retry attempt after a real, recorded failure must still be free to try the card again.
The proration insight: once a plan change happens mid-cycle, you owe the customer a credit for the OLD plan's unused remainder and owe yourself a charge for the NEW plan's same remainder, both measured over the identical daysRemaining / totalDays window. The single arithmetic rule that keeps this exact in integer cents is: multiply before you divide, and truncate only once, at the very end — amountCents * daysRemaining / totalDays. Any version that computes a "per-day rate" first (amountCents / totalDays) truncates that rate before it's ever multiplied, and the error compounds across every remaining day instead of being absorbed once. It is the exact same class of bug as adding percentages before rounding versus rounding after — order of operations around a truncation point is never free.
The crux, and the reason this kata exists: neither insight is optional, and they interact. A billing run that's atomic-and-idempotent but prorates with the wrong operation order still leaks money on every upgrade. A billing run with perfect proration math but no idempotency key still double-charges the instant a scheduler redelivers a task. Movement 4 builds both, side by side with their naive twins, so you can see each failure in isolation before trusting the combined system.
4. Build it — milestones
Attempt-first: the contract is BillingEngine.renewIfDue(Subscription, today) → invoiceId|null (bills exactly once per due cycle, advances the state machine, or is a safe no-op if not due), Proration.changePlan(Subscription, newPlan, today) → netCents (an immediate, prorated true-up on a mid-cycle plan change), and pause() / resume() / cancel() (guarded state transitions). Try each milestone before reading the reference implementation below.
- M1 — Trial converts to a paid cycle. A subscription starts
Trialing; nothing is charged until the trial ends; the first successful charge at trial end moves it toActiveand opens a normal-length billing cycle. - M2 — Correct mid-cycle proration. An upgrade at day 24 of a 14…44 cycle (20 of 30 days remaining) produces an exact, hand-verifiable net true-up in cents — and does NOT reset the cycle's anchor date.
- M3 — Dunning: fail, retry, recover or exhaust. A failed charge moves
Active→PastDue; a later successful retry (within the schedule) recovers toActive; exhausting every scheduled retry moves toCanceled. - M4 — Idempotent renewal under crash-and-retry. Simulate a crash immediately after the external charge succeeds but before anything is recorded locally, then retry: exactly one charge must land, and the period must advance exactly once.
- M5 — Guarded pause/resume/cancel, and a fixed-term Expired path. Illegal transitions (pausing a non-Active subscription, cancelling an already-terminal one) are rejected; a non-auto-renewing subscription reaching its term end
Expiresinstead of being charged.
Reference implementation — Java
Six small types — SubState, Plan, Subscription, FakePaymentGateway, Proration, and the BillingEngine itself — plus one design choice worth reading twice: the idempotency key is subscriptionId + ":" + currentPeriodEnd + ":" + failedAttempts, threaded all the way down to the (fake) payment gateway's OWN dedup check, not just kept in local bookkeeping. BillingEngine also ships an UNSAFE twin (renewNaive / Proration.prorateNaive) purely so the break-it section below can reproduce both Movement 1 bugs on the exact same classes — never call either from real code.
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/** The states a subscription can be in. */
enum SubState { TRIALING, ACTIVE, PAST_DUE, PAUSED, CANCELED, EXPIRED }
final class Plan {
final String id;
final String name;
final long priceCents;
final int cycleDays; // length of one billing cycle, in days
Plan(String id, String name, long priceCents, int cycleDays) {
this.id = id; this.name = name; this.priceCents = priceCents; this.cycleDays = cycleDays;
}
}
/**
* A subscription. The cycle is the half-open day interval
* [currentPeriodStart, currentPeriodEnd) -- currentPeriodEnd is the day the
* NEXT charge is due. The very first period runs trialDays long and is never
* charged (state stays TRIALING); every period after that is a normal,
* charged cycleDays-long period.
*/
final class Subscription {
final String id;
Plan plan;
SubState state;
int currentPeriodStart; // epoch day, inclusive
int currentPeriodEnd; // epoch day, exclusive -- the next due date
int failedAttempts = 0;
final boolean autoRenew;
final int termEndDay; // Integer.MAX_VALUE if there's no fixed term
Subscription(String id, Plan plan, int startDay, int trialDays, boolean autoRenew, int termEndDay) {
this.id = id; this.plan = plan; this.state = SubState.TRIALING;
this.currentPeriodStart = startDay;
this.currentPeriodEnd = startDay + trialDays;
this.autoRenew = autoRenew; this.termEndDay = termEndDay;
}
}
/**
* A fake payment gateway. charge() mirrors a REAL gateway's own idempotency
* guarantee (see the idempotent-payment-api kata): the same idempotencyKey
* is only ever captured once, no matter how many times charge() is called
* with it -- that guarantee lives at the gateway, independent of whatever
* bookkeeping the caller does. chargeNoKey() is the unsafe twin with no such
* protection, kept ONLY for the break-it demo.
*/
final class FakePaymentGateway {
private final Deque<Boolean> scriptedOutcomes = new ArrayDeque<>(); // true = succeed
private final Map<String, Boolean> seenKeys = new HashMap<>(); // idempotencyKey -> outcome, gateway-side
final List<Long> charged = new ArrayList<>(); // amounts actually captured
void willReturn(boolean... outcomes) { for (boolean o : outcomes) scriptedOutcomes.addLast(o); }
private boolean nextOutcome() { return scriptedOutcomes.isEmpty() ? true : scriptedOutcomes.pollFirst(); }
boolean charge(String idempotencyKey, long amountCents) {
Boolean prior = seenKeys.get(idempotencyKey);
if (prior != null) return prior; // seen this exact attempt before -- replay the ORIGINAL outcome
boolean ok = nextOutcome();
seenKeys.put(idempotencyKey, ok);
if (ok) charged.add(amountCents);
return ok;
}
/** UNSAFE twin, for the break-it demo ONLY: no idempotency key, no dedup. */
boolean chargeNoKey(long amountCents) {
boolean ok = nextOutcome();
if (ok) charged.add(amountCents);
return ok;
}
}
/** Proration math: derive the day-count arithmetic in integer cents, no floats anywhere. */
final class Proration {
/** CORRECT: multiply THEN divide -- one integer truncation, applied once, at the end. */
static long prorate(long amountCents, int days, int totalDays) {
return amountCents * days / totalDays;
}
/**
* BUGGY twin for the break-it demo: computes a per-day rate by dividing
* FIRST, truncating it, and only then multiplies by the day count. Never
* call this from real billing code -- it silently drifts from prorate().
*/
static long prorateNaive(long amountCents, int days, int totalDays) {
long perDay = amountCents / totalDays; // truncated BEFORE multiplying -- the bug
return perDay * days;
}
/**
* Mid-cycle plan change: credit the unused remainder of the OLD plan and
* charge the same remainder of the NEW plan, both prorated over the days
* left in the CURRENT cycle. Returns the net cents to charge (positive)
* or refund (negative) as one true-up invoice line. The cycle boundaries
* are NOT reset -- the next full-price invoice still lands on the
* original anchor date (currentPeriodEnd is untouched here).
*/
static long changePlan(Subscription sub, Plan newPlan, int today) {
int daysRemaining = sub.currentPeriodEnd - today; // today is unused, so it counts as remaining
int totalDays = sub.currentPeriodEnd - sub.currentPeriodStart;
long credit = Proration.prorate(sub.plan.priceCents, daysRemaining, totalDays);
long charge = Proration.prorate(newPlan.priceCents, daysRemaining, totalDays);
sub.plan = newPlan;
return charge - credit;
}
}
/**
* Bills subscriptions and drives the state machine:
* TRIALING/PAST_DUE --success--> ACTIVE --failure--> PAST_DUE --retries exhausted--> CANCELED.
* ACTIVE <-> PAUSED via pause()/resume(). Any non-terminal state -> CANCELED via cancel().
* A non-autoRenew subscription reaching its termEndDay -> EXPIRED instead of being charged.
*/
final class BillingEngine {
private final FakePaymentGateway gateway;
private final Map<String, String> invoicesByKey = new HashMap<>(); // idempotencyKey -> invoiceId
private final int[] retryScheduleDays; // e.g. {1,3,5,7} days after 1st failure
private long invoiceSeq = 0;
BillingEngine(FakePaymentGateway gateway, int[] retryScheduleDays) {
this.gateway = gateway; this.retryScheduleDays = retryScheduleDays;
}
/** The normal entry point: bill this subscription if (and only if) it's due today. */
String renewIfDue(Subscription sub, int today) {
return renewIfDue(sub, today, false);
}
/**
* IDEMPOTENT: the key is (subscriptionId, periodEnd, failedAttempts) --
* one distinct key per real ATTEMPT, so a genuine dunning retry (a new
* failedAttempts value) is billed fresh, while a REDELIVERY of the exact
* same attempt (e.g. a worker crashes right after the gateway call
* returns, before anything local was recorded, and is retried) reuses
* the identical key and the gateway itself refuses to charge twice.
* simulateCrashAfterGatewayCall exists ONLY for the break-it demo: it
* throws right after the external charge, before ANY local state
* (invoicesByKey, period, failedAttempts) is touched -- modeling a crash
* between "the card was charged" and "we recorded that fact."
*/
String renewIfDue(Subscription sub, int today, boolean simulateCrashAfterGatewayCall) {
if (sub.state == SubState.CANCELED || sub.state == SubState.PAUSED || sub.state == SubState.EXPIRED)
return null;
if (today < sub.currentPeriodEnd) return null; // not due yet
if (!sub.autoRenew && sub.currentPeriodEnd >= sub.termEndDay) {
sub.state = SubState.EXPIRED;
return null;
}
String key = sub.id + ":" + sub.currentPeriodEnd + ":" + sub.failedAttempts;
boolean ok = gateway.charge(key, sub.plan.priceCents); // gateway itself dedupes on `key`
if (simulateCrashAfterGatewayCall)
throw new RuntimeException("simulated crash after the charge, before it was locally recorded");
String invoiceId = invoicesByKey.get(key);
if (invoiceId == null) {
invoiceId = "inv-" + (++invoiceSeq);
invoicesByKey.put(key, invoiceId);
}
if (ok) {
sub.currentPeriodStart = sub.currentPeriodEnd;
sub.currentPeriodEnd = sub.currentPeriodStart + sub.plan.cycleDays;
sub.failedAttempts = 0;
sub.state = SubState.ACTIVE;
} else {
sub.failedAttempts++;
sub.state = (sub.failedAttempts > retryScheduleDays.length) ? SubState.CANCELED : SubState.PAST_DUE;
}
return invoiceId;
}
void pause(Subscription sub) {
if (sub.state != SubState.ACTIVE)
throw new IllegalStateException("can only pause an ACTIVE subscription, was " + sub.state);
sub.state = SubState.PAUSED;
}
void resume(Subscription sub) {
if (sub.state != SubState.PAUSED)
throw new IllegalStateException("can only resume a PAUSED subscription, was " + sub.state);
sub.state = SubState.ACTIVE;
}
void cancel(Subscription sub) {
if (sub.state == SubState.CANCELED || sub.state == SubState.EXPIRED)
throw new IllegalStateException("already terminal: " + sub.state);
sub.state = SubState.CANCELED;
}
/* ---- UNSAFE twin, for the break-it demo ONLY -- never call from real code ---- */
/**
* Charges through chargeNoKey() -- NO idempotency key reaches the
* gateway at all. simulateCrashAfterGatewayCall models the identical
* crash point as renewIfDue's (right after the external charge, before
* anything local is touched) so the two paths are an apples-to-apples
* comparison of the SAME failure landing in a system with, and without,
* an idempotency key on the gateway call.
*/
void renewNaive(Subscription sub, int today, boolean simulateCrashAfterGatewayCall) {
if (today < sub.currentPeriodEnd) return;
gateway.chargeNoKey(sub.plan.priceCents); // no dedup key -- the bug
if (simulateCrashAfterGatewayCall)
throw new RuntimeException("simulated crash after the charge, before the period-advance was persisted");
sub.currentPeriodStart = sub.currentPeriodEnd;
sub.currentPeriodEnd = sub.currentPeriodStart + sub.plan.cycleDays;
sub.state = SubState.ACTIVE;
}
}
Reference implementation — Go
Same shape: SubState, Plan, Subscription, FakePaymentGateway, package-level Prorate/ProrateNaive/ChangePlan, and a BillingEngine. Save as billing.go, package billing, module name billing (a go.mod with module billing / go 1.21 is enough to build everything in this kata).
// Package billing is the reference implementation for the "Design a
// Subscription / Recurring Billing System" fintech kata: a subscription
// state machine, integer-cents proration on mid-cycle plan changes, and
// idempotent billing that survives a crash-and-retry without double-charging.
package billing
import "fmt"
// SubState is one of the states a subscription can be in.
type SubState int
const (
Trialing SubState = iota
Active
PastDue
Paused
Canceled
Expired
)
func (s SubState) String() string {
switch s {
case Trialing:
return "TRIALING"
case Active:
return "ACTIVE"
case PastDue:
return "PAST_DUE"
case Paused:
return "PAUSED"
case Canceled:
return "CANCELED"
case Expired:
return "EXPIRED"
}
return "UNKNOWN"
}
// Plan is a priced offering: PriceCents per CycleDays-long billing cycle.
type Plan struct {
ID string
Name string
PriceCents int64
CycleDays int
}
// Subscription's cycle is the half-open day interval
// [CurrentPeriodStart, CurrentPeriodEnd) -- CurrentPeriodEnd is the day the
// NEXT charge is due. The very first period runs TrialDays long and is never
// charged (State stays Trialing); every period after that is a normal,
// charged CycleDays-long period.
type Subscription struct {
ID string
Plan Plan
State SubState
CurrentPeriodStart int // epoch day, inclusive
CurrentPeriodEnd int // epoch day, exclusive -- the next due date
FailedAttempts int
AutoRenew bool
TermEndDay int // NoFixedTerm sentinel if there's no fixed term
}
// NewSubscription starts a subscription TRIALING for trialDays.
func NewSubscription(id string, plan Plan, startDay, trialDays int, autoRenew bool, termEndDay int) *Subscription {
return &Subscription{
ID: id, Plan: plan, State: Trialing,
CurrentPeriodStart: startDay,
CurrentPeriodEnd: startDay + trialDays,
AutoRenew: autoRenew,
TermEndDay: termEndDay,
}
}
// NoFixedTerm is the TermEndDay sentinel meaning "never expires on its own."
const NoFixedTerm = int(^uint(0) >> 1) // math.MaxInt
// FakePaymentGateway mirrors a REAL gateway's own idempotency guarantee (see
// the idempotent-payment-api kata): the same idempotencyKey is only ever
// captured once, no matter how many times Charge is called with it -- that
// guarantee lives at the gateway, independent of whatever bookkeeping the
// caller does. ChargeNoKey is the unsafe twin with no such protection, kept
// ONLY for the break-it demo.
type FakePaymentGateway struct {
scripted []bool // scripted outcomes, consumed in order; true = succeed
seen map[string]bool
Charged []int64 // amounts actually captured
}
func NewFakePaymentGateway() *FakePaymentGateway {
return &FakePaymentGateway{seen: make(map[string]bool)}
}
// WillReturn scripts the next len(outcomes) charge attempts' results.
func (g *FakePaymentGateway) WillReturn(outcomes ...bool) {
g.scripted = append(g.scripted, outcomes...)
}
func (g *FakePaymentGateway) nextOutcome() bool {
if len(g.scripted) == 0 {
return true
}
ok := g.scripted[0]
g.scripted = g.scripted[1:]
return ok
}
// Charge is idempotent on idempotencyKey: a repeat call with the same key
// replays the ORIGINAL outcome instead of charging again.
func (g *FakePaymentGateway) Charge(idempotencyKey string, amountCents int64) bool {
if ok, seen := g.seen[idempotencyKey]; seen {
return ok
}
ok := g.nextOutcome()
g.seen[idempotencyKey] = ok
if ok {
g.Charged = append(g.Charged, amountCents)
}
return ok
}
// ChargeNoKey is the UNSAFE twin, for the break-it demo ONLY: no
// idempotency key, no dedup -- every call charges independently.
func (g *FakePaymentGateway) ChargeNoKey(amountCents int64) bool {
ok := g.nextOutcome()
if ok {
g.Charged = append(g.Charged, amountCents)
}
return ok
}
// Prorate is the CORRECT proration formula: multiply THEN divide -- one
// integer truncation, applied once, at the end.
func Prorate(amountCents int64, days, totalDays int) int64 {
return amountCents * int64(days) / int64(totalDays)
}
// ProrateNaive is the BUGGY twin for the break-it demo: it computes a
// per-day rate by dividing FIRST, truncating it, and only then multiplies
// by the day count. Never call this from real billing code -- it silently
// drifts from Prorate.
func ProrateNaive(amountCents int64, days, totalDays int) int64 {
perDay := amountCents / int64(totalDays) // truncated BEFORE multiplying -- the bug
return perDay * int64(days)
}
// ChangePlan is a mid-cycle plan change: credit the unused remainder of the
// OLD plan and charge the same remainder of the NEW plan, both prorated
// over the days left in the CURRENT cycle. Returns the net cents to charge
// (positive) or refund (negative) as one true-up invoice line. The cycle
// boundaries are NOT reset -- the next full-price invoice still lands on
// the original anchor date (CurrentPeriodEnd is untouched here).
func ChangePlan(sub *Subscription, newPlan Plan, today int) int64 {
daysRemaining := sub.CurrentPeriodEnd - today // today is unused, so it counts as remaining
totalDays := sub.CurrentPeriodEnd - sub.CurrentPeriodStart
credit := Prorate(sub.Plan.PriceCents, daysRemaining, totalDays)
charge := Prorate(newPlan.PriceCents, daysRemaining, totalDays)
sub.Plan = newPlan
return charge - credit
}
// BillingEngine bills subscriptions and drives the state machine:
// Trialing/PastDue --success--> Active --failure--> PastDue --retries
// exhausted--> Canceled. Active <-> Paused via Pause()/Resume(). Any
// non-terminal state -> Canceled via Cancel(). A non-AutoRenew
// subscription reaching its TermEndDay -> Expired instead of being charged.
type BillingEngine struct {
gateway *FakePaymentGateway
invoicesByKey map[string]string
retryScheduleLen int
invoiceSeq int64
}
func NewBillingEngine(gateway *FakePaymentGateway, retryScheduleDays []int) *BillingEngine {
return &BillingEngine{
gateway: gateway,
invoicesByKey: make(map[string]string),
retryScheduleLen: len(retryScheduleDays),
}
}
// RenewIfDue is the normal entry point: bill sub if (and only if) it's due today.
func (e *BillingEngine) RenewIfDue(sub *Subscription, today int) string {
inv, _ := e.renewIfDue(sub, today, false)
return inv
}
// RenewIfDueOrCrash is IDEMPOTENT: the key is (subscription id, PeriodEnd,
// FailedAttempts) -- one distinct key per real ATTEMPT, so a genuine
// dunning retry (a new FailedAttempts value) is billed fresh, while a
// REDELIVERY of the exact same attempt (e.g. a worker crashes right after
// the gateway call returns, before anything local was recorded, and is
// retried) reuses the identical key and the gateway itself refuses to
// charge twice. simulateCrashAfterGatewayCall exists ONLY for the
// break-it demo: it returns an error right after the external charge,
// before ANY local state (invoicesByKey, period, FailedAttempts) is
// touched -- modeling a crash between "the card was charged" and "we
// recorded that fact."
func (e *BillingEngine) RenewIfDueOrCrash(sub *Subscription, today int, simulateCrashAfterGatewayCall bool) (string, error) {
return e.renewIfDue(sub, today, simulateCrashAfterGatewayCall)
}
func (e *BillingEngine) renewIfDue(sub *Subscription, today int, simulateCrashAfterGatewayCall bool) (string, error) {
if sub.State == Canceled || sub.State == Paused || sub.State == Expired {
return "", nil
}
if today < sub.CurrentPeriodEnd {
return "", nil // not due yet
}
if !sub.AutoRenew && sub.CurrentPeriodEnd >= sub.TermEndDay {
sub.State = Expired
return "", nil
}
key := fmt.Sprintf("%s:%d:%d", sub.ID, sub.CurrentPeriodEnd, sub.FailedAttempts)
ok := e.gateway.Charge(key, sub.Plan.PriceCents) // gateway itself dedupes on `key`
if simulateCrashAfterGatewayCall {
return "", fmt.Errorf("simulated crash after the charge, before it was locally recorded")
}
invoiceID, exists := e.invoicesByKey[key]
if !exists {
e.invoiceSeq++
invoiceID = fmt.Sprintf("inv-%d", e.invoiceSeq)
e.invoicesByKey[key] = invoiceID
}
if ok {
sub.CurrentPeriodStart = sub.CurrentPeriodEnd
sub.CurrentPeriodEnd = sub.CurrentPeriodStart + sub.Plan.CycleDays
sub.FailedAttempts = 0
sub.State = Active
} else {
sub.FailedAttempts++
if sub.FailedAttempts > e.retryScheduleLen {
sub.State = Canceled
} else {
sub.State = PastDue
}
}
return invoiceID, nil
}
// Pause moves an ACTIVE subscription to PAUSED.
func (e *BillingEngine) Pause(sub *Subscription) error {
if sub.State != Active {
return fmt.Errorf("can only pause an ACTIVE subscription, was %s", sub.State)
}
sub.State = Paused
return nil
}
// Resume moves a PAUSED subscription back to ACTIVE.
func (e *BillingEngine) Resume(sub *Subscription) error {
if sub.State != Paused {
return fmt.Errorf("can only resume a PAUSED subscription, was %s", sub.State)
}
sub.State = Active
return nil
}
// Cancel moves any non-terminal subscription to CANCELED.
func (e *BillingEngine) Cancel(sub *Subscription) error {
if sub.State == Canceled || sub.State == Expired {
return fmt.Errorf("already terminal: %s", sub.State)
}
sub.State = Canceled
return nil
}
/* ---- UNSAFE twin, for the break-it demo ONLY -- never call from real code ---- */
// RenewNaive charges through ChargeNoKey -- NO idempotency key reaches the
// gateway at all. simulateCrashAfterGatewayCall models the identical crash
// point as RenewIfDueOrCrash's (right after the external charge, before
// anything local is touched) so the two paths are an apples-to-apples
// comparison of the SAME failure landing in a system with, and without, an
// idempotency key on the gateway call.
func (e *BillingEngine) RenewNaive(sub *Subscription, today int, simulateCrashAfterGatewayCall bool) error {
if today < sub.CurrentPeriodEnd {
return nil
}
e.gateway.ChargeNoKey(sub.Plan.PriceCents) // no dedup key -- the bug
if simulateCrashAfterGatewayCall {
return fmt.Errorf("simulated crash after the charge, before the period-advance was persisted")
}
sub.CurrentPeriodStart = sub.CurrentPeriodEnd
sub.CurrentPeriodEnd = sub.CurrentPeriodStart + sub.Plan.CycleDays
sub.State = Active
return nil
}
5. Break it
The reference impl ships two independent naive twins on the exact same types: Proration.prorateNaive (divides before multiplying) and BillingEngine.renewNaive (charges with no idempotency key). Both reproduce a Movement 1 bug concretely, with the SAME crash point modeled on both the safe and unsafe billing paths so the comparison is apples-to-apples.
(a) The proration drift. A $49.00 (4900-cent) plan, 20 of 30 days remaining after a mid-cycle upgrade:
long correctCredit = Proration.prorate(4900, 20, 30); // 4900*20/30 = 98000/30 = 3266 (one truncation, at the end)
long naiveCredit = Proration.prorateNaive(4900, 20, 30); // 4900/30=163 (truncated FIRST) * 20 = 3260
// correctCredit - naiveCredit == 6 -- a 6-cent drift, always in the same direction
Measured in this session: correct credit=3266 naive credit=3260, identically in Java and Go. Carried through changePlan's full net true-up (crediting the old $49 plan, charging the new $99 plan over the same 20 days), the naive net comes out 6 cents higher than the correct one — naiveNet - correctNet == 6, measured on every run. The direction never flips: the naive formula always truncates the daily rate down before multiplying, so it always under-credits the customer, which always shows up as an overcharge on the true-up invoice.
(b) The idempotency double-charge. Both the safe (renewIfDue) and unsafe (renewNaive) paths get the identical treatment: charge once, simulate a crash immediately after the gateway call returns (before anything is recorded locally), then retry as if the job recovered and reran:
public final class BillingTests {
static int pass = 0, fail = 0;
static void check(String name, boolean cond) {
if (cond) { pass++; System.out.println("PASS: " + name); }
else { fail++; System.out.println("FAIL: " + name); }
}
public static void main(String[] args) {
Plan pro = new Plan("pro", "Pro", 4900, 30); // $49.00 / 30-day cycle
Plan premium = new Plan("premium", "Premium", 9900, 30); // $99.00 / 30-day cycle
// --- M1: trial converts to a paid, ACTIVE cycle on the first successful charge ---
{
FakePaymentGateway gw = new FakePaymentGateway();
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-1", pro, 0, 14, true, Integer.MAX_VALUE);
check("M1: starts TRIALING", sub.state == SubState.TRIALING);
check("M1: not due mid-trial (day 5)", engine.renewIfDue(sub, 5) == null);
check("M1: nothing charged during trial", gw.charged.isEmpty());
String inv = engine.renewIfDue(sub, 14); // trial ends -- first real charge
check("M1: first charge fires exactly at trial end", inv != null);
check("M1: trial converts to ACTIVE", sub.state == SubState.ACTIVE);
check("M1: exactly one charge of the full plan price", gw.charged.equals(java.util.Arrays.asList(4900L)));
check("M1: next period is 30 days out (14..44)", sub.currentPeriodStart == 14 && sub.currentPeriodEnd == 44);
}
// --- M2: mid-cycle upgrade is a correctly prorated true-up, cycle boundary untouched ---
{
FakePaymentGateway gw = new FakePaymentGateway();
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-2", pro, 0, 14, true, Integer.MAX_VALUE);
engine.renewIfDue(sub, 14); // now ACTIVE, period 14..44 (30 days)
long net = Proration.changePlan(sub, premium, 24); // upgrade at day 24: 20 days remain of 30
check("M2: 20 days remain in a 30-day cycle prorates the OLD plan credit to 3266",
Proration.prorate(4900, 20, 30) == 3266);
check("M2: prorates the NEW plan charge to 6600 over the same 20 days",
Proration.prorate(9900, 20, 30) == 6600);
check("M2: net true-up invoice is 6600 - 3266 = 3334 cents", net == 3334);
check("M2: plan is swapped immediately", sub.plan.id.equals("premium"));
check("M2: cycle boundary is NOT reset by a plan change", sub.currentPeriodStart == 14 && sub.currentPeriodEnd == 44);
}
// --- M3: dunning -- failures move to PAST_DUE, a later success recovers to ACTIVE ---
{
FakePaymentGateway gw = new FakePaymentGateway();
gw.willReturn(false, false, true); // fails twice, then succeeds on the 3rd attempt
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-3", pro, 0, 0, true, Integer.MAX_VALUE);
engine.renewIfDue(sub, 0); // 1st attempt: fails
check("M3: 1st failed charge -> PAST_DUE", sub.state == SubState.PAST_DUE);
check("M3: failedAttempts is 1", sub.failedAttempts == 1);
engine.renewIfDue(sub, 1); // retry per schedule: fails again
check("M3: 2nd failed charge -> still PAST_DUE (retries remain)", sub.state == SubState.PAST_DUE);
check("M3: failedAttempts is 2", sub.failedAttempts == 2);
engine.renewIfDue(sub, 3); // retry succeeds -> recovers
check("M3: retry succeeds -> back to ACTIVE", sub.state == SubState.ACTIVE);
check("M3: failedAttempts reset to 0 on recovery", sub.failedAttempts == 0);
}
// --- M3b: dunning exhausted -- every retry in the schedule fails -> CANCELED ---
{
FakePaymentGateway gw = new FakePaymentGateway();
gw.willReturn(false, false, false, false, false); // fails every single time
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7}); // 4 retries after the 1st failure
Subscription sub = new Subscription("sub-3b", pro, 0, 0, true, Integer.MAX_VALUE);
engine.renewIfDue(sub, 0); // attempt 1 (fail) -> PAST_DUE, failedAttempts=1
engine.renewIfDue(sub, 1); // attempt 2 (fail) -> failedAttempts=2
engine.renewIfDue(sub, 3); // attempt 3 (fail) -> failedAttempts=3
engine.renewIfDue(sub, 5); // attempt 4 (fail) -> failedAttempts=4
check("M3b: still PAST_DUE with 4 failures and 4 scheduled retries", sub.state == SubState.PAST_DUE);
engine.renewIfDue(sub, 7); // attempt 5 (fail) -> exceeds retryScheduleDays.length (4) -> CANCELED
check("M3b: 5th failure exceeds the retry schedule -> CANCELED (dunning exhausted)",
sub.state == SubState.CANCELED);
}
// --- M4: idempotent renewal survives a crash-and-retry with NO double charge ---
{
FakePaymentGateway gw = new FakePaymentGateway();
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-4", pro, 0, 0, true, Integer.MAX_VALUE);
boolean threw = false;
try { engine.renewIfDue(sub, 0, true); } // charges, then simulates a crash before anything local commits
catch (RuntimeException e) { threw = true; }
check("M4: the simulated crash actually threw", threw);
check("M4: nothing local committed yet -- still TRIALING", sub.state == SubState.TRIALING);
check("M4: exactly one charge landed from the crashed attempt", gw.charged.size() == 1);
String inv = engine.renewIfDue(sub, 0); // retry, after "recovering" from the crash
check("M4: the retry completes and returns an invoice", inv != null);
check("M4: the retry did NOT charge again -- gateway deduped on the idempotency key", gw.charged.size() == 1);
check("M4: the period advanced exactly once", sub.currentPeriodStart == 0 && sub.currentPeriodEnd == 30);
}
// --- M5: pause/resume/cancel are guarded transitions; a paused sub is never billed ---
{
FakePaymentGateway gw = new FakePaymentGateway();
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-5", pro, 0, 0, true, Integer.MAX_VALUE);
engine.renewIfDue(sub, 0); // -> ACTIVE
engine.pause(sub);
check("M5: pause() moves ACTIVE -> PAUSED", sub.state == SubState.PAUSED);
check("M5: a paused sub is never billed even when due", engine.renewIfDue(sub, 30) == null);
check("M5: no charge landed while paused", gw.charged.size() == 1); // still just the M5-opening charge
boolean threw = false;
try { engine.pause(sub); } catch (IllegalStateException e) { threw = true; }
check("M5: pausing a non-ACTIVE sub is rejected", threw);
engine.resume(sub);
check("M5: resume() moves PAUSED -> ACTIVE", sub.state == SubState.ACTIVE);
engine.cancel(sub);
check("M5: cancel() moves ACTIVE -> CANCELED", sub.state == SubState.CANCELED);
threw = false;
try { engine.cancel(sub); } catch (IllegalStateException e) { threw = true; }
check("M5: cancelling an already-terminal sub is rejected", threw);
}
// --- M5b: a fixed-term, non-auto-renewing subscription EXPIRES instead of being charged ---
{
FakePaymentGateway gw = new FakePaymentGateway();
BillingEngine engine = new BillingEngine(gw, new int[]{1, 3, 5, 7});
Subscription sub = new Subscription("sub-5b", pro, 0, 0, false, 30); // 1 term, no auto-renew
engine.renewIfDue(sub, 0); // 1st (and only) paid cycle -> ACTIVE
check("M5b: first cycle still charges normally", gw.charged.size() == 1);
engine.renewIfDue(sub, 30); // due again, but termEndDay(30) has arrived
check("M5b: reaching termEndDay with autoRenew=false -> EXPIRED, not charged", sub.state == SubState.EXPIRED);
check("M5b: no second charge landed", gw.charged.size() == 1);
}
// --- Break it (a): naive divide-before-multiply proration drifts from the correct math ---
{
long correctCredit = Proration.prorate(4900, 20, 30);
long naiveCredit = Proration.prorateNaive(4900, 20, 30);
System.out.println("proration credit -- correct: " + correctCredit + " naive: " + naiveCredit);
check("BREAK-IT(a): naive divide-before-multiply drifts from the correct prorated credit",
naiveCredit != correctCredit);
check("BREAK-IT(a): the measured drift is exactly 6 cents on this $49 plan / 20-of-30 days",
correctCredit - naiveCredit == 6);
long correctCharge = Proration.prorate(9900, 20, 30);
long naiveCharge = Proration.prorateNaive(9900, 20, 30);
long correctNet = correctCharge - correctCredit;
long naiveNet = naiveCharge - naiveCredit;
System.out.println("net true-up -- correct: " + correctNet + " naive: " + naiveNet);
check("BREAK-IT(a): the naive net true-up overcharges the customer by 6 cents",
naiveNet - correctNet == 6);
}
// --- Break it (b): the SAME crash-and-retry, with vs without an idempotency key on the gateway call ---
{
FakePaymentGateway gwUnsafe = new FakePaymentGateway();
BillingEngine unsafeEngine = new BillingEngine(gwUnsafe, new int[]{1, 3, 5, 7});
Subscription unsafeSub = new Subscription("sub-unsafe", pro, 0, 0, true, Integer.MAX_VALUE);
boolean threw = false;
try { unsafeEngine.renewNaive(unsafeSub, 0, true); } // charges (no key), then simulates the same crash
catch (RuntimeException e) { threw = true; }
check("BREAK-IT(b): the simulated crash actually threw", threw);
check("BREAK-IT(b): one charge landed from the crashed attempt", gwUnsafe.charged.size() == 1);
unsafeEngine.renewNaive(unsafeSub, 0, false); // retry, after "recovering" from the crash
System.out.println("naive path charges after crash+retry: " + gwUnsafe.charged);
check("BREAK-IT(b): the retry charged AGAIN -- no key means the gateway can't recognize it",
gwUnsafe.charged.size() == 2);
check("BREAK-IT(b): the customer was billed $98.00 for a single $49.00 cycle",
gwUnsafe.charged.stream().mapToLong(Long::longValue).sum() == 9800L);
check("BREAK-IT(b): the period STILL only advanced once -- the bug is invisible in the subscription's own record",
unsafeSub.currentPeriodStart == 0 && unsafeSub.currentPeriodEnd == 30);
}
System.out.println();
System.out.println(pass + " passed, " + fail + " failed");
if (fail > 0) System.exit(1);
}
}
Measured in this session (javac SubscriptionBilling.java BillingTests.java && java BillingTests): 44/44 assertions pass. The proration drift measured exactly correct credit=3266 naive credit=3260 and correct net=3334 naive net=3340 every run. The crash+retry demo measured naive path charges after crash+retry: [4900, 4900] — a real $98.00 captured for one $49.00 cycle — while the identical crash+retry sequence through the idempotency-keyed path captured exactly one 4900-cent charge, on every run.
For Go, the equivalent tests live in billing_test.go next to billing.go above:
package billing
import "testing"
func freshPlans() (Plan, Plan) {
pro := Plan{ID: "pro", Name: "Pro", PriceCents: 4900, CycleDays: 30}
premium := Plan{ID: "premium", Name: "Premium", PriceCents: 9900, CycleDays: 30}
return pro, premium
}
func TestTrialConvertsToActiveOnFirstCharge(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-1", pro, 0, 14, true, NoFixedTerm)
if sub.State != Trialing {
t.Fatalf("want TRIALING, got %s", sub.State)
}
if inv := engine.RenewIfDue(sub, 5); inv != "" {
t.Fatalf("not due mid-trial, want no invoice, got %q", inv)
}
if len(gw.Charged) != 0 {
t.Fatalf("nothing should be charged during trial, got %v", gw.Charged)
}
inv := engine.RenewIfDue(sub, 14) // trial ends -- first real charge
if inv == "" {
t.Fatal("expected a charge exactly at trial end")
}
if sub.State != Active {
t.Fatalf("trial should convert to ACTIVE, got %s", sub.State)
}
if len(gw.Charged) != 1 || gw.Charged[0] != 4900 {
t.Fatalf("want exactly one 4900-cent charge, got %v", gw.Charged)
}
if sub.CurrentPeriodStart != 14 || sub.CurrentPeriodEnd != 44 {
t.Fatalf("want period 14..44, got %d..%d", sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
}
}
func TestMidCycleUpgradeIsCorrectlyProrated(t *testing.T) {
pro, premium := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-2", pro, 0, 14, true, NoFixedTerm)
engine.RenewIfDue(sub, 14) // now ACTIVE, period 14..44 (30 days)
net := ChangePlan(sub, premium, 24) // upgrade at day 24: 20 days remain of 30
if got := Prorate(4900, 20, 30); got != 3266 {
t.Errorf("old-plan credit = %d, want 3266", got)
}
if got := Prorate(9900, 20, 30); got != 6600 {
t.Errorf("new-plan charge = %d, want 6600", got)
}
if net != 3334 {
t.Errorf("net true-up = %d, want 3334", net)
}
if sub.Plan.ID != "premium" {
t.Errorf("plan not swapped: %s", sub.Plan.ID)
}
if sub.CurrentPeriodStart != 14 || sub.CurrentPeriodEnd != 44 {
t.Errorf("cycle boundary was reset by a plan change: %d..%d", sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
}
}
func TestDunningRecoversWithinRetrySchedule(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
gw.WillReturn(false, false, true) // fails twice, succeeds on the 3rd attempt
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-3", pro, 0, 0, true, NoFixedTerm)
engine.RenewIfDue(sub, 0)
if sub.State != PastDue || sub.FailedAttempts != 1 {
t.Fatalf("after 1st failure: state=%s attempts=%d, want PAST_DUE/1", sub.State, sub.FailedAttempts)
}
engine.RenewIfDue(sub, 1)
if sub.State != PastDue || sub.FailedAttempts != 2 {
t.Fatalf("after 2nd failure: state=%s attempts=%d, want PAST_DUE/2", sub.State, sub.FailedAttempts)
}
engine.RenewIfDue(sub, 3)
if sub.State != Active || sub.FailedAttempts != 0 {
t.Fatalf("after recovery: state=%s attempts=%d, want ACTIVE/0", sub.State, sub.FailedAttempts)
}
}
func TestDunningExhaustedCancels(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
gw.WillReturn(false, false, false, false, false) // fails every time
engine := NewBillingEngine(gw, []int{1, 3, 5, 7}) // 4 retries after the 1st failure
sub := NewSubscription("sub-3b", pro, 0, 0, true, NoFixedTerm)
engine.RenewIfDue(sub, 0)
engine.RenewIfDue(sub, 1)
engine.RenewIfDue(sub, 3)
engine.RenewIfDue(sub, 5)
if sub.State != PastDue {
t.Fatalf("after 4 failures with 4 scheduled retries: want PAST_DUE, got %s", sub.State)
}
engine.RenewIfDue(sub, 7) // 5th failure exceeds the retry schedule
if sub.State != Canceled {
t.Fatalf("dunning exhausted: want CANCELED, got %s", sub.State)
}
}
func TestIdempotentRenewalSurvivesCrashAndRetry(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-4", pro, 0, 0, true, NoFixedTerm)
_, err := engine.RenewIfDueOrCrash(sub, 0, true) // charges, then simulates a crash before anything local commits
if err == nil {
t.Fatal("expected the simulated crash to return an error")
}
if sub.State != Trialing {
t.Fatalf("nothing local should have committed yet, got state %s", sub.State)
}
if len(gw.Charged) != 1 {
t.Fatalf("want exactly one charge from the crashed attempt, got %v", gw.Charged)
}
inv := engine.RenewIfDue(sub, 0) // retry, after "recovering" from the crash
if inv == "" {
t.Fatal("expected the retry to complete and return an invoice")
}
if len(gw.Charged) != 1 {
t.Fatalf("the retry must NOT charge again, got %v", gw.Charged)
}
if sub.CurrentPeriodStart != 0 || sub.CurrentPeriodEnd != 30 {
t.Fatalf("period should advance exactly once, got %d..%d", sub.CurrentPeriodStart, sub.CurrentPeriodEnd)
}
}
func TestPauseResumeCancelAreGuardedTransitions(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-5", pro, 0, 0, true, NoFixedTerm)
engine.RenewIfDue(sub, 0) // -> ACTIVE
if err := engine.Pause(sub); err != nil || sub.State != Paused {
t.Fatalf("pause() should move ACTIVE -> PAUSED, got state=%s err=%v", sub.State, err)
}
if inv := engine.RenewIfDue(sub, 30); inv != "" {
t.Fatal("a paused subscription must never be billed even when due")
}
if len(gw.Charged) != 1 {
t.Fatalf("no charge should land while paused, got %v", gw.Charged)
}
if err := engine.Pause(sub); err == nil {
t.Fatal("pausing a non-ACTIVE subscription should be rejected")
}
if err := engine.Resume(sub); err != nil || sub.State != Active {
t.Fatalf("resume() should move PAUSED -> ACTIVE, got state=%s err=%v", sub.State, err)
}
if err := engine.Cancel(sub); err != nil || sub.State != Canceled {
t.Fatalf("cancel() should move ACTIVE -> CANCELED, got state=%s err=%v", sub.State, err)
}
if err := engine.Cancel(sub); err == nil {
t.Fatal("cancelling an already-terminal subscription should be rejected")
}
}
func TestFixedTermNonRenewingExpires(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-5b", pro, 0, 0, false, 30) // 1 term, no auto-renew
engine.RenewIfDue(sub, 0) // first (and only) paid cycle -> ACTIVE
if len(gw.Charged) != 1 {
t.Fatalf("first cycle should still charge normally, got %v", gw.Charged)
}
engine.RenewIfDue(sub, 30) // due again, but TermEndDay(30) has arrived
if sub.State != Expired {
t.Fatalf("reaching TermEndDay with AutoRenew=false should EXPIRE, got %s", sub.State)
}
if len(gw.Charged) != 1 {
t.Fatalf("no second charge should land, got %v", gw.Charged)
}
}
// TestBreakItProrationDrift is the break-it (a) demo: ProrateNaive divides
// before multiplying and drifts from the correct Prorate result.
func TestBreakItProrationDrift(t *testing.T) {
correctCredit := Prorate(4900, 20, 30)
naiveCredit := ProrateNaive(4900, 20, 30)
t.Logf("proration credit -- correct: %d naive: %d", correctCredit, naiveCredit)
if naiveCredit == correctCredit {
t.Fatal("BREAK-IT FAILED TO REPRODUCE: expected the naive formula to drift")
}
if correctCredit-naiveCredit != 6 {
t.Errorf("expected exactly a 6-cent drift on this $49/20-of-30-days case, got %d", correctCredit-naiveCredit)
}
}
// TestBreakItDoubleChargeWithoutIdempotencyKey is the break-it (b) demo: the
// SAME crash-and-retry sequence, once through RenewIfDueOrCrash (keyed) and
// once through RenewNaive (no key) -- only the unkeyed path double-charges.
func TestBreakItDoubleChargeWithoutIdempotencyKey(t *testing.T) {
pro, _ := freshPlans()
gw := NewFakePaymentGateway()
engine := NewBillingEngine(gw, []int{1, 3, 5, 7})
sub := NewSubscription("sub-unsafe", pro, 0, 0, true, NoFixedTerm)
err := engine.RenewNaive(sub, 0, true) // charges (no key), then simulates the crash
if err == nil {
t.Fatal("expected the simulated crash to return an error")
}
if len(gw.Charged) != 1 {
t.Fatalf("want one charge from the crashed attempt, got %v", gw.Charged)
}
if err := engine.RenewNaive(sub, 0, false); err != nil { // retry, after "recovering"
t.Fatal(err)
}
t.Logf("naive path charges after crash+retry: %v", gw.Charged)
if len(gw.Charged) != 2 {
t.Fatalf("BREAK-IT FAILED TO REPRODUCE: want a double charge (no key to recognize the retry), got %v", gw.Charged)
}
var total int64
for _, c := range gw.Charged {
total += c
}
if total != 9800 {
t.Errorf("customer should be billed 9800 cents ($98.00) for one $49.00 cycle, got %d", total)
}
}
Measured in this session: go build ./... and go vet ./... are clean; go test -v ./... passes all 9 tests, 0 failures, matching the Java numbers exactly — proration credit -- correct: 3266 naive: 3260, and naive path charges after crash+retry: [4900 4900]. Same bugs, same measured numbers, two languages.
6. Optimise — with trade-offs
| Decision | Option A | Option B | When A wins | When B wins |
|---|---|---|---|---|
| Billing cycle anchor | Anniversary billing (each subscription renews N days after ITS OWN start date, as built above) | Calendar billing (everyone on a plan renews on a fixed date, e.g. the 1st of the month) | Simpler day-count math (no "what does the 1st mean for a sub that started on the 31st" edge case), and spreads billing-job load evenly across the month instead of spiking on the 1st — the default for most SaaS products | The business NEEDS synchronized statements (e.g. enterprise invoicing tied to a calendar month) or wants predictable revenue recognition on fixed dates. Requires prorating the FIRST partial period at signup and handling variable month lengths (28–31 days) in every cycle-length calculation, not just at signup |
| Proration timing | Credit now (immediate true-up invoice at the moment of change, as built above) | Credit on next invoice (net the credit/charge into the customer's next regular bill instead of billing immediately) | The customer expects an immediate, itemized charge/refund reflecting the change right now — clearer at the moment of upgrade, and matches what most billing APIs (Stripe, Chargebee) do by default | Minimizing payment-gateway calls and avoiding a confusing tiny mid-month charge on the customer's statement; trades immediacy for fewer, larger invoices. Still needs the SAME multiply-then-divide math — it only changes WHEN the resulting cents are invoiced, not how they're computed |
| Dunning retry schedule | Fixed schedule (retry at +1, +3, +5, +7 days, as built above) | Exponential backoff (retry at +1, +2, +4, +8, ... days, or based on the decline reason code) | Predictable, easy to communicate to customers ("we'll retry your card in 3 days"), and easy to reason about in support tooling — fine when most declines are transient (insufficient funds that clears in a day or two) | You have decline-REASON data and want to react to it: retry an "insufficient funds" decline sooner (payday timing) but back off harder on a "card expired" or "stolen card" decline, where retrying at all just annoys the customer and risks a chargeback. Real payment processors (Stripe's Smart Retries) do exactly this — schedule from observed decline-reason data, not a fixed ladder |
| Lifecycle storage model | Explicit state-machine fields (a state enum + current period bounds, as built above) | Event-sourced (append every lifecycle event — TrialStarted, Charged, PlanChanged, Canceled — and derive current state by folding over the log) | Simplicity: the current state IS the row, trivial to query ("show me all PAST_DUE subs"), and enough for most products — you don't need to reconstruct history often | You need a full audit trail of every transition for support/compliance ("why did this subscription cancel on this exact date"), or you want to replay history to backfill a new derived field. This is the exact event-sourcing/CQRS trade-off from the double-entry ledger kata — a ledger's journal IS this pattern, applied to money instead of subscription state |
| How the "did the charge happen" ambiguity is resolved | Trust local state, retry unconditionally (the naive path built above) | Idempotency key threaded to the gateway itself (the safe path built above) | Never, for anything that moves real money — the measured break-it above shows exactly what this costs: a real double charge, invisible in the subscription's own record | Always. The gateway is the only party that can answer "did this specific attempt already happen" with certainty, because it's the one that actually talked to the card network. This is the identical mechanism Design an Idempotent Payment API builds standalone — here it's applied inside a recurring job instead of a one-off request |
7. Defend under drilling
- "How do you prorate a mid-cycle upgrade?" — Compute
daysRemainingin the current cycle (the due date minus today, inclusive of today since it's not yet used), then prorate BOTH plans over the SAMEdaysRemaining / totalDayswindow: credit the old plan's unused remainder, charge the new plan's remainder over that identical window, and invoice the net. The one rule that keeps it exact in integer cents: multiply the price by the day count FIRST, divide by total days SECOND — one truncation, at the end. The measured break-it shows what happens if you divide first instead: a 6-cent drift on a single proration, always biased the same direction, because a per-day rate computed by division loses precision before it's ever multiplied by anything. - "How do you avoid double-charging on a retried billing run?" — Give every billing ATTEMPT a deterministic key —
subscriptionId + currentPeriodEnd + failedAttempts— and enforce the dedup at the payment gateway itself, not just in your own bookkeeping. The measured break-it shows exactly why "just check your own database before charging" isn't enough on its own: the ambiguous case is specifically "did the external charge happen or not," and only the party that placed that external call (the gateway) can answer it. Without the key, the same crash-and-retry sequence captured $98.00 for a single $49.00 cycle, and — the genuinely dangerous part — the subscription's OWN state (its period dates) looked completely normal afterward. The bug was only visible in the payment records, not the billing records. - "What's your dunning policy?" — A bounded retry ladder (this kata: 4 retries at +1/+3/+5/+7 days), after which the subscription cancels rather than retrying forever. Every failure increments a counter and re-attempts with a FRESH idempotency key (a new
failedAttemptsvalue), so genuine retries actually try the card again, while a REDELIVERY of the same attempt (no new failure recorded) collides on the old key and is a no-op. A more mature system varies the schedule by decline reason instead of a flat ladder — see the trade-off table. - "How do you handle a plan price change for EXISTING subscribers?" — Never retroactively mutate
Plan.priceCentsfor a plan object subscribers already reference — that silently changes what every existing subscriber is charged next cycle with no record of why. Instead, version the plan (a newPlanrow/id at the new price) and decide a migration policy explicitly: grandfather existing subscribers on the OLD plan object indefinitely, or schedule a one-time "at your next renewal, you move to the new price" transition with advance notice. Either way it's a deliberate, visible operation on each subscription — never an in-place mutation of a price every existing customer implicitly points at. - "What breaks at 100× the subscriber volume?" — The nightly "scan every subscription for
currentPeriodEnd <= today" query becomes a full-table scan that doesn't finish before the next billing window opens. Fix: an index (or a separate priority queue / sorted set) keyed on the due date, so the job only touches subscriptions actually due today, and shard the scan across workers by subscription id or region. The idempotency key mechanism doesn't change AT ALL at this scale — it's exactly why keying on durable, deterministic facts (not a request UUID) matters: it stays correct whether one worker or a hundred parallel workers are racing to claim the same due subscriptions, as long as the KEY itself, and the gateway's dedup on it, are the actual source of truth.
8. You can now defend
- You can design a subscription's lifecycle as an explicit state machine (Trialing/Active/PastDue/Paused/Canceled/Expired) instead of ad-hoc field mutations, and name exactly which transitions are legal from which states.
- You can derive the integer-cents proration formula for a mid-cycle plan change from first principles, and you've personally reproduced, measured (a real 6-cent drift), and fixed the divide-before-multiply bug that causes it — in both Java and Go.
- You've implemented an idempotent billing run that survives a crash-and-retry without double-charging, by keying on (subscription, period, attempt) and enforcing the dedup at the payment gateway itself — and you've measured the exact double-charge ($98.00 for a $49.00 cycle) that happens without it.
- You can design and defend a dunning policy (bounded retry ladder vs exponential/reason-based backoff), and reason about what breaks first at 100× subscriber volume (the due-date scan, not the idempotency mechanism).
Re-authored/Deepened for this guide. Related theory: Designing a Payment System · What Are Idempotency Keys and How to Implement Them Safely for Payments. Sibling katas: Design an Idempotent Payment API · Design a Double-Entry Ledger. Reference implementations (Java + Go) compiled and tested in-session: javac clean, 44/44 assertions pass (including the deliberately-adversarial break-it assertions, which measured a 6-cent proration drift and a real $98.00-for-$49.00 double charge, identically reproduced); go build + go vet + go test -v clean, 9/9 tests pass, matching the Java numbers exactly.
🤖 Don't fully get this? Learn it with Claude
Stuck on Design a Subscription Billing System? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.
Build the mental picture, not memorization.
I just read a lesson on **Design a Subscription Billing System** (Hands-On Builds) and want to truly understand it. Explain Design a Subscription Billing System from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Socratic — adapts to where you're stuck.
Teach me **Design a Subscription Billing System** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Active recall exposes what you missed.
Quiz me on **Design a Subscription Billing System** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
Intuition + hook + flashcards for long-term memory.
Help me remember **Design a Subscription Billing System** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.