Functional Weave
Code in Rust

payroll.national-insurance@1.0.1

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,
  };
}