FifeRouter

2 September 2026 · ledger money

Seven ledger kinds, each with a known sign

We started with four and added three, and each addition was an argument about whether a motion was really new. The temptation each time was to widen an existing kind.

topup       >= 0   money in
grant       >= 0   credit we gave away, backed by no payment
refund      >= 0   usage credited back — we billed something we should not have
restoration >= 0   a reversal undone: the money came back after all
debit       <= 0   usage billed
reversal    <= 0   a topup undone at the card: refunded, or charged back
adjustment    any  a deliberate manual correction

Six of the seven have a pinned sign, enforced by a CHECK constraint. The balance is therefore a plain sum() with no sign logic anywhere, and a debit stored positive is not a code review finding — it is refused by Postgres.

We started with four. Each of the other three was added under pressure to widen an existing kind instead, and the arguments are more interesting than the outcome.

reversal — the one that would have broken the property

A payment refunded at the card has to take its credit back. The obvious move is to use refund, which is already there and already means something refund-shaped.

It cannot, and the reason is the sign. refund is pinned positive, because it already means credit returned to a balance for usage we should not have billed — a goodwill entry, pointing into the account. A card refund points the other way.

So the choice was: relax the sign constraint on refund so it can go either way, or add a kind.

Relaxing it is one line and it removes the property. With refund able to be either sign, a sign error in the refund path becomes representable — and the guarantee changes from "unrepresentable" to "we are careful", which is a different kind of guarantee with a shorter shelf life.

grant — the one that was already right

New accounts get starting credit. It is positive, so topup seems natural.

But reversible_headroom — the guard that stops a refund inventing a debt — is computed from top-ups. A grant counted as one would let a later card refund reverse credit that no payment ever backed. Free credit, clawed back to fund somebody else's chargeback.

The pleasing part: the guard needed no change. It had been written as FILTER (WHERE kind = 'topup') rather than "anything positive", so it was already correct about a kind that did not exist when it was written.

That is not foresight. It is what you get from naming the thing you mean instead of the shape it currently has.

restoration — the one we got wrong first

A dispute decided in our favour returns the money, so the reversal it caused was right at the time and wrong in hindsight.

Our first answer was that this could not be done automatically: it needs a positive compensating kind, refund means over-billing, adjustment is documented as a manual correction, and inventing a sixth kind for a twice-a-year event seemed disproportionate. We wrote it up as out of scope.

That was right about the constraint and wrong about the conclusion. The answer is a new kind, because that is what every other distinct motion here has.

And a restoration does something adjustment must not: it restores reversible headroom. The payment behind it is valid again, so a later refund of that same payment must still be reversible — otherwise winning a dispute would quietly make a customer unrefundable, which nobody would discover until they asked for their money back.

adjustment — the deliberate exception

One kind with no pinned sign, for corrections a person decides on. It is the escape hatch, and it exists so that the other six do not have to be widened when something genuinely unusual happens.

The rule we have kept: if a motion is automatic and recurring, it gets a kind. If it is a human deciding something one-off, it is an adjustment with a description saying who and why.

That rule is what stopped adjustment becoming a dumping ground. Every time something almost fitted it — the reversal, the grant, the restoration — the test was whether a webhook or a person would be posting it. A webhook is not a person.

The cost

Seven kinds is more than four, and every reader has to learn them. Each addition was a migration touching a CHECK constraint on a table with money in it.

What it buys is that sum(amount_microcents) is the balance, in every query, forever, with no case analysis — and that each new motion had to be argued for in a spec before it could exist, which is a much better filter than whether it was convenient at the time.


← All posts