From b30214db74fa40f67fdf67aba24274050801cc38 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 05:26:31 -0400 Subject: [PATCH 1/2] Classify the Budget helpers in the fiscal partition List the tax helpers among the exclusions and give spending heads their own section for components. Add tests for the shared trading allowance in 2027, and for Step 6 with the Pension Tax Allowance and finance cost relief applying together. Fixes #2762 Co-Authored-By: Claude Opus 5.5 --- changelog.d/2762.fixed.md | 1 + policyengine_uk/data/fiscal_partition.yaml | 17 ++- .../allowances/trading_allowance.yaml | 3 +- .../test_aggregate_impact_partition.py | 47 ++++++++ ...rading_allowance_miscellaneous_income.yaml | 33 +++++ .../test_carers_allowance_earnings_taper.py | 2 +- ...est_income_tax_step_6_step_7_properties.py | 114 ++++++++++++++++++ 7 files changed, 212 insertions(+), 5 deletions(-) create mode 100644 changelog.d/2762.fixed.md create mode 100644 policyengine_uk/tests/policy/reforms/oct_2026_budget/trading_allowance_miscellaneous_income.yaml diff --git a/changelog.d/2762.fixed.md b/changelog.d/2762.fixed.md new file mode 100644 index 0000000000..bae4db86d7 --- /dev/null +++ b/changelog.d/2762.fixed.md @@ -0,0 +1 @@ +Classified the Budget 2026 helper variables in the fiscal partition. diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index b5f39a095c..a765b7ed66 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -51,8 +51,11 @@ # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), # baseline_measure, assessment (a tax amount worked out for a means test, -# a ceiling or a record, not a payment), change_measure (a wage change -# that already sits inside the incomes a head taxes). +# a tax base, election, ceiling or record, not a payment), change_measure +# (a wage change that already sits inside the incomes a head taxes). +# spending_exclusions[]: components and assessments within spending heads, +# with the same variable, kind, head and note fields as exclusions. These +# are not additional payments alongside the named spending head. # model_double_counts[]: known violations still in the model: a variable a # total lists although the head variable `contained_in` already adds it. # None since the #2159 fix gave corporate_sdlt its own head. @@ -723,6 +726,10 @@ exclusions: - {variable: other_tax_credits, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: property_finance_cost_relief, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} - {variable: pension_tax_allowance, kind: component, head: income_tax, note: "A tax reduction, subtracted inside income_tax."} + - {variable: miscellaneous_income_trading_allowance_deduction, kind: assessment, head: income_tax, note: "Trading allowance relief on a tax base, deducted inside taxable_miscellaneous_income; not a separate tax reduction or payment."} + - {variable: uses_trading_allowance, kind: assessment, head: income_tax, note: "The election between trading allowance relief and ordinary treatment, not a monetary amount."} + - {variable: miscellaneous_income_before_trading_allowance, kind: assessment, head: income_tax, note: "The gross miscellaneous income base used by income tax and means tests, not an additional receipt."} + - {variable: income_not_otherwise_charged, kind: assessment, head: income_tax, note: "The part of miscellaneous_income eligible for trading allowance relief, already within that income base."} - {variable: basic_rate_earned_income_tax, kind: assessment, head: income_tax, note: "Earned income tax by band; earned_income_tax does not read it."} - {variable: higher_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} - {variable: add_rate_earned_income_tax, kind: assessment, head: income_tax, note: "As above."} @@ -766,6 +773,7 @@ exclusions: - {variable: pool_betting_duty, kind: component, head: gambling_duty} - {variable: remote_gaming_duty, kind: component, head: gambling_duty} - {variable: machine_games_duty, kind: component, head: gambling_duty} + - {variable: machine_games_standard_rate_higher_stake_net_takings, kind: component, head: gambling_duty, note: "A subset of the standard-rate machine-games tax base, read inside machine_games_duty; not additional net takings or a separate receipt."} - {variable: gaming_duty, kind: component, head: gambling_duty} - {variable: bingo_duty, kind: component, head: gambling_duty} - {variable: lottery_duty, kind: component, head: gambling_duty} @@ -773,6 +781,11 @@ exclusions: # Council tax net of a benefit - {variable: council_tax_less_benefit, kind: composite, spans: [council_tax], note: "Council tax net of Council Tax Reduction (the OBR's basis). The totals count gross council tax and credit the reduction as spending, so adding this would count council tax twice."} +spending_exclusions: + - {variable: carers_allowance_weekly_earnings_above_limit, kind: assessment, head: carers_allowance, note: "Weekly earnings excess used for eligibility and the earnings taper, not an additional benefit payment."} + - {variable: ebr_alternative_fuel_payment, kind: component, head: energy_bills_rebate, note: "The annual Alternative Fuel Payment already included in energy_bills_rebate."} + - {variable: monthly_ebr_alternative_fuel_payment, kind: component, head: energy_bills_rebate, note: "Monthly payments summed into ebr_alternative_fuel_payment, already included in energy_bills_rebate."} + model_double_counts: [] overlap_guards: diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml index d92597f6e4..c3bacdf89c 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/trading_allowance.yaml @@ -14,8 +14,7 @@ values: href: https://www.legislation.gov.uk/ukpga/2017/32/schedule/3 # Budget 2026 raises the allowance to £3,000 from 6 April 2027, to be # legislated in Finance Bill 2026-27. The s. 783AD amount also relieves - # miscellaneous income (s. 783AB); the model applies it to trading income - # and to Chapter 8 miscellaneous income (income_not_otherwise_charged). + # miscellaneous income (s. 783AB); the model shares it between both sources. 2027-04-06: value: 3_000 reference: diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py index f8a8b7dbe9..d17c642c3c 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py @@ -85,6 +85,53 @@ def test_receipts_and_spending_are_separate(shipped): assert not set(head.totals) & {"gov_tax", "household_tax"} +def test_spending_exclusions_name_known_variables_and_spending_heads(raw, system): + spending_heads = {head["id"] for head in raw["spending_heads"]} + head_variables = { + head["variable"] for head in raw["tax_heads"] + raw["spending_heads"] + } + exclusions = raw["exclusions"] + raw["spending_exclusions"] + excluded_variables = [entry["variable"] for entry in exclusions] + assert len(excluded_variables) == len(set(excluded_variables)) + assert not head_variables & set(excluded_variables) + for entry in raw["spending_exclusions"]: + assert entry["variable"] in system.variables, entry + assert entry["head"] in spending_heads, entry + assert entry["kind"] in {"component", "assessment"}, entry + assert entry["note"], entry + + +def test_budget_and_energy_support_variables_have_fiscal_classifications(raw): + # These tax, earnings and payment helpers must have an explicit owner; + # adding a head for a component would count the same receipt twice. + variables = { + "carers_allowance_weekly_earnings_above_limit", + "pension_tax_allowance", + "household_energy_payment", + "miscellaneous_income_trading_allowance_deduction", + "uses_trading_allowance", + "miscellaneous_income_before_trading_allowance", + "income_not_otherwise_charged", + "income_tax_charges", + "income_tax_reductions", + "machine_games_standard_rate_higher_stake_net_takings", + "ebr_alternative_fuel_payment", + "monthly_ebr_alternative_fuel_payment", + } + classified = [ + entry["variable"] + for section in ( + "tax_heads", + "spending_heads", + "exclusions", + "spending_exclusions", + ) + for entry in raw[section] + ] + assert variables <= set(classified) + assert all(classified.count(variable) == 1 for variable in variables) + + def test_dropped_when_matches_the_council_tax_abolition_formulas(shipped): switch = "gov.contrib.abolish_council_tax" assert shipped.dropped("gov_tax", [switch]) == { diff --git a/policyengine_uk/tests/policy/reforms/oct_2026_budget/trading_allowance_miscellaneous_income.yaml b/policyengine_uk/tests/policy/reforms/oct_2026_budget/trading_allowance_miscellaneous_income.yaml new file mode 100644 index 0000000000..8d93193e67 --- /dev/null +++ b/policyengine_uk/tests/policy/reforms/oct_2026_budget/trading_allowance_miscellaneous_income.yaml @@ -0,0 +1,33 @@ +# #2735 shares the allowance between trade receipts and Chapter 8 income; +# the Budget 2026 increase must reach that same shared allowance in 2027. +- name: Before the Budget increase one allowance covers trade and miscellaneous income + period: 2026 + input: + age: 40 + self_employment_gross_receipts: 2_000 + self_employment_income: 1_500 + miscellaneous_income: 2_000 + income_not_otherwise_charged: 2_000 + output: + trading_allowance_deduction: 500 + miscellaneous_income_trading_allowance_deduction: 0 + taxable_self_employment_income: 1_000 + taxable_miscellaneous_income: 2_000 + miscellaneous_income_before_trading_allowance: 2_000 + uses_trading_allowance: true + +- name: The Budget increase gives miscellaneous income the part of 3000 trade receipts leave + period: 2027 + input: + age: 40 + self_employment_gross_receipts: 2_000 + self_employment_income: 1_500 + miscellaneous_income: 2_000 + income_not_otherwise_charged: 2_000 + output: + trading_allowance_deduction: 1_500 + miscellaneous_income_trading_allowance_deduction: 1_000 + taxable_self_employment_income: 0 + taxable_miscellaneous_income: 1_000 + miscellaneous_income_before_trading_allowance: 2_000 + uses_trading_allowance: true diff --git a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py index 20fc09c071..093d9b66bc 100644 --- a/policyengine_uk/tests/test_carers_allowance_earnings_taper.py +++ b/policyengine_uk/tests/test_carers_allowance_earnings_taper.py @@ -221,7 +221,7 @@ def final_allowance(weekly_earnings, weekly_state_pension, year, region="NORTH_W def test_2030_31_takes_overlapping_benefits_off_only_the_taper_weeks(): - # £250 a week is £46 over the £204 limit; £40 a week of State Pension. + # £250 a week exceeds the projected limit; £40 a week of State Pension. limit, rate = ca_parameters(START_YEAR) excess = 250 - limit expected = START_YEAR_SHARE * max(rate - excess - 40, 0) * 52 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 46edc4dd8c..6cc429a92b 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 @@ -58,6 +58,7 @@ 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_ +from policyengine_uk.utils.income_tax_reductions import earlier_tax_reductions pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") @@ -445,3 +446,116 @@ def test_the_step_lists_are_disjoint_and_hold_the_modelled_provisions(): every = step_5 + step_6 + step_7 assert len(every) == len(set(every)) assert all(variable in system.variables for variable in every) + + +@pytest.mark.parametrize("year", [2026, 2027, 2028, 2029, 2030]) +def test_step_6_puts_the_pension_tax_allowance_after_every_other_reduction(sim, year): + income_tax = sim.tax_benefit_system.parameters(year).gov.hmrc.income_tax + reductions = list(income_tax.step_6_tax_reductions) + assert reductions[-1] == "pension_tax_allowance" + for index, reduction in enumerate(reductions): + assert earlier_tax_reductions(income_tax, reduction) == reductions[:index] + + +PENSION_ALLOWANCE_YEARS = [2027, 2028, 2029] + + +@pytest.fixture(scope="module") +def pension_age_landlords(): + # Elect actual expenses on a valid property-income route so both reliefs + # can apply. These pensioners have income at or just above each year's + # Pension Tax Allowance threshold, with small or tax-exhausting finance + # costs, some other tax credits, and English or Scottish income tax. + cases = [ + (excess, finance_costs, credits, region) + for excess in (0, 50) + for finance_costs in (100, 1_000) + for credits in (0, 25) + for region in ("LONDON", "SCOTLAND") + ] + people = { + f"landlord_{i}": { + variable: {year: value for year in PENSION_ALLOWANCE_YEARS} + for variable, value in { + "age": 70, + "private_pension_income": 0, + "property_rental_income": 4_000, + "property_income": 2_000, + "property_finance_costs": finance_costs, + "other_tax_credits": credits, + "uses_property_allowance": False, + }.items() + } + for i, (_, finance_costs, credits, _) in enumerate(cases) + } + simulation = Simulation( + situation={ + "people": people, + "benunits": { + f"benunit_{i}": {"members": [f"landlord_{i}"]} + for i in range(len(cases)) + }, + "households": { + f"household_{i}": { + "members": [f"landlord_{i}"], + "region": {year: region for year in PENSION_ALLOWANCE_YEARS}, + } + for i, (_, _, _, region) in enumerate(cases) + }, + } + ) + for year in PENSION_ALLOWANCE_YEARS: + parameters = simulation.tax_benefit_system.parameters(year) + threshold = 52 * parameters.gov.dwp.state_pension.new_state_pension.amount + simulation.set_input( + "private_pension_income", + year, + [threshold + excess - 2_000 for excess, _, _, _ in cases], + ) + return simulation + + +@pytest.mark.parametrize("year", PENSION_ALLOWANCE_YEARS) +def test_step_6_stays_within_step_5_with_the_pension_tax_allowance( + pension_age_landlords, year +): + simulation = pension_age_landlords + simulation.reset_calculations() + income_tax = simulation.tax_benefit_system.parameters(year).gov.hmrc.income_tax + assert income_tax.reliefs.pension_tax_allowance.in_effect + reductions = { + variable: simulation.calculate(variable, year).astype(float) + for variable in income_tax.step_6_tax_reductions + } + pension_allowance = reductions["pension_tax_allowance"] + finance_relief = reductions["property_finance_cost_relief"] + # Both reductions are exercised together, including a binding tax cap. + assert ((pension_allowance > 0) & (finance_relief > 0)).any() + step_5 = simulation.calculate("income_tax_pre_charges", year).astype(float) + step_6 = simulation.calculate("income_tax_reductions", year).astype(float) + listed_reductions = sum(reductions.values()) + tol = tolerance(step_5) + assert (listed_reductions <= step_5 + tol).all() + assert (step_6 <= step_5 + tol).all() + assert (np.abs(step_6 - listed_reductions) <= tol).all() + assert (np.abs(step_6 - step_5) <= tol).any() + + +@pytest.mark.parametrize("year", PENSION_ALLOWANCE_YEARS) +def test_pension_and_finance_cost_reductions_calculate_in_either_order( + pension_age_landlords, year +): + simulation = pension_age_landlords + reductions = ("pension_tax_allowance", "property_finance_cost_relief") + results = [] + for order in (reductions, reductions[::-1]): + simulation.reset_calculations() + results.append( + {name: simulation.calculate(name, year).copy() for name in order} + ) + for reduction in reductions: + np.testing.assert_array_equal(results[0][reduction], results[1][reduction]) + assert ( + (results[0]["pension_tax_allowance"] > 0) + & (results[0]["property_finance_cost_relief"] > 0) + ).any() From 6ca062e68a921902006a5bba23cbb8538029a093 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 05:53:09 -0400 Subject: [PATCH 2/2] Load spending exclusions in the partition reader Read the new section as exclusions are read, and refuse an entry whose head isn't a spending head. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/fiscal_partition.yaml | 7 ++-- .../test_aggregate_impact_partition.py | 34 ++++++++++++++--- .../utils/aggregate_impact/partition.py | 38 ++++++++++++------- 3 files changed, 58 insertions(+), 21 deletions(-) diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index a765b7ed66..e8507cb9e4 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -50,9 +50,10 @@ # kind: component (summed or read inside `head`), measure_within (an # analytical decomposition already inside `head` through its inputs), # composite (spans several heads), duplicate (same value as `head`), -# baseline_measure, assessment (a tax amount worked out for a means test, -# a tax base, election, ceiling or record, not a payment), change_measure -# (a wage change that already sits inside the incomes a head taxes). +# baseline_measure, assessment (an amount worked out for a means test, a +# benefit's eligibility or taper, a tax base, election, ceiling or record, +# not a payment), change_measure (a wage change that already sits inside +# the incomes a head taxes). # spending_exclusions[]: components and assessments within spending heads, # with the same variable, kind, head and note fields as exclusions. These # are not additional payments alongside the named spending head. diff --git a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py index d17c642c3c..a52276a8e0 100644 --- a/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py +++ b/policyengine_uk/tests/aggregate_impact/test_aggregate_impact_partition.py @@ -101,6 +101,29 @@ def test_spending_exclusions_name_known_variables_and_spending_heads(raw, system assert entry["note"], entry +def test_the_reader_loads_the_spending_exclusions(raw, shipped): + assert [entry.variable for entry in shipped.spending_exclusions] == [ + entry["variable"] for entry in raw["spending_exclusions"] + ] + + +@pytest.mark.parametrize("head", ["no_such_head", "income_tax"]) +def test_a_spending_exclusion_must_name_a_spending_head(raw, head): + partition = copy.deepcopy(raw) + partition["spending_exclusions"][0]["head"] = head + with pytest.raises(PartitionError, match="needs a spending head"): + parse_partition(partition) + + +def test_a_spending_exclusion_cannot_name_a_head_variable(raw): + partition = copy.deepcopy(raw) + partition["spending_exclusions"][0]["variable"] = raw["spending_heads"][0][ + "variable" + ] + with pytest.raises(PartitionError, match="also a head variable"): + parse_partition(partition) + + def test_budget_and_energy_support_variables_have_fiscal_classifications(raw): # These tax, earnings and payment helpers must have an explicit owner; # adding a head for a component would count the same receipt twice. @@ -161,11 +184,12 @@ def _remove_head(raw, head_id): for head in out[key]: if head.get("reads"): head["reads"] = [r for r in head["reads"] if r.get("head") != head_id] - out["exclusions"] = [ - e - for e in out.get("exclusions") or [] - if e.get("head") != head_id and head_id not in (e.get("spans") or []) - ] + for key in ("exclusions", "spending_exclusions"): + out[key] = [ + e + for e in out.get(key) or [] + if e.get("head") != head_id and head_id not in (e.get("spans") or []) + ] out["model_double_counts"] = [ d for d in out.get("model_double_counts") or [] diff --git a/policyengine_uk/utils/aggregate_impact/partition.py b/policyengine_uk/utils/aggregate_impact/partition.py index 91892300f5..db17fba9d0 100644 --- a/policyengine_uk/utils/aggregate_impact/partition.py +++ b/policyengine_uk/utils/aggregate_impact/partition.py @@ -110,6 +110,7 @@ class Partition: double_counts: tuple exclusions: tuple overlap_guards: tuple = () + spending_exclusions: tuple = () source: str = "" sha256: str = "" raw: Mapping = field(default_factory=dict, compare=False, repr=False) @@ -277,6 +278,20 @@ def _parse_total(name: str, raw: Mapping) -> TotalSpec: ) +def _parse_exclusions(raw: Mapping, section: str) -> tuple: + return tuple( + Exclusion( + variable=str(e.get("variable")), + kind=str(e.get("kind") or ""), + head=str(e.get("head") or ""), + spans=_as_tuple(e.get("spans"), "spans", section), + note=str(e.get("note") or e.get("reason") or ""), + ) + for e in (raw.get(section) or []) + if isinstance(e, Mapping) + ) + + def _parse_head(raw: Mapping, kind: str, index: int) -> Head: where = f"{kind}_heads[{index}]" if not isinstance(raw, Mapping): @@ -354,25 +369,15 @@ def parse_partition( issue=str(dc.get("issue") or ""), ) ) - exclusions = tuple( - Exclusion( - variable=str(e.get("variable")), - kind=str(e.get("kind") or ""), - head=str(e.get("head") or ""), - spans=_as_tuple(e.get("spans"), "spans", "exclusions"), - note=str(e.get("note") or e.get("reason") or ""), - ) - for e in (raw.get("exclusions") or []) - if isinstance(e, Mapping) - ) partition = Partition( schema_version=version, totals=totals, tax_heads=tax_heads, spending_heads=spending_heads, double_counts=tuple(double_counts), - exclusions=exclusions, + exclusions=_parse_exclusions(raw, "exclusions"), overlap_guards=tuple(raw.get("overlap_guards") or ()), + spending_exclusions=_parse_exclusions(raw, "spending_exclusions"), source=source, sha256=sha256, raw=raw, @@ -470,7 +475,7 @@ def structural_problems(partition: Partition) -> list: problems.append( f"overlap guard {guard.get('id')!r} names unknown head {name!r}" ) - for exclusion in partition.exclusions: + for exclusion in partition.exclusions + partition.spending_exclusions: if exclusion.variable in head_variables: problems.append( f"excluded variable {exclusion.variable!r} is also a head variable" @@ -482,6 +487,13 @@ def structural_problems(partition: Partition) -> list: problems.append( f"exclusion {exclusion.variable!r} refers to unknown head {referenced!r}" ) + spending_head_ids = {h.id for h in partition.spending_heads} + for exclusion in partition.spending_exclusions: + if exclusion.head not in spending_head_ids: + problems.append( + f"spending exclusion {exclusion.variable!r} needs a spending head, " + f"not {exclusion.head!r}" + ) return problems