Knowledge Guide
HomeHands-On BuildsFintech Labs

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:

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 endamountCents * 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.

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

DecisionOption AOption BWhen A winsWhen B wins
Billing cycle anchorAnniversary 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 productsThe 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 timingCredit 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 defaultMinimizing 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 scheduleFixed 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 modelExplicit 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 oftenYou 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 resolvedTrust 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 recordAlways. 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

8. You can now defend


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.

🎨 Explain it visually

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.
🤔 Walk me through it (interactive)

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.
🧪 Quiz me & fix my gaps

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.
🧠 Make it stick

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.

📝 My notes