A payment system starts with a ledger, not an API
Why double-entry bookkeeping is not an implementation detail when you move other people's money.
When you start a payment product, the temptation is to begin with what shows: the send screen, the balance on display, the receipt notification. That is the part you demo. It is not the part that matters.
What matters is the question you must be able to answer at any moment, without hesitation: where is every cent, and how did it get there?
A balance is not data
The first mistake, I have seen it several times: a wallets table with a balance column, and functions that increment or decrement it. Simple, fast, and wrong.
Wrong because a stored balance is a claim without evidence. If two transactions modified it at the same time, if an update failed halfway, if someone fixed it by hand on a Friday evening — nothing in the data lets you know.
In a double-entry ledger the balance is not stored: it is computed as the sum of an account's entries. Every movement of money produces at least two entries, one debit and one credit, summing to zero. You cannot create money, you cannot lose it. A transaction that does not balance is rejected by the database, not by an application check that someone will eventually bypass.
What it changes in the code
Concretely, the core fits in three tables: accounts, transactions, entries. Everything else — wallets, transfers, FX, fees — is a way of producing entries.
-- A transaction is valid if and only if its entries balance.
create table entries (
id uuid primary key,
transaction_id uuid not null references transactions(id),
account_id uuid not null references accounts(id),
amount numeric(20, 4) not null, -- positive on credit, negative on debit
currency char(3) not null
);
create or replace function assert_balanced(tx uuid) returns void as $$
begin
if (select sum(amount) from entries where transaction_id = tx) <> 0 then
raise exception 'transaction % is not balanced', tx;
end if;
end;
$$ language plpgsql;The constraint lives in the database because it is the only place every code path goes through. A forgotten service, a migration script, a manual fix: everything passes there.
Fees, FX, failures
A Mobile Money transfer to a bank account in another currency, with fees, is not one entry: it is six. The customer's debit, the credit to the transit account, the fees to the revenue account, the conversion through the FX account, the final credit.
It looks heavy. It is exactly the opposite. When a customer asks why they received 4,950 instead of 5,000, the answer is in the entries, line by line, with the timestamp and the rate applied. Without a ledger the answer is "we'll look into it", and "we'll look into it" is what loses a customer in payments.
And when an operation fails halfway, you do not "fix" it: you post a reversing transaction. Nothing is ever deleted. The history is the data.
Compliance lives in the same place
KYC, limits, sanctions lists: the instinct is to put them "in front", in a validation layer. I put them inside the transaction engine, as an acceptance condition, at the same level as balanced entries.
The reason is the same: it is the only place nothing bypasses. A compliance rule on the periphery ends up with an exception "for the admin", then "for tests", then for nobody remembers who.
What it costs
Time, up front. The ledger is the first brick of Nexus Pay and it took longer than the send screen ever will. But it is the brick you never rewrite: everything that comes after — payment rails, currencies, regulators — plugs into it without touching it.
In a payment system the only real question is trust. And trust is not declared in an interface. It is proven in the entries.
Read next
One hard dependency: a PostgreSQL connection string
How we designed an ambitious platform so it starts on a modest server — no Docker, no Redis, no queue.
Isolating several companies inside a single database
Row Level Security in PostgreSQL: the superuser trap, and why an application-level filter is not enough.
FastAPI in production: what actually matters
Five settings you won't find in any tutorial, and that separate an API that runs from an API you can operate.