Functional Weave
Code in Rust

education.student-loan-repayment@1.0.1

impl/typescript.ts

3,726 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 { type Money, money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { ukTaxYear } from "./dates_uk_tax_year.ts";  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
import { STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON } from "./payroll_student_loan_data.ts";  ← payroll.student-loan’s rule data (^1.0.0) · built alongside by fune
import { type StudentLoanPlan } from "./payroll_student_loan_types.ts";
import { UNEARNED_INCOME_LIMITS, UNEARNED_INCOME_LIMITS_HISTORY, UNEARNED_INCOME_LIMITS_HORIZON } from "./education_student_loan_repayment_data.ts";  ← this capability’s own data, compiled from data/unearned-income-limits.json into the same file by fune build
import { type StudentLoanRepayment } from "./education_student_loan_repayment_types.ts";

const PLANS = ["plan-1", "plan-2", "plan-4", "plan-5", "postgraduate"];

function gbp(value: Money, name: string): number {
  if (value.currency !== "GBP") {
    throw new RangeError(`${name} must be in GBP, received ${value.currency}`);
  }
  if (!Number.isInteger(value.minor) || value.minor < 0) {
    throw new RangeError(`${name} must not be negative, received ${value.minor}`);
  }
  return value.minor;
}

/**
 * One plan's repayment for a tax year, from the whole year's income.
 *
 * The threshold is payroll.student-loan's row in force on 6 April of the tax
 * year; the repayment drops its pence, as HMRC does.
 */
export function studentLoanRepayment(
  earnedIncome: Money,
  unearnedIncome: Money,
  plan: StudentLoanPlan,
  taxYearDate: string,
  deductedThroughPaye: Money,
): StudentLoanRepayment {
  const earned = gbp(earnedIncome, "earnedIncome");
  const unearned = gbp(unearnedIncome, "unearnedIncome");
  const deducted = gbp(deductedThroughPaye, "deductedThroughPaye");
  if (!PLANS.includes(plan)) {
    throw new RangeError(`unknown student loan plan "${plan}"`);
  }
  const year = ukTaxYear(taxYearDate);
  const start = year.start;

  const rule = STUDENT_LOAN_THRESHOLDS.find((r) => r.plan === plan && start >= r.validFrom && (r.validTo === null || start <= r.validTo));
  if (rule === undefined) {
    if (STUDENT_LOAN_THRESHOLDS_HISTORY !== "full" && STUDENT_LOAN_THRESHOLDS_HORIZON !== null && start < STUDENT_LOAN_THRESHOLDS_HORIZON) {
      throw new RangeError(
        `no student loan threshold for ${plan} in tax year ${year.label}: this build was installed with history=${STUDENT_LOAN_THRESHOLDS_HISTORY}, so it only carries rules from ${STUDENT_LOAN_THRESHOLDS_HORIZON}. Reinstall with history=full for earlier tax years.`,
      );
    }
    throw new RangeError(`no student loan threshold for ${plan} in tax year ${year.label}`);
  }
  const limit = UNEARNED_INCOME_LIMITS.find((r) => start >= r.validFrom && (r.validTo === null || start <= r.validTo));
  if (limit === undefined) {
    if (UNEARNED_INCOME_LIMITS_HISTORY !== "full" && UNEARNED_INCOME_LIMITS_HORIZON !== null && start < UNEARNED_INCOME_LIMITS_HORIZON) {
      throw new RangeError(
        `no unearned income limit for tax year ${year.label}: this build was installed with history=${UNEARNED_INCOME_LIMITS_HISTORY}, so it only carries rules from ${UNEARNED_INCOME_LIMITS_HORIZON}. Reinstall with history=full for earlier tax years.`,
      );
    }
    throw new RangeError(`no unearned income limit for tax year ${year.label}`);
  }

  const counted = earned + (unearned > limit.limit ? unearned : 0);
  const excess = counted - rule.annualThreshold;
  // Pence x basis points stays well inside 2^53 for any real income.
  const pounds = excess <= 0 ? 0 : Math.floor((excess * rule.basisPoints) / (10000 * 100));
  const repayment = pounds * 100;
  return {
    taxYear: year.label,
    threshold: money(rule.annualThreshold, "GBP"),
    basisPoints: rule.basisPoints,
    countedIncome: money(counted, "GBP"),
    repayment: money(repayment, "GBP"),
    deductedThroughPaye: money(deducted, "GBP"),
    balance: money(repayment - deducted, "GBP"),
  };
}