<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Samle Gjeld</title>
    <description>The latest articles on DEV Community by Samle Gjeld (@samlegjeldno).</description>
    <link>https://dev.to/samlegjeldno</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4109190%2Fcca450a3-208e-47a9-96df-55c3496b860a.png</url>
      <title>DEV Community: Samle Gjeld</title>
      <link>https://dev.to/samlegjeldno</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/samlegjeldno"/>
    <language>en</language>
    <item>
      <title>Writing a loan calculator means writing a root finder</title>
      <dc:creator>Samle Gjeld</dc:creator>
      <pubDate>Fri, 04 Sep 2026 06:41:46 +0000</pubDate>
      <link>https://dev.to/samlegjeldno/writing-a-loan-calculator-means-writing-a-root-finder-2fnc</link>
      <guid>https://dev.to/samlegjeldno/writing-a-loan-calculator-means-writing-a-root-finder-2fnc</guid>
      <description>&lt;p&gt;Norway requires every consumer loan to advertise its &lt;strong&gt;effective annual rate&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The easy half: the payment itself
&lt;/h2&gt;

&lt;p&gt;The annuity payment for a fixed-rate loan is closed form and pleasant:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// pv  = principal&lt;/span&gt;
&lt;span class="c1"&gt;// r   = periodic rate (annual nominal / 12)&lt;/span&gt;
&lt;span class="c1"&gt;// n   = number of periods&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pv&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;pv&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That covers the nominal rate. If you stop here, your calculator disagrees with every bank in the country.&lt;/p&gt;

&lt;h2&gt;
  
  
  The hard half: fees change the rate
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the real cash flow is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;at t=0 you receive &lt;code&gt;pv - originationFee&lt;/code&gt;
&lt;/li&gt;
&lt;li&gt;at t=1..n you pay &lt;code&gt;payment(pv, r, n) + monthlyFee&lt;/code&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The effective periodic rate &lt;code&gt;i&lt;/code&gt; is whatever makes the present value of those payments equal the amount received:&lt;/p&gt;

&lt;p&gt;$$\sum_{t=1}^{n} \frac{P}{(1+i)^t} = PV_{net}$$&lt;/p&gt;

&lt;p&gt;There is no algebraic solution for &lt;code&gt;i&lt;/code&gt;. You have to search for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bisection, not Newton
&lt;/h2&gt;

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

&lt;p&gt;Bisection is slower and cannot fail on a bracketed root. For something that runs once per keystroke on a form, slower is free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;effectiveRate&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nominalAnnual&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;originationFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;monthlyFee&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;pv&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;originationFee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pmt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pv&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;nominalAnnual&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;monthlyFee&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;pvAt&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pmt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;pmt&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="nx"&gt;n&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// 0% to 100% per month, wide enough for anything legal&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;pvAt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;net&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;mid&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;monthly&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;lo&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;hi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pow&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;monthly&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;12&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// annualise by compounding, not by x12&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things worth pointing out.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;pvAt&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Money and floats
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;What did matter was rounding for display consistently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;nok&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nx"&gt;Intl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nc"&gt;NumberFormat&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;nb-NO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;style&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;currency&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;currency&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;NOK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;maximumFractionDigits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;Intl.NumberFormat&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  No build step
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The calculators are here if you want to look at the source:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://samlegjeldno.github.io/gjeldskalkulator/" rel="noopener noreferrer"&gt;Debt consolidation calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://samlegjeldno.github.io/effektiv-rente-kalkulator/" rel="noopener noreferrer"&gt;Effective interest rate calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://samlegjeldno.github.io/renteokning-kalkulator/" rel="noopener noreferrer"&gt;Rate increase calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://samlegjeldno.github.io/gjeldfri-kalkulator/" rel="noopener noreferrer"&gt;Debt free date calculator&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://samlegjeldno.github.io/nedbetaling-prioritering/" rel="noopener noreferrer"&gt;Repayment prioritisation&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Disclosure so nobody has to guess: I work on &lt;a href="https://samlegjeld.no" rel="noopener noreferrer"&gt;samlegjeld.no&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>beginners</category>
      <category>showdev</category>
    </item>
  </channel>
</rss>
