Lesson 24Advanced and capstonesAdvanced

Account statements

A statement is a VIEW of the ledger, not stored data.

By Solomon Ajayi · Free to read, no signup

When a user asks for their statement, you're not pulling from a 'statements table', you're filtering the ledger to lines that touch their account and walking a running balance from the first entry forward. The closing balance is what the running sum lands on. NEVER cache running balance as a column; the moment a single insert misses the cache, your statement drifts from the ledger truth. Apply the three guided entries, then switch to the Statement view (top-left toggle) and pick different accounts to see how the same entries produce different statements.

A statement feels like a thing you store, but it is really a question you ask: for this one account, show me every line that touched it and the running total after each. There is no statements table. There is only the ledger, filtered to the account you care about and summed forward from the first entry. The closing balance is just where that walk ends.

Because a statement is per-account, the same three entries produce three different statements. The deposit touches Bank Account and User Wallet, so it shows up on both of their statements but never on Fee Revenue's. The service charge touches User Wallet and Fee Revenue, so the Bank statement does not move at all. A ledger is per-event; a statement is per-account; deriving one from the other on read is the entire job.

The reason this matters is integrity. As long as the statement is computed from the ledger every time you ask, it cannot disagree with the ledger, because it is the ledger. The instant you stash a running_balance column to make reads faster, you have created a second source of truth that has to be kept in perfect sync forever, across webhook retries, manual SQL fixes, and migrations. The first time one write skips that column, every statement you generate quietly becomes a lie.

Worked example, step by step

Deposit ₦10,000 to user wallet

Standard funding. The user deposited ₦10,000 via card. Bank Account up, User Wallet up.

Deposit ₦10,000
AccountDebitCredit
Bank Account (1200)₦10,000.00
User Wallet (2000)₦10,000.00

Two accounts touched: Bank (asset, debit) and User Wallet (liability, credit). After this lands, switch to Statement view and select User Wallet, you'll see one row, balance ₦10,000 credit.

User pays a ₦500 service fee

User bought a premium feature. ₦500 charged from their wallet. User Wallet down, Fee Revenue up. Notice: Bank Account is NOT touched, no money left your bank.

Service charge ₦500
AccountDebitCredit
User Wallet (2000)₦500.00
Fee Revenue (4000)₦500.00

After this: User Wallet running balance steps from ₦10,000 to ₦9,500 (credit). But the Bank Account statement is unchanged, Bank's statement does NOT show this entry, because the line never touched Bank. That's the point of a statement: per-account, not per-event.

Another deposit ₦5,000

User funds again. ₦5,000 in. Bank up, User Wallet up.

Deposit ₦5,000
AccountDebitCredit
Bank Account (1200)₦5,000.00
User Wallet (2000)₦5,000.00

Now switch to Statement view and toggle between accounts. User Wallet statement shows THREE rows (running balance: 10000 → 9500 → 14500). Bank Account statement shows TWO rows (10000 → 15000). Fee Revenue statement shows ONE row. Same ledger, three different views. The closing balance of each is just the forward sum of its own lines.

Takeaway

A statement is a derived view of the ledger filtered to one account, with a running balance computed forward from the first entry. It is NOT a separate table. NEVER cache `running_balance` as a column, the moment one insert misses your cache (a webhook retry, a manual SQL fix, a migration), the cached number drifts from the ledger truth and every statement you generate is a lie. Generate statements on read. Trust the ledger.

The code behind it

A statement is a VIEW that walks SUM(debit - credit) forward per account.

-- The ledger is the only source of truth: one row per line, amounts in kobo.
-- account_code: '1200' Bank Account, '2000' User Wallet, '4000' Fee Revenue.
-- No running_balance column anywhere. We derive it on read.

CREATE VIEW account_statement AS
SELECT
  l.account_code,
  l.posted_at,
  l.description,
  l.debit,
  l.credit,
  -- Walk the running balance forward, per account, in posting order.
  -- This is the whole statement: a forward sum of THIS account's own lines.
  SUM(l.debit - l.credit) OVER (
    PARTITION BY l.account_code
    ORDER BY l.posted_at, l.line_id          -- line_id breaks same-timestamp ties
    ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
  ) AS running_balance
FROM ledger_line l;

-- A statement is now just a question, never a stored table:
SELECT posted_at, description, debit, credit, running_balance
FROM account_statement
WHERE account_code = '2000'                    -- User Wallet
ORDER BY posted_at, line_id;                    -- match the order the sum walked
-- running_balance for User Wallet walks: -1000000 -> -950000 -> -1450000 kobo.
-- (Liability sums credit-heavy, so debit - credit is negative; flip the sign
--  at display time per account type. The closing balance is the last row.)

Practice this on a real ledger

Reading is half of it. Open this lesson in the lab to post the entries yourself against a real Postgres-backed double-entry ledger, with the validation on. Free, your sandbox is yours.

More in this section

Search lessons

Type to find any of the 85 lessons. Press Enter to open.