Part 3 · 2 chapters · ~12 min

Payments Ledger

A ledger for a wallet or bank: idempotent transfer APIs, double-entry journal, balances with concurrency control, a transfer state machine around external rails, unknown outcomes, and reconciliation.

7

Brief, questions and numbers

the brief
  1. Users hold balances and transfer money to each other and to external banks; every naira must be accounted for.
questionanswer we assume
consistency?strong for balances: no overdraft, no double spend
scale?5M transfers/day, peaks at salary days
external rails?yes, with timeouts and callbacks
audit?every change traceable forever
latency?p99 under 500 ms for internal transfers
code
transfers  = 5M/day ≈ 60/s average, ~600/s peak on salary days
entries    = 2-4 per transfer → ~20M rows/day → 7B rows/year: partition by month
hot rows   = settlement and fee accounts receive a credit on every transfer: hot spots
storage    = 7B × 150 B ≈ 1 TB/year plus indexes
PAYMENTS LEDGER
an append-only double-entry journal, idempotent APIs, and balances derived from entries
transfer APIIdempotency-Keytransfer state machinepending → postedbank rail adapterNIP, cardsjournalappend-only entriesbalancesprojection + versionoutboxeventsreconciliationvs bank statements
swipe the figure sideways, or tap expand for full screen
1/6
idempotent entry
Every request carries an idempotency key stored with a unique constraint and the original response, so a retried POST returns the first result instead of moving money twice.
unique idempotency key + stored responseretries return the first result
8

v1, the break, and v2

v1. A balances table updated in place by each transfer (UPDATE balance = balance - x), with an HTTP call to the bank inside the same database transaction.

The break. Retries double-charge without idempotency; updates without history cannot be audited; the bank call holds locks for seconds; and every transfer updates the same fee and settlement rows, serialising all traffic.

v2. An append-only double-entry journal with idempotency keys, balances maintained transactionally with versions, a state machine with rail calls outside transactions and an outbox, sharded sub-accounts for hot internal accounts (summed for reporting), monthly partitions, and reconciliation.

the sentence
v2 buys auditability, idempotency and throughput past hot accounts, and pays with more tables, asynchronous outcomes the UI must show, and a reconciliation process to run.