Building & Shipping

Money Is Not a Column

Brett Ridenour Brett Ridenour · Published September 2026

Every booking platform I have ever looked at starts the same way. There is a reservations table, and somewhere on it is an amount_paid column, and somewhere else is an amount_refunded column, and there is a nagging column called outstanding that everyone stops trusting within about six months of going live.

I know this because I built that version first. And I know it because last week I finished the second, larger, quieter version, and the reason it exists is not that the first one was wrong. The reason it exists is that the first one had no answer for what happens when a customer disputes a charge that was already partially refunded on a booking whose price was renegotiated after the original quote was already applied to a payout that partially cleared.

Say that sentence out loud once. Then imagine trying to answer it from four integer columns on a reservations row.

The realization

Double-entry accounting is not something you build for accountants. It is something you build for the sixth month of production traffic, when a real business is quietly happening on top of your software and nobody is telling you which of your columns are still telling the truth.

The specific realization was this. Money does not have a location in the world. It has a history. “This booking has been paid” is not a state. It is the result of a running total of events, each of which has to survive independently. If you lose one of those events — or, worse, if your system replays it — your numbers do not just drift. They lie in ways that look right for weeks.

Money does not have a location. It has a history.

— the lesson that took an embarrassing amount of production traffic to learn

So the answer is that the reservations table has no financial columns anymore. Not one. amount_paid is gone. amount_refunded is gone. outstanding is gone. All of them lived, briefly, and then got politely deprecated in favor of asking a real ledger what it thinks.

The ledger, in this case, is Formance, an open-source double-entry ledger that speaks a small DSL called Numscript. It runs in a Docker container next to Postgres. It has one job — book transactions, tell me the balances — and the entire point is that it takes no shortcuts. Every debit has a credit. Every posting is idempotent. Every balance is a query, not a stored number.

What ripping out the columns actually looked like

The route handlers used to do something like this on a successful payment.

Before — writing money into a database column

Which is fine, in the way that a house of cards is fine, up until the exact moment it is not fine.

What the route handlers do now, after last week, is emit an event.

After — emitting a ledger event, letting the worker post it

That is it. The route does not write to Formance. The route does not even know Formance exists. It hands the intent off to a LedgerEventEmitter, which drops the event into a BullMQ queue, which is drained by a worker, which is the only thing in the system allowed to talk to the ledger. And when the worker posts to Formance, it does it with an idempotency reference derived from the event, so replaying the same event twice is a no-op.

1
API Route
Emits a typed ledger event. Never touches Formance directly.
2
Outbox / Queue
BullMQ on Redis. Event is durable. Retries on failure.
3
Ledger Worker
Only thing that talks to Formance. Idempotent by reference.
4
Formance
Double-entry books. Every balance is a query, not a stored number.

Four steps between the button click and the money actually being booked. That felt like a lot the first time I drew it. It feels like almost nothing now.

The invariant that pays for the whole thing

The reason it is worth those four steps is a single invariant that becomes possible.

Formance never gets out of sync with itself. Because every posting is a debit and a credit, every ledger balance is provably consistent at every moment. There is no “did the refund get counted, or did I count it twice.” There is a query, and the query has an answer.

That means one weird, load-bearing line of code:

// packages/shared/src/types/financial-operations.ts
// outstanding = charged - paid + refunded

Read that once and it looks wrong. Refunds increase the outstanding balance? Yes. Because in a double-entry world, a refund means money left the business’s accounts, which means the business owes that money back to accounts receivable until it settles. The old “amount owed by the customer” mental model had you subtracting refunds from what they owed. The correct model has you adding refunds to what the business owes back. Same word, different debit-credit direction, completely different answer during a dispute.

The failure mode of the old system was that the two models silently disagreed, and nobody knew which column was telling the truth on any given day. The new one has one answer, and the answer is the ledger.

The outbox is doing more work than you think

The unglamorous piece — the thing I did not appreciate before shipping this — is the outbox itself. It is not a queue for performance. It is a queue for durability.

Here is what it buys you. Formance goes down for ten minutes. Redis stays up. Every event that would have been booked queues in the outbox. When Formance comes back, the worker drains the queue in order, with idempotency, and the books are exactly where they would have been if Formance never blinked. No reservations get blocked. No customer sees an error. No support ticket gets opened. The system takes a nap and wakes up to a clean set of books.

The alternative — synchronous writes from the route handler directly to Formance — means that anything less than 100% uptime on the ledger is downtime on your booking flow. And nobody’s ledger is 100%.

What actually broke, in the good way

The interesting thing about a rebuild like this is not the code. The code is mostly deletions. The interesting thing is which reports suddenly started disagreeing with reality, and being right to disagree.

The revenue-recognition report changed. The platform-fee accounting changed. The dispute flow got a new event type it never had before. The payout report finally reconciles to Stripe’s payout statement without a mental fudge factor. A whole class of “off by a penny” issues just stopped happening, because the pennies are no longer being computed twice by two different mental models and asked to agree.

The best sign that the rebuild was the right rebuild is that a lot of dashboards that used to look “close enough” now look boringly, mechanically, provably right. That is the whole game.

The takeaway

If you are building anything that touches money — a booking system, a marketplace, a subscription product, a wallet, any of it — the day you replace your amount_paid column with a real ledger is the day the software stops betraying you at inconvenient moments.

You do not need a ledger the day you go live. You need one the day before your first refund of a partially-paid booking on a disputed charge. Which, if you are lucky, will be about five months in.

Start earlier than that.