Functional Weave
Code in Rust

property.mortgage-affordability Unreviewed

Mortgage affordability: the loan-to-income multiple and a stress-tested repayment against income, both checked.

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 conveyancer or tax adviser 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 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

  • mortgage_affordability(£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 both
  • mortgage_affordability(£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 both
  • mortgage_affordability(£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.

pub fn mortgage_affordability(annual_income: &Money, monthly_commitments: &Money, loan: &Money, annual_rate_basis_points: i64, stress_rate_basis_points: i64, term_months: i64, max_income_multiple_basis_points: i64, max_debt_to_income_basis_points: i64) -> MortgageAffordability
annual_incomeMoneygross annual income the lender counts (after any haircut), greater than zero
monthly_commitmentsMoneyexisting monthly debt payments, zero or more
loanMoneythe mortgage applied for
annual_rate_basis_pointsintthe rate the mortgage will be charged at
stress_rate_basis_pointsintthe rate to test the repayment at; not below the actual rate
term_monthsintthe term, repaid monthly by capital and interest
max_income_multiple_basis_pointsintthe lender's loan-to-income ceiling; 45000 = 4.5 × income
max_debt_to_income_basis_pointsintthe lender's ceiling on repayments as a share of monthly income; 4000 = 40%
returnsMortgageAffordability

The type it declares, generated into your project

/// Both tests, their inputs, and whether the loan passes both.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MortgageAffordability {
    /// loan ÷ annual income, rounded up; 37038 = 3.7038 ×
    pub income_multiple_basis_points: i64,
    /// annual income × the ceiling, rounded down
    pub max_loan_by_multiple: Money,
    /// the loan is within the income multiple
    pub within_multiple: bool,
    /// annual income ÷ 12, rounded down
    pub monthly_income: Money,
    /// the stressed repayment test, from lending.affordability
    pub repayment: AffordabilityResult,
    /// within the multiple and the stressed repayment fits
    pub affordable: bool,
}

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

fune!(property.mortgage-affordability@^1);  // then call mortgage_affordability(…)
impl/rust.rs · 84 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.

use super::funejson::Value;  ← the fune runtime: the JSON value the test vectors use; fune build keeps it only where a signature takes one
use super::lending_affordability::{affordability, affordability_result_to_value};  ← from lending.affordability ^1.0.0 · built alongside by fune
use super::math_round_div::round_div;  ← from math.round-div ^1.0.0 · built alongside by fune
use super::money_amount::{money, money_from_value, money_to_value, Money};  ← from money.amount ^1.0.0 · built alongside by fune

const MAX_INCOME: i64 = 10_000_000_000;
const MAX_MULTIPLE: i64 = 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.
///
/// # Panics
/// Panics on mismatched currencies, an income, loan or multiple out of range,
/// or anything lending.affordability refuses.
#[allow(clippy::too_many_arguments)]
pub fn mortgage_affordability(
    annual_income: &Money,
    monthly_commitments: &Money,
    loan: &Money,
    annual_rate_basis_points: i64,
    stress_rate_basis_points: i64,
    term_months: i64,
    max_income_multiple_basis_points: i64,
    max_debt_to_income_basis_points: i64,
) -> MortgageAffordability {
    if loan.currency != annual_income.currency {
        panic!("currency mismatch: {} and {}", annual_income.currency, loan.currency);
    }
    if annual_income.minor < 12 || annual_income.minor > MAX_INCOME {
        panic!("annualIncome must be between 12 and {} minor units, received {}", MAX_INCOME, annual_income.minor);
    }
    if loan.minor <= 0 || loan.minor > MAX_INCOME * 20 {
        panic!("loan must be between 1 and {} minor units, received {}", MAX_INCOME * 20, loan.minor);
    }
    let m = max_income_multiple_basis_points;
    if m < 1 || m > MAX_MULTIPLE {
        panic!("maxIncomeMultipleBasisPoints must be between 1 and {}, received {}", MAX_MULTIPLE, m);
    }
    let currency = annual_income.currency.as_str();
    let monthly_income = money(annual_income.minor / 12, currency);
    let repayment = affordability(
        &monthly_income,
        monthly_commitments,
        loan,
        annual_rate_basis_points,
        stress_rate_basis_points,
        term_months,
        max_debt_to_income_basis_points,
    );
    let within = loan.minor * 10000 <= annual_income.minor * m;
    MortgageAffordability {
        income_multiple_basis_points: round_div(loan.minor * 10000, annual_income.minor, "up"),
        max_loan_by_multiple: money(round_div(annual_income.minor * m, 10000, "down"), currency),
        within_multiple: within,
        monthly_income,
        affordable: within && repayment.affordable,
        repayment,
    }
}

pub fn mortgage_affordability_to_value(result: &MortgageAffordability) -> Value {
    Value::obj(vec![
        ("incomeMultipleBasisPoints", Value::Int(result.income_multiple_basis_points)),
        ("maxLoanByMultiple", money_to_value(&result.max_loan_by_multiple)),
        ("withinMultiple", Value::Bool(result.within_multiple)),
        ("monthlyIncome", money_to_value(&result.monthly_income)),
        ("repayment", affordability_result_to_value(&result.repayment)),
        ("affordable", Value::Bool(result.affordable)),
    ])
}

pub fn fune_vector(args: &[Value]) -> Value {
    mortgage_affordability_to_value(&mortgage_affordability(
        &money_from_value(&args[0]),
        &money_from_value(&args[1]),
        &money_from_value(&args[2]),
        args[3].as_i64(),
        args[4].as_i64(),
        args[5].as_i64(),
        args[6].as_i64(),
        args[7].as_i64(),
    ))
}

Install

fune build

With that line in your source, in a Rust project (language rust in fune.project), fune build resolves it and its 3 dependencies, pins them in fune.lock, downloads only the Rust 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. A crate’s build.rs runs it before every compile. Or pin a range in fune.project and build in one step:

fune add property.mortgage-affordability
Download for Rust property.mortgage-affordability-1.0.1-rust.fune · 20,009 bytes sha256 e1681c8dac52787934a777442153026f2502ab446c1c41ab1a3c39f9ba1d9412

The manifest, vectors and README with only the Rust implementation. Install it without the registry with fune add ./property.mortgage-affordability-1.0.1-rust.fune, or fetch it from a terminal with fune pull property.mortgage-affordability@1.0.1:rust.

The whole function, every language, is one file too: property.mortgage-affordability-1.0.1.fune, 24,858 bytes, sha256 570381df9685b75339ee22fedf93eb8ab2f7d05aba48c4de45b125cea93163aa. 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.

CaseArgumentsExpected
£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
CaseArgumentsExpected
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.

## Before you rely on this

**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 above, 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.

**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 conveyancer or tax adviser 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,216
impl/python.py2,344
impl/rust.rs3,378
impl/typescript.ts2,331
vectors.json8,336