Designing a Payment / Wallet System

A system design interview guide to moving and tracking money correctly, where the defining requirement is that the system must never lose, invent, or double-count a single unit of currency.

Student edition · start with the guarantee

Represent money as auditable postings and enforce balances, currency, and retries transactionally.

Correctness contract: Each transaction balances per currency. Both account effects, no-overdraft checks, postings, and local request outcomes share an atomic boundary.

Basic HTTP, databases, and programming are assumed. Specialized terms are explained below. Named products motivate the problem; this is an educational design, not a claim about their current internals. Capacity numbers are illustrative decimal payload estimates, not benchmarks. Add indexes, protocol overhead, replicas, headroom, and failure capacity before provisioning.

A payment or wallet system is the textbook case where correctness outranks every other quality. Most services can tolerate a dropped request or a stale read; a money system cannot tolerate a balance that is off by a cent. That single constraint reshapes the whole design. Instead of optimizing for raw availability, you lean toward strong consistency on the money path; instead of mutating balances in place, you record an immutable, balanced trail of entries; and instead of hoping retries are harmless, you make them harmless with idempotency. This guide develops the core of such a system: the double-entry ledger, the locking that prevents double-spends, idempotent payment requests, sagas for multi-step flows, and the reconciliation that proves it all adds up.

Contents

  1. Requirements & Clarifying Questions
  2. Back-of-Envelope Estimation
  3. API & Data Model
  4. Correctness Above All
  5. The Double-Entry Ledger
  6. Append-Only & Balances
  7. Preventing Double-Spend
  8. Idempotent Payment Requests
  9. Multi-Step Payments & Sagas
  10. Reconciliation & Auditing
  11. Consistency Over Availability
  12. Failure Modes
  13. Tradeoffs & Alternatives
  14. A 45-Minute Interview Walkthrough
  15. Common Mistakes
  16. Summary
  17. Check Yourself
  18. Key Terms
  19. Further Reading
  20. Concepts to understand first
  21. Worked example
  22. Runnable Python model

Concepts to understand first

Each definition includes a concrete example and the condition under which the mechanism is useful. You can read this guide without opening another guide.

Double-entry ledger

An append-only journal where postings balance within each transaction and currency. Account types determine debit/credit meaning; a signed wallet-delta model is a simplifying convention, not the full accounting definition.

Example. A simplified transfer records Alice −1000 cents and Bob +1000 cents, atomically, with transaction sum zero. Funding, fees, and withdrawals post against explicit counterparty accounts.

Limit. Balanced postings detect some errors but do not prove the right amount/counterparty was chosen. Mutable balances, no-overdraft checks, currency, and idempotency must also be transactional.

Optimistic concurrency and compare-and-set

Update state only if the expected version or status still matches, in the same atomic operation that changes it. A failed comparison means a conflicting operation won; reread or return a conflict.

Example. UPDATE seats SET status='held', version=version+1 WHERE id=? AND status='available' allows only one concurrent claimant to update the row.

Limit. Application-level GET then unconditional SET is not equivalent. Include acquisition identity to reject stale callbacks, and couple related invariants in a transaction.

Idempotency keys

A client-generated identity for one logical operation. The server binds it to caller scope, endpoint, request fingerprint, state, and result. A retry uses the same key and reuses the original outcome rather than repeating the effect.

Example. Payment p42 times out after the provider charged it. Retrying with p42 lets the provider return the same charge instead of creating a new one.

Limit. A unique local claim is insufficient if the business effect and saved result can diverge. Keep unknown outcomes pending, use downstream idempotency, and state the retention/retry horizon.

Sagas and compensation

A durable workflow of local transactions with corrective actions for failures. Compensation is a new business operation, not a rollback of history. The workflow exposes intermediate states and must recover after crashes.

Example. Reserve a seat, authorize payment, confirm booking, then capture. A declined authorization releases the seat; an uncertain charge stays pending until reconciled.

Limit. Timeout does not mean the remote effect failed. Compensation can fail too and needs retries/escalation. A saga does not provide isolation or guarantee every external effect is reversible.

Transactional outbox

A business transaction writes its state change and an event row to the same database. A relay publishes committed event rows later, retrying until the broker durably accepts them.

Example. An order and its OrderCreated outbox row commit together. A relay crash after publish but before marking sent publishes the same event ID again on restart.

Limit. The relay is at-least-once, not magically exactly-once. Consumers dedup transactionally; preserve per-entity sequence when order matters. CDC is change data capture: reading committed database changes.

CAP and consistency models

During a network partition, a distributed service cannot offer both linearizable operations and successful completion of every eligible request at nonfailed nodes. CAP consistency is linearizability; it is not durability or 'no bytes lost'.

Example. A minority that cannot contact the authority blocks or rejects a linearizable read/write. A mergeable append log can accept local writes on both sides and preserve both records, while not giving one-copy ordering.

Limit. Causal/session/eventual guarantees are not a universal availability ladder. Sticky routing alone fails after failover unless the new replica has satisfied the client's dependencies.

1. Requirements & Clarifying Questions

"Design a payment system" can mean a wallet, a checkout or a whole processor. Ask:

FunctionalNon-functional
Charge a customer; refund all or partCorrectness: no money created, lost or moved twice
Transfer between internal accountsExactly-once effect under retries
Show balance and transaction historyFull audit trail kept for years
Reconcile with processors and banks dailyStrong consistency on the write path; high availability for reads
Intuition

Think of the ledger as a bank's paper journal written in pen. You never erase a line. You fix a mistake by writing a new line that reverses it. Every line moves money from somewhere to somewhere, so the columns always add up to zero.

2. Back-of-Envelope Estimation

100M payments/day averages 1157/s; a stated 5× peak gives 5787/s. Two 200-byte postings each give 40 GB/day and 14.6 TB/year payload. Seven years are about 102 TB before indexes, replicas, reconciliation, and headroom.

These numbers do not establish a universal primary limit. Hot accounts, commit policy, transaction shape, and storage determine capacity. Account sharding makes arbitrary transfers cross-shard; design that boundary first. Retain ledger history under explicit audit/legal policy rather than asserting it must literally remain forever.

3. API & Data Model

POST /v1/payments            Idempotency-Key: <uuid>
  { "amount": 1999, "currency": "USD", "source": "tok_visa", "merchant": "m_42" }
  -> 201 { "id": "pay_81", "status": "succeeded" | "pending" | "failed" }
POST /v1/payments/pay_81/refunds   Idempotency-Key: <uuid>   { "amount": 500 }
GET  /v1/accounts/acc_7/balance
GET  /v1/accounts/acc_7/entries?cursor=...
accounts(id PK, currency, balance_cents BIGINT, version BIGINT)
transactions(id PK, idempotency_key UNIQUE, type, status, created_at)
entries(id PK, txn_id FK, account_id FK, amount_cents BIGINT, created_at)
    -- append-only; SUM(amount_cents) per txn_id = 0
payments(id PK, txn_id, processor_ref, status, amount_cents, currency)

Amounts are integers in minor units (1999 = $19.99). The status on a payment is a small state machine: pending → succeeded or failed, with unknown for timeouts that reconciliation resolves later.

High-level design: strict write path, relaxed read path ClientIdempotency-Key Payment API Ledger DB (one txn) dedup keyentries (append-only)balances (materialized)outbox Processor / banksame key passed on Event stream Reconciliation jobledger vs settlement file Replicas / historystale reads OK
The payment API claims the key, writes balanced entries, updates balances and adds an outbox row in one transaction. It calls the processor with the same key. Reconciliation compares the ledger with the processor's settlement file. History screens read from replicas.

4. Correctness Above All

The first thing to say in an interview about a payment system is that its success criterion is unusual. For most systems we negotiate trade-offs — a little staleness for a lot of availability, some duplicates for simplicity. For money there is no such negotiation on the core invariant: every unit of currency that enters the system must be accounted for at every instant, and none may be created or destroyed except through an explicit, recorded transaction. A balance that is silently wrong is not a bug to be triaged later; it is a failure of the system's entire reason for existing.

This requirement is what justifies design choices that would look over-engineered elsewhere. We use transactions and locks where another system might use eventual consistency, we keep an immutable history where another system might overwrite a value, and we run continuous reconciliation where another system might trust its own writes. Each of these costs throughput or latency, and each is worth it because the alternative is losing or duplicating money. Stating this priority up front tells the interviewer you understand the domain rather than reflexively reaching for the scaling playbook.

A sharp framing: in a payment system, correctness is the feature and everything else — throughput, latency, even availability — is subordinate to it. Decisions that trade a little speed for certainty about the money are almost always right.

5. The Double-Entry Ledger

A double-entry journal balances debit and credit postings for each transaction and currency. Debit/credit are accounting directions whose effect depends on account type: debits increase asset accounts but decrease liability accounts; credits do the reverse. They are not universally synonyms for 'money sent' and 'money received'.

For a simplified wallet-delta model, a $10 internal transfer can use Alice −1000 cents and Bob +1000 cents with sum zero. A real ledger also names funding, withdrawal, bank, fee, suspense, and other counterparty accounts so every movement has balanced postings. Multi-currency movements require explicit per-currency accounts and an exchange/rounding policy; do not sum unlike currencies as one invariant.

# Simplified signed wallet deltas, not universal debit/credit signs:
transaction t1, currency USD:
  Alice: -1000 cents
  Bob:   +1000 cents
  sum:       0 cents

Commit all postings and relevant balance/materialization/idempotency effects atomically. Append-only entries are corrected by new linked postings rather than silent history edits. Balancing catches an unbalanced journal, not a wrong-but-balanced amount, destination, duplicated transfer, or missing external settlement; validation and reconciliation remain necessary.

Use integer smallest units or an exact decimal with the correct currency scale/rounding rules. Binary floats can introduce rounding errors. Retention and immutability follow audit/legal policy, with access controls and explicit corrections rather than an unqualified promise to keep all data forever.

6. Append-Only & Balances

Ledger entries are append-only and immutable: once written, an entry is never updated or deleted. A mistake is corrected not by editing the past but by writing a new, compensating entry that reverses it. This is a deliberate constraint, and it buys two things that are hard to overstate for a money system. First, a complete audit trail: every change to every balance is a row you can point to, with a cause and a timestamp, which is exactly what auditors and regulators require. Second, the elimination of an entire class of bugs — there is no in-place update to race on, no value to silently clobber.

That raises an obvious question: if you only ever append entries, how do you know an account's current balance? There are two answers, and real systems often use both. You can derive the balance by summing all of an account's entries — always correct, but slow once the history is large. Or you can materialize the balance, keeping a running total that is updated as part of the same transaction that appends the entry. The materialized balance is fast to read, and the append-only entries remain the source of truth you can always recompute against to catch drift.

ApproachHow balance is knownTrade-off
DerivedSum all entries for the account on demand.Always correct; slow as history grows.
MaterializedKeep a running total, updated with each entry.Fast reads; must update atomically with the entry.
BothMaterialize for speed; derive periodically to verify.Fast and self-checking; a little extra work.

7. Preventing Double-Spend

Validate positive exact money units, currency, and account pair. Serialize conflicting changes with row locks or atomic conditional updates. Lock multiple accounts in stable ID order where supported; bound deadlock/serialization retries.

A transfer updates both source and destination materialized balances, writes balanced postings, enforces relevant no-overdraft rules, and saves idempotency outcome in one transaction. Source deduction plus ledger append without destination materialization is inconsistent. Account types determine whether negative balances are valid.

Account-ID sharding does not co-locate arbitrary transfers. Use a reviewed distributed transaction, centralized posting boundary, or explicitly pending/reconciled protocol. A saga is not immediate cross-shard ledger atomicity. Benchmark throughput instead of treating 6000 TPS as a universal database limit.

8. Idempotent Payment Requests

Payments travel over networks, and networks drop responses, so a client that does not hear back will retry — and a retried payment must never charge twice. The defense is the same idempotency-key pattern used throughout reliable systems: the client generates a unique key for each logical payment and reuses it on every retry of that payment. The payment service records each key's outcome and, on seeing a key it has already completed, returns the stored result instead of charging again.

Payment API claims the key in the dedup store first, then charges once, returns the stored result, or answers 409
The payment API claims the idempotency key with an INSERT before doing anything else. If the claim wins, it charges once, passing the same key to the processor, and marks the key COMPLETED. A retry of a completed key gets the stored result; a retry while the first attempt is still running gets 409. If the charge times out, retry with the same key, or mark it UNKNOWN and let reconciliation settle it.
function pay(request, idempotency_key):
  try:
    dedup.claim(idempotency_key)        # claim FIRST: unique constraint, one winner
  except UniqueViolation:
    prior = dedup.get(idempotency_key)
    if prior.status == "COMPLETED":
      return prior.response             # retry: do not charge again
    return 409                          # first attempt still running
  result = charge(request, idempotency_key)  # processor dedups on the same key
  dedup.complete(idempotency_key, result)
  return result

Claim the key before checking for a prior result. If you check first and claim second, two duplicates can both see "no result yet" before either claims. Passing the same key to the external processor matters just as much. If the processor times out, you do not know whether it charged. Retry with the same key, and the processor returns the first result instead of charging again. If you still cannot tell, mark the payment UNKNOWN and let reconciliation settle it. Never guess.

This is the bridge between the payment system and general idempotency design: the money path is simply the highest-stakes place to apply it. A duplicate notification is an annoyance; a duplicate charge is a refund, a support ticket, and a dent in trust, so the dedup store here is not optional polish but part of the correctness guarantee.

9. Multi-Step Payments & Sagas

Durable payment states distinguish reserved, authorized, captured, settling, succeeded, rejected, and unknown. Authorization/capture differ by provider; the 'pivot' is business-specific. A timeout is unknown, not a decline. Query/retry the same provider key before compensation.

Refunds are new money movements with their own keys and may fail, incur fees, or take days. Compensations need retries, deadlines, and escalation; permanent external failures can leave unresolved workflows. 2PC provides atomic commit for participating resources and can block after prepare; isolation is a separate property. Banks/processors generally do not participate in your DB transaction.

10. Reconciliation & Auditing

Even a careful system must continually prove to itself that it is correct, because bugs, partial failures, and discrepancies with external processors are inevitable over time. Reconciliation is the routine that does this proving. Internally, it re-derives balances from the append-only ledger and checks them against the materialized balances, and it verifies the global invariant that debits equal credits. Externally, it compares the system's records against statements from banks and payment processors — the money you think you moved against the money they say moved — and flags any mismatch for investigation.

Processors report many things only later: a charge that timed out on your side but succeeded on theirs, a chargeback, or a settlement that nets out fees. Reconciliation is how those UNKNOWN and late states get resolved, usually from a daily settlement file.

This is where the append-only, double-entry design pays off most. Because the ledger is an immutable, balanced history, reconciliation has a trustworthy source of truth to check against, and any drift surfaces as a concrete discrepancy with a clear location rather than a vague sense that the numbers feel off. The same immutable trail is the audit record regulators require: a complete, tamper-evident account of every movement of money, queryable long after the fact.

# reconciliation: the system checks itself
for account in accounts:
  derived = sum(ledger.entries(account))      # recompute from source of truth
  assert derived == account.materialized_balance
assert sum(all debits) == sum(all credits)    # global money conservation
diff = compare(internal_records, processor_statement)  # external check

11. Consistency Over Availability

Every distributed system eventually faces the choice the CAP theorem describes: when the network partitions, you can stay consistent or stay available, but not both. Most consumer systems lean toward availability — better to serve a slightly stale feed than an error page. The money path makes the opposite call. If the system cannot be sure a payment will be recorded correctly and exactly once, it should refuse the operation rather than risk corrupting the ledger. A declined payment that the user can retry is recoverable; a double charge or a lost transfer is not.

In practice this means the core money path is built on strongly consistent storage and synchronous, transactional writes, accepting the latency and the occasional unavailability that come with it. It does not mean the entire product must be consistency-first — read-heavy, non-monetary surfaces like transaction history or analytics can be served from replicas and caches, where staleness is harmless. The art is drawing the line: strong consistency for anything that moves or guards money, relaxed consistency for everything that merely reports on it.

A useful boundary to articulate: be strict on the write path that changes balances, and relaxed on the read paths that only describe them. You buy correctness exactly where it is non-negotiable and pay for it nowhere else.

12. Failure Modes

What failsWhat happensHow to handle it
Processor call times outUnknown whether the card was chargedRetry with the same key; else mark UNKNOWN and reconcile
Client retries a paymentRisk of double chargeIdempotency key, claimed first
Crash between debit and creditHalf a transferBoth entries and balances in one DB transaction
Two transfers lock accounts in opposite orderDeadlockLock in account-id order; retry on deadlock errors
Saga step fails after the chargeMoney taken, order not placedRetry forward steps after the pivot; refund only as a last resort
Ledger and processor disagreeSilent driftDaily reconciliation; alert and hold on any mismatch
Hot merchant accountLock contention caps throughputSplit into sub-accounts and sum them, or batch credits

13. Tradeoffs & Alternatives

ChoiceOption AOption BUsual pick
Concurrency controlPessimistic row locks: simple, waits under contentionOptimistic versions: no locks, retries on conflictPessimistic for hot accounts, optimistic otherwise
Cross-service consistency2PC: atomic, blockingSaga: non-blocking, visible middle statesSaga, with the pivot step placed carefully
BalanceDerived from entries: always rightMaterialized: fastMaterialize, verify by deriving
StorageRelational DB: transactions, constraintsNoSQL: scale, weaker transactionsRelational (or NewSQL) for the ledger
Partition behaviorRefuse writes (CP)Accept and fix later (AP)CP for money movement

Worked example: two spends against one ten-dollar balance

Alice has 1000 cents; two distinct payments each try to spend 1000.

Worked example, in orderSerialize the debit: Lock the source row in a transaction or conditionally debit WHERE balance≥amount. Two different idempotency keys do not stop overspending by themselves.. Commit the first transfer: Debit Alice, credit Bob, append balanced postings, and update both materialized balances with request outcome atomically.. Reject the second transfer: It sees zero funds or updates zero rows. Stable account lock ordering avoids avoidable deadlocks.. Reconcile remote settlement: A local balanced journal cannot prove a bank settled. Preserve provider-keyed pending/unknown state and reconcile evidence.1Serialize the debit2Commit the first transfer3Reject the second transfer4Reconcile remote settlement
Read top to bottom. The table below explains the state and guarantee at each step.
StepState, calculation, and reasoning
1. Serialize the debitLock the source row in a transaction or conditionally debit WHERE balance≥amount. Two different idempotency keys do not stop overspending by themselves.
2. Commit the first transferDebit Alice, credit Bob, append balanced postings, and update both materialized balances with request outcome atomically.
3. Reject the second transferIt sees zero funds or updates zero rows. Stable account lock ordering avoids avoidable deadlocks.
4. Reconcile remote settlementA local balanced journal cannot prove a bank settled. Preserve provider-keyed pending/unknown state and reconcile evidence.
Check your understanding: Does a balanced ledger rule out every money bug?

No. Wrong amounts, currencies, counterparties, or duplicate balanced transactions still balance. Validate those invariants too.

Run a small, testable model

Download concepts.py. It uses only the Python 3.10+ standard library. From the directory where you saved it, run:

python3 concepts.py ledger
python3 concepts.py test

The model checks conservation, destination credit, no overdraft, duplicate replay, and rollback.

The program contains deterministic assertions and failure traces. It is a learning model, not a production service: it does not implement network consensus, distributed durability, authentication, or third-party delivery. Read its contracts and try changing an input before extending it.

Primary references for these concepts

14. A 45-Minute Interview Walkthrough

MinutesWhat to do
0–5Clarify: wallet or checkout, flows, currencies, volume. State "correctness first."
5–10Estimate TPS and ledger growth. Note that it fits a sharded relational DB.
10–20API, the double-entry data model, integer cents, the high-level diagram.
20–32Deep dives: double-spend and locking, idempotency with the processor, sagas vs 2PC.
32–40Failure modes: timeouts and UNKNOWN, reconciliation, deadlocks.
40–45CAP stance and the tradeoffs table; recap against the requirements.
Interview tip

Expect "what if the processor times out?" It is the favorite follow-up. The strong answer has three parts: retry with the same idempotency key, never assume failure, and let reconciliation settle what is still unknown.

15. Common Mistakes

16. Summary

A payment system is, above all, a correctness machine — a set of choices that together make it impossible to quietly lose or double-count money:

ConcernMechanism
What is the overriding requirement?Correctness — never lose, invent, or double-count money — above throughput or availability.
How is money tracked?A double-entry ledger: every transaction is balanced debits and credits.
How is history kept honest?Append-only, immutable entries; balances derived or materialized from them.
How is double-spend prevented?One DB transaction with row-level locking or optimistic versioning on the balance.
How are retried payments made safe?Scoped payload-bound idempotency plus atomic local outcome/effect or provider dedup protects repeated charges within its defined retention/failure model.
How are multi-step payments coordinated?Sagas expose intermediate states and recover through new corrective operations, retries, and reconciliation. Compensation is not a rollback and can fail or remain pending.
How do we know it all adds up?Reconciliation against the ledger and external statements; immutable audit trail.
What do we sacrifice for correctness?Consistency over availability on the money path — refuse rather than corrupt.
The recurring theme: a payment system spends complexity to buy certainty. Double-entry makes errors visible, immutability removes a class of bugs, locking serializes the dangerous writes, idempotency tames retries, sagas keep distributed flows whole, and reconciliation continuously proves the money is all there.

17. Check Yourself

  1. Alice pays Bob $12.50. Write the ledger entries in cents. What must they sum to?
    Answer

    Alice −1250, Bob +1250. They must sum to 0.

  2. Alice has $10 and sends two $10 payments at the same moment. Show how the double-spend happens, and one fix.
    Answer

    Both requests read a balance of $10, both pass the check, and both deduct, so the balance hits −$10. Fix: SELECT … FOR UPDATE on the account row inside the transaction, so the second request waits and then sees $0.

  3. Transfer 1 goes A→B and transfer 2 goes B→A at the same time. How can this deadlock, and how do you prevent it?
    Answer

    Transfer 1 locks A and waits for B. Transfer 2 locks B and waits for A. Neither can finish. Prevent it by always locking rows in a fixed order, such as the lower account id first.

  4. The processor call times out. Should you mark the payment failed and let the user retry?
    Answer

    No. The charge may have gone through. Retry with the same idempotency key so the processor returns the first result. If it is still unclear, mark it UNKNOWN and let reconciliation settle it.

  5. Why is "charge the card" usually the pivot step of a checkout saga?
    Answer

    It is costly to undo, since a refund is a new money movement with delay and maybe fees. So cheap, reversible steps like reserving stock go before it. Steps after it are retried until they succeed rather than undone.

  6. At 100M payments/day, 2 entries each at 200 B, how much ledger do you store in 7 years?
    Answer

    100M × 2 × 200 B = 40 GB/day. × 365 = 14.6 TB/year. × 7 ≈ 102 TB.

18. Key Terms

Double-entry ledger
An append-only journal where postings balance within each transaction and currency. Account types determine debit/credit meaning; a signed wallet-delta model is a simplifying convention, not the full accounting definition. Example and limits above.
Debit / credit
The two sides of an entry; signed amounts that sum to zero.
Minor units
The smallest currency unit, such as cents, stored as an integer.
Materialized balance
A running total updated with each entry.
Double-spend
Spending the same funds twice through a race.
Pessimistic / optimistic locking
Lock before reading / check a version when writing.
Saga
A durable workflow of local transactions with corrective actions for failures. Compensation is a new business operation, not a rollback of history. The workflow exposes intermediate states and must recover after crashes. Example and limits above.
Pivot step
The saga step after which you only go forward.
2PC
Two-phase commit: prepare everywhere, then commit everywhere.
Reconciliation
Checking your ledger against itself and against outside statements.
PCI DSS
Card-industry security rules for handling card data.

19. Further Reading


⌂