DEV Community

zikarelhub
zikarelhub

Posted on

Implementing a Double-Entry Ledger for Nigerian Fintech Wallets

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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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 } }
  );
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
}
Enter fullscreen mode Exit fullscreen mode

The Rule

Balance column: fast to build, impossible to audit, fails CBN examination
Double-entry ledger: more upfront, independently verifiable, CBN-compliant forever
Enter fullscreen mode Exit fullscreen mode

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)