A prepaid ledger that can say no
Replacing monthly invoicing with a prepaid balance: a signed append-only ledger and a booking gate that returns the exact shortfall.
A tutoring company billed families monthly, after the lessons. That means generating invoices, chasing them, and carrying the cost of every lesson already taught to a family that pays late or not at all. We deleted monthly invoicing and replaced it with a prepaid balance: money in first, debited as lessons complete.
The design fits in five decisions.
1. The ledger is the statement
One append-only table. amount_minor is a signed integer in minor units: positive
credit, negative debit. Each row also stores balance_after_minor, written from
the database's RETURNING clause in the same statement that moves the balance. The
UI displays it and never recomputes it.
The account row keeps a denormalised balance for fast reads. The invariant is that it always equals the sum of the ledger, and the scenario tests assert exactly that after every flow.
2. Paginate by sequence, not by time
Rows written in one transaction share created_at to the microsecond. A timestamp
cursor will silently drop or repeat them at a page boundary. The ledger has a seq column for ordering and cursors; the timestamp is for people.
3. Money is the truth, hours are a view
Families think in hours, but the balance is stored in money. Hours remaining is derived at read time from the current rate. If the rate changes, the same money buys different hours, and nothing stored has become wrong.
4. The booking gate
blocked ⟺ balance − cost_of_this_session < floor
floor = −1 × hourly_rate
cost = hourly_rate × duration_min / 60 The floor is one hour below zero, on purpose. Without headroom, a family a few cents short gets blocked mid-course, and that costs more in support time than the hour is worth.
A blocked booking returns 402 Payment Required with the shortfall in the body:
{"code":"insufficient_balance","details":{"shortfall_minor":4500}} The client doesn't show a generic error. It says how much more is needed and links to a top-up for exactly that amount.
5. The debit rides the lesson
The debit happens inside the transaction that finalises the session, so a lesson cannot complete without its money moving. The reverse is deliberately not true: if the debit fails, the session still finalises. Blocking it would leave the schedule wrong as well as the money, and one of those is easier to repair by hand.
Finalisation is also time-guarded. A "meeting ended" event that arrives before the lesson window has properly begun (someone testing their camera) is ignored, so a tech check can't move money.
What stays manual
Refunds go through staff and are mirrored into the ledger from the payment provider's webhook. Clawing back a tutor's payout after a chargeback is also manual: a partial dispute has no safe fraction to invert. Automating a rare, judgement-heavy path buys little and risks a lot.