Skip to content
Merged
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/2607.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Made pre-2027 rUK property income follow reforms to the full main income-tax scale, while preserving separate property rates from 2027-28 and Scottish treatment.
181 changes: 181 additions & 0 deletions policyengine_uk/tests/test_property_income_rate_reforms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
"""Property-rate routing before and from the model's annual 2027 period.

Finance Act 2026 ss. 6(8) and 7 commence the separate property rates in
2027-28. Earlier rUK property income follows the complete main-rate scale.
The amounts below are independent marginal-band arithmetic, not snapshots.
https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted
https://www.legislation.gov.uk/ukpga/2026/11/section/7/enacted
"""

import pytest

from policyengine_uk import Simulation

pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system")

UK_RATE = "gov.hmrc.income_tax.rates.uk[{}].rate"
PROPERTY_BASIC = "gov.hmrc.income_tax.rates.property.basic"
RUK_COUNTRIES = ("ENGLAND", "WALES", "NORTHERN_IRELAND")


def component_simulation(
year, earned, property_income, gift=0, country="ENGLAND", rates=None
):
"""Control taxable amounts after ordinary allowances to isolate the producer."""
return Simulation(
situation={
"people": {
"person": {
"age": {year: 40},
"non_savings_income_after_allowances": {
year: earned + property_income
},
"taxable_property_income_after_allowances": {year: property_income},
"gift_aid_grossed_up": {year: gift},
}
},
"benunits": {"benunit": {"members": ["person"]}},
"households": {
"household": {"members": ["person"], "country": {year: country}}
},
},
reform={path: {str(year): value} for path, value in (rates or {}).items()},
)


@pytest.mark.parametrize("year", [2025, 2026])
@pytest.mark.parametrize("country", RUK_COUNTRIES)
@pytest.mark.parametrize(
"earned, bracket, rate, expected",
[(10_000, 0, 0.21, 1_050), (40_000, 1, 0.41, 2_050), (130_000, 2, 0.46, 2_300)],
ids=["basic", "higher", "additional"],
)
def test_pre_2027_property_income_uses_main_rate_reform(
year, country, earned, bracket, rate, expected
):
sim = component_simulation(
year, earned, 5_000, country=country, rates={UK_RATE.format(bracket): rate}
)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
expected, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026])
@pytest.mark.parametrize(
"earned, gift, expected",
[(36_700, 0, 1_850), (36_700, 2_000, 1_450), (124_140, 2_000, 2_150)],
ids=["basic-higher", "gift-basic-higher", "gift-higher-additional"],
)
def test_main_rate_band_crossings_respect_gift_aid(year, earned, gift, expected):
rates = {UK_RATE.format(i): value for i, value in enumerate((0.21, 0.41, 0.46))}
sim = component_simulation(year, earned, 5_000, gift, rates=rates)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
expected, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026])
@pytest.mark.parametrize("gift", [0, 2_000])
def test_pre_2027_property_income_uses_optional_fourth_main_bracket(year, gift):
sim = component_simulation(
year, 9_999_000, 5_000, gift, rates={UK_RATE.format(3): 0.60}
)
# Gift Aid extends statutory basic/higher limits, not this optional bracket.
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
1_000 * 0.45 + 4_000 * 0.60, abs=0.01
)


@pytest.mark.parametrize("year", [2027, 2028])
@pytest.mark.parametrize("country", RUK_COUNTRIES)
def test_from_2027_main_rate_reform_leaves_separate_property_rate_unchanged(
year, country
):
sim = component_simulation(
year, 10_000, 5_000, country=country, rates={UK_RATE.format(0): 0.21}
)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
1_100, abs=0.01
)


@pytest.mark.parametrize("year", [2027, 2028])
def test_from_2027_separate_property_rate_reform_changes_property_tax(year):
sim = component_simulation(year, 10_000, 5_000, rates={PROPERTY_BASIC: 0.23})
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
1_150, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026])
def test_pre_2027_separate_property_rate_reform_does_not_apply(year):
sim = component_simulation(year, 10_000, 5_000, rates={PROPERTY_BASIC: 0.99})
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
1_000, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026, 2027, 2028])
def test_scottish_property_tax_keeps_scottish_rates_under_uk_reform(year):
rates = {UK_RATE.format(i): value for i, value in enumerate((0.21, 0.41, 0.46))}
sim = component_simulation(year, 40_000, 5_000, country="SCOTLAND", rates=rates)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
5_000 * 0.42, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026])
def test_scottish_gift_aid_band_crossing_is_unchanged(year):
sim = component_simulation(
year, 30_000, 5_000, 2_000, "SCOTLAND", {UK_RATE.format(1): 0.41}
)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
3_092 * 0.21 + 1_908 * 0.42, abs=0.01
)


@pytest.mark.parametrize("year", [2025, 2026, 2027, 2028])
def test_zero_property_income_has_zero_property_tax_under_reform(year):
sim = component_simulation(year, 40_000, 0, rates={UK_RATE.format(1): 0.41})
assert sim.calculate("property_income_tax", year)[0] == 0


@pytest.mark.parametrize(
"year, expected", [(2025, 1_000), (2026, 1_000), (2027, 1_100), (2028, 1_100)]
)
def test_baseline_property_rates_retain_annual_2027_boundary(year, expected):
sim = component_simulation(year, 10_000, 5_000)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
expected, abs=0.01
)


@pytest.mark.parametrize("country", RUK_COUNTRIES)
def test_main_rate_reform_reaches_property_and_final_tax_from_natural_income(country):
year = 2026
sim = Simulation(
situation={
"people": {
"person": {
"age": {year: 40},
"employment_income": {year: 22_570},
"property_income": {year: 5_000},
"property_allowance_deduction": {year: 0},
}
},
"benunits": {"benunit": {"members": ["person"]}},
"households": {
"household": {"members": ["person"], "country": {year: country}}
},
},
reform={UK_RATE.format(0): {str(year): 0.21}},
)
assert sim.calculate("personal_allowance", year)[0] == 12_570
assert sim.calculate("non_savings_income_after_allowances", year)[0] == 15_000
assert sim.calculate("taxable_property_income_after_allowances", year)[0] == 5_000
assert sim.calculate("earned_income_tax", year)[0] == pytest.approx(2_100, abs=0.01)
assert sim.calculate("property_income_tax", year)[0] == pytest.approx(
1_050, abs=0.01
)
assert sim.calculate("income_tax", year)[0] == pytest.approx(3_150, abs=0.01)
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@


class property_income_tax(Variable):
"""Tax on property income, following main UK rates before 2027-28 and
separate property rates thereafter for rUK taxpayers; Scottish
taxpayers retain the model's Scottish rate treatment.
"""

value_type = float
entity = Person
label = "Income tax on property income"
Expand All @@ -19,10 +24,51 @@ class property_income_tax(Variable):
title="OBR Economic and Fiscal Outlook November 2025",
href="https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/",
),
dict(
title="Finance Act 2026, s. 6 (commencement in 2027-28)",
href="https://www.legislation.gov.uk/ukpga/2026/11/section/6/enacted",
),
dict(
title="Finance Act 2026, s. 7 (property income rates)",
href="https://www.legislation.gov.uk/ukpga/2026/11/section/7/enacted",
),
]
unit = GBP

def formula(person, period, parameters):
"""Before 2027-28, property income uses the applicable main scale."""
rates = parameters(period).gov.hmrc.income_tax.rates
property_after_allowances = person(
"taxable_property_income_after_allowances", period
)
other_earned_taxable = max_(
0,
person("non_savings_income_after_allowances", period)
- property_after_allowances,
)
gross_gift = person("gift_aid_grossed_up", period)
ruk_tax = calc_income_tax_with_gift_aid(
rates.uk,
other_earned_taxable + property_after_allowances,
gross_gift,
) - calc_income_tax_with_gift_aid(rates.uk, other_earned_taxable, gross_gift)
first_extended = first_extended_scottish_threshold(rates.scotland.rates)
scottish_tax = calc_income_tax_with_gift_aid(
rates.scotland.rates,
other_earned_taxable + property_after_allowances,
gross_gift,
first_extended,
len(rates.scotland.rates.thresholds),
) - calc_income_tax_with_gift_aid(
rates.scotland.rates,
other_earned_taxable,
gross_gift,
first_extended,
len(rates.scotland.rates.thresholds),
)
return where(person("pays_scottish_income_tax", period), scottish_tax, ruk_tax)

def formula_2027(person, period, parameters):
"""
Calculate income tax on property income.

Expand Down
Loading