Functional Weave
Code in Rust

payroll.income-tax@1.0.0

impl/typescript.ts

8,644 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 { type IncomeTax } from "./payroll_income_tax_types.ts";
import { type IncomeTaxBand, INCOME_TAX_BANDS, INCOME_TAX_BANDS_HISTORY, OVERRIDING_LIMIT, OVERRIDING_LIMIT_HISTORY } from "./payroll_income_tax_data.ts";  ← this capability’s own data, compiled from data/income-tax-bands.json into the same file by fune build
import { parseTaxCode } from "./payroll_tax_code_parse.ts";  ← from payroll.tax-code-parse ^1.0.0 · built alongside by fune
import { type PayFrequency } from "./payroll_tax_period_types.ts";

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;

// HMRC's routines work "to 4 decimal places of a pound without correcting the
// final place". Holding amounts as integer ten-thousandths of a pound (1 = £0.0001,
// 100 = 1p) makes every one of those truncations an exact integer division.
const UNITS_PER_PENNY = 100;
const UNITS_PER_POUND = 10000;

function inForce<T extends { validFrom: string; validTo: string | null }>(row: T, onDate: string): boolean {
  return row.validFrom <= onDate && (row.validTo === null || onDate <= row.validTo);
}

function noRule(what: string, onDate: string, history: string, froms: string[]): never {
  // A build installed with history=current only carries the rules still in
  // force; answering an older date with this year's bands would be a quiet,
  // plausible wrong answer, so say why there is nothing instead.
  if (history !== "full" && froms.length > 0) {
    const earliest = froms.reduce((a, b) => (b < a ? b : a));
    if (onDate < earliest) {
      throw new RangeError(
        `no ${what} on ${onDate}: this build was installed with history=${history}, so it only carries rules from ${earliest}. Reinstall with history=full for earlier tax years.`
      );
    }
  }
  throw new RangeError(`no ${what} on ${onDate}`);
}

function bandsFor(region: string, onDate: string): IncomeTaxBand[] {
  const rows = INCOME_TAX_BANDS.filter((b) => b.region === region && inForce(b, onDate));
  if (rows.length === 0) {
    noRule(`income tax bands for ${region}`, onDate, INCOME_TAX_BANDS_HISTORY, INCOME_TAX_BANDS.filter((b) => b.region === region).map((b) => b.validFrom));
  }
  return [...rows].sort((a, b) => a.band - b.band);
}

function overridingLimit(onDate: string): number {
  const row = OVERRIDING_LIMIT.find((r) => inForce(r, onDate));
  if (row === undefined) noRule("PAYE overriding limit", onDate, OVERRIDING_LIMIT_HISTORY, OVERRIDING_LIMIT.map((r) => r.validFrom));
  return row.basisPoints;
}

/** Free pay (or K-code additional pay) for one week or month, in pence: HMRC paragraph 4.3.1. */
function periodAllowance(codeNumber: number, periodsPerYear: number): number {
  if (codeNumber === 0) return 0;
  // Codes above 500 are split into blocks of 500 and a remainder of 1-500, each
  // rounded up to the penny separately; that is how the printed tables were
  // built, and computing the whole code in one division is a penny out.
  const blocks = Math.floor((codeNumber - 1) / 500);
  const remainder = ((codeNumber - 1) % 500) + 1;
  const blockValue = periodsPerYear === 52 ? 9616 : 41667;
  return roundDiv((remainder * 10 + 9) * 100, periodsPerYear, "up") + blocks * blockValue;
}

/** Tax due to date on positive taxable pay by the banded Tax Formulae of paragraph 4.4, in pence. */
function bandedTax(taxablePence: number, bands: IncomeTaxBand[], n: number, periodsPerYear: number): number {
  const taxablePounds = roundDiv(taxablePence, 100, "down");
  let previousThreshold = 0; // c(i-1), units
  let previousThresholdTax = 0; // k(i-1), units
  let lowerPounds = 0;
  let cumulativeAnnualTax = 0; // K(i), units
  for (const band of bands) {
    if (band.upTo !== null) {
      const threshold = roundDiv(band.upTo * UNITS_PER_POUND * n, periodsPerYear, "down");
      // The income test compares the unrounded pay with the threshold rounded
      // UP to a whole pound (the round-pound limits of Tables C), while the
      // formula itself uses the exact threshold.
      const cvalue = roundDiv(threshold, UNITS_PER_POUND, "up");
      if (taxablePence > cvalue * 100) {
        cumulativeAnnualTax += (band.upTo - lowerPounds) * band.basisPoints;
        lowerPounds = band.upTo;
        previousThreshold = threshold;
        previousThresholdTax = roundDiv(cumulativeAnnualTax * n, periodsPerYear, "down");
        continue;
      }
    }
    const atThisRate = roundDiv((taxablePounds * UNITS_PER_POUND - previousThreshold) * band.basisPoints, 10000, "down");
    return roundDiv(previousThresholdTax + atThisRate, UNITS_PER_PENNY, "down");
  }
  throw new RangeError("income tax bands have no top band");
}

function checkGbp(name: string, amount: Money): void {
  if (amount.currency !== "GBP") {
    throw new RangeError(`payroll amounts must be in GBP, received ${amount.currency} for ${name}`);
  }
}

/**
 * PAYE income tax for one payment, following HMRC's "Specification for PAYE tax
 * table routines" (the computerised form of the tax tables, used by payroll
 * software): cumulative or week 1 / month 1, suffix, K, BR, D and NT codes,
 * Scottish and Welsh bands, and the 50% overriding limit.
 */
export function incomeTax(
  taxCode: string,
  frequency: PayFrequency,
  period: number,
  pay: Money,
  previousPayToDate: Money,
  previousTaxToDate: Money,
  payDate: string
): IncomeTax {
  checkGbp("pay", pay);
  checkGbp("previousPayToDate", previousPayToDate);
  checkGbp("previousTaxToDate", previousTaxToDate);
  if (!ISO_DATE.test(payDate)) {
    throw new RangeError(`payDate must be an ISO date (YYYY-MM-DD), received "${payDate}"`);
  }
  let weeksInPeriod: number;
  let validPeriod: boolean;
  if (frequency === "monthly") {
    weeksInPeriod = 1;
    validPeriod = period >= 1 && period <= 12;
  } else if (frequency === "weekly") {
    weeksInPeriod = 1;
    validPeriod = period >= 1 && period <= 53;
  } else if (frequency === "fortnightly") {
    weeksInPeriod = 2;
    validPeriod = (period >= 1 && period <= 52) || period === 54;
  } else if (frequency === "four-weekly") {
    weeksInPeriod = 4;
    validPeriod = (period >= 1 && period <= 52) || period === 56;
  } else {
    throw new RangeError(`unknown pay frequency "${frequency}"`);
  }
  if (!Number.isInteger(period) || !validPeriod) {
    throw new RangeError(`period ${period} is not a tax period for ${frequency} pay`);
  }

  const code = parseTaxCode(taxCode);
  // Looked up even when no band is needed (NT, pay under the allowance), so a
  // date outside the tax years on file is always refused.
  const bands = bandsFor(code.region, payDate);
  const periodsPerYear = frequency === "monthly" ? 12 : 52;
  // Weeks 53, 54 and 56 are always taxed on a week 1 basis (paragraph 14),
  // using the week 1, 2 or 4 figures.
  const cumulative = code.cumulative && period <= 52;
  const n = cumulative ? period : weeksInPeriod;
  const payToDateForTax = cumulative ? previousPayToDate.minor + pay.minor : pay.minor;

  let liability = 0;
  let allowanceToDate = 0;
  let taxablePay = 0;
  if (code.kind === "allowance" || code.kind === "negative-allowance") {
    const perPeriod = periodAllowance(code.number, periodsPerYear) * n;
    allowanceToDate = code.kind === "allowance" ? perPeriod : -perPeriod;
    const taxablePence = payToDateForTax - allowanceToDate;
    if (taxablePence > 0) {
      taxablePay = roundDiv(taxablePence, 100, "down") * 100;
      liability = bandedTax(taxablePence, bands, n, periodsPerYear);
    }
  } else if (code.kind === "basic-rate" || code.kind === "d-rate") {
    const basic = bands.findIndex((b) => b.basicRate);
    const index = code.kind === "basic-rate" ? basic : basic + 1 + code.number;
    if (basic < 0 || index >= bands.length) {
      throw new RangeError(`no ${code.code} rate for ${code.region} on ${payDate}`);
    }
    const pounds = payToDateForTax > 0 ? roundDiv(payToDateForTax, 100, "down") : 0;
    taxablePay = pounds * 100;
    liability = roundDiv(pounds * bands[index].basisPoints, 100, "down");
  }

  // The overriding limit: no more than 50% of this payment may go in tax. It
  // never restricts a refund, and on negative pay it is zero (paragraph 4.5.4).
  const limit = pay.minor > 0 ? roundDiv(pay.minor * overridingLimit(payDate), 10000, "down") : 0;
  const due = cumulative ? liability - previousTaxToDate.minor : liability;
  const tax = due > limit ? limit : due;

  return {
    tax: money(tax, "GBP"),
    taxToDate: money(previousTaxToDate.minor + tax, "GBP"),
    payToDate: money(previousPayToDate.minor + pay.minor, "GBP"),
    allowanceToDate: money(allowanceToDate, "GBP"),
    taxablePay: money(taxablePay, "GBP"),
    cumulative,
    limitApplied: due > limit,
    taxNotDeducted: money(due > limit ? due - limit : 0, "GBP"),
  };
}