payroll.national-insurance@1.0.2
impl/typescript.ts
6,871 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 { NI_PRIMARY_RATES, NI_PRIMARY_RATES_HISTORY, NI_PRIMARY_RATES_HORIZON, NI_SECONDARY_RATES, NI_SECONDARY_RATES_HISTORY, NI_SECONDARY_RATES_HORIZON, NI_THRESHOLDS, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON, type NiPrimaryRate, type NiSecondaryRate, type NiThresholds } from "./payroll_national_insurance_data.ts"; ← this capability’s own data, compiled from data/ni-thresholds.json into the same file by fune build
import { type DirectorNi, type NationalInsurance } from "./payroll_national_insurance_types.ts";
import { type PayFrequency } from "./payroll_tax_period_types.ts";
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
function inForce(validFrom: string, validTo: string | null, onDate: string): boolean {
return onDate >= validFrom && (validTo === null || onDate <= validTo);
}
// A pruned build must refuse a date it no longer carries rules for rather than
// answer it with a later year's rates.
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}`);
}
/** Earnings falling in (lower, upper]; upper null means no ceiling. */
function slice(earnings: number, lower: number, upper: number | null): number {
const top = upper === null ? earnings : Math.min(earnings, upper);
return Math.max(0, top - lower);
}
interface Limits {
lel: number;
pt: number;
st: number;
fust: number;
ust: number;
uel: number;
}
function limitsFor(t: NiThresholds, frequency: PayFrequency | "annual"): Limits {
switch (frequency) {
case "annual":
return { lel: t.lelAnnual, pt: t.ptAnnual, st: t.stAnnual, fust: t.fustAnnual, ust: t.ustAnnual, uel: t.uelAnnual };
case "monthly":
return { lel: t.lelMonthly, pt: t.ptMonthly, st: t.stMonthly, fust: t.fustMonthly, ust: t.ustMonthly, uel: t.uelMonthly };
case "weekly":
case "fortnightly":
case "four-weekly": {
// HMRC's CA38: for pay in multiples of a week, work on the weekly figures
// and multiply by the number of weeks.
const k = frequency === "weekly" ? 1 : frequency === "fortnightly" ? 2 : 4;
return { lel: t.lelWeekly * k, pt: t.ptWeekly * k, st: t.stWeekly * k, fust: t.fustWeekly * k, ust: t.ustWeekly * k, uel: t.uelWeekly * k };
}
default:
throw new RangeError(`unknown pay frequency "${frequency}"`);
}
}
interface Due {
employee: number;
employer: number;
}
// Regulation 12(1) SSCR 2001: primary and secondary are worked out separately
// and each total is rounded to the nearest penny, a half penny going up.
function contributions(earnings: number, l: Limits, p: NiPrimaryRate, s: NiSecondaryRate): Due {
const primary = slice(earnings, l.pt, l.uel) * p.ptToUelBasisPoints + slice(earnings, l.uel, null) * p.aboveUelBasisPoints;
const secondary =
slice(earnings, l.st, l.fust) * s.stToFustBasisPoints +
slice(earnings, Math.max(l.st, l.fust), l.ust) * s.fustToUstBasisPoints +
slice(earnings, Math.max(l.st, l.ust), null) * s.aboveUstBasisPoints;
return { employee: roundDiv(primary, 10000, "half-up"), employer: roundDiv(secondary, 10000, "half-up") };
}
/**
* Class 1 National Insurance on one payment, by the exact percentage method.
*
* With `director` null this is the ordinary earnings-period calculation. With
* it, the director's annual earnings period: contributions on everything paid
* so far this tax year at the annual thresholds, less what has already been
* paid, so the amount can go down (or negative) as well as up.
*/
export function nationalInsurance(earnings: Money, category: string, frequency: PayFrequency, payDate: string, director: DirectorNi | null): NationalInsurance {
if (earnings.currency !== "GBP") {
throw new RangeError(`National Insurance 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 (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 thresholds = NI_THRESHOLDS.find((t) => inForce(t.validFrom, t.validTo, payDate));
if (thresholds === undefined) {
missing("no National Insurance thresholds", payDate, NI_THRESHOLDS_HISTORY, NI_THRESHOLDS_HORIZON);
}
const primaryFor = (basis: string) =>
NI_PRIMARY_RATES.find((r) => r.category === category && r.basis === basis && inForce(r.validFrom, r.validTo, payDate));
// Directors only have rates of their own in a year the main rate changed
// mid-year (2023-24); otherwise they pay the ordinary rates.
const primary = (director !== null ? primaryFor("director") : undefined) ?? primaryFor("standard");
if (primary === undefined) {
missing(`no National Insurance rates for category ${category}`, payDate, NI_PRIMARY_RATES_HISTORY, NI_PRIMARY_RATES_HORIZON);
}
const secondary = NI_SECONDARY_RATES.find((r) => r.category === category && inForce(r.validFrom, r.validTo, payDate));
if (secondary === undefined) {
missing(`no National Insurance rates for category ${category}`, payDate, NI_SECONDARY_RATES_HISTORY, NI_SECONDARY_RATES_HORIZON);
}
if (director === null) {
const limits = limitsFor(thresholds, frequency);
const due = contributions(earnings.minor, limits, primary, secondary);
return {
employee: money(due.employee, "GBP"),
employer: money(due.employer, "GBP"),
lowerEarningsLimitReached: earnings.minor >= limits.lel,
};
}
for (const [name, value] of [["previousEarnings", director.previousEarnings], ["previousEmployee", director.previousEmployee], ["previousEmployer", director.previousEmployer]] as const) {
if (value.currency !== "GBP") {
throw new RangeError(`National Insurance must be in GBP, received ${value.currency} for ${name}`);
}
}
const cumulative = director.previousEarnings.minor + earnings.minor;
if (cumulative < 0) {
throw new RangeError(`earnings must not be negative, received ${cumulative} to date`);
}
const limits = limitsFor(thresholds, "annual");
const due = contributions(cumulative, limits, primary, secondary);
return {
employee: money(due.employee - director.previousEmployee.minor, "GBP"),
employer: money(due.employer - director.previousEmployer.minor, "GBP"),
lowerEarningsLimitReached: cumulative >= limits.lel,
};
}