Umang Saija
Field notes Book a call

Where did the cent go?

Two correct calculations can disagree by one cent. Here is why checkouts lose money in rounding, in plain English, and how to test for it.

5 min read · 42 s film
A papercut hand holds a magnifying glass over a pile of coins and finds the one missing cent

The short version

Three ideas. Everything below explains them, with things to try.

1Computers can’t store most prices exactly

In JavaScript, $19.99 × 100 comes out as 1998.9999… Keep money in whole cents.

2Rounding is a choice

Your cart, payment provider and ERP can each round correctly, and still disagree.

3Splitting a discount loses cents

$10 off three items is $3.33 each, $9.99 in total. Give the spare cent to one line, on purpose.

The short version as a papercut film, narrated, with captions.
A papercut cash register shows $11.00 while its receipt reads order −$10.99 and lines −$11.00

Two right answers, one cent apart

Three items, $19.99, $24.99 and $9.99, with 20% off everything.

The cart takes 20% off the total: $10.99. The order lines take 20% off each item and add them up: $11.00. Both are correct. They just round in different places.

Nobody complains about a cent. That is the problem: finance finds it at month end, as books that won’t balance and refunds that fail.

Try itChange the prices and watch the two totals split

One discount, two answers. $19.99 + $24.99 + $9.99 at 20% off: $10.99 off the total, but $11.00 when each line is discounted and added up.

Computers can’t count cents

By default, most programming languages store decimal numbers in binary, and binary can’t hold 0.1 exactly, just as decimal can’t hold ⅓.

So in JavaScript 0.1 + 0.2 is 0.30000000000000004. One easy mistake when turning $19.99 into cents is to multiply by 100 and drop the decimals: the answer is 1998 cents, not 1999.

573of the 9,999 prices from $0.01 to $99.99 lose a cent that way
1998cents, not 1999, for $19.99

Try itRun real JavaScript, and test a price of your own

The console does not lie. Math.floor(19.99 * 100) is 1998. Of the 9,999 prices from $0.01 to $99.99, 573 lose a cent this way.

Go deeper: the fix, and how to spot the bug

Keep money as a whole number of the smallest unit (cents, yen, fils), or in a true decimal type such as BigDecimal in Java, decimal in Python and C#, or DECIMAL in SQL. Payment APIs already work this way: Stripe takes every amount as a whole number in the currency’s smallest unit.5

Signs to look for as a tester:

  • a price that saves one cent lower than you typed it;
  • an import where 4.35 arrives as 4.34;
  • an API response containing 114.99999999999999 (that is 1.15 * 100);
  • a total that is right in the cart but a cent off in the email.
Two papercut buildings, CART and ERP, joined by a bridge that doesn’t meet; a coin falls through the gap

Rounding is a choice

Even with exact numbers, someone has to decide what happens to half a cent.

School says round half up. Python and C# round half to the nearest even digit by default, often called banker’s rounding. JavaScript rounds −2.5 to −2.

Each rule is fine on its own. The bug is two systems in the same order flow using different rules.

Try itRound one number with four different rules

Same number, four rules. 2.675 to two places: half away from zero 2.68, half to even 2.68, truncate 2.67, and JavaScript’s toFixed(2) 2.67.

Go deeper: who rounds 2.5 which way
WhereFunction2.5−2.5
ExcelROUND(x, 0)3−3
Rubyx.round3−3
PostgreSQLround(numeric)3−3
MySQLROUND() on exact values3−3
Pythonround(x)2−2
C#Math.Round(x)2−2
JavaScriptMath.round(x)3−2
JavaMath.round(x)3−2
Defaults, from each language’s documentation.1–4 Red marks a result that differs from rounding half away from zero.

Binary storage makes it worse: 2.675 is stored as 2.67499999999999982236…, so (2.675).toFixed(2) gives 2.67 when the number you typed rounds to 2.68.

A papercut $10 note split into three strips of $3.33, with a sign reading $3.33 × 3 = $9.99

Splitting a discount loses cents

$10 off three $12.50 items is $3.333… each. Round each one and you have $3.33 three times: $9.99.

It bites at refund time. Refund each item on its own and the refunds add up to more than the customer paid, so a payment provider will usually refuse the last one.

The fix: round each share down, then give the leftover cent to one line, on purpose. The pieces then always add up to the whole.

Try itSplit a discount three ways, then refund every line

$10.00 off, split three ways. Round each line: $3.33 + $3.33 + $3.33 = $9.99. Give the leftover cent to one line: $3.34 + $3.33 + $3.33 = $10.00.

Go deeper: the three ways systems split, and other currencies
  • Round each line. Simple, and the pieces drift from the whole.
  • Plug the last line. The last line takes whatever is left. The sum is right, but one product carries everyone’s rounding.
  • Largest remainder. Round each down, then hand the spare cents to the lines that lost the most. The sum is right and no line moves by more than a cent. It is the same method used to share out seats in some parliaments.7

Currencies differ too: the Japanese yen has no smaller unit and the Kuwaiti dinar has three decimals.6 A store that assumes two decimals shows “¥333.33”, which is not an amount anyone can pay.

Tax rounds it all again

Tax can be worked out on every line and added up, or once on the whole order.

At New York City’s 8.875%, $19.99 + $4.99 + $7.49 comes to $2.87 one way and $2.88 the other. Which is right depends on the tax rules where you sell. The bug is the storefront and the ERP choosing differently.

Try itSame cart, same rate, two tax totals

Tax: per line or per invoice? $19.99 + $4.99 + $7.49 at 8.875%: per line $2.87, per invoice $2.88.

A papercut mountain of coins at night, with a tag reading 3,490 of 10,000 orders disagree

A cent, ten thousand times

One cent sounds like nothing. So here are 10,000 simulated orders.

The cart and the ERP each worked correctly. They only rounded in different places. About one order in three ended with them disagreeing, by a cent or two each: too small to report, too common to ignore.

3,490of 10,000 simulated orders disagreed
$36.65left to reconcile by hand
2¢the most any single order was off

Try itRun the 10,000 orders yourself

Ten thousand orders later. 3,490 of 10,000 simulated orders (34.9%) end with the cart and the order lines disagreeing, by one or two cents: $36.65 in total.

Three papercut strips rejoined as $3.34 + $3.33 + $3.33 = $10.00, with a green PASS stamp

The fix: four rules to test

Every money bug above breaks one of these. Put them in the test plan before the first test case.

Open the 12-case test kit
Go deeper: questions to ask your team before testing
  • Is money ever stored as a floating-point number, anywhere, including the data passed between systems?
  • Which rounding rule do we use, and is it the same in the storefront, payment provider and ERP?
  • When a discount is split across lines, which line gets the leftover cent?
  • Is tax worked out per line or per order, and who decides?
  • Which currencies do we sell in, and where have we assumed two decimals?
  • How does a refund work out its amount: from the line, or from what is left on the payment?

The one thing to remember: rounding is a decision. Find out who made it, write it down, and test every system against it.

Did this note help?

Turn on JavaScript to give it a heart.

Sources

  1. MDN: Math.round() and Number.prototype.toFixed()
  2. Python: round() and Floating-point arithmetic: issues and limitations
  3. Microsoft: ROUND function (Excel) and Math.Round (.NET)
  4. PostgreSQL: mathematical functions, MySQL: rounding behaviour and Ruby: Float#round
  5. Stripe: supported currencies and zero-decimal currencies
  6. ISO 4217 currency codes and minor units
  7. Largest remainders method

The 10,000 orders are simulated in your browser with store-style prices; every figure on this page is checked by automated tests in this site’s code.

A checkout that has to be right to the cent?

Send me the build. I’ll send back a report you can act on: what breaks, how badly, and the exact steps to see it.

Umang Saija
Umang Saija

ISTQB-certified QA Analyst at Anatta Design, testing Shopify stores, their checkouts and the APIs behind them. Before that, nearly four years at Astound Digital on Salesforce Commerce Cloud, B2B Commerce and Data Cloud. LinkedIn · Email