A customer wrote to us in March about a single cent. Her invoice had three lines and a total, and the total was one cent more than the three lines added together. She was polite about it, and she was right.
We could have shrugged. Money is full of cents that do not exist, after all. But an accountant reads an invoice line by line, and a total that does not agree with its lines looks like a mistake, whether or not it is one.
The cent that went missing
Each line was a quantity times a price, with tax on top. We rounded the tax on every line to the nearest cent, which seemed careful, and then added the lines to get the total. The tax on the total, however, we worked out from the untaxed subtotal, because that is what the tax authority asks for.1
Three lines of 33.33 each, at 20 per cent, show the problem. Each line carries 6.666 of tax, which rounds to 6.67. Add three of them and the lines say 20.01. Tax on the 99.99 subtotal is 19.998, which rounds to 20.00. Both are rounded correctly. They disagree anyway.
“A total is a promise. The lines are only how you explain it.”
Round once, at the end
The rule we settled on is short. Work in the smallest unit, cents, as whole numbers. Keep every intermediate value exact, as a fraction of a cent if it has to be. Round exactly once, when a figure is going to be printed, and make sure the printed figures still add up.
That last clause is the hard part. Rounding once gives you the right total, but the lines, each rounded on their own, may no longer sum to it. So the lines are not rounded on their own. We decide the total first and then hand out its cents.
Splitting without losing anything
Handing out cents is an old problem with an old answer, usually called the largest remainder method. Round every share down, count how many cents are left over, and give one each to the shares that lost the most in rounding. Ties go to the earlier line, so the same invoice always comes out the same way.2
export function allocate(total: number, weights: number[]): number[] { // total is in cents. Every share is rounded down first, // then the leftover cents go to the largest remainders. const sum = weights.reduce((a, b) => a + b, 0) const exact = weights.map(w => (total * w) / sum) const shares = exact.map(Math.floor) let left = total - shares.reduce((a, b) => a + b, 0) exact .map((x, i) => ({ i, rest: x - shares[i] })) .sort((a, b) => b.rest - a.rest || a.i - b.i) .forEach(({ i }) => { if (left-- > 0) shares[i]++ }) return shares}The function takes the total in cents and a list of weights, which for an invoice are the untaxed line amounts, and returns shares that always add up to the total. Run it once for the tax, once for the net, and the printed lines agree with the printed totals.
What we test
We did not trust ourselves, so we replayed 4,000 real invoices through the old and the new code. The old code disagreed with itself on 61 of them. The new code disagreed on none, and the totals changed on only four, each by a single cent, all in the customer’s favour.
We also wrote the property down as a test, so it cannot come back quietly: for any total and any non-negative weights, the shares are whole numbers, they sum to the total, and no share differs from its exact value by a full cent.
The customer who wrote in has had her reply. We sent her the 4,000-invoice spreadsheet too. She has not asked for anything since.