The first wallet system I inherited stored balances as a single column on the user row. A background job updated it. When two payments landed in the same 200 milliseconds, the job read the same starting balance twice, and one of the two credits simply vanished. We found out three weeks later when a merchant did his own arithmetic and it did not match ours. That is the moment you learn, in your gut, why a wallet is not a number.
A wallet balance is a conclusion, not a fact. The facts are the individual movements of money in and out, and the balance is whatever those movements happen to add up to right now. Once you internalise that, most of the hard design questions answer themselves. This is a walk through how I build wallet systems now, after making enough of the classic mistakes to have opinions about them.
The balance is derived, never stored as truth
The core rule I will not compromise on: the authoritative record is an append-only list of entries, and the balance is the sum of those entries. You never UPDATE a balance. You INSERT a movement. If you want to know what someone has, you add up their history, or you read a cached total that you can always reconstruct from that history.
This sounds academic until you have to answer a support ticket that says "my balance is wrong". With a stored balance, you have nothing. There is no trail, no way to prove what happened, and no way to correct it without introducing yet another unaudited write. With a ledger, you print the entries, point at the one that is wrong or missing, and the disagreement resolves itself in minutes. I have watched a disputed 4,000 euro balance get settled in a ten-minute call because both sides were reading the same list of movements. That is the whole value proposition.
Double-entry is not accountant theatre
Every movement of money touches at least two accounts, and the amounts must net to zero. Money is never created or destroyed inside your system; it moves from somewhere to somewhere. When a user tops up 100 euros by card, that is not "user wallet +100". It is "user wallet +100" and "card settlement account -100". The two entries share a transaction id and together they balance.
People new to this ask why they cannot just record the one entry they care about. The answer is that the second entry is what makes the whole system self-checking. At any moment, the sum of every entry across every account in a given currency must be zero. If it is not, you have a bug, and you have it right now, not at month-end. We run that invariant as a scheduled check every fifteen minutes across roughly forty million entries, and the day it returns a non-zero number is the day we stop deploying and start reading logs.
The ledger does not care about your feelings or your deadline. If the entries do not sum to zero, something is wrong, and no amount of arguing in Slack will change the arithmetic.
What actually goes in an entry
An entry is small and boring on purpose. The columns I keep: a monotonic id, the account it affects, a signed amount stored in minor units as an integer, the currency, the transaction id that groups it with its counterpart, a type, and a created timestamp that never changes. No floats, ever. A balance in floating point is a lawsuit waiting for a rounding error.
Here is the constraint that has saved me more than once. Amounts are integers, currency is explicit on every row, and you never mix currencies inside a single transaction without an explicit FX pair of entries. A wallet holding euros and pounds is two logical accounts, not one account with a confused sum.
CREATE TABLE ledger_entry (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
account_id BIGINT NOT NULL,
tx_id UUID NOT NULL,
amount_minor BIGINT NOT NULL, -- signed, e.g. cents
currency CHAR(3) NOT NULL,
entry_type VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- The invariant: every transaction nets to zero, per currency.
SELECT tx_id, currency, SUM(amount_minor) AS net
FROM ledger_entry
GROUP BY tx_id, currency
HAVING SUM(amount_minor) <> 0;
-- This query must always return zero rows.
Idempotency, because networks lie
The payment gateway will call your webhook twice. The mobile client will retry on a flaky train connection and fire the same top-up three times. If your system processes each of those as a fresh credit, you have just invented money, and inventing money is the one bug in fintech that gets a meeting with your regulator.
Every request that writes to the ledger carries a client-supplied idempotency key. Before writing, you check whether that key has already produced a transaction. If it has, you return the original result and write nothing new. I enforce this at the database with a unique constraint on the key, not in application code, because application-level checks lose to concurrency every single time. Let the database be the referee.
public async Task<LedgerResult> PostAsync(TransferRequest req)
{
// The unique index on idempotency_key is the real guard.
try
{
return await _repo.InsertTransactionAsync(req);
}
catch (UniqueViolationException) when (req.IdempotencyKey is not null)
{
// Someone already posted this exact request. Return the original.
return await _repo.GetByIdempotencyKeyAsync(req.IdempotencyKey);
}
}
Concurrency and the race I described up top
Back to that vanished credit. The fix is to make concurrent writes to the same account serialise, or to make them safe to reorder. I strongly prefer the second where I can get it. Because entries are append-only and the balance is a sum, two credits to the same account do not actually conflict; order does not matter, they both just get inserted. The trouble only appears when a movement depends on the current balance, such as a withdrawal that must not overdraw.
For those, I take a short lock on the account row, read the current balance, decide, and insert, all inside one transaction. It is a boring pessimistic lock and it is fine. Wallet accounts are not high-contention like a hot inventory row; a single user rarely fires two withdrawals in the same millisecond. When I have needed more throughput, I have used a per-account version number with optimistic retry rather than reaching for anything exotic.
- Pure credits: no lock, just insert. Order is irrelevant.
- Balance-dependent debits: row lock on the account for the length of one short transaction.
- Batch settlements: process per-account so one slow account cannot stall the whole run.
- Never a distributed lock across services when a single database transaction will do the same job with fewer moving parts.
Caching the balance without lying about it
Summing millions of entries on every read does not scale, so you cache the balance. The discipline is that the cache is always reconstructible and never the source of truth. I keep a running balance column that is updated inside the same transaction as the entry insert, so the two can never drift. And separately, I keep the ability to recompute from scratch and compare.
That recompute-and-compare job is not optional. Run it nightly against a read replica, sum every account from raw entries, and diff against the cached figure. The first time it flags a mismatch you will be tempted to assume the job is broken. Sometimes it is. But the one time in five that it is real, you have just caught a corruption before a customer did, and that is worth every CPU minute the job costs.
You cannot delete a mistake, only reverse it
Somebody will post the wrong amount. A refund will fire against a transaction that was itself an error. The instinct is to go into the database and fix the bad row. Do not. An append-only ledger has no delete and no update; a mistake is corrected by posting a reversing entry that cancels the original and leaves both visible forever.
This matters for more than tidiness. When an auditor or a regulator asks what happened, "we posted 50 euros, realised it was wrong, and reversed it" is a clean story with two entries to show. "We changed a row" is not a story at all, it is a red flag. Immutability is what lets you say, credibly, that the history you are showing is the history that happened. I have sat in a due-diligence review where the ability to produce an unbroken, tamper-evident entry trail shortened the whole conversation by days.
Reconciliation against the outside world
Your ledger being internally consistent is necessary but not sufficient. The money it describes lives at a bank or a payment processor, and their view is the one that pays out. Every day I reconcile our settlement accounts against the processor's report. For every euro we think we hold, there must be a euro they agree we hold. Discrepancies get an entry in a dedicated suspense account and a human to chase them, not a quiet adjustment that hides the gap.
A concrete example of why this saves you: a processor once reported a chargeback two days after we had already paid out the underlying balance. Because our ledger had the payout recorded as its own transaction and the chargeback landed as another, we could see the exact sequence, book the loss to the right account, and adjust our payout timing rules so the same window could not be exploited again. Without that separation, it would have been an unexplained hole in a stored number.

What I would tattoo on every payments engineer
One sentence: the ledger is the product. The pretty balance in the app is a convenience, a cache, a view. The thing you are actually building and the thing that keeps you out of trouble is the honest, append-only record of every movement, each one balanced, each one immutable, each one reconstructible. Build that first and build it strictly, and the wallet, the statements, the reports, and the regulator conversations all fall out of it almost for free. Cut the corner and store a bare number, and you will spend years paying interest on that decision, usually at the worst possible moment, usually in front of a merchant with a calculator.
