Module 12 ยท Lesson 12

Design a payments ledger

The problem where correctness beats availability, and every rule is about failure.

Advanced 8 min read Updated 2026-11-02

What you will be able to do

  • Model money as immutable double-entry postings
  • Make payment attempts idempotent end to end
  • Explain reconciliation and why providers are the source of truth for settlement
  • Design for partial failure without losing money

Requirements and estimates

  • Functional: charge a customer, record the movement, refund, transfer between accounts, report balances.
  • Non-functional: no lost or duplicated money, auditable history, strong consistency per account, 99.99% availability for reads.
  • Scale: 10k payments/s peak, trillions of postings over years.
10k tps x 2 postings x 200 B = 4 MB/s of postings
~350 GB/day before compression, so partition by account and time
balances: derived per account, cached, reconciled nightly

Double-entry model

AccountDebitCredit
Customer wallet100.00
Merchant receivable98.00
Platform fee2.00
Total100.00100.00

Each transfer is a transaction containing postings that sum to zero. Postings are immutable: corrections are new compensating entries, never edits. That gives a complete audit trail and makes the ledger reconstructible from its own history.

Do not update a balance row directly
A balance column can drift from its postings after a bug, a partial write or a race. Keep the authoritative balance as the sum of postings per account, with a cached value that can be rebuilt and audited.

Idempotent payment attempts

Create a payment intent
The client sends an idempotency key. The server inserts intent (key, amount, status=pending) with a unique constraint.
Call the provider
Send the charge with the same key to the provider, so a retry there is also deduplicated.
Record the outcome
In one local transaction: insert postings, mark the intent captured, store the provider reference.
Handle the ambiguous case
Timeout means unknown, not failure. Mark pending and query the provider by key before retrying.
Reconcile
Daily settlement file is compared entry by entry; mismatches become an operational queue, not a silent loss.

The ambiguous case is the heart of the problem. A network timeout after the provider charged the card means your system does not know the state. Treating it as failure and retrying with a new key charges twice; marking it pending and querying the provider resolves it correctly.

The answer that shows maturity
"Immutable double-entry postings with balances derived per account. Payment intents carry an idempotency key unique in the database and reused with the provider. Timeouts are pending, never failed, and a daily reconciliation against settlement resolves mismatches."

Worth remembering

  • Balances are derived from postings; a stored balance is a cache that can drift.
  • Every movement of money has two entries, so the books always sum to zero.
  • Idempotency keys make a retried charge safe; unique constraints enforce it at the database.
  • Reconciliation is not optional: your ledger must be compared against the provider's settlement file daily.

Checkpoint

Answer all 3 questions to finish the lesson.
1. Why derive a balance instead of storing it as the source of truth?
The ledger's history is the truth. A cached balance is fine for speed only if it can be recomputed and verified.
2. A charge request times out. What is the correct interpretation?
A timeout after sending is ambiguous. Retrying blindly risks a double charge; the keyed status query resolves the truth.
3. What is reconciliation for?
Independent records must be compared. Settlement files reveal missed, duplicated or mispriced movements that no internal check can detect.