From a89d9e19289f08539c8cc361a408d6a88b502639 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 06:59:27 -0400 Subject: [PATCH 1/3] 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 2/3] 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 3/3] 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