From e28e3b6439ccde998dbdc750a099ac8c3caa70b7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:11:49 -0400 Subject: [PATCH 1/9] Correct statutory pension annual allowance taper income tests --- changelog.d/2244.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 71 ++++ .../allowances/annual_allowance/default.yaml | 4 +- .../allowances/annual_allowance/minimum.yaml | 11 +- .../annual_allowance/reduction_rate.yaml | 6 +- .../allowances/annual_allowance/taper.yaml | 11 +- .../annual_allowance/taper_income_range.yaml | 9 + .../pensions/pension_annual_allowance.yaml | 320 ++++++++++++++++++ .../personal_pension_contributions_tax.yaml | 4 +- .../test_pension_annual_allowance_taper.py | 156 +++++++++ .../allowances/pension_annual_allowance.py | 39 ++- ...ension_annual_allowance_adjusted_income.py | 26 ++ ...nsion_annual_allowance_threshold_income.py | 34 ++ ...sion_contributions_for_annual_allowance.py | 5 +- ...ributions_via_salary_sacrifice_pre_2015.py | 17 + ...lowance_taxable_lump_sum_death_benefits.py | 17 + 16 files changed, 719 insertions(+), 12 deletions(-) create mode 100644 changelog.d/2244.fixed.md create mode 100644 docs/engineering/pension-annual-allowance.md create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper_income_range.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml create mode 100644 policyengine_uk/tests/test_pension_annual_allowance_taper.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py create mode 100644 policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py create mode 100644 policyengine_uk/variables/input/pension_annual_allowance_taxable_lump_sum_death_benefits.py diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md new file mode 100644 index 0000000000..f88cd7c3be --- /dev/null +++ b/changelog.d/2244.fixed.md @@ -0,0 +1 @@ +- Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, salary sacrifice and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md new file mode 100644 index 0000000000..d4c4aa4c5a --- /dev/null +++ b/docs/engineering/pension-annual-allowance.md @@ -0,0 +1,71 @@ +# Pension annual allowance taper + +[FA 2004 s. 228ZA](https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA) +requires both adjusted income and threshold income to exceed their limits. +The threshold-income limit is the adjusted-income limit minus the standard +annual allowance. The reduction rounds down to a whole pound before it is +subtracted from the standard allowance, subject to the tapered minimum. + +| Tax years | Adjusted-income limit | Threshold-income limit | Minimum allowance | +| --- | ---: | ---: | ---: | +| 2016–17 to 2019–20 | £150,000 | £110,000 | £10,000 | +| 2020–21 to 2022–23 | £240,000 | £200,000 | £4,000 | +| From 2023–24 | £260,000 | £200,000 | £10,000 | + +The taper starts in 2016–17 under [Finance (No. 2) Act 2015, Schedule 4, +paragraph 10](https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10). +[Finance Act 2020 s. 22](https://www.legislation.gov.uk/ukpga/2020/14/section/22) +and [Finance (No. 2) Act 2023 s. 22](https://www.legislation.gov.uk/ukpga/2023/30/section/22) +set the later limits. The original reduction multiplier is the standard +allowance less £10,000, divided by £60,000: one half with the statutory +£40,000 standard. From 2020–21 the multiplier is expressly one half. + +## Model income conventions + +The model's `net_income` includes income before pension relief; that relief +is deducted separately through `allowances`. Employee contributions represent +net pay and personal contributions represent gross relief at source (RAS). +Accordingly: + +- `pension_annual_allowance_adjusted_income` adds the total pension input + amount to `net_income`, then subtracts the individual's own contributions. + This restores net-pay relief once and includes employer contributions and + salary sacrifice. RAS contributions do not reduce adjusted income. +- `pension_annual_allowance_threshold_income` subtracts + `pension_contributions_relief` from `net_income`, then adds back relevant + salary sacrifice. The relief deduction combines the net-pay deduction + already present in statutory Step 2 income and the gross RAS deduction + under s. 228ZA(5)(c). +- Both measures exclude `pension_annual_allowance_taxable_lump_sum_death_benefits`. + This input must already be included in income elsewhere, such as private + pension income; it does not itself add income to the taxable base. + +Gift Aid's adjusted-net-income deduction does not enter either measure. +These calculations therefore use `net_income`, independently of the separate +ITA 2007 s. 58 pension deduction from adjusted net income. + +## Inputs and data limits + +Arrangements made on or after **9 July 2015** are relevant salary sacrifice +under s. 228ZA(6). The default treats actual salary sacrifice as relevant +because the survey does not record arrangement dates. Supply +`pension_contributions_via_salary_sacrifice_pre_2015` for the part attributable +to earlier arrangements. Its exclusion is capped at actual salary sacrifice, +including under a reform that caps sacrifice. Flexible remuneration not +represented by these contribution inputs needs an explicit threshold-income +override. + +`pension_contributions_for_annual_allowance` is the shared pension input amount +used by both the taper and the charge. It defaults to employee, personal, +employer and actual salary-sacrifice contributions. For defined-benefit +arrangements, supply the statutory total pension input amount, including +valued DB accrual, through this variable. Cash employer funding is not a +valuation of DB rights. Survey estimates retain the existing cash proxy; +this correction does not reconstruct DB rights, overseas adjustments, unused +allowance histories or anti-avoidance arrangements under s. 228ZB. + +The taxable death-benefit and grandfathered-sacrifice inputs default to zero. +Pension relief retains the model's aggregate earnings cap and allocation +conventions. Historical years use calendar-year proxies for the named tax +years; the 2015 transitional pension-input alignment rules are outside this +taper calculation. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/default.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/default.yaml index 19ab2ac207..863ad41b9b 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/default.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/default.yaml @@ -1,4 +1,4 @@ -description: Annual allowance for relief on pension contributions. +description: Standard annual allowance for pension input amounts. values: 2015-04-01: 40_000 2023-04-06: 60_000 @@ -6,6 +6,8 @@ metadata: unit: currency-GBP label: Annual allowance for pension contributions reference: + - title: Finance (No. 2) Act 2023 s. 20 + href: https://www.legislation.gov.uk/ukpga/2023/30/section/20 - title: Finance Act 2004 s. 228 href: https://www.legislation.gov.uk/ukpga/2004/12/section/228 - title: Tax on your private pension contributions diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/minimum.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/minimum.yaml index ad1862cfcc..571aaf5076 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/minimum.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/minimum.yaml @@ -1,11 +1,18 @@ -description: Minimum annual allowance for relief on pension contributions. +description: Minimum tapered annual allowance for pension input amounts. values: - 2015-04-01: 4_000 + 2016-04-06: 10_000 + 2020-04-06: 4_000 2023-04-06: 10_000 metadata: unit: currency-GBP label: Minimum annual allowance for pension contributions reference: + - title: Finance (No. 2) Act 2015 Schedule 4 paragraph 10 + href: https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10 + - title: Finance Act 2020 s. 22 + href: https://www.legislation.gov.uk/ukpga/2020/14/section/22 + - title: Finance (No. 2) Act 2023 s. 22 + href: https://www.legislation.gov.uk/ukpga/2023/30/section/22 - title: Finance Act 2004 s. 228ZA href: https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA - title: Work out your reduced (tapered) annual allowance diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml index a226d14876..6ad4170303 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml @@ -1,9 +1,13 @@ description: Reduction rate for the annual allowance. values: - 2015-04-01: 0.5 + 2016-04-06: 0.5 metadata: unit: marginal-rate label: Annual allowance reduction rate reference: + - title: Finance Act 2020 s. 22 + href: https://www.legislation.gov.uk/ukpga/2020/14/section/22 + - title: Finance (No. 2) Act 2015 Schedule 4 paragraph 10 + href: https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10 - title: Tax on your private pension contributions href: https://www.gov.uk/tax-on-your-private-pension/annual-allowance diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper.yaml index 72835cd449..ffc019ada0 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper.yaml @@ -1,11 +1,18 @@ -description: Adjusted net income limit for tapering of the annual allowance. +description: Adjusted income limit for tapering of the pension annual allowance. values: - 2015-04-01: 240_000 + 2016-04-06: 150_000 + 2020-04-06: 240_000 2023-04-06: 260_000 metadata: unit: currency-GBP label: Annual allowance taper threshold reference: + - title: Finance (No. 2) Act 2015 Schedule 4 paragraph 10 + href: https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10 + - title: Finance Act 2020 s. 22 + href: https://www.legislation.gov.uk/ukpga/2020/14/section/22 + - title: Finance (No. 2) Act 2023 s. 22 + href: https://www.legislation.gov.uk/ukpga/2023/30/section/22 - title: Finance Act 2004 s. 228ZA href: https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA - title: Tax on your private pension contributions diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper_income_range.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper_income_range.yaml new file mode 100644 index 0000000000..12f4b2772a --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/taper_income_range.yaml @@ -0,0 +1,9 @@ +description: Adjusted income range over which the pension annual allowance falls from its standard amount to its minimum before 2020-21. +values: + 2016-04-06: 60_000 +metadata: + unit: currency-GBP + label: Pension annual allowance taper income range before 2020 + reference: + - title: Finance (No. 2) Act 2015 Schedule 4 paragraph 10 + href: https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml new file mode 100644 index 0000000000..e43437254c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -0,0 +1,320 @@ +# Finance Act 2004 s. 228ZA: both income limits must be exceeded, and the +# reduction is rounded down to the nearest whole pound. Pension contributions +# are gross amounts in the model. These are synthetic cases (no survey data). +# https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA + +- name: Adjusted and threshold income one pound below both limits + period: 2024 + input: + pension_annual_allowance_adjusted_income: 259_999 + pension_annual_allowance_threshold_income: 199_999 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income below limit and threshold income at limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 259_999 + pension_annual_allowance_threshold_income: 200_000 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income below limit and threshold income above limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 259_999 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income at limit and threshold income below limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_000 + pension_annual_allowance_threshold_income: 199_999 + output: + pension_annual_allowance: 60_000 + +- name: Both income limits equal their boundaries + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_000 + pension_annual_allowance_threshold_income: 200_000 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income at limit and threshold income above limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income one pound above limit and threshold below limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_001 + pension_annual_allowance_threshold_income: 199_999 + output: + pension_annual_allowance: 60_000 + +- name: Adjusted income one pound above limit and threshold at limit + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_001 + pension_annual_allowance_threshold_income: 200_000 + output: + pension_annual_allowance: 60_000 + +- name: Both incomes one pound above limits rounds half pound reduction down + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_001 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 60_000 + +- name: Threshold income one pound below limit blocks an observable taper + period: 2024 + input: + pension_annual_allowance_adjusted_income: 280_000 + pension_annual_allowance_threshold_income: 199_999 + output: + pension_annual_allowance: 60_000 + +- name: Threshold income at limit blocks an observable taper + period: 2024 + input: + pension_annual_allowance_adjusted_income: 280_000 + pension_annual_allowance_threshold_income: 200_000 + output: + pension_annual_allowance: 60_000 + +- name: Threshold income one pound above limit enables an observable taper + period: 2024 + input: + pension_annual_allowance_adjusted_income: 280_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 50_000 + +- name: Two pounds above adjusted limit reduces allowance by one pound + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_002 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 59_999 + +- name: Three pounds above adjusted limit rounds reduction down to one pound + period: 2024 + input: + pension_annual_allowance_adjusted_income: 260_003 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 59_999 + +- name: Fractional pounds round the reduction down rather than the allowance + period: 2024 + input: + pension_annual_allowance_adjusted_income: 280_001.5 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 50_000 + +- name: Employer contributions count in adjusted income + period: 2024 + input: + age: 45 + employment_income: 210_000 + employer_pension_contributions: 80_000 + output: + pension_annual_allowance_adjusted_income: 290_000 + pension_annual_allowance_threshold_income: 210_000 + pension_annual_allowance: 45_000 + +- name: Defined benefit pension input counts above the individuals own contributions + period: 2024 + input: + age: 45 + employment_income: 210_000 + employee_pension_contributions_reported: 10_000 + pension_contributions_for_annual_allowance: 100_000 + output: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 200_000 + pension_annual_allowance: 60_000 + +- name: Own net pay contributions cancel from adjusted income and lower threshold income + period: 2024 + input: + age: 45 + employment_income: 230_000 + employee_pension_contributions_reported: 10_000 + employer_pension_contributions: 70_000 + output: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 220_000 + pension_annual_allowance: 40_000 + +- name: Gross relief at source contributions lower threshold income to the gate + period: 2024 + input: + age: 45 + employment_income: 210_000 + personal_pension_contributions: 10_000 + employer_pension_contributions: 80_000 + output: + pension_annual_allowance_adjusted_income: 290_000 + pension_annual_allowance_threshold_income: 200_000 + pension_annual_allowance: 60_000 + +- name: Salary sacrifice raises threshold income and adjusted income + period: 2024 + input: + age: 45 + employment_income: 190_000 + employment_income_before_lsr: 190_000 + pension_contributions_via_salary_sacrifice: 30_000 + employer_pension_contributions: 80_000 + output: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 220_000 + pension_annual_allowance: 40_000 + +- name: Pre July 2015 salary sacrifice is excluded only from threshold income + period: 2024 + input: + age: 45 + employment_income: 190_000 + employment_income_before_lsr: 190_000 + pension_contributions_via_salary_sacrifice: 30_000 + pension_contributions_via_salary_sacrifice_pre_2015: 30_000 + employer_pension_contributions: 80_000 + output: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 190_000 + pension_annual_allowance: 60_000 + +- name: Grandfathered salary sacrifice cannot exceed actual sacrifice + period: 2024 + input: + age: 45 + employment_income: 210_000 + employment_income_before_lsr: 210_000 + pension_contributions_via_salary_sacrifice: 10_000 + pension_contributions_via_salary_sacrifice_pre_2015: 50_000 + employer_pension_contributions: 80_000 + output: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 210_000 + pension_annual_allowance: 40_000 + +- name: Gift Aid does not lower either annual allowance income measure + period: 2024 + input: + age: 45 + employment_income: 210_000 + employer_pension_contributions: 80_000 + gift_aid: 16_000 + output: + adjusted_net_income: 190_000 + pension_annual_allowance_adjusted_income: 290_000 + pension_annual_allowance_threshold_income: 210_000 + pension_annual_allowance: 45_000 + +- name: Taxable lump sum death benefits are deducted from both taper incomes + period: 2024 + input: + net_income: 250_000 + pension_annual_allowance_taxable_lump_sum_death_benefits: 60_000 + employer_pension_contributions: 100_000 + output: + pension_annual_allowance_adjusted_income: 290_000 + pension_annual_allowance_threshold_income: 190_000 + pension_annual_allowance: 60_000 + +# The taper was introduced for 2016-17 by Finance (No. 2) Act 2015 Sch. 4 para. 10, +# amended for 2020-21 by Finance Act 2020 s. 22 and for 2023-24 by +# Finance (No. 2) Act 2023 ss. 20 and 22. +- name: No annual allowance taper in 2015-16 + period: 2015 + input: + net_income: 1_000_000 + pension_annual_allowance_adjusted_income: 1_000_000 + pension_annual_allowance_threshold_income: 1_000_000 + output: + pension_annual_allowance: 40_000 + +- name: The 2016-17 taper starts at adjusted income of 150000 + period: 2016 + input: + pension_annual_allowance_adjusted_income: 170_000 + pension_annual_allowance_threshold_income: 110_001 + output: + pension_annual_allowance: 30_000 + +- name: The 2019-20 threshold gate remains 110000 + period: 2019 + input: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 110_000 + output: + pension_annual_allowance: 40_000 + +- name: The 2019-20 tapered minimum remains 10000 + period: 2019 + input: + pension_annual_allowance_adjusted_income: 300_000 + pension_annual_allowance_threshold_income: 110_001 + output: + pension_annual_allowance: 10_000 + +- name: The 2020-21 adjusted income limit rises to 240000 + period: 2020 + input: + pension_annual_allowance_adjusted_income: 260_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 30_000 + +- name: The 2020-21 threshold gate rises to 200000 + period: 2020 + input: + pension_annual_allowance_adjusted_income: 400_000 + pension_annual_allowance_threshold_income: 200_000 + output: + pension_annual_allowance: 40_000 + +- name: The 2022-23 tapered minimum remains 4000 + period: 2022 + input: + pension_annual_allowance_adjusted_income: 400_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 4_000 + +- name: The 2023-24 adjusted income limit rises to 260000 + period: 2023 + input: + pension_annual_allowance_adjusted_income: 280_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 50_000 + +- name: The 2023-24 tapered minimum rises to 10000 + period: 2023 + input: + pension_annual_allowance_adjusted_income: 400_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 10_000 + +- name: The 2026-27 taper retains the 2023-24 limits + period: 2026 + input: + pension_annual_allowance_adjusted_income: 280_000 + pension_annual_allowance_threshold_income: 200_001 + output: + pension_annual_allowance: 50_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/personal_pension_contributions_tax.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/personal_pension_contributions_tax.yaml index c1a88d2d2b..914ce021e4 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/personal_pension_contributions_tax.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/personal_pension_contributions_tax.yaml @@ -339,8 +339,8 @@ output: # FA 2004 s.228ZA: threshold income £300,000 > £200,000 and adjusted # income £400,000 > £260,000. £60,000 - (£400,000 - £260,000) / 2 - # reaches the £10,000 floor. The model's simplified taper gives the - # same floor here; this case does not validate both statutory tests. + # reaches the £10,000 floor. Boundary tests of both statutory income + # gates are in pension_annual_allowance.yaml. # https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/2024-04-06 pension_contributions_relief: 100_000 pension_annual_allowance: 10_000 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_taper.py b/policyengine_uk/tests/test_pension_annual_allowance_taper.py new file mode 100644 index 0000000000..8d5aa27390 --- /dev/null +++ b/policyengine_uk/tests/test_pension_annual_allowance_taper.py @@ -0,0 +1,156 @@ +"""MOCK population tests for the FA 2004 s. 228ZA annual allowance taper. + +The historical values come from Finance (No. 2) Act 2015 Sch. 4 para. 10, +Finance Act 2020 s. 22 and Finance (No. 2) Act 2023 ss. 20 and 22. The +threshold-income limit is the adjusted-income limit less the standard annual +allowance (s. 228ZA(3)(b)). All simulations use synthetic situations. + +Invariants: the allowance stays between the statutory minimum and standard +amount; it stays at the standard amount unless both gates are exceeded; and +above the threshold-income gate it never rises with adjusted income. Every +reduction is a whole number of pounds (s. 228ZA(2)). +""" + +import math + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + + +# (adjusted-income limit, threshold-income limit, minimum, standard allowance). +# These expected values are statutory fixtures, independent of model parameters. +PERIODS = { + **{year: (150_000, 110_000, 10_000, 40_000) for year in range(2016, 2020)}, + **{year: (240_000, 200_000, 4_000, 40_000) for year in range(2020, 2023)}, + **{year: (260_000, 200_000, 10_000, 60_000) for year in range(2023, 2028)}, +} +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +income_pair = st.tuples(st.integers(0, 1_000_000), st.integers(0, 500_000)) +population = st.lists(income_pair, min_size=4, max_size=10) + + +def _simulation(incomes, year): + situation = {"people": {}, "benunits": {}, "households": {}} + for index, (adjusted, threshold) in enumerate(incomes): + name = f"p{index}" + situation["people"][name] = { + "age": {year: 45}, + "pension_annual_allowance_adjusted_income": {year: adjusted}, + "pension_annual_allowance_threshold_income": {year: threshold}, + } + situation["benunits"][f"b{index}"] = {"members": [name]} + situation["households"][f"h{index}"] = {"members": [name]} + return Simulation(situation=situation) + + +@pytest.mark.parametrize("year,expected", PERIODS.items()) +def test_historical_taper_parameters(year, expected): + """Cover every tax year at both sides of each legislative change.""" + sim = _simulation([(0, 0)], year) + parameters = sim.tax_benefit_system.parameters( + year + ).gov.hmrc.income_tax.allowances.annual_allowance + adjusted, threshold, minimum, standard = expected + assert float(parameters.taper) == adjusted + assert float(parameters.default) == standard + assert float(parameters.taper - parameters.default) == threshold + assert float(parameters.minimum) == minimum + assert float(parameters.reduction_rate) == 0.5 + + +@PROPERTY_SETTINGS +@given(population, st.sampled_from(list(PERIODS))) +def test_allowance_bounds_and_statutory_two_gate_reference(incomes, year): + adjusted_limit, threshold_limit, minimum, standard = PERIODS[year] + sim = _simulation(incomes, year) + allowance = np.asarray(sim.calculate("pension_annual_allowance", year)) + assert np.all(allowance >= minimum) + assert np.all(allowance <= standard) + assert np.all(allowance == np.floor(allowance)) + + expected = [] + for adjusted, threshold in incomes: + if adjusted <= adjusted_limit or threshold <= threshold_limit: + expected.append(standard) + else: + reduction = math.floor((adjusted - adjusted_limit) / 2) + expected.append(max(minimum, standard - reduction)) + np.testing.assert_array_equal(allowance, expected) + + +@PROPERTY_SETTINGS +@given( + st.lists(st.integers(1, 1_000_000), min_size=4, max_size=12), + st.integers(1, 200_000), + st.sampled_from(list(PERIODS)), +) +def test_allowance_is_nonincreasing_in_adjusted_income_above_gate( + excesses, threshold_excess, year +): + adjusted_limit, threshold_limit, minimum, standard = PERIODS[year] + # Include the untapered boundary and a fully tapered endpoint so a + # constant output cannot satisfy the property merely by ignoring inputs. + adjusted_incomes = [adjusted_limit] + sorted( + adjusted_limit + excess for excess in excesses + ) + adjusted_incomes.append(adjusted_limit + 2 * (standard - minimum)) + adjusted_incomes.sort() + sim = _simulation( + [ + (adjusted, threshold_limit + threshold_excess) + for adjusted in adjusted_incomes + ], + year, + ) + allowance = np.asarray(sim.calculate("pension_annual_allowance", year)) + assert np.all(np.diff(allowance) <= 0) + assert allowance[0] == standard + assert allowance[-1] == minimum + + +@PROPERTY_SETTINGS +@given(population, st.sampled_from(list(PERIODS))) +def test_threshold_income_gate_preserves_standard_allowance(incomes, year): + adjusted_limit, threshold_limit, _, standard = PERIODS[year] + # Vary adjusted income above its limit while threshold income is at or + # below its own limit, including equality for every generated population. + cases = [(adjusted_limit + 100_000, threshold_limit)] + [ + (adjusted_limit + adjusted + 1, min(threshold, threshold_limit)) + for adjusted, threshold in incomes + ] + sim = _simulation(cases, year) + allowance = np.asarray(sim.calculate("pension_annual_allowance", year)) + np.testing.assert_array_equal(allowance, np.full(len(cases), standard)) + + +def test_pre_2020_allowance_reform_changes_multiplier_and_threshold_gate(): + """Original s. 228ZA uses (standard minus minimum) / 60,000.""" + year = 2018 + sim = _simulation([(156_000, 130_001), (156_000, 130_000)], year) + sim.apply_parameter_changes( + {"gov.hmrc.income_tax.allowances.annual_allowance.default": {"2018": 20_000}} + ) + allowance = sim.calculate("pension_annual_allowance", year) + # Gate = 150,000 - 20,000. Reduction = 6,000 * 10,000 / 60,000. + np.testing.assert_array_equal(allowance, [19_000, 20_000]) + + +def test_post_2020_allowance_reform_keeps_half_rate_and_changes_threshold_gate(): + """FA 2020 s. 22 fixes the multiplier at one half after 2019-20.""" + year = 2024 + sim = _simulation([(280_000, 220_001), (280_000, 220_000)], year) + sim.apply_parameter_changes( + {"gov.hmrc.income_tax.allowances.annual_allowance.default": {"2024": 40_000}} + ) + allowance = sim.calculate("pension_annual_allowance", year) + # Gate = 260,000 - 40,000. Reduction = (280,000 - 260,000) / 2. + np.testing.assert_array_equal(allowance, [30_000, 40_000]) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py index a939105d82..aa3cd8ecf3 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py @@ -1,15 +1,48 @@ from policyengine_uk.model_api import * +def _tapered_allowance(person, period, allowance, reduction_rate): + adjusted = person("pension_annual_allowance_adjusted_income", period) + threshold = person("pension_annual_allowance_threshold_income", period) + # s. 228ZA(3)(b) derives the threshold limit from the standard allowance. + high_income = (adjusted > allowance.taper) & ( + threshold > allowance.taper - allowance.default + ) + reduction = np.floor(max_(0, adjusted - allowance.taper) * reduction_rate) + return where( + high_income, + max_(allowance.minimum, allowance.default - reduction), + allowance.default, + ) + + class pension_annual_allowance(Variable): value_type = float entity = Person label = "Annual Allowance for pension contributions" + documentation = ( + "The standard annual allowance, tapered from 2016-17 only when both " + "the statutory adjusted-income and threshold-income limits are " + "exceeded. The reduction rounds down to whole pounds before applying " + "the tapered minimum. See docs/engineering/pension-annual-allowance.md " + "for income conventions and survey-data limitations." + ) definition_period = YEAR unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA" def formula(person, period, parameters): + return parameters( + period + ).gov.hmrc.income_tax.allowances.annual_allowance.default + + def formula_2016(person, period, parameters): + allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance + # Original s. 228ZA(1): (A - £10,000) / £60,000, equal to one half + # at the statutory £40,000 standard allowance. + rate = (allowance.default - allowance.minimum) / allowance.taper_income_range + return _tapered_allowance(person, period, allowance, rate) + + def formula_2020(person, period, parameters): allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - ANI = person("net_income", period) - reduction = max_(0, ANI - allowance.taper) * allowance.reduction_rate - return max_(allowance.minimum, allowance.default - reduction) + return _tapered_allowance(person, period, allowance, allowance.reduction_rate) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py new file mode 100644 index 0000000000..c8603cf750 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class pension_annual_allowance_adjusted_income(Variable): + value_type = float + entity = Person + label = "Adjusted income for the pension annual allowance" + documentation = ( + "FA 2004 s. 228ZA(4) adjusted income. Model net_income precedes pension " + "relief, so net-pay and claimed relief are already restored. Add the " + "total pension input amount less the individual's own contributions, " + "and exclude taxable lump-sum death benefits already included in " + "income. The pension input amount can be supplied to include statutory " + "defined-benefit accrual; its default is a cash-contribution proxy." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/4" + + def formula(person, period, parameters): + return ( + person("net_income", period) + + person("pension_contributions_for_annual_allowance", period) + - person("pension_contributions", period) + - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py new file mode 100644 index 0000000000..9e3d6910b9 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class pension_annual_allowance_threshold_income(Variable): + value_type = float + entity = Person + label = "Threshold income for the pension annual allowance" + documentation = ( + "FA 2004 s. 228ZA(5) threshold income. Model net_income precedes pension " + "relief: subtracting pension_contributions_relief combines the net-pay " + "deduction from statutory net income with the gross RAS deduction. " + "Add back actual salary sacrifice except the supplied amount from " + "arrangements before 9 July 2015. With no arrangement dates in survey " + "data, the default treats all sacrifice as on or after that date. " + "Exclude taxable lump-sum death benefits already included in income." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/5" + + def formula(person, period, parameters): + actual_sacrifice = person( + "pension_contributions_via_salary_sacrifice_adjusted", period + ) + grandfathered = max_( + 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) + ) + relevant_sacrifice = max_(0, actual_sacrifice - grandfathered) + return ( + person("net_income", period) + - person("pension_contributions_relief", period) + + relevant_sacrifice + - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 6a14f4784d..978646a999 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -10,7 +10,10 @@ class pension_contributions_for_annual_allowance(Variable): "employee, personal, employer, and salary sacrifice contributions. " "Per Finance Act 2004 s.233, the pension input amount for money " "purchase arrangements includes both individual contributions and " - "employer contributions." + "employer contributions. Supply the statutory total pension input " + "amount, including valued defined-benefit accrual, to override this " + "cash-contribution proxy. The same input feeds adjusted income for " + "the taper and the annual allowance charge." ) definition_period = YEAR reference = [ diff --git a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py new file mode 100644 index 0000000000..848a997852 --- /dev/null +++ b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class pension_contributions_via_salary_sacrifice_pre_2015(Variable): + value_type = float + entity = Person + label = "Pension salary sacrifice under arrangements before 9 July 2015" + documentation = ( + "The annual portion of actual pension salary sacrifice attributable " + "to arrangements made before 9 July 2015. Excluded from the annual " + "allowance threshold-income addback, capped at actual sacrifice. " + "Defaults to zero because survey arrangement dates are unobserved." + ) + definition_period = YEAR + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.average_earnings" + reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/6" diff --git a/policyengine_uk/variables/input/pension_annual_allowance_taxable_lump_sum_death_benefits.py b/policyengine_uk/variables/input/pension_annual_allowance_taxable_lump_sum_death_benefits.py new file mode 100644 index 0000000000..7a6cb8725e --- /dev/null +++ b/policyengine_uk/variables/input/pension_annual_allowance_taxable_lump_sum_death_benefits.py @@ -0,0 +1,17 @@ +from policyengine_uk.model_api import * + + +class pension_annual_allowance_taxable_lump_sum_death_benefits(Variable): + value_type = float + entity = Person + label = "Taxable lump-sum death benefits excluded from annual allowance incomes" + documentation = ( + "The taxable pension lump-sum death benefits already included in " + "net_income, excluded by FA 2004 s. 228ZA(4)(e) and (5)(d). " + "Supply the taxable benefit separately as income as well as here; " + "this input only removes it from the taper income tests. " + "Defaults to zero because survey data do not identify these benefits." + ) + definition_period = YEAR + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA" From 22794344eb1d365ee306ebac4f77201ed88ca062 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 08:32:21 -0400 Subject: [PATCH 2/9] Keep full pension salary sacrifice in statutory taper income --- changelog.d/2244.fixed.md | 2 +- docs/engineering/pension-annual-allowance.md | 15 +++- .../annual_allowance/reduction_rate.yaml | 6 +- .../pensions/pension_annual_allowance.yaml | 61 +++++++++++++++ .../test_pension_annual_allowance_taper.py | 77 ++++++++++++++++++- ...nsion_annual_allowance_threshold_income.py | 5 +- ...sion_contributions_for_annual_allowance.py | 2 + ...ributions_via_salary_sacrifice_pre_2015.py | 3 +- 8 files changed, 160 insertions(+), 11 deletions(-) diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index f88cd7c3be..3b4bd566dc 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -1 +1 @@ -- Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, salary sacrifice and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. +- Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, full salary sacrifice before the NICs exemption cap and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index d4c4aa4c5a..8813355fdd 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -50,8 +50,9 @@ Arrangements made on or after **9 July 2015** are relevant salary sacrifice under s. 228ZA(6). The default treats actual salary sacrifice as relevant because the survey does not record arrangement dates. Supply `pension_contributions_via_salary_sacrifice_pre_2015` for the part attributable -to earlier arrangements. Its exclusion is capped at actual salary sacrifice, -including under a reform that caps sacrifice. Flexible remuneration not +to earlier arrangements. Its exclusion is limited to full salary sacrifice +before the NICs exemption cap: that cap does not limit the s. 228ZA add-back. +Flexible remuneration not represented by these contribution inputs needs an explicit threshold-income override. @@ -64,6 +65,16 @@ valuation of DB rights. Survey estimates retain the existing cash proxy; this correction does not reconstruct DB rights, overseas adjustments, unused allowance histories or anti-avoidance arrangements under s. 228ZB. +[The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) +changes NICs treatment without changing s. 228ZA. From 2029, the model moves +sacrifice above the NICs exemption cap into +employee contributions and employment income. The pension input amount adds +only the remaining capped sacrifice to those employee contributions, so the +full amount is counted once. The threshold-income add-back uses the full +sacrifice before this reclassification. The separate employer-cost wage +response can still change income; it is held fixed when testing the cap's +statutory effect. + The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation conventions. Historical years use calendar-year proxies for the named tax diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml index 6ad4170303..28a7f5dc64 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/annual_allowance/reduction_rate.yaml @@ -1,13 +1,11 @@ -description: Reduction rate for the annual allowance. +description: Reduction rate for the annual allowance from 2020-21. values: - 2016-04-06: 0.5 + 2020-04-06: 0.5 metadata: unit: marginal-rate label: Annual allowance reduction rate reference: - title: Finance Act 2020 s. 22 href: https://www.legislation.gov.uk/ukpga/2020/14/section/22 - - title: Finance (No. 2) Act 2015 Schedule 4 paragraph 10 - href: https://www.legislation.gov.uk/ukpga/2015/33/schedule/4/paragraph/10 - title: Tax on your private pension contributions href: https://www.gov.uk/tax-on-your-private-pension/annual-allowance diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml index e43437254c..97a18699d3 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -170,6 +170,30 @@ pension_annual_allowance_threshold_income: 200_000 pension_annual_allowance: 60_000 +- name: Relief at source contributions below the threshold income gate prevent taper + period: 2024 + input: + age: 45 + employment_income: 270_000 + personal_pension_contributions: 80_000 + output: + pension_annual_allowance: 60_000 + +- name: Contributions without relief after age 75 do not lower either taper income + period: 2024 + input: + age: 80 + private_pension_income: 210_000 + personal_pension_contributions: 50_000 + employer_pension_contributions: 80_000 + output: + pension_contributions_relief: 0 + # Unrelieved own contributions cancel from adjusted income, but cannot + # reduce threshold income below its gate (210,000 - 50,000 = 160,000). + pension_annual_allowance_threshold_income: 210_000 + pension_annual_allowance_adjusted_income: 290_000 + pension_annual_allowance: 45_000 + - name: Salary sacrifice raises threshold income and adjusted income period: 2024 input: @@ -183,6 +207,27 @@ pension_annual_allowance_threshold_income: 220_000 pension_annual_allowance: 40_000 +# The NICs cap redirects 28,000 to employee contributions and the broad-base +# haircut reduces pay by 280. Threshold income still adds back all 30,000: +# 202,720 - 28,000 + 30,000 = 204,720. Pension input remains 130,000, +# adjusted income is 202,720 + 130,000 - 28,000 = 304,720, and the charge +# is (130,000 - 37,640) * 45% = 41,562. +- name: The 2029 NICs cap does not limit the salary sacrifice threshold income addback + period: 2029 + input: + age: 45 + employment_income_before_lsr: 175_000 + pension_contributions_via_salary_sacrifice: 30_000 + employer_pension_contributions: 100_000 + output: + employment_income: 202_720 + pension_contributions_relief: 28_000 + pension_contributions_for_annual_allowance: 130_000 + pension_annual_allowance_threshold_income: 204_720 + pension_annual_allowance_adjusted_income: 304_720 + pension_annual_allowance: 37_640 + personal_pension_contributions_tax: 41_562 + - name: Pre July 2015 salary sacrifice is excluded only from threshold income period: 2024 input: @@ -247,6 +292,22 @@ output: pension_annual_allowance: 40_000 +- name: Employment income alone cannot trigger a taper before 2016-17 + period: 2015 + input: + age: 45 + employment_income: 1_000_000 + output: + pension_annual_allowance: 40_000 + +- name: Employment income alone uses the 2018-19 taper limits + period: 2018 + input: + age: 45 + employment_income: 170_000 + output: + pension_annual_allowance: 30_000 + - name: The 2016-17 taper starts at adjusted income of 150000 period: 2016 input: diff --git a/policyengine_uk/tests/test_pension_annual_allowance_taper.py b/policyengine_uk/tests/test_pension_annual_allowance_taper.py index 8d5aa27390..48b5b433f2 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_taper.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_taper.py @@ -64,7 +64,11 @@ def test_historical_taper_parameters(year, expected): assert float(parameters.default) == standard assert float(parameters.taper - parameters.default) == threshold assert float(parameters.minimum) == minimum - assert float(parameters.reduction_rate) == 0.5 + if year < 2020: + rate = (parameters.default - parameters.minimum) / parameters.taper_income_range + else: + rate = parameters.reduction_rate + assert float(rate) == 0.5 @PROPERTY_SETTINGS @@ -154,3 +158,74 @@ def test_post_2020_allowance_reform_keeps_half_rate_and_changes_threshold_gate() allowance = sim.calculate("pension_annual_allowance", year) # Gate = 260,000 - 40,000. Reduction = (280,000 - 260,000) / 2. np.testing.assert_array_equal(allowance, [30_000, 40_000]) + + +@pytest.mark.parametrize("year", [2029, 2030]) +@pytest.mark.parametrize( + "variable", + [ + "pension_annual_allowance_threshold_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + ], +) +@settings( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given( + people=st.lists( + st.tuples( + st.integers(50_000, 400_000), + st.integers(2_001, 60_000), + st.integers(0, 150_000), + st.integers(0, 30_000), + st.integers(0, 60_000), + ), + min_size=4, + max_size=10, + ) +) +def test_nics_salary_sacrifice_cap_preserves_taper_and_charge(people, year, variable): + """The NICs cap changes neither FA 2004 income tests nor pension savings. + + Hold the separate employer-cost wage response at zero to isolate the + statutory NICs change. Build both taper incomes from contribution/pay + inputs, including a person whose threshold would cross the gate if the + sacrifice above the cap were incorrectly dropped. + """ + people = [(175_000, 30_000, 100_000, 0, 0)] + people + situation = {"people": {}, "benunits": {}, "households": {}} + for index, (pay, sacrifice, employer, employee, ras) in enumerate(people): + name = f"p{index}" + situation["people"][name] = { + key: {year: value} + for key, value in { + "age": 45, + "employment_income_before_lsr": pay, + "pension_contributions_via_salary_sacrifice": sacrifice, + "employer_pension_contributions": employer, + "employee_pension_contributions_reported": employee, + "personal_pension_contributions": ras, + }.items() + } + situation["benunits"][f"b{index}"] = {"members": [name]} + situation["households"][f"h{index}"] = {"members": [name]} + + results = [] + for cap in [2_000, float("inf")]: + sim = Simulation(situation=situation) + sim.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.append(np.asarray(sim.calculate(variable, year))) + np.testing.assert_allclose(results[0], results[1], rtol=0, atol=0.05) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index 9e3d6910b9..9b4133f39c 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -9,7 +9,8 @@ class pension_annual_allowance_threshold_income(Variable): "FA 2004 s. 228ZA(5) threshold income. Model net_income precedes pension " "relief: subtracting pension_contributions_relief combines the net-pay " "deduction from statutory net income with the gross RAS deduction. " - "Add back actual salary sacrifice except the supplied amount from " + "Add back full salary sacrifice before the NICs exemption cap, except " + "the supplied amount from " "arrangements before 9 July 2015. With no arrangement dates in survey " "data, the default treats all sacrifice as on or after that date. " "Exclude taxable lump-sum death benefits already included in income." @@ -20,7 +21,7 @@ class pension_annual_allowance_threshold_income(Variable): def formula(person, period, parameters): actual_sacrifice = person( - "pension_contributions_via_salary_sacrifice_adjusted", period + "pension_contributions_via_salary_sacrifice_from_pay", period ) grandfathered = max_( 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 978646a999..3671bc9e41 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -28,6 +28,8 @@ class pension_contributions_for_annual_allowance(Variable): ] unit = GBP + # Employee contributions already include sacrifice above the NICs cap. + # Adding the remaining capped sacrifice counts the full input once. adds = [ "employee_pension_contributions", "personal_pension_contributions", diff --git a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py index 848a997852..86046c2325 100644 --- a/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py +++ b/policyengine_uk/variables/input/consumption/property/pension_contributions_via_salary_sacrifice_pre_2015.py @@ -8,7 +8,8 @@ class pension_contributions_via_salary_sacrifice_pre_2015(Variable): documentation = ( "The annual portion of actual pension salary sacrifice attributable " "to arrangements made before 9 July 2015. Excluded from the annual " - "allowance threshold-income addback, capped at actual sacrifice. " + "allowance threshold-income addback, limited to full sacrifice before " + "the NICs exemption cap. " "Defaults to zero because survey arrangement dates are unobserved." ) definition_period = YEAR From f303fbfd50b3568f6b2d17b5fbd961eb66f29dcb Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 09:52:56 -0400 Subject: [PATCH 3/9] Isolate pension cap properties from personal allowance effects --- .../tests/test_pension_annual_allowance_taper.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_pension_annual_allowance_taper.py b/policyengine_uk/tests/test_pension_annual_allowance_taper.py index 48b5b433f2..5e545bcfc2 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_taper.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_taper.py @@ -178,7 +178,9 @@ def test_post_2020_allowance_reform_keeps_half_rate_and_changes_threshold_gate() @given( people=st.lists( st.tuples( - st.integers(50_000, 400_000), + # Fully relieved own contributions and high cash pay keep other + # reliefs stable across the two NICs cap settings. + st.integers(160_000, 400_000), st.integers(2_001, 60_000), st.integers(0, 150_000), st.integers(0, 30_000), @@ -192,7 +194,9 @@ def test_nics_salary_sacrifice_cap_preserves_taper_and_charge(people, year, vari """The NICs cap changes neither FA 2004 income tests nor pension savings. Hold the separate employer-cost wage response at zero to isolate the - statutory NICs change. Build both taper incomes from contribution/pay + statutory NICs change. Own contributions are within earnings; high cash + pay keeps the personal allowance stable across cap settings. Build both + taper incomes from contribution/pay inputs, including a person whose threshold would cross the gate if the sacrifice above the cap were incorrectly dropped. """ From c38168a3f46016224e2eb11bbe46b46f941d77c4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 11:48:07 -0400 Subject: [PATCH 4/9] Exclude NICs salary sacrifice reclassification from taper incomes --- changelog.d/2244.fixed.md | 1 + .../tests/test_statutory_pension_edges.py | 114 ++++++++++++++++++ policyengine_uk/utils/pensions.py | 24 ++++ ...ension_annual_allowance_adjusted_income.py | 12 +- ...nsion_annual_allowance_threshold_income.py | 40 ++++-- .../pensions/pension_contributions_relief.py | 21 +--- 6 files changed, 186 insertions(+), 26 deletions(-) create mode 100644 policyengine_uk/tests/test_statutory_pension_edges.py create mode 100644 policyengine_uk/utils/pensions.py diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index 3b4bd566dc..030667d113 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -1 +1,2 @@ - Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, full salary sacrifice before the NICs exemption cap and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. +- Removed the NICs-only salary-sacrifice reclassification from the taper income tests, including post-75 and basic relief-at-source cases. diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py new file mode 100644 index 0000000000..daeed0176e --- /dev/null +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -0,0 +1,114 @@ +"""Synthetic regressions for FA 2004 ss. 188, 190, 228ZA and 233. + +The NICs-only salary-sacrifice cap must preserve annual-allowance inputs, +including after age 75 and at the basic RAS relief boundary. Charge +comparisons either keep ordinary tax bands stable or hold ordinary tax +inputs fixed; unrestricted charge invariance is not asserted. +""" + +import numpy as np + +import pytest + +from hypothesis import HealthCheck, given, settings + +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + + +def make_sim(year, **values): + s = Simulation( + situation={ + "people": {"p": {k: {year: v} for k, v in values.items()}}, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + ) + s.apply_parameter_changes( + { + "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate": { + str(year): 0 + } + } + ) + return s + + +@pytest.mark.parametrize("year", [2029, 2030]) +@pytest.mark.parametrize("scheme", ["DC", "DB"]) +def test_nics_cap_does_not_create_taper_after_age75(year, scheme): + values = dict( + age=80, + employment_income_before_lsr=160000, + pension_contributions_via_salary_sacrifice=30000, + employer_pension_contributions=100000, + ) + if scheme == "DB": + values["pension_contributions_for_annual_allowance"] = 150000 + s = make_sim(year, **values) + actual = { + v: float(s.calculate(v, year)[0]) + for v in [ + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + ] + } + # The full 30,000 sacrifice raises 160,000 cash pay to threshold 190,000. + # The 28,000 returned for NICs remains employer pension provision in law. + assert actual["pension_annual_allowance_threshold_income"] == 190000 + assert actual["pension_annual_allowance"] == 60000 + assert actual["personal_pension_contributions_tax"] == ( + 31500 if scheme == "DC" else 40500 + ) + + +def _population_simulation(people, year, cap=2_000): + situation = {"people": {}, "benunits": {}, "households": {}} + for index, values in enumerate(people): + name = f"p{index}" + situation["people"][name] = { + key: {year: value} for key, value in values.items() + } + situation["benunits"][f"b{index}"] = {"members": [name]} + situation["households"][f"h{index}"] = {"members": [name]} + sim = Simulation(situation=situation) + sim.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 + }, + } + ) + return sim + + +@pytest.mark.parametrize("year", [2029, 2030]) +def test_nics_cap_preserves_basic_ras_threshold_income_boundary(year): + """Sections 190(2)-(4) and 191(7) preserve the full basic RAS amount.""" + values = { + "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, + } + for cap in [2_000, float("inf")]: + sim = _population_simulation([values], year, cap) + # Cash pay plus full sacrifice less the full basic RAS relief. + expected = { + "pension_contributions_for_annual_allowance": 304_600, + "pension_annual_allowance_threshold_income": 199_400, + "pension_annual_allowance_adjusted_income": 303_000, + "pension_annual_allowance": 60_000, + } + for variable, amount in expected.items(): + assert float(sim.calculate(variable, year)[0]) == amount, (cap, variable) diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py new file mode 100644 index 0000000000..66308abdc9 --- /dev/null +++ b/policyengine_uk/utils/pensions.py @@ -0,0 +1,24 @@ +import numpy as np + + +def pension_contributions_relief_amount( + contributions, relevant_uk_earnings, ras_contributions, age, hmrc +): + """FA 2004 ss. 188–191 relief for the supplied actual pay/contributions. + + Only RAS can use the basic-amount increase above earnings (s. 191(7)). + The annual allowance does not cap relief, and payments from age 75 are + unrelievable. Keeping this calculation shared lets the AA income tests + exclude the NICs-only salary-sacrifice reclassification exactly. + """ + basic_amount_increase = np.minimum( + np.maximum( + 0, + hmrc.income_tax.reliefs.pension_contribution.basic_amount + - relevant_uk_earnings, + ), + ras_contributions, + ) + annual_limit = relevant_uk_earnings + basic_amount_increase + under_age_limit = age < hmrc.pensions.pension_contributions_relief_age_limit + return np.minimum(contributions, annual_limit) * under_age_limit diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py index c8603cf750..428d467d56 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -11,16 +11,22 @@ class pension_annual_allowance_adjusted_income(Variable): "total pension input amount less the individual's own contributions, " "and exclude taxable lump-sum death benefits already included in " "income. The pension input amount can be supplied to include statutory " - "defined-benefit accrual; its default is a cash-contribution proxy." + "defined-benefit accrual; its default is a cash-contribution proxy. " + "The NICs-only returned salary sacrifice is removed from both model " + "income and member contributions. All actual own contributions are " + "subtracted, including unrelievable contributions from age 75." ) definition_period = YEAR unit = GBP reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/4" def formula(person, period, parameters): + returned = person("salary_sacrifice_returned_to_income", period) + actual_income = person("net_income", period) - returned + actual_member_contributions = person("pension_contributions", period) - returned return ( - person("net_income", period) + actual_income + person("pension_contributions_for_annual_allowance", period) - - person("pension_contributions", period) + - actual_member_contributions - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index 9b4133f39c..d58cfa132a 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import pension_contributions_relief_amount class pension_annual_allowance_threshold_income(Variable): @@ -6,13 +7,14 @@ class pension_annual_allowance_threshold_income(Variable): entity = Person label = "Threshold income for the pension annual allowance" documentation = ( - "FA 2004 s. 228ZA(5) threshold income. Model net_income precedes pension " - "relief: subtracting pension_contributions_relief combines the net-pay " - "deduction from statutory net income with the gross RAS deduction. " - "Add back full salary sacrifice before the NICs exemption cap, except " - "the supplied amount from " - "arrangements before 9 July 2015. With no arrangement dates in survey " - "data, the default treats all sacrifice as on or after that date. " + "FA 2004 s. 228ZA(5) threshold income. Remove the NICs-only returned " + "salary sacrifice from model income and recompute pension relief on " + "actual member contributions and relevant earnings. This combines " + "the net-pay deduction from statutory net income with gross RAS " + "relief, including the basic amount and age limit. Add back full " + "salary sacrifice before the NICs cap, except the supplied amount " + "from arrangements before 9 July 2015. With no arrangement dates in " + "survey data, all sacrifice defaults to on or after that date. " "Exclude taxable lump-sum death benefits already included in income." ) definition_period = YEAR @@ -20,6 +22,7 @@ class pension_annual_allowance_threshold_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/5" def formula(person, period, parameters): + returned = person("salary_sacrifice_returned_to_income", period) actual_sacrifice = person( "pension_contributions_via_salary_sacrifice_from_pay", period ) @@ -27,9 +30,30 @@ def formula(person, period, parameters): 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) ) relevant_sacrifice = max_(0, actual_sacrifice - grandfathered) + # NICs reclassification changes neither income-tax earnings nor the + # actual member contributions. Its relief need not equal the returned + # amount: after 75 there is none, and low earnings can use basic RAS. + actual_member_contributions = person("pension_contributions", period) - returned + actual_earnings = ( + person("employment_income", period) + - returned + + max_(0, person("self_employment_income", period)) + ) + relief = where( + returned > 0, + pension_contributions_relief_amount( + actual_member_contributions, + actual_earnings, + person("personal_pension_contributions", period), + person("age", period), + parameters(period).gov.hmrc, + ), + person("pension_contributions_relief", period), + ) return ( person("net_income", period) - - person("pension_contributions_relief", period) + - returned + - relief + relevant_sacrifice - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py index ed04e338df..8fdf23d848 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import pension_contributions_relief_amount class pension_contributions_relief(Variable): @@ -47,20 +48,10 @@ def formula(person, period, parameters): relevant_uk_earnings = person("employment_income", period) + max_( 0, person("self_employment_income", period) ) - # s. 190(1)-(2): relief up to relevant UK earnings, raised to the basic - # amount where earnings are lower. s. 191(7): only relief at source - # (personal pension contributions here) can give that increase. - basic_amount_increase = min_( - max_( - 0, - hmrc.income_tax.reliefs.pension_contribution.basic_amount - - relevant_uk_earnings, - ), + return pension_contributions_relief_amount( + contributions, + relevant_uk_earnings, person("personal_pension_contributions", period), + person("age", period), + hmrc, ) - annual_limit = relevant_uk_earnings + basic_amount_increase - # Contributions paid after age 75 are not relievable (s. 188(3)(a)). - under_age_limit = ( - person("age", period) < hmrc.pensions.pension_contributions_relief_age_limit - ) - return min_(contributions, annual_limit) * under_age_limit From ae2fe59e033699b3e042b2b2dfdacacbe52b4495 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 11:50:06 -0400 Subject: [PATCH 5/9] Exclude post-75 member payments from default DC pension input --- changelog.d/2244.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 28 ++- .../pensions/pension_annual_allowance.yaml | 40 +++- .../test_pension_annual_allowance_taper.py | 75 ------- .../tests/test_statutory_pension_edges.py | 192 +++++++++++++++++- ...sion_contributions_for_annual_allowance.py | 50 +++-- 6 files changed, 281 insertions(+), 105 deletions(-) diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index 030667d113..a5ce06d5b0 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -1,2 +1,3 @@ - Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, full salary sacrifice before the NICs exemption cap and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. - Removed the NICs-only salary-sacrifice reclassification from the taper income tests, including post-75 and basic relief-at-source cases. +- Excluded member contributions made at 75 or over from the default DC pension input while retaining all employer contributions and salary sacrifice. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 8813355fdd..2ef38978cc 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -32,7 +32,7 @@ Accordingly: This restores net-pay relief once and includes employer contributions and salary sacrifice. RAS contributions do not reduce adjusted income. - `pension_annual_allowance_threshold_income` subtracts - `pension_contributions_relief` from `net_income`, then adds back relevant + pension relief from `net_income`, then adds back relevant salary sacrifice. The relief deduction combines the net-pay deduction already present in statutory Step 2 income and the gross RAS deduction under s. 228ZA(5)(c). @@ -57,8 +57,15 @@ represented by these contribution inputs needs an explicit threshold-income override. `pension_contributions_for_annual_allowance` is the shared pension input amount -used by both the taper and the charge. It defaults to employee, personal, -employer and actual salary-sacrifice contributions. For defined-benefit +used by both the taper and the charge. The default DC input includes member +contributions made before age 75, employer contributions and full actual +salary sacrifice. Under [s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233) +and [s. 188(3)(a)](https://www.legislation.gov.uk/ukpga/2004/12/section/188), +member contributions made at 75 or over are excluded; employer contributions +still count. Contributions before 75 that exceed the earnings relief limit +still count. Adjusted income subtracts all actual member contributions under +s. 228ZA(4)(d)(ii), including those excluded from the post-75 DC input. +For defined-benefit arrangements, supply the statutory total pension input amount, including valued DB accrual, through this variable. Cash employer funding is not a valuation of DB rights. Survey estimates retain the existing cash proxy; @@ -68,13 +75,22 @@ allowance histories or anti-avoidance arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) changes NICs treatment without changing s. 228ZA. From 2029, the model moves sacrifice above the NICs exemption cap into -employee contributions and employment income. The pension input amount adds -only the remaining capped sacrifice to those employee contributions, so the -full amount is counted once. The threshold-income add-back uses the full +employee contributions and employment income. The AA calculations remove +that synthetic return from both pay and member contributions. Threshold +income recomputes relief on actual member contributions and actual relevant +earnings, preserving the basic RAS amount even when cash earnings are below +£3,600 and giving no member relief from age 75. The DC input keeps the full +employer sacrifice at every age. The threshold-income add-back uses the full sacrifice before this reclassification. The separate employer-cost wage response can still change income; it is held fixed when testing the cap's statutory effect. +Cap invariance covers the two taper incomes, pension input and allowance. +Charge comparisons additionally hold ordinary income-tax inputs fixed: the +separate ANI and personal-allowance interaction can change the marginal tax +charged on the same excess. The AA calculations do not repair that ordinary +income-tax reclassification. + The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation conventions. Historical years use calendar-year proxies for the named tax diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml index 97a18699d3..1630f917da 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -179,7 +179,7 @@ output: pension_annual_allowance: 60_000 -- name: Contributions without relief after age 75 do not lower either taper income +- name: Member contributions after age 75 are excluded from the DC input amount period: 2024 input: age: 80 @@ -188,11 +188,45 @@ employer_pension_contributions: 80_000 output: pension_contributions_relief: 0 - # Unrelieved own contributions cancel from adjusted income, but cannot - # reduce threshold income below its gate (210,000 - 50,000 = 160,000). + pension_contributions_for_annual_allowance: 80_000 + # Section 233 excludes unrelievable member contributions from the input + # amount. Section 228ZA(4)(d)(ii) still deducts those own contributions. pension_annual_allowance_threshold_income: 210_000 + pension_annual_allowance_adjusted_income: 240_000 + pension_annual_allowance: 60_000 + personal_pension_contributions_tax: 9_000 + +- name: Member contributions stop counting in the DC input at age 75 + period: 2024 + input: + age: [74, 75] + private_pension_income: [210_000, 210_000] + personal_pension_contributions: [50_000, 50_000] + employer_pension_contributions: [80_000, 80_000] + output: + pension_contributions_relief: [3_600, 0] + pension_contributions_for_annual_allowance: [130_000, 80_000] + pension_annual_allowance_threshold_income: [206_400, 210_000] + pension_annual_allowance_adjusted_income: [290_000, 240_000] + pension_annual_allowance: [45_000, 60_000] + personal_pension_contributions_tax: [38_250, 9_000] + +- name: Threshold income deducts relieved RAS rather than all member contributions + period: 2024 + input: + age: 45 + private_pension_income: 210_000 + personal_pension_contributions: 50_000 + employer_pension_contributions: 80_000 + output: + # With no relevant earnings only the basic RAS amount receives relief. + # Deducting all 50,000 instead would incorrectly close the threshold gate. + pension_contributions_relief: 3_600 + pension_contributions_for_annual_allowance: 130_000 + pension_annual_allowance_threshold_income: 206_400 pension_annual_allowance_adjusted_income: 290_000 pension_annual_allowance: 45_000 + personal_pension_contributions_tax: 38_250 - name: Salary sacrifice raises threshold income and adjusted income period: 2024 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_taper.py b/policyengine_uk/tests/test_pension_annual_allowance_taper.py index 5e545bcfc2..31a50391bc 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_taper.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_taper.py @@ -158,78 +158,3 @@ def test_post_2020_allowance_reform_keeps_half_rate_and_changes_threshold_gate() allowance = sim.calculate("pension_annual_allowance", year) # Gate = 260,000 - 40,000. Reduction = (280,000 - 260,000) / 2. np.testing.assert_array_equal(allowance, [30_000, 40_000]) - - -@pytest.mark.parametrize("year", [2029, 2030]) -@pytest.mark.parametrize( - "variable", - [ - "pension_annual_allowance_threshold_income", - "pension_annual_allowance", - "personal_pension_contributions_tax", - ], -) -@settings( - max_examples=5, - deadline=None, - derandomize=True, - suppress_health_check=[HealthCheck.too_slow], -) -@given( - people=st.lists( - st.tuples( - # Fully relieved own contributions and high cash pay keep other - # reliefs stable across the two NICs cap settings. - st.integers(160_000, 400_000), - st.integers(2_001, 60_000), - st.integers(0, 150_000), - st.integers(0, 30_000), - st.integers(0, 60_000), - ), - min_size=4, - max_size=10, - ) -) -def test_nics_salary_sacrifice_cap_preserves_taper_and_charge(people, year, variable): - """The NICs cap changes neither FA 2004 income tests nor pension savings. - - Hold the separate employer-cost wage response at zero to isolate the - statutory NICs change. Own contributions are within earnings; high cash - pay keeps the personal allowance stable across cap settings. Build both - taper incomes from contribution/pay - inputs, including a person whose threshold would cross the gate if the - sacrifice above the cap were incorrectly dropped. - """ - people = [(175_000, 30_000, 100_000, 0, 0)] + people - situation = {"people": {}, "benunits": {}, "households": {}} - for index, (pay, sacrifice, employer, employee, ras) in enumerate(people): - name = f"p{index}" - situation["people"][name] = { - key: {year: value} - for key, value in { - "age": 45, - "employment_income_before_lsr": pay, - "pension_contributions_via_salary_sacrifice": sacrifice, - "employer_pension_contributions": employer, - "employee_pension_contributions_reported": employee, - "personal_pension_contributions": ras, - }.items() - } - situation["benunits"][f"b{index}"] = {"members": [name]} - situation["households"][f"h{index}"] = {"members": [name]} - - results = [] - for cap in [2_000, float("inf")]: - sim = Simulation(situation=situation) - sim.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.append(np.asarray(sim.calculate(variable, year))) - np.testing.assert_allclose(results[0], results[1], rtol=0, atol=0.05) diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py index daeed0176e..f9700796ac 100644 --- a/policyengine_uk/tests/test_statutory_pension_edges.py +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -7,11 +7,8 @@ """ import numpy as np - import pytest - from hypothesis import HealthCheck, given, settings - from hypothesis import strategies as st from policyengine_uk import Simulation @@ -68,6 +65,39 @@ def test_nics_cap_does_not_create_taper_after_age75(year, scheme): ) +def test_post75_dc_member_contributions_are_excluded_from_input_amount(): + s = make_sim( + 2024, + age=80, + private_pension_income=210000, + personal_pension_contributions=50000, + employer_pension_contributions=80000, + ) + actual = { + v: float(s.calculate(v, 2024)[0]) + for v in [ + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + ] + } + assert actual["pension_contributions_for_annual_allowance"] == 80000 + assert actual["pension_annual_allowance_threshold_income"] == 210000 + assert actual["pension_annual_allowance_adjusted_income"] == 240000 + assert actual["pension_annual_allowance"] == 60000 + assert actual["personal_pension_contributions_tax"] == 9000 + + +AA_VARIABLES = [ + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance", +] + + def _population_simulation(people, year, cap=2_000): situation = {"people": {}, "benunits": {}, "households": {}} for index, values in enumerate(people): @@ -112,3 +142,159 @@ def test_nics_cap_preserves_basic_ras_threshold_income_boundary(year): } for variable, amount in expected.items(): assert float(sim.calculate(variable, year)[0]) == amount, (cap, variable) + + +@pytest.mark.parametrize("year", [2029, 2030]) +@pytest.mark.parametrize("scheme", ["DC", "DB"]) +@pytest.mark.parametrize("method", ["net_pay", "RAS"]) +@pytest.mark.parametrize("age", [45, 80]) +def test_nics_cap_preserves_aa_across_scheme_method_and_age(year, scheme, method, age): + """The review's 16 cases retain the same top rate on the whole excess. + + This charge comparison has stable ordinary tax bands: both cash pay and + pay after net-pay relief exceed the additional-rate threshold at either + cap setting. General charge invariance would be false because the cap's + separate ordinary-income/ANI behavior can change the personal allowance. + """ + values = { + "age": age, + "employment_income_before_lsr": 160_000, + "pension_contributions_via_salary_sacrifice": 30_000, + "employer_pension_contributions": 100_000, + ( + "employee_pension_contributions_reported" + if method == "net_pay" + else "personal_pension_contributions" + ): 10_000, + } + if scheme == "DB": + values["pension_contributions_for_annual_allowance"] = 150_000 + pension_input = 150_000 if scheme == "DB" else (140_000 if age < 75 else 130_000) + expected = { + "pension_contributions_for_annual_allowance": pension_input, + "pension_annual_allowance_threshold_income": 180_000 if age < 75 else 190_000, + # All own member contributions remain deductible from adjusted income, + # including the post-75 contributions excluded from the DC input. + "pension_annual_allowance_adjusted_income": 160_000 + pension_input - 10_000, + "pension_annual_allowance": 60_000, + "personal_pension_contributions_tax": (pension_input - 60_000) * 0.45, + } + for cap in [2_000, float("inf")]: + sim = _population_simulation([values], year, cap) + for variable, amount in expected.items(): + assert float(sim.calculate(variable, year)[0]) == pytest.approx( + amount, abs=0.01 + ), (cap, variable) + + +@st.composite +def _pension_person(draw): + method = draw(st.sampled_from(["net_pay", "RAS"])) + values = { + "age": draw(st.sampled_from([45, 74, 75, 80, 90])), + "employment_income_before_lsr": draw( + st.one_of(st.integers(0, 3_600), st.integers(3_601, 400_000)) + ), + "pension_contributions_via_salary_sacrifice": draw(st.integers(0, 300_000)), + "employer_pension_contributions": draw(st.integers(0, 200_000)), + ( + "employee_pension_contributions_reported" + if method == "net_pay" + else "personal_pension_contributions" + ): draw(st.integers(0, 100_000)), + } + if draw(st.booleans()): + values["pension_contributions_for_annual_allowance"] = draw( + st.integers(0, 400_000) + ) + return values + + +def _charge_with_fixed_ordinary_tax_inputs(results, year): + """Test the AA inputs to the charge separately from ordinary income tax. + + A fixed additional-rate base, no RAS and no allowances keep the charge + independent of the cap's separate effects on ANI/personal allowances. + The taper incomes and pension input above are always calculated from + the original pay/contribution inputs, without these overrides. + """ + people = [ + { + "age": 45, + "net_income": 400_000, + "allowances": 0, + "personal_pension_contributions": 0, + "pension_contributions_relief": 0, + "pension_contributions_for_annual_allowance": float(pension_input), + "pension_annual_allowance": float(allowance), + } + for pension_input, allowance in zip( + results["pension_contributions_for_annual_allowance"], + results["pension_annual_allowance"], + ) + ] + sim = _population_simulation(people, year) + charge = np.asarray(sim.calculate("personal_pension_contributions_tax", year)) + excess = np.maximum( + 0, + results["pension_contributions_for_annual_allowance"] + - results["pension_annual_allowance"], + ) + np.testing.assert_allclose(charge, excess * 0.45, rtol=0, atol=0.05) + return charge + + +@pytest.mark.parametrize("year", [2029, 2030]) +@settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(people=st.lists(_pension_person(), min_size=4, max_size=10)) +def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(people, year): + """All ages/earnings preserve AA computations when the NICs cap varies. + + The charge comparison fixes ordinary tax inputs. It makes no unrestricted + claim about the charge, ANI, personal allowances or ordinary income tax. + """ + # Always exercise each known statutory failure, alongside generated DB/DC, + # contribution methods, low earnings and people aged 75 and over. + people = [ + { + "age": 80, + "employment_income_before_lsr": 160_000, + "pension_contributions_via_salary_sacrifice": 30_000, + "employer_pension_contributions": 100_000, + }, + { + "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, + }, + ] + people + results = [] + for cap in [2_000, float("inf")]: + sim = _population_simulation(people, year, cap) + results.append( + { + variable: np.asarray(sim.calculate(variable, year)) + for variable in AA_VARIABLES + } + ) + for variable in AA_VARIABLES: + np.testing.assert_allclose( + results[0][variable], + results[1][variable], + rtol=0, + atol=0.05, + err_msg=variable, + ) + np.testing.assert_allclose( + _charge_with_fixed_ordinary_tax_inputs(results[0], year), + _charge_with_fixed_ordinary_tax_inputs(results[1], year), + rtol=0, + atol=0.05, + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 3671bc9e41..9463d75e7f 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -6,14 +6,14 @@ class pension_contributions_for_annual_allowance(Variable): entity = Person label = "Total pension contributions counting toward the Annual Allowance" documentation = ( - "The pension input amount for Annual Allowance purposes, including " - "employee, personal, employer, and salary sacrifice contributions. " - "Per Finance Act 2004 s.233, the pension input amount for money " - "purchase arrangements includes both individual contributions and " - "employer contributions. Supply the statutory total pension input " - "amount, including valued defined-benefit accrual, to override this " - "cash-contribution proxy. The same input feeds adjusted income for " - "the taper and the annual allowance charge." + "The default DC pension input amount under FA 2004 s. 233: member " + "contributions made before age 75, employer contributions and full " + "salary sacrifice. Member contributions from age 75 are unrelievable " + "under s. 188(3)(a) and excluded, while employer contributions still " + "count. Contributions before 75 above the earnings relief limit " + "still count. Supply the statutory total pension input amount, " + "including valued defined-benefit accrual, to override this cash " + "proxy. The same input feeds adjusted income and the AA charge." ) definition_period = YEAR reference = [ @@ -22,17 +22,31 @@ class pension_contributions_for_annual_allowance(Variable): href="https://www.legislation.gov.uk/ukpga/2004/12/section/233", ), dict( - title="HMRC: Annual Allowance", - href="https://www.gov.uk/tax-on-your-private-pension/annual-allowance", + title="Finance Act 2004 s. 188(3)(a)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), + dict( + title="HMRC PTM053200: other money purchase arrangements", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm053200", ), ] unit = GBP - # Employee contributions already include sacrifice above the NICs cap. - # Adding the remaining capped sacrifice counts the full input once. - adds = [ - "employee_pension_contributions", - "personal_pension_contributions", - "employer_pension_contributions", - "pension_contributions_via_salary_sacrifice_adjusted", - ] + def formula(person, period, parameters): + # The returned sacrifice is still an employer payment in income-tax + # law. Remove it from member contributions before the age exclusion, + # then include the full employer sacrifice once at every age. + member_contributions = person("pension_contributions", period) - person( + "salary_sacrifice_returned_to_income", period + ) + under_age_limit = ( + person("age", period) + < parameters( + period + ).gov.hmrc.pensions.pension_contributions_relief_age_limit + ) + return ( + member_contributions * under_age_limit + + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) From ce4f721a02dc22eb87cd09920c907983a699dafd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 17:44:28 -0400 Subject: [PATCH 6/9] Recompute annual allowance trading-loss relief on actual income --- changelog.d/2244.fixed.md | 1 + ...nsion_annual_allowance_trading_losses.yaml | 42 +++++++++++ policyengine_uk/utils/pensions.py | 72 +++++++++++++++++++ ...ension_annual_allowance_adjusted_income.py | 3 +- ...nsion_annual_allowance_threshold_income.py | 10 +-- 5 files changed, 123 insertions(+), 5 deletions(-) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index a5ce06d5b0..451dfaab84 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -1,3 +1,4 @@ - Corrected the pension annual allowance taper to use separate threshold and adjusted income tests, including employer pension inputs, full salary sacrifice before the NICs exemption cap and gross pension relief. Applied whole-pound rounding and the statutory limits from 2016–17 onwards. - Removed the NICs-only salary-sacrifice reclassification from the taper income tests, including post-75 and basic relief-at-source cases. - Excluded member contributions made at 75 or over from the default DC pension input while retaining all employer contributions and salary sacrifice. +- Recomputed income-dependent trading-loss relief from actual income for both annual allowance income tests, including the general-income and relief-cap limits. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml new file mode 100644 index 0000000000..ffd89b69d5 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml @@ -0,0 +1,42 @@ +# FA 2004 s.228ZA and ITA 2007 ss.24A, 25 and 64: trading losses +# must be limited using actual income, before the NICs-only reclassification. +# https://www.legislation.gov.uk/ukpga/2007/3/section/24A +# https://www.legislation.gov.uk/ukpga/2007/3/section/64 + +- name: Trading loss limits for AA use actual income in 2029 + period: 2029 + absolute_error_margin: 0.05 + input: + # Hold the separate employer-cost wage response fixed. + salary_sacrifice_broad_base_haircut: [0, 0, 0, 0, 0, 0, 0] + age: [45, 80, 80, 45, 45, 80, 80] + employment_income_before_lsr: [2_000, 250_000, 230_000, 2_000, 2_000, 2_000, 2_000] + pension_contributions_via_salary_sacrifice: [201_000, 30_000, 30_000, 201_000, 201_000, 201_000, 201_000] + employer_pension_contributions: [100_000, 100_000, 100_000, 100_000, 100_000, 100_000, 100_000] + trading_loss: [10_000, 200_000, 300_000, 10_000, 10_000, 10_000, 10_000] + employee_pension_contributions_reported: [0, 0, 0, 600, 0, 600, 0] + personal_pension_contributions: [0, 0, 0, 0, 600, 0, 600] + output: + pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 200_400, 200_400, 201_000, 201_000] + pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_000, 301_000, 300_400, 300_400] + pension_contributions_for_annual_allowance: [301_000, 130_000, 130_000, 301_600, 301_600, 301_000, 301_000] + pension_annual_allowance: [39_500, 31_250, 38_750, 39_500, 39_500, 39_800, 39_800] + +- name: Trading loss limits for AA use actual income in 2030 + period: 2030 + absolute_error_margin: 0.05 + input: + # Hold the separate employer-cost wage response fixed. + salary_sacrifice_broad_base_haircut: [0, 0, 0, 0, 0, 0, 0] + age: [45, 80, 80, 45, 45, 80, 80] + employment_income_before_lsr: [2_000, 250_000, 230_000, 2_000, 2_000, 2_000, 2_000] + pension_contributions_via_salary_sacrifice: [201_000, 30_000, 30_000, 201_000, 201_000, 201_000, 201_000] + employer_pension_contributions: [100_000, 100_000, 100_000, 100_000, 100_000, 100_000, 100_000] + trading_loss: [10_000, 200_000, 300_000, 10_000, 10_000, 10_000, 10_000] + employee_pension_contributions_reported: [0, 0, 0, 600, 0, 600, 0] + personal_pension_contributions: [0, 0, 0, 0, 600, 0, 600] + output: + pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 200_400, 200_400, 201_000, 201_000] + pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_000, 301_000, 300_400, 300_400] + pension_contributions_for_annual_allowance: [301_000, 130_000, 130_000, 301_600, 301_600, 301_000, 301_000] + pension_annual_allowance: [39_500, 31_250, 38_750, 39_500, 39_500, 39_800, 39_800] diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 66308abdc9..32ee4ad496 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -22,3 +22,75 @@ def pension_contributions_relief_amount( annual_limit = relevant_uk_earnings + basic_amount_increase under_age_limit = age < hmrc.pensions.pension_contributions_relief_age_limit return np.minimum(contributions, annual_limit) * under_age_limit + + +def annual_allowance_net_income(person, period, parameters): + """Model net income rebuilt without NICs-only returned salary sacrifice. + + Like net_income, this is before the separately modelled pension relief. + ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction, not + subtracting returned pay after an income-dependent deduction was taken. + Ordinary income tax keeps its existing calculation (issue #2454). + """ + from policyengine_core.model_api import add + + returned = person("salary_sacrifice_returned_to_income", period) + net_income = person("net_income", period) + if not np.any(returned): + return net_income + + hmrc = parameters(period).gov.hmrc + taxable_pay = person("taxable_employment_income", period) + actual_taxable_pay = np.maximum(0, taxable_pay - returned) + removed_taxable_pay = taxable_pay - actual_taxable_pay + income_before_loss = np.maximum( + 0, + person("net_income_before_trade_loss_relief", period) - removed_taxable_pay, + ) + # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member + # contributions, retaining its treatment of brought-forward trade losses + # and pension relief. Brought-forward losses only reduce trading profits. + other_deductions = [ + deduction + for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions + if deduction != "loss_relief" + ] + profits_before_carry_forward = np.maximum( + 0, + person("self_employment_income", period) + - add(person, period, other_deductions), + ) + losses_brought_forward_deducted = profits_before_carry_forward - person( + "taxable_self_employment_income", period + ) + actual_member_contributions = person("pension_contributions", period) - returned + relief_limit = np.maximum( + hmrc.income_tax.reliefs.pension_contribution.basic_amount, + actual_taxable_pay + profits_before_carry_forward, + ) + relieved_contributions = np.minimum(actual_member_contributions, relief_limit) * ( + person("age", period) < hmrc.pensions.pension_contributions_relief_age_limit + ) + adjusted_total = np.maximum( + 0, + income_before_loss + losses_brought_forward_deducted - relieved_contributions, + ) + cap = hmrc.income_tax.reliefs.cap + loss_cap = np.maximum(cap.amount, cap.rate * adjusted_total) + loss = np.where( + person("trading_allowance_deduction", period) > 0, + 0, + person("trading_loss", period), + ) + actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_before_loss)) + # Add back the loss computed using synthetic pay before deducting the + # actual-pay loss. Preserve supplied net-income/ANI inputs and zero-return + # people, including years before the NICs cap starts. + reconstructed = np.maximum( + 0, + net_income + + person("trade_loss_relief_against_general_income", period) + - removed_taxable_pay + - actual_loss_relief, + ) + return np.where(returned > 0, reconstructed, net_income) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py index 428d467d56..4d84d8c253 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import annual_allowance_net_income class pension_annual_allowance_adjusted_income(Variable): @@ -22,7 +23,7 @@ class pension_annual_allowance_adjusted_income(Variable): def formula(person, period, parameters): returned = person("salary_sacrifice_returned_to_income", period) - actual_income = person("net_income", period) - returned + actual_income = annual_allowance_net_income(person, period, parameters) actual_member_contributions = person("pension_contributions", period) - returned return ( actual_income diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index d58cfa132a..95f4db1d41 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import pension_contributions_relief_amount +from policyengine_uk.utils.pensions import ( + annual_allowance_net_income, + pension_contributions_relief_amount, +) class pension_annual_allowance_threshold_income(Variable): @@ -8,7 +11,7 @@ class pension_annual_allowance_threshold_income(Variable): label = "Threshold income for the pension annual allowance" documentation = ( "FA 2004 s. 228ZA(5) threshold income. Remove the NICs-only returned " - "salary sacrifice from model income and recompute pension relief on " + "salary sacrifice and recompute income-dependent Step 2 relief on " "actual member contributions and relevant earnings. This combines " "the net-pay deduction from statutory net income with gross RAS " "relief, including the basic amount and age limit. Add back full " @@ -51,8 +54,7 @@ def formula(person, period, parameters): person("pension_contributions_relief", period), ) return ( - person("net_income", period) - - returned + annual_allowance_net_income(person, period, parameters) - relief + relevant_sacrifice - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) From 998f3c5b64019a4748ab0e20d26b306ab7fd5d26 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 18:01:40 -0400 Subject: [PATCH 7/9] Separate DC and DB pension property inputs and document actual income Add the homogeneous loss property and assert default DC input paths. Clarify RAS and the 6 October age approximation. --- docs/engineering/pension-annual-allowance.md | 66 ++++++++--- .../tests/test_statutory_pension_edges.py | 108 +++++++++++++++--- 2 files changed, 136 insertions(+), 38 deletions(-) diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 2ef38978cc..a4e4140844 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -25,24 +25,48 @@ allowance less £10,000, divided by £60,000: one half with the statutory The model's `net_income` includes income before pension relief; that relief is deducted separately through `allowances`. Employee contributions represent net pay and personal contributions represent gross relief at source (RAS). -Accordingly: +Both annual-allowance income tests use +[`annual_allowance_net_income`](../../policyengine_uk/utils/pensions.py). +This shared helper reconstructs the model's net-income convention from actual +income, before separately modeled pension relief, excluding the NICs-only +salary-sacrifice return. Accordingly: - `pension_annual_allowance_adjusted_income` adds the total pension input - amount to `net_income`, then subtracts the individual's own contributions. - This restores net-pay relief once and includes employer contributions and - salary sacrifice. RAS contributions do not reduce adjusted income. -- `pension_annual_allowance_threshold_income` subtracts - pension relief from `net_income`, then adds back relevant - salary sacrifice. The relief deduction combines the net-pay deduction - already present in statutory Step 2 income and the gross RAS deduction + amount to that reconstructed income, then subtracts the individual's actual + own contributions. This restores net-pay relief once and includes employer + contributions and + salary sacrifice. Relievable RAS contributions made before age 75 do not + reduce adjusted income. +- `pension_annual_allowance_threshold_income` starts from the same + reconstructed income, deducts pension relief recomputed on actual earnings + and contributions, then adds back relevant salary sacrifice. The relief + deduction combines the net-pay deduction already present in statutory + Step 2 income and the gross RAS deduction under s. 228ZA(5)(c). - Both measures exclude `pension_annual_allowance_taxable_lump_sum_death_benefits`. This input must already be included in income elsewhere, such as private pension income; it does not itself add income to the taxable base. -Gift Aid's adjusted-net-income deduction does not enter either measure. -These calculations therefore use `net_income`, independently of the separate -ITA 2007 s. 58 pension deduction from adjusted net income. +Current-year trading-loss relief is income-dependent: the helper rebuilds +adjusted total income and the [ITA 2007 s. 24A relief +cap](https://www.legislation.gov.uk/ukpga/2007/3/section/24A) from actual earnings +and member contributions, including the add-back for trading losses brought +forward. It then limits [s. 64 loss +relief](https://www.legislation.gov.uk/ukpga/2007/3/section/64) to that cap and +the actual income available. Removing synthetic pay after deducting the +original relief would retain relief calculated on income the person did not +receive. Threshold income also recomputes [FA 2004 ss. 188–191 pension +relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual +earnings and contributions. + +Other modeled deductions do not depend on the returned pay: brought-forward +trade losses, capital allowances and the trading allowance depend on trading +profits. Property finance-cost relief is a downstream tax reducer. Gift Aid's +adjusted-net-income deduction and rate-limit extension do not enter either +annual-allowance income measure, nor does the separate ITA 2007 s. 58 pension +deduction from adjusted net income. Charitable investment gifts and +`other_deductions` remain in `allowances`; their statutory Step 2 treatment is +an existing gap shared with adjusted net income. ## Inputs and data limits @@ -62,7 +86,10 @@ contributions made before age 75, employer contributions and full actual salary sacrifice. Under [s. 233](https://www.legislation.gov.uk/ukpga/2004/12/section/233) and [s. 188(3)(a)](https://www.legislation.gov.uk/ukpga/2004/12/section/188), member contributions made at 75 or over are excluded; employer contributions -still count. Contributions before 75 that exceed the earnings relief limit +still count. The model reads age on **6 October** for the whole year. In the +year of a 75th birthday, member payments are therefore included or excluded +for the whole year according to age on that date; payments are not split at +the birthday. Contributions before 75 that exceed the earnings relief limit still count. Adjusted income subtracts all actual member contributions under s. 228ZA(4)(d)(ii), including those excluded from the post-75 DC input. For defined-benefit @@ -76,11 +103,11 @@ allowance histories or anti-avoidance arrangements under s. 228ZB. changes NICs treatment without changing s. 228ZA. From 2029, the model moves sacrifice above the NICs exemption cap into employee contributions and employment income. The AA calculations remove -that synthetic return from both pay and member contributions. Threshold -income recomputes relief on actual member contributions and actual relevant -earnings, preserving the basic RAS amount even when cash earnings are below -£3,600 and giving no member relief from age 75. The DC input keeps the full -employer sacrifice at every age. The threshold-income add-back uses the full +that synthetic return from both pay and member contributions and recompute +income-dependent relief as described above. Pension relief preserves the +basic RAS amount even when cash earnings are below £3,600 and gives no member +relief from age 75. The DC input keeps the full employer sacrifice at every +age. The threshold-income add-back uses the full sacrifice before this reclassification. The separate employer-cost wage response can still change income; it is held fixed when testing the cap's statutory effect. @@ -88,8 +115,9 @@ statutory effect. Cap invariance covers the two taper incomes, pension input and allowance. Charge comparisons additionally hold ordinary income-tax inputs fixed: the separate ANI and personal-allowance interaction can change the marginal tax -charged on the same excess. The AA calculations do not repair that ordinary -income-tax reclassification. +charged on the same excess. Ordinary income tax retains its existing +reclassification behavior; [#2454](https://github.com/PolicyEngine/policyengine-uk/issues/2454) +tracks that separate defect. The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py index f9700796ac..584a4dba61 100644 --- a/policyengine_uk/tests/test_statutory_pension_edges.py +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -8,7 +8,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, Phase, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -188,7 +188,7 @@ def test_nics_cap_preserves_aa_across_scheme_method_and_age(year, scheme, method @st.composite -def _pension_person(draw): +def _pension_person(draw, scheme): method = draw(st.sampled_from(["net_pay", "RAS"])) values = { "age": draw(st.sampled_from([45, 74, 75, 80, 90])), @@ -203,13 +203,23 @@ def _pension_person(draw): else "personal_pension_contributions" ): draw(st.integers(0, 100_000)), } - if draw(st.booleans()): + if scheme == "DB": values["pension_contributions_for_annual_allowance"] = draw( st.integers(0, 400_000) ) return values +def _assert_pension_input_path(sim, year, scheme): + """Partial person inputs would bypass the default DC formula for everyone.""" + periods = sim.get_holder( + "pension_contributions_for_annual_allowance" + ).get_input_periods("default") + assert [str(period) for period in periods] == ( + [str(year)] if scheme == "DB" else [] + ) + + def _charge_with_fixed_ordinary_tax_inputs(results, year): """Test the AA inputs to the charge separately from ordinary income tax. @@ -251,16 +261,21 @@ def _charge_with_fixed_ordinary_tax_inputs(results, year): derandomize=True, suppress_health_check=[HealthCheck.too_slow], ) -@given(people=st.lists(_pension_person(), min_size=4, max_size=10)) -def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(people, year): +@given( + dc_people=st.lists(_pension_person("DC"), min_size=4, max_size=10), + db_people=st.lists(_pension_person("DB"), min_size=4, max_size=10), +) +def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax( + dc_people, db_people, year +): """All ages/earnings preserve AA computations when the NICs cap varies. The charge comparison fixes ordinary tax inputs. It makes no unrestricted claim about the charge, ANI, personal allowances or ordinary income tax. """ - # Always exercise each known statutory failure, alongside generated DB/DC, - # contribution methods, low earnings and people aged 75 and over. - people = [ + # Keep every default DC record, including the fixed witnesses, separate + # from supplied DB inputs, which otherwise zero the DC input array. + dc_people = [ { "age": 80, "employment_income_before_lsr": 160_000, @@ -274,10 +289,73 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(people, "employer_pension_contributions": 100_000, "personal_pension_contributions": 3_600, }, - ] + people + ] + dc_people + for scheme, people in [("DC", dc_people), ("DB", db_people)]: + results = [] + for cap in [2_000, float("inf")]: + sim = _population_simulation(people, year, cap) + _assert_pension_input_path(sim, year, scheme) + results.append( + { + variable: np.asarray(sim.calculate(variable, year)) + for variable in AA_VARIABLES + } + ) + if scheme == "DC": + np.testing.assert_array_equal( + results[-1]["pension_contributions_for_annual_allowance"][:2], + [130_000, 304_600], + ) + for variable in AA_VARIABLES: + np.testing.assert_allclose( + results[0][variable], + results[1][variable], + rtol=0, + atol=0.05, + err_msg=f"{scheme}: {variable}", + ) + np.testing.assert_allclose( + _charge_with_fixed_ordinary_tax_inputs(results[0], year), + _charge_with_fixed_ordinary_tax_inputs(results[1], year), + rtol=0, + atol=0.05, + ) + + +@pytest.mark.parametrize("year", [2029, 2030]) +@pytest.mark.parametrize("scheme", ["DC", "DB"]) +@settings( + max_examples=3, + database=None, + deadline=None, + phases=[Phase.explicit, Phase.generate], +) +@example(loss=10_000) +@given(loss=st.integers(min_value=0, max_value=50_000)) +def test_trade_losses_do_not_leak_nics_reclassification_into_aa(year, scheme, loss): + """Income-dependent Step 2 relief uses actual income for both AA tests.""" + people = [] + for age in [45, 74, 75, 80]: + for method in ["net_pay", "RAS"]: + values = { + "age": age, + "employment_income_before_lsr": 2_000, + "pension_contributions_via_salary_sacrifice": 201_000, + "employer_pension_contributions": 100_000, + "trading_loss": loss, + ( + "employee_pension_contributions_reported" + if method == "net_pay" + else "personal_pension_contributions" + ): 600, + } + if scheme == "DB": + values["pension_contributions_for_annual_allowance"] = 350_000 + people.append(values) results = [] for cap in [2_000, float("inf")]: sim = _population_simulation(people, year, cap) + _assert_pension_input_path(sim, year, scheme) results.append( { variable: np.asarray(sim.calculate(variable, year)) @@ -285,16 +363,8 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax(people, } ) for variable in AA_VARIABLES: - np.testing.assert_allclose( + np.testing.assert_array_equal( results[0][variable], results[1][variable], - rtol=0, - atol=0.05, - err_msg=variable, + err_msg=f"{year=} {scheme=} {loss=} {variable=}", ) - np.testing.assert_allclose( - _charge_with_fixed_ordinary_tax_inputs(results[0], year), - _charge_with_fixed_ordinary_tax_inputs(results[1], year), - rtol=0, - atol=0.05, - ) From b6e81966284d4f4d9ff4236dbb3460427e86024a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 09:51:43 -0400 Subject: [PATCH 8/9] Preserve supplied ANI and limit trading losses after net-pay relief --- changelog.d/2244.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 37 ++++++++--- ...nsion_annual_allowance_trading_losses.yaml | 30 +++++++-- .../tests/test_statutory_pension_edges.py | 63 ++++++++++++++++++- policyengine_uk/utils/pensions.py | 51 ++++++++++++--- 5 files changed, 158 insertions(+), 24 deletions(-) diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index 451dfaab84..466340fd69 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -2,3 +2,4 @@ - Removed the NICs-only salary-sacrifice reclassification from the taper income tests, including post-75 and basic relief-at-source cases. - Excluded member contributions made at 75 or over from the default DC pension input while retaining all employer contributions and salary sacrifice. - Recomputed income-dependent trading-loss relief from actual income for both annual allowance income tests, including the general-income and relief-cap limits. +- Limited trading-loss relief after relievable net-pay contributions in every year and kept supplied adjusted-net-income inputs independent of the NICs cap. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index a4e4140844..78c46ab47d 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -33,15 +33,17 @@ salary-sacrifice return. Accordingly: - `pension_annual_allowance_adjusted_income` adds the total pension input amount to that reconstructed income, then subtracts the individual's actual - own contributions. This restores net-pay relief once and includes employer - contributions and - salary sacrifice. Relievable RAS contributions made before age 75 do not + own contributions. The helper retains the s. 228ZA(4)(c) net-pay relief + add-back after limiting trading losses to income remaining after net pay. + Employer contributions and salary sacrifice are included. Relievable RAS + contributions made before age 75 do not reduce adjusted income. - `pension_annual_allowance_threshold_income` starts from the same reconstructed income, deducts pension relief recomputed on actual earnings and contributions, then adds back relevant salary sacrifice. The relief - deduction combines the net-pay deduction already present in statutory - Step 2 income and the gross RAS deduction + deduction combines the net-pay employment-income deduction under + [FA 2004 s. 193(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/193) + and the gross RAS deduction under s. 228ZA(5)(c). - Both measures exclude `pension_annual_allowance_taxable_lump_sum_death_benefits`. This input must already be included in income elsewhere, such as private @@ -53,18 +55,35 @@ cap](https://www.legislation.gov.uk/ukpga/2007/3/section/24A) from actual earnin and member contributions, including the add-back for trading losses brought forward. It then limits [s. 64 loss relief](https://www.legislation.gov.uk/ukpga/2007/3/section/64) to that cap and -the actual income available. Removing synthetic pay after deducting the +the actual income available after relievable net-pay contributions, within +the earnings relief limit and before age 75. Section 193(2) takes net-pay +contributions out of employment income itself; only the excess under +s. 193(4) or (6) is a Step 2 deduction. +[ITA 2007 s. 25(4)](https://www.legislation.gov.uk/ukpga/2007/3/section/25) +prevents Step 2 relief from exceeding the remaining income. This limit +applies in every model year, including before the NICs cap begins in 2029. +It can therefore change pre-2029 allowances for people whose trading-loss +relief is limited by income and who make relievable net-pay contributions. +Removing synthetic pay after deducting the original relief would retain relief calculated on income the person did not receive. Threshold income also recomputes [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. +An explicitly supplied `adjusted_net_income` is authoritative. For this input +path, the helper restores gross Gift Aid and pension relief recomputed on +actual contributions and earnings. It does not remove returned pay or +reconstruct trading-loss relief: the supplied ANI contains neither synthetic +pay nor relief recalculated by the NICs reclassification. The annual-allowance +base is therefore independent of the NICs cap on this path too. + Other modeled deductions do not depend on the returned pay: brought-forward trade losses, capital allowances and the trading allowance depend on trading profits. Property finance-cost relief is a downstream tax reducer. Gift Aid's -adjusted-net-income deduction and rate-limit extension do not enter either -annual-allowance income measure, nor does the separate ITA 2007 s. 58 pension -deduction from adjusted net income. Charitable investment gifts and +adjusted-net-income deduction and rate-limit extension do not reduce either +annual-allowance income measure. When ANI is supplied, the helper restores +those ANI deductions, including the separate ITA 2007 s. 58 pension +deduction. Charitable investment gifts and `other_deductions` remain in `allowances`; their statutory Step 2 treatment is an existing gap shared with adjusted net income. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml index ffd89b69d5..100ede4a89 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance_trading_losses.yaml @@ -17,10 +17,10 @@ employee_pension_contributions_reported: [0, 0, 0, 600, 0, 600, 0] personal_pension_contributions: [0, 0, 0, 0, 600, 0, 600] output: - pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 200_400, 200_400, 201_000, 201_000] - pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_000, 301_000, 300_400, 300_400] + pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 201_000, 200_400, 201_000, 201_000] + pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_600, 301_000, 300_400, 300_400] pension_contributions_for_annual_allowance: [301_000, 130_000, 130_000, 301_600, 301_600, 301_000, 301_000] - pension_annual_allowance: [39_500, 31_250, 38_750, 39_500, 39_500, 39_800, 39_800] + pension_annual_allowance: [39_500, 31_250, 38_750, 39_200, 39_500, 39_800, 39_800] - name: Trading loss limits for AA use actual income in 2030 period: 2030 @@ -36,7 +36,25 @@ employee_pension_contributions_reported: [0, 0, 0, 600, 0, 600, 0] personal_pension_contributions: [0, 0, 0, 0, 600, 0, 600] output: - pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 200_400, 200_400, 201_000, 201_000] - pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_000, 301_000, 300_400, 300_400] + pension_annual_allowance_threshold_income: [201_000, 217_500, 202_500, 201_000, 200_400, 201_000, 201_000] + pension_annual_allowance_adjusted_income: [301_000, 317_500, 302_500, 301_600, 301_000, 300_400, 300_400] pension_contributions_for_annual_allowance: [301_000, 130_000, 130_000, 301_600, 301_600, 301_000, 301_000] - pension_annual_allowance: [39_500, 31_250, 38_750, 39_500, 39_500, 39_800, 39_800] + pension_annual_allowance: [39_500, 31_250, 38_750, 39_200, 39_500, 39_800, 39_800] + +- name: Net-pay relief limits the loss before the threshold gate in 2026 + period: 2026 + absolute_error_margin: 0.05 + input: + age: 45 + employment_income_before_lsr: 2_000 + pension_contributions_via_salary_sacrifice: 200_500 + employer_pension_contributions: 100_000 + employee_pension_contributions_reported: 1_000 + trading_loss: 10_000 + output: + # FA 2004 s.193(2) removes net pay before the ITA 2007 s.25(4) + # income limit: 2,000 pay - 1,000 net pay - 1,000 loss = 0. + pension_annual_allowance_threshold_income: 200_500 + pension_annual_allowance_adjusted_income: 301_500 + pension_contributions_for_annual_allowance: 301_500 + pension_annual_allowance: 39_250 diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py index 584a4dba61..d1fa848660 100644 --- a/policyengine_uk/tests/test_statutory_pension_edges.py +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -220,6 +220,12 @@ def _assert_pension_input_path(sim, year, scheme): ) +def _assert_ani_input_path(sim, year, supplied_ani): + """ANI inputs override the whole person array, including partial inputs.""" + periods = sim.get_holder("adjusted_net_income").get_input_periods("default") + assert [str(period) for period in periods] == ([str(year)] if supplied_ani else []) + + def _charge_with_fixed_ordinary_tax_inputs(results, year): """Test the AA inputs to the charge separately from ordinary income tax. @@ -290,18 +296,71 @@ def test_nics_cap_preserves_aa_inputs_and_charge_with_fixed_ordinary_tax( "personal_pension_contributions": 3_600, }, ] + dc_people - for scheme, people in [("DC", dc_people), ("DB", db_people)]: + # ANI is a partial-input override, so each person in these separate + # simulations must supply it. Loss relief is already reflected in ANI; + # neither actual nor synthetic pay/loss relief can reconstruct it again. + ani_people = [ + { + "age": age, + "employment_income_before_lsr": 2_000, + "pension_contributions_via_salary_sacrifice": 201_000, + "employer_pension_contributions": 100_000, + "adjusted_net_income": 150_000, + "trading_loss": loss, + **contributions, + } + for age in [45, 80] + for loss in [0, 10_000] + for contributions in [ + {}, + {"gift_aid": 800, "employee_pension_contributions_reported": 4_000}, + {"gift_aid": 800, "personal_pension_contributions": 4_000}, + ] + ] + ani_db_people = [ + {**person, "pension_contributions_for_annual_allowance": 350_000} + for person in ani_people + ] + for scheme, people, supplied_ani in [ + ("DC", dc_people, False), + ("DB", db_people, False), + ("DC", ani_people, True), + ("DB", ani_db_people, True), + ]: results = [] for cap in [2_000, float("inf")]: sim = _population_simulation(people, year, cap) _assert_pension_input_path(sim, year, scheme) + _assert_ani_input_path(sim, year, supplied_ani) results.append( { variable: np.asarray(sim.calculate(variable, year)) for variable in AA_VARIABLES } ) - if scheme == "DC": + if supplied_ani: + # Gross Gift Aid is 1,000. At age 45 actual earnings allow + # 2,000 net-pay relief or 3,600 RAS relief; age 80 allows + # neither. The loss never changes an already supplied ANI. + expected_adjusted = ( + [451_000, 454_000, 455_600] * 2 + [451_000, 448_000, 448_000] * 2 + if scheme == "DC" + else [500_000, 499_000, 500_600] * 2 + + [500_000, 497_000, 497_000] * 2 + ) + np.testing.assert_array_equal( + results[-1]["pension_annual_allowance_threshold_income"], + [351_000, 352_000, 352_000] * 4, + ) + np.testing.assert_array_equal( + results[-1]["pension_annual_allowance_adjusted_income"], + expected_adjusted, + ) + np.testing.assert_array_equal( + results[-1]["pension_annual_allowance"], + np.full(len(people), 10_000), + ) + elif scheme == "DC": np.testing.assert_array_equal( results[-1]["pension_contributions_for_annual_allowance"][:2], [130_000, 304_600], diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 32ee4ad496..741969a80f 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -1,5 +1,7 @@ import numpy as np +from policyengine_uk.utils.supplied_inputs import supplied_input + def pension_contributions_relief_amount( contributions, relevant_uk_earnings, ras_contributions, age, hmrc @@ -35,11 +37,40 @@ def annual_allowance_net_income(person, period, parameters): from policyengine_core.model_api import add returned = person("salary_sacrifice_returned_to_income", period) + hmrc = parameters(period).gov.hmrc + actual_member_contributions = person("pension_contributions", period) - returned + actual_earnings = ( + person("employment_income", period) + - returned + + np.maximum(0, person("self_employment_income", period)) + ) + supplied_ani = supplied_input(person, "adjusted_net_income", period) + if supplied_ani is not None: + # Supplied ANI already determines net income, including its losses. + # Restore only Gift Aid and pension relief on actual earnings/payments; + # neither returned pay nor a replacement loss deduction belongs here. + return ( + supplied_ani + + person("gift_aid_grossed_up", period) + + pension_contributions_relief_amount( + actual_member_contributions, + actual_earnings, + person("personal_pension_contributions", period), + person("age", period), + hmrc, + ) + ) + net_income = person("net_income", period) - if not np.any(returned): + actual_net_pay = person("employee_pension_contributions", period) - returned + relievable_net_pay = pension_contributions_relief_amount( + actual_net_pay, actual_earnings, 0, person("age", period), hmrc + ) + if not np.any(returned) and not np.any( + (relievable_net_pay > 0) & (person("trading_loss", period) > 0) + ): return net_income - hmrc = parameters(period).gov.hmrc taxable_pay = person("taxable_employment_income", period) actual_taxable_pay = np.maximum(0, taxable_pay - returned) removed_taxable_pay = taxable_pay - actual_taxable_pay @@ -63,7 +94,6 @@ def annual_allowance_net_income(person, period, parameters): losses_brought_forward_deducted = profits_before_carry_forward - person( "taxable_self_employment_income", period ) - actual_member_contributions = person("pension_contributions", period) - returned relief_limit = np.maximum( hmrc.income_tax.reliefs.pension_contribution.basic_amount, actual_taxable_pay + profits_before_carry_forward, @@ -82,10 +112,15 @@ def annual_allowance_net_income(person, period, parameters): 0, person("trading_loss", period), ) - actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_before_loss)) + # FA 2004 s.193(2) deducts relievable net pay from employment income + # before the s.25(4) limit on Step 2 loss relief. Apply this ordering even + # without a NICs return, retaining the pre-pension-relief model convention + # below so the callers can deduct or restore member relief once. + income_after_net_pay = np.maximum(0, income_before_loss - relievable_net_pay) + actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_after_net_pay)) # Add back the loss computed using synthetic pay before deducting the - # actual-pay loss. Preserve supplied net-income/ANI inputs and zero-return - # people, including years before the NICs cap starts. + # actual-pay loss. Supplied ANI has its own path above. For other people, + # net-pay/loss ordering also applies before the NICs cap starts. reconstructed = np.maximum( 0, net_income @@ -93,4 +128,6 @@ def annual_allowance_net_income(person, period, parameters): - removed_taxable_pay - actual_loss_relief, ) - return np.where(returned > 0, reconstructed, net_income) + return np.where( + (returned > 0) | (relievable_net_pay > 0), reconstructed, net_income + ) From 39f5fa85c2e3ed365dc788ad2e9f6355a2c3dd5e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 17 Oct 2026 11:41:08 -0400 Subject: [PATCH 9/9] Restrict rebuilt pension loss-cap basic uplift to RAS --- changelog.d/2244.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 7 +++- .../tests/test_statutory_pension_edges.py | 37 +++++++++++++++++++ policyengine_uk/utils/pensions.py | 10 ++--- 4 files changed, 49 insertions(+), 6 deletions(-) diff --git a/changelog.d/2244.fixed.md b/changelog.d/2244.fixed.md index 466340fd69..02ed57e7c0 100644 --- a/changelog.d/2244.fixed.md +++ b/changelog.d/2244.fixed.md @@ -3,3 +3,4 @@ - Excluded member contributions made at 75 or over from the default DC pension input while retaining all employer contributions and salary sacrifice. - Recomputed income-dependent trading-loss relief from actual income for both annual allowance income tests, including the general-income and relief-cap limits. - Limited trading-loss relief after relievable net-pay contributions in every year and kept supplied adjusted-net-income inputs independent of the NICs cap. +- Restricted the basic pension-relief uplift above earnings to relief at source when rebuilding the annual allowance trading-loss cap. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 78c46ab47d..906d7d60d9 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -53,7 +53,12 @@ Current-year trading-loss relief is income-dependent: the helper rebuilds adjusted total income and the [ITA 2007 s. 24A relief cap](https://www.legislation.gov.uk/ukpga/2007/3/section/24A) from actual earnings and member contributions, including the add-back for trading losses brought -forward. It then limits [s. 64 loss +forward. Pension relief in this rebuilt cap uses the same RAS-aware helper +as the income tests: [FA 2004 ss. 190(3) and +191(7)](https://www.legislation.gov.uk/ukpga/2004/12/section/191) reserve the +basic-amount uplift above earnings for RAS contributions. Pure net-pay +payments receive relief only up to earnings, and no member relief applies +from age 75. It then limits [s. 64 loss relief](https://www.legislation.gov.uk/ukpga/2007/3/section/64) to that cap and the actual income available after relievable net-pay contributions, within the earnings relief limit and before age 75. Section 193(2) takes net-pay diff --git a/policyengine_uk/tests/test_statutory_pension_edges.py b/policyengine_uk/tests/test_statutory_pension_edges.py index d1fa848660..f1742fed8d 100644 --- a/policyengine_uk/tests/test_statutory_pension_edges.py +++ b/policyengine_uk/tests/test_statutory_pension_edges.py @@ -144,6 +144,43 @@ def test_nics_cap_preserves_basic_ras_threshold_income_boundary(year): assert float(sim.calculate(variable, year)[0]) == amount, (cap, variable) +@pytest.mark.parametrize("year", [2026, 2029, 2030]) +@pytest.mark.parametrize("cap", [2_000, float("inf")], ids=["capped", "uncapped"]) +@pytest.mark.parametrize("net_pay", [2_000, 3_600], ids=["earnings", "above_earnings"]) +def test_basic_ras_uplift_does_not_relieve_net_pay_in_aa_loss_cap(year, cap, net_pay): + """FA 2004 ss.190(3)/191(7) reserve above-earnings basic relief for RAS.""" + sim = _population_simulation( + [ + { + "age": 45, + "employment_income_before_lsr": 2_000, + "employee_pension_contributions_reported": net_pay, + "personal_pension_contributions": 0, + "dividend_income": 250_000, + "trading_loss": 200_000, + "employer_pension_contributions": 100_000, + "pension_contributions_via_salary_sacrifice": 12_500, + } + ], + year, + cap, + ) + # Only 2,000 net pay is relievable. ATI is 250,000, so the 25% loss + # cap is 62,500 and statutory net income is 187,500. The 12,500 + # sacrifice brings threshold income exactly to the untapered boundary. + expected = { + "pension_annual_allowance_threshold_income": 200_000, + "pension_annual_allowance_adjusted_income": 302_000, + "pension_contributions_for_annual_allowance": 112_500 + net_pay, + "pension_annual_allowance": 60_000, + "personal_pension_contributions_tax": (52_500 + net_pay) * 0.45, + } + for variable, amount in expected.items(): + assert float(sim.calculate(variable, year)[0]) == pytest.approx( + amount, abs=0.01 + ) + + @pytest.mark.parametrize("year", [2029, 2030]) @pytest.mark.parametrize("scheme", ["DC", "DB"]) @pytest.mark.parametrize("method", ["net_pay", "RAS"]) diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 741969a80f..754fd23a2d 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -94,12 +94,12 @@ def annual_allowance_net_income(person, period, parameters): losses_brought_forward_deducted = profits_before_carry_forward - person( "taxable_self_employment_income", period ) - relief_limit = np.maximum( - hmrc.income_tax.reliefs.pension_contribution.basic_amount, + relieved_contributions = pension_contributions_relief_amount( + actual_member_contributions, actual_taxable_pay + profits_before_carry_forward, - ) - relieved_contributions = np.minimum(actual_member_contributions, relief_limit) * ( - person("age", period) < hmrc.pensions.pension_contributions_relief_age_limit + person("personal_pension_contributions", period), + person("age", period), + hmrc, ) adjusted_total = np.maximum( 0,