Most Nigerian fintech wallets store balance as a single decimal column. Here is why that fails and the complete double-entry ledger implementation that fixes it.
Why the Balance Column Fails
-- This is what most Nigerian fintechs build
CREATE TABLE wallets (
user_id UUID PRIMARY KEY,
balance DECIMAL(15,2) DEFAULT 0.00
);
-- Problems:
-- 1. No history — when balance is wrong, no way to trace it
-- 2. Race conditions — two concurrent withdrawals both pass balance check
-- 3. No reconciliation — cannot verify the number from first principles
-- 4. CBN examination — examiner asks for transaction history, you have none
The Double-Entry Schema
-- Balance is CALCULATED — never stored
CREATE TABLE ledger_entries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
transaction_id UUID NOT NULL,
account_id UUID NOT NULL,
entry_type VARCHAR(10) CHECK (entry_type IN ('DEBIT', 'CREDIT')),
amount DECIMAL(15,2) CHECK (amount > 0),
settlement_status VARCHAR(20) CHECK (settlement_status IN (
'PENDING', 'PROCESSING', 'SETTLED', 'FAILED', 'REVERSED'
)),
created_at TIMESTAMP DEFAULT NOW()
-- NO UPDATE, NO DELETE — immutable by policy
);
-- Available balance view
CREATE VIEW wallet_balances AS
SELECT
a.owner_id,
SUM(CASE
WHEN le.entry_type = 'CREDIT' AND le.settlement_status = 'SETTLED'
THEN le.amount
WHEN le.entry_type = 'DEBIT'
AND le.settlement_status IN ('SETTLED', 'PENDING')
THEN -le.amount
ELSE 0
END) AS available_balance
FROM accounts a
LEFT JOIN ledger_entries le ON le.account_id = a.id
GROUP BY a.owner_id;
Core Operations
// Every operation: two entries, one transaction, one idempotency key
async function recordDeposit(userId, amount, paystackRef) {
// Idempotency — duplicate webhooks are safe
const existing = await Transaction.findOne({
where: { idempotencyKey: paystackRef }
});
if (existing) return existing; // Already processed
const tx = await db.transaction({ isolationLevel: 'SERIALIZABLE' });
try {
const txn = await Transaction.create(
{ type: 'DEPOSIT', reference: paystackRef, idempotencyKey: paystackRef },
{ transaction: tx }
);
// DEBIT float (float gives the money)
await LedgerEntry.create(
{ transactionId: txn.id, accountId: floatAccountId,
entryType: 'DEBIT', amount, settlementStatus: 'PENDING' },
{ transaction: tx }
);
// CREDIT user wallet (user receives the money)
await LedgerEntry.create(
{ transactionId: txn.id, accountId: userAccountId,
entryType: 'CREDIT', amount, settlementStatus: 'PENDING' },
{ transaction: tx }
);
await tx.commit();
return txn;
} catch (e) {
await tx.rollback();
throw e;
}
}
// Confirm on webhook — update to SETTLED
async function confirmDeposit(paystackRef) {
const txn = await Transaction.findOne({ where: { reference: paystackRef } });
await LedgerEntry.update(
{ settlementStatus: 'SETTLED' },
{ where: { transactionId: txn.id } }
);
}
Reversal — New Entry, Never Delete
async function reverseTransaction(originalTxnId, reason, authorizedBy) {
const original = await Transaction.findByPk(originalTxnId, {
include: [LedgerEntry]
});
const tx = await db.transaction();
try {
const reversal = await Transaction.create({
type: 'REVERSAL',
reference: `REV-${original.reference}`,
idempotencyKey: `REV-${original.idempotencyKey}`,
reversalOf: originalTxnId,
reversalReason: reason
}, { transaction: tx });
// Flip every entry — DEBIT becomes CREDIT and vice versa
for (const entry of original.LedgerEntries) {
await LedgerEntry.create({
transactionId: reversal.id,
accountId: entry.accountId,
entryType: entry.entryType === 'DEBIT' ? 'CREDIT' : 'DEBIT',
amount: entry.amount,
settlementStatus: 'SETTLED'
}, { transaction: tx });
}
await tx.commit();
return reversal;
} catch (e) {
await tx.rollback();
throw e;
}
}
Daily Reconciliation — Verify Integrity
async function verifyLedgerBalance() {
const result = await db.query(`
SELECT
SUM(CASE WHEN entry_type = 'DEBIT' THEN amount ELSE 0 END) AS debits,
SUM(CASE WHEN entry_type = 'CREDIT' THEN amount ELSE 0 END) AS credits
FROM ledger_entries
WHERE settlement_status = 'SETTLED'
`);
const { debits, credits } = result[0];
const imbalance = Math.abs(debits - credits);
if (imbalance > 0.01) {
// CRITICAL — ledger is out of balance
await alertComplianceTeam({ severity: 'CRITICAL', imbalance });
throw new Error(`Ledger imbalance: ₦${imbalance}`);
}
return { balanced: true, debits, credits };
}
The Rule
Balance column: fast to build, impossible to audit, fails CBN examination
Double-entry ledger: more upfront, independently verifiable, CBN-compliant forever
ZikarelHub LTD is Nigeria's #1 software and digital agency — double-entry fintech ledgers built for Nigerian compliance and scale.
Have you implemented double-entry accounting in a Nigerian fintech? What challenges did you face? 👇
Top comments (0)