JavaScript invoice totals off by one cent: floating point money rounding
Our PDF invoices and the amounts charged through Stripe disagreed by a cent on 214 invoices in one month. Floats, toFixed and rounding at every step were to blame. We moved to integer minor units with one explicit rounding point.
At month-end close in January, our finance team ran the usual reconciliation between invoices generated by our billing service and the charges recorded in Stripe. 214 of roughly 9,800 invoices did not match. Every difference was exactly one cent, sometimes in our favour, sometimes in the customer's. A few customers had noticed too: their PDF said 1,234.57 and their card statement said 1,234.56.
The reconciliation script's output was short and uncomfortable:
invoice=INV-2025-01-04412 pdf_total=1234.57 stripe_amount=123456 diff=-0.01
invoice=INV-2025-01-04419 pdf_total=88.35 stripe_amount=8836 diff=+0.01
invoice=INV-2025-01-05102 pdf_total=402.10 stripe_amount=40209 diff=-0.01
mismatched=214 total=9817
Reproducing it
We picked invoice INV-2025-01-04412 and replayed its calculation in a Node REPL. It had seven
lines, quantities between 1 and 40, unit prices with two decimals, a 10% discount and 20% VAT. The
PDF path and the Stripe path both started from the same line items but computed totals differently:
- The PDF renderer summed line totals as JavaScript numbers, applied discount and tax to the sum,
and called
toFixed(2)at the end. - The Stripe path computed each line, rounded it with
Math.round(x * 100), and sent integer cents per line.
Both were "correct" in their own way. They just rounded at different points, using floating point values that were already slightly off.
What we ruled out
- Currency conversion. All affected invoices were in a single currency with no FX involved.
- Stripe tax calculation. We do not use Stripe Tax; we send precomputed amounts, so Stripe was simply charging what we told it to.
- Data corruption in the database. Unit prices are stored as
numeric(12,2)in PostgreSQL and were exact. The problem started once they reached JavaScript.
Root cause
Three things combined. node-postgres returns numeric values as strings, and our repository layer
converted them with parseFloat, turning exact decimals into binary floating point approximations.
Arithmetic on those values accumulated tiny errors such as 0.30000000000000004. Then the two code
paths rounded at different stages, and toFixed and Math.round(x * 100) both round the binary
value, not the decimal one you see. A value that should be exactly half a cent could be stored as
slightly less than half, and round down in one path but not the other.
What is happening under the hood
JavaScript numbers are IEEE 754 double precision floats. They represent integers exactly up to
Number.MAX_SAFE_INTEGER (about 9 quadrillion) but most decimal fractions cannot be represented
exactly in base 2. The classic examples:
0.1 + 0.2; // 0.30000000000000004
1.005 * 100; // 100.49999999999999
Math.round(1.005 * 100) / 100; // 1, not 1.01
(1.005).toFixed(2); // "1.00"
1.005 is actually stored as 1.00499999999999989..., so rounding it to two places is correct for
the binary value and wrong for the decimal value a human wrote. When you add many such values and
round at different places in different code paths, the results disagree by a cent on a small but
steady fraction of invoices, which is exactly what we saw: about 2%.
Payment providers avoid this by working in integer minor units. Stripe amounts are integers in the smallest currency unit, for example cents for USD and EUR, and whole yen for JPY, which has no minor unit.
Environment
| Component | Version / value |
|---|---|
| OS | Ubuntu 24.04 LTS (container node:22-bookworm-slim) |
| Runtime | Node.js 22.13.0 |
| Language | TypeScript 5.7 |
| Database | PostgreSQL 16.6, prices as numeric(12,2) |
| DB driver | pg 8.13 (numeric returned as string) |
| Payments | Stripe API, amounts in integer minor units |
Minimal reproduction
type Line = { qty: number; unitPrice: string }; // unitPrice comes from Postgres as a string
const lines: Line[] = [
{ qty: 2, unitPrice: '19.99' },
{ qty: 5, unitPrice: '4.35' },
{ qty: 3, unitPrice: '0.15' },
];
// Path A: sum floats, round once at the end (PDF)
const subtotalA = lines.reduce((s, l) => s + l.qty * parseFloat(l.unitPrice), 0);
const totalA = (subtotalA * 0.9 * 1.2).toFixed(2);
// Path B: round each line to cents first (Stripe)
const totalB = lines
.map((l) => Math.round(l.qty * parseFloat(l.unitPrice) * 0.9 * 1.2 * 100))
.reduce((s, c) => s + c, 0);
console.log(totalA, totalB / 100); // 67.15 vs 67.16 for this basket
Who is affected
Any JavaScript or TypeScript code that does money arithmetic with number and fractional units,
including front-end cart totals. Languages with the same float type (Python float, Java double,
PHP float) have identical issues. Systems that round per line in one place and per invoice in
another will mismatch even with exact decimal arithmetic, so the rounding policy matters as much as
the number type.
The fix
We changed the billing service so that money is always an integer count of minor units, and rounding happens at exactly one documented point: when a percentage (discount or tax) is applied to a line. We agreed the policy with finance: round half away from zero per line, then sum.
Parsing the database string straight into integer cents, without ever creating a float:
export function toMinor(amount: string, exponent = 2): bigint {
const [whole, frac = ''] = amount.split('.');
if (frac.length > exponent) throw new Error(`too many decimals: ${amount}`);
const sign = whole.startsWith('-') ? -1n : 1n;
const digits = whole.replace('-', '') + frac.padEnd(exponent, '0');
return sign * BigInt(digits);
}
Applying a percentage expressed in basis points with integer arithmetic and an explicit rounding mode:
// rateBp: 2000 = 20.00%. Rounds half away from zero.
export function applyRate(minor: bigint, rateBp: bigint): bigint {
const num = minor * rateBp;
const den = 10_000n;
const q = num / den;
const r = num % den;
const twice = (r < 0n ? -r : r) * 2n;
if (twice >= den) return num < 0n ? q - 1n : q + 1n;
return q;
}
The invoice calculation is now a pure function used by both the PDF renderer and the Stripe integration:
-const total = (subtotal * (1 - discount) * (1 + vat)).toFixed(2);
-await stripe.invoiceItems.create({ amount: Math.round(lineTotal * 100), ... });
+const inv = computeInvoice(lines, { discountBp: 1000n, vatBp: 2000n });
+renderPdf(inv); // formats inv.totalMinor for display only
+await pushToStripe(inv.lines); // sends each line's amountMinor as a Number
Display formatting happens last and only for humans:
const fmt = new Intl.NumberFormat('en-IE', { style: 'currency', currency: 'EUR' });
fmt.format(Number(inv.totalMinor) / 100); // safe: exact integer divided once for display
Alternatives and trade-offs
- A decimal library such as
decimal.jsorbig.js. Good when you need many decimal places, for example unit prices of 0.0042 per API call. You still have to choose a rounding mode and apply it at the boundary. - Plain
numberwith integer cents. Works for most businesses because integers are exact up toNumber.MAX_SAFE_INTEGER. We chosebigintbecause it makes accidental float maths a type error in TypeScript: you cannot multiply abigintby1.2. - Banker's rounding (half to even). Reduces bias over large volumes and is common in accounting systems. The right choice is whatever your ledger and tax authority expect; the wrong choice is two different modes in two code paths.
How to test the fix
Property-based testing caught more than our hand-written cases. With fast-check we generate random
baskets and assert that the PDF total always equals the sum of what we send to Stripe:
import fc from 'fast-check';
test('pdf total equals sum of stripe line amounts', () => {
const line = fc.record({
qty: fc.integer({ min: 1, max: 500 }),
unitPrice: fc.integer({ min: 1, max: 9_999_999 }).map((c) => (c / 100).toFixed(2)),
});
fc.assert(
fc.property(fc.array(line, { minLength: 1, maxLength: 30 }), (lines) => {
const inv = computeInvoice(lines, { discountBp: 1000n, vatBp: 2000n });
const sum = inv.lines.reduce((s, l) => s + l.amountMinor, 0n);
return sum === inv.totalMinor;
}),
);
});
npx vitest run billing/money
We also replayed all 9,817 January invoices through the new function and compared with Stripe.
Confirming in production
The February close had zero mismatches across 10,412 invoices. The reconciliation job now runs nightly instead of monthly and posts a count to our billing channel; it has reported zero every night since the deploy. For the 214 January invoices we issued credit notes or small adjustments agreed with finance, which took one afternoon.
Regression guard
- An ESLint
no-restricted-syntaxrule bansparseFloatandtoFixedinsidesrc/billing/. - The money module's types only accept
bigintamounts, so floats cannot reach it without an explicit conversion that shows up in review. - The nightly reconciliation alerts on any non-zero mismatch count.
// eslint.config.mjs (excerpt)
{
files: ['src/billing/**/*.ts'],
rules: {
'no-restricted-syntax': ['error',
{ selector: "CallExpression[callee.name='parseFloat']", message: 'Use toMinor() for money.' },
{ selector: "CallExpression[callee.property.name='toFixed']", message: 'Format money with Intl.NumberFormat at the edge.' },
],
},
}
Security implications
Rounding drift is mostly a correctness and trust issue, but it has an abuse angle: if a system rounds in the customer's favour on small amounts, an attacker can split purchases or top-ups into many tiny transactions and collect the difference. Rounding per line with a single consistent mode and enforcing minimum amounts removes that edge. We also validate that amounts arriving from the client are integers in minor units and recompute totals server-side rather than trusting them.
Prevention checklist
- Store money as exact decimals in the database and as integer minor units in code.
- Never
parseFloata price; parse strings straight into integers. - Write down the rounding mode and the point where rounding happens, and use one shared function.
- Respect each currency's minor unit (0 for JPY, 3 for KWD) instead of assuming 2.
- Reconcile your totals against the payment provider automatically, not once a month.
References
- MDN, Number: precision and
Number.MAX_SAFE_INTEGER— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/MAX_SAFE_INTEGER - MDN,
Number.prototype.toFixed()— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Number/toFixed - Stripe documentation, Supported currencies and zero-decimal currencies — https://docs.stripe.com/currencies
- node-postgres, pg-types and numeric parsing — https://node-postgres.com/features/types
- MDN,
BigInt— https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/BigInt
Add a label
Embed this post
<iframe src="https://hibug.me/javascript-floating-point-money-rounding" width="700" height="420" frameborder="0" loading="lazy"></iframe>
I have spent most of my career on the side of software that pages people at night: payment integrations, web platforms with real traffic, CI/CD pipelines, Kubernetes clusters and the databases underneath them. Somewhere along the way I started keeping a notebook of every bug that cost more than an hour — the exact error, what we ruled out, the real root cause and how we proved the fix. Hibug is where those notes live. Every post follows the same path: Investigate, Explain, Fix, Verify — with the environment spelled out, the fix tested, and the security angle checked.
Related Stories
jest.mock silently ignored after switching a Node.js project to native ESM
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.