Functional Weave
Code in TypeScript

invest.pension-annual-allowance@1.0.0

impl/typescript.ts

7,746 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 { ukTaxYear } from "./dates_uk_tax_year.ts";  ← from dates.uk-tax-year ^1.0.0 · built alongside by fune
import { type Money } from "./money_amount.ts";  ← from money.amount ^1.0.0 · built alongside by fune
import { ANNUAL_ALLOWANCE_RULES, ANNUAL_ALLOWANCE_RULES_HISTORY, ANNUAL_ALLOWANCE_RULES_HORIZON } from "./invest_pension_annual_allowance_data.ts";  ← this capability’s own data, compiled from data/annual-allowance.json into the same file by fune build
import { type AnnualAllowanceResult, type CarryForwardYear, type ChargeBasis, type PensionInputYear } from "./invest_pension_annual_allowance_types.ts";

/** How one tax year comes out against its own allowance, given the carry-forward it can reach. */
interface YearTest {
  standard: number;
  allowance: number;
  tapered: boolean;
  mpaa: number | null;
  alternative: number | null;
  total: number;
  basis: ChargeBasis;
  carryForwardUsed: number;
  excess: number;
  /** What this year leaves to carry forward, and what it was tested against. */
  unused: number;
  testedAllowance: number;
  testedInput: number;
}

interface Ledger {
  taxYear: string;
  allowance: number;
  input: number;
  unused: number;
  usedByLaterYears: number;
  usedThisYear: number;
}

function gbp(minor: number): Money {
  return { minor, currency: "GBP" };
}

function checkMoney(value: Money, name: string, label: string): number {
  if (value.currency !== "GBP") throw new RangeError(`${name} must be in GBP, received ${value.currency} for ${label}`);
  if (!Number.isInteger(value.minor) || value.minor < 0) {
    throw new RangeError(`${name} must be a whole number of pence, not negative, received ${value.minor} for ${label}`);
  }
  return value.minor;
}

function ruleFor(startYear: number) {
  const start = `${String(startYear).padStart(4, "0")}-04-06`;
  const label = ukTaxYear(start).label;
  const rule = ANNUAL_ALLOWANCE_RULES.find((r) => r.validFrom <= start && (r.validTo === null || start <= r.validTo));
  if (rule === undefined) {
    const horizon = ANNUAL_ALLOWANCE_RULES_HORIZON;
    const why =
      horizon !== null && start < horizon
        ? `: this build was installed with history=${ANNUAL_ALLOWANCE_RULES_HISTORY}, so it only carries rules from ${horizon}. Reinstall with history=full for earlier years.`
        : "";
    throw new RangeError(`no annual allowance rules for the ${label} tax year${why}`);
  }
  return { rule, label };
}

function test(startYear: number, year: PensionInputYear, carryForward: number): YearTest & { label: string } {
  const { rule, label } = ruleFor(startYear);
  const mp = checkMoney(year.moneyPurchaseInput, "moneyPurchaseInput", label);
  const other = checkMoney(year.otherInput, "otherInput", label);
  const threshold = checkMoney(year.thresholdIncome, "thresholdIncome", label);
  const adjusted = checkMoney(year.adjustedIncome, "adjustedIncome", label);
  const total = mp + other;
  if (!year.member && total > 0) {
    throw new RangeError(`pension inputs in ${label} need membership of a registered pension scheme in that year`);
  }

  // Taper: £1 off for every £2 of adjusted income over the limit, rounded down
  // to a whole pound, when threshold income is also over its limit.
  let allowance = rule.annualAllowance;
  const tapered = threshold > rule.thresholdIncome && adjusted > rule.adjustedIncome;
  if (tapered) {
    const reduction = Math.floor((adjusted - rule.adjustedIncome) / 200) * 100;
    allowance = Math.max(rule.minimumTaperedAllowance, allowance - reduction);
  }

  const base = { label, standard: rule.annualAllowance, allowance, tapered, total };
  const defaultUsed = Math.min(carryForward, Math.max(0, total - allowance));
  const defaultExcess = Math.max(0, total - allowance - defaultUsed);
  if (year.mpaaApplies && mp > rule.moneyPurchaseAllowance) {
    // Carry-forward can top up the alternative allowance, never the MPAA.
    const alternative = Math.max(0, allowance - rule.moneyPurchaseAllowance);
    const altUsed = Math.min(carryForward, Math.max(0, other - alternative));
    const altExcess = mp - rule.moneyPurchaseAllowance + Math.max(0, other - alternative - altUsed);
    const useAlternative = altExcess > defaultExcess;
    return {
      ...base,
      mpaa: rule.moneyPurchaseAllowance,
      alternative,
      basis: useAlternative ? "alternative" : "standard",
      carryForwardUsed: useAlternative ? altUsed : defaultUsed,
      excess: useAlternative ? altExcess : defaultExcess,
      unused: year.member ? Math.max(0, alternative - other) : 0,
      testedAllowance: alternative,
      testedInput: other,
    };
  }
  return {
    ...base,
    mpaa: year.mpaaApplies ? rule.moneyPurchaseAllowance : null,
    alternative: null,
    basis: "standard",
    carryForwardUsed: defaultUsed,
    excess: defaultExcess,
    unused: year.member ? Math.max(0, allowance - total) : 0,
    testedAllowance: allowance,
    testedInput: total,
  };
}

function available(entry: Ledger): number {
  return entry.unused - entry.usedByLaterYears - entry.usedThisYear;
}

/** Take `amount` from the entries' unused allowance, earliest year first. */
function consume(entries: Ledger[], amount: number, field: "usedByLaterYears" | "usedThisYear"): void {
  let left = amount;
  for (const entry of entries) {
    const take = Math.min(left, available(entry));
    entry[field] += take;
    left -= take;
  }
}

/**
 * The annual allowance for a UK tax year, and how much of the year's pension
 * input amounts is subject to the annual allowance charge.
 *
 * The earlier years are replayed oldest first, because an excess in one of
 * them uses up unused allowance from the years before it (earliest first),
 * and that is allowance the year being tested can no longer carry forward.
 * A year's own allowance is used before any carried forward; carry-forward
 * is then taken from the earliest of the three previous years first.
 */
export function pensionAnnualAllowance(
  taxYearDate: string,
  current: PensionInputYear,
  previousYears: readonly PensionInputYear[]
): AnnualAllowanceResult {
  const year = ukTaxYear(taxYearDate);
  const startYear = Number(year.start.slice(0, 4));
  if (previousYears.length < 3) {
    throw new RangeError(
      `previousYears must hold at least the three tax years before this one, oldest first, received ${previousYears.length}`
    );
  }
  const ledger: Ledger[] = [];
  previousYears.forEach((previous, i) => {
    const window = ledger.slice(Math.max(0, i - 3), i);
    const result = test(startYear - previousYears.length + i, previous, window.reduce((sum, e) => sum + available(e), 0));
    consume(window, result.carryForwardUsed, "usedByLaterYears");
    ledger.push({
      taxYear: result.label,
      allowance: result.testedAllowance,
      input: result.testedInput,
      unused: result.unused,
      usedByLaterYears: 0,
      usedThisYear: 0,
    });
  });
  const window = ledger.slice(ledger.length - 3);
  const carryForwardAvailable = window.reduce((sum, e) => sum + available(e), 0);
  const result = test(startYear, current, carryForwardAvailable);
  consume(window, result.carryForwardUsed, "usedThisYear");

  const carryForward: CarryForwardYear[] = window.map((e) => ({
    taxYear: e.taxYear,
    allowance: gbp(e.allowance),
    input: gbp(e.input),
    unused: gbp(e.unused),
    usedByLaterYears: gbp(e.usedByLaterYears),
    usedThisYear: gbp(e.usedThisYear),
  }));
  return {
    taxYear: result.label,
    standardAllowance: gbp(result.standard),
    allowance: gbp(result.allowance),
    tapered: result.tapered,
    moneyPurchaseAllowance: result.mpaa === null ? null : gbp(result.mpaa),
    alternativeAllowance: result.alternative === null ? null : gbp(result.alternative),
    totalInput: gbp(result.total),
    basis: result.basis,
    carryForward,
    carryForwardAvailable: gbp(carryForwardAvailable),
    carryForwardUsed: gbp(result.carryForwardUsed),
    excess: gbp(result.excess),
  };
}