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..e8507cb9e4 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -50,9 +50,13 @@ # 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 ceiling or a 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. # 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 +727,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 +774,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 +782,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..a52276a8e0 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,76 @@ 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_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. + 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]) == { @@ -114,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/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() 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