Functional Weave
Code in Python

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