Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/2762.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Classified the Budget 2026 helper variables in the fiscal partition.
20 changes: 17 additions & 3 deletions policyengine_uk/data/fiscal_partition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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."}
Expand Down Expand Up @@ -766,13 +774,19 @@ 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}
- {variable: car_vehicle_excise_duty, kind: component, head: vehicle_excise_duty}
# 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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]) == {
Expand Down Expand Up @@ -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 []
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
114 changes: 114 additions & 0 deletions policyengine_uk/tests/test_income_tax_step_6_step_7_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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()
Loading
Loading