DEV Community

Samle Gjeld
Samle Gjeld

Posted on

Writing a loan calculator means writing a root finder

Norway requires every consumer loan to advertise its effective annual rate, not the nominal one. The difference sounds like a rounding detail. It is not, and the reason is that there is no closed-form solution for it, so you end up writing a root finder to display a number on a landing page.

I built five small loan calculators recently, all vanilla JS, no dependencies, no build step. This is the part that took the longest to get right.

The easy half: the payment itself

The annuity payment for a fixed-rate loan is closed form and pleasant:

// pv  = principal
// r   = periodic rate (annual nominal / 12)
// n   = number of periods
function payment(pv, r, n) {
  if (r === 0) return pv / n;
  return (r * pv) / (1 - Math.pow(1 + r, -n));
}
Enter fullscreen mode Exit fullscreen mode

That covers the nominal rate. If you stop here, your calculator disagrees with every bank in the country.

The hard half: fees change the rate

The effective rate has to account for what the borrower actually receives and actually pays. An origination fee taken off the top means you borrow 300,000 but receive 297,500. A monthly administration fee means each payment is larger than the annuity formula says.

So the real cash flow is:

  • at t=0 you receive pv - originationFee
  • at t=1..n you pay payment(pv, r, n) + monthlyFee

The effective periodic rate i is whatever makes the present value of those payments equal the amount received:

$$\sum_{t=1}^{n} \frac{P}{(1+i)^t} = PV_{net}$$

There is no algebraic solution for i. You have to search for it.

Bisection, not Newton

My first version used Newton-Raphson. It is faster, and it is also the one that will hand you a NaN at 2am when someone enters a 0% rate with a fee, because the derivative goes flat and you divide by roughly nothing.

Bisection is slower and cannot fail on a bracketed root. For something that runs once per keystroke on a form, slower is free:

function effectiveRate(pv, nominalAnnual, n, originationFee = 0, monthlyFee = 0) {
  const net = pv - originationFee;
  const pmt = payment(pv, nominalAnnual / 12, n) + monthlyFee;

  const pvAt = (i) => {
    if (i === 0) return pmt * n;
    return pmt * (1 - Math.pow(1 + i, -n)) / i;
  };

  let lo = 0, hi = 1; // 0% to 100% per month, wide enough for anything legal
  for (let k = 0; k < 200; k++) {
    const mid = (lo + hi) / 2;
    if (pvAt(mid) > net) lo = mid; else hi = mid;
  }
  const monthly = (lo + hi) / 2;
  return Math.pow(1 + monthly, 12) - 1; // annualise by compounding, not by x12
}
Enter fullscreen mode Exit fullscreen mode

Two things worth pointing out.

pvAt uses the annuity present value formula rather than looping the sum. Same result, and it stays accurate at long terms where summing 360 divisions accumulates noticeably.

The last line compounds instead of multiplying. A monthly rate of 1% is not 12% a year, it is 12.68%. Multiplying by twelve here is the single most common mistake I found when I checked what other calculators were outputting, and it under-reports the rate the borrower is quoted.

Money and floats

I kept everything in kroner as floats and rounded only at display. That is usually the wrong advice, but here the inputs are user estimates rather than ledger entries, and nothing is settled against a real balance. If you are reconciling actual payments, use integer minor units instead.

What did matter was rounding for display consistently:

const nok = new Intl.NumberFormat('nb-NO', {
  style: 'currency', currency: 'NOK', maximumFractionDigits: 0
});
Enter fullscreen mode Exit fullscreen mode

Intl.NumberFormat handles the Norwegian thousands separator, which is a non-breaking space, not a comma or a period. Doing that manually produces output that looks subtly foreign to every Norwegian reader.

No build step

All five are a single HTML file with inline CSS and JS, served from GitHub Pages. Total page weight under 6 KB. No framework, no bundler, no dependency to patch six months from now.

The tradeoff is real: no component reuse, and the fifth one shares no code with the first. For pages this small that was cheaper than the alternative.

The calculators are here if you want to look at the source:

Disclosure so nobody has to guess: I work on samlegjeld.no, a Norwegian debt consolidation site, and these were built for it. The maths is jurisdiction-neutral though, and the effective rate problem is the same anywhere consumer credit disclosure is regulated.

If you have built one of these and solved the fee handling differently, I would genuinely like to hear it. The disagreement between calculators on the same inputs is larger than it should be.

Top comments (0)