From f609da338a9398b4eb34d1d272deffa164ea9e28 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 17:34:30 -0400 Subject: [PATCH 1/5] Limit Income Tax reductions to the Step 5 tax ITA 2007 s. 23 deducts tax reductions at Step 6 from the Step 5 tax and adds the s. 30 charges at Step 7. s. 29(2) allows a reduction only so far as there is Step 5 tax to deduct it from. income_tax netted the reductions against the Step 5 tax and the Step 7 charges together, so other_tax_credits above the Step 5 tax offset the High Income Child Benefit Charge and the annual allowance charge, and capped_mcad was capped at the Step 5 tax plus the HICBC. Split income_tax_additions and income_tax_subtractions into explicit Step 5, Step 6 and Step 7 lists, add income_tax_reductions (Step 6, limited to the Step 5 tax) and income_tax_charges (Step 7), and cap capped_mcad at the Step 5 tax left after other_tax_credits (s. 27(5)). Fixes #2544 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2544.fixed.md | 1 + changelog.d/2544.removed.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 2 + .../hmrc/income_tax/income_tax_additions.yaml | 15 ------- .../income_tax/income_tax_subtractions.yaml | 14 ------- .../income_tax/step_5_tax_components.yaml | 14 +++++++ .../income_tax/step_6_tax_reductions.yaml | 17 ++++++++ .../income_tax/step_7_additional_tax.yaml | 14 +++++++ policyengine_uk/utils/uc_income_tax.py | 2 +- .../hmrc/income_tax/allowances/capped_mcad.py | 30 ++++++++++++-- .../gov/hmrc/income_tax/income_tax.py | 39 +++++++++++++------ .../gov/hmrc/income_tax/income_tax_charges.py | 27 +++++++++++++ .../hmrc/income_tax/income_tax_pre_charges.py | 13 ++++--- .../hmrc/income_tax/income_tax_reductions.py | 38 ++++++++++++++++++ .../gov/hmrc/income_tax/other_tax_credits.py | 11 +++++- .../reliefs/property_finance_cost_relief.py | 2 +- 16 files changed, 187 insertions(+), 53 deletions(-) create mode 100644 changelog.d/2544.fixed.md create mode 100644 changelog.d/2544.removed.md delete mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_additions.yaml delete mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/income_tax_subtractions.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/step_5_tax_components.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/step_6_tax_reductions.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/income_tax/step_7_additional_tax.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/income_tax_charges.py create mode 100644 policyengine_uk/variables/gov/hmrc/income_tax/income_tax_reductions.py 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 cb5d415d0d..b791a62fcb 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -470,6 +470,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: ni_employee, kind: measure_within, head: nics_individual, note: "Class 1 employee; national_insurance adds the classes directly."} 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..46293f2962 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/step_7_additional_tax.yaml @@ -0,0 +1,14 @@ +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 list holds the Step 7 charges the model calculates; 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 +values: + 0000-01-01: + - CB_HITC + - personal_pension_contributions_tax 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 8a1596bc94..513f5130b5 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax.py @@ -5,19 +5,36 @@ class income_tax(Variable): value_type = float entity = Person label = "Income Tax" - documentation = "Total Income Tax liability" + documentation = ( + "Total Income Tax liability: the tax calculated at Step 5, less the " + "tax reductions deducted at Step 6, plus the charges added at Step 7." + ) definition_period = YEAR unit = GBP - reference = dict( - title="Income Tax Act 2007 s. 23", - href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", - ) + reference = [ + dict( + title="Income Tax Act 2007 s. 23", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/23", + ), + dict( + title="Income Tax Act 2007 s. 29(2)", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/29", + ), + dict( + title="Income Tax Act 2007 s. 30", + href="https://www.legislation.gov.uk/ukpga/2007/3/section/30", + ), + ] category = TAX 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) + # Step 6 deducts the tax reductions from the Step 5 tax, and only so far + # as there is Step 5 tax to deduct them from (ITA 2007 s. 29(2)). Step 7 + # then adds the s. 30 charges to the tax left, so a reduction larger + # than the Step 5 tax never offsets a charge. + 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..6ef18cdaea --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/income_tax/income_tax_charges.py @@ -0,0 +1,27 @@ +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." + ) + 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_( From 3de3eb1004b610d0d9ce80dc72a14baefa0087e9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 17:53:35 -0400 Subject: [PATCH 2/5] Test that Step 6 reductions never offset the Step 7 charges YAML cases for an HICBC payer whose other_tax_credits exceed their Step 5 tax, a married couple's allowance claimant who pays HICBC after VCT relief, the annual allowance charge, and reductions exactly at the Step 5 tax. Hypothesis properties: the Step 6 deduction is within [0, Step 5]; income tax is Step 5 - Step 6 + Step 7, at least the Step 7 charges and at most Step 5 + Step 7; each reduction is limited to the tax left before it; more other_tax_credits never raise income tax; and a differential against the pre-fix formula, which the fix raises by exactly the charges it offset. Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/income_tax/step_6_step_7.yaml | 129 +++++++++ ...est_income_tax_step_6_step_7_properties.py | 266 ++++++++++++++++++ 2 files changed, 395 insertions(+) create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/step_6_step_7.yaml create mode 100644 policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py 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..70ec81ecc4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/income_tax/step_6_step_7.yaml @@ -0,0 +1,129 @@ +# 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 + spouse: + age: 88 + 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 + +# 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 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..79960e7079 --- /dev/null +++ b/policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py @@ -0,0 +1,266 @@ +"""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 and the annual allowance charge, to the tax left. + +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 (the married + couple's allowance, other_tax_credits and the finance-cost reduction), + limited to the Step 5 tax; income tax is the Step 5 tax less the Step 6 + deduction plus the Step 7 charges. +3. Income tax >= the Step 7 charges, so reductions never offset a charge. +4. Income tax <= the Step 5 tax plus the Step 7 charges. +5. The married couple's allowance reduction is at most the Step 5 tax left + after other_tax_credits (s. 27(5) deducts those reductions before it), and + the finance-cost reduction at most the tax left after both. +6. Monotonic: more other_tax_credits never raises income tax. +7. Differential: against the formula on main before this fix (76cfc9e79), + income tax is never lower, and rises by exactly the part of the Step 7 + charges the old netting let the excess reductions offset. + +The cases run in one reusable simulation of single-person households: each +case is simulated twice, as drawn and with more other_tax_credits. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +YEAR = 2026 +CASES = 24 +PEOPLE = 2 * CASES # As drawn, then with more other_tax_credits. +# Every third case is a Scottish taxpayer. +SCOTTISH = [i % 3 == 2 for i in range(CASES)] + + +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, 100_000, 125_140]), # pay + amount(30_000), # savings interest + amount(60_000), # dividends + amount(40_000), # property profits + amount(25_000), # property finance costs + amount(90_000, [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([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", +] + + +@pytest.fixture(scope="module") +def sim(): + people = { + f"person_{i}": {"age": {YEAR: 45}, **{v: {YEAR: 0} for v in INPUTS}} + 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) + }, + } + ) + + +def run(sim, drawn): + columns = [np.array(column, dtype=float) for column in zip(*drawn)] + *inputs, child_benefit, extra_credits = columns + for variable, values in zip(INPUTS, inputs): + if variable == "other_tax_credits": + values = np.concatenate([values, values + extra_credits]) + else: + values = np.concatenate([values, values]) + sim.set_input(variable, YEAR, values) + sim.set_input("child_benefit", YEAR, np.concatenate([child_benefit] * 2)) + sim.reset_calculations() + + def calc(variable): + return sim.calculate(variable, YEAR).astype(float) + + 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"), + 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_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, with_more = r["income_tax"][:CASES], r["income_tax"][CASES:] + assert (with_more <= drawn_tax + tolerance(drawn_tax)).all() + # The other inputs are the same, so the Step 5 tax and charges are too. + assert np.allclose(r["step_5"][:CASES], r["step_5"][CASES:]) + assert np.allclose(r["step_7"][:CASES], r["step_7"][CASES:]) + + +def income_tax_before_the_fix(r): + """income_tax on 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, and the finance-cost reduction at the Step 5 tax left + after the other reductions.""" + capped_mcad = np.minimum(r["mca_deduction"], r["step_5"] + r["hicbc"]) + finance_cost_reduction = np.minimum( + r["finance_cost_reduction_uncapped"], + np.maximum(0, r["step_5"] - capped_mcad - r["other_tax_credits"]), + ) + additions = r["step_5"] + r["hicbc"] + r["annual_allowance_charge"] + subtractions = capped_mcad + r["other_tax_credits"] + finance_cost_reduction + return np.maximum(0, additions - subtractions), subtractions + + +@SETTINGS +@given(cases) +def test_the_fix_restores_exactly_the_charges_the_old_netting_offset(sim, drawn): + r = run(sim, drawn) + old, old_reductions = income_tax_before_the_fix(r) + charges = r["hicbc"] + r["annual_allowance_charge"] + excess = np.maximum(0, old_reductions - r["step_5"]) + tol = tolerance(r["step_5"], charges, r["other_tax_credits"]) + assert (r["income_tax"] >= old - tol).all() + assert (np.abs((r["income_tax"] - old) - np.minimum(charges, excess)) <= tol).all() + # The finance-cost reduction was already limited correctly. + finance_cost_old = np.minimum( + r["finance_cost_reduction_uncapped"], + np.maximum( + 0, + r["step_5"] + - np.minimum(r["mca_deduction"], r["step_5"] + r["hicbc"]) + - r["other_tax_credits"], + ), + ) + assert (np.abs(r["finance_cost_reduction"] - finance_cost_old) <= tol).all() + + +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) From 27eb87d8183d4539beef3920c09b881b9642e65a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 20 Oct 2026 17:57:31 -0400 Subject: [PATCH 3/5] Correct the capped MCA case that pinned the Step 5 + HICBC cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The case expected a £700 cap on an £800 reduction against £500 of Step 5 tax and a £200 HICBC. Under ITA 2007 s. 29(2) the reduction is limited to the £500 of Step 5 tax, and the HICBC is added at Step 7, so income tax is £200. Co-Authored-By: Claude Opus 5.5 --- .../hmrc/income_tax/allowances/is_allowance_eligible.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 From f903b28a75b9ef223c45547be6b3e4b40cd5c3c3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 21 Oct 2026 05:02:47 -0400 Subject: [PATCH 4/5] Make the Step 6/7 property tests exercise the drawn inputs (review r1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Reset the reusable simulation before setting inputs: reset_calculations() cleared employment income and Child Benefit, which the simulation was not built with, so the HICBC cases never ran. The tests now assert that every drawn input reaches the calculation, and draw pension-age people too. - Compare against a simulation of the pre-fix rules (a structural reform of income_tax and capped_mcad), not a reconstruction from the fixed run's values. The property allowance election weighs income tax on each route, so it can differ under the old rules. Income tax is never lower on either route, so never lower; where both take the same route, Step 5, the charges and the finance-cost reduction are unchanged and the restored charge is exact. - Drop the assumption that more other_tax_credits leave Step 5 unchanged, which an election change breaks. - Add the YAML case of a landlord whose election let the old netting offset £168 of HICBC, and qualify the s. 27(5) ordering in the docstring. Co-Authored-By: Claude Opus 5.5 --- .../gov/hmrc/income_tax/step_6_step_7.yaml | 30 +++ ...est_income_tax_step_6_step_7_properties.py | 196 ++++++++++++------ 2 files changed, 161 insertions(+), 65 deletions(-) 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 index 70ec81ecc4..095fe9fa82 100644 --- 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 @@ -111,6 +111,36 @@ 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)). 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 index 79960e7079..db624f19b7 100644 --- 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 @@ -8,38 +8,52 @@ 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 (the married - couple's allowance, other_tax_credits and the finance-cost reduction), - limited to the Step 5 tax; income tax is the Step 5 tax less the Step 6 +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 Step 7 charges. 3. Income tax >= the Step 7 charges, so reductions never offset a charge. 4. Income tax <= the Step 5 tax plus the Step 7 charges. 5. The married couple's allowance reduction is at most the Step 5 tax left - after other_tax_credits (s. 27(5) deducts those reductions before it), and - the finance-cost reduction at most the tax left after both. -6. Monotonic: more other_tax_credits never raises income tax. -7. Differential: against the formula on main before this fix (76cfc9e79), - income tax is never lower, and rises by exactly the part of the Step 7 - charges the old netting let the excess reductions offset. - -The cases run in one reusable simulation of single-person households: each -case is simulated twice, as drawn and with more other_tax_credits. + 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: more other_tax_credits never raises income tax. The Step 5 tax + and the charges can change with them, because the property allowance + election weighs the income tax on each route. +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): 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 charges and the finance-cost + reduction are unchanged, and income tax rises by exactly the part of the + Step 7 charges the old netting let the excess reductions offset. + +The cases run in reusable simulations of single-person households, a third of +them Scottish taxpayers and a third over State Pension age: each case is +simulated twice, as drawn and with more other_tax_credits. """ 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. -# Every third case is a Scottish taxpayer. SCOTTISH = [i % 3 == 2 for i in range(CASES)] +# The liability built from Steps 5 to 7. +INCOME_TAX = "income_tax" def amount(maximum, points=()): @@ -51,14 +65,15 @@ def amount(maximum, points=()): case = st.tuples( - amount(250_000, [12_570, 50_270, 60_000, 80_000, 100_000, 125_140]), # pay + 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), # property profits - amount(25_000), # property finance costs - amount(90_000, [27_432]), # other_tax_credits + 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 ) @@ -73,13 +88,57 @@ def amount(maximum, points=()): "other_tax_credits", "married_couples_allowance", "employer_pension_contributions", + "age", ] -@pytest.fixture(scope="module") -def sim(): +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}": {"age": {YEAR: 45}, **{v: {YEAR: 0} for v in INPUTS}} + f"person_{i}": { + **{v: {YEAR: 0} for v in INPUTS}, + "age": {YEAR: 45}, + "property_rental_income": {YEAR: 0}, + } for i in range(PEOPLE) } return Simulation( @@ -95,25 +154,43 @@ def sim(): } 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): + # Reset first: it clears inputs the simulation was not built with, such as + # Child Benefit, which would otherwise be lost. + sim.reset_calculations() 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): - if variable == "other_tax_credits": - values = np.concatenate([values, values + extra_credits]) - else: - values = np.concatenate([values, values]) - sim.set_input(variable, YEAR, values) - sim.set_input("child_benefit", YEAR, np.concatenate([child_benefit] * 2)) - sim.reset_calculations() + 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( @@ -130,7 +207,8 @@ def calc(variable): finance_cost_reduction=calc("property_finance_cost_relief"), finance_cost_reduction_uncapped=property_basic_rate * calc("property_finance_costs_relieved"), - income_tax=calc("income_tax"), + uses_property_allowance=sim.calculate("uses_property_allowance", YEAR), + income_tax=calc(INCOME_TAX), ) @@ -198,47 +276,35 @@ def test_more_other_tax_credits_never_raise_income_tax(sim, drawn): r = run(sim, drawn) drawn_tax, with_more = r["income_tax"][:CASES], r["income_tax"][CASES:] assert (with_more <= drawn_tax + tolerance(drawn_tax)).all() - # The other inputs are the same, so the Step 5 tax and charges are too. - assert np.allclose(r["step_5"][:CASES], r["step_5"][CASES:]) - assert np.allclose(r["step_7"][:CASES], r["step_7"][CASES:]) - - -def income_tax_before_the_fix(r): - """income_tax on 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, and the finance-cost reduction at the Step 5 tax left - after the other reductions.""" - capped_mcad = np.minimum(r["mca_deduction"], r["step_5"] + r["hicbc"]) - finance_cost_reduction = np.minimum( - r["finance_cost_reduction_uncapped"], - np.maximum(0, r["step_5"] - capped_mcad - r["other_tax_credits"]), - ) - additions = r["step_5"] + r["hicbc"] + r["annual_allowance_charge"] - subtractions = capped_mcad + r["other_tax_credits"] + finance_cost_reduction - return np.maximum(0, additions - subtractions), subtractions @SETTINGS @given(cases) -def test_the_fix_restores_exactly_the_charges_the_old_netting_offset(sim, drawn): - r = run(sim, drawn) - old, old_reductions = income_tax_before_the_fix(r) - charges = r["hicbc"] + r["annual_allowance_charge"] - excess = np.maximum(0, old_reductions - r["step_5"]) - tol = tolerance(r["step_5"], charges, r["other_tax_credits"]) - assert (r["income_tax"] >= old - tol).all() - assert (np.abs((r["income_tax"] - old) - np.minimum(charges, excess)) <= tol).all() - # The finance-cost reduction was already limited correctly. - finance_cost_old = np.minimum( - r["finance_cost_reduction_uncapped"], - np.maximum( - 0, - r["step_5"] - - np.minimum(r["mca_deduction"], r["step_5"] + r["hicbc"]) - - r["other_tax_credits"], - ), +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(r["finance_cost_reduction"] - finance_cost_old) <= tol).all() + assert (np.abs(old["income_tax"] - old_expected) <= tol).all() + # Never lower, whichever route each election takes. + assert (new["income_tax"] >= old["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_step_lists_are_disjoint_and_hold_the_modelled_provisions(): From b5fd880c962b9217713022f808cab6a08d15f3f9 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 22:20:19 -0400 Subject: [PATCH 5/5] Check Step 6/7 monotonicity on each property allowance route (review r2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The election weighs the winter fuel payment charge, so more credits can move a person to the route with more tax before the charge. Check that tax on each fixed route instead, and add a hand-computed case where £500 more credits raise it from £0 to £86 while total tax falls from £200 to £86. The simulation reset now keeps inputs set through set_input, so run() deletes any route a previous run fixed before setting its own. Co-Authored-By: Claude Opus 5.5 --- ...est_income_tax_step_6_step_7_properties.py | 83 +++++++++++++++++-- 1 file changed, 77 insertions(+), 6 deletions(-) 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 index 3894a60116..46edc4dd8c 100644 --- 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 @@ -24,9 +24,12 @@ 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: more other_tax_credits never raises income tax. The Step 5 tax - and the charges can change with them, because the property allowance - election weighs the income tax on each route. +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 @@ -187,10 +190,17 @@ def pre_fix_sim(): return build(PRE_FIX) -def run(sim, drawn): - # Reset first: it clears inputs the simulation was not built with, such as - # Child Benefit, which would otherwise be lost. +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 = {} @@ -310,6 +320,67 @@ def test_more_other_tax_credits_never_raise_income_tax(sim, drawn): 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):