Skip to content

9.7.10 — Digital Wallet

"Design a digital wallet. Users can top up, send money to each other, pay merchants, and see their balance and history."

Every other problem in this folder tolerates a small mistake. This one does not. A parking lot that loses a ticket is annoying; a wallet that loses ten pounds is fraud, and a wallet that creates ten pounds is worse.

So this page is really about one idea — money is never a number you edit — and everything else follows from it.

1. The questions to ask first

Is this a closed wallet or does money leave the system? A closed wallet — store credit, game currency, transport card — never interacts with a bank, so top-ups come in and spending stays inside. An open one moves real money out, which brings in a payment provider, settlement delays and reversals. Ask, because it halves or doubles the problem.

One currency or several? Several means every balance is per currency and a conversion is a real transaction with a rate and a spread, not an arithmetic detail.

Can a balance go negative? Usually no, and asking makes you sound like you have done this. Some wallets allow it — a small overdraft, or a merchant account that settles at the end of the day.

What varies? Fees do, by transaction type and by user tier. Limits do, per day and per transaction and per country. Both become interfaces.

State the contract: "I will design the ledger, top-up, peer-to-peer transfer and payment, with fees and limits pluggable. The bank and card provider are behind an interface. I am deferring currency conversion and dispute handling."

2. Classify it

A lifecycle problem over a strictly conserved resource. Transactions have states; money has an invariant that must never break. The invariant is the design.

3. The one decision that defines the answer: a ledger, not a balance

Here is the design almost everyone writes first:

typescript
class Wallet {                       
  balance: Money;

  debit(amount: Money): void {
    if (this.balance.lt(amount)) throw new InsufficientFunds();
    this.balance = this.balance.sub(amount);
  }
}

It is wrong, and not because of the race — the race is real and fixable. It is wrong because the balance is the only record of what happened.

Ask it any of these questions and it cannot answer: why is my balance £43.17? What was this £5 charge? Did that failed payment get refunded? Somebody's balance is wrong, when did it go wrong? A support agent adjusted a balance last Tuesday — who, and why?

The fix is the oldest idea in accounting. Store the movements, not the total.

typescript
type EntryKind = "topup" | "transfer" | "payment" | "refund" | "fee" | "adjustment";

interface LedgerEntry {                                          // (1) append-only, never edited
  readonly id: EntryId;
  readonly accountId: AccountId;
  readonly amount: Money;              // signed: negative is money leaving              // (2)
  readonly kind: EntryKind;
  readonly transactionId: TransactionId;                         // (3) groups the two sides
  readonly createdAt: Instant;
  readonly idempotencyKey: string;                               // (4)
}

(1) Entries are written once and never changed. Correcting a mistake means writing a new entry that reverses it, exactly as a real ledger does. A history you can edit is not a history.

(2) A signed amount, so a transfer is two entries: -500 on the sender and +500 on the receiver.

(3) Both sides carry the same transaction id, which is what lets you check that every transaction sums to zero.

(4) The client's key for this operation, and section 6 is about why it belongs on the entry itself.

The invariant that makes the whole thing verifiable: for any transaction, the entries must sum to exactly zero. Money is never created and never destroyed — it only moves between accounts. If a transfer of £5 produces -500 and +500, that sums to zero and is valid. If it produces -500 and nothing, the sum is -500 and money has vanished.

This is double-entry bookkeeping, and its power is that a single arithmetic check over your entire database tells you whether the system is sound. That is not available in any design where balances are edited in place.

Where does money come from, then? From an account outside the user's world. A top-up of £5 is -500 on a system account representing "money we received from the card provider" and +500 on the user. The system account goes more negative as more money enters, and its magnitude should equal what the bank says you hold. That comparison is your reconciliation, and it is only possible because the external world has an account too.

One transfer of £5 — transaction t-88entry 1 · account: ana · amount: −500 · t-88entry 2 · account: bo · amount: +500 · t-88sum = 0valid by constructionnothing created or lostAna's balance is never stored — it is derived+2000 topup−300 payment−500 transfer−25 feebalance = 1175Every question a support agent can ask is answerable from the entries.A stored balance can only answer one of them, and cannot prove it is right.
Figure 1 — Balance is a view, not a field. Storing movements makes history, audit and correction free; storing a total makes all three impossible.

"But summing every entry on every read is slow." Correct, and the answer is not to abandon the ledger. Keep a cached balance — a stored number maintained alongside the entries, updated in the same transaction that writes them. The entries remain the truth; the cached number is an optimisation you can rebuild at any time by re-summing. That distinction is the whole point: when the cached balance and the sum of entries disagree, you know which one is wrong.

4. Money as a type

number is not a money type, and an interviewer who sees amount: number will ask about 0.1 + 0.2.

typescript
class Money {
  private constructor(
    readonly minorUnits: number,        // (1) 1234 means £12.34
    readonly currency: Currency,
  ) {}

  static of(minorUnits: number, currency: Currency): Money {
    if (!Number.isInteger(minorUnits)) throw new Error("money must be whole minor units");
    return new Money(minorUnits, currency);
  }

  add(other: Money): Money {
    this.#assertSameCurrency(other);    // (2) adding GBP to EUR is a bug, not a conversion
    return new Money(this.minorUnits + other.minorUnits, this.currency);
  }

  splitEvenly(ways: number): Money[] {                            // (3)
    const base = Math.floor(this.minorUnits / ways);
    const remainder = this.minorUnits - base * ways;
    return Array.from({ length: ways }, (_, i) =>
      new Money(base + (i < remainder ? 1 : 0), this.currency));  // (4) pennies go to the first few
  }
}

(1) Integers, always. Pennies, cents, satoshis — whatever the smallest indivisible unit is.

(2) Currency is part of the type, so mixing them fails at the point of the mistake rather than producing a plausible wrong number three screens later.

(3) Splitting is where rounding bugs live, so the class owns it. This is the operation behind bill splitting (9.7.29) and behind fees.

(4) £10 split three ways is 334, 333, 333 — not three lots of 333 with a penny quietly lost. The parts must sum to the whole, and having the type guarantee it means no caller can get it wrong.

5. The transfer, with every guarantee named

typescript
async function transfer(cmd: TransferCommand): Promise<TransactionId> {
  return db.transaction(async tx => {                                        // (1)
    const existing = await tx.findByIdempotencyKey(cmd.idempotencyKey);
    if (existing) return existing.transactionId;                             // (2) already done

    const fee = feePolicy.feeFor(cmd);                                       // (3)
    const total = cmd.amount.add(fee);

    const debited = await tx.appendEntryIfFunded({                           // (4) conditional
      accountId: cmd.from, amount: total.negate(),
      kind: "transfer", idempotencyKey: cmd.idempotencyKey,
    });
    if (!debited) throw new InsufficientFunds(cmd.from);

    await tx.appendEntry({ accountId: cmd.to, amount: cmd.amount, kind: "transfer" });
    if (!fee.isZero()) {
      await tx.appendEntry({ accountId: FEES_ACCOUNT, amount: fee, kind: "fee" });  // (5)
    }
    return debited.transactionId;
  });
}

(1) One database transaction covers every entry. Either the whole transfer exists or none of it does, so the sum-to-zero invariant can never be observed broken.

(2) The idempotency check is inside the transaction and keyed on a unique index, so two simultaneous retries cannot both pass it. Checking before the transaction is a check-then-act race (9.5.1).

(3) The fee is computed once and included in what the sender must have. Charging a fee the sender could not afford is a way to produce a negative balance without noticing.

(4) The debit is conditional on sufficient funds, expressed as one statement:

sql
INSERT INTO ledger_entries (account_id, amount, kind, txn_id, idempotency_key)
SELECT :account, :amount, :kind, :txn, :key
 WHERE (SELECT COALESCE(SUM(amount), 0) FROM ledger_entries WHERE account_id = :account)
       + :amount >= 0;

The WHERE is the balance check and the INSERT is the debit, done together by the database. Zero rows means insufficient funds. Two simultaneous transfers cannot both pass, no matter how they interleave. In practice you would read the cached balance with a row lock rather than re-summing, but the shape — check and act as one statement — is the thing being demonstrated.

(5) The fee is its own entry to a fees account, so the transaction still sums to zero: -505 from the sender, +500 to the receiver, +5 to fees. If you subtracted the fee without an entry, five pence would vanish and your reconciliation would report a hole.

6. Idempotency, because the client will retry

A mobile client sends a transfer and the network drops the response. It retries. Without protection the money moves twice.

The key comes from the client, generated when the user taps send, and it stays the same across every retry of that action. A key generated by the server is useless, because a retry gets a new one.

The uniqueness must be enforced by the database, as a unique index on the key. Checking in application code is a check-then-act race that two simultaneous retries will both pass.

And the returned result must be identical. A retry should get the same transaction id and the same outcome, not a fresh "already processed" error, because the client cannot tell the difference between "your first attempt worked" and "something went wrong" if you answer with an error (9.6.3).

The subtlety worth volunteering: a key must be scoped to the operation and its parameters. If a client reuses a key for a different amount, that is a client bug, and the safe response is to reject it rather than return the old result — otherwise a bug in their retry logic silently turns a £500 transfer into a repeat of an earlier £5 one.

7. When real money is involved: the two-phase movement

Paying a merchant with a card is the ATM problem again (9.7.8): two systems, no shared transaction.

The shape is hold, then capture. Write a hold entry that reduces the available balance without moving anything, call the provider, and then either convert the hold into a real debit or release it.

typescript
type Balance = {
  settled: Money;      // (1) sum of completed entries
  available: Money;    // (2) settled minus active holds
};

(1) What the ledger says has actually happened. (2) What the user can spend right now. Two different numbers, and showing the wrong one is a real product bug — a user who sees settled will believe they have money that is already committed to a pending payment.

What happens when the provider never answers is the question that separates answers. You cannot know whether the payment succeeded. So the hold stays, and a reconciliation job compares your pending transactions against the provider's settled list on a schedule: anything they settled that you did not record gets captured, anything you hold that they never took gets released after a timeout. Reconciliation is not a nice-to-have here; it is the only thing that closes the gap between two systems that cannot share a transaction.

8. Where the races are

Two spends from the same wallet at the same moment. Handled by the conditional insert in section 5. The guarantee lives at the data, so it holds at one server or fifty.

A transfer between two wallets, and the reverse at the same time. Ana sends to Bo while Bo sends to Ana. If each transaction locks the sender first, they deadlock (9.5.3). Fix: sort the account ids and lock in that order, regardless of direction.

A hot merchant account. A merchant receiving thousands of payments a minute is a single row every writer wants. Credits do not need a balance check — they cannot make anything negative — so they can be appended without contending on a lock at all. If the cached balance becomes the bottleneck, split the account into several sub-accounts and sum them, which trades a slightly more expensive read for a much cheaper write.

9. What the interviewer will push on

"Where do you store the balance?" The question the whole problem turns on. The answer is that you store entries and derive the balance, keeping a cached total as an optimisation maintained in the same transaction. Then give the reason that is not about correctness: a stored balance cannot answer "why is it this number", cannot show history, cannot be audited, and cannot be proven right. Candidates who say balance: Money and move on have failed the main question without knowing it.

"How do you know money has not been lost?" Double entry. Every transaction's entries sum to zero, so one query over the whole database verifies the system. Add that external money enters through a system account, so the magnitude of that account is what you reconcile against the bank's figure — money appearing from nowhere is impossible to represent.

"The client retries a transfer. What stops a double send?" A client-generated idempotency key with a unique index in the database, checked inside the transaction, returning the identical original result on a repeat. Say why the check must be in the database: two simultaneous retries both pass an application-level check.

"How do you handle a payment provider that times out?" Hold and capture, plus reconciliation. There is no ordering of two systems that survives a crash between them, so you make the state recoverable and compare against the provider's records on a schedule. Mentioning available versus settled balance unprompted is the detail that shows you have built one.

"Two users transfer to each other simultaneously." Deadlock, and the fix is sorting the account ids before locking. It is the same fix as two rows in any transaction, and naming it as a general rule scores better than solving this instance.

"Why not just use floating point for money?" Because 0.1 + 0.2 is not 0.3 (1.4), and small errors accumulate over millions of transactions into a discrepancy nobody can explain. Integer minor units inside a Money type, with currency in the type so mixing currencies is a compile error.

The thing to volunteer that nobody asks for: an adjustment entry kind, for when support has to correct something. It sounds like a small detail and it is the difference between a system that can be fixed and one that cannot. Without it, the only way to correct a wrong balance is to edit history, which destroys the audit trail. With it, corrections are visible, attributable, and reversible — and every one of them is a signal that something upstream needs fixing.

Next: 9.7.11 — a problem that looks like a data structure exercise and is really about what a name is.

Recall

  • Store movements, not totals. An append-only ledger of signed entries; balance is derived. A cached balance is an optimisation maintained in the same transaction and rebuildable by re-summing.
  • Double entry: every transaction's entries sum to zero, so one query verifies the whole system. External money enters via a system account, which is what you reconcile against the bank.
  • Corrections are new reversing entries, never edits. Keep an adjustment kind so support corrections are visible and attributable.
  • Money is a type: integer minor units, currency inside the type, and splitEvenly owned by the class so the parts always sum to the whole.
  • The debit is one conditional statement — balance check and insert together — so two simultaneous spends cannot both pass.
  • Fees get their own entry to a fees account, or the transaction stops summing to zero.
  • Idempotency key comes from the client, is enforced by a unique index, is checked inside the transaction, and returns the identical original result on a retry.
  • Real money moves in two phases: hold then capture, with available and settled as two different balances, and reconciliation as the only thing that closes the gap when the provider times out.
  • Sort account ids before locking or mutual transfers deadlock.

Self-test: Give two reasons a stored balance is wrong that have nothing to do with races. What does "sums to zero" let you check, and how does external money fit? Why must the idempotency check be in the database? What are available and settled, and what breaks if you show the wrong one? Where does the fee entry go and why?

Quiz Bank

FoundationalWhy store a ledger of entries instead of a balance field, given that a balance field is simpler and faster?

The race is the least of it, and starting there is a mistake — a race can be fixed with a conditional update. The deeper problem is that a balance field records a conclusion and throws away the evidence.

Ask a balance field any of these and it cannot answer. Why is my balance £43.17? What was this charge for? Did that failed payment get refunded, or did it just vanish? A customer says their balance is wrong — when did it go wrong, and what changed it? Somebody adjusted a balance last Tuesday — who, and why?

Every one of those is a real support ticket, a real regulatory requirement, or a real debugging session, and none of them is answerable from a number.

Storing entries answers all of them for free, because the history is the data structure rather than something you remembered to log alongside it. And a log written separately from the balance is worse than useless, because the two can disagree and you have no way to tell which one is right.

The property that makes it verifiable is double entry. Every transaction writes entries that sum to exactly zero — money leaving one account arrives in another. So a single query summing all entries per transaction tells you whether the system has ever created or destroyed money. That is a whole-database correctness check that costs one query, and no design based on editing balances can offer anything like it.

Money entering from outside gets an account too, which is the part people miss. A £5 top-up is not "+500 from nowhere"; it is +500 on the user and -500 on a system account representing money received from the card provider. That account's magnitude should equal what the bank says it holds, and comparing the two is your reconciliation.

On the performance objection: you keep a cached balance, stored and maintained in the same transaction that writes the entries. Reads use the cache. The entries remain the truth. The key property is that when the two disagree you know which one is wrong and can rebuild the cache — which is precisely what you cannot do when the balance is all you have.

And corrections stay honest. With a ledger, fixing a mistake means writing a reversing entry, so the mistake and the fix are both visible forever. With a balance field, fixing it means editing history, and a history you can edit is not a history.

AppliedA user taps Send twice because the app froze. Walk through everything that stops the money moving twice.

Layer one, and the only one that actually guarantees it: an idempotency key with a unique database index.

The key is generated by the client at the moment the user taps send, and it stays the same for every retry of that action. This detail matters: a server-generated key gets regenerated on each attempt and prevents nothing.

The key is stored on the ledger entry with a unique index. When the second request arrives, one of two things happens. If the first has completed, the lookup inside the transaction finds it and returns the original transaction id. If the first is still in flight, the second one's insert violates the unique constraint and fails — and the handler treats that violation as "already processing", waits briefly and returns the original result.

Why the check must be in the database rather than in application code. An application-level "have I seen this key?" is a check-then-act race. Two simultaneous retries both look, both find nothing, and both proceed. The unique index is the only mechanism where the check and the act are one operation, and it works across any number of servers (9.5.1).

Layer two: the response must be identical, not an error. A retry should return the same transaction id and the same status as the original. Returning "duplicate request" as an error is a real product failure, because the client cannot distinguish it from "your transfer failed" and will show the user something alarming about a transfer that actually succeeded.

Layer three: the whole thing is one database transaction. The idempotency lookup, the debit entry and the credit entry are atomic, so there is no window where the key is recorded but the entries are not, or the reverse.

Layer four, and worth volunteering: the key is scoped to the parameters. If a client sends the same key with a different amount, that is a bug in their code, and the safe response is to reject it. Returning the earlier result would silently turn their £500 transfer into a repeat of an earlier £5 one, which is a much worse failure than an error.

What about the user tapping send twice deliberately, wanting two transfers? The app must generate a new key when the user starts a new action, and reuse the key only for automatic retries of the same one. That is a client-side responsibility and it belongs in the API documentation, because getting it wrong in either direction — a fresh key on retry, or a stale key on a new action — produces exactly one of the two bugs above.

And the reason this design is worth its complexity: the network will drop responses, phones will lose signal mid-request, and users will double-tap. Every one of those is normal. A wallet without idempotency does not have a rare bug; it has a bug that fires whenever the mobile network is poor.

InterviewExplain double-entry bookkeeping to someone who has never seen it, and say what it buys a software system specifically.

The idea in one sentence: money never appears or disappears, it only moves, so every recorded movement has two sides.

When Ana sends Bo £5, you do not record "Ana lost £5" and separately "Bo gained £5" as two independent facts. You record one transaction with two entries: -500 against Ana and +500 against Bo, both carrying the same transaction id. The two entries sum to zero.

That is the whole rule, and it is six hundred years old because it works.

What it buys a software system, specifically.

A correctness check over the entire database, in one query. Group every entry by transaction and sum. Every group must be zero. If any group is not, money was created or destroyed, and you know exactly which transaction did it. No design based on editing balance fields can offer anything comparable — you cannot check whether a set of numbers is "right" when the numbers are all you have.

Money from outside becomes representable. A top-up looks like it violates the rule, because £5 appears. It does not, because the counterpart is a system account representing money received from the card provider. That account grows more negative as money enters, and its magnitude is what you compare against the bank's statement. Reconciliation becomes an equality check rather than a guess.

Fees stop leaking. If a transfer takes £5 from Ana, gives £5 to Bo and quietly keeps 5p, the transaction sums to -5 and your check catches it immediately. Forcing the fee to be its own entry to a fees account keeps the books balanced and, as a side effect, means your total fee revenue is a query rather than a calculation.

Corrections stay auditable. You never edit an entry. A mistake is fixed by writing a reversing entry, so both the error and the correction are permanently visible, with a timestamp and an actor. Regulators require this, and more practically, so does anyone debugging a discrepancy six months later.

It makes the balance derived rather than asserted. A balance is the sum of an account's entries. That means it is always explainable — every penny traces to a movement — and always rebuildable, which is what lets you treat a cached balance as an optimisation rather than as the truth.

The one-sentence pitch to a sceptical engineer: it converts "is our money data correct?" from an unanswerable question into a query you can run every five minutes and alert on.

StaffReconciliation finds that your ledger says you hold £4,203,118 but the bank says £4,203,061. Design the investigation and the prevention.

A £57 gap. Start by noting what it is not: it is not a rounding error. Integer minor units do not drift, so this is one or more specific events, and the goal is to name them rather than to adjust a number.

Step one: decide which side is more likely wrong, and the answer is usually neither. The most common cause is timing rather than error — a transaction settled by the bank after your snapshot, or one you recorded that the bank settles tomorrow. So the first move is to reconcile over a closed window with a settlement cut-off, comparing like with like, rather than comparing two instantaneous figures taken at different moments. A large fraction of apparent discrepancies disappear here, and starting anywhere else wastes a day.

Step two: reconcile per transaction, not per total. A total tells you a number; a per-transaction comparison tells you which ones. Take the bank's settled list and your ledger entries against the system account for the same window, and match on the provider's reference. Three buckets fall out.

In the bank, not in your ledger. Money arrived and you never recorded it — a webhook you missed, or a response you lost. The user is short, which is the urgent case.

In your ledger, not at the bank. You recorded money that never actually arrived, or a payout you believe went out and did not. Your books overstate holdings.

In both, with different amounts. Usually a fee the provider deducted that you did not record, and a £57 gap across a busy day looks very much like accumulated fees.

Step three: fix the specific entries, in the ledger's own idiom. Never edit history. Write reversing or correcting entries with kind adjustment, each referencing the investigation, so the correction is as visible as the error. If a user was short, credit them and record why.

Step four, which is the real deliverable: stop it recurring.

Record fees as first-class entries. If the provider deducts a fee on settlement and your code only records the gross amount, you will drift by exactly the fee total every day. This is the single most common cause of a slow, steady discrepancy, and the fix is that every provider fee becomes an entry to the fees account so the transaction still sums to zero.

Make webhook handling idempotent and gap-detecting. Providers deliver notifications more than once and occasionally not at all. Idempotency handles the duplicates; a periodic pull of the provider's transaction list handles the missing ones. Relying on webhooks alone is relying on somebody else's delivery guarantee for your books.

Reconcile continuously, not monthly. A daily job with an alert on any non-zero difference turns a £57 mystery spanning weeks into a single transaction you can look at while the logs still exist. The cost of investigation grows steeply with age, because context disappears.

Alert on the trend as well as the value. A discrepancy that grows by a steady amount every day is a systematic bug — almost always an unrecorded fee category. One that appears once is an incident. The two need different responses, and the shape of the number over time tells you which you have long before the root cause does.

And the governance point worth making. Someone must own the reconciliation, with the authority to stop payouts if the gap exceeds a threshold. A discrepancy nobody is accountable for grows quietly for months, and by the time it is large enough to force attention, the evidence needed to explain it has been rotated out of the logs.

Flashcards

FlashLedger, not balance

Store signed append-only entries; derive the balance. A cached balance is an optimisation written in the same transaction and rebuildable. Corrections are reversing entries, never edits.

FlashDouble entry

Every transaction's entries sum to zero. One query verifies the whole database. External money enters via a system account, whose magnitude is what you reconcile against the bank.

FlashThe conditional debit

Balance check and insert in one statement; zero rows means insufficient funds. Two simultaneous spends cannot both pass, at one server or fifty.

FlashIdempotency in a wallet

Client-generated key, unique index in the database, checked inside the transaction, identical original result returned on retry. Reject a reused key with different parameters.

FlashAvailable versus settled

Settled = sum of completed entries. Available = settled minus active holds. Showing settled where available belongs lets a user spend money already committed.

FlashMoney type

Integer minor units, currency inside the type, splitEvenly owned by the class so parts always sum to the whole. £10 three ways is 334/333/333.