How double-entry bookkeeping protects your users' money
Every wallet balance is derived from a ledger of immutable transaction entries — never stored as a single number that can drift out of sync. Here's why that matters.
Most developers, when they first build a wallet feature, do something like this:
ALTER TABLE users ADD COLUMN balance INTEGER DEFAULT 0;
-- Credit a user
UPDATE users SET balance = balance + 5000 WHERE id = 'user_abc';
-- Debit a user
UPDATE users SET balance = balance - 5000 WHERE id = 'user_abc';
This works fine in development. In production, with real money, it will eventually cause problems. Here's why — and what to do instead.
The problem with storing balances
A stored balance is a derived value pretending to be a source of truth. The actual source of truth is the history of transactions that produced it. When you store a balance as a mutable column, you've discarded that history and replaced it with a number that can:
- Drift — if any transaction is written without updating the balance (network error, application crash, failed migration), the balance is now wrong and there's no automatic way to detect it.
- Race — two concurrent transactions can both read the same balance and both write back, each unaware of the other.
balance = 100, two concurrent+50writes both read100, both write150. Result:150instead of200. - Lie silently — a wrong balance produces no error. The system continues operating. You discover the discrepancy during a manual reconciliation, or when a user complains, or never.
Double-entry bookkeeping
The solution has been known since 15th century Florence: double-entry bookkeeping.
The rule is simple: every transaction creates two ledger entries — a debit on one account and a credit on another of equal value. The sum of all debits always equals the sum of all credits. If it doesn't, something went wrong and the ledger will tell you exactly where.
A transfer of ₦5,000 from user A to user B creates four entries:
| Account | Type | Amount | |---|---|---| | User A Wallet | Debit | ₦5,000 | | Payable to User B | Credit | ₦5,000 | | Payable from User A | Debit | ₦5,000 | | User B Wallet | Credit | ₦5,000 |
The balance of any wallet is never stored. It's computed on demand:
SELECT
SUM(CASE WHEN type = 'credit' THEN amount ELSE 0 END) -
SUM(CASE WHEN type = 'debit' THEN amount ELSE 0 END) AS balance
FROM ledger_entries
WHERE account_id = 'wallet_abc';
This balance is always correct by construction. There's no separate column that can drift.
Why immutability matters
Ledger entries are never updated or deleted — only inserted. This means:
- The full history of every kobo is permanently recorded
- You can reconstruct the state of any wallet at any point in time
- Any discrepancy between your ledger and the bank's records can be traced to a specific entry
- Auditors can verify balances independently without trusting your application layer
In PayKore's ledger, entries are append-only at the database level (enforced by a trigger that prevents UPDATE and DELETE on the ledger table). Application code that tries to mutate an entry raises a database error — it cannot succeed silently.
Reconciliation
Because every wallet balance is derived from ledger entries, and because every credit to a PayKore wallet corresponds to a real credit at the MFB, daily reconciliation is straightforward:
- Sum all wallet balances from the ledger (our internal view)
- Pull the balance of the pooled omnibus account from the MFB (the banking view)
- The two numbers should match
If they don't match, the discrepancy points to a specific entry or time window. With a stored-balance system, a mismatch tells you the final number is wrong but not which transaction caused it.
What this means for your product
If you're building on PayKore, you inherit this architecture. Every wallet balance your users see is computed from an immutable ledger, reconciled daily against real bank records. You don't have to build any of this yourself.
If you're building your own ledger — for any reason — the minimum viable implementation is:
- An append-only
ledger_entriestable (id, account_id, type, amount, reference, created_at) - A database-level constraint preventing UPDATE/DELETE on that table
- Balance computed by query, never stored
- A daily reconciliation job that compares your ledger to your bank statement
Start with this and you'll avoid the category of bugs that silently destroy user trust.