Every payments codebase eventually grows a table called accounts with a balance column and an UPDATE accounts SET balance = balance - $1 somewhere in a service. It works fine until the first retry storm, the first partial failure mid-transfer, or the first refund that runs twice. Then money appears or disappears, and no log tells you why.
This post walks through the ledger design we use when building the money core of a mobile payment platform. The goal is simple to state: the ledger must stay correct under retries, concurrency and partial failures, and it must be able to prove that it is correct every day.
Principle 1: entries are facts, balances are derived
A ledger records movements, not states. Each movement is a transaction made of two or more entries, and the entries in a transaction must sum to zero. Money leaving one account is a negative entry; money arriving is a positive one.
Entries are append-only. You never UPDATE or DELETE a posted entry. If something was wrong, you post a new transaction that reverses it. Your history stays complete and auditable, which matters when an auditor or regulator asks why a balance is what it is.
A minimal Postgres schema:
CREATE TABLE ledger_accounts (
id uuid PRIMARY KEY,
owner_id uuid NOT NULL,
kind text NOT NULL, -- 'customer', 'merchant', 'processor_clearing', 'fees'
currency char(3) NOT NULL
);
CREATE TABLE ledger_transactions (
id uuid PRIMARY KEY,
idempotency_key text NOT NULL UNIQUE,
state text NOT NULL CHECK (state IN ('pending','posted','reversed','failed')),
reverses_id uuid REFERENCES ledger_transactions(id),
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE ledger_entries (
id bigserial PRIMARY KEY,
transaction_id uuid NOT NULL REFERENCES ledger_transactions(id),
account_id uuid NOT NULL REFERENCES ledger_accounts(id),
amount_minor bigint NOT NULL, -- signed, in minor units (cents)
balance_type text NOT NULL CHECK (balance_type IN ('ledger','available'))
);
REVOKE UPDATE, DELETE ON ledger_entries FROM app_role;
Two details matter. Amounts are integers in minor units, never floats. And the application role physically cannot update or delete entries, so "append-only" is enforced by the database rather than by convention.
Principle 2: idempotency keys on every write
Mobile networks drop responses. Clients retry. Processors resend webhooks. A write that is not idempotent will eventually execute twice.
Every money-moving request carries an idempotency key, generated by the caller once and reused on every retry. The unique constraint on idempotency_key does the heavy lifting:
async function postTransfer(req: TransferRequest) {
return db.tx(async (t) => {
const inserted = await t.oneOrNone(
`INSERT INTO ledger_transactions (id, idempotency_key, state)
VALUES ($1, $2, 'pending')
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id`,
[uuid(), req.idempotencyKey]
);
if (!inserted) {
// Seen before: return the original outcome, never re-execute.
return t.one(
`SELECT id, state FROM ledger_transactions WHERE idempotency_key = $1`,
[req.idempotencyKey]
);
}
await t.none(
`INSERT INTO ledger_entries (transaction_id, account_id, amount_minor, balance_type)
VALUES ($1, $2, $3, 'ledger'), ($1, $4, $5, 'ledger')`,
[inserted.id, req.from, -req.amount, req.to, req.amount]
);
return t.one(
`UPDATE ledger_transactions SET state = 'posted'
WHERE id = $1 AND state = 'pending' RETURNING id, state`,
[inserted.id]
);
});
}
Forward the same key to your processor, which most major providers support, so the external side deduplicates too. A key scoped to your ledger alone does not stop the processor from executing a second refund.
Principle 3: a state machine that forbids illegal moves
Transactions move through a small set of states: pending, posted, reversed, failed. The legal transitions are narrow:
-
pendingtopostedorfailed -
postedtoreversed(only by posting a linked reversing transaction) - nothing leaves
failedorreversed
Encode this in one place. The WHERE state = 'pending' guard in the update above is a cheap example: a concurrent handler that tries to post an already-failed transaction simply updates zero rows. Pair it with an explicit transitions map in code and reject anything outside it. Without this, a late webhook can happily move a reversed payment back to posted.
Principle 4: ledger balance vs available balance
Payments have holds. A card authorisation reserves funds that have not settled; a payout may be initiated but not yet confirmed by the bank. If you model only one balance, you will either let users spend money that is not really there or block money that is.
Keep two views: a ledger balance of posted, settled value, and an available balance that also subtracts active holds. The balance_type column above is one way to do it; separate hold accounts are another. Either way, compute them from entries:
SELECT account_id,
SUM(amount_minor) FILTER (WHERE balance_type = 'ledger') AS ledger_balance,
SUM(amount_minor) AS available_balance
FROM ledger_entries
WHERE account_id = $1
GROUP BY account_id;
At scale you will cache these in a snapshot table updated in the same database transaction as the entries, but the entries remain the source of truth.
Principle 5: prove it daily
If every transaction sums to zero, the whole ledger sums to zero. That gives you a free invariant:
SELECT currency, SUM(e.amount_minor) AS drift
FROM ledger_entries e
JOIN ledger_accounts a ON a.id = e.account_id
GROUP BY currency
HAVING SUM(e.amount_minor) <> 0;
Any row returned is a page. Run it daily alongside reconciliation against processor and bank settlement files, where you match your processor clearing account against what the processor says it settled. Breaks should be investigated and explained, not written off.
Postgres or a dedicated ledger database?
Postgres with careful locking gets most teams a long way, especially if you lock accounts in a consistent order to avoid deadlocks. Purpose-built ledger databases help at very high throughput. But the technology matters less than the discipline: we have audited systems on impressive infrastructure that still had no reversing-entry model.
For the wider context, covering the other six services, compliance, rails and AI, see the full guide on mobile payment platform development. If you want help designing the money core of a new product, our custom software development team builds exactly this.
Frequently Asked Questions
Why store amounts as integers instead of decimals?
Integers in minor units avoid rounding drift from floating-point arithmetic. Postgres numeric is also safe, but signed bigint cents are fast, simple and unambiguous across languages.
Where should the idempotency key come from?
From the caller, generated once per logical operation and resent unchanged on every retry. If the server generates it, a retried request gets a new key and the protection disappears.
How do I correct a mistaken ledger entry?
Post a new transaction with equal and opposite entries, linked to the original via a reference such as reverses_id. Never edit or delete the original entry.
Do I need both a ledger balance and an available balance?
Yes, if you have authorisations, holds or delayed settlement. Otherwise you either let users spend unsettled funds or lock money that should be usable.
What should happen when the zero-sum check fails?
Alert immediately and stop automated payouts for the affected currency until an engineer explains the drift. A non-zero sum means an unbalanced write reached the ledger.
Can I retrofit a double-entry ledger into an existing app?
Yes, but it is painful under live traffic. Migrate by replaying historical transactions into entries, running both systems in parallel and reconciling them before switching reads over.


Top comments (0)