education.student-loan-repayment@1.0.0
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"),
};
}