Functional Weave
Code in TypeScript

lending.amortisation-schedule Unreviewed

Repayment schedule for a level-payment loan: interest, principal and balance per period, ending at exactly zero.

1.0.1 · published 2026-10-03 by charlie · Anterra

Pinned by 13 tests, run in TypeScript, Python and Rust.

Unreviewed. This capability’s implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

Not professional advice. This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed in its README, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.

What it does

The repayment table for a level-payment loan: for every period, the payment, the interest, the part that repays the loan, and the balance left. It is the table a mortgage offer or a loan statement shows, built the way the lender's ledger builds it.

## How each row is made

For example

  • amortisationSchedule(£1,000.00, 12%, 12, 12, half-up) → payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19 £1,000 at 12% over 12 months, half-up
  • amortisationSchedule(£2,000.00, 9.99%, 12, 12, up) → payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85 rounded up, the final payment is smaller
  • amortisationSchedule(£1,000.00, 12%, 12, 12, down) → payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19 rounded down, the final payment is larger

The function

The same function in TypeScript, Python and Rust, pinned by the same tests. Pick your language; the choice follows you around the registry.

export function amortisationSchedule(principal: Money, annualRateBasisPoints: number, termMonths: number, paymentsPerYear: number, mode: RoundingMode): AmortisationSchedule
principalMoneythe amount borrowed, greater than zero
annualRateBasisPointsintnominal annual rate, 0 to 100000
termMonthsintthe term; it must hold a whole number of payments
paymentsPerYearint1 to 52
modeRoundingModerounding of the level payment, as lending.loan-payment
returnsAmortisationSchedule

The types it declares, generated into your project

/** One period of the schedule. */
export interface AmortisationRow {
  /** 1 for the first payment */
  readonly period: number;
  /** the level payment, or the adjusted final one */
  readonly payment: Money;
  /** interest for the period on the opening balance */
  readonly interest: Money;
  /** the part of the payment that repays the loan */
  readonly principal: Money;
  /** the balance after this payment */
  readonly balance: Money;
}

/** The level payment, every period, and the totals. */
export interface AmortisationSchedule {
  readonly payment: Money;
  readonly rows: readonly AmortisationRow[];
  readonly totalInterest: Money;
  readonly totalPaid: Money;
}

Your code names it in one line, in the file that uses it

import { amortisationSchedule } from "#fune/lending.amortisation-schedule@^1";
impl/typescript.ts · 62 lines · open · raw

Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.

import { type RoundingMode } from "./math_round_div.ts";  ← from math.round-div ^1.0.0 · built alongside by fune
import { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { loanPayment, paymentCount, roundWide } from "./lending_loan_payment.ts";  ← from lending.loan-payment ^1.0.0 · built alongside by fune
import { type AmortisationRow, type AmortisationSchedule } from "./lending_amortisation_schedule_types.ts";

/**
 * Interest for one period on a positive balance: balance × b / D, rounded
 * half-up to the minor unit, which is what gets posted to the account.
 */
export function periodInterest(balance: number, annualRateBasisPoints: number, paymentsPerYear: number): number {
  if (balance <= 0 || annualRateBasisPoints === 0) return 0;
  const product = BigInt(balance) * BigInt(annualRateBasisPoints);
  // Rust holds the product in an i64; refuse it everywhere rather than in one language.
  if (product > 9223372036854775807n) throw new RangeError("balance too large for exact interest");
  return roundWide(product, 10000n * BigInt(paymentsPerYear), "half-up");
}

/**
 * The schedule a lender's system produces: each period's interest is
 * computed on the opening balance and rounded to a whole minor unit, the
 * level payment repays interest first and principal with the rest, and the
 * last payment is whatever clears the balance, so it ends at exactly zero.
 */
export function amortisationSchedule(
  principal: Money,
  annualRateBasisPoints: number,
  termMonths: number,
  paymentsPerYear: number,
  mode: RoundingMode,
): AmortisationSchedule {
  const payment = loanPayment(principal, annualRateBasisPoints, termMonths, paymentsPerYear, mode);
  const n = paymentCount(annualRateBasisPoints, termMonths, paymentsPerYear);
  const currency = principal.currency;
  const rows: AmortisationRow[] = [];
  let balance = principal.minor;
  let totalInterest = 0;
  let totalPaid = 0;
  for (let period = 1; period <= n && balance > 0; period += 1) {
    const interest = periodInterest(balance, annualRateBasisPoints, paymentsPerYear);
    // The last period, or one where the level payment would overshoot (a
    // rounded-up payment can clear the loan a period early), pays exactly
    // what is owed.
    const due = period === n || balance + interest <= payment.minor ? balance + interest : payment.minor;
    const repaid = due - interest;
    balance -= repaid;
    totalInterest += interest;
    totalPaid += due;
    rows.push({
      period,
      payment: money(due, currency),
      interest: money(interest, currency),
      principal: money(repaid, currency),
      balance: money(balance, currency),
    });
  }
  return {
    payment,
    rows,
    totalInterest: money(totalInterest, currency),
    totalPaid: money(totalPaid, currency),
  };
}

Install

fune build

With that line in your source, in a TypeScript project (language typescript in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the TypeScript package of each, and builds the code above into your project’s .fune/build, one readable file per capability with a header linking back here. Or pin a range in fune.project and build in one step:

fune add lending.amortisation-schedule
Download for TypeScript lending.amortisation-schedule-1.0.1-typescript.fune · 59,177 bytes sha256 9d89738c0ecf7cbcf3a2f429dc2ac9d46538f49c76de3eb207de7b396780498c

The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./lending.amortisation-schedule-1.0.1-typescript.fune, or fetch it from a terminal with fune pull lending.amortisation-schedule@1.0.1:typescript.

The whole function, every language, is one file too: lending.amortisation-schedule-1.0.1.fune, 66,045 bytes, sha256 d20048fdaec868479307700ac9f444968a01d572c7be5809edaf48096ef9f96c. It installs into a project of any language.

Customise it in your app

The seams this capability offers. Put a marker directly above a function of your own and fune build wires it into the built code; the package on the registry is not changed, the built file’s header lists it under CUSTOMISED, and fune hooks lists every hook in the project. How hooks work.

before — your function gets the arguments and returns them, changed or not, or throws to refuse the call.

// fune: before lending.amortisation-schedule

after — your function gets the result and the arguments, and returns the final result.

// fune: after lending.amortisation-schedule

replace — inside this capability’s code only, calls to a dependency go to your function, with the same signature. Other capabilities that use it are unaffected; write in * to replace it everywhere.

// fune: replace lending.loan-payment in lending.amortisation-schedule
// fune: replace math.round-div in lending.amortisation-schedule
// fune: replace money.amount in lending.amortisation-schedule

step — your function runs at a numbered point inside the function’s body, receives the in-scope values it names as parameters, and may return replacements. List the points with fune show lending.amortisation-schedule --steps.

// fune: step lending.amortisation-schedule after <n|label>

Tests

A version published now needs at least 8 tests for every function, and one that expects the error for each function that throws; the registry refuses it otherwise. fune verify --all runs each case in TypeScript, Python and Rust, and a project runs them again with fune verify. This page lists the cases; it does not run them. The exact JSON is vectors.json.

CaseArgumentsExpected
£1,000 at 12% over 12 months, half-up £1,000.00, 12%, 12, 12, half-up → payment £88.85, rows ×12, total interest £66.19, total paid £1,066.19
rounded up, the final payment is smaller £2,000.00, 9.99%, 12, 12, up → payment £175.83, rows ×12, total interest £109.85, total paid £2,109.85
rounded down, the final payment is larger £1,000.00, 12%, 12, 12, down → payment £88.84, rows ×12, total interest £66.19, total paid £1,066.19
£5,000 at 6% over 6 months £5,000.00, 6%, 6, 12, half-up → payment £847.98, rows ×6, total interest £87.87, total paid £5,087.87
interest-free: the pennies left over go on the last payment £1,000.00, 0%, 12, 12, down → payment £83.33, rows ×12, total interest £0.00, total paid £1,000.00
rounded up, a tiny interest-free loan clears three payments early £0.25, 0%, 12, 12, up → payment £0.03, rows ×9, total interest £0.00, total paid £0.25
a single annual payment £1,000.00, 10%, 12, 1, half-up → payment £1,100.00, rows ×1, total interest £100.00, total paid £1,100.00
quarterly over two years at 8% £10,000.00, 8%, 24, 4, half-up → payment £1,365.10, rows ×8, total interest £920.80, total paid £10,920.80
a small loan where rounding dominates £10.00, 19.99%, 12, 12, half-up → payment £0.93, rows ×12, total interest £1.10, total paid £11.10
dollars, monthly for three years at 7.5% $15,000.00, 7.5%, 36, 12, half-up → payment $466.59, rows ×36, total interest $1,797.36, total paid $16,797.36
Show the other 3 tests
CaseArgumentsExpected
a zero principal is refused £0.00, 5%, 12, 12, half-up → error: principal must be greater than zero
a term that is not a whole number of payments £1,000.00, 5%, 7, 4, half-up → error: is not a whole number of payments
a negative rate is refused £1,000.00, -0.5%, 12, 12, half-up → error: annualRateBasisPoints must be between 0 and 100000

More from the author

1. The level payment comes from lending.loan-payment, rounded with the mode you pass. 2. Each period's interest is the opening balance × rate / paymentsPerYear, rounded half-up to a whole minor unit: that is the amount actually posted. 3. The payment pays that interest first; the rest reduces the balance. 4. The final payment is not the level payment. It is whatever is owed: the remaining balance plus that period's interest. So the balance ends at exactly zero, never at -3p or +2p.

The final payment is where every penny of rounding collects. With the payment rounded half-up it is within a few pence of the others; rounded `up` it is a little smaller, rounded `down` a little larger. If a rounded-up payment would clear the loan before the term ends (possible only for tiny loans, such as 25p over 12 months), the schedule stops at the payment that clears it, so it can have fewer rows than the term has payments. It never has a row with a payment of zero.

The totals are the sums of the posted rows: totalPaid = principal + totalInterest, exactly.

## What it does not do

The periods are equal and the rate is nominal and fixed, as in lending.loan-payment. It does not model daily interest, payment holidays, rate changes, fees or overpayments (lending.overpayment-effect covers a one-off overpayment).

## Limits

The same arguments as lending.loan-payment, so at most 3000 rows. A balance whose interest product (balance × basis points) would exceed 2^63 - 1 is an error ("balance too large for exact interest") in every language.

The module also exports `periodInterest(balance, annualRateBasisPoints, paymentsPerYear)`, the rounding rule of step 2.

## Before you rely on this

**Not professional advice.** This capability calculates lending figures from published rules. It is a software component for developers, not financial advice. Rules change and every rate here has an effective date. Check that the dates cover your case. Verify results against the official sources listed above, and have a consumer-credit compliance specialist review how you use it, before anyone relies on the output. Provided "as is" under its licence, without warranty.

**Unreviewed.** This capability's implementations agree in every language and pass its published test vectors, which were worked out from the official sources cited. But no qualified consumer-credit compliance specialist has yet checked those vectors, or confirmed that the capability covers the cases it claims. Treat it as a draft. Do not use it for real people, money or decisions without your own expert review. Once a qualified reviewer signs off, this notice is replaced with their name, qualification and the date. Each new version needs fresh sign-off.

1.0.1 marks it unreviewed. The code and the tests are unchanged.

Files

PathBytes
README.md3,116
impl/python.py2,823
impl/rust.rs3,817
impl/typescript.ts2,672
vectors.json42,733