The five account types and their natural side
"Natural side" is the side that INCREASES the balance. The opposite side decreases it.
| Type | Increases on | Decreases on | Examples |
|---|---|---|---|
| Asset | Debit | Credit | Bank, receivable, equipment |
| Liability | Credit | Debit | User wallet, accounts payable |
| Equity | Credit | Debit | Owner's capital, retained earnings |
| Income (revenue) | Credit | Debit | Sales, interchange, interest |
| Expense | Debit | Credit | Salaries, processing fees, bad debt |
Mnemonic: DEA on the debit side (Debits favor Expenses, Drawings, Assets), CLIR on the credit side (Credits favor Liabilities, Income, Revenue). Or just memorise the table.
The invariants
- Assets = Liabilities + Equity. The accounting equation. Holds before and after every journal entry. If it doesn't, you posted a bad entry.
- Total debits = total credits. True for every individual entry AND across the whole ledger. Run this as a SQL query on cron; alert when it drifts.
- Entries are immutable. Never UPDATE a posted line, never DELETE one. Corrections are NEW reversing entries. This is what makes the audit trail trustworthy.
- sum(user wallets) = FBO balance. The iron law of sub-accounting. If it breaks, your sponsor bank's audit fails. Reconcile daily.
Common entry shapes
Deposit
| Bank Account (asset) | DEBIT | amount |
| User Wallet (liability) | CREDIT | amount |
Two lines. Asset up, liability up. The money came in from outside and you now owe it to the user.
Withdrawal (settled)
| User Wallet (liability) | DEBIT | amount |
| Bank Account (asset) | CREDIT | amount |
Reverse of a deposit. Liability down, asset down. The money left.
Withdrawal (initiated, not yet settled)
| User Wallet (liability) | DEBIT | amount |
| Pending Withdrawal (liability) | CREDIT | amount |
Reservation pattern. Total liability unchanged; just moves from 'available' to 'pending'. The bank account moves later when the provider actually settles.
Refund
| User Wallet (liability) | DEBIT | userCreditedAmount |
| Platform Fee Revenue (income) | DEBIT | feeAmount |
| Bank Account (asset) | CREDIT | grossAmount |
A reversing entry; the original deposit stays on the books forever. Revenue gets a debit because we're un-recognising it.
Card swipe interchange (issuer side)
| Network Receivable (asset) | DEBIT | yourNetCut |
| Network Fee Expense (expense) | DEBIT | networkCut |
| Interchange Revenue (income) | CREDIT | grossInterchange |
Three lines. You earn the gross, pay the network its slice, take home the rest as a receivable until settlement.
Compensating entry (saga rollback)
| Original credit account | DEBIT | originalAmount |
| Original debit account | CREDIT | originalAmount |
The exact inverse of the step you're undoing. NEW entry, not a delete. Audit trail shows: initiated, failed, compensated.
Engineering patterns that show up everywhere
- Idempotency keys. Every entry that originates from an external event carries the provider's event_id in an
idempotency_keycolumn. A partial UNIQUE INDEX makes duplicate posts impossible at the DB layer. - FOR UPDATE on the wallet row. Inside the withdrawal transaction. Pairs with a CHECK (balance >= 0) as a DB-level backstop. Belt-and-suspenders against double-spend.
- Outbox table. Side-effects (SMS, email, webhooks) get inserted into an outbox table in the SAME transaction as the journal entry. A separate worker drains the outbox with FOR UPDATE SKIP LOCKED so many workers can run in parallel without double-delivering.
- Balance projection. Cache the current balance on the wallet row for fast reads. Update it atomically inside the same transaction as every journal entry. If it ever drifts, rebuild from the ledger (which is always authoritative).
- Partition by month. journal_entry gets partitioned by posted_at once it crosses billions of rows. Hot partitions stay on fast disk; cold ones move to cheap storage. A monthly snapshot makes historical-balance queries one row.
- COALESCE every SUM. SUM() over zero rows returns NULL. NULL arithmetic propagates and breaks alerts and projections silently. Wrap every aggregate that could see an empty set:
COALESCE(SUM(...), 0). - bigint, never float. Money is always stored in minor units (kobo, cents) as a bigint. Floating point loses pennies; numeric loses speed at scale. bigint is the answer.
Common mistakes
- UPDATEing wallet.balance instead of posting a new entry. Breaks the audit trail. Always post the entry; let the projection follow.
- Trusting a webhook to fire exactly once.Webhooks are at-least-once. If your handler isn't idempotent at the DB layer, you ship a double-booking bug in v1.
- Storing money as a number, numeric without scale, or a float. The fintech version of off-by-one. bigint kobo forever.
- Treating "wallet balance" as one number. It's at least three: available, pending, total. Use a reservation pattern from day one.
- Computing running_balance and caching it on the line row. The next backdated entry breaks every cached balance below it. Compute the running balance at READ time.