A money formatter using `Number.toFixed(2)`. Looks like the right tool for the job. Different runtimes implement the rounding subtly differently, and on float inputs the result can flip-flop between values for the same nominal number.
The vulnerable diff
// billing/format.ts
function formatMoney(amount: number): string {
// BUG: float input + toFixed has rounding inconsistencies
return amount.toFixed(2);
}
// 1.005.toFixed(2) // → "1.00" (NOT "1.01") in V8 due to float repr.
// 1.015.toFixed(2) // → "1.02" in some runtimes, "1.01" in others.What is wrong
Two problems. First, `Number.toFixed` is specified to use round-half-to-even (banker's rounding) in some places and round-half-away-from-zero in others — implementations historically differed. Second, the inputs are floats, so `1.005` isn't actually 1.005 in memory — it's `1.00499999...`, which rounds down. The result: `(1.005).toFixed(2)` returns `"1.00"` in V8, despite the mathematical expectation of `"1.01"`. For accounting, this kind of inconsistency adds up over many transactions. The fix is integer minor-units throughout and explicit rounding before display.
The attack
Examples:
(1.005).toFixed(2) // → "1.00" (V8)
(1.015).toFixed(2) // → "1.01" (V8; "1.02" elsewhere)
(0.1 + 0.2).toFixed(2) // → "0.30" — looks fine, but the underlying value is 0.30000000000000004Discovered usually via accountant reconciling reports.
Mesrai's review comment
mesraipilot · Bot · reviewed 30 sec ago
[mesrai] [code-review] [Logic] [Rounding] [medium]
`Number.toFixed` is unreliable for money:
- input is float, so 1.005 isn't really 1.005
- rounding mode differs across runtimes
Use integer minor-units. Store paise. Convert + format at display:
function formatRupees(paise: number): string {
const sign = paise < 0 ? "-" : "";
const abs = Math.abs(paise);
const rupees = Math.trunc(abs / 100);
const fraction = String(abs % 100).padStart(2, "0");
return `${sign}${rupees}.${fraction}`;
}
For non-integer cases (mid-calculation), use a decimal library
(big.js, decimal.js) with the rounding mode explicit:
new Big(value).round(2, Big.roundHalfUp).toString();The fix
// billing/format.ts — fixed (integer paise)
export function formatRupees(paise: number): string {
const sign = paise < 0 ? "-" : "";
const abs = Math.abs(paise);
const rupees = Math.trunc(abs / 100);
const fraction = String(abs % 100).padStart(2, "0");
return `${sign}${rupees}.${fraction}`;
}
// For Intl-aware locale formatting (Indian comma style etc.):
export function formatRupeesIntl(paise: number, locale = "en-IN"): string {
return new Intl.NumberFormat(locale, {
style: "currency", currency: "INR", minimumFractionDigits: 2,
}).format(paise / 100);
}Manual integer formatting for the canonical path. `Intl.NumberFormat` for locale-aware display (handles Indian comma grouping correctly).
Why human review missed it
Money rounding lives at the intersection of three error-prone topics: floats, rounding modes, and locale formatting. Mesrai catches every `Number.toFixed` and `Math.round` on a currency variable and recommends explicit integer-paise math + Intl formatting.
Related rules + further reading
Mesrai rule pack: logic/no-tofixed-on-money — flags toFixed on currency variables.
Mozilla MDN: Number.toFixed — rounding caveats.
ECMA-262 § 21.1.3.3 — toFixed specification.
Takeaway
toFixed lies on money. Integer paise + manual format. Or decimal lib with explicit rounding. Mesrai catches the gap.