diff --git a/changelog.d/2544.fixed.md b/changelog.d/2544.fixed.md new file mode 100644 index 0000000000..52f9ed5bac --- /dev/null +++ b/changelog.d/2544.fixed.md @@ -0,0 +1 @@ +- Limited Income Tax reductions to the tax calculated at Step 5, so they no longer offset the High Income Child Benefit Charge or the pension annual allowance charge added at Step 7 (ITA 2007 ss. 23, 29 and 30). The married couple's allowance reduction is no longer capped at the Step 5 tax plus the High Income Child Benefit Charge. The Step 5 components, Step 6 reductions and Step 7 charges are now separate parameter lists: `step_5_tax_components`, `step_6_tax_reductions` and `step_7_additional_tax` replace `income_tax_additions` and `income_tax_subtractions`. diff --git a/changelog.d/2544.removed.md b/changelog.d/2544.removed.md new file mode 100644 index 0000000000..de9d00e3e6 --- /dev/null +++ b/changelog.d/2544.removed.md @@ -0,0 +1 @@ +- Removed the `gov.hmrc.income_tax.income_tax_additions` and `gov.hmrc.income_tax.income_tax_subtractions` parameter lists. Use `step_5_tax_components` and `step_7_additional_tax` for the first, and `step_6_tax_reductions` for the second. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index f242470b4a..f312afcb8c 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -714,6 +714,8 @@ exclusions: - {variable: savings_income_tax, kind: component, head: income_tax} - {variable: dividend_income_tax, kind: component, head: income_tax} - {variable: property_income_tax, kind: component, head: income_tax} + - {variable: income_tax_reductions, kind: component, head: income_tax, note: "Step 6 tax reductions, subtracted inside income_tax."} + - {variable: income_tax_charges, kind: component, head: income_tax, note: "Step 7 charges (CB_HITC and the annual allowance charge), added inside income_tax."} - {variable: CB_HITC, kind: component, head: income_tax} - {variable: personal_pension_contributions_tax, kind: component, head: income_tax} - {variable: capped_mcad, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_additions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_additions.yaml deleted file mode 100644 index df5f016a9f..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_additions.yaml +++ /dev/null @@ -1,15 +0,0 @@ -description: All variables that are added together to calculate total income tax. -metadata: - label: Additive components of total Income Tax - unit: list - reference: - - title: Income Tax Act 2007 s. 23 - href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 -values: - 0000-01-01: - - earned_income_tax - - savings_income_tax - - dividend_income_tax - - property_income_tax - - CB_HITC - - personal_pension_contributions_tax diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml deleted file mode 100644 index e6bc103f75..0000000000 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml +++ /dev/null @@ -1,14 +0,0 @@ -description: All variables that are subtracted from the base amount to calculate total income tax. -metadata: - label: Subtractive components of total Income Tax - unit: list - reference: - - title: Income Tax Act 2007 s. 23 - href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 - - title: Income Tax Act 2007 s. 26(1)(a) - href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 -values: - 0000-01-01: - - capped_mcad - - other_tax_credits - - property_finance_cost_relief diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_5_tax_components.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_5_tax_components.yaml new file mode 100644 index 0000000000..45c1338828 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_5_tax_components.yaml @@ -0,0 +1,14 @@ +description: Income Tax at each rate on the parts of a person's income left after allowances, which Step 5 of the Income Tax calculation adds together before tax reductions are deducted. +metadata: + label: Step 5 income tax components + unit: list + period: year + reference: + - title: Income Tax Act 2007 s. 23 (Steps 4 and 5) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 +values: + 0000-01-01: + - earned_income_tax + - savings_income_tax + - dividend_income_tax + - property_income_tax diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml new file mode 100644 index 0000000000..aaf29f578d --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml @@ -0,0 +1,17 @@ +description: Tax reductions deducted at Step 6 of the Income Tax calculation. Each is limited by its own rules, and together they are limited to the tax calculated at Step 5, so they never reduce the charges added at Step 7. +metadata: + label: Step 6 income tax reductions + unit: list + period: year + reference: + - title: Income Tax Act 2007 s. 23 (Step 6) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 + - title: Income Tax Act 2007 s. 26(1) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/26 + - title: Income Tax Act 2007 s. 29(2) and (3) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/29 +values: + 0000-01-01: + - capped_mcad + - other_tax_credits + - property_finance_cost_relief diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/step_7_additional_tax.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/step_7_additional_tax.yaml new file mode 100644 index 0000000000..41d503978e --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_7_additional_tax.yaml @@ -0,0 +1,16 @@ +description: Charges to Income Tax added at Step 7 of the Income Tax calculation, after the tax reductions, so tax reductions do not offset them. The winter fuel payment charge is also a Step 7 amount, added separately in income_tax after income_tax_before_winter_fuel_payment_charge to avoid the cycle through benefit means tests. This list holds the other modelled Step 7 charges; it does not cover every provision in section 30. +metadata: + label: Step 7 additional income tax + unit: list + period: year + reference: + - title: Income Tax Act 2007 s. 23 (Step 7) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/23 + - title: Income Tax Act 2007 s. 30(1) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/30 + - title: Finance Act 2026 Sch. 10 para. 4(3) (winter fuel payment charge) + href: https://www.legislation.gov.uk/ukpga/2026/11/schedule/10/paragraph/4 +values: + 0000-01-01: + - CB_HITC + - personal_pension_contributions_tax diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml index a91dfaee06..ad4d9536cc 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/allowances/is_allowance_eligible.yaml @@ -25,7 +25,12 @@ income_tax_pre_charges: 500.0 CB_HITC: 200.0 output: - capped_mcad: 700.0 + # The reduction is deducted at Step 6 only so far as there is Step 5 tax + # (ITA 2007 s. 29(2)); the High Income Child Benefit Charge is added at + # Step 7 and is not reduced. + capped_mcad: 500.0 + income_tax_reductions: 500.0 + income_tax: 200.0 - name: Annual Allowance for Pension Contributions period: 2024 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/step_6_step_7.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/step_6_step_7.yaml new file mode 100644 index 0000000000..552b4b6a95 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/step_6_step_7.yaml @@ -0,0 +1,188 @@ +# Tax reductions (Step 6) and charges (Step 7) in the Income Tax calculation. +# +# ITA 2007 s. 23 deducts the tax reductions at Step 6 from the tax calculated at +# Step 5, then adds the s. 30 charges at Step 7 to the tax left. A reduction may +# be deducted only so far as there is Step 5 tax to deduct it from, counting the +# reductions already deducted (s. 29(2) and (3)). So reductions never offset the +# High Income Child Benefit Charge (ITEPA 2003 Pt 10 Ch 8) or the annual +# allowance charge (FA 2004 s. 227), which s. 30(1) adds at Step 7. +# https://www.legislation.gov.uk/ukpga/2007/3/section/23 +# https://www.legislation.gov.uk/ukpga/2007/3/section/29 +# https://www.legislation.gov.uk/ukpga/2007/3/section/30 +# +# Step 5 tax on £100,000 of pay in 2026-27 (rest of UK): the £12,570 Personal +# Allowance leaves £87,430, so £37,700 × 20% + £49,730 × 40% = £27,432. +# Child Benefit for an only child is £27.05 a week (gov.uk/child-benefit), so +# £1,406.60 a year, rounded down to £1,406 for the charge (ITEPA s. 681C(3)). +# Adjusted net income of £100,000 is above £80,000, so the charge is 100%. + +- name: Tax reductions above the Step 5 tax do not offset the High Income Child Benefit Charge. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + employment_income: 100_000 + # For example VCT or EIS relief. + other_tax_credits: 50_000 + child: + age: 5 + benunit: + members: [parent, child] + output: + income_tax_pre_charges: [27_432, 0] + # The £50,000 is deducted only so far as there is Step 5 tax: £27,432. + income_tax_reductions: [27_432, 0] + CB_HITC: [1_406, 0] + income_tax_charges: [1_406, 0] + # £27,432 - £27,432 + £1,406. + income_tax: [1_406, 0] + +# Married Couple's Allowance: a claimant one of whose couple was born before +# 6 April 1935, here caring for a grandchild. The allowance is a model input; +# £4,360 gives a reduction of 10% (ITA 2007 s. 46), £436, the smallest gov.uk +# lists for 2025-26 (gov.uk/married-couples-allowance). s. 27(5) deducts VCT +# relief before the Married Couple's Allowance. +- name: The Married Couple's Allowance reduction is limited to the Step 5 tax left, not the tax plus the High Income Child Benefit Charge. + period: 2026 + absolute_error_margin: 0 + input: + people: + claimant: + age: 92 + employment_income: 100_000 + other_tax_credits: 27_200 + married_couples_allowance: 4_360 + # Keep this case focused on the Married Couple's Allowance. + winter_fuel_payment: 0 + spouse: + age: 88 + winter_fuel_payment: 0 + grandchild: + age: 10 + benunit: + members: [claimant, spouse, grandchild] + output: + income_tax_pre_charges: [27_432, 0, 0] + married_couples_allowance_deduction: [436, 0, 0] + # £27,432 - £27,200 of VCT relief leaves £232 for the £436 reduction. + capped_mcad: [232, 0, 0] + income_tax_reductions: [27_432, 0, 0] + CB_HITC: [1_406, 0, 0] + # Before the fix the reduction was capped at £27,432 + £1,406, so all + # £436 was deducted and income tax was £27,432 + £1,406 - £27,636 = £1,202. + income_tax: [1_406, 0, 0] + +- name: Tax reductions exactly equal to the Step 5 tax leave only the Step 7 charge. + period: 2026 + absolute_error_margin: 0 + input: + people: + parent: + age: 40 + employment_income: 100_000 + other_tax_credits: 27_432 + child: + age: 5 + benunit: + members: [parent, child] + output: + income_tax_pre_charges: [27_432, 0] + income_tax_reductions: [27_432, 0] + income_tax: [1_406, 0] + +- name: Tax reductions exactly equal to the Step 5 tax leave no tax without a Step 7 charge. + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 100_000 + other_tax_credits: 27_432 + output: + income_tax_pre_charges: 27_432 + income_tax_reductions: 27_432 + income_tax_charges: 0 + income_tax: 0 + +- name: Tax reductions just below the Step 5 tax are all deducted. + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 100_000 + other_tax_credits: 27_000 + output: + income_tax_reductions: 27_000 + income_tax: 432 + +# A landlord's property allowance election weighs income tax on each route, so it +# has to see the charges the reductions cannot offset. With the £1,000 property +# allowance (partial relief, ITTOIA 2005 s. 783BK), £99,000 of income leaves +# £86,430 after the Personal Allowance: Step 5 tax £37,700 × 20% + £48,730 × 40% +# = £27,032, all of it taken by the £27,200 of credits, so £1,406 of HICBC is +# left. Without it, £100,000 of income gives £27,432 of Step 5 tax; the credits +# and £232 of the £400 finance-cost reduction (20% of £2,000) take all of it, +# and £1,406 of HICBC is left. Either way the liability is £1,406. Before the +# fix, the allowance route netted the £168 of excess credits against the HICBC +# and gave £1,238. +- name: The property allowance election does not let tax reductions offset the High Income Child Benefit Charge. + period: 2026 + absolute_error_margin: 0 + input: + people: + landlord: + age: 45 + employment_income: 90_000 + property_income: 10_000 + property_rental_income: 10_000 + property_finance_costs: 2_000 + other_tax_credits: 27_200 + child: + age: 5 + benunit: + members: [landlord, child] + output: + CB_HITC: [1_406, 0] + income_tax: [1_406, 0] + +# The annual allowance charge on £70,000 of employer contributions: £10,000 +# above the £60,000 annual allowance, taxed at 40% as the slice from £87,430 +# to £97,430 of income after allowances (FA 2004 s. 227(4A)-(4B)). +- name: Tax reductions above the Step 5 tax do not offset the annual allowance charge. + period: 2026 + absolute_error_margin: 0 + input: + employment_income: 100_000 + employer_pension_contributions: 70_000 + other_tax_credits: 30_000 + output: + income_tax_pre_charges: 27_432 + income_tax_reductions: 27_432 + personal_pension_contributions_tax: 4_000 + income_tax_charges: 4_000 + income_tax: 4_000 + +# Finance Act 2026 Sch. 10 para. 4(3) adds the winter fuel payment charge +# (ITEPA 2003 Part 10 Chapter 9) to ITA 2007 s. 30(1), so it is also a +# Step 7 charge. A single eligible recipient under 80 receives £200 under +# SI 2025/969 reg. 3. Income above £35,000 makes the whole payment chargeable +# under ITEPA 2003 s. 681I; the charge does not use the tax left after credits. +# https://www.legislation.gov.uk/ukpga/2026/11/schedule/10/paragraph/4 +# https://www.legislation.gov.uk/uksi/2025/969/regulation/3 +# https://www.legislation.gov.uk/ukpga/2003/1/section/681I +- name: Excess tax reductions do not offset the winter fuel payment or annual allowance charges. + period: 2026 + absolute_error_margin: 0 + input: + age: 70 + employment_income: 100_000 + employer_pension_contributions: 70_000 + other_tax_credits: 50_000 + output: + income_tax_pre_charges: 27_432 + income_tax_reductions: 27_432 + personal_pension_contributions_tax: 4_000 + income_tax_charges: 4_000 + income_tax_before_winter_fuel_payment_charge: 4_000 + winter_fuel_payment: 200 + winter_fuel_payment_charge: 200 + income_tax: 4_200 diff --git a/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py b/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py new file mode 100644 index 0000000000..46edc4dd8c --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py @@ -0,0 +1,447 @@ +"""Property tests for Steps 5 to 7 of the Income Tax calculation (ITA 2007 s. 23). + +Step 6 deducts the tax reductions from the tax calculated at Step 5, and only so +far as there is Step 5 tax to deduct them from, counting the reductions already +deducted (s. 29(2) and (3)). Step 7 adds the s. 30 charges, here the High Income +Child Benefit Charge, the annual allowance charge and the winter fuel payment +charge, to the tax left. The last is added separately from the other Step 7 +charges to avoid a cycle through the benefit means tests. + +Invariants, for every drawn case: + +1. Bounds: 0 <= Step 6 deduction <= Step 5 tax. +2. Accounting: the Step 6 deduction is the sum of the reductions on the Step 6 + list, limited to the Step 5 tax; income tax is the Step 5 tax less the Step 6 + deduction plus the non-winter-fuel Step 7 charges. +3. Tax before the winter fuel payment charge >= the other Step 7 charges, so + reductions never offset a charge. +4. Tax before the winter fuel payment charge <= the Step 5 tax plus the other + Step 7 charges. Total tax is that tax plus the winter fuel payment charge, + and is never less than all the charges together. +5. The married couple's allowance reduction is at most the Step 5 tax left + after other_tax_credits, and the finance-cost reduction at most the tax left + after both. s. 27(5) deducts the VCT, EIS, SEIS, social investment, + community investment and maintenance reductions before the married couple's + allowance; other_tax_credits also holds foreign tax relief, which s. 27(6) + deducts last. The order changes how the total is split, not the total. +6. Monotonic: on each property allowance route, more other_tax_credits + leave the Step 5 tax and every charge unchanged and never raise tax before + the winter fuel payment charge. On the route the election takes, they never + raise total tax. Tax before the charge can rise there, because the election + weighs the charge: more credits can make the allowance route cheaper once + the charge counts, though it has more tax before the charge. +7. Differential, against a simulation of the rules before this fix (main at + 76cfc9e79: max(0, additions - subtractions), with the married couple's + allowance capped at the Step 5 tax plus the High Income Child Benefit + Charge): total income tax is never lower, since it is never lower on either + property allowance route and the election takes the cheaper one. Where both + take the same route, the Step 5 tax, the other charges and the finance-cost + reduction are unchanged, and tax before the winter fuel payment charge + rises by exactly the part of the other Step 7 charges the old netting let + the excess reductions offset. Comparisons across routes use total tax, + because the winter fuel payment charge can change the election. + +The cases run in reusable simulations of single-person households, a third of +them Scottish taxpayers and ages including State Pension age: each case is +simulated twice, as drawn and with more other_tax_credits. Charge-exempting +benefit entitlements stay fixed at false. Otherwise a lower tax deduction in +a means test can end an award and introduce the winter fuel payment charge, +so a monotonic tax comparison would not isolate the reductions being tested. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_uk import Simulation +from policyengine_uk.model_api import GBP, YEAR as YEAR_PERIOD, Person, Variable +from policyengine_uk.model_api import add, max_, min_ + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +CASES = 24 +PEOPLE = 2 * CASES # As drawn, then with more other_tax_credits. +SCOTTISH = [i % 3 == 2 for i in range(CASES)] +# The liability built from Steps 5 to 7 before the separately added WFP charge. +INCOME_TAX = "income_tax_before_winter_fuel_payment_charge" +CHARGE_EXEMPTIONS = [ + "is_on_income_support", + "is_on_income_based_jsa", + "is_on_pension_credit", + "is_on_income_related_esa", + "is_on_universal_credit", +] + + +def amount(maximum, points=()): + return st.one_of( + st.just(0.0), + st.floats(min_value=0, max_value=maximum), + st.sampled_from([float(p) for p in points] or [0.0]), + ) + + +case = st.tuples( + amount(250_000, [12_570, 50_270, 60_000, 80_000, 90_000, 100_000, 125_140]), + amount(30_000), # savings interest + amount(60_000), # dividends + amount(40_000, [10_000]), # property profits, all of them receipts + amount(25_000, [2_000]), # property finance costs + amount(90_000, [27_200, 27_432]), # other_tax_credits + amount(11_270, [4_360]), # married couple's allowance + amount(120_000, [70_000]), # employer pension contributions + st.sampled_from([45.0, 45.0, 70.0]), # age + st.sampled_from([0.0, 0.0, 1_406.6, 2_337.4]), # Child Benefit + st.floats(min_value=0, max_value=40_000), # extra other_tax_credits +) +cases = st.lists(case, min_size=CASES, max_size=CASES) + +INPUTS = [ + "employment_income", + "savings_interest_income", + "dividend_income", + "property_income", + "property_finance_costs", + "other_tax_credits", + "married_couples_allowance", + "employer_pension_contributions", + "age", +] + + +class capped_mcad(Variable): + value_type = float + entity = Person + label = "capped Married Couples' Allowance deduction before the fix" + definition_period = YEAR_PERIOD + unit = GBP + + def formula(person, period, parameters): + return min_( + person("married_couples_allowance_deduction", period), + add(person, period, ["income_tax_pre_charges", "CB_HITC"]), + ) + + +def _income_tax_before_the_fix(person, period, parameters): + p = parameters(period).gov.hmrc.income_tax + additions = add(person, period, ["income_tax_pre_charges", "income_tax_charges"]) + return max_(0, additions - add(person, period, p.step_6_tax_reductions)) + + +PRE_FIX_INCOME_TAX = type( + INCOME_TAX, + (Variable,), + dict( + value_type=float, + entity=Person, + label="Income Tax before the fix", + definition_period=YEAR_PERIOD, + unit=GBP, + formula=_income_tax_before_the_fix, + ), +) + + +class PRE_FIX(Reform): + def apply(self): + self.update_variable(capped_mcad) + self.update_variable(PRE_FIX_INCOME_TAX) + + +def build(reform=None): + people = { + f"person_{i}": { + **{v: {YEAR: 0} for v in INPUTS}, + **{v: {YEAR: False} for v in CHARGE_EXEMPTIONS}, + "age": {YEAR: 45}, + "property_rental_income": {YEAR: 0}, + } + for i in range(PEOPLE) + } + return Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"person_{i}"]} for i in range(PEOPLE) + }, + "households": { + f"household_{i}": { + "members": [f"person_{i}"], + "region": {YEAR: "SCOTLAND" if SCOTTISH[i % CASES] else "LONDON"}, + } + for i in range(PEOPLE) + }, + }, + reform=reform, + ) + + +@pytest.fixture(scope="module") +def sim(): + return build() + + +@pytest.fixture(scope="module") +def pre_fix_sim(): + return build(PRE_FIX) + + +def run(sim, drawn, route=None): + """The drawn cases, each as drawn and with more other_tax_credits, on the + property allowance route the election takes or, given a route, on that + route for everyone.""" + # Reset first, so no value calculated for the last draw survives. The reset + # keeps inputs set through set_input, so each run sets every drawn input + # again, and deletes the route a previous run fixed before fixing its own. + sim.reset_calculations() + sim.delete_arrays("uses_property_allowance") + if route is not None: + sim.set_input("uses_property_allowance", YEAR, np.full(PEOPLE, route)) + columns = [np.array(column, dtype=float) for column in zip(*drawn)] + *inputs, child_benefit, extra_credits = columns + supplied = {} + for variable, values in zip(INPUTS, inputs): + extra = extra_credits if variable == "other_tax_credits" else 0 + supplied[variable] = np.concatenate([values, values + extra]) + sim.set_input(variable, YEAR, supplied[variable]) + sim.set_input("property_rental_income", YEAR, supplied["property_income"]) + supplied["child_benefit"] = np.concatenate([child_benefit] * 2) + sim.set_input("child_benefit", YEAR, supplied["child_benefit"]) + + def calc(variable): + return sim.calculate(variable, YEAR).astype(float) + + # The drawn inputs reach the calculation. + for variable, values in supplied.items(): + assert np.allclose(calc(variable), values, rtol=1e-6, atol=0.01), variable + + income_tax = sim.tax_benefit_system.parameters(YEAR).gov.hmrc.income_tax + property_basic_rate = income_tax.rates.property.basic + return dict( + listed_reductions=sum(calc(v) for v in income_tax.step_6_tax_reductions), + listed_charges=sum(calc(v) for v in income_tax.step_7_additional_tax), + step_5=calc("income_tax_pre_charges"), + step_6=calc("income_tax_reductions"), + step_7=calc("income_tax_charges"), + hicbc=calc("CB_HITC"), + annual_allowance_charge=calc("personal_pension_contributions_tax"), + other_tax_credits=calc("other_tax_credits"), + mca_deduction=calc("married_couples_allowance_deduction"), + capped_mcad=calc("capped_mcad"), + finance_cost_reduction=calc("property_finance_cost_relief"), + finance_cost_reduction_uncapped=property_basic_rate + * calc("property_finance_costs_relieved"), + uses_property_allowance=sim.calculate("uses_property_allowance", YEAR), + income_tax=calc(INCOME_TAX), + winter_fuel_payment_charge=calc("winter_fuel_payment_charge"), + total_income_tax=calc("income_tax"), + ) + + +def tolerance(*values): + """A penny plus float32 rounding on the amounts involved.""" + return 0.01 + 1e-6 * sum(np.abs(v) for v in values) + + +SETTINGS = settings( + max_examples=30, + deadline=None, + suppress_health_check=[HealthCheck.too_slow], +) + + +@SETTINGS +@given(cases) +def test_step_6_deduction_is_the_reductions_limited_to_the_step_5_tax(sim, drawn): + r = run(sim, drawn) + tol = tolerance(r["step_5"], r["other_tax_credits"]) + assert (r["step_5"] >= 0).all() + assert (r["step_6"] >= 0).all() + assert (r["step_6"] <= r["step_5"] + tol).all() + limited = np.minimum(r["listed_reductions"], r["step_5"]) + assert (np.abs(r["step_6"] - limited) <= tol).all() + + +@SETTINGS +@given(cases) +def test_income_tax_is_step_5_less_step_6_plus_step_7(sim, drawn): + r = run(sim, drawn) + charges = r["listed_charges"] + tol = tolerance(r["step_5"], charges) + assert (np.abs(r["step_7"] - charges) <= tol).all() + # Both modelled s. 30 charges are on the Step 7 list. + assert (charges >= r["hicbc"] + r["annual_allowance_charge"] - tol).all() + expected = r["step_5"] - r["step_6"] + charges + assert (np.abs(r["income_tax"] - expected) <= tol).all() + # Reductions never offset a Step 7 charge, and never add tax. + assert (r["income_tax"] >= charges - tol).all() + assert (r["income_tax"] <= r["step_5"] + charges + tol).all() + + +@SETTINGS +@given(cases) +def test_total_tax_adds_the_winter_fuel_payment_charge_after_the_reductions(sim, drawn): + r = run(sim, drawn) + charge = r["winter_fuel_payment_charge"] + tol = tolerance(r["income_tax"], charge) + expected = r["income_tax"] + charge + assert (np.abs(r["total_income_tax"] - expected) <= tol).all() + assert (r["total_income_tax"] >= r["step_7"] + charge - tol).all() + + +@SETTINGS +@given(cases) +def test_each_reduction_is_limited_to_the_tax_left_before_it(sim, drawn): + r = run(sim, drawn) + tol = tolerance(r["step_5"], r["other_tax_credits"]) + left_after_credits = np.maximum(0, r["step_5"] - r["other_tax_credits"]) + assert (r["capped_mcad"] >= 0).all() + assert ( + np.abs(r["capped_mcad"] - np.minimum(r["mca_deduction"], left_after_credits)) + <= tol + ).all() + left_after_mca = np.maximum(0, left_after_credits - r["capped_mcad"]) + assert (r["finance_cost_reduction"] <= left_after_mca + tol).all() + assert ( + r["finance_cost_reduction"] <= r["finance_cost_reduction_uncapped"] + tol + ).all() + + +@SETTINGS +@given(cases) +def test_more_other_tax_credits_never_raise_income_tax(sim, drawn): + r = run(sim, drawn) + drawn_tax = r["total_income_tax"][:CASES] + with_more = r["total_income_tax"][CASES:] + assert (with_more <= drawn_tax + tolerance(drawn_tax)).all() + + +@SETTINGS +@given(cases) +def test_more_other_tax_credits_never_raise_tax_on_either_route(sim, drawn): + for route in [True, False]: + r = run(sim, drawn, route=route) + assert (r["uses_property_allowance"] == route).all() + drawn_tax, with_more = r["income_tax"][:CASES], r["income_tax"][CASES:] + assert (with_more <= drawn_tax + tolerance(drawn_tax)).all() + # On a given route the credits reach only Step 6. + for variable in ["step_5", "step_7", "winter_fuel_payment_charge"]: + as_drawn, with_more = r[variable][:CASES], r[variable][CASES:] + assert (np.abs(with_more - as_drawn) <= tolerance(as_drawn)).all() + + +def test_more_credits_can_move_the_election_to_more_tax_before_the_charge(sim): + # An English taxpayer aged 70 with £30,500 of pay and £5,000 of property + # receipts, all profit, and £5,000 of finance costs, with £3,800 and then + # £4,300 of other_tax_credits. The 2026-27 personal allowance is £12,570 + # and the basic rate 20%. + # - Actual expenses: total income £35,500, over the £35,000 at which the + # whole £200 winter fuel payment is charged (ITEPA 2003 s. 681I; SI + # 2025/969 reg. 3). Step 5 tax is 20% of £22,930, £4,586, and the + # finance-cost reduction 20% of £5,000, £1,000. Either amount of credits + # leaves no tax for Step 6 to reduce, so total tax is the £200 charge. + # - Partial relief (ITTOIA 2005 s. 783BK): profits of £4,000 give total + # income of £34,500 and no charge. Step 5 tax is 20% of £21,930, £4,386, + # with no finance-cost reduction: £586 is left after £3,800 of credits + # and £86 after £4,300. + # The election takes actual expenses at £200 against £586, then the + # allowance at £86 against £200. Total tax falls from £200 to £86, but + # tax before the charge rises from nothing to £86. + english = ~np.array(SCOTTISH) + drawn = [(30_500, 0, 0, 5_000, 5_000, 3_800, 0, 0, 70, 0, 500)] * CASES + + def on(route=None): + r = run(sim, drawn, route=route) + return { + variable: (r[variable][:CASES][english], r[variable][CASES:][english]) + for variable in [ + "uses_property_allowance", + "income_tax", + "winter_fuel_payment_charge", + "total_income_tax", + ] + } + + def check(r, variable, before, after): + assert np.allclose(r[variable][0], before, atol=0.01), variable + assert np.allclose(r[variable][1], after, atol=0.01), variable + + allowance, expenses, elected = on(True), on(False), on() + check(allowance, "income_tax", 586, 86) + check(allowance, "winter_fuel_payment_charge", 0, 0) + check(expenses, "income_tax", 0, 0) + check(expenses, "winter_fuel_payment_charge", 200, 200) + check(elected, "uses_property_allowance", False, True) + check(elected, "income_tax", 0, 86) + check(elected, "winter_fuel_payment_charge", 200, 0) + check(elected, "total_income_tax", 200, 86) + + +@SETTINGS +@given(cases) +def test_against_the_rules_before_the_fix(sim, pre_fix_sim, drawn): + new, old = run(sim, drawn), run(pre_fix_sim, drawn) + tol = tolerance(new["step_5"], new["step_7"], new["other_tax_credits"]) + # The pre-fix simulation reproduces main's formulas. + assert ( + np.abs( + old["capped_mcad"] + - np.minimum(old["mca_deduction"], old["step_5"] + old["hicbc"]) + ) + <= tol + ).all() + old_expected = np.maximum( + 0, old["step_5"] + old["step_7"] - old["listed_reductions"] + ) + assert (np.abs(old["income_tax"] - old_expected) <= tol).all() + # Never lower, whichever route each election takes. + assert (new["total_income_tax"] >= old["total_income_tax"] - tol).all() + # On the same route, only the offset charges come back. + same = new["uses_property_allowance"] == old["uses_property_allowance"] + for variable in ["step_5", "step_7", "finance_cost_reduction"]: + assert (np.abs(new[variable] - old[variable])[same] <= tol[same]).all() + excess = np.maximum(0, old["listed_reductions"] - old["step_5"]) + restored = np.minimum(old["step_7"], excess) + raised = new["income_tax"] - old["income_tax"] + assert (np.abs(raised - restored)[same] <= tol[same]).all() + + +def test_the_fix_restores_exactly_the_charges_the_old_netting_offset(sim, pre_fix_sim): + # A pension-age recipient owes both the High Income Child Benefit Charge + # and an annual allowance charge. Excess credits can offset neither those + # charges nor the separately added winter fuel payment charge. + drawn = [(100_000, 0, 0, 0, 0, 50_000, 0, 70_000, 70, 1_406.6, 0)] * CASES + new, old = run(sim, drawn), run(pre_fix_sim, drawn) + assert (new["winter_fuel_payment_charge"] > 0).all() + assert np.allclose(old["income_tax"], 0) + assert np.allclose(new["income_tax"], new["step_7"]) + assert np.allclose(new["total_income_tax"] - old["total_income_tax"], new["step_7"]) + + +def test_the_step_lists_are_disjoint_and_hold_the_modelled_provisions(): + from policyengine_uk import CountryTaxBenefitSystem + + system = CountryTaxBenefitSystem() + p = system.parameters(YEAR).gov.hmrc.income_tax + step_5 = list(p.step_5_tax_components) + step_6 = list(p.step_6_tax_reductions) + step_7 = list(p.step_7_additional_tax) + assert set(step_5) == { + "earned_income_tax", + "savings_income_tax", + "dividend_income_tax", + "property_income_tax", + } + assert { + "capped_mcad", + "other_tax_credits", + "property_finance_cost_relief", + } <= set(step_6) + assert {"CB_HITC", "personal_pension_contributions_tax"} <= set(step_7) + every = step_5 + step_6 + step_7 + assert len(every) == len(set(every)) + assert all(variable in system.variables for variable in every) diff --git a/policyengine_uk/utils/uc_income_tax.py b/policyengine_uk/utils/uc_income_tax.py index c34920d687..d8cab4feb3 100644 --- a/policyengine_uk/utils/uc_income_tax.py +++ b/policyengine_uk/utils/uc_income_tax.py @@ -91,7 +91,7 @@ def uc_income_tax_on_earnings_slice(person, period, parameters, include_trade=Tr period, [ variable - for variable in income_tax.income_tax_subtractions + for variable in income_tax.step_6_tax_reductions if variable != "property_finance_cost_relief" ], ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/capped_mcad.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/capped_mcad.py index b4d73c2a3d..61f33ceff4 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/capped_mcad.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/capped_mcad.py @@ -3,14 +3,36 @@ class capped_mcad(Variable): label = "capped Married Couples' Allowance deduction" + documentation = ( + "The married couple's allowance tax reduction deducted at Step 6 of " + "the Income Tax calculation. It is limited to the tax calculated at " + "Step 5 left after the reductions in other_tax_credits, which hold the " + "VCT, EIS, SEIS, social investment, community investment and " + "maintenance reductions deducted before it. It never reduces the " + "charges added at Step 7, such as the High Income Child Benefit " + "Charge. Foreign tax relief, also in other_tax_credits, should be " + "deducted after it (s. 27(6)); the order changes how the total is " + "split between the reductions, not the total." + ) entity = Person definition_period = YEAR value_type = float unit = GBP + reference = [ + dict( + title="Income Tax Act 2007 s. 27(4) and (5)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/27", + ), + dict( + title="Income Tax Act 2007 s. 29(2) and (3)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] def formula(person, period, parameters): - capping_value = add(person, period, ["income_tax_pre_charges", "CB_HITC"]) - return min_( - person("married_couples_allowance_deduction", period), - capping_value, + tax_left = max_( + 0, + person("income_tax_pre_charges", period) + - person("other_tax_credits", period), ) + return min_(person("married_couples_allowance_deduction", period), tax_left) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py index 352fb5396e..20e2e76d00 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py @@ -8,7 +8,8 @@ class income_tax(Variable): documentation = ( "Total Income Tax liability: the liability before the winter fuel " "payment charge, plus the charge, which is additional tax added at " - "Step 7 after tax reductions." + "Step 7 after the reductions capped at the Step 5 tax. Finance Act " + "2026 Sch. 10 para. 4(3) adds the charge to ITA 2007 s. 30(1)." ) definition_period = YEAR unit = GBP @@ -21,7 +22,14 @@ class income_tax(Variable): title="Income Tax Act 2007 s. 30", href="https://www.legislation.gov.uk/ukpga/2007/3/section/30", ), + dict( + title="Finance Act 2026 Sch. 10 para. 4(3)", + href="https://www.legislation.gov.uk/ukpga/2026/11/schedule/10/paragraph/4", + ), ) + # The winter fuel payment charge is a Step 7 amount, alongside the other + # s. 30 charges, but is added separately to avoid the benefit means-test + # cycle described on income_tax_before_winter_fuel_payment_charge. category = TAX adds = [ "income_tax_before_winter_fuel_payment_charge", diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py index dfd8b453d9..03f8122b8a 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_before_winter_fuel_payment_charge.py @@ -7,7 +7,9 @@ class income_tax_before_winter_fuel_payment_charge(Variable): label = "Income Tax before the winter fuel payment charge" documentation = ( "Income Tax liability before the winter fuel payment charge is added " - "at Step 7. The means tests deduct this rather than income_tax: the " + "at Step 7. This is the Step 5 tax less the capped Step 6 reductions, " + "plus the other Step 7 charges. The means tests deduct this rather " + "than income_tax: the " "charge is not tax on the income they assess but the recovery of a " "winter fuel payment, and it depends on receipt of Pension Credit " "and Universal Credit, so deducting it would make the model circular." @@ -20,9 +22,12 @@ class income_tax_before_winter_fuel_payment_charge(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.hmrc.income_tax - - additions = add(person, period, p.income_tax_additions) - subtractions = add(person, period, p.income_tax_subtractions) - - return max_(0, additions - subtractions) + # ITA 2007 s. 29(2) limits reductions to the Step 5 tax. Add the + # non-WFP Step 7 charges only after that deduction, so excess + # reductions cannot offset them. + tax_left_after_step_6 = max_( + 0, + person("income_tax_pre_charges", period) + - person("income_tax_reductions", period), + ) + return tax_left_after_step_6 + person("income_tax_charges", period) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_charges.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_charges.py new file mode 100644 index 0000000000..20f4425b28 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_charges.py @@ -0,0 +1,30 @@ +from policyengine_uk.model_api import * + + +class income_tax_charges(Variable): + value_type = float + entity = Person + label = "Income Tax charges" + documentation = ( + "The charges to Income Tax added at Step 7 of the Income Tax " + "calculation, after the tax reductions, such as the High Income Child " + "Benefit Charge and the pension annual allowance charge. Tax " + "reductions do not offset them. The winter fuel payment charge is also " + "a Step 7 charge (Finance Act 2026 Sch. 10 para. 4(3)), but income_tax " + "adds it separately after income_tax_before_winter_fuel_payment_charge " + "to avoid the cycle through benefit means tests." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax Act 2007 s. 23 (Step 7)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ), + dict( + title="Income Tax Act 2007 s. 30(1)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/30", + ), + ] + + adds = "gov.hmrc.income_tax.step_7_additional_tax" diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_pre_charges.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_pre_charges.py index d6014a5b2f..645b661751 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_pre_charges.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_pre_charges.py @@ -5,6 +5,12 @@ class income_tax_pre_charges(Variable): value_type = float entity = Person label = "Income Tax before any tax charges" + documentation = ( + "The tax calculated at Step 5 of the Income Tax calculation: the tax " + "at each rate on the parts of income left after allowances, added " + "together. Tax reductions are deducted from it at Step 6, and only so " + "far as it is enough to deduct them from." + ) definition_period = YEAR reference = dict( title="Income Tax Act 2007 s. 23", @@ -12,9 +18,4 @@ class income_tax_pre_charges(Variable): ) unit = GBP - adds = [ - "earned_income_tax", - "savings_income_tax", - "dividend_income_tax", - "property_income_tax", - ] + adds = "gov.hmrc.income_tax.step_5_tax_components" diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_reductions.py b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_reductions.py new file mode 100644 index 0000000000..31f8ed035c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_reductions.py @@ -0,0 +1,38 @@ +from policyengine_uk.model_api import * + + +class income_tax_reductions(Variable): + value_type = float + entity = Person + label = "Income Tax reductions" + documentation = ( + "The tax reductions deducted at Step 6 of the Income Tax calculation. " + "A reduction may be deducted only so far as there is tax calculated at " + "Step 5 to deduct it from, counting the reductions already deducted. " + "So the total is the reductions on the Step 6 list, each limited by " + "its own rules, up to the Step 5 tax, whatever the order of deduction. " + "The reductions never reduce the charges added at Step 7, such as the " + "High Income Child Benefit Charge. Section 29(4A) also limits the EIS, " + "SEIS, social investment, VCT and community investment reductions to " + "the Step 5 tax less the tax treated as deducted from Gift Aid " + "donations. That limit is not applied, as other_tax_credits holds " + "those reductions together with maintenance and foreign tax relief." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Income Tax Act 2007 s. 23 (Step 6)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ), + dict( + title="Income Tax Act 2007 s. 29", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + ] + + def formula(person, period, parameters): + income_tax = parameters(period).gov.hmrc.income_tax + reductions = max_(0, add(person, period, income_tax.step_6_tax_reductions)) + step_5_tax = max_(0, person("income_tax_pre_charges", period)) + return min_(reductions, step_5_tax) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py b/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py index dcf7260c39..d2676a0387 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/other_tax_credits.py @@ -6,8 +6,17 @@ class other_tax_credits(Variable): documentation = """Includes Venture Capital Trusts, Enterprise Investment Schemes, Seed Enterprise Investment Schemes, Community Investment Tax Relief, maintenance/alimony payments, Social Investment Tax Relief and foreign tax relief on income. Landlords' tax reduction for -residential finance costs is property_finance_cost_relief, not this.""" +residential finance costs is property_finance_cost_relief, not this. These are tax reductions +deducted at Step 6 of the Income Tax calculation, so with the other reductions they are limited +to the tax calculated at Step 5 and never offset the Step 7 charges (income_tax_reductions). +Because they are held together, the further limit on the EIS, SEIS, social investment, VCT and +community investment reductions in ITA 2007 s. 29(4A) to (4C), the Step 5 tax less the tax +treated as deducted from Gift Aid donations, is not applied.""" entity = Person definition_period = YEAR value_type = float unit = GBP + reference = dict( + title="Income Tax Act 2007 s. 29", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py index b23956b772..4be8225499 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/property_finance_cost_relief.py @@ -46,7 +46,7 @@ def formula(person, period, parameters): # High Income Child Benefit Charge, are not reduced. other_reductions = [ variable - for variable in income_tax.income_tax_subtractions + for variable in income_tax.step_6_tax_reductions if variable != "property_finance_cost_relief" ] tax_left = max_(