Money in system design: ledgers, idempotency and reconciliation
How to handle money correctly: integer minor units, double-entry ledgers, balances derived from entries, idempotent payment calls, the unknown-outcome state, authorisation and capture, and daily reconciliation with providers.
Reading is half of it. See this used in a real interview: walk through Design a Payment System →
Any system that charges, pays out, holds credits or tracks balances has to get money exactly right, and interviewers hold money to a higher standard than anything else: a duplicated like is a curiosity; a duplicated charge is a support ticket, a chargeback and possibly a regulator. A few rules, applied consistently, cover most of what can go wrong.
Representing amounts
- Store amounts as integers in minor units (cents, pence):
4999means $49.99. Never floats:0.1 + 0.2is not0.3in binary floating point, and rounding errors accumulate. - Always store the currency next to the amount; never add amounts in different currencies.
- Be explicit about rounding when splitting (fees, taxes, splitting a bill three ways), and put the leftover cent somewhere deliberate.
- Some currencies have no minor unit (JPY) or three decimal places (KWD); use a currency table, not an assumption of two decimals.
Double-entry ledgers
A ledger records every movement of money as entries that sum to zero: money leaves one account and enters another. A $50 payment with a $1.50 fee might be:
| Account | Debit | Credit |
|---|---|---|
| customer_card_receivable | 50.00 | |
| merchant_balance | 48.50 | |
| platform_fee_revenue | 1.50 |
Rules:
- Every transaction’s entries balance; the database enforces it (a check in the transaction that writes them).
- Entries are append-only: mistakes are fixed with new reversing entries, never by editing history.
- Balances are derived from entries (sum of an account’s entries), cached in a balance row updated in the same transaction, and verifiable at any time by re-summing.
Why not just a balance column? Because a single column cannot explain itself: when it is wrong, you cannot tell why, and concurrent updates silently lose money. A ledger is an audit trail by construction. See event sourcing for the general idea.
Concurrency on balances
Two withdrawals racing against one balance must not both succeed if together they overdraw it. Use a conditional update in the same transaction as the entries (UPDATE balances SET amount = amount − 3000 WHERE account = ? AND amount >= 3000), or lock the balance row for the short transaction. See transactions and isolation. For extremely hot accounts (a platform’s fee account), split them into sub-accounts and sum on read.
Idempotency everywhere
Networks fail after the money moved but before you heard about it. Every money-moving operation must be safe to retry:
- Clients send an idempotency key with each payment request; the server stores the key with the result and returns the same result on retry. Stripe keeps keys for 24 hours.
- Your calls to the payment provider also carry an idempotency key (usually your payment id), so your own retries cannot double-charge.
- Webhook handlers deduplicate by event id. See designing webhooks.
The unknown outcome
The hardest state in payments: you sent a charge request and the connection timed out. Did the customer get charged? You do not know.
- Record the payment as pending/unknown, never as failed (a failed state invites the user to try again and get charged twice).
- Resolve it by querying the provider with your idempotency key or payment id, by retrying the same request with the same key, or from the provider’s webhook.
- Show the user "processing" until resolved.
Model payments as a state machine (created → authorised → captured → refunded, with failed and unknown branches), with conditional transitions so duplicates and races are no-ops. See Design a Payment System.
Authorise, capture, refund
Card payments are two steps: authorise (reserve funds on the card) and capture (actually take them), with up to about seven days between. Use this deliberately:
- Authorise at checkout, capture when the goods ship or the service is delivered.
- If the order fails or is cancelled before capture, void the authorisation (free and instant) instead of refunding (fees, days to appear).
- Marketplaces (Airbnb, DoorDash) authorise at booking and capture later. See Design Airbnb.
Reconciliation
Your records and the provider’s will disagree sometimes: a webhook missed, a timeout resolved differently, a manual refund in the provider’s dashboard. Daily reconciliation compares your ledger with the provider’s settlement reports line by line, flags mismatches, and routes them to a person or an automated fix. It is how every real payments team finds the bugs that tests missed. Also reconcile the ledger against itself: every transaction balances, every cached balance equals the sum of its entries.
Payouts and money held for others
When you hold money for sellers or drivers, the ledger tracks what you owe them; payouts move it out on a schedule, with holds for refunds and chargebacks. Payouts are idempotent batch jobs with their own reconciliation against bank reports. See background jobs.
Security and compliance
Never store raw card numbers; use the provider’s tokenisation so your systems stay out of most PCI scope. Restrict and audit who can move money, require two people for manual adjustments, and keep the ledger immutable for audit.
Checklist
- Integer minor units plus currency; explicit rounding.
- Double-entry, append-only ledger; balances derived and checked.
- Conditional updates for concurrent debits.
- Idempotency keys from client to provider; webhooks deduplicated.
- An unknown state resolved by querying, never assumed failed.
- Authorise then capture; void before refund.
- Daily reconciliation against provider reports.