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.