Skip to content

NICs-only salary sacrifice cap changes ordinary income tax after age 75 #2454

Open

Description

@MaxGhenis

Found while fixing #2244 in #2441. #2441 fixes only the annual allowance accounting; this broader salary-sacrifice reclassification is outside its scope.

Defect and statutory basis

From 2029-30, salary_sacrifice_returned_to_income sends sacrifice above the
£2,000 NICs cap to both employment_income and
employee_pension_contributions. Its documentation assumes the additional
member contribution receives equal income-tax relief. For people aged 75 or
over, pension_contributions_relief correctly returns zero under Finance Act
2004 s. 188(3)(a), so the artificial pay increase remains taxable. It also raises
adjusted net income and can reduce the personal allowance. This affects
ordinary income tax independently of the annual allowance charge.

The National Insurance Contributions (Employer Pensions Contributions) Act
2026 amends SSCBA 1992 s. 4 for NICs purposes. It does not make the returned
amount employment income for income-tax purposes, and employer pension
contributions retain the ITEPA 2003 ss. 308/308A treatment. The model's NICs
implementation must preserve that distinction, including when member relief
is unavailable or limited.

Sources:

MOCK reproducer

Save the script at the end of this issue as reproduce_nics_income_tax.py in a checkout of #2441's head (ae2fe59e0 or later), then run uv run python reproduce_nics_income_tax.py. It prints JSON.

The script uses only six synthetic situations (three inputs in each of 2029
and 2030), with no survey data. It holds the broad-base haircut at zero and
compares a £2,000 cap with an unlimited cap. Each cap receives a separate
simulation cloned from a loaded model. ordinary_income_tax is income_tax
minus personal_pension_contributions_tax, which isolates the wider defect
from PR #2441's annual allowance changes. The two post-75 witnesses assert
that this ordinary tax rises.

The principal inputs are age 80, cash pay £160,000, salary sacrifice £30,000,
and employer contributions £100,000. A second age-80 case uses £100,000 cash
pay to expose the personal-allowance interaction. A third case reproduces the
age-45 basic-RAS boundary (cash pay £2,000, sacrifice £201,000, employer
£100,000, gross RAS £3,600) and prints the tax/ANI diagnostics for comparison.

Observed outputs

The build job ran the reproducer in the foreground with exit status 0 and
saved its full output. The measured code was
based on ae2fe59e0, with the production P2 post-75 DC-input correction already
applied to the working tree before calculation. Thus both narrow AA fixes
were present; they do not fix this ordinary-income-tax defect.

For each row below, the cap redirects £28,000 into both pay and employee
contributions. Pension contribution relief remains £0. Ordinary tax means
total income tax less the AA charge, as calculated by the script.

Year Cash pay Ordinary tax, unlimited cap Ordinary tax, £2,000 cap Ordinary-tax increase Personal allowance, unlimited → capped
2029 £160,000 £58,203 £70,803 £12,600 £0 → £0
2030 £160,000 £58,203 £70,803 £12,600 £0 → £0
2029 £100,000 £27,432 £43,803 £16,371 £12,570 → £0
2030 £100,000 £27,432 £43,803 £16,371 £12,570 → £0

In the £160,000 case, net income and ANI rise from £160,000 to £188,000.
Threshold income stays £190,000, adjusted income stays £290,000, the DC pension
input stays £130,000, the AA stays £60,000 and the AA charge stays £31,500.
The £12,600 total-income-tax increase is entirely ordinary tax.

In the £100,000 case, net income and ANI rise from £100,000 to £128,000,
removing the £12,570 personal allowance. Threshold income stays £130,000,
adjusted income stays £230,000, the DC pension input stays £130,000 and the
AA stays £60,000. The AA charge nevertheless rises from £29,614.50 to £31,500
(£1,885.50) through the ordinary reduced-net-income/allowance interaction.
Total income tax rises by £18,256.50, comprising that charge change and
£16,371 ordinary tax. These figures are identical in 2029 and 2030.

For the basic-RAS boundary, ordinary tax stays £0 in both years, but net income
and ANI rise from £2,000 to £201,000 and the personal allowance falls from
£12,570 to £0. After the AA fixes, threshold income stays £199,400, adjusted
income stays £303,000, the pension input stays £304,600 and the AA stays
£60,000. The AA charge rises from £96,273 to £97,173 (£900) through the
ordinary-tax/relief interaction. The unrestricted AA charge is therefore not
invariant to the NICs cap; the dedicated AA income, input and allowance
computations are invariant in these witnesses.

Suggested scope for a follow-up

Keep the NICs base reclassification separate from income-tax pay and pension
relief. Test post-75 contributions, relief-limited low earnings, and ANI /
personal allowance as well as ordinary income tax, with the haircut fixed at
zero. Preserve the NICs policy and retain the dedicated statutory income
accounting used for the annual allowance.

Reproducer script (MOCK situations only)
"""MOCK-only diagnostic for NICs reclassification leaking into ordinary tax.

Run from the worktree with its locked venv and four-thread environment::

    uv run --frozen --no-sync python .job/reproduce_nics_income_tax.py

An optional --source-root points at an extracted source tree, allowing the
same situations and installed dependencies to diagnose the reviewed head.
No survey data, uprating or microsimulation is involved.
"""

import argparse
import json
import sys
from pathlib import Path


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--source-root", default=str(Path.cwd()))
    args = parser.parse_args()
    sys.path.insert(0, str(Path(args.source_root).resolve()))

    from policyengine_uk import Simulation
    import policyengine_uk.simulation as uk_simulation

    # Clone a loaded system, as the review's MOCK diagnostics do. Each cap
    # comparison still receives a separate simulation and parameter tree.
    base = uk_simulation.CountryTaxBenefitSystem()
    uk_simulation.CountryTaxBenefitSystem = base.clone

    cases = {
        "post75_review_witness": {
            "age": 80,
            "employment_income_before_lsr": 160_000,
            "pension_contributions_via_salary_sacrifice": 30_000,
            "employer_pension_contributions": 100_000,
        },
        "post75_personal_allowance_witness": {
            "age": 80,
            "employment_income_before_lsr": 100_000,
            "pension_contributions_via_salary_sacrifice": 30_000,
            "employer_pension_contributions": 100_000,
        },
        "basic_ras_boundary": {
            "age": 45,
            "employment_income_before_lsr": 2_000,
            "pension_contributions_via_salary_sacrifice": 201_000,
            "employer_pension_contributions": 100_000,
            "personal_pension_contributions": 3_600,
        },
    }
    variables = [
        "employment_income",
        "salary_sacrifice_returned_to_income",
        "employee_pension_contributions",
        "pension_contributions_relief",
        "net_income",
        "adjusted_net_income",
        "personal_allowance",
        "pension_contributions_for_annual_allowance",
        "pension_annual_allowance_threshold_income",
        "pension_annual_allowance_adjusted_income",
        "pension_annual_allowance",
        "personal_pension_contributions_tax",
        "income_tax",
    ]
    rows = []
    for year in [2029, 2030]:
        situation = {
            "people": {
                name: {key: {year: value} for key, value in inputs.items()}
                for name, inputs in cases.items()
            },
            "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(cases)},
            "households": {
                f"h{i}": {"members": [name], "region": {year: "LONDON"}}
                for i, name in enumerate(cases)
            },
        }
        results = {}
        for label, cap in [("cap2000", 2_000), ("unlimited", float("inf"))]:
            simulation = Simulation(situation=situation)
            simulation.apply_parameter_changes(
                {
                    "gov.hmrc.national_insurance.salary_sacrifice_pension_cap": {
                        str(year): cap
                    },
                    "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate": {
                        str(year): 0
                    },
                }
            )
            results[label] = {
                variable: simulation.calculate(variable, year) for variable in variables
            }
        for i, (name, inputs) in enumerate(cases.items()):
            row = {"year": year, "case": name, "inputs": inputs}
            for label in results:
                row[label] = {
                    variable: float(values[i])
                    for variable, values in results[label].items()
                }
                # The only non-ordinary tax item in these situations is the
                # AA charge. Remove it explicitly to isolate this issue.
                row[label]["ordinary_income_tax"] = (
                    row[label]["income_tax"]
                    - row[label]["personal_pension_contributions_tax"]
                )
            row["cap_minus_unlimited"] = {
                variable: row["cap2000"][variable] - row["unlimited"][variable]
                for variable in [*variables, "ordinary_income_tax"]
            }
            rows.append(row)
    print(json.dumps({"dataset": "MOCK", "rows": rows}, indent=2), flush=True)
    # These assert the diagnostic is reproducing the wider defect. They are
    # deliberately outside the regression suite: the AA fix must not fix it.
    for row in rows:
        if row["case"].startswith("post75_"):
            assert row["cap_minus_unlimited"]["ordinary_income_tax"] > 0, row


if __name__ == "__main__":
    main()

Filed by the UK hub from a build job's write-up for #2441 round 3. The figures above came from that job's foreground run, on a working tree based on ae2fe59e0.

Activity

  1. self-assigned this
    on Oct 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions