I audited my own repo yesterday. I’ve been building Freebo — a booking and payments platform for tour and charter operators — since December, mostly heads-down. I hadn’t stopped to count anything. When I did, one number surprised me more than the rest: zero.

There are zero financial balance columns in my Postgres database. Not a balance field on a customer. Not an amount_paid on a reservation. Not an outstanding anywhere. Ask this system what a customer owes and it does not run a SELECT.
That was a deliberate choice, and everything downstream of it — the queue architecture, the retry semantics, the way refunds work, the reason I sleep — is a consequence of that one decision.
0
database tables
0
balance columns
0
API routes
0
external systems
The temptation you have to refuse
The obvious way to build a bookings platform is: a reservations table with total_cents, paid_cents, and outstanding_cents, and every payment webhook updates those columns. It’s fast to write, fast to query, and it will bite you in the softest possible way.
Here is what happens. A Stripe webhook fires. Your handler updates paid_cents. The write goes through. Now Stripe retries the same webhook because your ACK didn’t arrive in time. Your handler runs again. paid_cents doubles. The customer’s booking is marked overpaid. You issue a refund. Now paid_cents is negative. Somewhere a report says the operator owes the customer money that the operator never actually received.
You didn’t lose money. You lost the ability to say what happened. That’s worse.
You didn’t lose money. You lost the ability to say what happened.
— Me, at 2am, staring at a spreadsheet
What replaces the columns
Every financial event in Freebo — charge, quote change, payment, refund, cancellation, Stripe payout, chargeback, dispute opened, dispute won, dispute lost, revenue recognition — is emitted as a typed event, pushed onto a Redis queue via BullMQ, and posted by a worker into a Formance ledger with an idempotency key derived from the transaction reference.
Formance is a double-entry accounting engine. You post transactions in a small DSL called Numscript that reads like a bank teller’s slip:

Every event carries an idempotency key derived from the reservation, event type, and Stripe event id. The ledger rejects duplicates at that boundary, so retries are safe by default. If I want to know the outstanding balance on a booking, I don’t sum a column — I ask Formance for the balance of that account. The balance is derived from the transaction history. It cannot get out of sync with itself, because it is itself.
Three things fall out of this that I did not fully appreciate until I was living with them.
1. Formance being down cannot block a customer from booking
The write path is Route → LedgerEventEmitter → Redis queue → worker → Formance. The customer’s checkout doesn’t wait on the ledger. If Formance is unreachable, or slow, or restarting, events pile up in Redis. When it comes back, the worker drains the queue. Everything eventually reconciles. Nothing that happened while the ledger was down is lost.
If I had paid_cents columns, a Formance outage would either take down my checkout or force me to write “temporary” state to Postgres and reconcile later. That “later” is where everyone’s bugs live.
2. Retries cannot double-post
Every event carries a transaction reference. Formance rejects duplicate references at the ledger boundary. If BullMQ retries a job (which it will — that’s the whole point of using a queue), the second attempt is a no-op inside the ledger. My worker code doesn’t need to be idempotent. The ledger is idempotent, and my worker code is allowed to be dumb.
3. Refunds increase accounts receivable
This one is counterintuitive and it matters. Money left the business. From the business’s point of view, the customer now owes you less, but the amount you have collected also went down. In double-entry, that shows up as an increase in AR, not a decrease. If you had a paid_cents column and you subtracted the refund from it, your total collections number would look right — but your relationship to the customer would be wrong. Formance forces you to model the arrow the right way, because there is nowhere to lie.
The same discipline shows up elsewhere
Once you accept that “state is derived from history, not stored,” it starts leaking into other parts of the system.
Immutable quote versions. Every price change on a booking creates a new immutable quote_version row. Reservations point at an active_quote_version_id. Nothing is ever mutated in place. When a customer disputes a charge four months later, I can reconstruct exactly what they were shown and agreed to, line by line. Mutable pricing rows make that impossible.
The pricing formula sitting on top is order-dependent and unforgiving. Rearrange any two lines and the tax is wrong:
subtotal = base + add_ons + custom_charges − custom_discounts
subtotal_after_promo = subtotal − promo_discount
platform_fee = subtotal_after_promo × platform_fee_rate
taxable = subtotal_after_promo + platform_fee (taxable items only)
tax = taxable × tax_rate
grand_total = subtotal_after_promo + platform_fee + fees + tax
outstanding = grand_total − payments + refunds
All money is stored as integer cents. Never floats. Ever. That is one of maybe a dozen invariants I encoded into the codebase’s custom Claude Code skills so that automated work — my own worktree fleet included — physically cannot ship a float in place of an integer without getting caught in review.
Real-time availability against a mutable calendar. Availability isn’t a boolean. For any product on any day, the system has to resolve recurring schedules, per-vessel schedules, blackout dates, existing reservations, in-flight holds from other customers currently in checkout, booking cutoffs, turnaround gaps, and a rules engine of operator-authored conditions (“no 3-hour charters on Saturdays before noon”). Fast enough to render an 18-month calendar; correct enough to never double-book a boat. That single file is 1,598 lines. It exists because the naive version sold the same vessel twice.
Why any of this fits in one person’s head
The honest answer is: it doesn’t, on any given day. What makes it tractable is that the invariants are small and load-bearing, and everything else in the codebase is subordinate to them.
- Money in cents.
- Tenant-scoped queries always.
- Quote versions never mutated.
- Ledger is the source of truth.
- The API returns the shape; the UI adapts to it, never the other way.
That’s five rules. I can hold five rules. Once those are true, I can add another API route, another React page, another migration, and it will land inside a system that continues to make sense. Break any of them and the pile stops being a pile and starts being a landslide.
The takeaway
If you are building anything that touches money, the question I would ask before you write your next migration is: can I reconstruct the truth from the events, or am I storing the truth as a running sum?
Running sums are seductive. They are also lossy. Every retry, every partial failure, every “quick fix” chips away at your ability to say — with confidence, to a real human, on a phone call — what actually happened. Ledgers give you back that confidence. The cost is a queue, a worker, a small DSL, and the discipline to never, ever write a balance column.
I have not regretted it once.