How to check currency rounding
Run node -e "console.log((1.005).toFixed(2))". It prints 1.00, not 1.01, because the literal is stored as 1.00499999999999989342. Then run the same number through Intl.NumberFormat with roundingMode: 'halfExpand' and read $1.01. Two rounding routines in one process, two answers.
Why check this
Run this on any release that touches pricing, tax, discounts or currency conversion, and once on every new service before it takes a payment. The failure it prevents is an invoice whose lines add up to one figure and whose total says another, by one minor unit, on maybe one order in a hundred.
That defect survives code review. The code reads Math.round(amount * 100) / 100 or total.toFixed(2), which is what a reviewer expects. Nothing in the source says how a half will be handled, or whether tax is rounded before or after the lines are summed. Only running the numbers says.
The check settles what the arithmetic does, not what it should do. The rule belongs to your finance team and differs by jurisdiction. Get it in writing first.
Prerequisites
- Node 22 or later.
node --versionprintedv22.23.2here. Any runtime with IEEE 754 doubles shows the same first two steps, so the finding is not specific to Node. - The rounding rule the service is supposed to follow: half-up or half-even, and whether tax is computed per line or on the order total.
- MDN on Number.prototype.toFixed and MDN on Intl.NumberFormat for what each one promises.
Save this as halfcents.mjs for step 3. It walks every amount that ends in half a cent between 0.005 and 9.995.
// node halfcents.mjs
let up = 0, down = 0;
const sample = [];
for (let t = 5; t <= 9995; t += 10) {
const x = t / 1000;
const halfUp = ((t + 5) / 1000).toFixed(2); // what a half-up rule would give
if (x.toFixed(2) === halfUp) up += 1;
else { down += 1; if (sample.length < 6) sample.push(x); }
}
console.log('amounts of the form N.NN5 :', up + down);
console.log('toFixed(2) rounded up :', up);
console.log('toFixed(2) rounded down :', down);
console.log('first six rounded down :', sample.join(' '));
Save this as rounding.mjs for step 4. It prices one basket four ways: tax per line and tax on the sum, each in integer minor units and in floating point.
// node rounding.mjs
const cents = [499, 599, 1299]; // 4.99, 5.99, 12.99
const RATE = 825n; // 8.25%, in hundredths of a percent
const halfUp = (a, b) => (2n * a + b) / (2n * b); // round a/b half away from zero
const show = (c) => (c / 100n) + '.' + String(c % 100n).padStart(2, '0');
const net = cents.reduce((s, c) => s + BigInt(c), 0n);
const perLine = cents.reduce((s, c) => s + halfUp(BigInt(c) * RATE, 10000n), 0n);
const onSum = halfUp(net * RATE, 10000n);
const f = cents.map((c) => c / 100);
const fNet = f.reduce((s, x) => s + x, 0);
const fPerLine = f.reduce((s, x) => s + Number((x * 0.0825).toFixed(2)), 0);
const fOnSum = Number((fNet * 0.0825).toFixed(2));
console.log('net ', show(net));
console.log('tax per line, integer ', show(perLine), ' total', show(net + perLine));
console.log('tax on the sum, integer ', show(onSum), ' total', show(net + onSum));
console.log('tax per line, float ', fPerLine.toFixed(2), ' total', (fNet + fPerLine).toFixed(2));
console.log('tax on the sum, float ', fOnSum.toFixed(2), ' total', (fNet + fOnSum).toFixed(2));
console.log('the float total, unrounded', fNet + fPerLine);
Steps
- Step 1.
Print the two numbers that show what a decimal literal is worth in memory.
node -e "console.log(0.1 + 0.2, (1.005).toFixed(2), (1.005).toFixed(20))"0.30000000000000004 1.00 1.00499999999999989342The first value is the famous one and the least useful. The second is the one that costs money:
toFixed(2)rounded a half down. The third says why. The value being rounded was never a half, so rounding it to two places correctly gives 1.00. - Step 2.
Round the same numbers a second way, in the same process, and compare.
node -e "const f = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', roundingMode: 'halfExpand' }); for (const v of [1.005, 1.015, 1.045, 2.675]) console.log(String(v).padEnd(6), v.toFixed(2), f.format(v), v.toFixed(20))"1.005 1.00 $1.01 1.00499999999999989342 1.015 1.01 $1.02 1.01499999999999990230 1.045 1.04 $1.05 1.04499999999999992895 2.675 2.67 $2.68 2.67499999999999982236Four amounts, and on every one the two routines disagree by a cent.
toFixedrounds the binary value, which is below the half.Intl.NumberFormatrounds the shortest decimal string that identifies the value, which is exactly the half, sohalfExpandpushes it up. Column four is the reason both answers are defensible. - Step 3.
Measure how often the disagreement happens rather than guessing from four cases.
node halfcents.mjsamounts of the form N.NN5 : 1000 toFixed(2) rounded up : 520 toFixed(2) rounded down : 480 first six rounded down : 0.015 0.045 0.075 0.105 0.145 0.155Of the thousand amounts that land on half a cent, 480 round down under
toFixed. Which ones is decided by where each decimal falls against the nearest double, so it looks arbitrary from the source. A suite that samples a few prices misses it. - Step 4.
Price one basket four ways and compare the totals.
node rounding.mjsnet 23.97 tax per line, integer 1.97 total 25.94 tax on the sum, integer 1.98 total 25.95 tax per line, float 1.97 total 25.94 tax on the sum, float 1.98 total 25.95 the float total, unrounded 25.939999999999998The same three items and the same 8.25 percent give 25.94 or 25.95, decided by whether tax is rounded on each line or once on the sum. Both are arithmetically correct. Only one matches your tax rule. The last line is the float total before display: it prints as 25.94 and is not 25.94, so a later comparison against 25.94 fails.
How to read the result
| What you see | What it means | What to do |
| --- | --- | --- |
| toFixed and Intl agree on every sampled half | The prices in your sample happen to sit on exact binary halves | Widen the sample. Step 3 is the wide sample |
| Line totals sum to one cent less than the order total | Tax is rounded per line in one place and on the sum in another | Pick one order of operations and apply it in the invoice, the total and the refund |
| The stored total ends in .999999... | The amount is a float that was displayed rounded, never rounded in storage | Store minor units as integers. See Zero decimal currency |
| The refund of a rounded amount is off by a cent | The refund recomputes from the unrounded source instead of reading the charged figure | Refund the number that was charged, not the number that was calculated |
Common mistakes
What to check next
- Zero decimal currency: the fix for most of this is integer minor units, and the multiplier is not always 100.
- Price tampering: once the arithmetic is right, check who is allowed to supply the number.
- How to test currency formatting: the same amount rendered for a reader, where the symbol and the digit count change.
- How to check the thousand separator and decimal separator: a total parsed back from a formatted string is a separate defect with the same symptom.
- How to test API with invalid input: send a price with four decimals and see which layer rejects it.
FAQ
What are the currency rounding rules I should test against?
Two questions: which half rule (half-up, half-even) and at which point (per line, per order, per tax rate). Both come from your finance team or the tax authority, not from the code. The steps above tell you which one the service implements.
Is half-even worth using instead of half-up?
Half-even removes the upward bias when many halves are rounded, which matters for accrual accounting over large volumes. For a checkout it matters less than agreeing on one rule everywhere. Intl.NumberFormat takes roundingMode: 'halfEven' if you need it.
Does using decimal.js or big.js remove the problem?
It removes the representation half of it. A decimal library parses "1.005" as exactly 1.005 and rounds it up. It does not decide whether tax is rounded per line or on the sum, which step 4 shows is worth the same one cent.
Why does 0.1 + 0.2 matter for money at all?
On its own it rarely does, because the error is far below a cent. It matters through comparison and accumulation: a total built from floats is not equal to the literal it prints as, which step 4 shows as 25.939999999999998.
Verified
Verified by Maks Vernynode 22.23.2
Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.
Related on this site
intermediate8 minpublished updated Maks Verny