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.