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

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

  1. 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.00499999999999989342

    The 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.

  2. 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.67499999999999982236

    Four amounts, and on every one the two routines disagree by a cent. toFixed rounds the binary value, which is below the half. Intl.NumberFormat rounds the shortest decimal string that identifies the value, which is exactly the half, so halfExpand pushes it up. Column four is the reason both answers are defensible.

  3. Step 3.

    Measure how often the disagreement happens rather than guessing from four cases.

    node halfcents.mjs
    
    amounts 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.155

    Of 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.

  4. Step 4.

    Price one basket four ways and compare the totals.

    node rounding.mjs
    
    net                        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.939999999999998

    The 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

Sign: The rounding rule is documented as half-up, the unit tests pass, and production rounds some halves down.Cause: toFixed and Math.round both operate on the binary double, and most two-place decimals ending in 5 are stored slightly below the half. Step 3 counts 480 of 1000 such amounts rounding down. The tests pass because their fixtures happen to be among the other 520.
Sign: The same amount formats as 1.00 in the API response and 1.01 on the invoice PDF.Cause: One path calls toFixed and the other calls Intl.NumberFormat. They round different representations of the same double, as step 2 shows on four amounts in one process. Neither is a bug on its own, and nothing warns when a service uses both.
Sign: Line items and order total are each correct, and they differ by one minor unit.Cause: Rounding does not distribute over addition. Rounding tax on each line and summing gives 1.97 on the basket in step 4; rounding once on the sum gives 1.98. Fixing this means choosing an order of operations, not fixing a calculation.
Sign: A price with more than two decimals is accepted at checkout and reappears as a different figure on the statement.Cause: A unit price of 0.335 per item is legitimate input in many catalogues, and rounding it to the currency happens somewhere downstream. If that somewhere is the payment request rather than the order, the order and the charge disagree permanently.

What to check next

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.

intermediate8 minpublished updated Maks Verny