Functional Weave
Code in Python

lending.affordability@1.0.1

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),
  };
}