lending.loan-payment Unreviewed
Level repayment for a loan (annuity formula), computed exactly and rounded once, the way you choose.
1.0.1 · published 2026-10-03 by charlie · Anterra
Pinned by 23 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 level (annuity) payment that repays a loan in equal instalments: what a mortgage, car loan or personal loan quote shows as "monthly payment".
## The formula
For example
loanPayment(£100,000.00, 5%, 300, 12, half-up)→ £584.59 £100,000 mortgage at 5% over 25 years is £584.59 a monthloanPayment(£100,000.00, 5%, 300, 12, up)→ £584.60 the same mortgage rounded up so it never under-repaysloanPayment(£1,000.00, 12%, 12, 12, half-up)→ £88.85 £1,000 at 12% over a year
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 loanPayment(principal: Money, annualRateBasisPoints: number, termMonths: number, paymentsPerYear: number, mode: RoundingMode): Money
| principal | Money | the amount borrowed, greater than zero |
| annualRateBasisPoints | int | nominal annual rate, 0 to 100000; 525 = 5.25% |
| termMonths | int | the term; it must hold a whole number of payments |
| paymentsPerYear | int | 1 to 52: 12 monthly, 4 quarterly, 26 fortnightly, 52 weekly |
| mode | RoundingMode | how the exact payment becomes whole minor units; "up" never under-repays |
| returns | Money | the payment per period, in the principal's currency |
Your code names it in one line, in the file that uses it
import { loanPayment } from "#fune/lending.loan-payment@^1";
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, roundDiv } 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
/** A hundred years of monthly payments, sixty of weekly ones. */
export const MAX_PAYMENTS = 3000;
/**
* The number of payments a term holds, after checking the arguments every
* lending capability shares.
*/
export function paymentCount(annualRateBasisPoints: number, termMonths: number, paymentsPerYear: number): number {
if (!Number.isInteger(annualRateBasisPoints) || annualRateBasisPoints < 0 || annualRateBasisPoints > 100000) {
throw new RangeError(`annualRateBasisPoints must be between 0 and 100000, received ${annualRateBasisPoints}`);
}
if (!Number.isInteger(paymentsPerYear) || paymentsPerYear < 1 || paymentsPerYear > 52) {
throw new RangeError(`paymentsPerYear must be between 1 and 52, received ${paymentsPerYear}`);
}
if (!Number.isInteger(termMonths) || termMonths < 1) {
throw new RangeError(`termMonths must be at least 1, received ${termMonths}`);
}
if ((termMonths * paymentsPerYear) % 12 !== 0) {
throw new RangeError(`a term of ${termMonths} months is not a whole number of payments at ${paymentsPerYear} a year`);
}
const n = (termMonths * paymentsPerYear) / 12;
if (n > MAX_PAYMENTS) {
throw new RangeError(`${n} payments is more than the ${MAX_PAYMENTS} payment limit`);
}
return n;
}
/**
* Round the exact quotient numerator / denominator (both positive) with a
* math.round-div mode, however large they are. Only the integer part and
* where the remainder sits against one half matter, so the decision is handed
* to roundDiv as a small fraction with the same integer part and the same
* side of the half: q, q + 1/4, q + 1/2 or q + 3/4.
*/
export function roundWide(numerator: bigint, denominator: bigint, mode: RoundingMode): number {
const q = numerator / denominator;
const twice = (numerator % denominator) * 2n;
const small = Number(q);
if (twice === 0n) return roundDiv(small, 1, mode);
if (twice === denominator) return roundDiv(2 * small + 1, 2, mode);
return roundDiv(4 * small + (twice < denominator ? 1 : 3), 4, mode);
}
/**
* The level payment that repays `principal` over the term at a nominal
* annual rate divided equally between the periods:
*
* payment = P · r / (1 − (1 + r)^−n), r = rate / paymentsPerYear
*
* With r = b / D (b basis points, D = 10000 × paymentsPerYear) this is the
* exact fraction P · b · (D + b)^n / (D · ((D + b)^n − D^n)), evaluated in
* integers of whatever size it takes and rounded once. A zero rate is P / n.
*/
export function loanPayment(
principal: Money,
annualRateBasisPoints: number,
termMonths: number,
paymentsPerYear: number,
mode: RoundingMode,
): Money {
if (!Number.isInteger(principal.minor) || principal.minor <= 0) {
throw new RangeError(`principal must be greater than zero, received ${principal.minor}`);
}
const n = paymentCount(annualRateBasisPoints, termMonths, paymentsPerYear);
if (annualRateBasisPoints === 0) {
return money(roundDiv(principal.minor, n, mode), principal.currency);
}
const b = BigInt(annualRateBasisPoints);
const d = 10000n * BigInt(paymentsPerYear);
const grown = (d + b) ** BigInt(n);
const numerator = BigInt(principal.minor) * b * grown;
const denominator = d * (grown - d ** BigInt(n));
return money(roundWide(numerator, denominator, mode), principal.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.loan-payment
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./lending.loan-payment-1.0.1-typescript.fune, or fetch it from a terminal with fune pull lending.loan-payment@1.0.1:typescript.
The whole function, every language, is one file too: lending.loan-payment-1.0.1.fune, 22,812 bytes, sha256 ae3adb6e388662e058f53225156c2198c46abb159e47c6967cb931c48a9670a1. 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.loan-payment
after — your function gets the result and the arguments, and returns the final result.
// fune: after lending.loan-payment
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 math.big-integer in lending.loan-payment
// fune: replace math.round-div in lending.loan-payment
// fune: replace money.amount in lending.loan-payment
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.loan-payment --steps.
// fune: step lending.loan-payment 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.
| Case | Arguments | Expected | |
|---|---|---|---|
| £100,000 mortgage at 5% over 25 years is £584.59 a month | £100,000.00, 5%, 300, 12, half-up | → | £584.59 |
| the same mortgage rounded up so it never under-repays | £100,000.00, 5%, 300, 12, up | → | £584.60 |
| £1,000 at 12% over a year | £1,000.00, 12%, 12, 12, half-up | → | £88.85 |
| rounding down the same loan | £1,000.00, 12%, 12, 12, down | → | £88.84 |
| zero rate is the principal divided equally | £1,000.00, 0%, 12, 12, half-up | → | £83.33 |
| zero rate rounded up | £1,000.00, 0%, 12, 12, up | → | £83.34 |
| £150.00 over a year at 0% divides exactly | £150.00, 0%, 12, 12, half-up | → | £12.50 |
| an exact half penny (£1.50 over 12 months is 12.5p) rounds away from zero under half-up | £1.50, 0%, 12, 12, half-up | → | £0.13 |
| half-even sends the exact half to the even penny | £1.50, 0%, 12, 12, half-even | → | £0.12 |
| £25,000 car loan at 3.99% over five years | £25,000.00, 3.99%, 60, 12, half-up | → | £460.30 |
Show the other 13 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| weekly payments over two years at 6.5% | £10,000.00, 6.5%, 24, 52, half-up | → | £102.60 |
| quarterly payments over three years at 8% | £5,000.00, 8%, 36, 4, half-up | → | £472.80 |
| a single annual payment is principal plus a year's interest | £1,000.00, 10%, 12, 1, half-up | → | £1,100.00 |
| fortnightly over three years | £3,000.00, 7%, 36, 26, half-up | → | £42.69 |
| forty years at 4.25% in euros | €350,000.00, 4.25%, 480, 12, half-up | → | €1,517.67 |
| a very high rate: 1000% over 12 months | £500.00, 1000%, 12, 12, half-up | → | £416.96 |
| a zero principal is refused | £0.00, 5%, 12, 12, half-up | → | error: principal must be greater than zero |
| a negative rate is refused | £1,000.00, -0.01%, 12, 12, half-up | → | error: annualRateBasisPoints must be between 0 and 100000 |
| payments per year above 52 are refused | £1,000.00, 5%, 12, 365, half-up | → | error: paymentsPerYear must be between 1 and 52 |
| a zero term is refused | £1,000.00, 5%, 0, 12, half-up | → | error: termMonths must be at least 1 |
| a term that is not a whole number of payments | £1,000.00, 5%, 13, 4, half-up | → | error: is not a whole number of payments |
| more payments than the limit | £1,000.00, 5%, 1,200, 52, half-up | → | error: payment limit |
| an unknown rounding mode | £1,000.00, 5%, 12, 12, nearest | → | error: unknown rounding mode |
More from the author
With a nominal annual rate split equally between the periods, r = rate / paymentsPerYear, and n payments:
payment = P × r / (1 − (1 + r)^−n)
That is the textbook present-value-of-an-annuity formula, the same one as a spreadsheet's PMT. Here it is never evaluated in floating point. The rate is b basis points, so r = b / D with D = 10000 × paymentsPerYear, and the formula is exactly the fraction
P × b × (D + b)^n / ( D × ((D + b)^n − D^n) )
Both sides are integers. For a 25-year monthly mortgage (D + b)^n is a number of over 1,500 digits, far past what `math.rational` holds (its parts must stay within 2^53 so JavaScript numbers stay exact), so the fraction is built with arbitrary-size integers: `bigint` in TypeScript, `int` in Python and `math.big-integer` in Rust. It is then rounded exactly once, with the `math.round-div` mode you pass. A zero rate is P / n, rounded the same way.
## Rounding
- `half-up` gives the nearest penny, what most quotes show: £100,000 at 5% over 25 years is £584.59. - `up` never under-repays: every payment is at most a penny high, and the final payment (see lending.amortisation-schedule) comes out slightly smaller. - `down` or `half-even` are available where a contract says so.
The mode applies to the exact value, so there is no double rounding: a payment of exactly x.5 pence rounds by the mode's tie rule, anything else to the nearer penny.
## What it does not do
- The rate is nominal and divided equally (12% a year is 1% a month). It is not an APR or AER; lending.apr and banking.savings-aer convert. - Every period is treated as equal. Interest charged daily, or a first period of odd length, changes the payment slightly; build the schedule with lending.daily-interest when that matters. - No fees are added, and the payment is not a regulated APR disclosure.
## Limits
The rate is 0 to 100000 basis points, payments per year 1 to 52, and the term must hold a whole number of payments (13 months cannot be paid quarterly), at most 3000 of them. The principal must be positive and, for TypeScript to stay exact, below 2^51 minor units.
The module also exports `paymentCount` (the argument checks and n) and `roundWide` (round a large positive fraction with a round-div mode), which the other lending capabilities reuse.
## 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
| Path | Bytes |
|---|---|
| README.md | 3,634 |
| impl/python.py | 3,427 |
| impl/rust.rs | 3,920 |
| impl/typescript.ts | 3,440 |
| vectors.json | 5,096 |