Your wallets are a liability
Building a Ledger, #2 of 12
Last year, first day on a wallet ledger for one of the big processors here. Vendor meeting, before anybody had opened a laptop, and somebody said the line I have not been able to unhear since:
Do not write code you will have to defend in front of the EFCC.
Everybody laughed. Nobody was joking.
It took me a while to work out what was actually being said. It was not do not commit fraud. Nobody in that room was planning to. It was narrower than that, and much more uncomfortable:
If this is ever examined, nobody reads your code. They read your rows.
Post #1 built a ledger that cannot create or destroy money. Every transaction sums to zero, enforced by the database, no exceptions. It works.
It also cannot tell you whether the money in it is yours.
The founder on the call
He is asked how much the company is holding. He runs this:
SELECT SUM(balance) FROM wallets;
-- 2,400,000,000
₦2.4 billion. He says it out loud.
That number is wrong in the most important way a number can be wrong. It is not money the company has. It is money the company owes. Every naira in a customer wallet is a debt, payable on demand, to somebody who can walk into an agent shop and ask for it. What the company actually has is whatever is sitting in the settlement bank account, and that is a completely different number.
The gap between those two numbers is what this post is about. In this market it is also the distance between a soft chair in your own office and a concrete floor in an EFCC detention facility.
Which is why it belongs in the schema on day zero, not day one.
Nobody tells you which rows to write
Fair warning before we go further: there are about thirty lines of code in this post and they are the least interesting part.
Here is the bit that never makes it into the ticket.
You get handed let users send money to each other. You write the function. Debit one wallet, credit the other, wrap it in a transaction, done before lunch. Post #1 already dealt with the part that looked hard, which was the concurrency.
What nobody mentions is that the function you just wrote will move money it does not understand. It cannot tell a customer's deposit from a promo credit. It cannot tell your revenue from somebody else's float. It does not know that one of those numbers is not yours. It moves all of them with equal confidence, the sum-to-zero check passes every single time, and the books balance the whole way down.
Then somebody asks where the money went, and the answer gets reconstructed from the rows you chose to write.
Not the code. The rows.
So the claim is this: an amount is not a fact until you know what kind of account it sits on. Post #1 gave us numbers that always conserve. This post gives those numbers meaning.
What is −5,000?
Start with a puzzle, because it is the whole problem in one line.
An account in your ledger is sitting at −5,000. Is that a bug?
You cannot answer that. Neither can your database.
If it is a customer wallet, it is a serious bug, because a customer cannot owe you money they never borrowed.
If it is your fee revenue account, it is completely normal. That is what revenue looks like from the inside.
If it is your settlement account, you have an emergency, because you are claiming to hold cash you do not have.
Same number. Three different worlds. Signed integers carry magnitude and direction. They carry no meaning at all.
So the invariant from post #1 proves nothing was destroyed. It does not prove anything is right. To get closer to right, the ledger has to know what kind of thing each account is.
Five kinds of account
Every account answers one question: does this represent something you have, something you owe, or something that moved?
There are five answers. The machinery underneath them is about 500 years old, and the five-way split on top has been stable for most of the modern era.
Asset. Something you have or are owed. Your settlement account. Cash in an ATM. Money a merchant owes you.
Liability. Something you owe. Customer wallets go here. Tax withheld and not yet remitted. Merchant payouts you have not sent.
Equity. What is left for the owners after liabilities. Share capital, retained earnings.
Revenue. Money earned. Transaction fees, subscription income.
Expense. Money spent. Promo credits, provider costs, fraud losses.
Assets and expenses are things you hold or consumed. Liabilities, equity and revenue are things you owe or earned. That split produces the equation everything hangs on:
Assets = Liabilities + Equity + (Revenue − Expenses)
Now go back to −5,000. It is only answerable because the account has a kind. That is the entire mechanism.
Your wallets are a liability
This is the one that matters, and it is not an accounting technicality. If you hold a mobile money licence in Nigeria, it is written into your licence conditions.
What the regulator actually says
Under the CBN's July 2021 Guidelines, operators settle all obligations into settlement accounts at deposit money banks and keep separate accounts for their other business. Those settlement accounts are opened as nominee accounts on behalf of customers, and section 9(c) governs them with four conditions [1]:
- The account shall not be interest bearing.
- No right of set-off.
- Debits only for settlement-related transactions.
- No bank charges of any kind.
Section 9(d) then adds a separate prohibition: the account cannot be used as collateral for loans, under any guise.
Read those as an engineer rather than a lawyer. No right of set-off means you cannot take what customers owe you out of the pool. Debits only for settlement means money may not leave that account for any other reason, including yours. No collateral means you cannot borrow against it.
The regulator is saying, in legal language, that this is not your money.
Then comes the line that should make anyone building a ledger sit up. Section 9(e) requires that the settlement account balance always equal the total unspent balance of every e-money holder [1].
That is Assets = Liabilities. One asset account, one class of liability, mandated by law, checkable with a query.
The accounting equation is not a nice-to-have here. It is a licence condition.
(One caveat, because it matters if you build savings: those funds are an explicit exception. They go into a separate pool and get invested in Nigerian Treasury Bills, and section 10.1.3(a) caps fees and charges for managing that investment at 10% of the interest income it earns. Read section 10 properly before you model any of it.)
Why those conditions exist
Every one of them is there because somebody did the opposite.
The pool account is the largest number in the company and it sits there looking spendable. When payroll is due and revenue is thin, the distance between we are holding ₦2.4 billion and we have ₦2.4 billion is the distance between a going concern and that concrete floor. In this market it is not hypothetical. It is a recurring news story, and it is what that vendor meeting was actually about.
A schema will not stop a determined founder. But a ledger where customer float is typed as a liability, sitting next to a settlement asset that is supposed to match it, makes the gap visible on day one rather than on the day somebody comes asking.
Why the branch closes at 4pm
There is a reporting consequence that will feel familiar to anyone who has worked near a Nigerian bank. Operators must reconcile pool balances daily and make weekly returns to the CBN [1].
That is a trial balance, turned into a legal obligation.
It is also why the branch shuts its doors at 4pm and the banker you know still gets home at 9. The doors close so the day can be counted. Somebody has to prove that what the system says matches what the accounts actually hold, line by line, and sign it before anyone leaves.
That work does not disappear because you have Postgres instead of a branch network. It just moves. If your ledger cannot produce that number on demand, somebody on your team produces it by hand every evening, forever, and they are not enjoying it.
Reading the sign
The mechanical bit, and it is short. We keep the signed bigint from post #1. Positive means debit. Negative means credit. That is the only convention to memorise.
Combine it with the five kinds:
| Kind | Increases with | Healthy balance is |
|---|---|---|
| Asset | debit (+) | positive |
| Expense | debit (+) | positive |
| Liability | credit (−) | negative |
| Equity | credit (−) | negative |
| Revenue | credit (−) | negative |
Which produces the thing that trips everyone up:
A customer with ₦5,000 in their wallet has a raw ledger balance of −5,000.
That looks wrong. It is not. It is negative because from the company's point of view, that is a debt. The minus sign is the ledger being honest about whose money it is.
You obviously do not show a customer −5,000. So you convert. And since this rule is going to be needed in more than one place, it goes in one place:
CREATE FUNCTION natural_sign(k account_kind) RETURNS int
LANGUAGE sql IMMUTABLE AS $$
SELECT CASE WHEN k IN ('liability','equity','revenue') THEN -1 ELSE 1 END;
$$;
-- Anywhere you need the number a human expects:
SELECT natural_sign(a.kind) * SUM(e.amount) AS natural_balance
Two copies of a sign rule will drift, and the day they drift is the day your dashboard and your trial balance disagree about the same account. One function, IMMUTABLE so the planner can inline it.
The number in your database and the number on the customer's screen are different things, and the account kind is the function between them. Literally, in this case. That is most of what debits and credits actually buy you.
Three transactions
Watch the equation hold.
1. Customer deposits ₦5,000 by bank transfer.
Settlement account (asset) + 5,000 debit
Customer wallet (liability) − 5,000 credit
-------
0
You have ₦5,000 more in the bank and you owe ₦5,000 more to a customer. Assets 5,000, Liabilities 5,000.
2. You charge a ₦25 fee.
Customer wallet (liability) + 25 debit
Fee revenue (revenue) − 25 credit
-------
0
You owe slightly less, and you earned ₦25. No cash moved. The bank balance is untouched. What changed is who has a claim on it.
3. You give a ₦1,000 promo credit.
Marketing expense (expense) + 1,000 debit
Customer wallet (liability) − 1,000 credit
-------
0
Look carefully, because this is where a balance column would quietly lie to you.
The wallet went up by ₦1,000. Your bank account did not. You now owe ₦5,975 while holding ₦5,000. That is not a bug. It is the accurate, slightly uncomfortable fact that you promised money you have not funded.
Assets 5,000
Liabilities 5,975
Revenue 25
Expenses 1,000
5,000 = 5,975 + 0 + (25 − 1,000)
5,000 = 5,000 ✓
Still balanced, and now telling you something a balance column never could: the promotion made you poorer, and by exactly ₦1,000.
That is the whole argument in one example. A deposit and a promo credit both add money to a wallet. With a balance column they are literally the same operation. In a typed ledger they are opposites. One increases an asset. The other increases an expense. One is funded. The other is a promise.
And it opens exactly the way you would expect. Growth runs a promo over the weekend. Finance hears about it on Monday. Nothing in the wallets table separates ₦40 million of promo credits from ₦40 million customers actually deposited, so the float looks healthy right up until enough people withdraw at once. By then the question is not what your database says. It is whether the settlement account can cover it.
Where money enters
Post #1 said the ledger cannot create money. Typed accounts force you to confront what that means at the edges.
A customer deposits ₦5,000. You cannot simply credit their wallet, because the entry has to come from somewhere or it will not balance. You are physically prevented from conjuring it.
That constraint is doing you a favour. Every naira entering the system has to name its counterparty, and the counterparty is a real thing in the world: the bank account the money landed in.
The system account
Most teams discover this and then defeat it. They create an account called system or main or bank, with no type, and post everything against it. It grows into an enormous number nobody can explain, and becomes the black hole where every unexplained naira goes to hide.
A system account is a confession that you have not modelled something.
Banks already have a name for this. It is the suspense account, and anyone who has worked in bank operations has a story about a suspense balance nobody could explain, that grew quietly for years, and was eventually written off because everyone who knew what it was had left. A system account is a suspense account you built deliberately, before you had a single customer.
What it costs
A founder told me how this played out at his company. The shape is worth walking through, because at no point did anyone have to be dishonest.
They had a system account. Customer money came in, wallets were credited, the counter-entry went to system. Provider fees went out of system. Operating costs came out of system. A promo campaign came out of system. It was the account for everything that was not a user wallet, which made it the account for everything nobody had thought about yet.
Every decision was defensible on the day it was made. The money was in the bank. The number looked fine. Nothing objected, because nothing in the system knew the difference between money they had earned and money they were holding for somebody else.
By the time a real reconciliation happened, the hole nearly took the company down. Every customer balance was correct. Every wallet showed exactly the right number. The money to honour those numbers was not there. They had been running the business on customer float for months, and the ledger reported balanced books the entire time, because it was balanced.
Sum-to-zero held on every transaction. It just could not say what kind of zero it was.
They had magnitude and direction, and no meaning.
Typed accounts would not have stopped anyone spending that money. They would have made it impossible to spend without seeing it. An operating cost posted against a liability account is a question somebody answers out loud. An operating cost posted against system is just Tuesday.
It is also, eventually, an afternoon spent explaining what system means to people who do not find it funny.
Starting from nothing
Small practical problem everyone hits on day one, same answer.
Your ledger is empty. You want to record that the company started with ₦10,000,000 of capital in the bank. Debit the settlement account ₦10,000,000. What do you credit?
Nothing exists yet, and a single-sided entry gets rejected by the trigger.
The answer is equity. Equity is the account that exists so the books can start.
Settlement account (asset) + 10,000,000 debit
Share capital (equity) − 10,000,000 credit
Same pattern handles migrations, which matters more than the founding case. Moving off a legacy system with existing balances? You do not insert balances. You post an opening transaction per account against an opening-balances equity account, and from that moment every naira has a traceable origin. The migration becomes a ledger entry you can point an auditor at.
One query for the whole business
Post #1 checked one thing per transaction. Typed accounts give you a check across the entire database:
-- Every entry ever written, per currency. Must be zero.
SELECT currency, SUM(amount) AS imbalance
FROM entries
GROUP BY currency
HAVING SUM(amount) <> 0;
If the per-transaction trigger is doing its job, this always returns nothing. Which is the point. It returns a row only if somebody bypassed the trigger, disabled it, or wrote straight to the table. It is your tamper detector.
The typed version is the trial balance. One row per account, which is what an accountant means by the phrase:
CREATE VIEW trial_balance AS
SELECT a.id,
a.code,
a.kind,
a.currency,
COALESCE(SUM(e.amount), 0) AS raw_balance,
natural_sign(a.kind) * COALESCE(SUM(e.amount), 0) AS natural_balance
FROM accounts a
LEFT JOIN entries e
ON e.account_id = a.id
AND e.currency = a.currency
WHERE NOT a.is_rollup
GROUP BY a.id, a.code, a.kind, a.currency;
Three details. It is a LEFT JOIN so accounts with no entries still show as zero, because an account silently missing from a trial balance is worse than one showing nothing. It joins on both account_id and currency, matching post #1's composite foreign key instead of relying on you remembering it exists. And it excludes rollups, because summing a parent alongside its children counts the same money twice.
Roll that up by kind and you get the equation itself:
CREATE VIEW accounting_equation AS
SELECT kind, currency, SUM(natural_balance) AS balance
FROM trial_balance
GROUP BY kind, currency;
Two views, not one, and the names are worth getting right. A trial balance lists every account. Grouping by kind gives you the accounting equation, which is a different report answering a different question. Call the second one a trial balance and the first accountant who opens your repo will quietly downgrade their opinion of everything else in it.
This is the number the founder should have quoted on that call. It is also, roughly, what the CBN wants reconciled every day.
Why accountants use two columns
Time to answer the title, because I have been using one signed column and real accounting systems do not.
A traditional ledger has debit and credit, both always positive, and the two must total the same:
| Account | Debit | Credit |
|---|---|---|
| Settlement | 5,000 | |
| Customer wallet | 5,000 |
Three real advantages:
No negative numbers anywhere. You cannot fat-finger a sign, because there are no signs.
A category of mistake gets harder to express. With one signed column, +5000 and −5000 posted to the wrong two accounts passes the sum check happily. Two columns make that harder to write by accident.
It is what every finance system on earth expects. Your accountant, your auditor, your ERP import and every regulatory return assume DR/CR.
I am still using one signed column. The sum-to-zero check is one operation on one column and post #1's constraint works unchanged. Two columns need SUM(debit) = SUM(credit), a check that exactly one is non-zero per row, and decisions about nulls versus zeros. More surface for the same guarantee.
And DR/CR is a presentation format, so you can always derive it:
SELECT CASE WHEN amount > 0 THEN amount END AS debit,
CASE WHEN amount < 0 THEN -amount END AS credit
FROM entries;
You cannot go the other way as cleanly.
If you are building for an organisation that already has a finance team with opinions, store DR/CR natively and save yourself the argument. The important thing is not which representation you pick. It is that the account kind is recorded, so the sign has meaning.
The schema
CREATE TYPE account_kind AS ENUM (
'asset', 'liability', 'equity', 'revenue', 'expense'
);
An enum rather than text with a CHECK: same validation, and adding a sixth kind becomes a deliberate migration rather than a typo that silently succeeds.
Now the migration. Post #1 shipped an accounts table, so yours has rows in it, and you cannot add a NOT NULL column to a table that already has rows. The order of the next four steps matters more than it looks, and I will come back to why.
-- 1. Add the columns, nullable for now.
ALTER TABLE accounts ADD COLUMN kind account_kind;
ALTER TABLE accounts ADD COLUMN code text;
ALTER TABLE accounts ADD COLUMN parent_id uuid REFERENCES accounts (id);
ALTER TABLE accounts ADD COLUMN is_rollup boolean NOT NULL DEFAULT false;
CREATE INDEX accounts_parent_idx ON accounts (parent_id);
-- 2. Seed the chart of accounts FIRST, so the rollup exists
-- before anything tries to point at it.
INSERT INTO accounts (code, name, kind, currency, is_rollup) VALUES
('asset:settlement:gtb', 'GTB settlement account', 'asset', 'NGN', false),
('asset:cash:agent_float', 'Agent cash float', 'asset', 'NGN', false),
('liability:wallets', 'User wallets', 'liability', 'NGN', true ),
('liability:tax:withholding', 'Withholding tax payable', 'liability', 'NGN', false),
('liability:payouts:pending', 'Merchant payouts pending', 'liability', 'NGN', false),
('equity:opening_balances', 'Opening balances', 'equity', 'NGN', false),
('revenue:fees:transfer', 'Transfer fee revenue', 'revenue', 'NGN', false),
('expense:promotions', 'Promotional credits', 'expense', 'NGN', false),
('expense:provider_fees', 'Payment provider fees', 'expense', 'NGN', false);
-- 3. Backfill. Everything that existed before this migration was a wallet,
-- so it is a liability and it hangs off the rollup we just created.
UPDATE accounts a
SET kind = 'liability',
code = 'liability:wallets:' || a.id,
parent_id = (SELECT id FROM accounts WHERE code = 'liability:wallets')
WHERE a.kind IS NULL;
-- 4. Tighten.
ALTER TABLE accounts
ALTER COLUMN kind SET NOT NULL,
ALTER COLUMN code SET NOT NULL,
ADD CONSTRAINT accounts_code_unique UNIQUE (code);
Seed before backfill, not after. Do it the other way round and every migrated wallet gets a null parent_id, because the rollup it should point at does not exist yet. Nothing errors. The wallets simply never roll up, and you find out during your first reconciliation.
code gives you stable, readable identifiers. You will use them in code, in reconciliation scripts, and in conversations with your accountant, all of which go better when the thing has a name instead of a UUID.
Rollups, and why parent_id is there
Individual user wallets are still their own rows, all of kind liability, each with parent_id pointing at liability:wallets.
That parent is a rollup, not a posting account, and the distinction matters more than it looks. Post entries to both a wallet and its parent and you count the same money twice, so your trial balance is quietly wrong. Never post to the parent and its balance is zero, so comparing against it is meaningless.
Now, the tempting way to enforce this is to work it out from the data. An account with children is a rollup, so:
-- Do not do this.
IF EXISTS (SELECT 1 FROM accounts WHERE parent_id = NEW.account_id) THEN
That is a bug, and a nasty one. An account is postable right up until the moment it gets its first child, and then it silently stops being postable. liability:wallets accepts entries on Monday. On Tuesday the first wallet points at it. Those Monday entries are now double-counted forever, and nothing anywhere raises a complaint, because on Monday the rule genuinely did not apply.
A classification that can change when unrelated data changes is not a classification. So state it structurally, at creation, with the is_rollup column from the migration:
CREATE FUNCTION reject_posting_to_rollup() RETURNS trigger AS $$
BEGIN
IF (SELECT is_rollup FROM accounts WHERE id = NEW.account_id) THEN
RAISE EXCEPTION 'account % is a rollup and cannot be posted to directly',
NEW.account_id USING ERRCODE = 'restrict_violation';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER entries_leaf_accounts_only
BEFORE INSERT ON entries
FOR EACH ROW EXECUTE FUNCTION reject_posting_to_rollup();
The flag is set when the account is created and cannot drift with the data. If you want to go further, the composite foreign key trick from post #1 works here too: a UNIQUE (id, is_rollup) on accounts lets you require that every parent_id points at a row where is_rollup is true, so a wallet cannot hang off another wallet.
A rollup's balance is the sum of its children, computed on demand. Which means the sum of every user wallet is the wallet total by construction, not by luck, and comparing that against asset:settlement:gtb is the licence condition from earlier, expressed as a query.
That is post #12 in miniature.
What this does not solve
Same discipline as last time, because a rule that gets oversold gets ignored.
Typed accounts still do not mean correct. Debit expense:promotions when you meant expense:provider_fees and everything balances perfectly while your P&L is wrong. Post #1 said conservation is not accuracy. Classification is not accuracy either.
A balanced equation does not mean you are solvent. Assets equals liabilities plus equity holds even when equity is deeply negative. The ledger balances beautifully all the way into insolvency. It is an integrity check, not a health check.
Nothing here handles timing. A fee earned today and collected next month is a receivable, and this model has no concept of accrual versus cash. That is where a lot of fintech reporting goes wrong, and it deserves its own post.
The FX case is still open. Post #1's trigger groups by currency, so a multi-currency transaction must balance in each currency independently. Real FX needs balancing legs through an FX position account, and revaluation when rates move.
This chart of accounts is not portable. The invariant survives any asset. Almost nothing around it does. Yen has no minor unit, so post #1's "store everything in kobo" assumption breaks on day one. On-chain assets settle by confirmation rather than bank transfer, and confirmations can be reorganised away. What counts as settled, how many decimals you need, and who you reconcile against are decisions you make before your posting logic means anything.
And check the regulations yourself. The guidelines here are from July 2021, the CBN revises them as it sees fit, and they cover mobile money operators specifically. Hold a PSSP, switching, PSB or microfinance licence and your conditions differ. The accounting argument holds regardless. The legal detail may not.
What ships
Tag v0.2-post-02: the account_kind enum, the four-step migration, is_rollup accounts with the leaf-only posting trigger, the natural_sign function, the seeded chart of accounts, the trial_balance and accounting_equation views, and tests asserting that the whole ledger sums to zero per currency, that the equation holds after a deposit, a fee and a promo, that posting to a rollup is rejected, and that the wallet rollup equals the sum of individual wallets.
The founder on that call had one number. He should have had three: ₦2.4 billion owed to customers, whatever is actually in the settlement account, and the difference between them. If the first two are not close together, that is the only fact on the call that matters.
And it is the difference somebody else will eventually calculate, from your rows, in a room you did not choose.
Next post: splitting ₦100 three ways without losing a kobo. Money types, minor units, and why the remainder has to go somewhere on purpose.
References
[1] Central Bank of Nigeria (2021). Framework and Guidelines on Mobile Money Services in Nigeria. Settlement account conditions at section 9; savings wallets at section 10. https://www.cbn.gov.ng/Out/2021/CCD/Framework%20and%20Guidelines%20on%20Mobile%20Money%20Services%20in%20Nigeria%20-%20July%202021.pdf
#2 of 12 in Building a Ledger, a double-entry ledger engine in Go. Code: https://github.com/Helewud/kobo













