Your invoice template renders four line items and a grand total. Add the four printed amounts by hand and you get $1,134.74. The PDF says the total is $1,134.73. The template is fine, the data is fine, and the same numbers looked correct in your app.
The cause: you round the same money in two different places
Two things combine. Decimal fractions cannot be represented precisely in binary floating point, which is why 0.1 + 0.2 evaluates to 0.30000000000000004 in JavaScript. On its own that error is invisible at two decimal places. The visible mismatch comes from the second problem: each row is rounded for display, while the total is summed from the unrounded values. The reader sees one set of numbers and the total describes another.
const lines = [
{ desc: 'Consulting', qty: 7.5, rate: 145.50 },
{ desc: 'Support blocks', qty: 3, rate: 4.005 },
{ desc: 'Per-unit fees', qty: 11, rate: 0.335 },
{ desc: 'Overage', qty: 2.25, rate: 12.345 },
]
// Each row is rounded for display...
const rows = lines.map(l => (l.qty * l.rate).toFixed(2))
// ...but the total is summed from the raw floats.
const total = lines.reduce((sum, l) => sum + l.qty * l.rate, 0)
console.log(rows) // [ '1091.25', '12.02', '3.69', '27.78' ]
console.log(total) // 1134.72625
console.log(total.toFixed(2)) // '1134.73', but the rows above sum to 1134.74The fix: hold money in integer minor units and round once per row
const lines = [
{ desc: 'Consulting', qty: 7.5, rateCents: 14550 },
{ desc: 'Support blocks', qty: 3, rateCents: 400.5 },
{ desc: 'Per-unit fees', qty: 11, rateCents: 33.5 },
{ desc: 'Overage', qty: 2.25, rateCents: 1234.5 },
]
const money = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' })
const fmt = cents => money.format(cents / 100)
// Round each row once, then total the rounded rows.
const rows = lines.map(l => ({ ...l, lineCents: Math.round(l.qty * l.rateCents) }))
const totalCents = rows.reduce((sum, r) => sum + r.lineCents, 0)
rows.forEach(r => console.log(r.desc, fmt(r.lineCents)))
console.log('TOTAL', fmt(totalCents))
// Consulting $1,091.25
// Support blocks $12.02
// Per-unit fees $3.69
// Overage $27.78
// TOTAL $1,134.74The total is now built from the exact values printed in the rows, so the column always adds up. Rounding once per row also makes the printed row amount the amount of record, which is usually what you want in a document you are about to send, sign, or archive.
Two caveats
First, toFixed and Math.round are not the round-half-up your finance team pictures. MDN's own example has (2.55).toFixed(1) returning '2.5', because 2.55 is not exactly 2.55 in binary and the closest representable float is lower. Math.round is worse on negative amounts: a trailing .5 rounds toward positive infinity, so Math.round(-1.5) is -1, not -2, which will surprise you on the first credit note or refund line. If a specific rounding policy matters, implement it explicitly rather than inheriting whatever the language does.
Second, two decimal places is not universal. Intl.NumberFormat applies each currency's own minor unit, so the same amount prints as $1,134.74 in USD, 1,135 yen in JPY, and KWD 1,134.746 in Kuwaiti dinar. A hardcoded factor of 100 will misprice any currency whose minor unit is not one hundredth, so store the scale alongside the amount instead of assuming cents.
Back to All Questions