Why a balance column is not enough
The first version of most payment features stores a number. A user has a balance, and a payment subtracts from it. It works in a demo, it works for a month, and then somebody asks a question the number cannot answer: why is this balance what it is?
At that point the team discovers that a balance written directly into a column has no memory. It tells you where you are and nothing about how you got there. If two requests update it at once, one may be lost. If a bug subtracts twice, nothing records it. If an auditor asks you to prove the total, you have no proof, only a total.
Accountants solved this centuries ago. The solution is double entry bookkeeping, and engineers who build anything involving money are well served by learning it properly.
The core idea
In double entry, no amount ever appears from nowhere or disappears into nothing. Every transaction records value leaving one place and arriving in another, as a set of entries that sum to zero.
If a customer pays you 100, you do not simply add 100 to a balance. You record two entries: 100 into your bank account, and 100 recognised as owed to the customer, or earned as revenue. Both sides are written together, and together they balance.
This yields one powerful invariant: across the whole system, debits equal credits. If you ever find that they do not, something is wrong, and you know it immediately instead of at year end.
The pieces
There are only a handful of concepts, and they map neatly onto tables.
- Account: a named bucket of value, such as a customer wallet, a bank account, a fees account or a suspense account.
- Transaction: one business event, such as a payment or a refund, which groups related entries.
- Entry (or posting): a single line in a transaction, adding to or subtracting from one account by an amount.
The rule is that the entries within a transaction must sum to zero. The balance of an account is not stored as an input. It is the sum of its entries.
A minimal schema in PostgreSQL might look like this:
CREATE TABLE accounts (
id bigserial PRIMARY KEY,
name text NOT NULL,
currency char(3) NOT NULL
);
CREATE TABLE transactions (
id bigserial PRIMARY KEY,
reference text NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE TABLE entries (
id bigserial PRIMARY KEY,
transaction_id bigint NOT NULL REFERENCES transactions(id),
account_id bigint NOT NULL REFERENCES accounts(id),
amount bigint NOT NULL -- minor units, signed
);
A customer funds their wallet with 5,000 naira, held as 500000 kobo. The transaction has two entries: plus 500000 to the customer wallet account, and minus 500000 to the settlement account that represents the bank money behind it. They sum to zero.
Rules that save you later
Never edit or delete entries. The ledger is append only. If a mistake needs correcting, post a new transaction that reverses it. This preserves history and makes every balance explainable at any point in time.
Use integers for money. Store amounts in minor units, kobo or pence or pesewas, as integers. Floating point arithmetic cannot represent many decimal values exactly, and the errors accumulate quietly. Keep a currency with every amount, and never add amounts in different currencies.
Make balance validation a database matter, not a hope. The sum of entries in a transaction should be checked at write time, inside the same database transaction, so a half written event cannot exist. Many teams add a constraint trigger or do the check in a single function that is the only way to write entries.
Use idempotency keys. Networks retry. If a payment request is sent twice, you must not post it twice. A unique reference on the transaction, as in the schema above, turns a duplicate into a harmless conflict.
Do not mutate a balance without an entry. If a balance is cached for speed, treat it as a derived value that can always be rebuilt from entries, and compare the two regularly.
Concurrency and locking
The harder part is protecting against overspending when requests arrive together. Two withdrawals of 80 from a wallet holding 100 must not both succeed.
There are several sound approaches. You can lock the account row for the duration of the transaction, so the second request waits, then re-reads the balance. You can maintain a locked running balance on the account that is updated in the same transaction as the entries. Or you can use serialisable isolation and handle retries. The right choice depends on volume and on how contended your hot accounts are.
A warning on hot accounts: a single fees account that every transaction touches becomes a bottleneck. Common remedies include splitting it into several sub accounts and summing them for reporting.
Modelling real events
Real payments are not instant, so model the stages instead of pretending they are.
A card payment might first move value from the customer to a pending account, and later from pending to your settled account once funds arrive. Fees post to their own account. Refunds are new transactions in the opposite direction, not deletions. Value in transit sits in a clearly named holding account, and an account that should always drain to zero, a suspense account, shows you at once when something has gone unmatched.
This turns the ledger into a map of where money is, at every moment, instead of a single outcome.
Balances at a point in time
Because entries are never changed, you can ask what an account held at any moment. Sum the entries up to that timestamp and you have it. This is what makes month end statements, dispute investigations and regulatory questions routine instead of forensic. A customer who says their balance was wrong last Tuesday gets an answer built from facts, not a guess.
For speed, many systems keep periodic snapshots, such as a closing balance each day, and add only the entries since. The snapshot is a convenience. The entries remain the truth, and the snapshot should always be verifiable against them.
Currencies
Multiple currencies do not break the model, but they need discipline. Keep every account in a single currency, and record a currency exchange as a transaction that moves value out of one currency account and into another, through a dedicated conversion account on each side, with the rate stored on the transaction. Each currency then balances on its own, and gains or losses from rate movements appear in an account you can inspect.
Reconciliation
A ledger is your own record. The bank, the card processor and the mobile money operator each hold theirs. Reconciliation compares them, line by line, and every difference needs an explanation.
Because your ledger is itemised and append only, you can do this properly. A mismatch becomes a specific question: this settlement line has no matching entry. You are no longer comparing two totals and wondering.
Mistakes we see
- Storing balance as the source of truth, with entries as an afterthought.
- Using floats or decimals with loose rounding rules.
- Allowing updates and deletes on entries "just this once".
- Omitting a currency, then discovering two wallets cannot be compared.
- Treating the ledger as a reporting table rather than the system of record.
Where to start
You do not need a large system to benefit. Even a modest product that holds customer value should begin with accounts, transactions and entries that sum to zero. It costs little at the start and a great deal to retrofit.
Build the ledger first and the features on top of it. When someone asks why a balance is what it is, the answer will be sitting in the table.
