Functional Weave
Code in Rust

payroll.student-loan@1.0.1

impl/typescript.ts

2,934 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 { STUDENT_LOAN_THRESHOLDS, STUDENT_LOAN_THRESHOLDS_HISTORY, STUDENT_LOAN_THRESHOLDS_HORIZON } from "./payroll_student_loan_data.ts";  ← this capability’s own data, compiled from data/student-loan-thresholds.json into the same file by fune build
import { type StudentLoanPlan } from "./payroll_student_loan_types.ts";
import { type PayFrequency } from "./payroll_tax_period_types.ts";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
const PLANS = ["plan-1", "plan-2", "plan-4", "plan-5", "postgraduate"];

/**
 * The student or postgraduate loan deduction for one pay period.
 *
 * The period threshold is the annual one scaled exactly by weeks/52 or
 * months/12 (Education (Student Loans) (Repayment) Regulations 2009 reg. 44(2)),
 * not HMRC's printed weekly and monthly figures, which are that fraction cut
 * to the penny. The deduction then drops its pence (reg. 44(3)), so the whole
 * calculation stays in integers: pence times the period denominator.
 */
export function studentLoan(earnings: Money, plan: StudentLoanPlan, frequency: PayFrequency, payDate: string): Money {
  if (earnings.currency !== "GBP") {
    throw new RangeError(`student loan deductions must be in GBP, received ${earnings.currency}`);
  }
  if (!Number.isInteger(earnings.minor) || earnings.minor < 0) {
    throw new RangeError(`earnings must not be negative, received ${earnings.minor}`);
  }
  if (!PLANS.includes(plan)) {
    throw new RangeError(`unknown student loan plan "${plan}"`);
  }
  let periods: number;
  let perYear: number;
  switch (frequency) {
    case "weekly": periods = 1; perYear = 52; break;
    case "fortnightly": periods = 2; perYear = 52; break;
    case "four-weekly": periods = 4; perYear = 52; break;
    case "monthly": periods = 1; perYear = 12; break;
    default: throw new RangeError(`unknown pay frequency "${frequency}"`);
  }
  if (!ISO_DATE.test(payDate)) {
    throw new RangeError(`payDate must be an ISO date (YYYY-MM-DD), received "${payDate}"`);
  }

  const rule = STUDENT_LOAN_THRESHOLDS.find((r) => r.plan === plan && payDate >= r.validFrom && (r.validTo === null || payDate <= r.validTo));
  if (rule === undefined) {
    if (STUDENT_LOAN_THRESHOLDS_HISTORY !== "full" && STUDENT_LOAN_THRESHOLDS_HORIZON !== null && payDate < STUDENT_LOAN_THRESHOLDS_HORIZON) {
      throw new RangeError(
        `no student loan threshold for ${plan} on ${payDate}: 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} on ${payDate}`);
  }

  // Everything below is in pence x perYear, so the threshold is exact.
  const excess = earnings.minor * perYear - rule.annualThreshold * periods;
  if (excess <= 0) return money(0, "GBP");
  const pounds = Math.floor((excess * rule.basisPoints) / (10000 * perYear * 100));
  return money(pounds * 100, "GBP");
}