payroll.pension-auto-enrolment@1.0.2
impl/typescript.ts
4,768 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 { AE_MINIMUMS, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON, AE_THRESHOLDS, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON } from "./payroll_pension_auto_enrolment_data.ts"; ← this capability’s own data, compiled from data/ae-thresholds.json into the same file by fune build
import { type PensionContributions, type PensionScheme } from "./payroll_pension_auto_enrolment_types.ts";
import { type PayFrequency } from "./payroll_tax_period_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function missing(what: string, onDate: string, history: string, horizon: string | null): never {
if (history !== "full" && horizon !== null && onDate < horizon) {
throw new RangeError(
`${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${horizon}. Reinstall with history=full for earlier tax years.`
);
}
throw new RangeError(`${what} on ${onDate}`);
}
function checkRate(name: string, value: number | null): void {
if (value !== null && (!Number.isInteger(value) || value < 0)) {
throw new RangeError(`${name} must be a whole number of basis points, 0 or more, received ${value}`);
}
}
/**
* Workplace pension contributions for one pay reference period.
*
* The band is applied with the published per-period figures (£520 to £4,189 a
* month, £120 to £967 a week), never the annual band divided on the fly. Each
* contribution is rounded once to the nearest penny, half up; under relief at
* source the deduction from pay is then 80% of the gross contribution, again
* rounded half up, and the difference is the relief the provider claims.
*/
export function pensionContributions(earnings: Money, frequency: PayFrequency, scheme: PensionScheme, payDate: string): PensionContributions {
if (earnings.currency !== "GBP") {
throw new RangeError(`pension contributions 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}`);
}
checkRate("employeeBasisPoints", scheme.employeeBasisPoints);
checkRate("employerBasisPoints", scheme.employerBasisPoints);
if (scheme.earningsBasis !== "qualifying-earnings" && scheme.earningsBasis !== "all-earnings") {
throw new RangeError(`unknown earnings basis "${scheme.earningsBasis}"`);
}
if (scheme.arrangement !== "relief-at-source" && scheme.arrangement !== "net-pay") {
throw new RangeError(`unknown relief arrangement "${scheme.arrangement}"`);
}
if (frequency !== "weekly" && frequency !== "fortnightly" && frequency !== "four-weekly" && frequency !== "monthly") {
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 t = AE_THRESHOLDS.find((r) => payDate >= r.validFrom && (r.validTo === null || payDate <= r.validTo));
if (t === undefined) missing("no auto-enrolment earnings thresholds", payDate, AE_THRESHOLDS_HISTORY, AE_THRESHOLDS_HORIZON);
const m = AE_MINIMUMS.find((r) => payDate >= r.validFrom && (r.validTo === null || payDate <= r.validTo));
if (m === undefined) missing("no auto-enrolment minimum contributions", payDate, AE_MINIMUMS_HISTORY, AE_MINIMUMS_HORIZON);
const [lower, trigger, upper] =
frequency === "weekly" ? [t.lowerWeekly, t.triggerWeekly, t.upperWeekly]
: frequency === "fortnightly" ? [t.lowerFortnightly, t.triggerFortnightly, t.upperFortnightly]
: frequency === "four-weekly" ? [t.lowerFourWeekly, t.triggerFourWeekly, t.upperFourWeekly]
: [t.lowerMonthly, t.triggerMonthly, t.upperMonthly];
// Qualifying earnings are the part of pay above the lower level and not
// above the upper one (Pensions Act 2008 s.13).
const pensionable = scheme.earningsBasis === "all-earnings" ? earnings.minor : Math.max(0, Math.min(earnings.minor, upper) - lower);
const employerRate = scheme.employerBasisPoints ?? m.employerBasisPoints;
const employeeRate = scheme.employeeBasisPoints ?? Math.max(0, m.totalBasisPoints - employerRate);
const employeeGross = roundDiv(pensionable * employeeRate, 10000, "half-up");
const employer = roundDiv(pensionable * employerRate, 10000, "half-up");
const netPay = scheme.arrangement === "net-pay";
const deduction = netPay ? employeeGross : roundDiv(employeeGross * (10000 - m.reliefAtSourceBasisPoints), 10000, "half-up");
return {
pensionableEarnings: money(pensionable, "GBP"),
employeeGross: money(employeeGross, "GBP"),
employeeDeduction: money(deduction, "GBP"),
employer: money(employer, "GBP"),
deductBeforeTax: netPay,
earningsTriggerMet: earnings.minor > trigger,
};
}