Payments are where "mostly correct" stops being good enough — a duplicate charge or a lost cent is a real person's money. This chapter builds a payment system and wallet from the safety rules up: idempotency, exactly-once execution, a double-entry ledger, and reconciliation against the outside world.
Most systems can shrug off a lost request — the user just refreshes. A payment system cannot. Here the unit of work is someone's money, and the two ways to be wrong are both unforgivable: charge a person twice, or take their money and lose the record of where it went.
So this chapter is built around one promise: every movement of money is written as two matching lines — money out of one account, the same amount into another — and the books must always balance. That idea has a name (the double-entry ledger), and almost everything else here exists to protect it: idempotency keys so a retry can't double-charge, exactly-once execution at the boundary to outside payment providers, and reconciliation to catch the day the outside world and your books disagree.
We'll follow money from a tap on a phone, through your services and out to a payment provider, into the ledger, and finally as a balance in a wallet — then make every step survive retries, crashes, and an unreliable outside world.
Start with the happy path. A customer taps "Pay $20." That tap becomes a payment event — a small piece of data describing the intent ("charge $20 to this card, credit this merchant"). From there it travels a short relay:
Two directions matter. Pay-in is money coming into the system — a customer funding a purchase or topping up a wallet. Pay-out is money going out — paying a merchant, withdrawing to a bank. They share the same machinery but flip the accounts, and pay-outs are usually slower and more tightly controlled because money is leaving for good.
Networks lie. A client sends "charge $20," the charge actually succeeds, but the reply gets lost on the way back. The client sees a timeout, assumes failure, and — reasonably — retries. Without a defense, that second request charges the customer again. This is the single most common way to double-bill people, and it has a clean fix.
The client attaches an idempotency key to the request: a unique ID it generates once for this payment attempt and reuses on every retry (often a random UUID, e.g. pay_8f3c…). The rule on the server is simple: "if I've seen this key before, don't do the work again — just return the result I already produced."
Mechanically the server keeps an idempotency store — a table keyed by that ID. On each request it looks the key up:
So an idempotent operation is one you can safely repeat: doing it once or ten times leaves the world in the same state. The customer is charged exactly once no matter how many times the request arrives.
"Charge exactly once" sounds like a single guarantee, but you can't buy it directly — networks and crashes mean any message you send might be lost. What you can build cheaply is at-least-once: keep retrying until you get an acknowledgement, accepting that the work might therefore happen more than once. (Recall the queue from Chapter 1: at-least-once delivery means a worker may see the same message twice.)
The trick is the equation: at-least-once delivery + idempotent handling = effectively exactly-once. You retry freely so nothing is ever lost, and idempotency makes the duplicates harmless. The state of the world ends up as if the operation ran exactly one time.
This needs a guard at every boundary where a duplicate could sneak through:
Two layers of the same idea: dedup inside, dedup at the edge. Neither alone is enough — your executor might crash after calling the PSP but before recording it, so the PSP-side key is what saves you on the next attempt.
seen-set guard so a repeated key is a no-op. The observable result depends only on the set of distinct operations, not on how many times each was delivered — that's idempotency as a mathematical property, not a hope.Here is the heart of the system. Money is never created or destroyed inside your platform — it only moves from one place to another. The accounting world encoded this 500 years ago as double-entry bookkeeping: every transaction is written as at least two lines that cancel out. One account is debited (money leaves it) and another is credited (the same money arrives), and the two sides are equal.
We model each line as an entry — an account and a signed amount (a delta). A transaction is a list of entries, and the iron rule is that the deltas sum to zero. A $20 pay-in might be:
customer_card: −2000 (money leaves the card, in cents)merchant_wallet: +2000 (the same money lands in the wallet)Sum: −2000 + 2000 = 0. Balanced. If a transaction's entries don't sum to zero, it's by definition wrong — money would have appeared or vanished — and you reject it before it's ever written.
Two more properties make the ledger trustworthy. It is append-only and immutable: you never edit or delete a posted entry; a mistake is fixed by appending a reversing transaction. And balances are derived, not stored as truth: an account's balance is just the sum of all its entries — a fold over the log. That means the books can always be re-derived and independently checked, which is exactly what auditors and regulators want.
Your ledger is internally perfect, and that is not enough. The PSP has its own records, the banks have theirs, and the only way to know your truth matches their truth is to compare them line by line. That comparison is reconciliation, and at scale it's a daily, automated job.
Each day the PSP hands you a settlement file — a list of the transactions it actually processed and the money it actually moved. Banks produce statements too. Reconciliation lines those up against your internal ledger and walks both sides:
The job classifies each mismatch and routes it to a repair workflow: auto-fix the known-benign cases (post the missing fee entry), and queue the genuinely ambiguous ones for a human to investigate. Reconciliation is how you sleep at night — it's the safety net that catches the bugs every other layer missed.
Here's a trap that looks innocent. After recording a payment, the payment service needs to do two things: write the result to its database, and publish an event ("payment succeeded") so the wallet service, the notifications service, and analytics can react. Two writes, two different systems — the database and the message bus.
The problem: you can't make two separate systems commit atomically. If you write the DB and then crash before publishing, the payment is recorded but nobody downstream ever hears — the wallet balance never updates. Publish first and then crash before the DB commit, and you've announced a payment that didn't actually happen. This is the dual-write problem, and it's the same consistency tension from Chapter 9, now with money on the line.
The fixes all share one move — collapse two writes into one:
outbox table. One commit, both or neither. A separate relay then reads the outbox and publishes to the bus, retrying until each event is sent. The atomic write is local; the publish is just at-least-once delivery on top.A payment isn't an instant; it's a little journey with stages, and treating it as a state machine keeps it honest. A payment moves through explicit states — REQUESTED → PENDING → SUCCEEDED, or → FAILED — and only legal transitions are allowed. You can always answer "where is this payment right now?", and a stuck payment is visible rather than silently lost.
Because each step talks to slow, flaky external systems, the coordination is asynchronous and built to survive failure:
The state machine plus idempotent steps means a crashed payment can simply be re-driven: read its current state, attempt the next legal transition, and any duplicate work is absorbed by the guards from sections 2–3.
step : State → Event → State. Illegal transitions simply don't typecheck into existence; recovery is just calling step again from the last persisted state. State machines make "what can happen next" finite and inspectable.The hardest failure in payments is not "it failed" — it's "I don't know." Your executor calls the PSP and the connection drops before the answer arrives. Did the money move or not? You must never guess. The discipline is: treat the outcome as unknown, record it as PENDING, and resolve it by querying the PSP for that idempotency key (or letting reconciliation catch it in the settlement file). Optimistically marking it succeeded is how you lose money; pessimistically marking it failed is how you charge twice on retry. Unknown stays unknown until the source of truth tells you.
Then there's the legal frame. Touching card data drags you into PCI-DSS, a strict standard for handling cardholder data. The winning strategy is scope minimization: handle as little card data as possible — ideally none. Let the PSP's hosted fields or tokenization capture the card so a raw card number never touches your servers; you store a token, not a PAN. Less data in scope means a smaller, cheaper, safer compliance surface.
Finally, everything leans on the audit trail: the append-only ledger plus immutable event log means every cent is traceable to who moved it, when, and why. That same immutability that made balances re-derivable is also what makes the system provable to an auditor — closing the loop on "move money once, prove it forever."
The whole chapter as a checklist — the rules that keep money safe:
Two ideas from this chapter compress into a handful of lines. A transaction is a List[Entry] whose deltas must sum == 0 — that's the double-entry invariant. A balance is a fold over entries. And idempotency is a seen-set guard: apply no-ops if it has already processed that key. Flip the languages; the shape is identical.
txn.map(_.delta).sum == 0 is the entire safety of the ledger expressed in one line — money can only move, never appear.