Storing Money in a Database Without Losing Cents or Customers
Money bugs don't announce themselves. A float column silently drops half a cent on every invoice, reconciliation drifts by a few euros a month, and six months later someone asks why the monthly total doesn't match the sum of the line items. Every one of these bugs traces back to decisions made in the first hour of building the billing feature. Here are ours.
Store integers. Minor units, not euros
A monetary amount as DECIMAL(10,2) works, until the day you need micro-pricing, per-minute metering, or a currency without a decimal part. The cleaner habit: store every amount as an integer in the currency's smallest unit. €49.99 becomes 4999. In PostgreSQL, a BIGINT column; in Laravel, integer attributes out of the box.
Your API and UI then convert to major units only at the edges:
// display
number_format($amountMinor / 100, 2);
// Stripe-style APIs expect minor units anyway
'amount' => 4999, 'currency' => 'eur',
This is the same convention Stripe uses, and adopting it everywhere removes an entire class of "off by a factor of 100" bugs when two systems exchange amounts. Pick one convention and document it. The mixing is the bug.
Floats deserve an explicit ban. 0.1 + 0.2 == 0.30000000000000004 looks like a joke until it's your ledger, and no amount of round() at the end fixes a sum that accumulated error across a thousand rows. If a column already exists as FLOAT, plan a migration carefully; the fix is the column type, not more rounding downstream.
Currency is a column, not a config value
Every money table needs a currency column next to the amount: varchar, three letters, ISO 4217 (EUR, not EU or eur interchangeably). Even if you only serve one market today, the column costs nothing and prevents the future rewrite where someone asks for USD support and every table needs a new field plus a data migration.
The rules that follow from it:
- Never compare or add amounts in different currencies. Group by currency, convert explicitly, or reject the operation. "Sum" across currencies is a bug, not a total.
- Format per locale, store per currency. The same €49.99 renders as
49,99 €in German and€49.99in English. The stored value never changes; the presentation does. Laravel'sNumber::currency()and PHP'sNumberFormatterhandle this, so don't hand-roll the format. - A user's preferred display locale and the currency they're charged in are two different facts. Keeping them coupled is how you end up billing Germans in dollars because their UI is in English.
Rounding: decide once, write it down
Rounding is where vague specs turn into support tickets. The questions to settle before the first invoice ships:
- What do you round, and when? Per line item, then sum? Or sum first, then round? They give different totals, and tax authorities in some jurisdictions specify which one is correct. VAT is generally calculated per line and rounded per line, so pick the rule your accountant recognizes.
- Which method? Half-up is what finance expects. The programmer default "banker's rounding" (half-to-even) surprises everyone who isn't a banker.
- How many digits at each step? Currency conversions through an intermediate rate lose precision; a common practice is to carry four decimals through calculation and round to minor units only at the end.
One function does all of it, and only that function rounds. If rounding rules are scattered through the codebase, every one of them is a future audit finding.
Conversions and the audit trail
Anything that changes an amount (a currency conversion, a refund, a mid-cycle price change) gets its own record with the original amount, the rate or factor applied, and the timestamp. Rates move daily; a stored rate next to the transaction is the difference between "check the archive" and "we cannot reproduce this number."
For apps doing real currency exchange, store the rate source too (your bank's API, the ECB reference rate, your payment processor's quote). "We used Stripe's conversion rate at charge time" is an auditable statement. "We used some rate around that day" is not.
The checklist
- Amounts as integers in minor units; floats banned from money columns.
- Currency stored next to every amount, ISO 4217 codes only.
- One rounding function, one documented rule, applied consistently.
- No cross-currency arithmetic without an explicit conversion step.
- Every conversion or adjustment recorded with rate and timestamp.
- Formatting happens at the presentation edge only.
Most of this is an afternoon of setup and a paragraph of documentation. Retrofitting it into a billing system with live customers is a quarter's worth of engineering and a lot of very quiet spreadsheet checking. Worth an afternoon.