impl/typescript.ts
2,882 bytes · the TypeScript implementation · view 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),
};
}