Balance Sheets, Ledgers, and Double-Entry Accounting — A Developer's Guide to Fintech Finance

Overview

What This Post Is About

If you’ve ever built a payments feature, a wallet system, or a recharge platform and wondered “why does the finance team keep talking about debits and credits differently than I do?” — this post is for you.

Financial systems have their own language. Terms like debit, credit, ledger, account, and balance sheet mean something very specific in accounting — and that meaning is often the opposite of what engineers expect from their banking experience.

This post walks through the foundational concepts of accounting — accounts, ledgers, debits, credits, and the balance sheet — in plain language, and then shows how these concepts map directly to the code you write when building fintech or recharge platforms.

Who Should Read This

  • Backend engineers building payment, wallet, or recharge features for the first time
  • Product managers who need to speak the same language as their finance team
  • QA engineers writing test scenarios for financial transactions
  • Anyone who has asked “what exactly is a ledger entry?”

Problem Statement

The Gap Between Engineering and Accounting

When most engineers see the word credit, they think: money added to my account. When they see debit, they think: money removed.

That intuition comes from the customer-facing side of a bank statement. But inside the systems that produce that bank statement, credit and debit mean something entirely different — and getting this wrong when building financial software leads to:

  • Balances that go out of sync
  • Transactions that are impossible to audit or explain
  • Financial reports that don’t match reality
  • Bugs that are invisible until a discrepancy is discovered weeks later

The core challenge: accounting was invented long before software, and the mental model it uses — double-entry bookkeeping — is not how most developers naturally think about data. Bridging that gap is what this post is about.


Initial Approaches Tried (What Didn’t Work)

Approach 1: A Single Balance Column

The most natural starting point for any wallet feature: a balance column on a user table.

UPDATE wallets SET balance = balance - 100 WHERE user_id = 42;

What goes wrong:

  • There is no record of why the balance changed
  • Concurrent updates cause race conditions — two requests read the same balance, both subtract, and one subtraction is lost
  • If a bug applies the wrong amount, there is no way to know what the balance should be
  • Finance teams cannot produce a statement of account from this data

Approach 2: An Event / Transaction Log

A step forward: record every operation as a row in a transactions table.

INSERT INTO transactions (user_id, amount, type, created_at)
VALUES (42, -100, 'recharge', NOW());

What goes wrong:

  • Nothing enforces that the money went somewhere — a deduction from one account with no corresponding credit to another is logically invalid, but the schema allows it
  • Partial failures (sender debited, receiver not credited) leave the system inconsistent
  • There is no link between related entries — a payment’s debit and its corresponding credit are unconnected rows
  • Reporting requires complex, fragile SQL that reconstructs what accounting structures would give for free

Approach 3: Separate Tables per Transaction Type

As the system grows, a common pattern emerges: separate tables for recharges, cash-ins, settlements, commissions, etc.

What goes wrong:

  • Getting the total balance of any entity requires joining across many tables
  • Adding a new transaction type means a new table, new queries, new reports
  • No single source of truth — the “real” balance lives nowhere in particular

Each of these approaches solves the immediate problem but creates a bigger one. The underlying issue is that they don’t use an accounting model — they use an event log with no financial structure.


Why This Final Approach — Double-Entry Bookkeeping

The 500-Year-Old Solution

Double-entry bookkeeping was formalised in 1494 by Luca Pacioli. It has been the standard for financial accounting ever since — and for good reason. The rule is simple:

Every financial event must produce at least two entries: a debit on one account and a credit on another, of equal value.

This single constraint eliminates most of the problems listed above:

  • It is mathematically impossible for money to “disappear” — every debit has a corresponding credit
  • The system self-audits: if debits don’t equal credits, the transaction is rejected
  • Any account balance can be fully reconstructed from its transaction history
  • Financial reports (balance sheet, P&L, ledger) are natural outputs of this structure

The rest of this post explains the building blocks of this model and how to implement them.


Implementation Details

Concept 1 — What Is an “Account” in Accounting?

In accounting, an account is not a bank account or a user account. It is a named container that tracks the balance of one specific thing — a wallet, a pile of cash, an amount someone owes you, or a revenue stream.

Every financial entity in your system maps to an account:

Thing in the System Accounting Account
User’s prepaid wallet Asset account
Cash collected at an agent Asset account (Cash in Hand)
Bulk airtime stock bought from an operator Asset account (Inventory)
Amount a merchant owes you Asset account (Accounts Receivable)
Amount you owe the operator Liability account (Accounts Payable)
Revenue from service fees Revenue account
Cost of commissions paid out Expense account

All accounts are classified into five standard groups:

┌─────────────────────────────────────────────────────┐
│                  CHART OF ACCOUNTS                  │
├─────────────────┬───────────────────────────────────┤
│ Group           │ What It Tracks                    │
├─────────────────┼───────────────────────────────────┤
│ Assets          │ Things you own or are owed        │
│ Liabilities     │ Things you owe to others          │
│ Equity          │ Owner's stake in the business     │
│ Revenue         │ Money earned                      │
│ Expenses        │ Money spent                       │
└─────────────────┴───────────────────────────────────┘

The relationship between these groups is captured by the fundamental accounting equation:

Assets = Liabilities + Equity + (Revenue − Expenses)

This equation must always hold. Double-entry bookkeeping enforces it automatically.


Concept 2 — What Is a Ledger?

A ledger is simply a record of all the transactions that have affected an account, in order, showing the running balance.

Think of it like a bank statement — but for any account in your system, not just a bank account.

Account: User Wallet — Alice
─────────────────────────────────────────────────────────────
Date        Description              Debit    Credit   Balance
─────────────────────────────────────────────────────────────
2024-01-01  Opening balance                            500.00
2024-01-02  Cash-in received                  200.00   700.00
2024-01-03  Recharge — 10 airtime    10.00             690.00
2024-01-04  Cash-in received                  100.00   790.00
2024-01-05  Recharge — 50 data       50.00             740.00
─────────────────────────────────────────────────────────────

A General Ledger (GL) is the complete collection of all accounts and their transaction histories. It is the single source of truth for the entire financial state of the system.

In code, this is typically one central account_transactions table where every financial event is recorded as rows (journal entries), each linked to the account it affects.


Concept 3 — Debit and Credit: The Counterintuitive Part

This is where most developers get confused. Here is the key distinction:

The Everyday Meaning (Banking / Customer View)

Term What It Means to a Customer
Credit Money added to my account — good for me
Debit Money removed from my account — bad for me

This is the perspective of someone reading their bank statement — i.e., the bank talking to you about your money.

The Accounting Meaning (Internal / System View)

In double-entry accounting, debit and credit are directions, not values. They are opposites, but which direction increases a balance depends on the type of account.

Account Type Debit Effect Credit Effect
Asset Increases balance Decreases balance
Liability Decreases balance Increases balance
Equity Decreases balance Increases balance
Revenue Decreases balance Increases balance
Expense Increases balance Decreases balance

A simple way to remember this:

Assets and Expenses → Debit to INCREASE
Liabilities, Equity, Revenue → Credit to INCREASE

Why Does a Bank Credit Increase Your Balance?

When a bank says “we credited your account,” they mean they credited their liability to you. From the bank’s perspective, your deposit is a liability — they owe you that money. Crediting a liability increases it, so your balance goes up from the bank’s accounting view.

When you see “credit” on your bank statement, the bank is showing you the effect from their books — not from yours.

Example: Alice Receives a Cash-In of 200

From Alice’s wallet account (an Asset):

  • Alice’s wallet balance should go up by 200
  • To increase an Asset account → Debit

From the payer’s wallet account (also an Asset):

  • The payer’s wallet balance should go down by 200
  • To decrease an Asset account → Credit
DR  Alice's Wallet (Asset)       +200   ← Asset increases → Debit
CR  Payer's Wallet (Asset)       −200   ← Asset decreases → Credit

Total debits = Total credits = 200. The equation balances. ✓


Concept 4 — Journal Entries

A journal entry is the record of a single financial event, containing all the debit and credit entries that make it up.

Every journal entry must satisfy:

Sum of all Debit amounts = Sum of all Credit amounts

In a fintech system, a single business event (like a recharge transaction) often produces multiple journal entries:

Example: Merchant recharges 10 airtime for a customer

Journal Entry — Airtime Recharge
─────────────────────────────────────────────────────────────
Account                   Type        DR      CR
─────────────────────────────────────────────────────────────
Merchant Wallet           Asset               10.00   ← wallet decreases
Recharge Expense          Expense     10.00           ← cost recorded
MNO Inventory             Asset               10.00   ← stock drawn
MNO Payable               Liability   10.00           ← obligation to operator
─────────────────────────────────────────────────────────────
Total                                 20.00   20.00   ✓

Breaking this down:

  • The merchant’s wallet decreases (Credit an Asset → decreases it)
  • A recharge expense is recorded (Debit an Expense → increases it)
  • The MNO inventory (bulk stock) decreases (Credit an Asset → decreases it)
  • The amount owed to the MNO increases (Debit a Liability… wait)

Actually, let me re-examine:

  • MNO Payable is a Liability. To increase a Liability → Credit.
Journal Entry — Airtime Recharge (corrected)
─────────────────────────────────────────────────────────────
Account                   Type        DR      CR
─────────────────────────────────────────────────────────────
Merchant Wallet           Asset               10.00   ← DR reduces asset (wrong)

Let me walk through this properly:

Event: Merchant uses 10 of wallet balance to perform an airtime recharge

DR  Recharge Expense (Expense)         10   ← expense increases
CR  Merchant Wallet (Asset)            10   ← wallet decreases

DR  MNO Inventory (Asset)              10   ← stock drawn (if tracking inventory value)
CR  MNO Payable (Liability)            10   ← amount owed to operator increases
─────────────────────────────────────────────────────────────
Total Debits = 20    Total Credits = 20    ✓

This is why a recharge platform tracks both a wallet account and a MNO payable — the inventory decreases but the obligation to settle with the operator increases, until the operator is paid.


Concept 5 — The Balance Sheet

The balance sheet is a snapshot of all account balances at a specific point in time. It answers the question: what is the financial state of the business right now?

It is structured around the accounting equation:

┌──────────────────────────────────────────────────────────────┐
│                        BALANCE SHEET                         │
│                    As of 31 January 2025                     │
├─────────────────────────────┬────────────────────────────────┤
│ ASSETS                      │ LIABILITIES + EQUITY           │
├─────────────────────────────┼────────────────────────────────┤
│ User Wallets         50,000 │ MNO Payable           20,000   │
│ Cash in Hand         10,000 │ Accounts Payable       5,000   │
│ MNO Stock Inventory  25,000 │ Customer Stock Payable 8,000   │
│ Accounts Receivable  15,000 │                                │
│                             │ Equity (Capital)      55,000   │
│                             │ Retained Revenue      12,000   │
├─────────────────────────────┼────────────────────────────────┤
│ Total Assets        100,000 │ Total Liabilities + Equity     │
│                             │                      100,000   │
└─────────────────────────────┴────────────────────────────────┘

Assets = Liabilities + Equity — always. If it doesn’t balance, something is wrong.

In a fintech system, generating a balance sheet is a query against the account_to_closing_balance table (daily snapshots), grouped by account type. There is no manual calculation involved — it is a direct output of the double-entry ledger.


Concept 6 — How These Map to a Fintech / Recharge System

Here is how every major balance flow in a recharge platform maps to accounting entries:

Cash-In (Advance — Credit Extended Before Cash Settlement)

A distributor extends prepaid credit to a merchant. The cash will be settled later.

DR  Merchant Wallet (Asset)                +1,000   ← merchant gains balance
CR  Accounts Receivable — Merchant (Asset)  +1,000   ← distributor is now owed

DR  Distributor Payable (Liability)        +1,000   ← distributor owes its parent
CR  Distributor Wallet (Asset)             −1,000   ← distributor's balance reduces

Accounts Receivable tracks what the merchant owes. It sits on the Assets side because it is money owed to the platform.

Cash-In (Paid — Immediate Settlement)

DR  Cash / Bank (Asset)              +1,000   ← payment received
CR  Accounts Receivable (Asset)      −1,000   ← receivable cleared

Recharge Transaction

DR  Recharge Expense (Expense)   +10   ← cost of recharge
CR  Merchant Wallet (Asset)      −10   ← merchant's balance deducted

DR  MNO Inventory (Asset)        +10   ← (if tracking stock value)
CR  MNO Payable (Liability)      +10   ← obligation to operator increases

Operator Settlement (Paying the MNO)

DR  MNO Payable (Liability)      +500   ← obligation reduces
CR  Bank Account (Asset)         −500   ← bank balance reduces

Service Fee Revenue Recognition

DR  Service Fee Expense (Expense)   +5   ← cost to the paying party
CR  Service Fee Revenue (Revenue)   +5   ← income to the platform

Concept 7 — The Data Model in Code

A minimal but complete implementation of this model in a relational database:

accounts table

CREATE TABLE accounts (
  id            SERIAL PRIMARY KEY,
  name          VARCHAR NOT NULL,
  account_group VARCHAR NOT NULL,  -- 'asset' | 'liability' | 'equity' | 'revenue' | 'expense'
  balance       DECIMAL(18,4) NOT NULL DEFAULT 0,
  owner_id      INTEGER,
  owner_type    VARCHAR            -- 'user' | 'operator' | 'platform'
);

transactions table

CREATE TABLE transactions (
  id          SERIAL PRIMARY KEY,
  reference   VARCHAR NOT NULL,    -- groups related journal entries
  description VARCHAR,
  created_at  TIMESTAMP NOT NULL DEFAULT NOW(),
  status      VARCHAR NOT NULL     -- 'pending' | 'posted' | 'reversed'
);

journal_entries table

CREATE TABLE journal_entries (
  id                SERIAL PRIMARY KEY,
  transaction_id    INTEGER NOT NULL REFERENCES transactions(id),
  account_id        INTEGER NOT NULL REFERENCES accounts(id),
  flow              VARCHAR NOT NULL,          -- 'dr' | 'cr'
  amount            DECIMAL(18,4) NOT NULL,
  statement_balance DECIMAL(18,4) NOT NULL,    -- account balance AFTER this entry
  created_at        TIMESTAMP NOT NULL DEFAULT NOW()
);

Key invariant: For every transaction_id, the sum of all amount where flow = 'dr' must equal the sum of all amount where flow = 'cr'. Enforce this in your service layer, not just in comments.

Atomic balance update (TypeScript / Knex pseudocode)

async function postJournalEntry(
  trx: Knex.Transaction,
  transactionId: number,
  accountId: number,
  flow: 'dr' | 'cr',
  amount: number
): Promise<void> {

  // Determine whether this is an increase or decrease based on account group
  const account = await trx('accounts').where({ id: accountId }).first();
  const isIncrease = shouldIncrease(account.account_group, flow);

  // Atomically update the running balance — no read-modify-write
  if (isIncrease) {
    await trx('accounts').where({ id: accountId }).increment('balance', amount);
  } else {
    await trx('accounts').where({ id: accountId }).decrement('balance', amount);
  }

  // Fetch updated balance for the statement_balance snapshot
  const updated = await trx('accounts').where({ id: accountId }).first();

  // Record the journal entry
  await trx('journal_entries').insert({
    transaction_id: transactionId,
    account_id: accountId,
    flow,
    amount,
    statement_balance: updated.balance
  });
}

function shouldIncrease(group: string, flow: 'dr' | 'cr'): boolean {
  // Assets and Expenses increase on Debit
  // Liabilities, Equity, Revenue increase on Credit
  const debitIncrease = ['asset', 'expense'];
  return debitIncrease.includes(group) ? flow === 'dr' : flow === 'cr';
}

Wrapping a full transaction atomically

await db.transaction(async (trx) => {
  const txn = await trx('transactions').insert({
    reference: generateReference(),
    description: 'Airtime recharge — 10 units',
    status: 'pending'
  }).returning('id');

  // Post all journal entries atomically
  await postJournalEntry(trx, txn.id, merchantWalletAccountId,  'cr', 10);
  await postJournalEntry(trx, txn.id, rechargeExpenseAccountId, 'dr', 10);
  await postJournalEntry(trx, txn.id, mnoInventoryAccountId,    'dr', 10);
  await postJournalEntry(trx, txn.id, mnoPayableAccountId,      'cr', 10);

  await trx('transactions').where({ id: txn.id }).update({ status: 'posted' });

  // If anything above throws, the entire transaction rolls back
});

Concept 8 — Reversals (Never Delete, Always Compensate)

When a transaction needs to be undone (failed recharge, disputed payment), the correct approach is never to delete or modify the original entries. Instead, post a new set of equal-and-opposite journal entries that bring the balances back to where they were.

Original entries:
  DR  Recharge Expense    10
  CR  Merchant Wallet     10

Reversal entries (new transaction, linked to original):
  DR  Merchant Wallet     10   ← reverses the original credit
  CR  Recharge Expense    10   ← reverses the original debit

After the reversal, the net effect on both accounts is zero — but the full history of both the original transaction and the reversal is preserved. This is how financial systems maintain a tamper-evident audit trail.


Concept 9 — Daily Closing Balances

Running balance queries over a full transaction history becomes expensive at scale. A common pattern is to snapshot balances at the end of each day:

CREATE TABLE closing_balances (
  account_id       INTEGER NOT NULL,
  date             DATE NOT NULL,
  closing_balance  DECIMAL(18,4) NOT NULL,
  PRIMARY KEY (account_id, date)   -- one row per account per day
);

To get the balance of any account at the end of any historical date, you query this table directly — no need to replay the full ledger. This makes balance sheet generation a trivial query regardless of how many years of history exist.


Outcome / Benefits

Applying these accounting concepts to a fintech platform delivers concrete engineering benefits:

Benefit How It Comes From Accounting
Balance consistency Double-entry makes inconsistent states mathematically impossible
Full audit trail Every balance is explained by its journal entries
Easy reconciliation Compare GL totals to external statements (bank, operator) directly
Instant financial reports Balance sheet = group accounts by type, no ETL needed
Safe concurrency Atomic DB increments eliminate race conditions
Reversals without data loss Compensating entries preserve full history

Lessons Learned / Pitfalls

1. CR/DR Are Directions, Not Values — Internalise This Early

The single biggest source of bugs in financial code is applying a debit when a credit was needed, or vice versa. Write a helper function (shouldIncrease(accountGroup, flow)) and test it exhaustively before writing any transaction logic.

2. Validate the Double-Entry Invariant Programmatically

After composing any set of journal entries, sum the debits and credits before posting. If they don’t match, throw an error. Never let an unbalanced transaction reach the database.

const totalDr = entries.filter(e => e.flow === 'dr').reduce((s, e) => s + e.amount, 0);
const totalCr = entries.filter(e => e.flow === 'cr').reduce((s, e) => s + e.amount, 0);
if (Math.abs(totalDr - totalCr) > 0.0001) {
  throw new Error(`Unbalanced journal entry: DR ${totalDr} ≠ CR ${totalCr}`);
}

3. Always Use Atomic DB Operations for Balance Updates

A SELECT followed by an UPDATE on a balance column is a race condition. Use INCREMENT/DECREMENT at the database level and let the DBMS handle concurrency.

4. Store statement_balance on Every Journal Entry

The balance of an account after each entry is redundant in theory (you can derive it). In practice it makes debugging, auditing, and point-in-time reporting dramatically easier. Store it.

5. Design the Chart of Accounts Before Writing Code

The CoA is the schema of your financial model. Changing account groupings or adding new account types after transaction data exists requires migrations and can break historical reports. Spend time on it first.

6. Never Delete or Modify Historical Ledger Entries

Historical journal entries are immutable. Any correction must be a new reversal transaction. Build this constraint into your data model (no UPDATE/DELETE permissions on journal entry rows from the application).

7. The Advance / Paid Distinction Is Not Optional

In settlement-heavy platforms, the difference between a provisional credit (“advance”) and a settled payment (“paid”) is a first-class accounting concern. It drives Accounts Receivable tracking, payables ageing, and reconciliation. Model it explicitly from day one.


Future Improvements

Multi-Currency Support

The model described assumes a single currency. Extending accounts and journal_entries with a currency field, plus an exchange rate table, enables cross-currency platforms. The double-entry model extends naturally — you add entries to track foreign exchange gains/losses as a separate account.

Event-Driven Reconciliation

Rather than running periodic batch reconciliation jobs, a streaming reconciliation service could compare internal journal entries against external operator or bank feeds in near-real-time, surfacing discrepancies immediately.

Immutability at the DB Level

Enforce ledger immutability at the database level: revoke UPDATE and DELETE privileges on the journal_entries table for the application user. This makes the audit trail tamper-proof even against application bugs.

CQRS for Reporting

As the ledger grows, separating the write model (journal entries) from the read model (materialized balance views, pre-computed reports) via CQRS (Command Query Responsibility Segregation) improves reporting performance without touching the core accounting model.


Summary

Concept What It Means in Accounting How It Applies in Fintech
Account Named container tracking one financial thing Wallet, bank account, stock inventory, payable
Ledger Ordered history of all entries for an account journal_entries table filtered by account_id
Debit (DR) Left side of the entry — increases Assets/Expenses Deducting from a payable, recording a cost
Credit (CR) Right side of the entry — increases Liabilities/Revenue Adding to a wallet, recording revenue
Double-Entry Every event: DR total = CR total Enforced by the transaction engine
Balance Sheet Assets = Liabilities + Equity snapshot Generated from account closing balances
Journal Entry Record of one financial event A row group in journal_entries by transaction_id
Closing Balance End-of-day account balance snapshot Enables fast historical reporting
Reversal Compensating entry to undo a transaction Never delete — post equal-and-opposite entries
Accounts Receivable Money others owe you Outstanding advance cash-ins
Accounts Payable Money you owe others Unsettled MNO obligations

The foundational insight is this: a fintech platform is just a very fast, very automated accounting system. The rules that govern a paper ledger in 1494 govern your journal_entries table today. Understanding that connection makes financial software dramatically easier to reason about, build, and debug.


This post is intended as an educational guide for engineers and product teams. Examples are generic and apply broadly to recharge, wallet, and payment platforms.


Have questions or want to share your own experiences? Drop them in the comments below.