FifeRouter

31 August 2026 · ledger money

The balance is the sum of the ledger, or it is nothing

We cache the balance on the account row. There is deliberately no function that sets it, and that absence is the whole design.

Every account has a balance_microcents column. It is a cache — the real balance is the sum of the ledger entries — and caches drift. This one cannot, because of something that is not in the code.

There is no set_balance.

Why the cache exists

Authenticating a request has to answer "does this account have credit" on every call. Doing that as SELECT sum(amount) FROM ledger WHERE account_id = ... is an aggregate over a table that only grows, on the hot path, forever. So the answer is cached on the account row and read with one indexed lookup.

That is the normal reason to cache, and the normal outcome is the two disagreeing six months later with nobody able to say which is right.

The one place it moves

async with conn.transaction():
    await conn.execute("INSERT INTO ledger (...) VALUES (...)")
    await conn.execute(
        "UPDATE accounts SET balance_microcents = balance_microcents + %s "
        "WHERE id = %s", (amount, account_id))

One function, post, writes both. Same transaction, always. The cache is never written on its own, so it cannot be written wrongly on its own — there is no code path that changes a balance without an entry explaining why.

Everything else is a thin wrapper: credit posts a positive topup, debit posts a negative debit, reverse posts a negative reversal. Each validates its own sign and then calls post. None of them touches the account row.

The function we did not write

The tempting one is:

async def set_balance(account_id, microcents): ...

You want it the first time you need to correct something. A customer was double-charged, or a migration went sideways, and there is an obviously right number that the ledger does not currently sum to. Setting it is one line.

Do that once and the invariant is gone. Not because that particular correction was wrong, but because from then on "the balance is the sum of the ledger" is a thing that is usually true, and code that relies on always-true properties starts being subtly wrong in ways nobody can reproduce.

So the correction goes in as a ledger entry too — kind: adjustment, the one kind with no pinned sign, with a description saying who decided and why. The balance moves because the ledger moved. It always does.

Signs are a schema problem, not a discipline problem

Each kind has a direction, and the database enforces it:

CONSTRAINT ledger_sign_ck CHECK (
    (kind IN ('debit','reversal')                       AND amount_microcents <= 0)
    OR (kind IN ('topup','refund','grant','restoration') AND amount_microcents >= 0)
    OR kind = 'adjustment'
)

A debit stored positive is not caught in review. It is refused by Postgres. That means the balance is a plain sum() with no sign logic anywhere — and, more usefully, that a whole category of bug is unrepresentable rather than merely unlikely.

adjustment is the deliberate exception, and it is the only one. When we needed to put credit back after winning a chargeback, the temptation was to use it — automatic, positive, obviously a "correction". We added a restoration kind instead, because adjustment means a person decided this and a webhook is not a person.

What it buys

A test that runs after every scenario:

assert run(ledger.balance(id)) == run(ledger.computed_balance(id))

The cached number and the recomputed number, in the same assertion. It has never failed, which is unremarkable — and it is the kind of unremarkable that only happens when the thing being asserted is impossible to break rather than merely easy to get right.


← All posts