property.mortgage-affordability
Mortgage affordability: the loan-to-income multiple and a stress-tested repayment against income, both checked.
1.0.0 (not the latest) · published 2026-10-03 by charlie · Anterra
Pinned by 13 tests, run in TypeScript, Python and Rust.
Not professional advice. This capability calculates property figures from published rules. It is a software component for developers, not legal or 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 conveyancer or tax adviser review how you use it, before anyone relies on the output. Provided “as is” under its licence, without warranty.
What it does
The two checks a UK mortgage lender runs on how much a buyer can borrow:
1. **Income multiple (loan-to-income).** The loan divided by gross annual income, against the lender's ceiling, typically 4 to 4.5 times income (`maxIncomeMultipleBasisPoints` 45000 = 4.5×). Returned as a multiple in basis points, rounded up, and as the largest loan the multiple allows, rounded down. 2. **Stress-tested repayment.** The monthly repayment at the actual rate and at a higher stress rate, plus existing commitments, as a share of monthly income, against the lender's ceiling. This is `lending.affordability` unchanged: its full result comes back as `repayment`.
For example
mortgageAffordability(£54,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40%)→ income multiple basis points 370.38%, max loan by multiple £243,000.00, within multiple true, monthly income £4,500.00, repayment …, affordable true £200k on £54,000 a year: 3.70× income and 39.52% stressed, passes bothmortgageAffordability(£40,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40%)→ income multiple basis points 500%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false £200k on £40,000 a year: 5× income and 53.34% stressed, fails bothmortgageAffordability(£40,000.00, £0.00, £180,000.00, 4.25%, 7.25%, 300, 450%, 40%)→ income multiple basis points 450%, max loan by multiple £180,000.00, within multiple true, monthly income £3,333.33, repayment …, affordable true exactly 4.5× income is within a 4.5× multiple
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 mortgageAffordability(annualIncome: Money, monthlyCommitments: Money, loan: Money, annualRateBasisPoints: number, stressRateBasisPoints: number, termMonths: number, maxIncomeMultipleBasisPoints: number, maxDebtToIncomeBasisPoints: number): MortgageAffordability
| annualIncome | Money | gross annual income the lender counts (after any haircut), greater than zero |
| monthlyCommitments | Money | existing monthly debt payments, zero or more |
| loan | Money | the mortgage applied for |
| annualRateBasisPoints | int | the rate the mortgage will be charged at |
| stressRateBasisPoints | int | the rate to test the repayment at; not below the actual rate |
| termMonths | int | the term, repaid monthly by capital and interest |
| maxIncomeMultipleBasisPoints | int | the lender's loan-to-income ceiling; 45000 = 4.5 × income |
| maxDebtToIncomeBasisPoints | int | the lender's ceiling on repayments as a share of monthly income; 4000 = 40% |
| returns | MortgageAffordability |
The type it declares, generated into your project
/** Both tests, their inputs, and whether the loan passes both. */
export interface MortgageAffordability {
/** loan ÷ annual income, rounded up; 37038 = 3.7038 × */
readonly incomeMultipleBasisPoints: number;
/** annual income × the ceiling, rounded down */
readonly maxLoanByMultiple: Money;
/** the loan is within the income multiple */
readonly withinMultiple: boolean;
/** annual income ÷ 12, rounded down */
readonly monthlyIncome: Money;
/** the stressed repayment test, from lending.affordability */
readonly repayment: AffordabilityResult;
/** within the multiple and the stressed repayment fits */
readonly affordable: boolean;
}
Your code names it in one line, in the file that uses it
import { mortgageAffordability } from "#fune/property.mortgage-affordability@^1";
Imports name this capability’s declared dependencies, which fune builds next to it in your project; each one links to its page.
import { affordability } from "./lending_affordability.ts"; ← from lending.affordability ^1.0.0 · built alongside by fune
import { 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
import { type MortgageAffordability } from "./property_mortgage_affordability_types.ts";
const MAX_INCOME = 10_000_000_000;
const MAX_MULTIPLE = 200_000;
/**
* Loan-to-income against the lender's multiple, and the stressed repayment
* against income via lending.affordability. Every rounding goes against the
* borrower, and the multiple comparison itself is exact.
*/
export function mortgageAffordability(
annualIncome: Money,
monthlyCommitments: Money,
loan: Money,
annualRateBasisPoints: number,
stressRateBasisPoints: number,
termMonths: number,
maxIncomeMultipleBasisPoints: number,
maxDebtToIncomeBasisPoints: number,
): MortgageAffordability {
if (loan.currency !== annualIncome.currency) {
throw new RangeError(`currency mismatch: ${annualIncome.currency} and ${loan.currency}`);
}
if (!Number.isInteger(annualIncome.minor) || annualIncome.minor < 12 || annualIncome.minor > MAX_INCOME) {
throw new RangeError(`annualIncome must be between 12 and ${MAX_INCOME} minor units, received ${annualIncome.minor}`);
}
if (!Number.isInteger(loan.minor) || loan.minor <= 0 || loan.minor > MAX_INCOME * 20) {
throw new RangeError(`loan must be between 1 and ${MAX_INCOME * 20} minor units, received ${loan.minor}`);
}
const m = maxIncomeMultipleBasisPoints;
if (!Number.isInteger(m) || m < 1 || m > MAX_MULTIPLE) {
throw new RangeError(`maxIncomeMultipleBasisPoints must be between 1 and ${MAX_MULTIPLE}, received ${m}`);
}
const currency = annualIncome.currency;
const monthlyIncome = money(Math.floor(annualIncome.minor / 12), currency);
const repayment = affordability(
monthlyIncome,
monthlyCommitments,
loan,
annualRateBasisPoints,
stressRateBasisPoints,
termMonths,
maxDebtToIncomeBasisPoints,
);
const withinMultiple = loan.minor * 10000 <= annualIncome.minor * m;
return {
incomeMultipleBasisPoints: roundDiv(loan.minor * 10000, annualIncome.minor, "up"),
maxLoanByMultiple: money(roundDiv(annualIncome.minor * m, 10000, "down"), currency),
withinMultiple,
monthlyIncome,
repayment,
affordable: withinMultiple && repayment.affordable,
};
}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 property.mortgage-affordability
The manifest, vectors and README with only the TypeScript implementation. Install it without the registry with fune add ./property.mortgage-affordability-1.0.0-typescript.fune, or fetch it from a terminal with fune pull property.mortgage-affordability@1.0.0:typescript.
The whole function, every language, is one file too: property.mortgage-affordability-1.0.0.fune, 23,702 bytes, sha256 c21373cf87be1b2371401c4a94dde8cf05b3576404af90e365b48bae5c91586d. 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 property.mortgage-affordability
after — your function gets the result and the arguments, and returns the final result.
// fune: after property.mortgage-affordability
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.affordability in property.mortgage-affordability
// fune: replace math.round-div in property.mortgage-affordability
// fune: replace money.amount in property.mortgage-affordability
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 property.mortgage-affordability --steps.
// fune: step property.mortgage-affordability 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 | |
|---|---|---|---|
| £200k on £54,000 a year: 3.70× income and 39.52% stressed, passes both | £54,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | income multiple basis points 370.38%, max loan by multiple £243,000.00, within multiple true, monthly income £4,500.00, repayment …, affordable true |
| £200k on £40,000 a year: 5× income and 53.34% stressed, fails both | £40,000.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | income multiple basis points 500%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false |
| exactly 4.5× income is within a 4.5× multiple | £40,000.00, £0.00, £180,000.00, 4.25%, 7.25%, 300, 450%, 40% | → | income multiple basis points 450%, max loan by multiple £180,000.00, within multiple true, monthly income £3,333.33, repayment …, affordable true |
| a penny over 4.5× fails the multiple though the repayment fits | £40,000.00, £0.00, £180,000.01, 4.25%, 7.25%, 300, 450%, 40% | → | income multiple basis points 450.01%, max loan by multiple £180,000.00, within multiple false, monthly income £3,333.33, repayment …, affordable false |
| within the multiple but the stressed repayment does not fit | £80,000.00, £500.00, £300,000.00, 5%, 8%, 360, 450%, 40% | → | income multiple basis points 375%, max loan by multiple £360,000.00, within multiple true, monthly income £6,666.66, repayment …, affordable false |
| a comfortable 2× income loan | £100,000.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | income multiple basis points 200%, max loan by multiple £450,000.00, within multiple true, monthly income £8,333.33, repayment …, affordable true |
| an interest-free loan still gets a stressed repayment; odd income rounds the multiple up and the maximum down | £60,000.01, £0.00, £120,000.00, 0%, 3%, 240, 450%, 35% | → | income multiple basis points 200%, max loan by multiple £270,000.04, within multiple true, monthly income £5,000.00, repayment …, affordable true |
| mixed currencies are refused | £54,000.00, £300.00, €200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | error: currency mismatch |
| a zero income is refused | £0.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | error: annualIncome must be between 12 and |
| a zero loan is refused | £54,000.00, £0.00, £0.00, 4.5%, 7.5%, 300, 450%, 40% | → | error: loan must be between 1 and |
Show the other 3 tests
| Case | Arguments | Expected | |
|---|---|---|---|
| a zero multiple is refused | £54,000.00, £0.00, £200,000.00, 4.5%, 7.5%, 300, 0%, 40% | → | error: maxIncomeMultipleBasisPoints must be between 1 and 200000 |
| a stress rate below the actual rate is refused by lending.affordability | £54,000.00, £0.00, £200,000.00, 4.5%, 4%, 300, 450%, 40% | → | error: stressRateBasisPoints must not be below the actual rate |
| negative commitments are refused by lending.affordability | £54,000.00, -£0.01, £200,000.00, 4.5%, 7.5%, 300, 450%, 40% | → | error: monthlyCommitments must not be negative |
More from the author
`affordable` is true only when both pass.
## Everything rounds against the borrower
The monthly income is annual ÷ 12 rounded down; the multiple is rounded up; the maximum loan down; and lending.affordability rounds repayments and ratios up. A test that passes only because of a rounding is not a pass. The multiple test itself is exact: `withinMultiple` is loan × 10000 ≤ income × ceiling, with no rounding in it.
## The ceilings are the lender's
Neither ceiling is a rule in this code. The Bank of England Financial Policy Committee's loan-to-income flow limit restricts the share of a lender's new mortgages at or above 4.5× income, but it is a portfolio limit, not a cap on any one loan; the lender's own policy sets the multiple for each applicant. The stress rate is also the lender's: the FCA's MCOB 11.6.18R requires lenders to allow for likely rate rises, and the FPC's specific stress test was withdrawn from 1 August 2022 (see lending.affordability for the sources).
## What it does not do
Joint applications are the caller's to add up (pass the combined income the lender counts). Expenditure models, credit scores, deposit and loan-to-value (see lending.ltv), and interest-only lending are out of scope. Annual income may be up to £100,000,000 (10,000,000,000 minor units) and the multiple up to 20× (200000), which keeps the arithmetic exact in every language.
Files
| Path | Bytes |
|---|---|
| README.md | 2,098 |
| impl/python.py | 2,344 |
| impl/rust.rs | 3,378 |
| impl/typescript.ts | 2,331 |
| vectors.json | 8,336 |