Money is integer cents, and the tax rate freezes on the line
Two boring data-modeling rules buy you a year without phantom accounting bugs: store money as integer cents end to end, and freeze the tax rate onto each line at creation.
The day an invoice reconciles on the web dashboard but is off by one cent on the mobile app, nobody has a bug they can point at. The math looks correct in both, and you spend an afternoon discovering that a float went in on one path and a string came back on another. That cent is not a rounding nuisance. It is the data model telling you it never agreed with itself on what money is.
I shipped a B2B billing system for a market where a finance admin reconciles real invoices by hand, and the two decisions that bought me a quiet year were both unglamorous and made on day one. Neither is clever. They just refuse to let money be ambiguous.
Money is integer cents, all the way through
There is exactly one correct representation of an amount in the whole system: an integer number of cents. Not a float, not a decimal string, not “whatever the JSON happened to serialize.” The price is 150000, meaning 1,500.00. It is an integer in the database, over the wire, in the mobile client, in the web client. It becomes a human-readable string in one place: a single money() formatter at the render edge, the last thing that happens before pixels.
The bug you are avoiding is subtle because every individual conversion looks fine.
- A float anywhere is a slow leak. The moment one service does arithmetic in floating point, 0.1 plus 0.2 stops being 0.3, and the error compounds across line items until a multi-page invoice is off by a cent. The web and mobile apps, rounding at different moments, land on different totals. Now the document the customer pays against disagrees with itself.
- Formatting in two places is two sources of truth. If the backend sends a pre-formatted “1,500.00” and the frontend has its own formatter too, you have two opinions about separators and symbols. They will drift. Send the integer, format once.
Money has exactly one shape in transit, an integer count of the smallest unit, and exactly one place it turns into text. Every other formatting decision in your codebase is a bug you have not hit yet.
The tax rate freezes onto the line, forever
The second rule is the one people argue with, because it looks like denormalization for no reason. When you create an invoice line, you write the tax rate that applied at that moment onto the line. Not a foreign key to a tax_rates table you join at read time. The literal number, stamped on the row.
Here is why the “clean” version is a trap. If the line just references the current rate, then your historical invoices are not stored facts, they are recomputations, correct only as long as the rate never changes. The day the standard rate moves, and in a real market it eventually does, every invoice you ever issued silently recomputes to the new number the next time someone opens it. A document you sent a customer last year, that they paid, that their accountant filed, now shows a different total. You did not edit it. The join did.
An issued invoice is a historical record of what was charged, not a live calculation of what would be charged today. So the rate lives on the line, the totals live on the line, and changing the global rate touches nothing that already happened. Old lines keep telling the truth about the day they were created.
Build for how money actually moves
Both rules come from the same instinct: design the money system around how money behaves in the real world here, not around the payments demo. In my market that world is partial payments, two cheques against one invoice, references typed by hand, a void with a stated reason. None of it survives if the amounts are squishy floats or your totals quietly rewrite themselves.
The cent and the frozen rate are not optimizations. They are the difference between a ledger and a guess that is right most days. Make money one shape, format it once, and never let an old invoice answer with today’s number.