0.1 + 0.2 === 0.30000000000000004. You've seen it. In a toy it's a curiosity; in anything that touches money — billing, payroll, interest, payouts — it's a silent reconciliation bug waiting to happen. No error, no crash. The numbers just drift, and you find out when someone reconciles the books.
I spent this week on the money-critical core of a mortgage-servicing problem, and the discipline that keeps it correct comes down to three rules. They're simple, and most codebases break at least one.
Rule 1 — integer cents, never float dollars
let total = 0;
for (let i = 0; i < 10; i++) total += 0.1;
total === 1.0; // false — it's 0.9999999999999999
Floating-point can't represent most decimal fractions exactly, so every +, *, and / on dollar amounts can shave or add a fraction of a cent. One operation is invisible; ten thousand accruals is a real gap.
Represent money as integer cents (or whatever the minor unit is). $1,234.56 → 123456. Keep every intermediate value an integer and only format to dollars at the very edge:
const formatCents = (c) =>
`$${Math.trunc(c / 100).toLocaleString("en-US")}.${String(Math.abs(c % 100)).padStart(2, "0")}`;
Rule 2 — make the day-count explicit, and compute it in UTC
Interest, late fees, and per-diem all depend on how many days. The day-count convention (Actual/365-Fixed, Actual/360, 30/360…) changes the answer, so it should be a stated decision in the code, never an accident of whatever Date math you reached for.
// Interest accrued at an annual rate over N days, Actual/365-Fixed, in integer cents.
function accruedInterestCents(principalCents, annualRatePct, days) {
const p = BigInt(principalCents);
const rateScaled = BigInt(Math.round(annualRatePct * 100)); // 8.99% -> 899
// p * (rateScaled/10000) * days / 365 == p*rateScaled*days / 3_650_000
return Number(roundDivHalfUp(p * rateScaled * BigInt(days), 3_650_000n)); // helper in the repo
}
And compute the day count in UTC — local-time date math drifts by a day across daylight-saving transitions:
const days = Math.round((Date.UTC(y2, m2 - 1, d2) - Date.UTC(y1, m1 - 1, d1)) / 86_400_000);
Rule 3 — round once per period, not per day
This is the subtle one. Compute the period's interest and round it once. If you round a per-day figure and then sum it, the rounding error compounds:
accruedInterestCents(1_000_000, 8.99, 30); // 7389 ($73.89) — round once over 30 days
perDiemCents(1_000_000, 8.99) * 30; // 7380 ($73.80) — round daily, then sum
Same inputs. A 9-cent gap. On one loan it's noise; across a servicing book it's a reconciliation finding and an unhappy auditor. Decide exactly where rounding happens, and make it happen once.
The pattern
Integer cents, an explicit + UTC day-count, and round-once-per-period. None of it is hard — it's just easy to skip, and the failure mode is silent. So pin it down with tests:
$ node --test
✔ accrued interest is exact integer cents (Actual/365F, rounded once)
✔ rounds once per period, not per-day-then-summed (silent drift)
✔ payoff statement = principal + accrued interest + fees
✔ daysBetween counts leap day and is DST-proof (UTC)
✔ LTV in basis points, no float drift
...
Full runnable module + tests (per-diem accrual, payoff/discharge statements, LTV): https://github.com/sravan27/mortgage-money-math — node --test, zero dependencies.
This is the same "does the code silently return the wrong number?" discipline I've been applying to databases. I open-sourced a checker, silentdrop, that finds the query-layer version of these silent bugs in JS databases — the write-up is here. Money math is the same problem with a decimal point and higher stakes.
I take on correctness-critical builds — fintech money-math, database/sync query layers — as fixed-scope sprints; details are in the repos.
Top comments (0)