Why this matters: payment systems accrete requirements for a decade — refund policies, fraud rules, finance reports, settlement flows. The test of lesson 2's design is that each arrival lands on a seam that already exists: the state machine grows states, the ledger grows entry types, the port grows an implementation detail. Practicing these landings teaches you to answer follow-ups by placing, not redesigning — and to recognize the ones that deserve a sketch instead of a build.
Refunds grown into a full lifecycle
Lesson 3 validated and issued refunds inline. The production version promotes the refund to a first-class entity with its own machine — REQUESTED → PROCESSING → SUCCEEDED / FAILED / UNKNOWN — referencing the original payment, carrying its own idempotency key (a double-clicked refund button is exactly as dangerous as a double-clicked pay button), and subject to the same timeout honesty: a refund call that times out is an unknown refund, resolved by the same reconciler pattern. The remainder rule sharpens too: a refund in flight reserves its amount against the remainder, so two concurrent partials can't both validate against the same headroom. Notice the shape of this extension — no new concepts, just the payment's own disciplines (keys, UNKNOWN, reconcile, ledger entries) applied to a second entity. That symmetry is the design working.
Webhooks versus polling: who resolves UNKNOWN
The reconciler as built polls — sweeps unknowns and asks the gateway. Real gateways usually also push: a webhook calls you when a charge settles. The trade-off is worth stating crisply. Polling is simple and self-contained but resolves on your sweep clock, so an unknown can hang for minutes. Webhooks resolve in seconds and cost no sweep traffic — but they arrive from the network, so they can be duplicated, delayed, or out of order; a webhook is a hint, not a truth. The production answer is both: accept webhooks as fast-path triggers that feed the same settle routine, keep the polling reconciler as the backstop that guarantees no unknown hangs forever if webhooks go missing. Because both paths converge on settle, adding webhooks touches zero state-machine or ledger code — the seam did its job.
Reconciliation reports from the ledger
Eventually finance asks: does our ledger agree with the gateway's records? This is the append-only ledger earning its keep. A report job walks a day's ledger entries, pulls the gateway's settlement file for the same day, and diffs — entries we have that they don't (our unknown-handling missed something), entries they have that we don't (a charge landed that we never settled). Discrepancies become compensating entries plus investigation, never edits. Say the deeper point: this report is only possible because the ledger is append-only — a mutable history can't be audited against an external record, because you can't distinguish "corrected" from "tampered."
A fraud-check seam before authorization
"Block suspicious payments" arrives at every payments team. The placement question is the whole question: the check belongs between confirm being requested and the gateway being called — after the intent exists (so declined attempts are recorded and auditable), before money moves (a post-charge fraud check is a refund generator). Concretely, confirm gains one step: consult a FraudCheck port with the intent; on decline, transition to a BLOCKED state — a new terminal edge on the machine — without any gateway call. The port ships as a trivial always-approve rule and grows real scoring later; callers never change. That's open/closed at the right grain: you knew a policy would eventually live here, so you left a socket, not a hard-wired rule.
Payouts and settlement — the direction sketch
So far money flows one way: customers pay the platform. A marketplace eventually pays out — sellers get their share. Sketch, don't build: payouts are a separate flow with its own state machine (accrue → schedule → execute → settle), driven off the same ledger — a seller's balance is a derivation over ledger entries (their sales minus refunds minus fees), never a mutable counter that can drift from history. The instinct to name: money-out reuses the record-keeping spine but not the payment machine, because the failure modes differ — a failed payout retries; a failed charge asks the customer. Saying "same ledger, separate machine, and that's a project boundary" is the senior answer; designing it live isn't expected.
Key takeaway
Every extension reuses the core's own disciplines: refunds get their own state machine with their own idempotency key and the same UNKNOWN honesty; webhooks join polling as a second trigger converging on the one settle routine; reconciliation reports and payout balances are derivations over the append-only ledger — possible precisely because it's never edited; fraud checks socket in between confirm and the gateway call, before money moves. When a payments follow-up arrives, ask which existing discipline it rhymes with before inventing a new one.