A refund calculation using JavaScript floats. Author multiplied original amount by tax rate. Off by paisa on most calculations. Compounded over 10K transactions, the error is real money owed or kept.
The vulnerable diff
// billing/refund.ts
function calculateRefund(originalRupees: number, taxRate: number) {
// BUG: float math on money
const refund = originalRupees * taxRate; // 0.1 * 0.2 ≠ 0.02
return refund;
}
// 0.1 + 0.2 === 0.30000000000000004
// 1149.99 * 0.18 === 206.99820000000003
// On 10K refunds: cumulative error in the thousands of paise.What is wrong
IEEE 754 double-precision floats can exactly represent powers of 2 but not most decimal fractions. `0.1` is a repeating binary; `0.1 + 0.2 = 0.30000000000000004`. For currency, every multiplication and addition introduces tiny errors that compound. The standard pattern is to store and operate on integer minor-units (paise, cents) throughout the system, and only convert to a major-unit string at display time. Alternative: a decimal library (big.js, decimal.js) for non-integer cases.
The attack
Symptom: refunds off by paisa, customer complaints, accounting reconciliation requires a fudge factor.
# Direct: 1149.99 * 0.18 → 206.9982...
# Rounded to 2dp: 207.00 (looks right)
# 10,000 refunds at this pattern:
# Cumulative drift: ~₹18 due to rounding-direction asymmetry.
# Over the year: easily ₹2,000+ unreconciled.Banks audit this. Reconciliation pipelines flag it.
Mesrai's review comment
mesraipilot · Bot · reviewed 1 min ago
[mesrai] [code-review] [Logic] [Float-Money] [critical]
Float arithmetic on currency. Use integer minor-units (paise):
function calculateRefundPaise(originalPaise: number, taxBp: number): number {
// taxBp = tax in basis points (18% = 1800 bp). Integer math throughout.
return Math.round(originalPaise * taxBp / 10_000);
}
// Display:
const display = (paise / 100).toFixed(2); // OK for display only
For non-tax cases where percentages are exact decimals, a decimal
library (big.js / decimal.js) is fine too:
import Big from "big.js";
const refund = new Big(originalRupees).times(0.18).round(2);
Whichever you pick, never mix float math with currency in business
logic. Storage = integer minor-units. Computation = integer or
decimal. Display = formatted string.The fix
// billing/refund.ts — fixed (integer paise)
function calculateRefundPaise(originalPaise: number, taxBp: number): number {
// taxBp = basis points (18% = 1800 bp). Math.round on division.
return Math.round((originalPaise * taxBp) / 10_000);
}
// Display helper:
function formatRupees(paise: number): string {
return (paise / 100).toFixed(2);
}Store paise. Multiply by basis points (no decimal). Divide last with `Math.round`. The arithmetic stays in integer space until the final rounded result.
Why human review missed it
Float-on-money is one of those bugs that looks correct in tests with small numbers — the rounding error is invisible at single-transaction scale. The bug compounds at scale. Mesrai catches every arithmetic operation on a variable identified as currency (named `amount`, `price`, `total`, `refund` etc., or typed as Money).
Related rules + further reading
Mesrai rule pack: logic/no-float-on-currency — flags float arithmetic on currency variables.
Martin Fowler: Money pattern.
Mike Cohen: Avoiding the float-rounding trap on currency.
Takeaway
Integer paise throughout. Float never touches money. Display only at the boundary. Mesrai catches the gap.