From a89d9e19289f08539c8cc361a408d6a88b502639 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 06:59:27 -0400 Subject: [PATCH 01/14] Relieve pension contributions to relevant earnings and charge the excess once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pension_contributions_relief capped relief at the annual allowance, and personal_pension_contributions_tax also charged the excess over it, so contributions above the allowance were taxed twice. Relief now runs to the s. 190 limit (the greater of relevant UK earnings and £3,600), and the annual allowance charge recovers it once. The charge now taxes the chargeable amount as the top slice of reduced net income at the basic, higher and additional rates (FA 2004 s. 227(4A)), or the Scottish rates with the starter band at the Scottish basic rate (s. 227(4AA)), instead of one marginal rate. Unused allowance carried forward (s. 228A) is a new input, nil by default. Fixes #2237 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2237.fixed.md | 1 + .../pension_contributions_relief.yaml | 51 +++- .../personal_pension_contributions_tax.yaml | 117 +++++++- ...est_pension_annual_allowance_properties.py | 270 ++++++++++++++++++ ...sion_annual_allowance_chargeable_amount.py | 34 +++ .../pensions/pension_contributions_relief.py | 58 ++-- .../private_pension_contributions_tax.py | 61 +++- .../input/unused_pension_annual_allowance.py | 23 ++ 8 files changed, 577 insertions(+), 38 deletions(-) create mode 100644 changelog.d/2237.fixed.md create mode 100644 policyengine_uk/tests/test_pension_annual_allowance_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_chargeable_amount.py create mode 100644 policyengine_uk/variables/input/unused_pension_annual_allowance.py diff --git a/changelog.d/2237.fixed.md b/changelog.d/2237.fixed.md new file mode 100644 index 0000000000..59cc022a7e --- /dev/null +++ b/changelog.d/2237.fixed.md @@ -0,0 +1 @@ +Relieve pension contributions up to the annual limit in Finance Act 2004 s. 190 (the greater of relevant UK earnings and £3,600) instead of the annual allowance, and charge the annual allowance charge once on the excess (s. 227), so contributions above the allowance are no longer taxed twice. The charge now taxes the chargeable amount as the top slice of reduced net income at the basic, higher and additional rates (s. 227(4A)), or at the Scottish rates with the starter band charged at the Scottish basic rate (s. 227(4AA)), rather than at one marginal rate. Add `unused_pension_annual_allowance` for unused allowance carried forward from the three previous years (s. 228A; nil by default) and `pension_annual_allowance_chargeable_amount`. A trading loss no longer reduces relevant earnings from employment. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml index dc3dc27731..a81063aa63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml @@ -6,14 +6,20 @@ personal_pension_contributions: 40000 output: pension_contributions_relief: 40000 -- name: Pension relief excessive +- name: Pension relief is not capped at the (tapered) annual allowance period: 2020 - absolute_error_margin: 0 + absolute_error_margin: 0.01 input: employment_income: 2000000 personal_pension_contributions: 400000 output: - pension_contributions_relief: 4000 + # FA 2004 s. 190: relief up to relevant UK earnings, so all 400,000 is + # relieved even though the tapered annual allowance is 4,000. + pension_contributions_relief: 400000 + # The annual allowance charge recovers the relief on the excess: + # (400,000 - 4,000) at 45% on top of reduced net income of 1,600,000. + pension_annual_allowance_chargeable_amount: 396000 + personal_pension_contributions_tax: 396000 * 0.45 - name: £30k self-employment income, no private pension contributions period: 2023 absolute_error_margin: 0.01 @@ -49,3 +55,42 @@ output: pension_contributions_relief: 6_000 income_tax: 11432 - 6000 * 0.4 + +- name: Relief on the basic amount when relevant earnings are lower (s. 190(2)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 2_000 + personal_pension_contributions: 5_000 + output: + pension_contributions_relief: 3_600 + +- name: Relief up to relevant earnings, not beyond (s. 190(1)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 40_000 + self_employment_income: 10_000 + personal_pension_contributions: 70_000 + output: + pension_contributions_relief: 50_000 + +- name: A trading loss does not reduce relevant earnings from employment + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 40_000 + self_employment_income: -10_000 + personal_pension_contributions: 45_000 + output: + pension_contributions_relief: 40_000 + +- name: No relief on contributions paid at 75 or over (s. 188(3)(a)) + period: 2024 + absolute_error_margin: 0.01 + input: + age: 75 + employment_income: 40_000 + personal_pension_contributions: 10_000 + output: + pension_contributions_relief: 0 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 efee04245b..d106b4d8f9 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 @@ -28,10 +28,11 @@ employee_pension_contributions_reported: 70_000 output: # Total for AA: 70_000, excess = 70_000 - 60_000 = 10_000 - # pension_contributions_relief = min(100k, 70k) then min(70k, 60k) = 60_000 - # taxed_income = 100_000 - 12_570 - 60_000 = 27_430 - # Marginal rate at 27_430 = 20% (basic rate) + # pension_contributions_relief = 70_000 (within relevant earnings) + # reduced net income = 100_000 - 12_570 - 70_000 = 17_430 + # 17_430 + 10_000 = 27_430 is within the basic rate band # Tax charge: 10_000 * 0.2 = 2_000 + pension_contributions_relief: 70_000 personal_pension_contributions_tax: 2_000 - name: Employer contributions push total over AA @@ -44,8 +45,8 @@ output: # Total for AA: 20_000 + 50_000 = 70_000, excess = 70_000 - 60_000 = 10_000 # pension_contributions_relief = min(100k, 20k) = 20_000 - # taxed_income = 100_000 - 12_570 - 20_000 = 67_430 - # Marginal rate at 67_430 = 40% (higher rate) + # reduced net income = 100_000 - 12_570 - 20_000 = 67_430 + # 67_430 to 77_430 is within the higher rate band # Tax charge: 10_000 * 0.4 = 4_000 personal_pension_contributions_tax: 4_000 @@ -58,7 +59,109 @@ output: # Total for AA: 70_000 (all employer), excess = 70_000 - 60_000 = 10_000 # pension_contributions_relief = 0 (no employee/personal contributions) - # taxed_income = 100_000 - 12_570 = 87_430 - # Marginal rate at 87_430 = 40% (higher rate) + # reduced net income = 100_000 - 12_570 = 87_430 + # 87_430 to 97_430 is within the higher rate band # Tax charge: 10_000 * 0.4 = 4_000 personal_pension_contributions_tax: 4_000 + +# Issue #2237 reproducer: one adult, £150,000 earnings, London, 2024. Before +# the fix, relief stopped at the £60,000 allowance and the charge was also +# levied on the excess, so £80,000 of contributions cost £8,000 more income +# tax than £60,000. Relief now runs to relevant earnings and the charge +# recovers it once, so income tax is the same. +- name: Issue 2237 - contributions at the annual allowance + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 60_000 + region: LONDON + output: + pension_contributions_relief: 60_000 + personal_pension_contributions_tax: 0 + income_tax: 28_460 + +- name: Issue 2237 - contributions £20,000 above the annual allowance + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 80_000 + region: LONDON + output: + pension_contributions_relief: 80_000 + pension_annual_allowance_chargeable_amount: 20_000 + # Reduced net income 150_000 - 80_000 = 70_000; 70_000 to 90_000 is all + # higher rate. + personal_pension_contributions_tax: 8_000 + income_tax: 28_460 + +- name: Charge spans bands as the top slice of reduced net income (s. 227(4A)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + personal_pension_contributions: 60_000 + employer_pension_contributions: 40_000 + output: + # Relief 60_000; reduced net income 60_000 - 12_570 - 60_000 floored at 0. + # Chargeable amount 100_000 - 60_000 = 40_000: 37_700 at 20% and 2_300 + # at 40%. + pension_contributions_relief: 60_000 + pension_annual_allowance_chargeable_amount: 40_000 + personal_pension_contributions_tax: 37_700 * 0.2 + 2_300 * 0.4 + +- name: Scottish taxpayer pays Scottish rates on the chargeable amount + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 80_000 + region: SCOTLAND + output: + # Reduced net income 70_000; 70_000 to 90_000 is in the 45% advanced band. + personal_pension_contributions_tax: 20_000 * 0.45 + +- name: Scottish starter band is charged at the Scottish basic rate (s. 227(4AA)) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + personal_pension_contributions: 100_000 + region: SCOTLAND + output: + # Reduced net income 100_000 - 12_570 - 100_000 floored at 0. Chargeable + # amount 40_000: 20% up to the 16_956 basic rate limit (the starter band + # included), 21% to 31_092, then 42%. + personal_pension_contributions_tax: 16_956 * 0.2 + (31_092 - 16_956) * 0.21 + (40_000 - 31_092) * 0.42 + +- name: Unused annual allowance brought forward covers the excess (s. 228A) + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 80_000 + unused_pension_annual_allowance: 20_000 + region: LONDON + output: + pension_annual_allowance_chargeable_amount: 0 + personal_pension_contributions_tax: 0 + income_tax: 28_460 - 8_000 + +- name: Unused annual allowance brought forward covers part of the excess + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 80_000 + unused_pension_annual_allowance: 5_000 + region: LONDON + output: + pension_annual_allowance_chargeable_amount: 15_000 + personal_pension_contributions_tax: 15_000 * 0.4 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py new file mode 100644 index 0000000000..0d028c92b2 --- /dev/null +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -0,0 +1,270 @@ +"""Property-based tests for pension contributions relief and the annual +allowance charge (issue #2237). + +Relief runs to the annual limit in FA 2004 s. 190 (the greater of relevant UK +earnings and the basic amount), not to the annual allowance. The annual +allowance charge (s. 227) then taxes the pension input amount above the +allowance, plus any unused allowance brought forward (s. 228A), as the top +slice of reduced net income at the non-savings rates (s. 227(4A)), or the +Scottish rates with the starter band at the Scottish basic rate (s. 227(4AA)). + +Invariants, for any generated population of single adults: + +1. Relief bounds: relief is the lesser of the contributions and the s. 190 + limit for anyone under 75, and nil at 75 or over. +2. The excess is taxed once: raising contributions, up to relevant earnings, + never raises income tax by more than it raises the charge. So income tax + before the charge never rises with contributions. +3. No double taxation: for someone whose income is all non-savings income, + contributing more than the allowance, up to relevant earnings, never costs + more income tax than contributing up to the allowance. +4. Charge bounds: nil exactly when the chargeable amount is nil, and between + the lowest and highest rate charged times the chargeable amount. +5. Differential: the charge equals an independent band-by-band sum of the + chargeable amount stacked on reduced net income. +6. Carry-forward: unused allowance reduces the chargeable amount one for one + until it is nil, and never raises the charge. + +Invariant 3 is restricted to non-savings income. With dividends on top, the +relief comes off non-savings income and lets dividends fall to lower dividend +rates, while s. 227(4A) charges the excess at the non-savings rates, so income +tax can rise (intended: that is what the statute does). It also stops where +contributions use up income after allowances: relief beyond that is worth +nothing, while the charge still falls on the whole input amount (also +intended). +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# 2020: £40,000 allowance, £4,000 tapered minimum. 2024: £60,000 allowance. +# 2026: Scottish starter band present; 2027: property income rates. +YEARS = [2020, 2024, 2026, 2027] +REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] +TOLERANCE = 0.01 + +person_strategy = st.fixed_dictionaries( + { + "age": st.integers(18, 80), + "employment_income": st.integers(0, 400_000), + "self_employment_income": st.integers(-20_000, 200_000), + "employer_pension_contributions": st.integers(0, 80_000), + "contribution_share": st.floats(0, 1.2), + "savings_interest_income": st.integers(0, 30_000), + "dividend_income": st.integers(0, 80_000), + "unused_pension_annual_allowance": st.integers(0, 120_000), + "region": st.sampled_from(REGIONS), + } +) +population_strategy = st.lists(person_strategy, min_size=4, max_size=12) + + +def _situation(people, year, overrides=None): + overrides = overrides or {} + situation = {"people": {}, "benunits": {}, "households": {}} + for i, person in enumerate(people): + name = f"p{i}" + values = {**person, **overrides.get(i, {})} + situation["people"][name] = { + key: {year: values[key]} + for key in [ + "age", + "employment_income", + "self_employment_income", + "employer_pension_contributions", + "personal_pension_contributions", + "savings_interest_income", + "dividend_income", + "unused_pension_annual_allowance", + ] + } + situation["benunits"][f"b{i}"] = {"members": [name]} + situation["households"][f"h{i}"] = { + "members": [name], + "region": {year: values["region"]}, + } + return situation + + +def _relevant_earnings(person): + return person["employment_income"] + max(0, person["self_employment_income"]) + + +def _with_contributions(people, contributions): + return [ + {**person, "personal_pension_contributions": float(c)} + for person, c in zip(people, contributions) + ] + + +def _calc(people, year, variables): + sim = Simulation(situation=_situation(people, year)) + return {v: np.array(sim.calculate(v, year), dtype=float) for v in variables} + + +def _base_contributions(people): + return [p["contribution_share"] * max(_relevant_earnings(p), 3_600) for p in people] + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS)) +def test_relief_is_the_section_190_limit(people, year): + people = _with_contributions(people, _base_contributions(people)) + out = _calc(people, year, ["pension_contributions_relief"]) + basic_amount = 3_600 + for i, person in enumerate(people): + limit = max(_relevant_earnings(person), basic_amount) + expected = ( + min(person["personal_pension_contributions"], limit) + if person["age"] < 75 + else 0 + ) + assert abs(out["pension_contributions_relief"][i] - expected) < TOLERANCE + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS), st.floats(0, 1)) +def test_extra_contributions_never_raise_tax_by_more_than_the_charge( + people, year, step +): + # Two contribution levels, both within relevant earnings. + earnings = np.array([max(_relevant_earnings(p), 3_600) for p in people]) + low = np.array([min(p["contribution_share"], 1) for p in people]) * earnings + high = low + step * (earnings - low) + variables = ["income_tax", "personal_pension_contributions_tax"] + before = _calc(_with_contributions(people, low), year, variables) + after = _calc(_with_contributions(people, high), year, variables) + tax_rise = after["income_tax"] - before["income_tax"] + charge_rise = ( + after["personal_pension_contributions_tax"] + - before["personal_pension_contributions_tax"] + ) + assert np.all(tax_rise <= charge_rise + TOLERANCE) + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS), st.floats(0, 1)) +def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, step): + # Non-savings income only, under 75, no carry-forward: contributions of + # exactly the allowance (net of employer contributions) against any + # amount from there up to relevant earnings. + people = [ + { + **p, + "age": min(p["age"], 74), + "savings_interest_income": 0, + "dividend_income": 0, + "unused_pension_annual_allowance": 0, + } + for p in people + ] + zero_sim = Simulation( + situation=_situation(_with_contributions(people, [0] * len(people)), year) + ) + allowance = np.array( + zero_sim.calculate("pension_annual_allowance", year), dtype=float + ) + # Income after allowances with no contributions. Relief beyond it is worth + # nothing, but the charge still falls on the input amount, so the + # comparison stops there as well as at relevant earnings. + taxable_income = np.maximum( + 0, + np.array(zero_sim.calculate("adjusted_net_income", year), dtype=float) + - np.array(zero_sim.calculate("allowances", year), dtype=float), + ) + earnings = np.array([_relevant_earnings(p) for p in people], dtype=float) + upper = np.minimum(earnings, taxable_income) + employer = np.array([p["employer_pension_contributions"] for p in people]) + at_allowance = np.clip(allowance - employer, 0, earnings) + above = at_allowance + step * np.maximum(0, upper - at_allowance) + tax_at = _calc(_with_contributions(people, at_allowance), year, ["income_tax"]) + tax_above = _calc(_with_contributions(people, above), year, ["income_tax"]) + assert np.all(tax_above["income_tax"] <= tax_at["income_tax"] + TOLERANCE) + + +def _reference_charge(chargeable, reduced_net_income, thresholds, rates): + """Band-by-band sum of the chargeable amount stacked on income.""" + bottom = reduced_net_income + top = reduced_net_income + chargeable + upper = list(thresholds[1:]) + [np.inf] + charge = 0.0 + for lower, upper_limit, rate in zip(thresholds, upper, rates): + charge += rate * max(0.0, min(top, upper_limit) - max(bottom, lower)) + return charge + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS)) +def test_charge_matches_band_by_band_reference(people, year): + people = _with_contributions(people, _base_contributions(people)) + sim = Simulation(situation=_situation(people, year)) + chargeable = np.array( + sim.calculate("pension_annual_allowance_chargeable_amount", year), dtype=float + ) + charge = np.array( + sim.calculate("personal_pension_contributions_tax", year), dtype=float + ) + reduced_net_income = np.maximum( + 0, + np.array(sim.calculate("adjusted_net_income", year), dtype=float) + - np.array(sim.calculate("allowances", year), dtype=float), + ) + scottish = np.array(sim.calculate("pays_scottish_income_tax", year)) + rates = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax.rates + uk_thresholds, uk_rates = list(rates.uk.thresholds), list(rates.uk.rates) + sco_thresholds = list(rates.scotland.rates.thresholds) + sco_rates = list(rates.scotland.rates.rates) + if year >= 2018: + # s. 227(4AA): the starter band is charged at the Scottish basic rate. + sco_rates[0] = sco_rates[1] + for i in range(len(people)): + thresholds, band_rates = ( + (sco_thresholds, sco_rates) if scottish[i] else (uk_thresholds, uk_rates) + ) + expected = _reference_charge( + chargeable[i], reduced_net_income[i], thresholds, band_rates + ) + assert abs(charge[i] - expected) < TOLERANCE + # Bounds: nil exactly when nothing is chargeable, and between the + # lowest and highest rate charged. + assert (charge[i] == 0) == (chargeable[i] == 0) + assert charge[i] >= min(band_rates) * chargeable[i] - TOLERANCE + assert charge[i] <= max(band_rates) * chargeable[i] + TOLERANCE + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS), st.integers(0, 150_000)) +def test_unused_allowance_reduces_chargeable_amount_one_for_one(people, year, extra): + people = _with_contributions(people, _base_contributions(people)) + variables = [ + "pension_annual_allowance_chargeable_amount", + "personal_pension_contributions_tax", + ] + before = _calc(people, year, variables) + more_unused = [ + { + **p, + "unused_pension_annual_allowance": p["unused_pension_annual_allowance"] + + extra, + } + for p in people + ] + after = _calc(more_unused, year, variables) + chargeable_before = before["pension_annual_allowance_chargeable_amount"] + expected = np.maximum(0, chargeable_before - extra) + assert np.allclose( + after["pension_annual_allowance_chargeable_amount"], expected, atol=TOLERANCE + ) + assert np.all( + after["personal_pension_contributions_tax"] + <= before["personal_pension_contributions_tax"] + TOLERANCE + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_chargeable_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_chargeable_amount.py new file mode 100644 index 0000000000..c9c4e300c4 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_chargeable_amount.py @@ -0,0 +1,34 @@ +from policyengine_uk.model_api import * + + +class pension_annual_allowance_chargeable_amount(Variable): + value_type = float + entity = Person + label = "Chargeable amount for the pension annual allowance charge" + documentation = ( + "The default chargeable amount (FA 2004 s. 227ZA(3)): the total pension " + "input amount less the annual allowance, which unused annual allowance " + "brought forward increases (s. 228A(2)). Nil if the input amount is " + "within that allowance." + ) + definition_period = YEAR + reference = [ + dict( + title="Finance Act 2004 s. 227ZA", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/227ZA", + ), + dict( + title="Finance Act 2004 s. 228A", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ), + ] + unit = GBP + + def formula(person, period, parameters): + pension_input_amount = person( + "pension_contributions_for_annual_allowance", period + ) + annual_allowance = person("pension_annual_allowance", period) + max_( + 0, person("unused_pension_annual_allowance", period) + ) + return max_(0, pension_input_amount - annual_allowance) 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 a506e58726..5b26c82d24 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -5,25 +5,49 @@ class pension_contributions_relief(Variable): value_type = float entity = Person label = "Reduction in taxable income from pension contributions" - definition_period = YEAR - reference = dict( - title="Finance Act 2004 s. 188-194", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + documentation = ( + "Relief for the contributions an individual pays to registered pension " + "schemes (FA 2004 s. 188), up to the annual limit for relief: the " + "greater of the individual's relevant UK earnings and the basic amount " + "(s. 190). The annual allowance does not limit relief. Contributions " + "above it are relieved here, and the annual allowance charge " + "(personal_pension_contributions_tax) then recovers that relief " + "(s. 227)." ) + definition_period = YEAR + reference = [ + dict( + title="Finance Act 2004 s. 188", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", + ), + dict( + title="Finance Act 2004 s. 189", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/189", + ), + dict( + title="Finance Act 2004 s. 190", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", + ), + ] unit = GBP def formula(person, period, parameters): + hmrc = parameters(period).gov.hmrc contributions = person("pension_contributions", period) - pension_allowance = person("pension_annual_allowance", period) - age_limit = parameters( - period - ).gov.hmrc.pensions.pension_contributions_relief_age_limit - income = add(person, period, ["employment_income", "self_employment_income"]) - under_age_limit = person("age", period) < age_limit - basic_amount = parameters( - period - ).gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount - tax_relief = min_(income, contributions) * under_age_limit - max_pension_relief = max_(basic_amount, pension_allowance) - - return min_(tax_relief, max_pension_relief) + # Relevant UK earnings (s. 189(2)) are employment income and trading + # income. A trading loss gives no relevant earnings; it does not + # reduce employment income. + relevant_uk_earnings = person("employment_income", period) + max_( + 0, person("self_employment_income", period) + ) + # s. 190(1)-(2): relief up to relevant UK earnings, or the basic amount + # if that is greater. + annual_limit = max_( + relevant_uk_earnings, + hmrc.income_tax.reliefs.pension_contribution.basic_amount, + ) + # 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 diff --git a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py index 4fad564e10..6f500a7fa5 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py @@ -4,7 +4,19 @@ class personal_pension_contributions_tax(Variable): value_type = float entity = Person - label = "Reduction in taxable income from pension contributions to pensions other than the State Pension" + label = "Pension annual allowance charge" + documentation = ( + "The annual allowance charge (FA 2004 s. 227) on the chargeable amount, " + "the pension input amount above the annual allowance. It is charged at " + "the basic, higher and additional rates on the slices of the chargeable " + "amount that fall in each band when it is added to the top of the " + "individual's reduced net income, their income after allowances " + "(s. 227(4A)-(4B)). A Scottish taxpayer pays the Scottish rates, with " + "the Scottish basic rate on any slice within the Scottish basic rate " + "limit (s. 227(4AA)). Contributions above the annual allowance still " + "get relief (pension_contributions_relief), so the charge recovers that " + "relief once rather than taxing the excess twice." + ) definition_period = YEAR reference = [ dict( @@ -19,16 +31,43 @@ class personal_pension_contributions_tax(Variable): unit = GBP def formula(person, period, parameters): - p = parameters(period).gov.hmrc.income_tax.rates.uk - taxed_income = person("taxed_income", period) - - personal_pension_contributions = person( - "pension_contributions_for_annual_allowance", period + rates = parameters(period).gov.hmrc.income_tax.rates + chargeable_amount = person("pension_annual_allowance_chargeable_amount", period) + # Reduced net income is income after allowances (ITA 2007 s. 23 + # Step 3). Pension contributions relief and Gift Aid are deductions + # here, where the law instead extends the rate limits (s. 227(4C)), + # which places the chargeable amount at the same point in the bands. + reduced_net_income = max_( + 0, + person("adjusted_net_income", period) - person("allowances", period), ) - pension_annual_allowance = person("pension_annual_allowance", period) - taxable_contributions = ( - personal_pension_contributions - pension_annual_allowance + top_of_charge = reduced_net_income + chargeable_amount + + uk_charge = rates.uk.calc(top_of_charge) - rates.uk.calc(reduced_net_income) + + scottish_rates = rates.scotland.rates + scottish_charge = scottish_rates.calc(top_of_charge) - scottish_rates.calc( + reduced_net_income ) + # s. 227(4AA)(b)(i): no slice is charged at the starter rate. The + # Scottish basic rate applies to every slice up to the Scottish basic + # rate limit, so lift the starter band to the basic rate. The starter + # bracket is null before 2018-19, when the lowest Scottish rate was the + # basic rate. + starter_bracket = person.simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets[ + 0 + ] + if starter_bracket.rate(period.start) is not None: + starter_rate = scottish_rates.rates[0] + scottish_basic_rate = scottish_rates.rates[1] + starter_limit = scottish_rates.thresholds[1] + starter_band_slice = min_(top_of_charge, starter_limit) - min_( + reduced_net_income, starter_limit + ) + scottish_charge += (scottish_basic_rate - starter_rate) * starter_band_slice - tax_rate = p.marginal_rates(taxed_income) - return max_(0, taxable_contributions * tax_rate) + return where( + person("pays_scottish_income_tax", period), + scottish_charge, + uk_charge, + ) diff --git a/policyengine_uk/variables/input/unused_pension_annual_allowance.py b/policyengine_uk/variables/input/unused_pension_annual_allowance.py new file mode 100644 index 0000000000..59b2549769 --- /dev/null +++ b/policyengine_uk/variables/input/unused_pension_annual_allowance.py @@ -0,0 +1,23 @@ +from policyengine_uk.model_api import * + + +class unused_pension_annual_allowance(Variable): + label = "unused pension annual allowance brought forward" + documentation = ( + "Unused annual allowance from the three previous tax years that is " + "available for this tax year (FA 2004 s. 228A(5)): the amount by which " + "each of those years' annual allowance exceeded the total pension " + "input amount, for years in which the individual was a member of a " + "registered pension scheme, less any part already used up in an " + "intervening year (earliest year first). It increases this year's " + "annual allowance (s. 228A(2)). Survey data does not record past " + "pension input amounts, so this defaults to nil." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = dict( + title="Finance Act 2004 s. 228A", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ) From 1bb293cac8fe8f94db513d16f9af349a9ff84397 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:07:24 -0400 Subject: [PATCH 02/14] Raise the rate limits for relief at source and Gift Aid in the charge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review round 1 on #2240: - The annual allowance charge now stacks the chargeable amount on statutory reduced net income and raises every rate limit by relieved relief-at-source contributions and grossed-up Gift Aid (FA 2004 s. 227(4C)). Translating the model's deductions and flooring at zero lost the extension once relief-at-source contributions passed income after allowances (£8,460 charged where the statute gives £8,000). - Only relief at source can give the increase to the £3,600 basic amount (s. 191(7)); personal contributions are taken as relief at source, employee contributions as net pay, as capital_gains_tax does. - Recognise the Scottish starter band only when the raw bracket has both a rate and a threshold. - Property tests cover both relief methods and Gift Aid, use float32-aware tolerances, and exclude Scotland from the no-double-tax property; a YAML example pins the Scottish deviation from relieving relief at source by deduction. Co-Authored-By: Claude Opus 5.5 --- changelog.d/2237.fixed.md | 2 +- .../pension_contributions_relief.yaml | 22 +++ .../personal_pension_contributions_tax.yaml | 71 +++++++++- ...est_pension_annual_allowance_properties.py | 125 ++++++++++++++---- .../pensions/pension_contributions_relief.py | 31 +++-- .../private_pension_contributions_tax.py | 92 +++++++++---- 6 files changed, 275 insertions(+), 68 deletions(-) diff --git a/changelog.d/2237.fixed.md b/changelog.d/2237.fixed.md index 59cc022a7e..bbb0352cef 100644 --- a/changelog.d/2237.fixed.md +++ b/changelog.d/2237.fixed.md @@ -1 +1 @@ -Relieve pension contributions up to the annual limit in Finance Act 2004 s. 190 (the greater of relevant UK earnings and £3,600) instead of the annual allowance, and charge the annual allowance charge once on the excess (s. 227), so contributions above the allowance are no longer taxed twice. The charge now taxes the chargeable amount as the top slice of reduced net income at the basic, higher and additional rates (s. 227(4A)), or at the Scottish rates with the starter band charged at the Scottish basic rate (s. 227(4AA)), rather than at one marginal rate. Add `unused_pension_annual_allowance` for unused allowance carried forward from the three previous years (s. 228A; nil by default) and `pension_annual_allowance_chargeable_amount`. A trading loss no longer reduces relevant earnings from employment. +Relieve pension contributions up to the annual limit in Finance Act 2004 s. 190 (relevant UK earnings, raised to £3,600 where lower, the increase only through relief at source, s. 191(7)) instead of the annual allowance, and charge the annual allowance charge once on the excess (s. 227), so contributions above the allowance are no longer taxed twice. The charge now taxes the chargeable amount as the top slice of reduced net income at the basic, higher and additional rates (s. 227(4A)), with the rate limits raised by relief-at-source contributions and grossed-up Gift Aid (s. 227(4C)), or at the Scottish rates with the starter band charged at the Scottish basic rate (s. 227(4AA)), rather than at one marginal rate. Add `unused_pension_annual_allowance` for unused allowance carried forward from the three previous years (s. 228A; nil by default) and `pension_annual_allowance_chargeable_amount`. A trading loss no longer reduces relevant earnings from employment. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml index a81063aa63..7ea1ec526f 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml @@ -65,6 +65,28 @@ output: pension_contributions_relief: 3_600 +- name: A net pay arrangement cannot give the basic-amount increase (s. 191(7)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 2_000 + employee_pension_contributions_reported: 3_000 + output: + pension_contributions_relief: 2_000 + +- name: Only relief at source fills the basic-amount increase (s. 191(7)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 2_000 + employee_pension_contributions_reported: 1_500 + personal_pension_contributions: 1_000 + output: + # 2_000 of earnings relieves all 2_500 up to earnings; the increase to + # 3_600 can only come from the 1_000 of relief at source, of which 500 is + # left after earnings: 2_000 + 500. + pension_contributions_relief: 2_500 + - name: Relief up to relevant earnings, not beyond (s. 190(1)) period: 2024 absolute_error_margin: 0.01 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 d106b4d8f9..3afa8bb08c 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 @@ -103,16 +103,44 @@ absolute_error_margin: 0.01 input: employment_income: 60_000 - personal_pension_contributions: 60_000 + employee_pension_contributions_reported: 60_000 employer_pension_contributions: 40_000 output: - # Relief 60_000; reduced net income 60_000 - 12_570 - 60_000 floored at 0. - # Chargeable amount 100_000 - 60_000 = 40_000: 37_700 at 20% and 2_300 - # at 40%. + # Net pay: relief 60_000 comes off employment income, so reduced net + # income is 60_000 - 60_000 floored at 0 (the personal allowance is + # unused). Chargeable amount 100_000 - 60_000 = 40_000: 37_700 at 20% and + # 2_300 at 40%. pension_contributions_relief: 60_000 pension_annual_allowance_chargeable_amount: 40_000 personal_pension_contributions_tax: 37_700 * 0.2 + 2_300 * 0.4 +- name: Relief at source raises the rate limits instead (s. 227(4C)) + period: 2024 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + personal_pension_contributions: 60_000 + employer_pension_contributions: 40_000 + output: + # Relief at source leaves reduced net income at 60_000 - 12_570 = 47_430 + # and raises the basic rate limit by 60_000 to 97_700, so all 40_000 + # falls at 20% (HMRC PTM056120). + personal_pension_contributions_tax: 40_000 * 0.2 + +- name: Gift Aid raises the rate limits by its grossed-up amount (s. 227(4C)) + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 60_000 + personal_pension_contributions: 10_000 + employer_pension_contributions: 60_000 + gift_aid: 1_000 + output: + # Reduced net income 60_000 - 12_570 = 47_430. Basic rate limit + # 37_700 + 10_000 + 1_250 (Gift Aid grossed up) = 48_950. Chargeable + # amount 70_000 - 60_000 = 10_000: 1_520 at 20%, 8_480 at 40%. + personal_pension_contributions_tax: 1_520 * 0.2 + 8_480 * 0.4 + - name: Scottish taxpayer pays Scottish rates on the chargeable amount period: 2026 absolute_error_margin: 0.01 @@ -131,14 +159,45 @@ input: age: 45 employment_income: 100_000 - personal_pension_contributions: 100_000 + employee_pension_contributions_reported: 100_000 region: SCOTLAND output: - # Reduced net income 100_000 - 12_570 - 100_000 floored at 0. Chargeable + # Net pay: reduced net income 100_000 - 100_000 floored at 0. Chargeable # amount 40_000: 20% up to the 16_956 basic rate limit (the starter band # included), 21% to 31_092, then 42%. personal_pension_contributions_tax: 16_956 * 0.2 + (31_092 - 16_956) * 0.21 + (40_000 - 31_092) * 0.42 +# A model deviation, documented: the model gives relief at source as a +# deduction, so a Scottish starter-rate slice is relieved at 19%, while relief +# at source pays 20% and the charge on that slice is at the Scottish basic rate +# (s. 227(4AA)). Contributing beyond the allowance therefore costs this taxpayer +# 1% of the 3_967 starter band (£39.67) more income tax. +- name: Scottish relief at source at the annual allowance (model deviation, see comment) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 80_000 + personal_pension_contributions: 60_000 + region: SCOTLAND + output: + # Taxable income 80_000 - 12_570 - 60_000 = 7_430: 3_967 at 19%, 3_463 at 20%. + income_tax: 3_967 * 0.19 + 3_463 * 0.2 + +- name: Scottish relief at source beyond the annual allowance (model deviation, see comment) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 80_000 + personal_pension_contributions: 67_430 + region: SCOTLAND + output: + # No taxable income after relief; the 7_430 chargeable amount sits within + # the raised Scottish basic rate limit, at 20%. + personal_pension_contributions_tax: 7_430 * 0.2 + income_tax: 7_430 * 0.2 + - name: Unused annual allowance brought forward covers the excess (s. 228A) period: 2024 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 0d028c92b2..f00ddda502 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -1,17 +1,23 @@ """Property-based tests for pension contributions relief and the annual allowance charge (issue #2237). -Relief runs to the annual limit in FA 2004 s. 190 (the greater of relevant UK -earnings and the basic amount), not to the annual allowance. The annual -allowance charge (s. 227) then taxes the pension input amount above the -allowance, plus any unused allowance brought forward (s. 228A), as the top -slice of reduced net income at the non-savings rates (s. 227(4A)), or the -Scottish rates with the starter band at the Scottish basic rate (s. 227(4AA)). +Relief runs to the annual limit in FA 2004 s. 190 (relevant UK earnings, +raised to the basic amount where lower, the increase only through relief at +source, s. 191(7)), not to the annual allowance. The model takes personal +pension contributions as relief at source and employee contributions as a net +pay arrangement. The annual allowance charge (s. 227) then taxes the pension +input amount above the allowance, plus any unused allowance brought forward +(s. 228A), as the top slice of reduced net income at the non-savings rates +(s. 227(4A)), with the rate limits raised by relief-at-source contributions +and grossed-up Gift Aid (s. 227(4C)), or at the Scottish rates with the +starter band at the Scottish basic rate (s. 227(4AA)). -Invariants, for any generated population of single adults: +Invariants, for any generated population of single adults with contributions +split between relief at source and net pay: 1. Relief bounds: relief is the lesser of the contributions and the s. 190 - limit for anyone under 75, and nil at 75 or over. + limit (the basic-amount increase capped at relief-at-source contributions) + for anyone under 75, and nil at 75 or over. 2. The excess is taxed once: raising contributions, up to relevant earnings, never raises income tax by more than it raises the charge. So income tax before the charge never rises with contributions. @@ -31,7 +37,11 @@ tax can rise (intended: that is what the statute does). It also stops where contributions use up income after allowances: relief beyond that is worth nothing, while the charge still falls on the whole input amount (also -intended). +intended). And it is restricted to taxpayers outside Scotland. The model gives +relief at source as a deduction, which relieves a Scottish starter-rate slice +at 19%, while the charge on that slice is at the Scottish basic rate +(s. 227(4AA)), so income tax can rise by up to 1% of the starter band +(a model deviation: relief at source pays 20%; see the YAML example). """ import numpy as np @@ -51,6 +61,13 @@ YEARS = [2020, 2024, 2026, 2027] REGIONS = ["LONDON", "NORTH_EAST", "WALES", "SCOTLAND"] TOLERANCE = 0.01 +# Simulated values are float32: allow a relative error on large amounts. +RELATIVE_TOLERANCE = 1e-6 + + +def _close(actual, expected): + return abs(actual - expected) <= max(TOLERANCE, RELATIVE_TOLERANCE * abs(expected)) + person_strategy = st.fixed_dictionaries( { @@ -59,6 +76,9 @@ "self_employment_income": st.integers(-20_000, 200_000), "employer_pension_contributions": st.integers(0, 80_000), "contribution_share": st.floats(0, 1.2), + # Share of contributions paid through a net pay arrangement. + "net_pay_share": st.sampled_from([0.0, 0.5, 1.0]), + "gift_aid": st.integers(0, 5_000), "savings_interest_income": st.integers(0, 30_000), "dividend_income": st.integers(0, 80_000), "unused_pension_annual_allowance": st.integers(0, 120_000), @@ -82,6 +102,8 @@ def _situation(people, year, overrides=None): "self_employment_income", "employer_pension_contributions", "personal_pension_contributions", + "employee_pension_contributions_reported", + "gift_aid", "savings_interest_income", "dividend_income", "unused_pension_annual_allowance", @@ -101,7 +123,12 @@ def _relevant_earnings(person): def _with_contributions(people, contributions): return [ - {**person, "personal_pension_contributions": float(c)} + { + **person, + "personal_pension_contributions": float(c) * (1 - person["net_pay_share"]), + "employee_pension_contributions_reported": float(c) + * person["net_pay_share"], + } for person, c in zip(people, contributions) ] @@ -122,13 +149,12 @@ def test_relief_is_the_section_190_limit(people, year): out = _calc(people, year, ["pension_contributions_relief"]) basic_amount = 3_600 for i, person in enumerate(people): - limit = max(_relevant_earnings(person), basic_amount) - expected = ( - min(person["personal_pension_contributions"], limit) - if person["age"] < 75 - else 0 - ) - assert abs(out["pension_contributions_relief"][i] - expected) < TOLERANCE + earnings = _relevant_earnings(person) + relief_at_source = person["personal_pension_contributions"] + total = relief_at_source + person["employee_pension_contributions_reported"] + limit = earnings + min(max(0, basic_amount - earnings), relief_at_source) + expected = min(total, limit) if person["age"] < 75 else 0 + assert _close(out["pension_contributions_relief"][i], expected) @PROPERTY_SETTINGS @@ -163,7 +189,9 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste "age": min(p["age"], 74), "savings_interest_income": 0, "dividend_income": 0, + "gift_aid": 0, "unused_pension_annual_allowance": 0, + "region": "NORTH_EAST" if p["region"] == "SCOTLAND" else p["region"], } for p in people ] @@ -191,13 +219,15 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste assert np.all(tax_above["income_tax"] <= tax_at["income_tax"] + TOLERANCE) -def _reference_charge(chargeable, reduced_net_income, thresholds, rates): - """Band-by-band sum of the chargeable amount stacked on income.""" +def _reference_charge(chargeable, reduced_net_income, thresholds, rates, extension): + """Band-by-band sum of the chargeable amount stacked on income, with every + rate limit raised by ``extension``.""" bottom = reduced_net_income top = reduced_net_income + chargeable - upper = list(thresholds[1:]) + [np.inf] + lowers = [thresholds[0]] + [t + extension for t in thresholds[1:]] + uppers = lowers[1:] + [np.inf] charge = 0.0 - for lower, upper_limit, rate in zip(thresholds, upper, rates): + for lower, upper_limit, rate in zip(lowers, uppers, rates): charge += rate * max(0.0, min(top, upper_limit) - max(bottom, lower)) return charge @@ -213,10 +243,17 @@ def test_charge_matches_band_by_band_reference(people, year): charge = np.array( sim.calculate("personal_pension_contributions_tax", year), dtype=float ) + get = lambda v: np.array(sim.calculate(v, year), dtype=float) # noqa: E731 + # s. 227(4B)-(4C): reduced net income keeps relief-at-source contributions + # and Gift Aid, which raise the rate limits by their gross amounts. + relief_at_source = np.minimum( + get("personal_pension_contributions"), get("pension_contributions_relief") + ) + extension = get("gift_aid_grossed_up") + relief_at_source reduced_net_income = np.maximum( 0, - np.array(sim.calculate("adjusted_net_income", year), dtype=float) - - np.array(sim.calculate("allowances", year), dtype=float), + get("adjusted_net_income") + - np.maximum(0, get("allowances") - get("gift_aid") - relief_at_source), ) scottish = np.array(sim.calculate("pays_scottish_income_tax", year)) rates = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax.rates @@ -231,9 +268,9 @@ def test_charge_matches_band_by_band_reference(people, year): (sco_thresholds, sco_rates) if scottish[i] else (uk_thresholds, uk_rates) ) expected = _reference_charge( - chargeable[i], reduced_net_income[i], thresholds, band_rates + chargeable[i], reduced_net_income[i], thresholds, band_rates, extension[i] ) - assert abs(charge[i] - expected) < TOLERANCE + assert _close(charge[i], expected) # Bounds: nil exactly when nothing is chargeable, and between the # lowest and highest rate charged. assert (charge[i] == 0) == (chargeable[i] == 0) @@ -268,3 +305,41 @@ def test_unused_allowance_reduces_chargeable_amount_one_for_one(people, year, ex after["personal_pension_contributions_tax"] <= before["personal_pension_contributions_tax"] + TOLERANCE ) + + +def test_scottish_basic_band_is_not_lifted_when_the_starter_band_is_removed(): + """A reform that removes the Scottish starter band (null threshold, basic + band from zero) leaves the lowest band at the basic rate: the charge is + not lifted to the intermediate rate.""" + from policyengine_uk.utils.scenario import Scenario + + def remove_starter_band(simulation): + system = simulation.tax_benefit_system + system.reset_parameters() + brackets = system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets + brackets[0].threshold.update(period="year:2026-01-01:1", value=None) + brackets[1].threshold.update(period="year:2026-01-01:1", value=0) + system.process_parameters() + + situation = { + "people": { + "p": { + "age": {2026: 45}, + "employment_income": {2026: 12_570}, + "employer_pension_contributions": {2026: 70_000}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"], "region": {2026: "SCOTLAND"}}}, + } + sim = Simulation( + situation=situation, + scenario=Scenario( + simulation_modifier=remove_starter_band, applied_before_data_load=True + ), + ) + rates = sim.tax_benefit_system.parameters(2026).gov.hmrc.income_tax.rates + assert float(rates.scotland.rates.rates[0]) == 0.2 + # Reduced net income 0; chargeable amount 10_000, all in the basic band. + charge = float(sim.calculate("personal_pension_contributions_tax", 2026)[0]) + assert abs(charge - 10_000 * 0.2) < TOLERANCE 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 5b26c82d24..6602af436c 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -8,10 +8,13 @@ class pension_contributions_relief(Variable): documentation = ( "Relief for the contributions an individual pays to registered pension " "schemes (FA 2004 s. 188), up to the annual limit for relief: the " - "greater of the individual's relevant UK earnings and the basic amount " - "(s. 190). The annual allowance does not limit relief. Contributions " - "above it are relieved here, and the annual allowance charge " - "(personal_pension_contributions_tax) then recovers that relief " + "individual's relevant UK earnings, raised to the basic amount where " + "earnings are lower (s. 190). Only relief at source can give the part " + "above earnings (s. 191(7)); the model takes personal pension " + "contributions as relief at source and employee contributions as a " + "net pay arrangement. The annual allowance does not limit relief. " + "Contributions above it are relieved here, and the annual allowance " + "charge (personal_pension_contributions_tax) then recovers that relief " "(s. 227)." ) definition_period = YEAR @@ -28,6 +31,10 @@ class pension_contributions_relief(Variable): title="Finance Act 2004 s. 190", href="https://www.legislation.gov.uk/ukpga/2004/12/section/190", ), + dict( + title="Finance Act 2004 s. 191", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/191", + ), ] unit = GBP @@ -40,12 +47,18 @@ 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, or the basic amount - # if that is greater. - annual_limit = max_( - relevant_uk_earnings, - hmrc.income_tax.reliefs.pension_contribution.basic_amount, + # 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, + ), + person("personal_pension_contributions", period), ) + 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 diff --git a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py index 6f500a7fa5..17d05685f0 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py @@ -1,6 +1,18 @@ from policyengine_uk.model_api import * +def _top_slice_charge(bottom, top, thresholds, rates, extension): + """Tax on the slice from ``bottom`` to ``top`` of income, at ``rates`` + between ``thresholds``, every limit above the first raised by + ``extension``.""" + charge = 0 + for i, rate in enumerate(rates): + lower = thresholds[i] + (extension if i > 0 else 0) + upper = thresholds[i + 1] + extension if i + 1 < len(thresholds) else np.inf + charge = charge + rate * max_(0, min_(top, upper) - max_(bottom, lower)) + return charge + + class personal_pension_contributions_tax(Variable): value_type = float entity = Person @@ -11,11 +23,13 @@ class personal_pension_contributions_tax(Variable): "the basic, higher and additional rates on the slices of the chargeable " "amount that fall in each band when it is added to the top of the " "individual's reduced net income, their income after allowances " - "(s. 227(4A)-(4B)). A Scottish taxpayer pays the Scottish rates, with " - "the Scottish basic rate on any slice within the Scottish basic rate " - "limit (s. 227(4AA)). Contributions above the annual allowance still " - "get relief (pension_contributions_relief), so the charge recovers that " - "relief once rather than taxing the excess twice." + "(s. 227(4A)-(4B)), with the rate limits raised by relief-at-source " + "contributions and grossed-up Gift Aid (s. 227(4C)). A Scottish " + "taxpayer pays the Scottish rates, with the Scottish basic rate on any " + "slice within the Scottish basic rate limit (s. 227(4AA)). " + "Contributions above the annual allowance still get relief " + "(pension_contributions_relief), so the charge recovers that relief " + "once rather than taxing the excess twice." ) definition_period = YEAR reference = [ @@ -33,38 +47,62 @@ class personal_pension_contributions_tax(Variable): def formula(person, period, parameters): rates = parameters(period).gov.hmrc.income_tax.rates chargeable_amount = person("pension_annual_allowance_chargeable_amount", period) - # Reduced net income is income after allowances (ITA 2007 s. 23 - # Step 3). Pension contributions relief and Gift Aid are deductions - # here, where the law instead extends the rate limits (s. 227(4C)), - # which places the chargeable amount at the same point in the bands. - reduced_net_income = max_( + # The model deducts relief at source (personal pension contributions) + # and Gift Aid from income, where the law leaves them in reduced net + # income and raises the rate limits instead (s. 227(4C); FA 2004 + # s. 192(4), ITA 2007 s. 414). Undo the deduction and raise the + # limits, as capital_gains_tax does for the basic rate band. Net pay + # contributions stay deducted, as in law. + relief_at_source = min_( + person("personal_pension_contributions", period), + person("pension_contributions_relief", period), + ) + rate_limit_extension = person("gift_aid_grossed_up", period) + relief_at_source + allowances_at_step_3 = max_( 0, - person("adjusted_net_income", period) - person("allowances", period), + person("allowances", period) + - person("gift_aid", period) + - relief_at_source, + ) + # Reduced net income: income after Step 3 of ITA 2007 s. 23 (s. 227(4B)). + reduced_net_income = max_( + 0, person("adjusted_net_income", period) - allowances_at_step_3 ) top_of_charge = reduced_net_income + chargeable_amount - uk_charge = rates.uk.calc(top_of_charge) - rates.uk.calc(reduced_net_income) - - scottish_rates = rates.scotland.rates - scottish_charge = scottish_rates.calc(top_of_charge) - scottish_rates.calc( - reduced_net_income + uk_charge = _top_slice_charge( + reduced_net_income, + top_of_charge, + list(rates.uk.thresholds), + list(rates.uk.rates), + rate_limit_extension, ) + # s. 227(4AA)(b)(i): no slice is charged at the starter rate. The # Scottish basic rate applies to every slice up to the Scottish basic - # rate limit, so lift the starter band to the basic rate. The starter - # bracket is null before 2018-19, when the lowest Scottish rate was the - # basic rate. + # rate limit, so charge the starter band at the basic rate. The + # starter bracket (the first) is null before 2018-19, when the lowest + # Scottish rate was the basic rate; it is present only with both a + # rate and a threshold. + scottish_rates = rates.scotland.rates + scottish_band_rates = list(scottish_rates.rates) starter_bracket = person.simulation.tax_benefit_system.parameters.gov.hmrc.income_tax.rates.scotland.rates.brackets[ 0 ] - if starter_bracket.rate(period.start) is not None: - starter_rate = scottish_rates.rates[0] - scottish_basic_rate = scottish_rates.rates[1] - starter_limit = scottish_rates.thresholds[1] - starter_band_slice = min_(top_of_charge, starter_limit) - min_( - reduced_net_income, starter_limit - ) - scottish_charge += (scottish_basic_rate - starter_rate) * starter_band_slice + has_starter_band = ( + starter_bracket.rate(period.start) is not None + and starter_bracket.threshold(period.start) is not None + and len(scottish_band_rates) > 1 + ) + if has_starter_band: + scottish_band_rates[0] = scottish_band_rates[1] + scottish_charge = _top_slice_charge( + reduced_net_income, + top_of_charge, + list(scottish_rates.thresholds), + scottish_band_rates, + rate_limit_extension, + ) return where( person("pays_scottish_income_tax", period), From c922b5850171aa592710d749452d60169c482854 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:50:53 -0400 Subject: [PATCH 03/14] Allow float32 rounding in the charge bounds Review round 2 on #2240: the upper and lower charge bounds now allow the same relative float32 error as the other assertions. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_pension_annual_allowance_properties.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index f00ddda502..98611839b0 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -274,8 +274,9 @@ def test_charge_matches_band_by_band_reference(people, year): # Bounds: nil exactly when nothing is chargeable, and between the # lowest and highest rate charged. assert (charge[i] == 0) == (chargeable[i] == 0) - assert charge[i] >= min(band_rates) * chargeable[i] - TOLERANCE - assert charge[i] <= max(band_rates) * chargeable[i] + TOLERANCE + slack = max(TOLERANCE, RELATIVE_TOLERANCE * max(band_rates) * chargeable[i]) + assert charge[i] >= min(band_rates) * chargeable[i] - slack + assert charge[i] <= max(band_rates) * chargeable[i] + slack @PROPERTY_SETTINGS From dfbba870d575c03c02d61091d39ebba97a8a0e67 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 01:18:18 -0400 Subject: [PATCH 04/14] Reconcile pension annual allowance charge with Gift Aid --- changelog.d/2237.fixed.md | 2 +- docs/engineering/gift-aid.md | 12 ++ ...on_contributions_for_annual_allowance.yaml | 2 +- .../personal_pension_contributions_tax.yaml | 123 +++++++++++++++++- ...est_pension_annual_allowance_properties.py | 44 ++++++- .../private_pension_contributions_tax.py | 25 ++-- 6 files changed, 184 insertions(+), 24 deletions(-) diff --git a/changelog.d/2237.fixed.md b/changelog.d/2237.fixed.md index bbb0352cef..902bbba243 100644 --- a/changelog.d/2237.fixed.md +++ b/changelog.d/2237.fixed.md @@ -1 +1 @@ -Relieve pension contributions up to the annual limit in Finance Act 2004 s. 190 (relevant UK earnings, raised to £3,600 where lower, the increase only through relief at source, s. 191(7)) instead of the annual allowance, and charge the annual allowance charge once on the excess (s. 227), so contributions above the allowance are no longer taxed twice. The charge now taxes the chargeable amount as the top slice of reduced net income at the basic, higher and additional rates (s. 227(4A)), with the rate limits raised by relief-at-source contributions and grossed-up Gift Aid (s. 227(4C)), or at the Scottish rates with the starter band charged at the Scottish basic rate (s. 227(4AA)), rather than at one marginal rate. Add `unused_pension_annual_allowance` for unused allowance carried forward from the three previous years (s. 228A; nil by default) and `pension_annual_allowance_chargeable_amount`. A trading loss no longer reduces relevant earnings from employment. +- Limited pension contributions relief by relevant UK earnings (or the £3,600 basic amount through relief at source) instead of the annual allowance. Calculated the annual allowance charge across UK or Scottish tax bands with unused allowance carried forward, and preserved Gift Aid's gross band extension without deducting the gift from the charge's income base. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 20ac92abe7..107c2e7dae 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -82,3 +82,15 @@ additional relief. Changing tax band can also increase the Personal Savings Allowance discretely under s. 12B, so its relief cannot be bounded by a constant times a very small gift. Tests of the continuous band-extension bound must allow for that allowance change or hold the zero-rate allowance fixed. + +## Pension annual allowance charge + +The annual allowance charge uses reduced net income after ITA 2007 s. 23 +Step 3 ([FA 2004 s. 227(4B)](https://www.legislation.gov.uk/ukpga/2004/12/section/227)). +It starts from `net_income`, before the s. 58 gross Gift Aid deduction, and +subtracts `allowances` after removing relieved relief-at-source pension +contributions. Gift Aid is already excluded from those allowances. The charge +then stacks the excess pension input amount on this base, extending the rate +limits by `gift_aid_grossed_up` plus relieved relief-at-source contributions +(s. 227(4C)). This keeps the Gift Aid adjustment to adjusted net income separate +from the amount charged to tax. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml index bc247412d8..a68044e979 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml @@ -76,5 +76,5 @@ pension_contributions_via_salary_sacrifice: 0 output: # Total: 10_000 + 5_000 + 50_000 = 65_000 - # This exceeds the 2024 AA of 40_000 (verified separately by AA tax charge test) + # This exceeds the 2024 AA of 60_000 (verified separately by AA tax charge test) pension_contributions_for_annual_allowance: 65_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 3afa8bb08c..ef8ce48fe4 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 @@ -7,6 +7,11 @@ # # The pension annual allowance is £60,000 from 2023-04-06 (FA 2004 s.228, # as amended by Finance (No. 2) Act 2023). +# Relief and the annual allowance charge are separate: FA 2004 ss.188-190 +# cap individual relief at relevant UK earnings (or the £3,600 basic amount), +# while ss.227-238 govern pension input amounts and the annual allowance charge. +# https://www.legislation.gov.uk/ukpga/2004/12/section/190 +# https://www.legislation.gov.uk/ukpga/2004/12/part/4/chapter/5/crossheading/annual-allowance-charge - name: Contributions within annual allowance - no tax charge period: 2024 @@ -93,8 +98,10 @@ output: pension_contributions_relief: 80_000 pension_annual_allowance_chargeable_amount: 20_000 - # Reduced net income 150_000 - 80_000 = 70_000; 70_000 to 90_000 is all - # higher rate. + # Model reduced net income £150,000, with the basic and higher limits + # extended by £80,000. The entire charge is at the higher rate. + # The omitted RAS adjustment to the personal-allowance taper does not + # change the charge's band in this case. personal_pension_contributions_tax: 8_000 income_tax: 28_460 @@ -141,7 +148,75 @@ # amount 70_000 - 60_000 = 10_000: 1_520 at 20%, 8_480 at 40%. personal_pension_contributions_tax: 1_520 * 0.2 + 8_480 * 0.4 -- name: Scottish taxpayer pays Scottish rates on the chargeable amount +- name: Gift Aid with relief at source above relevant earnings crosses the basic band + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 40_000 + personal_pension_contributions: 70_000 + employer_pension_contributions: 50_000 + gift_aid: 1_000 + region: LONDON + output: + # FA 2004 ss.188-190: only £40,000 of the £70,000 personal contribution + # attracts relief. The unrelieved £30,000 remains part of the pension + # input amount under s.233(1)(a), assuming it has not been refunded. + # https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm053200 + pension_contributions_relief: 40_000 + pension_contributions_for_annual_allowance: 120_000 + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 60_000 + gift_aid_grossed_up: 1_250 + # ITA 2007 s.58 deducts the gross gift for adjusted net income only; + # it does not deduct the gift from net income or reduced net income. + # https://www.legislation.gov.uk/ukpga/2007/3/section/58 + net_income: 40_000 + # s.227(4B): RNI = £40,000 - £12,570 PA = £27,430, with no Gift Aid + # deduction. s.227(4C): basic limit = £37,700 + £40,000 relieved RAS + # + £1,250 gross Gift Aid = £78,950. Of the £60,000 excess, £51,520 + # is taxed at 20% and £8,480 at 40%. Gift Aid reduces the no-gift + # charge of £13,946 by £250, without a second income deduction. + personal_pension_contributions_tax: 51_520 * 0.2 + 8_480 * 0.4 + +- name: Relief at source above relevant earnings without Gift Aid + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 40_000 + personal_pension_contributions: 70_000 + employer_pension_contributions: 50_000 + region: LONDON + output: + # The same £27,430 RNI and £60,000 chargeable amount, with the basic + # limit extended only by £40,000 relieved RAS to £77,700. + pension_contributions_relief: 40_000 + pension_annual_allowance_chargeable_amount: 60_000 + personal_pension_contributions_tax: 50_270 * 0.2 + 9_730 * 0.4 + +- name: Fully tapered annual allowance still allows relief up to relevant earnings + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 400_000 + personal_pension_contributions: 100_000 + region: LONDON + 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. + # https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/2024-04-06 + pension_contributions_relief: 100_000 + pension_annual_allowance: 10_000 + pension_annual_allowance_chargeable_amount: 90_000 + # s.227(4A)-(4C): RNI £400,000 and the additional-rate limit raised + # by £100,000 to £225,140 leave the entire excess at 45%. + personal_pension_contributions_tax: 90_000 * 0.45 + +- name: Scottish taxpayer pays Scottish rates (model PA taper limitation) period: 2026 absolute_error_margin: 0.01 input: @@ -150,7 +225,12 @@ personal_pension_contributions: 80_000 region: SCOTLAND output: - # Reduced net income 70_000; 70_000 to 90_000 is in the 45% advanced band. + # Model reduced net income £150,000 and the limits extended by £80,000 + # put the entire charge in the 45% advanced band. Known s.58 limitation: + # the model omits RAS from ANI, so PA is zero instead of £12,570. With + # that PA restored, the statutory charge would be £5,000 at 42% plus + # £15,000 at 45% = £8,850. The net-pay case below checks statutory rates + # without depending on that existing PA-taper deviation. personal_pension_contributions_tax: 20_000 * 0.45 - name: Scottish starter band is charged at the Scottish basic rate (s. 227(4AA)) @@ -165,6 +245,10 @@ # Net pay: reduced net income 100_000 - 100_000 floored at 0. Chargeable # amount 40_000: 20% up to the 16_956 basic rate limit (the starter band # included), 21% to 31_092, then 42%. + # 2026-27 limits: https://www.gov.scot/publications/scottish-income-tax-technical-factsheet/ + pension_contributions_relief: 100_000 + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 40_000 personal_pension_contributions_tax: 16_956 * 0.2 + (31_092 - 16_956) * 0.21 + (40_000 - 31_092) * 0.42 # A model deviation, documented: the model gives relief at source as a @@ -208,10 +292,41 @@ unused_pension_annual_allowance: 20_000 region: LONDON output: + # Assume registered-scheme membership in the prior year and an unused + # £20,000 still available from the previous three tax years. s.228A + # increases this year's £60,000 allowance to £80,000, covering all + # £80,000 pension input. Carry-forward does not increase s.190 relief. + # https://www.legislation.gov.uk/ukpga/2004/12/section/228A/2024-04-06 + pension_contributions_relief: 80_000 + pension_contributions_for_annual_allowance: 80_000 + pension_annual_allowance: 60_000 pension_annual_allowance_chargeable_amount: 0 personal_pension_contributions_tax: 0 + # Existing model limitation: s.58 omits the RAS adjustment when tapering + # PA, leaving PA zero here. This income-tax expectation preserves that + # behavior; the zero annual allowance charge is the statutory check. income_tax: 28_460 - 8_000 +- name: Carry-forward covers excess without a personal allowance taper + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + personal_pension_contributions: 80_000 + unused_pension_annual_allowance: 20_000 + region: LONDON + output: + # FA 2004 s.228A: assume £20,000 of eligible unused allowance from the + # prior three years. Available allowance £60,000 + £20,000 covers the + # full £80,000 input. s.190 independently allows relief on all £80,000. + # PA is the full £12,570 with or without s.58's pension adjustment. + pension_contributions_relief: 80_000 + pension_contributions_for_annual_allowance: 80_000 + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 0 + personal_pension_contributions_tax: 0 + - name: Unused annual allowance brought forward covers part of the excess period: 2024 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 98611839b0..158eaf72dc 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -30,6 +30,9 @@ chargeable amount stacked on reduced net income. 6. Carry-forward: unused allowance reduces the chargeable amount one for one until it is nil, and never raises the charge. +7. Gift Aid never raises income tax, including when pension contributions + exceed relevant earnings or the available annual allowance. Its s. 58 + adjusted-net-income deduction is not a deduction from reduced net income. Invariant 3 is restricted to non-savings income. With dividends on top, the relief comes off non-savings income and lets dividends fall to lower dividend @@ -154,7 +157,11 @@ def test_relief_is_the_section_190_limit(people, year): total = relief_at_source + person["employee_pension_contributions_reported"] limit = earnings + min(max(0, basic_amount - earnings), relief_at_source) expected = min(total, limit) if person["age"] < 75 else 0 - assert _close(out["pension_contributions_relief"][i], expected) + relief = out["pension_contributions_relief"][i] + assert _close(relief, expected) + assert relief <= max(earnings, basic_amount) + max( + TOLERANCE, RELATIVE_TOLERANCE * max(earnings, basic_amount) + ) @PROPERTY_SETTINGS @@ -189,7 +196,6 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste "age": min(p["age"], 74), "savings_interest_income": 0, "dividend_income": 0, - "gift_aid": 0, "unused_pension_annual_allowance": 0, "region": "NORTH_EAST" if p["region"] == "SCOTLAND" else p["region"], } @@ -206,7 +212,7 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste # comparison stops there as well as at relevant earnings. taxable_income = np.maximum( 0, - np.array(zero_sim.calculate("adjusted_net_income", year), dtype=float) + np.array(zero_sim.calculate("net_income", year), dtype=float) - np.array(zero_sim.calculate("allowances", year), dtype=float), ) earnings = np.array([_relevant_earnings(p) for p in people], dtype=float) @@ -244,16 +250,16 @@ def test_charge_matches_band_by_band_reference(people, year): sim.calculate("personal_pension_contributions_tax", year), dtype=float ) get = lambda v: np.array(sim.calculate(v, year), dtype=float) # noqa: E731 - # s. 227(4B)-(4C): reduced net income keeps relief-at-source contributions - # and Gift Aid, which raise the rate limits by their gross amounts. + # FA 2004 s. 227(4B)-(4C), ITA 2007 ss. 23 and 58: reduced net income + # starts before the gross Gift Aid adjustment to adjusted net income. + # Relief at source stays in this base and extends the rate limits. relief_at_source = np.minimum( get("personal_pension_contributions"), get("pension_contributions_relief") ) extension = get("gift_aid_grossed_up") + relief_at_source reduced_net_income = np.maximum( 0, - get("adjusted_net_income") - - np.maximum(0, get("allowances") - get("gift_aid") - relief_at_source), + get("net_income") - np.maximum(0, get("allowances") - relief_at_source), ) scottish = np.array(sim.calculate("pays_scottish_income_tax", year)) rates = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax.rates @@ -278,6 +284,30 @@ def test_charge_matches_band_by_band_reference(people, year): assert charge[i] >= min(band_rates) * chargeable[i] - slack assert charge[i] <= max(band_rates) * chargeable[i] + slack + # FA 2004 ss. 227ZA and 228A: no charge within the available allowance, + # including unused allowance from the previous three years. + available = get("pension_annual_allowance") + np.maximum( + 0, get("unused_pension_annual_allowance") + ) + within_allowance = get("pension_contributions_for_annual_allowance") <= available + assert np.all(charge >= 0) + assert np.all(charge[within_allowance] == 0) + + +@PROPERTY_SETTINGS +@given(population_strategy, st.sampled_from(YEARS)) +def test_gift_aid_never_increases_income_tax_with_pension_contributions(people, year): + people = _with_contributions(people, _base_contributions(people)) + # Compare the same pension/input amounts with zero, drawn, and larger + # gifts, across UK and Scottish rates and mixed income sources. + zero_gift = [{**p, "gift_aid": 0} for p in people] + larger_gift = [{**p, "gift_aid": p["gift_aid"] + 4_000} for p in people] + tax = _calc(zero_gift + people + larger_gift, year, ["income_tax"])["income_tax"] + before, with_gift, with_more = np.split(tax, 3) + slack = TOLERANCE + RELATIVE_TOLERANCE * np.maximum(abs(before), abs(with_gift)) + assert np.all(with_gift <= before + slack) + assert np.all(with_more <= with_gift + slack) + @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS), st.integers(0, 150_000)) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py index 17d05685f0..7c3fec74a4 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py @@ -37,6 +37,10 @@ class personal_pension_contributions_tax(Variable): title="Finance Act 2004 s. 227", href="https://www.legislation.gov.uk/ukpga/2004/12/section/227", ), + dict( + title="Income Tax Act 2007 s. 58", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/58", + ), dict( title="Finance Act 2004 s. 233", href="https://www.legislation.gov.uk/ukpga/2004/12/section/233", @@ -47,12 +51,12 @@ class personal_pension_contributions_tax(Variable): def formula(person, period, parameters): rates = parameters(period).gov.hmrc.income_tax.rates chargeable_amount = person("pension_annual_allowance_chargeable_amount", period) - # The model deducts relief at source (personal pension contributions) - # and Gift Aid from income, where the law leaves them in reduced net - # income and raises the rate limits instead (s. 227(4C); FA 2004 - # s. 192(4), ITA 2007 s. 414). Undo the deduction and raise the - # limits, as capital_gains_tax does for the basic rate band. Net pay - # contributions stay deducted, as in law. + # FA 2004 s. 227(4B)-(4C): relief at source remains in reduced net + # income and raises the rate limits (s. 192(4)). The model includes + # that relief in allowances, so restore it at Step 3. Net pay + # contributions stay deducted. Gift Aid is already excluded from + # allowances; only its gross amount extends the limits (ITA 2007 + # s. 414(2)), without another deduction from the taxable base. relief_at_source = min_( person("personal_pension_contributions", period), person("pension_contributions_relief", period), @@ -60,13 +64,12 @@ def formula(person, period, parameters): rate_limit_extension = person("gift_aid_grossed_up", period) + relief_at_source allowances_at_step_3 = max_( 0, - person("allowances", period) - - person("gift_aid", period) - - relief_at_source, + person("allowances", period) - relief_at_source, ) - # Reduced net income: income after Step 3 of ITA 2007 s. 23 (s. 227(4B)). + # Reduced net income is income after ITA 2007 s. 23 Step 3, not + # adjusted net income after the s. 58 gross Gift Aid deduction. reduced_net_income = max_( - 0, person("adjusted_net_income", period) - allowances_at_step_3 + 0, person("net_income", period) - allowances_at_step_3 ) top_of_charge = reduced_net_income + chargeable_amount From cc355ce28c29b00e50f886d4e8335e601f7f57c1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 02:19:49 -0400 Subject: [PATCH 05/14] Condition pension charge RAS limits on ordinary tax liability --- changelog.d/2237.fixed.md | 2 +- docs/engineering/gift-aid.md | 28 ++++++-- .../private_pension_contributions_tax.py | 65 ++++++++++++++++--- 3 files changed, 80 insertions(+), 15 deletions(-) diff --git a/changelog.d/2237.fixed.md b/changelog.d/2237.fixed.md index 902bbba243..51bcc78469 100644 --- a/changelog.d/2237.fixed.md +++ b/changelog.d/2237.fixed.md @@ -1 +1 @@ -- Limited pension contributions relief by relevant UK earnings (or the £3,600 basic amount through relief at source) instead of the annual allowance. Calculated the annual allowance charge across UK or Scottish tax bands with unused allowance carried forward, and preserved Gift Aid's gross band extension without deducting the gift from the charge's income base. +- Limited pension contributions relief by relevant UK earnings (or the £3,600 basic amount through relief at source) instead of the annual allowance. Calculated the annual allowance charge across UK or Scottish tax bands with unused allowance carried forward, applying the statutory above-basic ordinary-income condition to the RAS band extension and preserving Gift Aid's separate gross band extension. The Scottish top-rate threshold remains dependent on #2130. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 107c2e7dae..c4c9c22558 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -90,7 +90,27 @@ Step 3 ([FA 2004 s. 227(4B)](https://www.legislation.gov.uk/ukpga/2004/12/sectio It starts from `net_income`, before the s. 58 gross Gift Aid deduction, and subtracts `allowances` after removing relieved relief-at-source pension contributions. Gift Aid is already excluded from those allowances. The charge -then stacks the excess pension input amount on this base, extending the rate -limits by `gift_aid_grossed_up` plus relieved relief-at-source contributions -(s. 227(4C)). This keeps the Gift Aid adjustment to adjusted net income separate -from the amount charged to tax. +then stacks the excess pension input amount on this base. Gross Gift Aid +extends the rate limits under s. 227(4C), independently of pension relief. +This keeps the Gift Aid adjustment to adjusted net income separate from the +amount charged to tax. + +The pension extension is conditional. [FA 2004 s. 192(4)/(4A)](https://www.legislation.gov.uk/ukpga/2004/12/section/192) +requires ordinary income, apart from relief under that section, to attract +higher/additional-rate tax, or a Scottish rate above basic. The charge uses +only limits increased under s. 192; s. 227(5) excludes the pension excess from +income, so that excess cannot satisfy the condition. The model tests ordinary +liability before RAS relief, retaining Gift Aid's extension and the existing +allowance allocation. Scottish liability uses non-savings income; dividend +rates alone and savings taxed at nil do not satisfy the UK condition. + +[HMRC PTM056120](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm056120) +describes the RAS extension for the annual allowance charge without stating +this condition. Its examples do not resolve low-income contributors whose +pension excess crosses a band. The model follows the statutory condition; +the [2018/459 explanatory memorandum, paragraph 7.4](https://www.legislation.gov.uk/uksi/2018/459/pdfs/uksiem_20180459_en.pdf) +also describes an above-basic Scottish liability requirement. Assessment +practice under the broader manual description remains a methodology question. +The charge can exceed relief on the contribution when ordinary income fails +the condition, so contribution and Gift Aid tax-monotonicity comparisons must +hold eligibility for the RAS extension fixed. diff --git a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py index 7c3fec74a4..1a5e6e2574 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py @@ -23,16 +23,22 @@ class personal_pension_contributions_tax(Variable): "the basic, higher and additional rates on the slices of the chargeable " "amount that fall in each band when it is added to the top of the " "individual's reduced net income, their income after allowances " - "(s. 227(4A)-(4B)), with the rate limits raised by relief-at-source " - "contributions and grossed-up Gift Aid (s. 227(4C)). A Scottish " + "(s. 227(4A)-(4B)), with gross Gift Aid extending rate limits. " + "Relieved relief-at-source contributions extend limits only where " + "ordinary income, before that relief, meets s. 192(4)/(4A)'s " + "above-basic-rate condition (s. 227(4C)/(5)). A Scottish " "taxpayer pays the Scottish rates, with the Scottish basic rate on any " "slice within the Scottish basic rate limit (s. 227(4AA)). " - "Contributions above the annual allowance still get relief " - "(pension_contributions_relief), so the charge recovers that relief " - "once rather than taxing the excess twice." + "Personal contribution relief has a separate earnings limit " + "(pension_contributions_relief); the annual allowance does not " + "also deny that relief." ) definition_period = YEAR reference = [ + dict( + title="Finance Act 2004 s. 192", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/192", + ), dict( title="Finance Act 2004 s. 227", href="https://www.legislation.gov.uk/ukpga/2004/12/section/227", @@ -49,10 +55,12 @@ class personal_pension_contributions_tax(Variable): unit = GBP def formula(person, period, parameters): - rates = parameters(period).gov.hmrc.income_tax.rates + income_tax = parameters(period).gov.hmrc.income_tax + rates = income_tax.rates chargeable_amount = person("pension_annual_allowance_chargeable_amount", period) # FA 2004 s. 227(4B)-(4C): relief at source remains in reduced net - # income and raises the rate limits (s. 192(4)). The model includes + # income. It raises rate limits only if s. 192's condition is met. + # The model includes # that relief in allowances, so restore it at Step 3. Net pay # contributions stay deducted. Gift Aid is already excluded from # allowances; only its gross amount extends the limits (ITA 2007 @@ -61,7 +69,7 @@ def formula(person, period, parameters): person("personal_pension_contributions", period), person("pension_contributions_relief", period), ) - rate_limit_extension = person("gift_aid_grossed_up", period) + relief_at_source + gross_gift = person("gift_aid_grossed_up", period) allowances_at_step_3 = max_( 0, person("allowances", period) - relief_at_source, @@ -73,12 +81,41 @@ def formula(person, period, parameters): ) top_of_charge = reduced_net_income + chargeable_amount + # s. 192(4): test ordinary liability apart from RAS relief, with + # Gift Aid's limits still extended. s. 227(5) excludes the pension + # excess itself. Dividends have separate rates and cannot satisfy + # the higher/additional-rate condition; savings at a nil rate cannot + # satisfy it either. Retain the model's fixed allowance allocation. + savings = person("taxable_savings_interest_income", period) + dividends = person("taxable_dividend_income", period) + non_savings = max_(0, reduced_net_income - savings - dividends) + savings_top = max_(0, reduced_net_income - dividends) + savings_after_allowances = savings_top - non_savings + savings_amounts = income_tax.allowances.personal_savings_allowance + savings_allowance_without_ras = select( + [ + reduced_net_income > rates.uk.thresholds[2] + gross_gift, + reduced_net_income > rates.uk.thresholds[1] + gross_gift, + ], + [savings_amounts.additional, savings_amounts.higher], + default=savings_amounts.basic, + ) + nil_rate_savings = min_( + savings_after_allowances, + savings_allowance_without_ras + + max_(0, rates.savings_starter_rate.allowance - non_savings), + ) + uk_basic_limit = rates.uk.thresholds[1] + gross_gift + uk_ras_condition = (non_savings > uk_basic_limit) | ( + savings_top > max_(uk_basic_limit, non_savings + nil_rate_savings) + ) + uk_charge = _top_slice_charge( reduced_net_income, top_of_charge, list(rates.uk.thresholds), list(rates.uk.rates), - rate_limit_extension, + gross_gift + relief_at_source * uk_ras_condition, ) # s. 227(4AA)(b)(i): no slice is charged at the starter rate. The @@ -99,12 +136,20 @@ def formula(person, period, parameters): ) if has_starter_band: scottish_band_rates[0] = scottish_band_rates[1] + # s. 192(4A): only non-savings income is charged at Scottish rates. + # The first rate above basic is intermediate from 2018, higher before. + scottish_ras_condition = False + for threshold, rate in zip(scottish_rates.thresholds, scottish_band_rates): + if rate > scottish_band_rates[0]: + scottish_ras_condition = scottish_ras_condition | ( + non_savings > threshold + gross_gift + ) scottish_charge = _top_slice_charge( reduced_net_income, top_of_charge, list(scottish_rates.thresholds), scottish_band_rates, - rate_limit_extension, + gross_gift + relief_at_source * scottish_ras_condition, ) return where( From 15acaaed403ae294d451a2b89ae83234a75b574b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 02:20:26 -0400 Subject: [PATCH 06/14] Cover conditional RAS extension and isolate Scottish threshold dependency --- .github/test_outcome_allowlist.toml | 6 + .../personal_pension_contributions_tax.yaml | 155 ++++++++++- ...est_pension_annual_allowance_properties.py | 253 ++++++++++++++++-- .../pensions/pension_contributions_relief.py | 6 +- 4 files changed, 383 insertions(+), 37 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 9206674edc..52ae620767 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -64,3 +64,9 @@ nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_con outcome = "skipped" reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." + +[[allow]] +nodeid = "policyengine_uk/tests/test_pension_annual_allowance_properties.py::test_scottish_charge_below_statutory_top_limit_is_all_advanced_rate" +outcome = "xfailed" +reason = "#2130" +why = "Known bug #2130 sets the Scottish top-rate taxable-income limit too low. A separate fix owns the parameter; remove this strict xfail when it lands." 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 ef8ce48fe4..c1a88d2d2b 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 @@ -121,7 +121,7 @@ pension_annual_allowance_chargeable_amount: 40_000 personal_pension_contributions_tax: 37_700 * 0.2 + 2_300 * 0.4 -- name: Relief at source raises the rate limits instead (s. 227(4C)) +- name: Relief at source raises rate limits with ordinary higher-rate income (ss. 192(4), 227(4C)) period: 2024 absolute_error_margin: 0.01 input: @@ -130,10 +130,142 @@ employer_pension_contributions: 40_000 output: # Relief at source leaves reduced net income at 60_000 - 12_570 = 47_430 - # and raises the basic rate limit by 60_000 to 97_700, so all 40_000 - # falls at 20% (HMRC PTM056120). + # before s.192 relief, above the basic limit. Section 192(4) therefore + # raises the basic rate limit by 60_000 to 97_700, so all 40_000 + # of the chargeable amount falls at 20%. personal_pension_contributions_tax: 40_000 * 0.2 +# FA 2004 s.192(4)/(4A) tests ordinary income before s.192 relief. The +# annual allowance excess is not income for that test (s.227(5)). These +# reproduce the review's cases where only the excess enters higher bands. +- name: Basic-rate ordinary income does not qualify for RAS band extension + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 40_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: LONDON + output: + pension_contributions_relief: 20_000 + pension_annual_allowance_chargeable_amount: 20_000 + # RNI £27,430; £10,270 of excess at 20%, £9,730 at 40%. + personal_pension_contributions_tax: 5_946 + +- name: Scottish basic-rate ordinary income does not qualify for RAS band extension + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 25_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: SCOTLAND + output: + pension_contributions_relief: 20_000 + pension_annual_allowance_chargeable_amount: 20_000 + # RNI £12,430; £4,526 at 20%, £14,136 at 21%, £1,338 at 42%. + personal_pension_contributions_tax: 4_435.72 + +- name: RAS eligibility starts strictly above the ordinary UK basic-rate limit + period: 2024 + absolute_error_margin: 0.01 + input: + age: [45, 45] + employment_income: [50_270, 50_271] + personal_pension_contributions: [20_000, 20_000] + employer_pension_contributions: [60_000, 60_000] + region: [LONDON, LONDON] + output: + # RNI exactly £37,700 has no ordinary higher-rate income; the £20,000 + # charge is all at 40%. At RNI £37,701, the £20,000 RAS extension applies: + # £19,999 of excess at 20% plus £1 at 40%. + personal_pension_contributions_tax: [8_000, 4_000.20] + +- name: Higher-rate savings qualify for RAS extension but dividend upper rates do not + period: 2024 + absolute_error_margin: 0.01 + input: + age: [45, 45] + employment_income: [40_000, 40_000] + savings_interest_income: [20_000, 0] + dividend_income: [0, 20_000] + personal_pension_contributions: [20_000, 20_000] + employer_pension_contributions: [60_000, 60_000] + region: [LONDON, LONDON] + output: + # RNI £47,430 in both cases. Savings include an ordinary higher-rate + # slice even after the £500 PSA; dividend upper rates are separate + # rates, outside s.192(4). Only the savings case gets the RAS extension. + personal_pension_contributions_tax: [5_946, 8_000] + +- name: Wholly nil-rate savings do not qualify for RAS band extension + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 50_200 + savings_interest_income: 100 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: LONDON + output: + # Before RAS, non-savings £37,630 is below basic. The £100 savings + # takes RNI to £37,730 but is wholly covered by the PSA. No ordinary + # higher-rate tax is chargeable, so the £20,000 excess is all at 40%. + personal_pension_contributions_tax: 8_000 + +- name: Gift Aid can remove ordinary higher-rate liability and the RAS extension + period: 2024 + absolute_error_margin: 0.01 + input: + age: [45, 45] + employment_income: [60_000, 60_000] + personal_pension_contributions: [20_000, 20_000] + employer_pension_contributions: [60_000, 60_000] + gift_aid: [0, 8_000] + region: [LONDON, LONDON] + output: + # RNI £47,430. Without a gift, ordinary income is above £37,700 and + # RAS raises that limit to £57,700. With £10,000 gross Gift Aid, the + # ordinary limit is £47,700: no s.192 claim, and only the gift extends + # the charge's limit. The £20,000 excess then has £270 at 20% and + # £19,730 at 40%. Tax need not be monotone when RAS eligibility changes. + personal_pension_contributions_tax: [5_946, 7_946] + +- name: UK higher-rate savings do not meet the Scottish RAS extension condition + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 25_000 + savings_interest_income: 30_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: SCOTLAND + output: + # Non-savings RNI £12,430 is below Scottish intermediate rate. Higher + # UK rates on savings do not meet s.192(4A)'s Scottish-rate condition. + # RNI £42,430 plus £20,000 excess remains in the Scottish 42% band. + personal_pension_contributions_tax: 8_400 + +- name: RAS condition uses the Scottish basic limit before the starter band existed + period: 2017 + absolute_error_margin: 0.01 + input: + age: [45, 45] + employment_income: [43_000, 43_001] + personal_pension_contributions: [20_000, 20_000] + employer_pension_contributions: [40_000, 40_000] + region: [SCOTLAND, SCOTLAND] + output: + # PA £11,500, Scottish basic limit £31,500, AA £40,000. RNI exactly + # at the basic limit does not meet s.192(4A); £1 above does, raising + # the limit by RAS. The £20,000 excess is 40%, or £19,999 at 20% + £1 at 40%. + pension_annual_allowance_chargeable_amount: [20_000, 20_000] + personal_pension_contributions_tax: [8_000, 4_000.20] + - name: Gift Aid raises the rate limits by its grossed-up amount (s. 227(4C)) period: 2025 absolute_error_margin: 0.01 @@ -173,11 +305,12 @@ # https://www.legislation.gov.uk/ukpga/2007/3/section/58 net_income: 40_000 # s.227(4B): RNI = £40,000 - £12,570 PA = £27,430, with no Gift Aid - # deduction. s.227(4C): basic limit = £37,700 + £40,000 relieved RAS - # + £1,250 gross Gift Aid = £78,950. Of the £60,000 excess, £51,520 - # is taxed at 20% and £8,480 at 40%. Gift Aid reduces the no-gift - # charge of £13,946 by £250, without a second income deduction. - personal_pension_contributions_tax: 51_520 * 0.2 + 8_480 * 0.4 + # deduction. Ordinary income is below higher rate, so s.192(4) does + # not increase the limit for RAS. Section 227(5) excludes the excess + # from that income condition. Only £1,250 gross Gift Aid extends the + # basic limit to £38,950: £11,520 at 20% and £48,480 at 40%. The gift + # reduces the no-gift charge of £21,946 by £250. + personal_pension_contributions_tax: 11_520 * 0.2 + 48_480 * 0.4 - name: Relief at source above relevant earnings without Gift Aid period: 2024 @@ -189,11 +322,11 @@ employer_pension_contributions: 50_000 region: LONDON output: - # The same £27,430 RNI and £60,000 chargeable amount, with the basic - # limit extended only by £40,000 relieved RAS to £77,700. + # The same £27,430 RNI and £60,000 chargeable amount. Ordinary income + # is below higher rate, so s.192(4) leaves the basic limit at £37,700. pension_contributions_relief: 40_000 pension_annual_allowance_chargeable_amount: 60_000 - personal_pension_contributions_tax: 50_270 * 0.2 + 9_730 * 0.4 + personal_pension_contributions_tax: 10_270 * 0.2 + 49_730 * 0.4 - name: Fully tapered annual allowance still allows relief up to relevant earnings period: 2024 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 158eaf72dc..69b3ed98e6 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -8,8 +8,8 @@ pay arrangement. The annual allowance charge (s. 227) then taxes the pension input amount above the allowance, plus any unused allowance brought forward (s. 228A), as the top slice of reduced net income at the non-savings rates -(s. 227(4A)), with the rate limits raised by relief-at-source contributions -and grossed-up Gift Aid (s. 227(4C)), or at the Scottish rates with the +(s. 227(4A)), with the rate limits raised by qualifying relief-at-source contributions +(ss. 192(4)/(4A), 227(4C)) and grossed-up Gift Aid, or at the Scottish rates with the starter band at the Scottish basic rate (s. 227(4AA)). Invariants, for any generated population of single adults with contributions @@ -21,20 +21,27 @@ 2. The excess is taxed once: raising contributions, up to relevant earnings, never raises income tax by more than it raises the charge. So income tax before the charge never rises with contributions. -3. No double taxation: for someone whose income is all non-savings income, - contributing more than the allowance, up to relevant earnings, never costs - more income tax than contributing up to the allowance. +3. With non-savings income only and stable eligibility for any RAS band + extension, contributions above the allowance up to relevant earnings never + cost more income tax than contributing up to the allowance. 4. Charge bounds: nil exactly when the chargeable amount is nil, and between the lowest and highest rate charged times the chargeable amount. 5. Differential: the charge equals an independent band-by-band sum of the chargeable amount stacked on reduced net income. 6. Carry-forward: unused allowance reduces the chargeable amount one for one until it is nil, and never raises the charge. -7. Gift Aid never raises income tax, including when pension contributions - exceed relevant earnings or the available annual allowance. Its s. 58 - adjusted-net-income deduction is not a deduction from reduced net income. - -Invariant 3 is restricted to non-savings income. With dividends on top, the +7. With stable RAS extension eligibility, Gift Aid never raises income tax, + including when contributions exceed relevant earnings or the allowance. + Its s. 58 adjusted-net-income deduction does not reduce reduced net income. + +Invariant 3 requires the s.192 condition to hold at both contribution levels +when there are RAS contributions. The excess can enter higher bands while +ordinary income remains below higher rate, and s.227(5) excludes that excess +from the condition. Total tax can then rise without double taxation. Gift Aid +can also remove ordinary higher-rate liability and thus RAS band extension, +so invariant 7 compares only cases with the same eligibility at each gift. + +Invariant 3 is also restricted to non-savings income. With dividends on top, the relief comes off non-savings income and lets dividends fall to lower dividend rates, while s. 227(4A) charges the excess at the non-savings rates, so income tax can rise (intended: that is what the statute does). It also stops where @@ -48,6 +55,7 @@ """ import numpy as np +import pytest from hypothesis import HealthCheck, given, settings from hypothesis import strategies as st @@ -220,14 +228,43 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste employer = np.array([p["employer_pension_contributions"] for p in people]) at_allowance = np.clip(allowance - employer, 0, earnings) above = at_allowance + step * np.maximum(0, upper - at_allowance) - tax_at = _calc(_with_contributions(people, at_allowance), year, ["income_tax"]) - tax_above = _calc(_with_contributions(people, above), year, ["income_tax"]) - assert np.all(tax_above["income_tax"] <= tax_at["income_tax"] + TOLERANCE) + variables = [ + "income_tax", + "net_income", + "allowances", + "personal_pension_contributions", + "pension_contributions_relief", + "gift_aid_grossed_up", + ] + tax_at = _calc(_with_contributions(people, at_allowance), year, variables) + tax_above = _calc(_with_contributions(people, above), year, variables) + basic_limit = float( + zero_sim.tax_benefit_system.parameters( + year + ).gov.hmrc.income_tax.rates.uk.thresholds[1] + ) + + def qualifies_for_ras_extension(out): + ras = np.minimum( + out["personal_pension_contributions"], out["pension_contributions_relief"] + ) + ordinary_income = np.maximum( + 0, out["net_income"] - np.maximum(0, out["allowances"] - ras) + ) + return ordinary_income > basic_limit + out["gift_aid_grossed_up"] + + net_pay_only = np.array([p["net_pay_share"] == 1 for p in people]) + applicable = net_pay_only | ( + qualifies_for_ras_extension(tax_at) & qualifies_for_ras_extension(tax_above) + ) + assert np.all( + tax_above["income_tax"][applicable] + <= tax_at["income_tax"][applicable] + TOLERANCE + ) def _reference_charge(chargeable, reduced_net_income, thresholds, rates, extension): - """Band-by-band sum of the chargeable amount stacked on income, with every - rate limit raised by ``extension``.""" + """Band-by-band sum with rate limits raised by the eligible extension.""" bottom = reduced_net_income top = reduced_net_income + chargeable lowers = [thresholds[0]] + [t + extension for t in thresholds[1:]] @@ -238,6 +275,54 @@ def _reference_charge(chargeable, reduced_net_income, thresholds, rates, extensi return charge +def _reference_ras_extension_eligibility( + reduced_net_income, savings, dividends, gross_gift, scottish, income_tax +): + """Find an ordinary income slice taxed above basic, apart from s.192. + + Integrate the ordinary non-savings and savings slices separately. Dividend + upper rates and nil-rate savings do not meet s.192(4)/(4A); the annual + allowance excess never enters these slices (s.227(5)). + """ + non_savings = max(0, reduced_net_income - savings - dividends) + savings_top = max(0, reduced_net_income - dividends) + if scottish: + scale = income_tax.rates.scotland.rates + # These generated years all have the starter band before basic. + basic_rate = float(scale.rates[1]) + slices = [(0, non_savings)] + else: + scale = income_tax.rates.uk + basic_rate = float(scale.rates[0]) + psa = income_tax.allowances.personal_savings_allowance + if reduced_net_income > scale.thresholds[2] + gross_gift: + savings_allowance = float(psa.additional) + elif reduced_net_income > scale.thresholds[1] + gross_gift: + savings_allowance = float(psa.higher) + else: + savings_allowance = float(psa.basic) + starting_rate_savings = max( + 0, income_tax.rates.savings_starter_rate.allowance - non_savings + ) + nil_savings = min( + savings_top - non_savings, starting_rate_savings + savings_allowance + ) + slices = [(0, non_savings), (non_savings + nil_savings, savings_top)] + for index, (threshold, rate) in enumerate(zip(scale.thresholds, scale.rates)): + if rate <= basic_rate: + continue + lower = threshold + gross_gift + upper = ( + scale.thresholds[index + 1] + gross_gift + if index + 1 < len(scale.thresholds) + else np.inf + ) + for bottom, top in slices: + if min(top, upper) > max(bottom, lower): + return True + return False + + @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) def test_charge_matches_band_by_band_reference(people, year): @@ -252,11 +337,12 @@ def test_charge_matches_band_by_band_reference(people, year): get = lambda v: np.array(sim.calculate(v, year), dtype=float) # noqa: E731 # FA 2004 s. 227(4B)-(4C), ITA 2007 ss. 23 and 58: reduced net income # starts before the gross Gift Aid adjustment to adjusted net income. - # Relief at source stays in this base and extends the rate limits. + # RAS stays in this base; s.192 conditions its rate-limit extension + # on ordinary higher-rate liability apart from s.192 itself. relief_at_source = np.minimum( get("personal_pension_contributions"), get("pension_contributions_relief") ) - extension = get("gift_aid_grossed_up") + relief_at_source + gross_gift = get("gift_aid_grossed_up") reduced_net_income = np.maximum( 0, get("net_income") - np.maximum(0, get("allowances") - relief_at_source), @@ -273,8 +359,17 @@ def test_charge_matches_band_by_band_reference(people, year): thresholds, band_rates = ( (sco_thresholds, sco_rates) if scottish[i] else (uk_thresholds, uk_rates) ) + qualifies = _reference_ras_extension_eligibility( + reduced_net_income[i], + get("taxable_savings_interest_income")[i], + get("taxable_dividend_income")[i], + gross_gift[i], + bool(scottish[i]), + sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax, + ) + extension = gross_gift[i] + (relief_at_source[i] if qualifies else 0) expected = _reference_charge( - chargeable[i], reduced_net_income[i], thresholds, band_rates, extension[i] + chargeable[i], reduced_net_income[i], thresholds, band_rates, extension ) assert _close(charge[i], expected) # Bounds: nil exactly when nothing is chargeable, and between the @@ -296,17 +391,42 @@ def test_charge_matches_band_by_band_reference(people, year): @PROPERTY_SETTINGS @given(population_strategy, st.sampled_from(YEARS)) -def test_gift_aid_never_increases_income_tax_with_pension_contributions(people, year): +def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): people = _with_contributions(people, _base_contributions(people)) # Compare the same pension/input amounts with zero, drawn, and larger # gifts, across UK and Scottish rates and mixed income sources. zero_gift = [{**p, "gift_aid": 0} for p in people] larger_gift = [{**p, "gift_aid": p["gift_aid"] + 4_000} for p in people] - tax = _calc(zero_gift + people + larger_gift, year, ["income_tax"])["income_tax"] - before, with_gift, with_more = np.split(tax, 3) + combined = zero_gift + people + larger_gift + sim = Simulation(situation=_situation(combined, year)) + get = lambda v: np.array(sim.calculate(v, year), dtype=float) # noqa: E731 + ras = np.minimum( + get("personal_pension_contributions"), get("pension_contributions_relief") + ) + reduced_net_income = np.maximum( + 0, get("net_income") - np.maximum(0, get("allowances") - ras) + ) + p = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax + qualifies = np.array( + [ + _reference_ras_extension_eligibility( + rni, savings, dividends, gift, scottish, p + ) + for rni, savings, dividends, gift, scottish in zip( + reduced_net_income, + get("taxable_savings_interest_income"), + get("taxable_dividend_income"), + get("gift_aid_grossed_up"), + sim.calculate("pays_scottish_income_tax", year), + ) + ] + ) + no_gift_gate, gift_gate, more_gift_gate = np.split(qualifies, 3) + stable_gate = (no_gift_gate == gift_gate) & (gift_gate == more_gift_gate) + before, with_gift, with_more = np.split(get("income_tax"), 3) slack = TOLERANCE + RELATIVE_TOLERANCE * np.maximum(abs(before), abs(with_gift)) - assert np.all(with_gift <= before + slack) - assert np.all(with_more <= with_gift + slack) + assert np.all(with_gift[stable_gate] <= (before + slack)[stable_gate]) + assert np.all(with_more[stable_gate] <= (with_gift + slack)[stable_gate]) @PROPERTY_SETTINGS @@ -374,3 +494,90 @@ def remove_starter_band(simulation): # Reduced net income 0; chargeable amount 10_000, all in the basic band. charge = float(sim.calculate("personal_pension_contributions_tax", 2026)[0]) assert abs(charge - 10_000 * 0.2) < TOLERANCE + + +def _charge_situation(year, region, **inputs): + return { + "people": { + "p": { + "age": {year: 45}, + **{key: {year: value} for key, value in inputs.items()}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"], "region": {year: region}}}, + } + + +def test_mixed_methods_below_higher_rate_have_unextended_charge_lower_bound(): + """Either relief allocation leaves ordinary income below higher rate. + + With £50,000 earnings, at least £30,000 of £40,000 net pay must receive + relief if all £20,000 RAS does. RNI is therefore at most £7,430. A net-pay + allocation of £40,000 instead leaves RNI nil. Neither can meet s.192(4). + The £70,000 excess therefore costs at least £37,700 × 20% + £32,300 × 40%. + This does not presume a statutory ordering of net-pay and RAS relief. + """ + sim = Simulation( + situation=_charge_situation( + 2024, + "LONDON", + employment_income=50_000, + employee_pension_contributions_reported=40_000, + personal_pension_contributions=20_000, + employer_pension_contributions=70_000, + ) + ) + assert float(sim.calculate("pension_contributions_relief", 2024)[0]) == 50_000 + assert ( + float(sim.calculate("pension_annual_allowance_chargeable_amount", 2024)[0]) + == 70_000 + ) + charge = float(sim.calculate("personal_pension_contributions_tax", 2024)[0]) + assert charge >= 20_460 - TOLERANCE + + +@pytest.fixture +def scottish_advanced_rate_charge_simulation(): + return Simulation( + situation=_charge_situation( + 2026, + "SCOTLAND", + employment_income=112_000, + employer_pension_contributions=70_000, + ) + ) + + +def test_scottish_charge_uses_top_limit_parameter( + scottish_advanced_rate_charge_simulation, +): + """Keep the charge test correct when #2130 fixes the top-rate parameter.""" + sim = scottish_advanced_rate_charge_simulation + scale = sim.tax_benefit_system.parameters( + 2026 + ).gov.hmrc.income_tax.rates.scotland.rates + top_limit = float(scale.thresholds[-1]) + advanced_rate, top_rate = map(float, scale.rates[-2:]) + # PA £6,570; RNI £105,430; chargeable amount £10,000. + advanced_amount = min(10_000, max(0, top_limit - 105_430)) + expected = advanced_amount * advanced_rate + (10_000 - advanced_amount) * top_rate + charge = float(sim.calculate("personal_pension_contributions_tax", 2026)[0]) + assert charge == pytest.approx(expected, abs=TOLERANCE) + + +@pytest.mark.xfail( + strict=True, + reason="#2130: Scottish top-rate limit must be £125,140 of taxable income", +) +def test_scottish_charge_below_statutory_top_limit_is_all_advanced_rate( + scottish_advanced_rate_charge_simulation, +): + """S6M-20844: £105,430 RNI plus £10,000 excess remains below £125,140. + + https://www.parliament.scot/chamber-and-committees/votes-and-motions/S6M-20844 + """ + sim = scottish_advanced_rate_charge_simulation + assert float(sim.calculate("personal_allowance", 2026)[0]) == 6_570 + charge = float(sim.calculate("personal_pension_contributions_tax", 2026)[0]) + assert charge == pytest.approx(4_500, abs=TOLERANCE) 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 6602af436c..ed04e338df 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -13,9 +13,9 @@ class pension_contributions_relief(Variable): "above earnings (s. 191(7)); the model takes personal pension " "contributions as relief at source and employee contributions as a " "net pay arrangement. The annual allowance does not limit relief. " - "Contributions above it are relieved here, and the annual allowance " - "charge (personal_pension_contributions_tax) then recovers that relief " - "(s. 227)." + "Contributions above it can be relieved here and separately attract " + "the annual allowance charge (personal_pension_contributions_tax) " + "under s. 227. The charge need not equal their tax relief." ) definition_period = YEAR reference = [ From 183e64b7417e995d3f199078a4c8f22533e1a8ef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 05:26:31 -0400 Subject: [PATCH 07/14] Apply named-rate RAS gate and strengthen pension charge regressions --- .github/test_outcome_allowlist.toml | 6 - changelog.d/2237.fixed.md | 2 +- docs/engineering/gift-aid.md | 13 +- .../personal_pension_contributions_tax.yaml | 72 +++++++- ...est_pension_annual_allowance_properties.py | 156 ++++++++++++++---- .../private_pension_contributions_tax.py | 57 ++++--- 6 files changed, 239 insertions(+), 67 deletions(-) diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 52ae620767..9206674edc 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -64,9 +64,3 @@ nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_con outcome = "skipped" reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." - -[[allow]] -nodeid = "policyengine_uk/tests/test_pension_annual_allowance_properties.py::test_scottish_charge_below_statutory_top_limit_is_all_advanced_rate" -outcome = "xfailed" -reason = "#2130" -why = "Known bug #2130 sets the Scottish top-rate taxable-income limit too low. A separate fix owns the parameter; remove this strict xfail when it lands." diff --git a/changelog.d/2237.fixed.md b/changelog.d/2237.fixed.md index 51bcc78469..4c4c48d226 100644 --- a/changelog.d/2237.fixed.md +++ b/changelog.d/2237.fixed.md @@ -1 +1 @@ -- Limited pension contributions relief by relevant UK earnings (or the £3,600 basic amount through relief at source) instead of the annual allowance. Calculated the annual allowance charge across UK or Scottish tax bands with unused allowance carried forward, applying the statutory above-basic ordinary-income condition to the RAS band extension and preserving Gift Aid's separate gross band extension. The Scottish top-rate threshold remains dependent on #2130. +- Limited pension contributions relief by relevant UK earnings (or the £3,600 basic amount through relief at source) instead of the annual allowance. Calculated the annual allowance charge across UK or Scottish tax bands with unused allowance carried forward, applying the statutory named-rate ordinary-income condition to the RAS band extension and preserving Gift Aid's separate gross band extension. Separate savings and UK property rates do not establish RAS eligibility. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index dd15a19794..5c6098a0c4 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -104,8 +104,17 @@ higher/additional-rate tax, or a Scottish rate above basic. The charge uses only limits increased under s. 192; s. 227(5) excludes the pension excess from income, so that excess cannot satisfy the condition. The model tests ordinary liability before RAS relief, retaining Gift Aid's extension and the existing -allowance allocation. Scottish liability uses non-savings income; dividend -rates alone and savings taxed at nil do not satisfy the UK condition. +allowance allocation. Scottish liability uses non-savings income, including +property income. The UK condition names the higher and additional rates: +separate dividend rates, savings rates from 2017-18 and property rates from +2027-28 do not satisfy it. [FA 2016 s. 6](https://www.legislation.gov.uk/ukpga/2016/24/section/6/enacted) +introduced savings rates and expressly added them to some other higher-rate +tests, without expanding s. 192(4). [FA 2026 s. 6](https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted) +and [Schedule 1](https://www.legislation.gov.uk/ukpga/2026/11/schedule/1/enacted) +introduce property rates without expanding s. 192 either. Before those +changes, income charged at ordinary higher/additional rates can qualify. +Scottish property remains subject to Scottish rates under ITA 2007 +s. 11CA(4), so it can still meet s. 192(4A). [HMRC PTM056120](https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm056120) describes the RAS extension for the annual allowance charge without stating 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..a712b800b1 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 @@ -183,7 +183,7 @@ # £19,999 of excess at 20% plus £1 at 40%. personal_pension_contributions_tax: [8_000, 4_000.20] -- name: Higher-rate savings qualify for RAS extension but dividend upper rates do not +- name: Separate savings and dividend rates do not qualify for RAS extension period: 2024 absolute_error_margin: 0.01 input: @@ -195,10 +195,72 @@ employer_pension_contributions: [60_000, 60_000] region: [LONDON, LONDON] output: - # RNI £47,430 in both cases. Savings include an ordinary higher-rate - # slice even after the £500 PSA; dividend upper rates are separate - # rates, outside s.192(4). Only the savings case gets the RAS extension. - personal_pension_contributions_tax: [5_946, 8_000] + # RNI £47,430 in both cases. Savings higher and dividend upper rates + # are separate rates, outside the literal s.192(4) condition. + personal_pension_contributions_tax: [8_000, 8_000] + +- name: Savings higher rate alone does not satisfy the literal s192 condition + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 40_000 + savings_interest_income: 15_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: LONDON + output: + # FA 2016 s.6 introduced separate savings higher/additional rates from + # 2017-18 without expanding FA 2004 s.192(4). Ordinary non-savings + # £27,430 remains below basic; RNI £42,430 plus £20,000 excess is 40%. + personal_pension_contributions_tax: 8_000 + +- name: Separate property higher rate does not satisfy the literal s192 condition + period: 2027 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 20_000 + property_income: 32_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: LONDON + output: + # FA 2026 s.6 introduces separate property rates from 2027-28 without + # expanding s.192(4). Other non-savings income is below basic; the + # excess is taxed at the s.227 UK rates on top of RNI £39,430. + personal_pension_contributions_tax: 8_000 + +- name: Savings taxed at the ordinary higher rate qualify before separate savings rates + period: 2016 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 40_000 + savings_interest_income: 15_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 40_000 + region: LONDON + output: + # RNI £44,000 includes ordinary higher-rate savings before 2017-18. + # RAS extends the £32,000 basic limit to £52,000: £8,000 at 20%, + # then £12,000 at 40% of the £20,000 annual allowance excess. + personal_pension_contributions_tax: 6_400 + +- name: Property taxed at the ordinary higher rate qualifies before separate property rates + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 20_000 + property_income: 32_000 + personal_pension_contributions: 20_000 + employer_pension_contributions: 60_000 + region: LONDON + output: + # RNI £39,430 includes ordinary higher-rate property before 2027-28. + # The RAS extension puts £18,270 at 20% and £1,730 at 40%. + personal_pension_contributions_tax: 4_346 - name: Wholly nil-rate savings do not qualify for RAS band extension period: 2024 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 69b3ed98e6..9aedff95c4 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -56,7 +56,7 @@ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -99,6 +99,39 @@ def _close(actual, expected): population_strategy = st.lists(person_strategy, min_size=4, max_size=12) +@st.composite +def reference_population_strategy(draw): + people = [ + {**person, "property_income": draw(st.integers(0, 80_000))} + for person in draw(population_strategy) + ] + # Unrestricted draws rarely combine a positive RAS charge with ordinary + # income below higher rate. Include generated people on either side of + # that condition in every population, with enough excess to cross a limit + # whose extension changes the charge. + for income in [ + draw(st.integers(38_000, 45_000)), + draw(st.integers(60_000, 70_000)), + ]: + people.append( + { + "age": draw(st.integers(18, 74)), + "employment_income": income, + "self_employment_income": 0, + "employer_pension_contributions": draw(st.integers(60_000, 80_000)), + "contribution_share": draw(st.floats(0.5, 0.75)), + "net_pay_share": 0.0, + "gift_aid": draw(st.integers(0, 2_000)), + "savings_interest_income": 0, + "dividend_income": 0, + "property_income": 0, + "unused_pension_annual_allowance": 0, + "region": "LONDON", + } + ) + return people + + def _situation(people, year, overrides=None): overrides = overrides or {} situation = {"people": {}, "benunits": {}, "households": {}} @@ -120,6 +153,9 @@ def _situation(people, year, overrides=None): "unused_pension_annual_allowance", ] } + situation["people"][name]["property_income"] = { + year: values.get("property_income", 0) + } situation["benunits"][f"b{i}"] = {"members": [name]} situation["households"][f"h{i}"] = { "members": [name], @@ -189,10 +225,32 @@ def test_extra_contributions_never_raise_tax_by_more_than_the_charge( after["personal_pension_contributions_tax"] - before["personal_pension_contributions_tax"] ) - assert np.all(tax_rise <= charge_rise + TOLERANCE) + slack = TOLERANCE + RELATIVE_TOLERANCE * np.maximum( + abs(before["income_tax"]), abs(after["income_tax"]) + ) + assert np.all(tax_rise <= charge_rise + slack) @PROPERTY_SETTINGS +@example( + people=[ + { + "age": 54, + "employment_income": 362_880, + "self_employment_income": 5_249, + "employer_pension_contributions": 60_817, + "contribution_share": 1.0294618785360077, + "net_pay_share": 1.0, + "gift_aid": 2_629, + "savings_interest_income": 64, + "dividend_income": 8_818, + "unused_pension_annual_allowance": 96_126, + "region": "LONDON", + } + ], + year=2020, + step=0.5420285667238235, +) @given(population_strategy, st.sampled_from(YEARS), st.floats(0, 1)) def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, step): # Non-savings income only, under 75, no carry-forward: contributions of @@ -204,6 +262,7 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste "age": min(p["age"], 74), "savings_interest_income": 0, "dividend_income": 0, + "property_income": 0, "unused_pension_annual_allowance": 0, "region": "NORTH_EAST" if p["region"] == "SCOTLAND" else p["region"], } @@ -257,9 +316,12 @@ def qualifies_for_ras_extension(out): applicable = net_pay_only | ( qualifies_for_ras_extension(tax_at) & qualifies_for_ras_extension(tax_above) ) + slack = TOLERANCE + RELATIVE_TOLERANCE * np.maximum( + abs(tax_at["income_tax"]), abs(tax_above["income_tax"]) + ) assert np.all( tax_above["income_tax"][applicable] - <= tax_at["income_tax"][applicable] + TOLERANCE + <= (tax_at["income_tax"] + slack)[applicable] ) @@ -276,38 +338,51 @@ def _reference_charge(chargeable, reduced_net_income, thresholds, rates, extensi def _reference_ras_extension_eligibility( - reduced_net_income, savings, dividends, gross_gift, scottish, income_tax + reduced_net_income, + savings, + dividends, + gross_gift, + scottish, + income_tax, + year, + property_income=0, ): """Find an ordinary income slice taxed above basic, apart from s.192. - Integrate the ordinary non-savings and savings slices separately. Dividend - upper rates and nil-rate savings do not meet s.192(4)/(4A); the annual - allowance excess never enters these slices (s.227(5)). + Section 192(4) names the higher and additional rates, excluding dividend + rates, savings rates from 2017 and rUK property rates from 2027. Scottish + property still bears Scottish rates and can meet s.192(4A). The annual + allowance excess never enters these ordinary income slices (s.227(5)). """ non_savings = max(0, reduced_net_income - savings - dividends) savings_top = max(0, reduced_net_income - dividends) if scottish: scale = income_tax.rates.scotland.rates # These generated years all have the starter band before basic. - basic_rate = float(scale.rates[1]) + basic_rate = float(scale.rates[1] if year >= 2018 else scale.rates[0]) slices = [(0, non_savings)] else: scale = income_tax.rates.uk basic_rate = float(scale.rates[0]) - psa = income_tax.allowances.personal_savings_allowance - if reduced_net_income > scale.thresholds[2] + gross_gift: - savings_allowance = float(psa.additional) - elif reduced_net_income > scale.thresholds[1] + gross_gift: - savings_allowance = float(psa.higher) - else: - savings_allowance = float(psa.basic) - starting_rate_savings = max( - 0, income_tax.rates.savings_starter_rate.allowance - non_savings - ) - nil_savings = min( - savings_top - non_savings, starting_rate_savings + savings_allowance + ordinary_rates_top = max( + 0, non_savings - (property_income if year >= 2027 else 0) ) - slices = [(0, non_savings), (non_savings + nil_savings, savings_top)] + slices = [(0, ordinary_rates_top)] + if year < 2017: + psa = income_tax.allowances.personal_savings_allowance + if reduced_net_income > scale.thresholds[2] + gross_gift: + savings_allowance = float(psa.additional) + elif reduced_net_income > scale.thresholds[1] + gross_gift: + savings_allowance = float(psa.higher) + else: + savings_allowance = float(psa.basic) + starting_rate_savings = max( + 0, income_tax.rates.savings_starter_rate.allowance - non_savings + ) + nil_savings = min( + savings_top - non_savings, starting_rate_savings + savings_allowance + ) + slices.append((non_savings + nil_savings, savings_top)) for index, (threshold, rate) in enumerate(zip(scale.thresholds, scale.rates)): if rate <= basic_rate: continue @@ -324,7 +399,7 @@ def _reference_ras_extension_eligibility( @PROPERTY_SETTINGS -@given(population_strategy, st.sampled_from(YEARS)) +@given(reference_population_strategy(), st.sampled_from(YEARS)) def test_charge_matches_band_by_band_reference(people, year): people = _with_contributions(people, _base_contributions(people)) sim = Simulation(situation=_situation(people, year)) @@ -355,6 +430,7 @@ def test_charge_matches_band_by_band_reference(people, year): if year >= 2018: # s. 227(4AA): the starter band is charged at the Scottish basic rate. sco_rates[0] = sco_rates[1] + gate_coverage = {False: False, True: False} for i in range(len(people)): thresholds, band_rates = ( (sco_thresholds, sco_rates) if scottish[i] else (uk_thresholds, uk_rates) @@ -366,12 +442,25 @@ def test_charge_matches_band_by_band_reference(people, year): gross_gift[i], bool(scottish[i]), sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax, + year, + get("taxable_property_income")[i], ) extension = gross_gift[i] + (relief_at_source[i] if qualifies else 0) expected = _reference_charge( chargeable[i], reduced_net_income[i], thresholds, band_rates, extension ) assert _close(charge[i], expected) + alternative_extension = gross_gift[i] + ( + 0 if qualifies else relief_at_source[i] + ) + alternative_charge = _reference_charge( + chargeable[i], + reduced_net_income[i], + thresholds, + band_rates, + alternative_extension, + ) + gate_coverage[qualifies] |= not _close(expected, alternative_charge) # Bounds: nil exactly when nothing is chargeable, and between the # lowest and highest rate charged. assert (charge[i] == 0) == (chargeable[i] == 0) @@ -379,6 +468,10 @@ def test_charge_matches_band_by_band_reference(people, year): assert charge[i] >= min(band_rates) * chargeable[i] - slack assert charge[i] <= max(band_rates) * chargeable[i] + slack + # Both eligibility outcomes must actually affect a generated charge; + # positive contributions alone would leave this property vacuous. + assert all(gate_coverage.values()) + # FA 2004 ss. 227ZA and 228A: no charge within the available allowance, # including unused allowance from the previous three years. available = get("pension_annual_allowance") + np.maximum( @@ -410,7 +503,7 @@ def test_gift_aid_never_increases_tax_with_stable_ras_eligibility(people, year): qualifies = np.array( [ _reference_ras_extension_eligibility( - rni, savings, dividends, gift, scottish, p + rni, savings, dividends, gift, scottish, p, year ) for rni, savings, dividends, gift, scottish in zip( reduced_net_income, @@ -450,11 +543,18 @@ def test_unused_allowance_reduces_chargeable_amount_one_for_one(people, year, ex chargeable_before = before["pension_annual_allowance_chargeable_amount"] expected = np.maximum(0, chargeable_before - extra) assert np.allclose( - after["pension_annual_allowance_chargeable_amount"], expected, atol=TOLERANCE + after["pension_annual_allowance_chargeable_amount"], + expected, + atol=TOLERANCE, + rtol=RELATIVE_TOLERANCE, + ) + slack = TOLERANCE + RELATIVE_TOLERANCE * np.maximum( + abs(before["personal_pension_contributions_tax"]), + abs(after["personal_pension_contributions_tax"]), ) assert np.all( after["personal_pension_contributions_tax"] - <= before["personal_pension_contributions_tax"] + TOLERANCE + <= before["personal_pension_contributions_tax"] + slack ) @@ -552,7 +652,7 @@ def scottish_advanced_rate_charge_simulation(): def test_scottish_charge_uses_top_limit_parameter( scottish_advanced_rate_charge_simulation, ): - """Keep the charge test correct when #2130 fixes the top-rate parameter.""" + """The charge follows the configured top-rate parameter.""" sim = scottish_advanced_rate_charge_simulation scale = sim.tax_benefit_system.parameters( 2026 @@ -566,10 +666,6 @@ def test_scottish_charge_uses_top_limit_parameter( assert charge == pytest.approx(expected, abs=TOLERANCE) -@pytest.mark.xfail( - strict=True, - reason="#2130: Scottish top-rate limit must be £125,140 of taxable income", -) def test_scottish_charge_below_statutory_top_limit_is_all_advanced_rate( scottish_advanced_rate_charge_simulation, ): diff --git a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py index 1a5e6e2574..fb0aae07ae 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/private_pension_contributions_tax.py @@ -26,7 +26,8 @@ class personal_pension_contributions_tax(Variable): "(s. 227(4A)-(4B)), with gross Gift Aid extending rate limits. " "Relieved relief-at-source contributions extend limits only where " "ordinary income, before that relief, meets s. 192(4)/(4A)'s " - "above-basic-rate condition (s. 227(4C)/(5)). A Scottish " + "above-basic-rate condition (s. 227(4C)/(5)). Separate savings rates " + "from 2017 and UK property rates from 2027 do not satisfy s. 192(4). A Scottish " "taxpayer pays the Scottish rates, with the Scottish basic rate on any " "slice within the Scottish basic rate limit (s. 227(4AA)). " "Personal contribution relief has a separate earnings limit " @@ -83,32 +84,42 @@ def formula(person, period, parameters): # s. 192(4): test ordinary liability apart from RAS relief, with # Gift Aid's limits still extended. s. 227(5) excludes the pension - # excess itself. Dividends have separate rates and cannot satisfy - # the higher/additional-rate condition; savings at a nil rate cannot - # satisfy it either. Retain the model's fixed allowance allocation. + # excess itself. Separate dividend rates, savings rates from 2017 + # (FA 2016 s. 6) and UK property rates from 2027 (FA 2026 s. 6) do not + # satisfy its named higher/additional-rate condition. Retain the + # model's fixed allowance allocation, apart from RAS relief. savings = person("taxable_savings_interest_income", period) dividends = person("taxable_dividend_income", period) non_savings = max_(0, reduced_net_income - savings - dividends) - savings_top = max_(0, reduced_net_income - dividends) - savings_after_allowances = savings_top - non_savings - savings_amounts = income_tax.allowances.personal_savings_allowance - savings_allowance_without_ras = select( - [ - reduced_net_income > rates.uk.thresholds[2] + gross_gift, - reduced_net_income > rates.uk.thresholds[1] + gross_gift, - ], - [savings_amounts.additional, savings_amounts.higher], - default=savings_amounts.basic, - ) - nil_rate_savings = min_( - savings_after_allowances, - savings_allowance_without_ras - + max_(0, rates.savings_starter_rate.allowance - non_savings), - ) uk_basic_limit = rates.uk.thresholds[1] + gross_gift - uk_ras_condition = (non_savings > uk_basic_limit) | ( - savings_top > max_(uk_basic_limit, non_savings + nil_rate_savings) - ) + # Property is stacked above other non-savings income. Its part of + # this pre-RAS base must use allowances with RAS restored too. + property_at_separate_rates = min_( + person("taxable_property_income", period), non_savings + ) * (period.start.year >= 2027) + uk_ras_condition = non_savings - property_at_separate_rates > uk_basic_limit + if period.start.year < 2017: + # Before separate savings rates, a non-nil higher/additional + # savings slice was charged at the rates named in s. 192(4). + savings_top = max_(0, reduced_net_income - dividends) + savings_after_allowances = savings_top - non_savings + savings_amounts = income_tax.allowances.personal_savings_allowance + savings_allowance_without_ras = select( + [ + reduced_net_income > rates.uk.thresholds[2] + gross_gift, + reduced_net_income > uk_basic_limit, + ], + [savings_amounts.additional, savings_amounts.higher], + default=savings_amounts.basic, + ) + nil_rate_savings = min_( + savings_after_allowances, + savings_allowance_without_ras + + max_(0, rates.savings_starter_rate.allowance - non_savings), + ) + uk_ras_condition = uk_ras_condition | ( + savings_top > max_(uk_basic_limit, non_savings + nil_rate_savings) + ) uk_charge = _top_slice_charge( reduced_net_income, From 2154a3158b7ab03937a00186444a8836256428aa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 05:31:42 -0400 Subject: [PATCH 08/14] Isolate historic RAS gate control with statutory personal allowance --- .../gov/hmrc/pensions/personal_pension_contributions_tax.yaml | 4 ++++ 1 file changed, 4 insertions(+) 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 a712b800b1..78c4d24cf9 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 @@ -236,6 +236,10 @@ absolute_error_margin: 0.01 input: age: 45 + # Supply the statutory 2016-17 allowance to isolate the RAS condition + # from the inherited historical PA parameter (which retains £10,600). + # https://www.legislation.gov.uk/ukpga/2015/11/section/5 + personal_allowance: 11_000 employment_income: 40_000 savings_interest_income: 15_000 personal_pension_contributions: 20_000 From 93a21a3047d6ec2b0c3b881a02b3b67a8814df0a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:06:27 -0400 Subject: [PATCH 09/14] test: reproduce missing pension relief in adjusted net income --- .../pension_adjusted_net_income.yaml | 140 +++++++++++++ ...test_adjusted_net_income_pension_relief.py | 188 ++++++++++++++++++ 2 files changed, 328 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/pension_adjusted_net_income.yaml create mode 100644 policyengine_uk/tests/test_adjusted_net_income_pension_relief.py diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/pension_adjusted_net_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/pension_adjusted_net_income.yaml new file mode 100644 index 0000000000..5eac8b73a2 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/pension_adjusted_net_income.yaml @@ -0,0 +1,140 @@ +# MOCK cases: gross relieved contributions reduce ANI without becoming a +# second deduction from the rate-band base (ITA 2007 s.58). + +- name: RAS contributions restore the full Personal Allowance + period: 2024 + absolute_error_margin: 0.01 + input: + people: + taxpayer: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 60_000 + households: + household: + members: [taxpayer] + region: LONDON + output: + net_income: 150_000 + adjusted_net_income: 90_000 + personal_allowance: 12_570 + allowances: 72_570 + received_allowances_earned_income: 72_570 + earned_taxable_income: 77_430 + pension_annual_allowance: 60_000 + personal_pension_contributions_tax: 0 + income_tax: 23_432 + +- name: Pension and Gift Aid adjustments both restore the allowance without double deduction + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 30_000 + gift_aid: 16_000 + output: + net_income: 150_000 + gift_aid_grossed_up: 20_000 + adjusted_net_income: 100_000 + personal_allowance: 12_570 + earned_taxable_income: 107_430 + # £57,700 at 20% and £49,730 at 40%. + income_tax: 31_432 + +- name: Supplied ANI restores pension relief to the taxable base + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 70_000 + personal_pension_contributions: 10_000 + adjusted_net_income: 60_000 + output: + net_income: 70_000 + personal_allowance: 12_570 + earned_taxable_income: 47_430 + income_tax: 11_432 + +- name: Personal and employee contributions represented gross both reduce ANI once + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 130_000 + personal_pension_contributions: 20_000 + employee_pension_contributions: 10_000 + output: + net_income: 130_000 + pension_contributions_relief: 30_000 + adjusted_net_income: 100_000 + personal_allowance: 12_570 + allowances: 42_570 + earned_taxable_income: 87_430 + income_tax: 27_432 + +- name: Pension relief applies once when property income fills the higher-rate band + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 110_000 + taxable_property_income: 10_000 + personal_pension_contributions: 20_000 + output: + net_income: 120_000 + adjusted_net_income: 100_000 + personal_allowance: 12_570 + received_allowances_earned_income: 32_570 + non_savings_income_after_allowances: 87_430 + taxable_property_income_after_allowances: 10_000 + property_income_tax: 4_000 + income_tax: 27_432 + +- name: Allocation to earned savings and dividend income uses the unchanged taxable base + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 10_000 + savings_interest_income: 15_000 + dividend_income: 110_000 + personal_pension_contributions: 10_000 + output: + net_income: 135_000 + adjusted_net_income: 125_000 + personal_allowance: 70 + allowances: 10_070 + received_allowances_earned_income: 10_000 + received_allowances_savings_income: 70 + received_allowances_dividend_income: 0 + +- name: Restored Personal Allowance and RAS extension each enter CGT bands once + period: 2025 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 110_000 + personal_pension_contributions: 60_000 + capital_gains: 30_000 + output: + adjusted_net_income: 50_000 + personal_allowance: 12_570 + # Taxable income £97,430; extended basic-rate limit £97,700 leaves + # £270 at 18%, with £26,730 at 24%, after the £3,000 AEA. + capital_gains_tax: 6_463.80 + +- name: ANI pension deduction does not feed back into annual allowance taper or charge + period: 2024 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 340_000 + personal_pension_contributions: 10_000 + employer_pension_contributions: 30_000 + output: + net_income: 340_000 + adjusted_net_income: 330_000 + pension_annual_allowance: 20_000 + # £40,000 contributions less £20,000 AA, charged at 45%. + personal_pension_contributions_tax: 9_000 diff --git a/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py b/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py new file mode 100644 index 0000000000..cba8a2eca3 --- /dev/null +++ b/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py @@ -0,0 +1,188 @@ +"""MOCK regressions for pension relief in adjusted net income (#2243). + +ITA 2007 s.58 deducts gross relieved relief-at-source contributions from +adjusted net income. They restore the personal allowance and reduce HICBC, +while entering the income tax rate-band base only once. All populations are +synthetic situations; these tests never load or download a survey dataset. +""" + +import numpy as np +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + + +@pytest.fixture(scope="module") +def template(): + return Simulation(situation={"people": {"template": {"age": {2024: 45}}}}) + + +def simulate(template, year, cases, *, with_child=False, gift_aid=0, supplied_ani=None): + """Give each taxpayer a separate London household and optional child.""" + people, benunits, households, adult_indices = {}, {}, {}, [] + for index, (wages, contributions) in enumerate(cases): + adult = f"adult_{index}" + adult_indices.append(len(people)) + people[adult] = { + "age": {year: 45}, + "employment_income": {year: wages}, + "personal_pension_contributions": {year: contributions}, + "employee_pension_contributions": {year: 0}, + "employer_pension_contributions": {year: 0}, + "gift_aid": {year: gift_aid}, + } + if supplied_ani is not None: + people[adult]["adjusted_net_income"] = {year: supplied_ani} + members = [adult] + if with_child: + child = f"child_{index}" + people[child] = {"age": {year: 8}} + members.append(child) + benunits[f"family_{index}"] = {"members": members} + if with_child: + # Hold actual Child Benefit received fixed to isolate its charge, + # including the statutory whole-percentage and pound rounding. + benunits[f"family_{index}"]["child_benefit"] = {year: 1_200} + households[f"household_{index}"] = { + "members": members, + "region": {year: "LONDON"}, + } + + simulation = template.clone(clone_tax_benefit_system=False) + simulation.build_from_situation( + {"people": people, "benunits": benunits, "households": households} + ) + simulation.input_variables = simulation.get_known_variables() + simulation.calculated_periods = [] + variables = ( + "pension_contributions_relief", + "pension_annual_allowance", + "net_income", + "adjusted_net_income", + "personal_allowance", + "earned_taxable_income", + "income_tax", + "CB_HITC", + ) + return { + name: np.asarray(simulation.calculate(name, year), dtype=float)[adult_indices] + for name in variables + } + + +def test_150000_pay_and_60000_ras_restore_full_personal_allowance(template): + values = simulate(template, 2024, [(150_000, 60_000)]) + expected = { + "pension_contributions_relief": 60_000, + "net_income": 150_000, + "adjusted_net_income": 90_000, + "personal_allowance": 12_570, + "earned_taxable_income": 77_430, + # £37,700 at 20% plus £39,730 at 40%; relief is deducted once. + "income_tax": 37_700 * 0.2 + 39_730 * 0.4, + } + for name, amount in expected.items(): + assert values[name][0] == pytest.approx(amount, abs=0.01), name + + +@pytest.mark.parametrize( + "year,wages,threshold", [(2023, 60_000, 50_000), (2024, 70_000, 60_000)] +) +def test_ras_reduces_income_to_hicbc_threshold(template, year, wages, threshold): + values = simulate(template, year, [(wages, 0), (wages, 10_000)], with_child=True) + assert values["adjusted_net_income"][1] == threshold + assert values["CB_HITC"][0] > 0 + assert values["CB_HITC"][1] == 0 + + +@pytest.mark.parametrize("gift_aid", [0, 1_600]) +@pytest.mark.parametrize( + "wages,contributions,expected_relief,expected_annual_allowance", + [ + (70_000, 10_000, 10_000, 60_000), + (150_000, 60_000, 60_000, 60_000), + (300_000, 60_000, 40_000, 40_000), + (340_000, 60_000, 20_000, 20_000), + (400_000, 60_000, 10_000, 10_000), + (300_000, 5_000, 5_000, 40_000), + ], +) +def test_supplied_ani_preserves_pension_relief_and_annual_allowance( + template, + wages, + contributions, + expected_relief, + expected_annual_allowance, + gift_aid, +): + # The 2024 model's AA is £60,000, tapering at 50% above £260,000 + # of its net-income base, with a £10,000 floor. Direct ANI inputs + # must reconstruct that base without changing the relief cap or charge. + expected_ani = wages - expected_relief - gift_aid / 0.8 + ordinary = simulate(template, 2024, [(wages, contributions)], gift_aid=gift_aid) + supplied = simulate( + template, + 2024, + [(wages, contributions)], + gift_aid=gift_aid, + supplied_ani=expected_ani, + ) + for name, expected in { + "net_income": wages, + "adjusted_net_income": expected_ani, + "pension_contributions_relief": expected_relief, + "pension_annual_allowance": expected_annual_allowance, + }.items(): + assert ordinary[name][0] == pytest.approx(expected, abs=0.01), name + assert supplied[name][0] == pytest.approx(expected, abs=0.01), name + for name in ordinary: + np.testing.assert_allclose( + supplied[name], ordinary[name], atol=0.01, err_msg=name + ) + + +@settings(max_examples=30, deadline=None, derandomize=True, database=None) +@given( + year=st.sampled_from([2023, 2024]), + wages=st.integers(40_000, 180_000), + lower_contribution=st.integers(0, 20_000), + increase=st.integers(200, 20_000), +) +def test_increasing_relieved_ras_never_lowers_pa_or_raises_hicbc( + template, year, wages, lower_contribution, increase +): + higher_contribution = lower_contribution + increase + hicbc_start, hicbc_end = (50_000, 60_000) if year == 2023 else (60_000, 80_000) + # The two witnesses cross a statutory taper boundary in every example. + # Monotonicity alone would pass even if pensions never changed ANI. + pa_wages = 100_000 + higher_contribution + hicbc_wages = hicbc_start + higher_contribution + cases = [ + (pay, contribution) + for pay in (wages, pa_wages, hicbc_wages) + for contribution in (lower_contribution, higher_contribution) + ] + values = simulate(template, year, cases, with_child=True) + + expected_ani = np.array([pay - contribution for pay, contribution in cases]) + expected_pa = np.maximum( + 0, np.ceil(12_570 - np.maximum(0, expected_ani - 100_000) / 2) + ) + charge_percentage = np.clip( + np.floor(100 * (expected_ani - hicbc_start) / (hicbc_end - hicbc_start)), + 0, + 100, + ) + expected_hicbc = np.floor(1_200 * charge_percentage / 100) + np.testing.assert_allclose(values["adjusted_net_income"], expected_ani, atol=0.01) + np.testing.assert_allclose(values["personal_allowance"], expected_pa, atol=0.01) + np.testing.assert_allclose(values["CB_HITC"], expected_hicbc, atol=0.01) + + pa = values["personal_allowance"].reshape(-1, 2) + hicbc = values["CB_HITC"].reshape(-1, 2) + assert np.all(pa[:, 1] >= pa[:, 0]) + assert np.all(hicbc[:, 1] <= hicbc[:, 0]) + assert pa[1, 1] > pa[1, 0] + assert hicbc[2, 1] < hicbc[2, 0] From 1dbc881720cd7105a305ab2d86d801e870e9eea7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 06:08:02 -0400 Subject: [PATCH 10/14] fix: include relieved pension contributions in adjusted net income --- changelog.d/2243.fixed.md | 1 + docs/engineering/gift-aid.md | 52 +++++++++++++------ .../hmrc/income_tax/adjusted_net_income.py | 19 +++++-- .../hmrc/income_tax/allowances/allowances.py | 2 +- .../allowances/personal_allowance.py | 2 +- .../gov/hmrc/income_tax/net_income.py | 19 ++++--- .../pensions/pension_contributions_relief.py | 22 +++++++- 7 files changed, 88 insertions(+), 29 deletions(-) create mode 100644 changelog.d/2243.fixed.md diff --git a/changelog.d/2243.fixed.md b/changelog.d/2243.fixed.md new file mode 100644 index 0000000000..8a5d6856b7 --- /dev/null +++ b/changelog.d/2243.fixed.md @@ -0,0 +1 @@ +- Deducted relieved pension contributions from adjusted net income so that the Personal Allowance taper and High Income Child Benefit Charge reflect pension relief, while retaining a single deduction from the income tax base. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 2437e5efa2..c3c9eb391e 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -32,19 +32,41 @@ corrected tax at its top gives the tax on that slice. This preserves the original scale calculation when the gift is zero and handles gifts that cross several thresholds. -`net_income` holds the income base previously exposed as `adjusted_net_income`. -Taxable-income calculations and allowance allocation use this base. -When adjusted net income is supplied explicitly, its gross Gift Aid adjustment -is added back to obtain the taxable-income base. This preserves direct income -inputs when the gift is zero without recovering ordinary taxable income from a -rounded adjusted-net-income calculation. The marriage-neutral income-tax -reform splits `net_income` before each person's Gift Aid adjustment. -`adjusted_net_income` deducts the gross gift once, as [s. 58(1), Step -2](https://www.legislation.gov.uk/ukpga/2007/3/section/58) requires. The Personal -Allowance taper and High Income Child Benefit Charge therefore see the lower -adjusted net income. The Personal Allowance formula does not deduct Gift Aid a -second time. Capital gains tax uses `net_income`, retains its gross Gift Aid -band extension, and no longer removes the net gift from `allowances`. +`net_income` holds the model's income base before pension relief, personal +allowances and Gift Aid adjustments. Taxable-income calculations and allowance +allocation use this base. When adjusted net income is supplied explicitly, +gross Gift Aid and relieved pension contributions are added back to recover +that base. Ordinary cases calculate it directly to avoid recovering income +from rounded adjusted net income. The marriage-neutral income-tax reform +splits `net_income` before each person's adjustments. + +`adjusted_net_income` deducts the gross gift and relieved pension contributions +once, as [ITA 2007 s. 58](https://www.legislation.gov.uk/ukpga/2007/3/section/58) +requires. Statutory net income already excludes net-pay contributions under +[FA 2004 s. 193](https://www.legislation.gov.uk/ukpga/2004/12/section/193) and +relief on claim under [s. 194(1)](https://www.legislation.gov.uk/ukpga/2004/12/section/194); +s. 58 then deducts gross relief-at-source contributions under +[s. 192(1)](https://www.legislation.gov.uk/ukpga/2004/12/section/192). +The model represents employment income gross of employee and personal pension +contributions and deducts their aggregate relief through `allowances`. +Consequently, its ANI calculation deducts that relieved aggregate too. Pension +inputs follow the existing gross contribution convention; do not gross them +up again. The Personal Allowance taper and High Income Child Benefit Charge +see the resulting ANI without making a second adjustment. + +Pension relief remains in `allowances`, and the rate-band bases continue to +read `net_income`, so it reduces the ordinary income tax base once. Property, +savings and dividend income retain that same allocation. Capital gains tax +removes relieved personal pension contributions from its deducted allowances +and instead extends its basic-rate band by that amount plus gross Gift Aid. +The annual allowance taper continues to use its separate income base; its +statutory adjusted-income definition is distinct from s. 58 ANI. The existing +annual allowance cap on pension relief and the separate charge are unchanged. + +For example, £150,000 employment income and £60,000 gross personal pension +contributions in London in 2024 give ANI of £90,000 and the full £12,570 +Personal Allowance. The ordinary taxable base is £77,430, giving £23,432 +income tax (£37,700 at 20% and £39,730 at 40%). Savings and dividends stack above all non-savings income, property income included ([ITA 2007 s. 16](https://www.legislation.gov.uk/ukpga/2007/3/section/16); @@ -64,8 +86,8 @@ including s. 12B(8) for Scottish taxpayers, with s. 414(2)(b)). Several limitations remain relevant when interpreting mixed-income results: -- Adjusted net income still omits other adjustments in s. 58, including the - deduction for pension contributions relieved at source in Step 3. +- Adjusted net income does not implement the s. 58 Step 4 add-back of relief + for trade union or police organisation payments. - Gifts are assumed to qualify. The model does not charge the donor for a shortfall in income tax and capital gains tax covering the tax attributed to gifts under [ss. 423–424](https://www.legislation.gov.uk/ukpga/2007/3/section/424). diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py index dc62518612..212130b27c 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/adjusted_net_income.py @@ -5,6 +5,13 @@ class adjusted_net_income(Variable): value_type = float entity = Person label = "Adjusted net income" + documentation = ( + "Income after the gross Gift Aid and relieved pension contribution " + "adjustments in ITA 2007 s.58. The model's net_income is before " + "pension relief, so both relieved employee and personal contributions " + "are deducted here. Pension relief separately enters allowances once " + "when calculating the income charged to tax." + ) definition_period = YEAR reference = dict( title="Income Tax Act 2007, s. 58", @@ -13,6 +20,12 @@ class adjusted_net_income(Variable): unit = GBP def formula(person, period, parameters): - # ITA 2007 s.58, Step 2: deduct the gift grossed up at the basic rate. - # This adjustment affects the PA taper and HICBC, not taxable income. - return person("net_income", period) - person("gift_aid_grossed_up", period) + # s.58 deducts gross Gift Aid and relieved RAS. The model also holds + # employee contributions gross in net_income, with their relief in + # allowances, so deduct that relief here too. Taxable-income bases + # continue to use net_income and allowances: no second deduction. + return ( + person("net_income", period) + - person("gift_aid_grossed_up", period) + - person("pension_contributions_relief", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py index 229589a4fc..a45c531b4d 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/allowances.py @@ -4,7 +4,7 @@ class allowances(Variable): value_type = float entity = Person - label = "Allowances applicable to adjusted net income" + label = "Allowances and reliefs deducted from the income tax base" definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py index 735684812c..8553f40580 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/personal_allowance.py @@ -17,7 +17,7 @@ def formula(person, period, parameters): PA = params.gov.hmrc.income_tax.allowances.personal_allowance personal_allowance = PA.amount ANI = person("adjusted_net_income", period) - # adjusted_net_income already includes the s.58 Gift Aid adjustment. + # adjusted_net_income includes the s.58 Gift Aid and pension relief. excess = max_(0, ANI - PA.maximum_ANI) reduction = excess * PA.reduction_rate return max_(0, np.ceil(personal_allowance - reduction)) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py index ef5d11154f..75792140fc 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py @@ -5,11 +5,12 @@ class net_income(Variable): value_type = float entity = Person - label = "Income before personal allowances and Gift Aid adjustments" + label = "Income before allowances and Gift Aid adjustments" documentation = ( "The model's taxable income components after trade loss relief " - "against general income and before the Gift Aid adjustment " - "to adjusted net income. Gift Aid extends rate limits and reduces " + "against general income and before pension relief, personal " + "allowances and the Gift Aid adjustment to adjusted net income. " + "Pension relief is deducted through allowances. Gift Aid extends rate limits and reduces " "adjusted net income, but does not reduce the income charged to tax." ) definition_period = YEAR @@ -21,12 +22,16 @@ class net_income(Variable): def formula(person, period, parameters): # An explicitly supplied ANI already includes the s.58 adjustment. - # Restore it for the taxable base, preserving direct ANI inputs at - # zero Gift Aid. Ordinary cases compute the base directly to avoid - # subtracting and re-adding a large gift through a float32 ANI cache. + # Restore Gift Aid and pension relief for the pre-allowance base. + # Ordinary cases compute the base directly to avoid subtracting and + # re-adding relief through a float32 ANI cache. supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: - return supplied_ani + person("gift_aid_grossed_up", period) + return ( + supplied_ani + + person("gift_aid_grossed_up", period) + + person("pension_contributions_relief", period) + ) # ITA 2007 ss.23 and 64 deduct trade loss relief at Step 2, before # personal allowances, Gift Aid's ANI adjustment and tax rate limits. return max_( 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 a506e58726..9eab1e2127 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.supplied_inputs import supplied_input class pension_contributions_relief(Variable): @@ -14,7 +15,6 @@ class pension_contributions_relief(Variable): def formula(person, period, parameters): contributions = person("pension_contributions", period) - pension_allowance = person("pension_annual_allowance", period) age_limit = parameters( period ).gov.hmrc.pensions.pension_contributions_relief_age_limit @@ -24,6 +24,24 @@ def formula(person, period, parameters): period ).gov.hmrc.income_tax.reliefs.pension_contribution.basic_amount tax_relief = min_(income, contributions) * under_age_limit - max_pension_relief = max_(basic_amount, pension_allowance) + supplied_ani = supplied_input(person, "adjusted_net_income", period) + if supplied_ani is not None: + # The existing AA cap depends on net_income, which restores this + # relief to a supplied ANI. Solve the cap without that cycle: + # r = min(eligible, cap(A + r)), where A = ANI + gross Gift Aid. + allowance = parameters( + period + ).gov.hmrc.income_tax.allowances.annual_allowance + income_before_relief = supplied_ani + person("gift_aid_grossed_up", period) + max_pension_relief = max_( + max_(basic_amount, allowance.minimum), + allowance.default + - allowance.reduction_rate + * max_(0, income_before_relief + allowance.default - allowance.taper) + / (1 + allowance.reduction_rate), + ) + else: + pension_allowance = person("pension_annual_allowance", period) + max_pension_relief = max_(basic_amount, pension_allowance) return min_(tax_relief, max_pension_relief) From ded019a73ea510af1f5d761577142a7373e61403 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:39:28 -0400 Subject: [PATCH 11/14] docs: correct pension relief test explanations --- .../gov/hmrc/pensions/pension_contributions_relief.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml index 7ea1ec526f..39ed62cd63 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_relief.yaml @@ -17,7 +17,8 @@ # relieved even though the tapered annual allowance is 4,000. pension_contributions_relief: 400000 # The annual allowance charge recovers the relief on the excess: - # (400,000 - 4,000) at 45% on top of reduced net income of 1,600,000. + # (400,000 - 4,000) at 45% on top of reduced net income of 2,000,000: + # the charge adds relieved RAS back to income after allowances. pension_annual_allowance_chargeable_amount: 396000 personal_pension_contributions_tax: 396000 * 0.45 - name: £30k self-employment income, no private pension contributions @@ -82,9 +83,9 @@ employee_pension_contributions_reported: 1_500 personal_pension_contributions: 1_000 output: - # 2_000 of earnings relieves all 2_500 up to earnings; the increase to - # 3_600 can only come from the 1_000 of relief at source, of which 500 is - # left after earnings: 2_000 + 500. + # 2_000 is relieved within earnings, plus 500 through the RAS-only + # basic-amount increase. Of the 1_000 of relief at source, 500 remains + # after using earnings: 2_000 + 500. pension_contributions_relief: 2_500 - name: Relief up to relevant earnings, not beyond (s. 190(1)) From c2f3fd56eaf56c4787a0f5d55307a98fea0047b3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:39:43 -0400 Subject: [PATCH 12/14] fix: preserve pension relief when supplied ANI is inverted --- changelog.d/2243.fixed.md | 2 +- docs/engineering/gift-aid.md | 7 +++ .../reforms/cps/marriage_tax_reforms.py | 12 ++--- .../reforms/cps/marriage_tax_reforms.yaml | 27 ++++++++++ ...test_adjusted_net_income_pension_relief.py | 49 ++++++++++++++++++- policyengine_uk/utils/income_tax.py | 9 ++++ .../gov/hmrc/income_tax/net_income.py | 7 +-- 7 files changed, 100 insertions(+), 13 deletions(-) diff --git a/changelog.d/2243.fixed.md b/changelog.d/2243.fixed.md index 8a5d6856b7..0ea60ffe4b 100644 --- a/changelog.d/2243.fixed.md +++ b/changelog.d/2243.fixed.md @@ -1 +1 @@ -- Deducted relieved pension contributions from adjusted net income so that the Personal Allowance taper and High Income Child Benefit Charge reflect pension relief, while retaining a single deduction from the income tax base. +- Deducted relieved pension contributions from adjusted net income so that the Personal Allowance taper and High Income Child Benefit Charge reflect pension relief, while retaining a single deduction from the income tax base and restoring pension relief consistently for supplied ANI in the marriage-neutral reform. diff --git a/docs/engineering/gift-aid.md b/docs/engineering/gift-aid.md index 357fc5db57..6303e0ba16 100644 --- a/docs/engineering/gift-aid.md +++ b/docs/engineering/gift-aid.md @@ -64,6 +64,13 @@ statutory adjusted-income definition is distinct from s. 58 ANI. Contribution relief follows the earnings/basic-amount limit. Pension input above the annual allowance attracts the separate annual allowance charge. +Both the baseline and marriage-neutral reform reconstruct `net_income` from +supplied ANI by restoring gross Gift Aid and relieved pension contributions +through the same helper. Supplying the ANI derived by the model therefore +preserves the pre-allowance base, including when an annual allowance is +explicitly supplied. That allowance determines the separate charge rather +than contribution relief. + For example, £150,000 employment income and £60,000 gross personal pension contributions in London in 2024 give ANI of £90,000 and the full £12,570 Personal Allowance. The ordinary taxable base is £77,430, giving £23,432 diff --git a/policyengine_uk/reforms/cps/marriage_tax_reforms.py b/policyengine_uk/reforms/cps/marriage_tax_reforms.py index 32cedd3f82..d3531b0e1e 100644 --- a/policyengine_uk/reforms/cps/marriage_tax_reforms.py +++ b/policyengine_uk/reforms/cps/marriage_tax_reforms.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income from policyengine_uk.utils.supplied_inputs import supplied_input from typing import Union, Optional @@ -157,20 +158,19 @@ def formula(person, period, parameters): class net_income(Variable): label = "Optimised net income" documentation = ( - "Income before Gift Aid adjustments, split equally between married partners" + "Income before pension relief and Gift Aid adjustments, split equally " + "between married partners" ) entity = Person definition_period = YEAR value_type = float def formula(person, period, parameters): - # A supplied adjusted net income already includes the s.58 Gift - # Aid step, and it took precedence over this reform's split before - # the reform moved to net_income. Keep that: restore the gift for - # the taxable base, as the baseline net_income does. + # Supplied ANI takes precedence over this reform's split. Restore + # gross Gift Aid and relieved pensions, as the baseline inverse does. supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: - return supplied_ani + person("gift_aid_grossed_up", period) + return net_income_from_adjusted_net_income(person, period, supplied_ani) income = person("unadjusted_net_income", period) # Split between the spouses or civil partners only, never their # children. diff --git a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml index 7dd1c38ab0..c0ada51228 100644 --- a/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml +++ b/policyengine_uk/tests/policy/reforms/cps/marriage_tax_reforms.yaml @@ -218,6 +218,33 @@ earned_taxable_income: [37_430, 0] income_tax: [7_486, 0] +- name: Marriage-neutral IT — supplied ANI restores pension relief to the taxable base + period: 2024 + absolute_error_margin: 0.01 + reforms: + - policyengine_uk.reforms.cps.marriage_tax_reforms.marriage_neutral_it_reform + input: + people: + supplied: + age: 45 + employment_income: 150_000 + personal_pension_contributions: 60_000 + adjusted_net_income: 90_000 + benunits: + supplied_family: + members: [supplied] + is_married: false + households: + supplied_household: + members: [supplied] + region: LONDON + output: + net_income: 150_000 + adjusted_net_income: 90_000 + pension_contributions_relief: 60_000 + personal_allowance: 12_570 + income_tax: 23_432 + - name: Marriage-neutral IT — a supplied adjusted net income gets the grossed-up gift back period: 2026 absolute_error_margin: 1 diff --git a/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py b/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py index 61c4731996..a7f25bbd89 100644 --- a/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py +++ b/policyengine_uk/tests/test_adjusted_net_income_pension_relief.py @@ -19,7 +19,16 @@ def template(): return Simulation(situation={"people": {"template": {"age": {2024: 45}}}}) -def simulate(template, year, cases, *, with_child=False, gift_aid=0, supplied_ani=None): +def simulate( + template, + year, + cases, + *, + with_child=False, + gift_aid=0, + supplied_ani=None, + person_inputs=None, +): """Give each taxpayer a separate London household and optional child.""" people, benunits, households, adult_indices = {}, {}, {}, [] for index, (wages, contributions) in enumerate(cases): @@ -35,6 +44,8 @@ def simulate(template, year, cases, *, with_child=False, gift_aid=0, supplied_an } if supplied_ani is not None: people[adult]["adjusted_net_income"] = {year: supplied_ani} + for name, value in (person_inputs or {}).items(): + people[adult][name] = {year: value} members = [adult] if with_child: child = f"child_{index}" @@ -65,6 +76,7 @@ def simulate(template, year, cases, *, with_child=False, gift_aid=0, supplied_an "earned_taxable_income", "income_tax", "CB_HITC", + "student_loan_repayment", ) return { name: np.asarray(simulation.calculate(name, year), dtype=float)[adult_indices] @@ -144,6 +156,41 @@ def test_supplied_ani_preserves_pension_relief_and_annual_allowance( ) +def test_supplied_ani_preserves_explicit_annual_allowance_and_student_loan(template): + person_inputs = { + "pension_annual_allowance": 20_000, + "student_loan_plan": "PLAN_2", + "student_loan_balance": 100_000, + } + ordinary = simulate( + template, 2024, [(150_000, 60_000)], person_inputs=person_inputs + ) + supplied = simulate( + template, + 2024, + [(150_000, 60_000)], + person_inputs=person_inputs, + supplied_ani=ordinary["adjusted_net_income"][0], + ) + for name in ordinary: + np.testing.assert_allclose( + supplied[name], ordinary[name], atol=0.01, err_msg=name + ) + # Section 190 relief is independent of AA after #2240: the £40,000 + # excess attracts a separate charge. An explicit AA must not make the + # supplied-ANI inverse invent income or increase Plan 2 repayments. + for name, expected in { + "pension_annual_allowance": 20_000, + "pension_contributions_relief": 60_000, + "adjusted_net_income": 90_000, + "net_income": 150_000, + # (150,000 - 27,295) * 9%. + "student_loan_repayment": 11_043.45, + }.items(): + assert ordinary[name][0] == pytest.approx(expected, abs=0.01), name + assert supplied[name][0] == pytest.approx(expected, abs=0.01), name + + @settings(max_examples=30, deadline=None, derandomize=True, database=None) @given( year=st.sampled_from([2023, 2024]), diff --git a/policyengine_uk/utils/income_tax.py b/policyengine_uk/utils/income_tax.py index 23201e2fd6..2b50b200e1 100644 --- a/policyengine_uk/utils/income_tax.py +++ b/policyengine_uk/utils/income_tax.py @@ -1,6 +1,15 @@ import numpy as np +def net_income_from_adjusted_net_income(person, period, adjusted_net_income): + """Restore the model's pre-allowance base from supplied s.58 ANI.""" + return ( + adjusted_net_income + + person("gift_aid_grossed_up", period) + + person("pension_contributions_relief", period) + ) + + def calc_income_tax_with_gift_aid( scale, income, gross_gift, first_extended_threshold=1, last_extended_threshold=3 ): diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py index 75792140fc..d155498144 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/net_income.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.income_tax import net_income_from_adjusted_net_income from policyengine_uk.utils.supplied_inputs import supplied_input @@ -27,11 +28,7 @@ def formula(person, period, parameters): # re-adding relief through a float32 ANI cache. supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: - return ( - supplied_ani - + person("gift_aid_grossed_up", period) - + person("pension_contributions_relief", period) - ) + return net_income_from_adjusted_net_income(person, period, supplied_ani) # ITA 2007 ss.23 and 64 deduct trade loss relief at Step 2, before # personal allowances, Gift Aid's ANI adjustment and tax rate limits. return max_( From e4e6323812ef92846ea43d09b8505cf23c2a3765 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 07:50:27 -0400 Subject: [PATCH 13/14] test: bound pension contributions by restored personal allowance --- ...est_pension_annual_allowance_properties.py | 41 +++++++++++++++---- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 9aedff95c4..4df2f1f149 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -232,6 +232,26 @@ def test_extra_contributions_never_raise_tax_by_more_than_the_charge( @PROPERTY_SETTINGS +@example( + people=[ + { + "age": 45, + "employment_income": 150_000, + "self_employment_income": 0, + "employer_pension_contributions": 0, + "contribution_share": 0.0, + "net_pay_share": net_pay_share, + "gift_aid": 0, + "savings_interest_income": 0, + "dividend_income": 0, + "unused_pension_annual_allowance": 0, + "region": "LONDON", + } + for net_pay_share in (0.0, 0.0, 1.0, 1.0) + ], + year=2024, + step=1.0, +) @example( people=[ { @@ -274,13 +294,20 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste allowance = np.array( zero_sim.calculate("pension_annual_allowance", year), dtype=float ) - # Income after allowances with no contributions. Relief beyond it is worth - # nothing, but the charge still falls on the input amount, so the - # comparison stops there as well as at relevant earnings. + income_tax = zero_sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax + # Contributions can restore the full personal allowance as ANI falls. + # Retain the other allowances from zero contributions, but reserve the + # year's full PA so relief remains valuable throughout the comparison. + # Beyond that income, the charge still falls on the full input amount. + restored_allowances = ( + np.array(zero_sim.calculate("allowances", year), dtype=float) + - np.array(zero_sim.calculate("personal_allowance", year), dtype=float) + + float(income_tax.allowances.personal_allowance.amount) + ) taxable_income = np.maximum( 0, np.array(zero_sim.calculate("net_income", year), dtype=float) - - np.array(zero_sim.calculate("allowances", year), dtype=float), + - restored_allowances, ) earnings = np.array([_relevant_earnings(p) for p in people], dtype=float) upper = np.minimum(earnings, taxable_income) @@ -297,11 +324,7 @@ def test_contributions_above_the_allowance_are_not_taxed_twice(people, year, ste ] tax_at = _calc(_with_contributions(people, at_allowance), year, variables) tax_above = _calc(_with_contributions(people, above), year, variables) - basic_limit = float( - zero_sim.tax_benefit_system.parameters( - year - ).gov.hmrc.income_tax.rates.uk.thresholds[1] - ) + basic_limit = float(income_tax.rates.uk.thresholds[1]) def qualifies_for_ras_extension(out): ras = np.minimum( From 7f1b608f20c9ddd9d4de77721e7e90ba212d73a7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 16 Oct 2026 10:05:50 -0400 Subject: [PATCH 14/14] Use pre-pension income in trading-loss reference --- .../tests/test_trading_loss_properties.py | 25 ++++++++----------- 1 file changed, 10 insertions(+), 15 deletions(-) diff --git a/policyengine_uk/tests/test_trading_loss_properties.py b/policyengine_uk/tests/test_trading_loss_properties.py index c74afc311c..18526fb992 100644 --- a/policyengine_uk/tests/test_trading_loss_properties.py +++ b/policyengine_uk/tests/test_trading_loss_properties.py @@ -8,10 +8,10 @@ general income equals min(loss, max(£50,000, 25% of adjusted total income), net income), if ordinary expenses and losses beat the trading allowance. Otherwise the allowance applies and current loss relief is nil. - Net income is the adjusted net income under ordinary expense treatment - without the loss (a separate simulation); adjusted total income is - that less the pension contributions given relief (own contributions up to - the greater of £3,600 and pay plus profits, none from age 75). + Net income is the model's pre-pension net_income under ordinary expense + treatment without the loss (a separate simulation); adjusted total income + is that less the pension contributions given relief once (own contributions + up to the greater of £3,600 and pay plus profits, none from age 75). 2. Differential: a person's Income Tax with a loss equals the Income Tax of the same person without the loss and with their income cut by the relief, taken from non-savings income first, then savings, then dividends: the @@ -229,12 +229,12 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): ] a = calculate( single_people(people, regions), - ["trade_loss_relief_against_general_income", "adjusted_net_income"], + ["trade_loss_relief_against_general_income", "net_income"], ) b = calculate( single_people(without, regions), [ - "adjusted_net_income", + "net_income", "taxable_employment_income", "taxable_self_employment_income", ], @@ -247,7 +247,7 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): relieved = (person["age"] < 75) * min( person["personal_pension_contributions"], max(3_600.0, earnings) ) - ati = max(0.0, b["adjusted_net_income"][i] - relieved) + ati = max(0.0, b["net_income"][i] - relieved) cap = max(50_000.0, 0.25 * ati) # Generated taxpayers have no receipts, capital allowances or prior # losses. With unknown receipts, allowance treatment fully exempts a @@ -256,20 +256,15 @@ def test_relief_is_the_loss_within_net_income_and_the_cap(people, regions): allowance = profit if profit <= 1_000 else 0.0 ordinary = person["trading_loss"] > allowance expected = ( - min(person["trading_loss"], cap, b["adjusted_net_income"][i]) - if ordinary - else 0.0 + min(person["trading_loss"], cap, b["net_income"][i]) if ordinary else 0.0 ) allowance_kept = 0.0 if ordinary else allowance relief = a["trade_loss_relief_against_general_income"][i] assert abs(relief - expected) <= tolerance(expected, ati), (person, relief) # Ordinary net income falls by the chosen relief or allowance. assert abs( - b["adjusted_net_income"][i] - - allowance_kept - - relief - - a["adjusted_net_income"][i] - ) <= tolerance(b["adjusted_net_income"][i]), person + b["net_income"][i] - allowance_kept - relief - a["net_income"][i] + ) <= tolerance(b["net_income"][i]), person @st.composite