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