DEV Community

DaikuanTool
DaikuanTool

Posted on

How to Calculate a Remaining Loan Balance Without Storing 360 Payments

A 30-year monthly loan has 360 payments. Generating every row is useful when a UI needs an amortization table, but it is unnecessary when the product only needs one answer:

What is the remaining balance after payment 60?

For a fixed-rate, fixed-payment loan, the answer has a closed form. You can calculate it in constant time and constant extra space—no 360-element array, no loop through every previous payment.

This post derives that formula, implements it in TypeScript, and tests it against an iterative reference implementation. The goal is not to replace a full schedule everywhere; it is to choose the smallest correct computation for the feature you are building.

The model assumes a fixed annual rate, monthly payments, an annuity (level-payment) repayment method, no fees, no prepayments, and a first payment one month after disbursement. It is a programming example, not a loan quote or financial advice.

Define the inputs precisely

We need four values:

type FixedRateLoan = {
  principal: number;  // P: amount borrowed
  annualRate: number; // for example, 0.035 for 3.5%
  months: number;     // total scheduled monthly payments
};
Enter fullscreen mode Exit fullscreen mode

Let:

  • P be the original principal;
  • r be the monthly rate, annualRate / 12;
  • n be the total number of payments;
  • A be the fixed monthly payment; and
  • k be the number of payments already made.

The balance we want is the balance immediately after payment k. That timing matters. A balance before the 60th payment includes one more month's interest than a balance after it.

First calculate the fixed payment

For a positive monthly rate, the annuity payment is:

A = P × r × (1 + r)^n / ((1 + r)^n - 1)
Enter fullscreen mode Exit fullscreen mode

The zero-rate case must be handled separately. If r is zero, the payment is simply P / n; substituting zero into the expression above creates 0 / 0.

export function annuityPayment(loan: FixedRateLoan): number {
  const { principal, annualRate, months } = loan;

  if (principal < 0 || annualRate < 0 || months < 1) {
    throw new Error("principal and annualRate must be non-negative; months must be positive");
  }

  const r = annualRate / 12;
  if (r === 0) return principal / months;

  const factor = Math.pow(1 + r, months);
  return (principal * r * factor) / (factor - 1);
}
Enter fullscreen mode Exit fullscreen mode

This payment formula is not yet the remaining balance. It only tells us the fixed amount paid each month. The balance also depends on how interest accumulates and how each payment reduces it.

Derive the balance formula

At the start, the balance is P. After one period of interest and one payment:

B₁ = P × (1 + r) - A
Enter fullscreen mode Exit fullscreen mode

After two periods:

B₂ = (P × (1 + r) - A) × (1 + r) - A
Enter fullscreen mode Exit fullscreen mode

Expanding that recurrence produces a geometric series. After k payments:

Bₖ = P × (1 + r)^k - A × (((1 + r)^k - 1) / r)
Enter fullscreen mode Exit fullscreen mode

The first term grows the original balance through k interest periods. The second term is the future-value-at-month-k effect of the k payments already made.

An equivalent, and sometimes more intuitive, expression discounts the remaining payments back to month k:

Bₖ = A × (1 - (1 + r)^-(n - k)) / r
Enter fullscreen mode Exit fullscreen mode

Both are valid for r > 0. In code, the first form makes the relationship to the recurrence explicit; the second is a useful independent check in a test.

Implement the O(1) balance calculation

The function below keeps the inputs immutable, clamps the paid-payment count to the loan term, and handles the zero-rate case.

const clamp = (value: number, min: number, max: number): number =>
  Math.min(max, Math.max(min, value));

export function remainingAnnuityBalance(
  loan: FixedRateLoan,
  paidPayments: number,
): number {
  const { principal, annualRate, months } = loan;

  if (principal < 0 || annualRate < 0 || months < 1) {
    throw new Error("principal and annualRate must be non-negative; months must be positive");
  }

  // This function models completed whole monthly payments.
  const k = clamp(Math.trunc(paidPayments), 0, months);
  if (k === 0) return principal;
  if (k === months) return 0;

  const r = annualRate / 12;
  if (r === 0) return principal * (1 - k / months);

  const payment = annuityPayment(loan);
  const growth = Math.pow(1 + r, k);
  const balance = principal * growth - payment * ((growth - 1) / r);

  // A tiny negative number can occur because JavaScript uses binary floats.
  return Math.max(0, balance);
}
Enter fullscreen mode Exit fullscreen mode

This is O(1) time and O(1) extra memory. Math.pow is not a loop over the loan periods from your application's perspective; the function does not allocate or retain 360 schedule rows.

Avoid rounding payment, growth, or balance to cents inside this function. Round only at the boundary where a number is displayed. Rounding the balance at every simulated period is exactly the sort of detail that makes two implementations disagree by a few cents.

Check it against a 30-year example

Take a principal of 1,000,000 currency units, a 3.5% annual rate, and 360 monthly payments:

const loan = {
  principal: 1_000_000,
  annualRate: 0.035,
  months: 360,
};

console.log(annuityPayment(loan).toFixed(2));
// 4490.45

console.log(remainingAnnuityBalance(loan, 60).toFixed(2));
// 896970.73

console.log(remainingAnnuityBalance(loan, 120).toFixed(2));
// 774268.75

console.log(remainingAnnuityBalance(loan, 240).toFixed(2));
// 454104.01
Enter fullscreen mode Exit fullscreen mode

It can be surprising that 60 payments of 4,490.45 do not reduce a 1,000,000 principal by 269,427. The difference is interest. In an annuity schedule, early payments contain a larger interest portion because the opening balance is still high. The formula is a concise way to expose that fact; it is not a shortcut that changes the underlying economics.

Keep a loop as a reference implementation

The closed form is ideal when you need one balance, but an iterative version is still valuable in tests. It acts as an executable definition of the recurrence:

export function remainingBalanceByIteration(
  loan: FixedRateLoan,
  paidPayments: number,
): number {
  const k = clamp(Math.trunc(paidPayments), 0, loan.months);
  const r = loan.annualRate / 12;
  const payment = annuityPayment(loan);
  let balance = loan.principal;

  for (let period = 0; period < k; period += 1) {
    const interest = balance * r;
    const principalPaid = payment - interest;
    balance = Math.max(0, balance - principalPaid);
  }

  return balance;
}
Enter fullscreen mode Exit fullscreen mode

This reference implementation is O(k) time but still uses O(1) extra memory because it holds only the current balance. Building an array of rows is a separate choice. It is appropriate for a table or CSV export, but not required just to find a balance.

Test formula, boundaries, and invariants

Formula code benefits from tests that compare two implementations rather than tests that only repeat the formula. Here is a Vitest example:

import { describe, expect, it } from "vitest";

const loan = {
  principal: 1_000_000,
  annualRate: 0.035,
  months: 360,
};

describe("remainingAnnuityBalance", () => {
  it("matches an iterative recurrence at several checkpoints", () => {
    for (const paid of [0, 1, 60, 120, 240, 359, 360]) {
      expect(remainingAnnuityBalance(loan, paid)).toBeCloseTo(
        remainingBalanceByIteration(loan, paid),
        7,
      );
    }
  });

  it("returns known balances for the 3.5%, 30-year example", () => {
    expect(annuityPayment(loan)).toBeCloseTo(4490.45, 2);
    expect(remainingAnnuityBalance(loan, 60)).toBeCloseTo(896970.73, 2);
    expect(remainingAnnuityBalance(loan, 120)).toBeCloseTo(774268.75, 2);
  });

  it("handles a zero-rate installment loan", () => {
    const zeroRateLoan = { principal: 120_000, annualRate: 0, months: 12 };

    expect(remainingAnnuityBalance(zeroRateLoan, 0)).toBe(120_000);
    expect(remainingAnnuityBalance(zeroRateLoan, 5)).toBe(70_000);
    expect(remainingAnnuityBalance(zeroRateLoan, 12)).toBe(0);
  });

  it("does not return a negative balance outside the term", () => {
    expect(remainingAnnuityBalance(loan, -3)).toBe(loan.principal);
    expect(remainingAnnuityBalance(loan, 999)).toBe(0);
  });
});
Enter fullscreen mode Exit fullscreen mode

The test has two complementary parts. Known-value assertions catch an accidental change in the business definition. Comparing with the recurrence catches algebra or indexing mistakes, such as treating k as payments remaining instead of payments completed.

Know when not to use the shortcut

The closed form is correct only while its assumptions remain true. Do not use it unchanged when the loan has:

  • a variable rate or a rate reset;
  • an irregular first period or non-monthly payment dates;
  • fees added to each payment;
  • a partial prepayment that changes future payment or term;
  • payment holidays, arrears, or a balloon payment; or
  • equal-principal rather than fixed-payment amortization.

For those cases, model the actual cash-flow events in chronological order. The good news is that you still do not need to store every intermediate row unless the product needs to display them. A reducer over events can keep just the current balance while applying each event.

Use the right computation for the screen

A full amortization schedule is the right output for “show me every payment.” A closed form is the right tool for “show me the balance after 60 payments,” a dashboard checkpoint, or a what-if calculation that would otherwise create disposable arrays on every keystroke.

To verify the same fixed-rate, monthly annuity inputs interactively, try this remaining mortgage balance calculator. Compare the result with a lender's schedule, keeping in mind that actual first-period day counts, rounding rules, rate changes, and prepayments can produce a different contractual balance.

The main implementation lesson is simple: retain a full schedule when a feature needs the rows; use the formula when it only needs one state. Both should agree under the same assumptions.

Top comments (0)