DEV Community

Hammad Shams Uddin
Hammad Shams Uddin

Posted on

Your amortisation schedule is one row short, and only sometimes

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

You cannot be billed $1,896.2027.

That is the whole bug, and I had it in a loan calculator and an amortisation schedule for months without noticing, because on most of the numbers I tested with it makes no difference at all.

The line

Standard amortisation. Work out the monthly payment, then walk the balance down month by month:

let pay = principal * r / (1 - Math.pow(1 + r, -n));

let balance = principal;
while (balance > 0.005) {
  const interest      = balance * r;
  let   principalPart = pay - interest;
  if (principalPart > balance) principalPart = balance;   // final payment
  balance -= principalPart;
  rows.push({ payment: principalPart + interest, interest, balance });
}
Enter fullscreen mode Exit fullscreen mode

That is correct arithmetic and it is not what happens to you.

pay comes out of that formula as something like 1896.2027149.... The schedule then charges 1896.2027149... every month for thirty years. Your lender charges you $1,896.20, because money has two decimal places, and the two-hundredths of a cent they are not collecting has to come from somewhere.

What it costs

$300,000 at 6.5% over 30 years, run both ways:

payments last payment
unrounded 1896.2027… 360 $1,896.20
rounded 1896.20 361 $4.53

The schedule said the loan ends in month 360. In reality there is a 361st bill for $4.53, because paying two-hundredths of a cent less each month leaves $4.53 of principal standing when the schedule thinks you are done.

Four dollars is not the point. The number of rows is wrong, and that is the one thing an amortisation schedule exists to tell you.

Why it survived

Here is the same comparison on a different loan:

payments last payment
unrounded 360 $1,380.51
rounded 360 $1,379.86

$250,000 at 5.25%, and both methods land on 360. The rounding happens to fall the other way and the stub is absorbed by the final payment.

So the bug is real, reproducible, and invisible on plenty of ordinary inputs. Any test suite built from round numbers — $250,000, 5%, 30 years — passes with it in. Mine would have.

This is a shape worth recognising, because it is not really about money:

A test case that agrees with both the correct and the incorrect implementation is not evidence of anything. It feels like coverage. It is the absence of coverage, dressed up.

When I wrote the tests afterwards I deliberately kept the $250,000 case in, alongside the $300,000 one. Not because it catches the bug — it cannot — but because a fix that only works on the input that exposed the problem is not a fix, and I want to know if I ever break the agreeing case too.

The fix is one line and one consequence

let pay = principal * r / (1 - Math.pow(1 + r, -n));
pay = Math.round(pay * 100) / 100;      // a lender bills to the cent
Enter fullscreen mode Exit fullscreen mode

The consequence is that the loop has to be allowed to run past n. If you wrote for (let m = 0; m < n; m++) you now silently drop the stub payment and end with a non-zero balance, which is worse than where you started — the schedule is still 360 rows but now it does not add up. The while (balance > 0.005) form handles it, with a guard so a pathological rate cannot spin forever.

The same mistake, upstairs

The summary calculator had its own version:

const emi   = principal * r * Math.pow(1+r, n) / (Math.pow(1+r, n) - 1);
const total = emi * n;                    // <- both wrong things at once
Enter fullscreen mode Exit fullscreen mode

total multiplies the unrounded payment, while the page prints the rounded one. So "Monthly payment $1,896.20" and "Total of payments $682,633.47" are not consistent with each other: a visitor with a phone can multiply 1896.20 by 360, get $682,632.00, and be right that something is off.

And it assumes the term is exactly n payments, which is the bug above wearing a different hat.

Running the real schedule fixes both, and 360 iterations of arithmetic costs nothing:

function trueCost(principal, monthlyRate, months) {
  let pay = principal * monthlyRate / (1 - Math.pow(1 + monthlyRate, -months));
  pay = Math.round(pay * 100) / 100;

  let balance = principal, paid = 0, interest = 0, count = 0;
  while (balance > 0.005 && count < months + 600) {
    count++;
    const i = balance * monthlyRate;
    let principalPart = pay - i;
    if (principalPart > balance) principalPart = balance;
    balance -= principalPart;
    if (balance < 0.005) balance = 0;
    interest += i;
    paid += principalPart + i;
  }
  return { payment: pay, months: count, total: paid, interest };
}
Enter fullscreen mode Exit fullscreen mode

It also gives you the number of payments for free, which is worth showing — it is the answer to "when am I actually finished", and it is not always the number you typed in.

Tests a borrower could run

The assertions that turned out to be worth the most are the ones checkable against a real statement, not against another implementation of the same formula:

  • every dollar of principal is repaid — no more, no less
  • interest plus principal equals what was paid
  • the final balance is exactly zero, not 1e-9
  • the payment is a whole number of cents
  • an extra $200 a month shortens the term and costs less interest

None of those restate the amortisation formula, which is the point. A test that recomputes principal * r / (1 - (1+r)^-n) and compares it to the code passes whether or not the code is right about anything else.


I build Utilorax, a set of free browser-based tools. This came out of the loan calculator, the mortgage calculator and the amortisation schedule, all three of which now count the 361st payment.

Top comments (0)