Why this matters: payment systems are where "mostly correct" means "wrong about money." The design that scores here is built from four load-bearing decisions — a state machine that admits ignorance, idempotency anchored to the right identity, a ledger that never edits, and a reconciler that resolves rather than guesses. Each one exists because of a specific requirement answer from lesson 1; this lesson shows the chain from answer to structure.
Start from the operations, not the nouns
createIntent(amount, currency, idemKey) -> PaymentIntent (no money moved)
confirm(intentId) -> triggers the gateway charge
status(intentId) -> where is this payment now?
refund(intentId, amount) -> full or partial, own lifecycle
reconcile() -> resolves UNKNOWNs — the loop
nobody puts on the board
The last operation is the one that separates strong candidates. Everyone lists create/confirm/status/refund; almost nobody writes reconcile unprompted. But once timeout-means-unknown is on the table, something must later find out what actually happened — and if it isn't in your operation list, your design has a state with no exit.
The state machine, with ignorance as a state
CREATED ──confirm──▶ CONFIRMING ──gateway says yes──▶ SUCCEEDED
│
├──gateway says no──▶ FAILED
│
└──timeout / crash──▶ UNKNOWN
│
reconciler asks gateway what happened
│
┌────────────┴───────────┐
▼ ▼
SUCCEEDED FAILED
UNKNOWN is the design's center of gravity. It is not an error, not a retry counter, not a log line — it is a state, with exactly one legal exit: reconciliation, where you ask the gateway what became of the charge and settle accordingly. No transition guesses. UNKNOWN → SUCCEEDED happens because the gateway confirmed the charge landed; UNKNOWN → FAILED because the gateway confirmed it didn't. Making the state machine the single authority on legal transitions — and rejecting illegal ones loudly — is the single-responsibility principle where it bites hardest: one type owns the truth about where a payment can go next.
Idempotency keys belong to the intent
Duplicates come from two directions — a double-clicking user and an auto-retrying client — and one mechanism handles both: a client-supplied idempotency key, attached to the intent. Two rules give it its power:
createIntent with a seen key -> return the EXISTING intent, create nothing confirm (any number of times) -> the SAME key rides every gateway call
The first rule makes create replay-safe: the double-click produces one intent, not two. The second is the deeper one — because the gateway deduplicates on the key, a retried charge call cannot bill twice, no matter how many times confirm is invoked or how many timeouts intervened. This is what "retries allowed, but only if provably safe" means: the proof is the key.
Say out loud why the key anchors to the intent and not the HTTP request: a request-scoped key changes on every retry, which is precisely when you need it to stay the same. The key's job is to make this payment unrepeatable, and the intent is this payment's identity.
The ledger is append-only, and separate from state
Finance said money history is never edited. That requirement splits your records in two, and keeping the halves apart is separation of concerns doing real work:
Payment state (mutable) "where is this payment NOW"
one row per intent, status overwritten
as transitions fire
Ledger (append-only) "what has EVER happened"
CHARGE_SUCCEEDED intent-42 +50.00 EUR
REFUND_ISSUED intent-42 -20.00 EUR
entries only appended at state-settling
moments; corrections are compensating
entries, never edits
The two answer different questions, change under different rules, and serve different masters — support reads state, finance reads the ledger. Collapse them into one table and you must either edit history (forbidden) or reconstruct current status by replaying entries on every read (needless). The discipline that matters: ledger appends happen exactly when a state settles — a payment reaching SUCCEEDED appends the charge entry, a refund completing appends its entry. No settle, no entry; every settle, exactly one entry.
The gateway behind an interface; the reconciler behind the gateway
The gateway is external, so it goes behind a port your code owns: charge(intentId, amount, idemKey, timeout) and lookup(idemKey) — the second method is easy to forget and is the reconciler's whole toolkit. The reconciler is a loop that sweeps UNKNOWN payments, calls lookup, and settles each one: state transition plus ledger append, atomically from the system's point of view. This is dependency inversion with a concrete payoff: the core depends on the port, tests substitute a stub gateway that can time out on demand, and the reconciler is testable without a network.
Refunds are their own machine, not negative payments
A refund references the original payment, validates against the refundable remainder (original amount minus refunds already issued — partials must not stack past the original), and runs its own small lifecycle: requested → succeeded/failed, with its own gateway call and its own ledger entry. The rejected shortcut — modeling a refund as a payment with a negative amount — collapses two different validation regimes into one type: a payment validates against nothing prior, a refund validates against its parent's remainder. Different rules, different machines.
The invariants, stated as a set
1. Every payment is in exactly one state; transitions only through the state machine's legal edges 2. UNKNOWN exits only via reconciliation — no transition guesses 3. One idempotency key per intent; every gateway charge for that intent carries it 4. Ledger entries are appended at settling moments, never edited; corrections are compensating entries 5. Refunds against one payment never exceed its original amount
What we rejected, and why
Timeout → FAILED — the reflex from ordinary code, and here it's a lie about money: the charge may have landed. Failure must mean the gateway said no, nothing weaker.
Retry-until-success inside confirm — even with the idempotency key making it billing-safe, a synchronous retry loop holds the caller hostage to a gateway that takes 30+ seconds when unhealthy. Return UNKNOWN-ish "pending" to the caller and let the reconciler own resolution on its own clock.
One record type for state and history — either you edit history or you replay it on every status check. The split costs one extra type and buys both masters what they need.
Key takeaway
Four decisions carry the design, each traceable to a requirement: UNKNOWN as a first-class state whose only exit is reconciliation (timeout means you don't know); idempotency keys anchored to the intent so create replays return the existing intent and retried charges can't double-bill (the proof in "provably safe"); an append-only ledger separated from mutable state (history is never edited); and refunds as their own state machine validating against the refundable remainder. If your design has an UNKNOWN with no reconciler, it has a state with no exit.