diff --git a/changelog.d/2431.fixed.md b/changelog.d/2431.fixed.md new file mode 100644 index 0000000000..8631399fe9 --- /dev/null +++ b/changelog.d/2431.fixed.md @@ -0,0 +1 @@ +- Indexed the annual savings starting-rate limit by September CPI from 2031–32, with statutory £10 rounding after the freeze. diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index e6be77d34e..275812cdd4 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -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 ---------- @@ -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. @@ -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: @@ -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" @@ -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", @@ -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) @@ -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, @@ -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 } @@ -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( diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md index 6ab9344984..bc532db98a 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/README.md @@ -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. diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml index bb57a28247..3cc8f71baa 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/savings_starter_rate/allowance.yaml @@ -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 diff --git a/policyengine_uk/tests/test_savings_starting_rate_indexation.py b/policyengine_uk/tests/test_savings_starting_rate_indexation.py new file mode 100644 index 0000000000..f5df01c766 --- /dev/null +++ b/policyengine_uk/tests/test_savings_starting_rate_indexation.py @@ -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 + ) diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 23211c9960..0e8961f917 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -408,6 +408,9 @@ def series(base, first, last, month_day="04-06"): } }, "rates": { + "savings_starter_rate": { + "allowance": series(5_000, 2021, 2030) + }, "uk": { "brackets": [ { @@ -424,7 +427,7 @@ def series(base, first, last, month_day="04-06"): ), }, ] - } + }, }, }, "national_insurance": {