DEV Community

DaikuanTool
DaikuanTool

Posted on

Annuity vs Equal Principal: Building Both Amortization Schedules in TypeScript

Two loans can have the same principal, rate, and term while producing very different monthly bills.

The difference is often the amortization method:

  • Annuity (also called a fixed-payment or level-payment schedule) keeps the total scheduled payment constant when the rate is fixed.
  • Equal principal pays the same amount of principal every month, so the total payment starts higher and falls over time.

Neither method is a mysterious financial product. Both follow the same monthly loop: calculate interest from the opening balance, decide how much principal is paid, reduce the balance, and record the row. The implementation choice is about the rule that determines the principal portion.

In this post, we will build both schedules in TypeScript, compare their output, and add tests that protect the edge cases most likely to break an amortization table.

The examples assume a fixed annual rate, monthly payments, no fees, no prepayments, and a first payment one month after disbursement. They are a software model, not a quote or lending advice.

Start with a schedule, not a single payment

It is tempting to write a function that returns only a monthly payment. That is enough for a simple calculator card, but it is not enough for a useful loan feature.

An amortization schedule lets a user—or another part of the application—inspect:

  • the opening balance used to calculate each month's interest;
  • the split between interest and principal;
  • the payment for each period;
  • the remaining balance after the payment; and
  • totals that can be checked against a contract or another implementation.

Let's model one row and the inputs first:

type RepaymentMethod = "annuity" | "equal-principal";

type LoanInput = {
  principal: number;
  annualRate: number; // 0.035 means 3.5%
  months: number;
  method: RepaymentMethod;
};

type ScheduleRow = {
  period: number;
  payment: number;
  principalPaid: number;
  interest: number;
  balance: number;
};

type LoanSchedule = {
  rows: ScheduleRow[];
  totalInterest: number;
  totalPaid: number;
};
Enter fullscreen mode Exit fullscreen mode

The important convention is that balance is the balance after the row's principal payment. Interest for the row is calculated from the opening balance, which is the previous row's closing balance.

The two formulas

Let P be principal, r be the monthly rate, and n be the number of monthly payments.

For an annuity schedule, the fixed payment is:

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

Each month:

interest      = openingBalance × r
principalPaid = fixedPayment - interest
Enter fullscreen mode Exit fullscreen mode

The payment stays fixed, but the mix changes. Early payments contain more interest because the opening balance is larger. Later payments contain more principal.

For an equal-principal schedule, the principal portion is fixed instead:

principalPaid = P / n
interest      = openingBalance × r
payment       = principalPaid + interest
Enter fullscreen mode Exit fullscreen mode

As the balance falls by the same principal amount every month, interest also falls every month. The first payment is therefore the highest.

Implement both schedules in one loop

The following function keeps the shared accounting in one place and changes only the repayment rule. It preserves precision while calculating and rounds only values stored in the display schedule.

const money = (value: number): number =>
  Math.round((value + Number.EPSILON) * 100) / 100;

function fixedAnnuityPayment(
  principal: number,
  monthlyRate: number,
  months: number,
): number {
  if (monthlyRate === 0) return principal / months;

  const factor = Math.pow(1 + monthlyRate, months);
  return (principal * monthlyRate * factor) / (factor - 1);
}

export function buildSchedule(input: LoanInput): LoanSchedule {
  const principal = Math.max(0, input.principal);
  const months = Math.max(1, Math.round(input.months));
  const monthlyRate = Math.max(0, input.annualRate) / 12;

  const fixedPayment =
    input.method === "annuity"
      ? fixedAnnuityPayment(principal, monthlyRate, months)
      : 0;

  let balance = principal;
  const rows: ScheduleRow[] = [];

  for (let period = 1; period <= months; period += 1) {
    const interest = balance * monthlyRate;
    let principalPaid =
      input.method === "annuity"
        ? fixedPayment - interest
        : principal / months;

    // Absorb floating-point residue in the last scheduled payment.
    if (period === months || principalPaid > balance) {
      principalPaid = balance;
    }

    const payment = principalPaid + interest;
    balance = Math.max(0, balance - principalPaid);

    rows.push({
      period,
      payment: money(payment),
      principalPaid: money(principalPaid),
      interest: money(interest),
      balance: money(balance),
    });
  }

  return {
    rows,
    totalInterest: money(rows.reduce((sum, row) => sum + row.interest, 0)),
    totalPaid: money(rows.reduce((sum, row) => sum + row.payment, 0)),
  };
}
Enter fullscreen mode Exit fullscreen mode

There are two small details worth calling out.

First, monthlyRate === 0 is not an unusual error path—it is the correct formula for a zero-interest installment plan. Dividing by (1 + r)^n - 1 when r is zero yields 0 / 0, so handle that case explicitly.

Second, do not round balance before calculating the next period's interest. Display-level rounding is normal, but feeding rounded values back into the calculation can produce a non-zero final balance or a drift in total interest. The final-period guard absorbs the remaining floating-point residue instead.

Compare the schedules with the same input

Now give both methods identical loan terms: 1,000,000 currency units, a fixed 3.5% annual rate, and 360 monthly payments.

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

const annuity = buildSchedule({ ...baseLoan, method: "annuity" });
const equalPrincipal = buildSchedule({
  ...baseLoan,
  method: "equal-principal",
});

console.table({
  annuityFirstPayment: annuity.rows[0].payment,
  annuityTotalInterest: annuity.totalInterest,
  equalPrincipalFirstPayment: equalPrincipal.rows[0].payment,
  equalPrincipalLastPayment: equalPrincipal.rows.at(-1)!.payment,
  equalPrincipalTotalInterest: equalPrincipal.totalInterest,
});
Enter fullscreen mode Exit fullscreen mode

The output is approximately:

Metric Annuity Equal principal
First payment 4,490.45 5,694.44
Last payment 4,490.45 2,785.88
Total interest 616,560.90 526,458.33

The equal-principal schedule pays about 90,102.57 less interest in this example. It is not a discount: more principal is paid earlier, so there is less balance left to accrue interest in later months.

The balance paths make that visible. After 120 payments, the annuity schedule still has a balance of about 774,268.75. The equal-principal schedule has exactly 666,666.67 left before display rounding. A comparison component that shows only total interest misses this cash-flow difference.

Write tests for invariants and known values

Financial schedule tests should do more than snapshot a table. Test a few known outputs, then assert the properties that must hold for every valid schedule.

The following tests use Node's built-in test runner, but the same ideas work with Vitest or Jest:

import assert from "node:assert/strict";
import test from "node:test";

test("annuity keeps the scheduled payment level", () => {
  const schedule = buildSchedule({
    principal: 1_000_000,
    annualRate: 0.035,
    months: 360,
    method: "annuity",
  });

  assert.equal(schedule.rows[0].payment, 4490.45);
  assert.equal(schedule.rows.at(-1)?.balance, 0);
  assert.ok(schedule.rows.every(row => row.balance >= 0));
});

test("equal principal has a declining payment and a fixed principal portion", () => {
  const schedule = buildSchedule({
    principal: 1_000_000,
    annualRate: 0.035,
    months: 360,
    method: "equal-principal",
  });

  assert.equal(schedule.rows[0].payment, 5694.44);
  assert.equal(schedule.rows.at(-1)?.payment, 2785.88);
  assert.equal(schedule.totalInterest, 526458.33);

  for (const row of schedule.rows.slice(0, -1)) {
    assert.equal(row.principalPaid, 2777.78);
  }
});

test("zero interest divides principal evenly for either method", () => {
  for (const method of ["annuity", "equal-principal"] as const) {
    const schedule = buildSchedule({
      principal: 120_000,
      annualRate: 0,
      months: 12,
      method,
    });

    assert.equal(schedule.rows[0].payment, 10_000);
    assert.equal(schedule.totalInterest, 0);
    assert.equal(schedule.rows.at(-1)?.balance, 0);
  }
});
Enter fullscreen mode Exit fullscreen mode

One caution about the equal-principal test: its stored rows are rounded to cents, so a displayed monthly principal value of 2,777.78 cannot sum exactly to 1,000,000 across 360 rows. That is why the code keeps the unrounded balance during calculation and corrects the final period. If your contract specifies a particular rounding allocation, encode that rule explicitly and test the exact final installment.

Where a production calculator needs more inputs

This implementation is deliberately narrow. A production system may need to model:

  • fees paid upfront or alongside each payment;
  • a first period with a non-standard number of days;
  • rate resets and changing payment dates;
  • prepayments that shorten the term or lower future payments; and
  • a balloon payment at maturity.

Each is easier to add when the schedule is a first-class result rather than an afterthought. For example, a monthly fee can become another field in ScheduleRow; a prepayment can reduce balance before interest is calculated for the next period. The core invariant remains the same: opening balance minus principal paid must equal closing balance.

Make the trade-off inspectable

When users compare repayment methods, show the first payment, the final payment, total interest, and at least a few remaining-balance checkpoints. Those values explain the trade-off more honestly than a label such as “cheaper” or “easier.”

To compare the same two fixed-rate monthly schedules interactively, use this repayment method comparison calculator. Enter the principal, rate, and term, then inspect both the early-payment pressure and total-interest difference.

Annuity and equal principal are ultimately two different rules for allocating principal over time. Once the code expresses that rule clearly and tests the balance invariants, the resulting schedules are much easier to extend, audit, and trust.

Top comments (0)