Functional Weave
Code in TypeScript

lending.affordability Unreviewed

Debt-to-income and a stressed-rate affordability test for a proposed loan's monthly repayment.

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

Two numbers an underwriter looks at for a new loan, repaid monthly:

- **Debt-to-income (DTI)**: existing monthly debt payments plus the new loan's repayment, as a share of monthly income, in basis points (3138 = 31.38%). - **Stressed DTI**: the same with the new loan's repayment recalculated at a higher stress rate, and whether that stays within the lender's ceiling.

For example

  • affordability(£4,500.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 40%) → payment £1,111.67, stressed payment £1,477.99, debt to income basis points 31.38%, stressed debt to income basis points 39.52%, affordable true, headroom £22.01 £200k mortgage on £4,500 a month, stressed at +3 points, passes 40%
  • affordability(£4,500.00, £800.00, £200,000.00, 4.5%, 7.5%, 300, 40%) → payment £1,111.67, stressed payment £1,477.99, debt to income basis points 42.49%, stressed debt to income basis points 50.63%, affordable false, headroom -£477.99 the same loan with £800 of other debts fails
  • affordability(£3,000.00, £0.00, £150,000.00, 5%, 8%, 360, 45%) → payment £805.24, stressed payment £1,100.65, debt to income basis points 26.85%, stressed debt to income basis points 36.69%, affordable true, headroom £249.35 no other commitments

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 affordability(monthlyIncome: Money, monthlyCommitments: Money, principal: Money, annualRateBasisPoints: number, stressRateBasisPoints: number, termMonths: number, maxDebtToIncomeBasisPoints: number): AffordabilityResult
monthlyIncomeMoneyincome per month the lender counts, greater than zero
monthlyCommitmentsMoneyexisting monthly debt payments, zero or more
principalMoneythe proposed loan
annualRateBasisPointsintthe rate the loan will be charged at
stressRateBasisPointsintthe rate to test at; not below the actual rate
termMonthsintthe term, repaid monthly
maxDebtToIncomeBasisPointsintthe lender's ceiling on repayments as a share of income; 4000 = 40%
returnsAffordabilityResult

The type it declares, generated into your project

/** The repayment at both rates, the ratios, and whether the stressed one fits. */
export interface AffordabilityResult {
  /** monthly repayment at the actual rate, rounded up */
  readonly payment: Money;
  /** monthly repayment at the stress rate, rounded up */
  readonly stressedPayment: Money;
  /** (commitments + payment) / income, rounded up */
  readonly debtToIncomeBasisPoints: number;
  /** (commitments + stressed payment) / income, rounded up */
  readonly stressedDebtToIncomeBasisPoints: number;
  /** the stressed ratio is within the maximum */
  readonly affordable: boolean;
  /** income × maximum − commitments − stressed payment; negative when short */
  readonly headroom: Money;
}

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

import { affordability } from "#fune/lending.affordability@^1";
impl/typescript.ts · 57 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 { 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 { applyRate } from "./money_apply_rate.ts";  ← from money.apply-rate ^1.0.0 · built alongside by fune
import { loanPayment } from "./lending_loan_payment.ts";  ← from lending.loan-payment ^1.0.0 · built alongside by fune
import { type AffordabilityResult } from "./lending_affordability_types.ts";

/**
 * A proposed loan's monthly repayment as a share of income, at the rate it
 * will be charged and at a stress rate, and whether the stressed share fits
 * under the lender's ceiling. Everything is rounded against the borrower
 * (payments and ratios up, the allowance down), because an affordability
 * test that passes on a rounding is not a test.
 */
export function affordability(
  monthlyIncome: Money,
  monthlyCommitments: Money,
  principal: Money,
  annualRateBasisPoints: number,
  stressRateBasisPoints: number,
  termMonths: number,
  maxDebtToIncomeBasisPoints: number,
): AffordabilityResult {
  const currency = monthlyIncome.currency;
  for (const other of [monthlyCommitments, principal]) {
    if (other.currency !== currency) {
      throw new RangeError(`currency mismatch: ${currency} and ${other.currency}`);
    }
  }
  if (!Number.isInteger(monthlyIncome.minor) || monthlyIncome.minor <= 0) {
    throw new RangeError(`monthlyIncome must be greater than zero, received ${monthlyIncome.minor}`);
  }
  if (!Number.isInteger(monthlyCommitments.minor) || monthlyCommitments.minor < 0) {
    throw new RangeError(`monthlyCommitments must not be negative, received ${monthlyCommitments.minor}`);
  }
  if (!Number.isInteger(stressRateBasisPoints) || stressRateBasisPoints < annualRateBasisPoints) {
    throw new RangeError(`stressRateBasisPoints must not be below the actual rate, received ${stressRateBasisPoints}`);
  }
  if (!Number.isInteger(maxDebtToIncomeBasisPoints) || maxDebtToIncomeBasisPoints < 1 || maxDebtToIncomeBasisPoints > 10000) {
    throw new RangeError(`maxDebtToIncomeBasisPoints must be between 1 and 10000, received ${maxDebtToIncomeBasisPoints}`);
  }
  const payment = loanPayment(principal, annualRateBasisPoints, termMonths, 12, "up");
  const stressed = loanPayment(principal, stressRateBasisPoints, termMonths, 12, "up");
  const ratio = (repayment: number) =>
    roundDiv((monthlyCommitments.minor + repayment) * 10000, monthlyIncome.minor, "up");
  const stressedRatio = ratio(stressed.minor);
  const allowance = applyRate(monthlyIncome, maxDebtToIncomeBasisPoints, "down");
  return {
    payment,
    stressedPayment: stressed,
    debtToIncomeBasisPoints: ratio(payment.minor),
    stressedDebtToIncomeBasisPoints: stressedRatio,
    // The ratio is rounded up and the ceiling is whole, so this is the exact
    // comparison (commitments + payment) / income <= ceiling.
    affordable: stressedRatio <= maxDebtToIncomeBasisPoints,
    headroom: money(allowance.minor - monthlyCommitments.minor - stressed.minor, 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 4 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.affordability
Download for TypeScript lending.affordability-1.0.1-typescript.fune · 17,108 bytes sha256 77057daf91cdac648f12ef7f902c9da046397c7bc8ee88cb26f9eb382c3e966b

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

The whole function, every language, is one file too: lending.affordability-1.0.1.fune, 23,905 bytes, sha256 6df533da587af8202f351258764764ef705d4978e30e84e333aae7ff28213787. 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.affordability

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

// fune: after lending.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.loan-payment in lending.affordability
// fune: replace math.round-div in lending.affordability
// fune: replace money.amount in lending.affordability
// fune: replace money.apply-rate in lending.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 lending.affordability --steps.

// fune: step lending.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 mortgage on £4,500 a month, stressed at +3 points, passes 40% £4,500.00, £300.00, £200,000.00, 4.5%, 7.5%, 300, 40% → payment £1,111.67, stressed payment £1,477.99, debt to income basis points 31.38%, stressed debt to income basis points 39.52%, affordable true, headroom £22.01
the same loan with £800 of other debts fails £4,500.00, £800.00, £200,000.00, 4.5%, 7.5%, 300, 40% → payment £1,111.67, stressed payment £1,477.99, debt to income basis points 42.49%, stressed debt to income basis points 50.63%, affordable false, headroom -£477.99
no other commitments £3,000.00, £0.00, £150,000.00, 5%, 8%, 360, 45% → payment £805.24, stressed payment £1,100.65, debt to income basis points 26.85%, stressed debt to income basis points 36.69%, affordable true, headroom £249.35
stress rate equal to the actual rate £2,500.00, £100.00, £10,000.00, 7%, 7%, 60, 30% → payment £198.02, stressed payment £198.02, debt to income basis points 11.93%, stressed debt to income basis points 11.93%, affordable true, headroom £451.98
interest-free loan: the stress still bites £2,000.00, £0.00, £12,000.00, 0%, 3%, 24, 35% → payment £500.00, stressed payment £515.78, debt to income basis points 25%, stressed debt to income basis points 25.79%, affordable true, headroom £184.22
a borderline: payment lands exactly on the ceiling £1,000.00, £0.00, £12,000.00, 0%, 0%, 24, 50% → payment £500.00, stressed payment £500.00, debt to income basis points 50%, stressed debt to income basis points 50%, affordable true, headroom £0.00
a penny over the ceiling fails £1,000.00, £0.01, £12,000.00, 0%, 0%, 24, 50% → payment £500.00, stressed payment £500.00, debt to income basis points 50.01%, stressed debt to income basis points 50.01%, affordable false, headroom -£0.01
zero income is refused £0.00, £0.00, £10,000.00, 5%, 8%, 60, 40% → error: monthlyIncome must be greater than zero
negative commitments are refused £3,000.00, -£0.01, £10,000.00, 5%, 8%, 60, 40% → error: monthlyCommitments must not be negative
a stress rate below the actual rate is refused £3,000.00, £0.00, £10,000.00, 5%, 4%, 60, 40% → error: stressRateBasisPoints must not be below the actual rate
Show the other 3 tests
CaseArgumentsExpected
a ceiling above 100% is refused £3,000.00, £0.00, £10,000.00, 5%, 8%, 60, 100.01% → error: maxDebtToIncomeBasisPoints must be between 1 and 10000
mixed currencies are refused £3,000.00, €0.00, £10,000.00, 5%, 8%, 60, 40% → error: currency mismatch
a zero term is refused, by lending.loan-payment £3,000.00, £0.00, £10,000.00, 5%, 8%, 0, 40% → error: termMonths must be at least 1

More from the author

It returns both repayments, both ratios, `affordable` (the stressed ratio is within `maxDebtToIncomeBasisPoints`) and `headroom`: how much monthly repayment room is left under the ceiling after the stressed repayment, which is negative when the loan does not fit.

## Rounding, all against the borrower

An affordability test that passes on a rounding is not a test, so:

- both repayments come from lending.loan-payment rounded `up`; - both ratios are rounded up to the next basis point, which makes `affordable` exactly the comparison (commitments + repayment) / income <= ceiling, with no rounding in it; - the allowance (income × ceiling) behind `headroom` is rounded down, with money.apply-rate.

## The stress rate is yours

The stress rate is an argument, not a rule in code, because there is no single published one. In the UK, MCOB 11.6.18R requires a mortgage lender to take account of likely future interest rate rises over at least the first five years (not where the rate is fixed for five years or more), having regard to market expectations. The Bank of England Financial Policy Committee's specific test (the reversion rate plus 3 percentage points) was withdrawn with effect from 1 August 2022. Sources: FCA, "Interest rate 'stress test' rule – application of MCOB 11.6.18R", https://www.fca.org.uk/firms/interest-rate-stress-test-rule ; Bank of England, "Financial Policy Committee confirms withdrawal of mortgage market affordability test" (June 2022), https://www.bankofengland.co.uk/news/2022/june/financial-policy-committee-confirms-withdrawal-of-mortgage-market-affordability-test . The ceiling is the lender's policy too.

## What it does not do

- Loan-to-income (the FPC's 4.5× income flow limit is about the loan size, not the repayment): divide the loan by annual income. - Expenditure, household size or the income the lender is willing to count: pass the income after whatever haircut the policy applies. - Interest-only or part-and-part loans: the repayment is always capital and interest.

## 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,570
impl/python.py2,838
impl/rust.rs3,736
impl/typescript.ts2,882
vectors.json6,067