April 5, 2026 · 7 min read
Why we store money in cents (and the bugs we caught when we converted)
Floating-point dollars cause real bugs in real production. RestoWebMaker stores all money as integer cents — here's why and what we found when we made the switch.
If you're building a system that handles money and you're storing dollars as floats, you've already shipped bugs you haven't found yet.
It's the textbook problem: 0.1 + 0.2 === 0.30000000000000004 in JavaScript, in Python, in any language that uses IEEE-754 floating point. For most code this is academic — for money code, it's a real bug that compounds. RestoWebMaker stores every monetary column as integer cents (an Int in our Prisma schema), and only formats to dollars at the display boundary. Here's the case for it and the surprises we found when we converted.
The case for integer cents
Three reasons:
- Arithmetic is exact. Adding 1099 cents and 250 cents gives you 1349 cents. No rounding, no surprise eighth decimal. You can do this in any language without imports.
- Storage is uniform. Every database column for money is an
Int. No deciding whether to useDecimal(10,2)ornumericor float. No drift between columns that should match. - Stripe and most payment APIs already use cents. Stripe's API takes
amountas an integer count of the smallest currency unit (cents for USD, pence for GBP). Your domain model lining up with the payment processor means you never convert at the API boundary, only at the display boundary.
The conversion plan
The migration was Plan 12 in our internal numbering. We had ~14 monetary columns scattered across Order, OrderItem, OrderLine, SeatItem, and Check. The conversion was three steps:
- Add new
*Centscolumns next to each existing decimal column. Backfill them by multiplying the float by 100 and rounding. - Switch read paths to the new columns. Keep writes going to both for a release.
- Drop the old columns. Add a single
lib/money.tsmodule with three functions —toCents,fromCents, andformatCents— and a lint rule against importing dollars anywhere except inside that module.
What we found
Bug 1: a $0.01 drift on multi-item orders
When we backfilled, six orders showed a 1-cent discrepancy between the order total and the sum of order items. The cause was beautiful: we'd been computing orderTotal = sum(items.map(i => i.price * i.qty))and the rounding happened separately on each line. So an order with three items at $5.33 each would show item totals of $5.33, $5.33, $5.33 (each rounded) and an order total of $15.99 (also rounded from $15.99000000…). The user saw 3 × $5.33 = $16.00on a different page and wrote in.
After the conversion, the line items store an integer cents price, the multiplication is integer math, and the sum is exact. The discrepancy disappeared.
Bug 2: tax computed differently in two places
Tax appeared on the order summary (computed in the API) and again on the receipt (computed in the email template). Both used the same rate, both used floating point, and they disagreed by a penny on about 4% of orders. The receipt was correct; the summary was wrong in both directions depending on the order shape. After conversion, we centralized tax into a single function in lib/money.ts that takes integer cents in and returns integer cents out.
Bug 3: Stripe webhook sometimes underpaid us by a cent
Our subscription billing reconciliation compared subscription.amount (our local copy) against the Stripe webhook's amount_paid. The local copy was a decimal; Stripe's value was integer cents. When we round-tripped — multiply by 100, round, compare — about 1% of charges showed a 1-cent mismatch and got flagged for manual review. Pure floating-point representation error. After conversion, both sides are integer cents and the comparison is exact.
Bug 4: gift-card balance going negative by a fraction of a cent
A gift card with a $25.00 balance, after one $10.13 redemption, would sometimes show a remaining balance of $14.869999999999997. We'd been displaying balance.toFixed(2) which masked this; the bug surfaced when someone bought another item for $14.87 and got a "not enough balance" error for a single decimal cent. The fix was the conversion itself.
What lives in lib/money.ts
toCents(dollars: number): number // Math.round(d * 100)
fromCents(cents: number): number // cents / 100
formatCents(cents: number, currency?: string): stringtoCents is the only place we accept a decimal dollar amount. fromCents exists only to support legacy display code that hasn't migrated; new code uses formatCentsdirectly. formatCents wraps Intl.NumberFormat with sane defaults — currency-aware, locale-aware, so €25 displays as €25.00 in the EU andEUR 25.00 in the US.
The lint rule
The hardest part of the conversion isn't the data, it's making sure new code doesn't reintroduce the bug class. We added an ESLint rule that flags any property access matching/Price$|Total$|Amount$|Subtotal$/ on a number that isn't typed as Cents. Cents is a branded type:
type Cents = number & { readonly __brand: "Cents" };Branded types in TypeScript are a runtime no-op but a compile-time contract. You can't accidentally pass dollars where cents are expected because TypeScript will complain. The ergonomics aren't perfect — you have to assert at boundaries — but the bug class is gone.
What this looks like for users
Nothing. Users see dollars on every page. The conversion was invisible. The four bugs above have all been quietly fixed; some of them had been in production for over a year. That's the signature of a good infrastructure change — nobody outside the team knows it happened, and the things that used to occasionally break stop breaking.
Try it yourself
Every template has a live demo with no signup. Personalize one for your own restaurant and see how it looks:
Browse 13 templates →