DEV Community

Joe Lin for BeGoodTool.com

Posted on

How to Build a Loan Amortization Schedule That Does Not Loop Forever

A loan calculator that only prints a monthly payment is missing the part people usually need: how much of each payment buys down principal, how much is interest, and what an extra payment changes. I built the schedule with an explicit month-by-month loop, including a guard for inputs that can never repay the balance.

The standard payment is only the starting point

The amortize function handles both ordinary interest-bearing loans and a zero-rate edge case:

function amortize(P, monthlyRate, baseN, extraPmt) {
  let payment;
  if (monthlyRate === 0) {
    payment = P / baseN;
  } else {
    const f = Math.pow(1 + monthlyRate, baseN);
    payment = (P * monthlyRate * f) / (f - 1);
  }
  let balance = P;
  let totalInterest = 0;
  let months = 0;
  const monthly = [];
Enter fullscreen mode Exit fullscreen mode

The zero-rate branch is not optional. The usual formula divides by f - 1; when the rate is zero, that denominator is zero and the result becomes NaN. A principal divided by the number of months is the correct payment for that case.

The loop separates interest from principal

Each month calculates interest on the current balance, then applies the scheduled payment and optional extra amount:

const interest = balance * monthlyRate;
let principalPart = payment - interest + extraPmt;
if (principalPart > balance) principalPart = balance;
if (principalPart <= 0) break;
balance -= principalPart;
totalInterest += interest;
months += 1;
monthly.push({
  interest,
  principal: principalPart,
  balance: Math.max(balance, 0),
});
Enter fullscreen mode Exit fullscreen mode

Clamping the last principal payment prevents the schedule from ending at a negative balance. The principalPart <= 0 check is the more important defensive detail: if a payment cannot cover interest, the balance will never shrink. Without a break, a malformed or impossible loan could make the browser hang in an infinite loop.

There is also a safety cap:

const maxMonths = baseN + 1200;
while (balance > 0.005 && months < maxMonths) {
  // ...
}
Enter fullscreen mode Exit fullscreen mode

The cap is not a financial rule. It is a UI protection against pathological inputs.

Extra payments need a comparable baseline

The computed result runs the schedule once with the extra payment. If an extra amount was entered, it runs a second baseline schedule with zero extra:

if (extraPmt > 0) {
  const base = amortize(P, monthlyRate, baseN, 0);
  savedInterest = Math.max(
    base.totalInterest - totalInterest,
    0
  );
  savedMonths = Math.max(base.months - withExtra.months, 0);
}
Enter fullscreen mode Exit fullscreen mode

That comparison is more useful than claiming an extra payment is “good.” It tells the user how many months and how much modeled interest the choice saves under the same principal, rate, and term.

Yearly rows are an aggregation, not a second calculation

The source walks the monthly array in groups of twelve, accumulating principalPaid, interestPaid, and the remaining balance. This keeps the chart and yearly table faithful to the exact monthly schedule. The headline total payment is P + totalInterest, not simply the advertised payment multiplied by the original term, because extra payments can shorten the schedule.

The limitations are important: rates are fixed, payments are monthly, there are no taxes, insurance, fees, refinancing, or lender-specific rounding rules. A positive extra payment is applied every month, not once. The result is a planning estimate, not a payoff quote from a bank.

Rounding and impossible loans deserve a visible policy

The internal schedule keeps fractional cents while it runs. That avoids accumulating a display-rounding error twelve times a year, but it also means the result may differ by a small amount from a lender that rounds interest on every statement. The UI formatter should be treated as presentation, not as a promise that a bank will accept the exact displayed payment.

The payment <= 0 check in the computed result is another useful boundary. A user can enter a positive principal and a negative rate, or otherwise create combinations that do not resemble a normal loan. Rather than force the loop to produce a confident-looking schedule, the view can return null when the payment cannot cover interest under the selected assumptions.

Extra payments are modeled as a monthly amount, which is a clear choice but not the only real-world choice. Some lenders apply a one-time payment immediately, some recast the payment, and some charge penalties. Those policies cannot be inferred from a generic form. The honest output is therefore “under this recurring-extra-payment model,” not “this is your official payoff date.”

If I needed lender-grade output, I would add a payment-date calendar, per-payment rounding rules, fees, and a configurable extra-payment event list. For a browser explainer, the current monthly array is a better foundation than pretending those details do not exist.

The yearly aggregation also handles a final partial year naturally. It loops until the monthly array is exhausted, so a loan paid off after 37 months produces four rows, with the last row containing only one month. That is more accurate than always multiplying a twelve-month average by four and is a nice example of why retaining the monthly schedule pays off.

Reading a schedule without confusing balance and interest

Each monthly entry carries three different ideas: the interest charged for that month, the principal removed in that month, and the balance left afterward. The yearly rows add the first two fields but carry forward the final balance. That distinction matters because cumulative interest is not the same thing as the current balance, and a chart with both lines can otherwise be surprisingly easy to misread.

The result also calculates payoff years with integer division and a remainder:

const payoffYears = Math.floor(withExtra.months / 12);
const payoffMonthsRem = withExtra.months % 12;
Enter fullscreen mode Exit fullscreen mode

That presentation preserves a schedule such as 30 years and 6 months instead of rounding it to 31 years. It is a small example of keeping the internal month count authoritative while choosing a friendlier display unit afterward.

The extra-payment comparison follows the same principle. It does not pretend the extra amount changes the original contractual rate or term. It runs the same amortization logic twice, then reports the difference in months and modeled interest. If the user changes the extra payment to zero, the comparison disappears rather than showing a fabricated saving.

For testing, I would check the invariants rather than only one expected monthly payment: the balance should never become negative, each monthly principal amount should be no greater than the previous balance, and the final balance should be at or below the small stopping threshold. Those checks correspond directly to the defensive branches in the source and make regressions easier to diagnose.

I turned this amortization loop into a small free tool: Loan Amortization Calculator.

Top comments (0)