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/2431.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze.
37 changes: 34 additions & 3 deletions policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Index the personal allowance, the basic rate limit and the equivalent NICs
thresholds by September CPI once their freeze ends.
"""Index the personal allowance, basic and savings starting-rate limits, and
the equivalent NICs thresholds by September CPI once their freeze ends.

Income tax
----------
Expand All @@ -12,6 +12,13 @@
the same percentage and round the result up to a multiple of £100. Each year
starts from the rounded amount of the year before.

s21(3A) similarly raises the savings starting-rate limit, but rounds the
full result up to a multiple of £10. Finance Act 2026 s9 fixes that annual
limit at £5,000 through 2030-31 and disapplies s21 for those years. From
2031-32, ``index_savings_starting_rate_limit`` applies the statutory step
to the previous rounded year's limit. Future September CPI is a model
forecast, not a known statutory future amount.

Finance Act 2021 s5, as amended by Finance Act 2023 s5 and Finance Act 2026
s10, sets the basic rate limit at £37,700 and the personal allowance at
£12,570 for 2022-23 to 2030-31 and disapplies ss21 and 57 for those years.
Expand Down Expand Up @@ -85,7 +92,7 @@
(a reform dict, ``parameter_changes``, ``Reform.from_dict``) writes its changes
onto the parameters before they are processed, as does a simulation modifier
that resets and reprocesses them (as ``no_economic_assumptions`` does), so the
values are rebuilt with the change: a changed CPI forecast moves all seven,
values are rebuilt with the change: a changed CPI forecast moves all eight,
and a change to the personal allowance or basic rate limit after 2030-31 also
moves the aligned NICs thresholds (see ``policyengine_uk.utils.dated_reforms``).
The exceptions edit the processed parameters, so they do not move the others:
Expand All @@ -105,6 +112,8 @@
# ITA 2007 s57(3) Step 2 and s21(3) Step 2.
PERSONAL_ALLOWANCE_MULTIPLE = Decimal(10)
BASIC_RATE_LIMIT_MULTIPLE = Decimal(100)
# ITA 2007 s21(3A) Step 2.
SAVINGS_STARTING_RATE_LIMIT_MULTIPLE = Decimal(10)
PENNY = Decimal("0.01")
# Fiscal-year conversion reads each tax year's value at 30 April.
FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30"
Expand All @@ -124,6 +133,7 @@
INDEXED_THRESHOLDS = (
"gov.hmrc.income_tax.allowances.personal_allowance.amount",
"gov.hmrc.income_tax.rates.uk[1].threshold",
"gov.hmrc.income_tax.rates.savings_starter_rate.allowance",
"gov.hmrc.national_insurance.class_1.thresholds.primary_threshold",
"gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit",
"gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold",
Expand Down Expand Up @@ -162,6 +172,14 @@ def index_basic_rate_limit(previous: Decimal, increase: Decimal) -> Decimal:
return round_up_to_multiple(step_1, BASIC_RATE_LIMIT_MULTIPLE)


def index_savings_starting_rate_limit(previous: Decimal, increase: Decimal) -> Decimal:
"""ITA 2007 s21(1) and (3A): next year's annual savings starting limit."""
if increase <= 0:
return previous
amount = previous * (1 + increase)
return round_up_to_multiple(amount, SAVINGS_STARTING_RATE_LIMIT_MULTIPLE)


def weekly_equivalent(annual: Decimal) -> Decimal:
"""Annual amount over 52, to the penny, as the parameter files store it."""
return (annual / WEEKS_IN_YEAR).quantize(PENNY, ROUND_HALF_UP)
Expand Down Expand Up @@ -311,9 +329,13 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode:
class_4 = parameters.gov.hmrc.national_insurance.class_4.thresholds
personal_allowance_parameter = income_tax.allowances.personal_allowance.amount
basic_rate_limit_parameter = income_tax.rates.uk.brackets[1].threshold
savings_starting_rate_limit_parameter = (
income_tax.rates.savings_starter_rate.allowance
)
indexed = [
personal_allowance_parameter,
basic_rate_limit_parameter,
savings_starting_rate_limit_parameter,
class_1.primary_threshold,
class_1.upper_earnings_limit,
class_1.secondary_threshold,
Expand All @@ -333,6 +355,12 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode:
basic_rate_limit = tax_year_path(
basic_rate_limit_parameter, years, increases, index_basic_rate_limit
)
savings_starting_rate_limit = tax_year_path(
savings_starting_rate_limit_parameter,
years,
increases,
index_savings_starting_rate_limit,
)
higher_rate_threshold = {
year: personal_allowance[year] + basic_rate_limit[year] for year in years
}
Expand All @@ -342,6 +370,9 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode:

write_unstated_tax_years(personal_allowance_parameter, personal_allowance)
write_unstated_tax_years(basic_rate_limit_parameter, basic_rate_limit)
write_unstated_tax_years(
savings_starting_rate_limit_parameter, savings_starting_rate_limit
)
write_unstated_tax_years(class_4.lower_profits_limit, personal_allowance)
write_unstated_tax_years(class_4.upper_profits_limit, higher_rate_threshold)
write_unstated_tax_years(
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,10 @@
# Savings starter rate

The annual starting-rate limit remains £5,000 through 2030–31 under Finance
Act 2026 section 9. From 2031–32, Income Tax Act 2007 section 21(3A) increases
the preceding rounded limit by the positive September CPI rise and rounds the
full amount upward to a multiple of £10. Zero or negative CPI leaves the
limit unchanged. `create_threshold_indexation.py` generates these annual
values through the economic-assumptions horizon; future CPI inputs are
forecasts or scenario assumptions. The pre-2015 parameter history is retained
without being verified by this prospective change.
Original file line number Diff line number Diff line change
@@ -1,10 +1,33 @@
description: Starter allowance for savings.
description: Annual starting-rate limit for savings income.
# ITA 2007 s21(3A) increases the previous rounded annual limit by positive
# September CPI and rounds the result up to a multiple of £10. FA 2026 s9
# fixes £5,000 through 2030-31 and disapplies that indexation. From 2031-32,
# create_threshold_indexation.py applies the statutory step, so this file
# has no generic uprating. The pre-2015 model entry is retained unchanged;
# this prospective indexation fix does not verify that historical amount.
values:
2010-04-01: 5_000
2015-04-06:
value: 5_000
metadata:
reference:
- title: Finance Act 2014 s. 3 (2) and (3)
href: https://www.legislation.gov.uk/ukpga/2014/26/section/3
2026-04-06: 5_000
2027-04-06: 5_000
2028-04-06: 5_000
2029-04-06: 5_000
2030-04-06: 5_000
metadata:
period: week
period: year
unit: currency-GBP
label: Savings starter rate allowance
reference:
- title: Tax on savings interest
href: https://www.gov.uk/apply-tax-free-interest-on-savings
reference:
- title: Income Tax Act 2007 s. 12 (3) and (4)
href: https://www.legislation.gov.uk/ukpga/2007/3/section/12
- title: Income Tax Act 2007 s. 21 (1), (3A), (5) and (6)
href: https://www.legislation.gov.uk/ukpga/2007/3/section/21
- title: Finance Act 2026 s. 9 (freeze for 2026-27 to 2030-31)
href: https://www.legislation.gov.uk/ukpga/2026/11/section/9
- title: Tax on savings interest
href: https://www.gov.uk/apply-tax-free-interest-on-savings
246 changes: 246 additions & 0 deletions policyengine_uk/tests/test_savings_starting_rate_indexation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
"""ITA 2007 s21(3A): annual savings limit, September CPI and £10 ceilings.

The £5,000 freeze ends in 2030-31 (FA 2026 s9). Future CPI in these
tests is an explicit scenario assumption, not a statutory future amount.
Savings-only income avoids the separately tracked starter-band taper #1799.
"""

from decimal import Decimal
from pathlib import Path

import numpy as np
import pytest
import yaml
from policyengine_core.reforms import Reform

import policyengine_uk
from policyengine_uk import CountryTaxBenefitSystem, Simulation
from policyengine_uk.parameters.gov.hmrc import create_threshold_indexation
from policyengine_uk.utils.scenario import Scenario

LIMIT = "gov.hmrc.income_tax.rates.savings_starter_rate.allowance"
CPI = "gov.economic_assumptions.statutory_uprating_inputs.cpi_september"
PA = "gov.hmrc.income_tax.allowances.personal_allowance.amount"
PARAMETER_FILE = (
Path(policyengine_uk.__file__).parent
/ "parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml"
)


@pytest.fixture(scope="module")
def system():
return CountryTaxBenefitSystem()


def limit(system, year, baseline=False):
root = system.parameters.baseline if baseline else system.parameters
return root.get_child(LIMIT)(f"{year}-01-01")


def with_cpi(system, rates):
result = system.clone()
result.reset_parameters()
parameter = result.parameters.get_child(CPI)
for year, rate in rates.items():
parameter.update(period=f"year:{year}-09-01:1", value=rate)
result.process_parameters()
return result


@pytest.mark.parametrize("year", range(2026, 2031))
def test_starting_rate_limit_is_frozen_through_2030(system, year):
assert limit(system, year) == 5_000


def test_starting_rate_limit_has_annual_metadata_without_double_uprating():
metadata = yaml.safe_load(PARAMETER_FILE.read_text())["metadata"]
assert metadata["period"] == "year"
assert "uprating" not in metadata


@pytest.mark.parametrize(
"previous,increase,expected",
[
(5_000, "0.02", 5_100),
(5_100, "0.02", 5_210),
(5_210, "0.02", 5_320),
(5_000, "0.001", 5_010),
(5_000, "0.002", 5_010),
(5_000, "0", 5_000),
(5_100, "-0.01", 5_100),
],
)
def test_starting_rate_statutory_rounding(previous, increase, expected):
assert create_threshold_indexation.index_savings_starting_rate_limit(
Decimal(previous), Decimal(increase)
) == Decimal(expected)


def test_starting_rate_uses_the_previous_rounded_annual_limit(system):
indexed = with_cpi(system, dict.fromkeys(range(2030, 2039), 0.02))
for year, expected in [(2030, 5_000), (2031, 5_100), (2032, 5_210), (2033, 5_320)]:
assert limit(indexed, year) == expected
if year > 2030:
assert limit(indexed, year, baseline=True) == expected
assert limit(indexed, 2040) == limit(indexed, 2039)
# Reapplying the helper does not append another year's increase.
create_threshold_indexation.add_threshold_indexation(indexed.parameters)
assert limit(indexed, 2031) == 5_100


@pytest.mark.parametrize("rate", [0, -0.01])
def test_nonpositive_cpi_preserves_the_preceding_limit(system, rate):
indexed = with_cpi(system, {2030: 0.02, 2031: rate})
assert limit(indexed, 2031) == 5_100
assert limit(indexed, 2032) == 5_100


def savings_situation():
years = range(2030, 2034)
return {
"people": {
"saver": {
"age": {year: 40 for year in years},
"savings_interest_income": {year: 20_000 for year in years},
}
},
"benunits": {"benunit": {"members": ["saver"]}},
"households": {
"household": {
"members": ["saver"],
"region": {year: "LONDON" for year in years},
}
},
}


@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"])
def test_indexed_starting_rate_reaches_final_savings_income_tax(route):
# Hold the PA at £12,570 to isolate this band's effect. With £20,000
# interest and the unchanged £1,000 PSA, £6,430 remains before the
# starting rate. The unchanged post-2027 basic savings rate is 22%.
changes = {
CPI: {"2030-09-01.2038-08-31": 0.02},
PA: {"2030-04-06.2034-04-05": 12_570},
}
if route == "parameter_changes":
simulation = Simulation(
situation=savings_situation(),
scenario=Scenario(parameter_changes=changes),
)
else:
reform = changes if route == "dict" else Reform.from_dict(changes)
simulation = Simulation(situation=savings_situation(), reform=reform)
for year, starter, tax in [
(2030, 5_000, 314.60),
(2031, 5_100, 292.60),
(2032, 5_210, 268.40),
(2033, 5_320, 244.20),
]:
assert simulation.calculate("personal_allowance", year)[0] == 12_570
assert simulation.calculate("savings_allowance", year)[0] == 1_000
assert simulation.calculate("savings_starter_rate_income", year)[0] == starter
np.testing.assert_allclose(
simulation.calculate("savings_income_tax", year), [tax], rtol=0, atol=0.001
)
np.testing.assert_allclose(
simulation.calculate("income_tax", year), [tax], rtol=0, atol=0.001
)


def savings_simulation(route, changes):
if route == "parameter_changes":
return Simulation(
situation=savings_situation(),
scenario=Scenario(parameter_changes=changes),
)
reform = changes if route == "dict" else Reform.from_dict(changes)
return Simulation(situation=savings_situation(), reform=reform)


@pytest.mark.parametrize("route", ["dict", "parameter_changes", "Reform.from_dict"])
def test_dated_limit_override_resumes_indexation_from_the_changed_limit(route):
# ITA 2007 s21(3A) uses the previous year's limit, including a reform's
# £6,000, then rounds the 2% increase up to £10: £6,120 in 2032.
# The separately constructed CPI/PA-only control retains its own path.
changes = {
CPI: {"2030-09-01.2038-08-31": 0.02},
PA: {"2030-04-06.2034-04-05": 12_570},
}
control = savings_simulation(route, changes)
simulation = savings_simulation(
route, {**changes, LIMIT: {"2031-04-06.2032-04-05": 6_000}}
)
assert limit(simulation.tax_benefit_system, 2030) == 5_000
assert limit(simulation.tax_benefit_system, 2031) == 6_000
assert limit(simulation.tax_benefit_system, 2032) == 6_120
assert limit(control.tax_benefit_system, 2032) == 5_210
# With genuine £20,000 interest, the held PA and natural PSA leave
# £6,430 before the starting rate: (6,430 - limit) x 22%.
for current, year, tax in [
(simulation, 2031, 94.60),
(simulation, 2032, 68.20),
(control, 2032, 268.40),
]:
assert current.calculate("personal_allowance", year)[0] == 12_570
assert current.calculate("savings_allowance", year)[0] == 1_000
for variable in ("savings_income_tax", "income_tax"):
np.testing.assert_allclose(
current.calculate(variable, year), [tax], rtol=0, atol=0.001
)


@pytest.mark.parametrize(
"start,stop,expected",
[
("2029-04-06", "2030-04-29", (6_000, 5_000, 5_100, 5_210)),
("2029-04-06", "2030-04-30", (6_000, 6_000, 6_120, 6_250)),
("2030-04-06", "2030-04-29", (5_000, 5_000, 5_100, 5_210)),
],
)
def test_override_replacing_the_last_file_value_keeps_uncovered_years(
start, stop, expected
):
# The file's last anchor is 2030-04-06. Replacing it must retain the
# stated 2030 freeze when the change ends before the 30 April sample.
# A change covering that sample instead supplies the previous-year
# base for 2031; £6,120 x 1.02 then ceilings to £6,250 for 2032.
simulation = savings_simulation(
"dict",
{
CPI: {"2030-09-01.2038-08-31": 0.02},
LIMIT: {f"{start}.{stop}": 6_000},
},
)
assert [
limit(simulation.tax_benefit_system, year) for year in range(2029, 2033)
] == list(expected)


def test_reset_and_reprocessing_keep_the_changed_savings_indexation_path():
simulation = savings_simulation(
"dict",
{
CPI: {"2030-09-01.2038-08-31": 0.02},
PA: {"2030-04-06.2034-04-05": 12_570},
LIMIT: {"2031-04-06.2032-04-05": 6_000},
},
)
system = simulation.tax_benefit_system
assert limit(system, 2032) == 6_120
system.reset_parameters()
system.reset_parameters()
system.process_parameters()
assert [limit(system, year) for year in range(2030, 2033)] == [
5_000,
6_000,
6_120,
]
assert system._parameters_before_reset is None
# Calculate after rebuilding, so this is not a cached pre-reset award.
assert simulation.calculate("personal_allowance", 2032)[0] == 12_570
assert simulation.calculate("savings_allowance", 2032)[0] == 1_000
for variable in ("savings_income_tax", "income_tax"):
np.testing.assert_allclose(
simulation.calculate(variable, 2032), [68.20], rtol=0, atol=0.001
)
Loading
Loading