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