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.