Money math in plain JavaScript: lessons from building a loan calculator
Laskuri.org is a Finnish site with more than two dozen calculators: loans, compound interest, percentages, unit converters, fitness formulas. It is a static site with no framework and no bundler. Each calculator is one p
Laskuri.org is a Finnish site with more than two dozen calculators: loans, compound interest, percentages, unit converters, fitness formulas. It is a static site with no framework and no bundler. Each calculator is one plain JavaScript file.
The loan calculator was the hardest one to get right. These are the things that mattered.
Parse numbers the way people type them
Finnish users write 10 000,25, often with a non-breaking space, and negative rates sometimes arrive with a real minus sign (U+2212) pasted from a bank's page. Number() rejects all of these. The parser accepts them and refuses anything ambiguous:
function parseNumber(value) {
const s = String(value).trim()
.replace(/\u2212/g, '-')
.replace(/[ \u00a0\u202f]/g, '');
return /^[+-]?(?:\d+(?:[.,]\d*)?|[.,]\d+)$/.test(s)
? Number(s.replace(',', '.'))
: NaN;
}
1,000.00 and 1e4 return NaN on purpose. Guessing which separator the user meant is worse than asking again.
Count in cents
Every balance and payment is an integer number of cents. Interest is rounded once per month, the way a bank statement shows it, and the last payment is whatever clears the balance to exactly zero. With floating-point euros the schedule drifts by a cent somewhere around year fifteen, and users notice.
Equal-principal loans have their own trap: dividing the amount by the number of months and rounding each instalment does not add up. The calculator rounds the cumulative principal instead and takes the difference, so the instalments always sum to the loan.
Use expm1 and log1p for the annuity
The textbook annuity formula subtracts two numbers that are almost equal when the monthly rate is small. Math.expm1 and Math.log1p exist for exactly this:
function annuity(principal, annualRate, months) {
const r = annualRate / 1200;
return r === 0
? principal / months
: principal * r / -Math.expm1(-months * Math.log1p(r));
}
The zero-rate branch is not optional. Reference rates were negative for years, and a zero total rate is a real input.
The effective rate has no formula
The annual percentage rate, with fees included, is the rate at which the discounted payments equal the money the borrower actually received. There is no closed form, so the calculator brackets the answer by doubling and then bisects a hundred times. It is slower than Newton's method and never diverges, which matters more in a browser.
One file for the browser and for the tests
Each calculator file ends its pure part with two lines:
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document === 'undefined') return;
In the browser the file carries on and wires up the form. In Node it stops after exporting the functions, so the tests need no bundler and no DOM:
test('known annuity benchmark: 100000, 5%, 30 years', () => {
close(annuity(100000, 5, 360), 536.8216230121398);
});
The site has over two hundred of these tests, run with Node's built-in test runner and no dependencies.
Live rates without a fragile page
The calculator pre-fills the Euribor reference rate from the Bank of Finland. That is the only server-side code on the site: one small PHP endpoint that fetches the published rates, caches them for six hours, and refuses to replace a cached quote with an older one.
The page does not depend on it. A snapshot of the rates is embedded in the HTML at build time, the request to the endpoint has a timeout, and if both fail the user gets an example rate and a note asking them to type their own. The rate field stays editable in every case.
What I would do again
- Keep the math in pure functions with no DOM access.
- Store money as integers.
- Test against a published benchmark, not against your own output.
- Treat every live data source as something that will be down one day.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.