I maintain ~10 small zero-dependency CLI tools for freelancer admin (invoicing, late fees, currency conversion, tax estimates) — some in Python, some ported to Node so people can npx them without installing anything. Porting money-handling code between the two languages taught me a lesson the hard way: Math.round(n * 100) / 100 and Python's round(n, 2) do not agree 1.2% of the time, and the disagreement is silent — no crash, no NaN, just a wrong cent that only shows up on specific inputs.
The setup
Every port had the same shape: read a dollar/ruble amount, do arithmetic (apply a rate, a fee, a tax bracket), round to 2 decimals, print it. I'd hand-write 5-10 test cases per port, compare output against the Python original byte-for-byte, and ship once they matched. That caught obvious bugs. It did not catch this one, because none of my hand-picked test cases happened to land exactly on a rounding boundary.
Why they disagree
Python's round() uses round-half-to-even (banker's rounding) and operates on the exact binary value of the float. JavaScript has no built-in decimal round — the common pattern is:
Math.round(n * 100) / 100
This rounds half-up, but more importantly, n * 100 is itself a new floating-point multiplication that can land on a different side of .5 than the original value, because n * 100 isn't the same double as "the true value times 100" — it's the double closest to that product.
A real one that shipped and sat live for days before I caught it — converting USDT to RUB at a P2P rate (85900 USDT at a rate of 117.1):
Python (ground truth): 9993507.21
JS (Math.round, shipped): 9993507.22
One cent off, on a 7-figure number, with zero errors or warnings anywhere.
How I actually found it
Not by reading the code harder — I'd already read it. I wrote a fuzzer: generate 50,000 random amounts, rates, and fees, run both the Python original and the JS port on each, diff the outputs.
50,000 random inputs → 1.2% disagreed by exactly 1 cent
1.2% doesn't sound like much until you remember every one of those is a real dollar amount a real invoice would show to a real client.
The fix
Round-half-to-even on the exact decimal expansion of the double, not on an intermediate n * 100 multiplication:
function round2(n) {
if (n === 0) return 0;
const neg = n < 0;
const s = Math.abs(n).toFixed(20); // exact decimal expansion of the double
const [intPart, fracPart] = s.split(".");
const keep = fracPart.slice(0, 2);
const rest = fracPart.slice(2);
let roundUp;
if (rest[0] > "5") roundUp = true;
else if (rest[0] < "5") roundUp = false;
else {
const restAfter = rest.slice(1).replace(/0+$/, "");
roundUp = restAfter.length > 0 || parseInt(keep[1] || "0", 10) % 2 === 1;
}
let keepNum = BigInt(intPart + keep.padEnd(2, "0"));
if (roundUp) keepNum += 1n;
const result = Number(keepNum) / 100;
return neg ? -result : result;
}
It's uglier than one line of Math.round, but it agrees with Python on all 50,000 fuzzed inputs — and on every input since.
What I'd actually recommend
If you're porting money logic between languages (or even just adding decimal rounding to a JS app that touches money), don't trust hand-picked test cases to catch rounding-boundary bugs — they're specifically the inputs you're least likely to think of by hand. A quick fuzz-and-diff against a reference implementation (or against toFixed + exact decimal math, if you don't have one) takes a few minutes to write and will catch the 1-in-80 case your test suite won't.
The affected tools (now fixed) are open source, zero-dependency, MIT-licensed:
- usdt-rub-js — USDT→RUB conversion estimator
- freelance-currency-js — live-rate currency converter
- freelance-late-fee-js — overdue invoice late-fee calculator
- freelance-rate-js — hourly rate calculator
The rest of these money-math tools ship together in the Freelancer Starter Kit — invoicing, contracts, NDAs, timesheets, all the same zero-dependency philosophy.
Top comments (0)