diff --git a/changelog.d/2179.fixed.md b/changelog.d/2179.fixed.md new file mode 100644 index 0000000000..1342add228 --- /dev/null +++ b/changelog.d/2179.fixed.md @@ -0,0 +1 @@ +- From 2031-32, index the personal allowance and basic rate limit by the previous September's CPI with the statutory round-ups (Income Tax Act 2007 ss57 and 21), keep the primary threshold, upper earnings limit and Class 4 profits limits aligned with them, and raise the secondary threshold by September CPI. Before, they followed calendar-year CPI series without rounding. diff --git a/docs/book/policy/model-baseline.md b/docs/book/policy/model-baseline.md index eb0afdde4c..6da9c04145 100644 --- a/docs/book/policy/model-baseline.md +++ b/docs/book/policy/model-baseline.md @@ -8,7 +8,9 @@ PolicyEngine UK models the UK tax and benefit system as of 2025, incorporating m ### Budget 2025 threshold freezes -Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £96 a week through 2030-31, then uprates them with CPI from 2031-32. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The model annualises the weekly secondary threshold as 52 × £96 = £4,992; the annual figure in law is £5,000 ([#1967](https://github.com/PolicyEngine/policyengine-uk/issues/1967)). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. +Budget 2025 keeps the income tax personal allowance and basic rate limit, and the equivalent National Insurance thresholds, at their current levels until April 2031. Finance Act 2021 s.5, as amended by Finance Act 2023 s.5 and Finance Act 2026 s.10, fixes the [personal allowance](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml) at £12,570 and the [basic rate limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml) at £37,700 for 2022-23 to 2030-31. The model holds the [primary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml) and [lower profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml) at £12,570, the [upper earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml) and [upper profits limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml) at £50,270, and the [secondary threshold](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml) at £5,000 a year through 2030-31. For Class 1, regulations set only the 2026-27 amounts; the later years are announced policy. The Class 4 limits are standing figures in SSCBA 1992 s.15(3). The [lower earnings limit](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/lower_earnings_limit.yaml) is not frozen: it rose with CPI to £129 a week in 2026-27. + +From 2031-32 the model indexes these thresholds by September CPI, in [`create_threshold_indexation.py`](https://github.com/PolicyEngine/policyengine-uk/blob/main/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The personal allowance and basic rate limit follow Income Tax Act 2007 ss.57 and 21 year by year: each rises by the CPI increase to the previous September, with the allowance's increase rounded up to a multiple of £10 and the limit rounded up to a multiple of £100. The government says the National Insurance thresholds will also be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI 2026/231, para 5.1), and it has kept them aligned with income tax through the freeze (HMRC's Budget 2025 policy paper on the threshold freeze; Explanatory Memorandum to SI 2026/231, paras 5.14 and 5.15). The model assumes the alignment continues, round-ups included: the primary threshold and lower profits limit equal the personal allowance, and the upper earnings and profits limits equal the higher rate threshold (the personal allowance plus the basic rate limit). It also assumes the secondary threshold rises by September CPI, unrounded; the Autumn Budget 2024 policy paper says only that it will be increased in line with CPI. None of these National Insurance rules after 2030-31 is law. ## 2025 reforms diff --git a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py index b977aca217..530f8b1b54 100644 --- a/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py +++ b/policyengine_uk/parameters/gov/dwp/state_pension/triple_lock/create_triple_lock.py @@ -44,12 +44,12 @@ from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( AWE_OBSERVATION_MONTH_DAY, CPI_OBSERVATION_MONTH_DAY, + PUBLISHED_PRECISION, last_input_year, + round_to_published_precision, ) FIRST_UPRATING_YEAR = 2011 -# Published statistics are quoted to one decimal place of a percentage. -PUBLISHED_PRECISION = Decimal("0.001") # Guards the round-up of a guaranteed minimum against floating-point noise, # so a ratio of 1.025000000000001 is not taken up to 2.6%. RATIO_PRECISION = Decimal("0.000000001") @@ -66,13 +66,6 @@ def uprating_instant(year: int) -> str: return f"{year}-04-30" -def round_to_published_precision(rate: float) -> float: - """Round a growth rate to 0.1 percentage points, halves away from zero.""" - return float( - Decimal(repr(float(rate))).quantize(PUBLISHED_PRECISION, ROUND_HALF_UP) - ) - - def round_up_to_published_precision(rate: float) -> float: """Round a guaranteed minimum rate up to the next 0.1 percentage points.""" cleaned = Decimal(repr(float(rate))).quantize(RATIO_PRECISION, ROUND_HALF_UP) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/README.md b/policyengine_uk/parameters/gov/economic_assumptions/README.md index c95242d250..d59e6c8878 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/README.md +++ b/policyengine_uk/parameters/gov/economic_assumptions/README.md @@ -83,6 +83,26 @@ September. Then replace May-July earnings with the October release's figure, which the review uses: the April 2025 and April 2026 rises used October's 4.1% and 4.8%, where September's first estimates were 4.0% and 4.7%. +## September CPI uprating + +[`create_september_cpi_uprating.py`](./create_september_cpi_uprating.py) +builds `yoy_growth.september_cpi_uprating`, the rise taking effect in April +of each year (keyed to 1 January, like the other series), from +`statutory_uprating_inputs.cpi_september`: September CPI of the year before, +rounded to the published 0.1 percentage points, and zero when prices did not +rise. `create_economic_assumption_indices` compounds it into +`indices.september_cpi_uprating`. The triple lock reads the same rounded +September figure, so a scenario that edits calendar-year CPI or sets +September CPI directly moves both. + +Income Tax Act 2007 ss21 and 57 index the basic rate limit and the personal +allowance by this measure once no freeze applies. +[`../hmrc/create_threshold_indexation.py`](../hmrc/create_threshold_indexation.py) +applies them from 2031-32, keeps the primary threshold, upper earnings limit +and Class 4 profits limits aligned with them, and raises the secondary +threshold by the same rises, up to the last year of +`indices.september_cpi_uprating`. + ## Refreshing after a new EFO 1. Replace the 2025-2030 block in each series with values from the new EFO diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_september_cpi_uprating.py b/policyengine_uk/parameters/gov/economic_assumptions/create_september_cpi_uprating.py new file mode 100644 index 0000000000..5e8407eacd --- /dev/null +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_september_cpi_uprating.py @@ -0,0 +1,97 @@ +"""Build the April uprating by the previous September's CPI. + +Income Tax Act 2007 ss21 and 57 index the basic rate limit and the personal +allowance for a tax year by the percentage increase in the consumer prices +index for the September before it, and only when prices rose (ss21(1) and +57(2)). The Explanatory Memorandum to SI 2026/231 (paras 5.1 and 5.2) says the +equivalent NICs thresholds will be uprated by September CPI once their freeze +ends, and that the Class 2 and Class 3 rates, the lower earnings limit and the +small profits threshold were raised for 2026-27 by September 2025's 3.8%. + +``gov.economic_assumptions.yoy_growth.september_cpi_uprating`` holds the rise +taking effect in April of each year, keyed to 1 January as the other growth +series are. It is September CPI of the previous year from +``statutory_uprating_inputs.cpi_september``, rounded to the 0.1 percentage +points the ONS publishes, as the State Pension triple lock reads it. When +prices fall the rise is zero, as ss21 and 57 only raise amounts. + +``create_economic_assumption_indices`` compounds the rises into +``gov.economic_assumptions.indices.september_cpi_uprating``. +``policyengine_uk.parameters.gov.hmrc.create_threshold_indexation`` uses the +rises, up to the index's last year, to index the income tax and NICs +thresholds after their freeze. The series starts from a 2010 base, the year +of the first September CPI input, and runs to one year past the statutory +inputs, as the triple lock does. +""" + +from typing import Dict, Iterable + +from policyengine_core.parameters import Parameter, ParameterNode + +from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( + CPI_OBSERVATION_MONTH_DAY, + last_input_year, + round_to_published_precision, +) + +# The index starts at 1 in this year; its value holds no rise. +BASE_YEAR = 2010 +FIRST_UPRATING_YEAR = BASE_YEAR + 1 + + +def september_cpi_uprating_rate(september_cpi: float) -> float: + """April rise for a September CPI rate: the published rate, never a cut.""" + rate = round_to_published_precision(september_cpi) + # Not max(rate, 0.0), which keeps a rounded -0.0. + return rate if rate > 0 else 0.0 + + +def uprating_years(parameters: ParameterNode) -> Iterable[int]: + """Aprils covered: from April 2011 to one year past the statutory inputs.""" + return range(FIRST_UPRATING_YEAR, last_input_year(parameters) + 2) + + +def september_cpi_uprating_rates(parameters: ParameterNode) -> Dict[int, float]: + """The rise taking effect in April of each year, in year order.""" + cpi_september = ( + parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + ) + rates = {} + for year in uprating_years(parameters): + september = cpi_september(f"{year - 1}-{CPI_OBSERVATION_MONTH_DAY}") + if september is None: + raise ValueError( + f"Missing September {year - 1} CPI for the April {year} uprating." + ) + rates[year] = september_cpi_uprating_rate(september) + return rates + + +def add_september_cpi_uprating(parameters: ParameterNode) -> ParameterNode: + """Add ``gov.economic_assumptions.yoy_growth.september_cpi_uprating``.""" + values = {f"{BASE_YEAR}-01-01": None} + values.update( + { + f"{year}-01-01": rate + for year, rate in september_cpi_uprating_rates(parameters).items() + } + ) + new_parameter = Parameter( + "gov.economic_assumptions.yoy_growth.september_cpi_uprating", + data={ + "description": ( + "Uprating taking effect in April of each year: September " + "CPI inflation of the previous year, and no rise when prices " + "fall." + ), + "values": values, + "metadata": { + "unit": "/1", + "label": "September CPI uprating rate", + }, + }, + ) + parameters.gov.economic_assumptions.yoy_growth.add_child( + "september_cpi_uprating", new_parameter + ) + return parameters diff --git a/policyengine_uk/parameters/gov/economic_assumptions/create_statutory_uprating_inputs.py b/policyengine_uk/parameters/gov/economic_assumptions/create_statutory_uprating_inputs.py index fe1fdac8c7..c6e4f33436 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/create_statutory_uprating_inputs.py +++ b/policyengine_uk/parameters/gov/economic_assumptions/create_statutory_uprating_inputs.py @@ -24,11 +24,16 @@ covers. """ +from decimal import ROUND_HALF_UP, Decimal + from policyengine_core.parameters import Parameter, ParameterNode CPI_OBSERVATION_MONTH_DAY = "09-01" AWE_OBSERVATION_MONTH_DAY = "07-01" +# Published statistics are quoted to one decimal place of a percentage. +PUBLISHED_PRECISION = Decimal("0.001") + # Statutory input: (calendar-year series in yoy_growth.obr, observation date). STATUTORY_INPUTS = { "cpi_september": ("consumer_price_index", CPI_OBSERVATION_MONTH_DAY), @@ -36,6 +41,17 @@ } +def round_to_published_precision(rate: float) -> float: + """Round a growth rate to 0.1 percentage points, halves away from zero. + + Uprating reviews use the figure as published, so forecasts of an input + are rounded the same way before they set an uprating. + """ + return float( + Decimal(repr(float(rate))).quantize(PUBLISHED_PRECISION, ROUND_HALF_UP) + ) + + def _last_year(parameter: Parameter) -> int: return max( int(value.instant_str[:4]) diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py new file mode 100644 index 0000000000..0499a85c81 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -0,0 +1,259 @@ +"""Index the personal allowance, the basic rate limit and the equivalent NICs +thresholds by September CPI once their freeze ends. + +Income tax +---------- +Income Tax Act 2007 s57 raises the personal allowance (s35(1)) for a tax year +if the consumer prices index for the September before the start of the year +is higher than it was for the previous September (s57(2)). The allowance for +the previous year is multiplied by the percentage increase, the result is +rounded up to a multiple of £10, and that increase is added to the previous +allowance (s57(3), Steps 1 to 3). s21(1) and (3) raise the basic rate limit by +the same percentage and round the result up to a multiple of £100. Each year +starts from the rounded amount of the year before. + +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. +The parameter files hold those years. From the first tax year after a +parameter's last stated value, ``index_personal_allowance`` and +``index_basic_rate_limit`` apply the statutory steps. + +National Insurance +------------------ +No statute indexes the NICs thresholds. SSCBA 1992 s5 needs the Class 1 +limits and thresholds specified for each tax year, and the Class 4 limits in +SSCBA s15(3) are subject to alteration by order after the Treasury's annual +review under SSAA 1992 s141. Government policy aligns them with income tax: +the primary threshold and lower profits limit with the personal allowance, +and the upper earnings and profits limits with the higher rate threshold (the +personal allowance plus the basic rate limit). HMRC's Budget 2025 note says +they "will remain aligned" for the frozen years to 2030-31. The Explanatory +Memorandum to SI 2026/231 para 5.1 says that after 2030-31 the personal +allowance, higher rate threshold, UEL, UPL, PT and LPL "will be uprated by the +September Consumer Prices Index (CPI)". Para 5.14 says the UEL has been +aligned with the higher rate threshold since April 2009, and para 5.15 that +for 2026-27 it "will remain at a level equivalent to the annual HRT". Nothing +announced says the NICs thresholds will take the income tax round-ups. The +model assumes the alignment continues, which is consistent with that policy: +after its own last stated value each of these NICs thresholds equals its +income tax equivalent for the same year: + +- lower profits limit = personal allowance; +- upper profits limit = personal allowance + basic rate limit; +- primary threshold and upper earnings limit = those annual amounts divided + by 52 and rounded to the penny, the weekly convention the parameter files + already use (12,570 / 52 = 241.73 and 50,270 / 52 = 966.73). + +The secondary threshold is not aligned with an income tax threshold. HMRC's +Autumn Budget 2024 note says that after its freeze it "will be increased in +line with Consumer Prices Index (CPI)". Nothing later says which CPI, how to +round, or what happens when prices fall, so the model assumes it rises by the +same September CPI increase each year, unrounded and never cut. + +The percentage increase for the tax year starting in April of year Y is +``gov.economic_assumptions.yoy_growth.september_cpi_uprating`` at year Y: +September CPI of year Y - 1 to the 0.1 percentage points the ONS publishes, +and zero when prices did not rise. Values are written for each tax year up to +the last year of ``gov.economic_assumptions.indices.september_cpi_uprating``, +the horizon over which every other parameter is uprated. Each is keyed to +1 January of the year the tax year starts, as core uprating keys its values, +so the unconverted ``parameters.baseline`` copy reads the same amount for a +year as fiscal-year conversion gives ``parameters.gov``. Stated values, +including a scenario's overrides, keep their own dates, so that copy read at +1 January lags them by a year, as it does every April-dated parameter. These +parameters carry no ``uprating`` metadata, so core uprating does not extend +them. + +The values are computed when the parameters are processed. A reform passed as +a dict edits the processed parameters, so changing one of these parameters +does not move the others. A scenario whose ``parameter_changes`` edit the raw +parameters, or a simulation modifier that resets and reprocesses them (as +``no_economic_assumptions`` does), rebuilds the values. So a changed CPI +forecast moves all seven, and a raw edit to the personal allowance or basic +rate limit after 2030-31 also moves the aligned NICs thresholds. +""" + +from decimal import ROUND_CEILING, ROUND_HALF_UP, Decimal +from typing import Callable, Dict + +from policyengine_core.parameters import Parameter, ParameterNode +from policyengine_core.parameters.parameter_at_instant import ParameterAtInstant + +WEEKS_IN_YEAR = 52 +# ITA 2007 s57(3) Step 2 and s21(3) Step 2. +PERSONAL_ALLOWANCE_MULTIPLE = Decimal(10) +BASIC_RATE_LIMIT_MULTIPLE = Decimal(100) +PENNY = Decimal("0.01") +# Fiscal-year conversion reads each tax year's value at 30 April. +FISCAL_YEAR_SAMPLE_MONTH_DAY = "04-30" +# Indexed values are keyed to 1 January, as core uprating keys its values. +INDEXED_VALUE_MONTH_DAY = "01-01" + + +def to_decimal(value: float) -> Decimal: + """Exact decimal for a stored amount or rate, without binary noise.""" + return Decimal(repr(float(value))) + + +def round_up_to_multiple(amount: Decimal, multiple: Decimal) -> Decimal: + """The amount if it is a multiple, else the next multiple above it.""" + return (amount / multiple).to_integral_value(ROUND_CEILING) * multiple + + +def index_personal_allowance(previous: Decimal, increase: Decimal) -> Decimal: + """ITA 2007 s57(2)-(3): the allowance for the next tax year. + + ``increase`` is the September CPI increase as a fraction (0.021 for 2.1%). + """ + if increase <= 0: + return previous + step_1 = previous * increase + step_2 = round_up_to_multiple(step_1, PERSONAL_ALLOWANCE_MULTIPLE) + return previous + step_2 + + +def index_basic_rate_limit(previous: Decimal, increase: Decimal) -> Decimal: + """ITA 2007 s21(1) and (3): the basic rate limit for the next tax year.""" + if increase <= 0: + return previous + step_1 = previous * (1 + increase) + return round_up_to_multiple(step_1, BASIC_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) + + +def index_secondary_threshold(previous: Decimal, increase: Decimal) -> Decimal: + """The secondary threshold rises with CPI, unrounded, and never falls.""" + if increase <= 0: + return previous + return previous * (1 + increase) + + +def first_tax_year(instant_str: str) -> int: + """First tax year (by its starting calendar year) a dated value covers.""" + year = int(instant_str[:4]) + return year if instant_str[5:] <= FISCAL_YEAR_SAMPLE_MONTH_DAY else year + 1 + + +def last_stated_tax_year(parameter: Parameter) -> int: + """Tax year of the parameter's latest dated value.""" + return max(first_tax_year(value.instant_str) for value in parameter.values_list) + + +def value_in_tax_year(parameter: Parameter, year: int) -> Decimal: + return to_decimal(parameter(f"{year}-{FISCAL_YEAR_SAMPLE_MONTH_DAY}")) + + +def tax_year_path( + parameter: Parameter, + years: range, + increases: Dict[int, Decimal], + step: Callable[[Decimal, Decimal], Decimal], +) -> Dict[int, Decimal]: + """The parameter's value in each tax year in ``years``, stated or indexed. + + Years up to the last stated value read the parameter; each later year + applies ``step`` to the year before with that year's increase. + """ + last_stated = last_stated_tax_year(parameter) + values = {} + for year in years: + if year <= last_stated: + values[year] = value_in_tax_year(parameter, year) + else: + values[year] = step(values[year - 1], increases[year]) + return values + + +def write_after_last_stated_value( + parameter: Parameter, values: Dict[int, Decimal] +) -> None: + """Add a value for each tax year after the last stated one.""" + last_stated = last_stated_tax_year(parameter) + for year, value in sorted(values.items()): + if year > last_stated: + parameter.values_list.append( + ParameterAtInstant( + parameter.name, + f"{year}-{INDEXED_VALUE_MONTH_DAY}", + data=float(value), + ) + ) + parameter.values_list.sort(key=lambda value: value.instant_str, reverse=True) + + +def september_cpi_increases(parameters: ParameterNode) -> Dict[int, Decimal]: + """The September CPI increase for each tax year to the uprating horizon.""" + economic_assumptions = parameters.gov.economic_assumptions + rises = economic_assumptions.yoy_growth.september_cpi_uprating + index = economic_assumptions.indices.september_cpi_uprating + years = [int(value.instant_str[:4]) for value in index.values_list] + # The index's first year is its base, with no increase. + return { + year: to_decimal(rises(f"{year}-01-01")) + for year in range(min(years) + 1, max(years) + 1) + } + + +def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: + """Index the income tax and NICs thresholds after their last stated values. + + Runs after ``create_economic_assumption_indices``, which builds the + September CPI index whose horizon this follows. + """ + increases = september_cpi_increases(parameters) + income_tax = parameters.gov.hmrc.income_tax + class_1 = parameters.gov.hmrc.national_insurance.class_1.thresholds + 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 + indexed = [ + personal_allowance_parameter, + basic_rate_limit_parameter, + class_1.primary_threshold, + class_1.upper_earnings_limit, + class_1.secondary_threshold, + class_4.lower_profits_limit, + class_4.upper_profits_limit, + ] + # From the earliest last stated value, so the income tax amounts cover + # every year a NICs threshold takes from them. + years = range( + min(last_stated_tax_year(parameter) for parameter in indexed), + max(increases) + 1, + ) + + personal_allowance = tax_year_path( + personal_allowance_parameter, years, increases, index_personal_allowance + ) + basic_rate_limit = tax_year_path( + basic_rate_limit_parameter, years, increases, index_basic_rate_limit + ) + higher_rate_threshold = { + year: personal_allowance[year] + basic_rate_limit[year] for year in years + } + secondary_threshold = tax_year_path( + class_1.secondary_threshold, years, increases, index_secondary_threshold + ) + + write_after_last_stated_value(personal_allowance_parameter, personal_allowance) + write_after_last_stated_value(basic_rate_limit_parameter, basic_rate_limit) + write_after_last_stated_value(class_4.lower_profits_limit, personal_allowance) + write_after_last_stated_value(class_4.upper_profits_limit, higher_rate_threshold) + write_after_last_stated_value( + class_1.primary_threshold, + {year: weekly_equivalent(value) for year, value in personal_allowance.items()}, + ) + write_after_last_stated_value( + class_1.upper_earnings_limit, + { + year: weekly_equivalent(value) + for year, value in higher_rate_threshold.items() + }, + ) + write_after_last_stated_value(class_1.secondary_threshold, secondary_threshold) + return parameters diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml index 10221e70f8..b2e9030828 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/allowances/personal_allowance/amount.yaml @@ -1,22 +1,42 @@ description: The Personal Allowance is deducted from general income. +# Income Tax Act 2007 s57 indexes the allowance by September CPI: the increase +# is rounded up to a multiple of £10 and added to the previous year's +# allowance. Finance Act 2021 s5(2) and (3)(b), as amended by Finance Act 2023 +# s5 and Finance Act 2026 s10, fixes it at £12,570 and disapplies s57 for +# 2022-23 to 2030-31. From the tax year after the last value below (2031-32), +# policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py applies +# s57 year by year, so this file has no uprating. values: 2015-04-06: 10_600 2017-04-06: 11_500 2018-04-06: 11_850 2019-04-06: 12_500 - 2021-04-06: 12_570 - 2027-04-06: 12_570 + 2021-04-06: + value: 12_570 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2021 art 3(a) + href: https://www.legislation.gov.uk/uksi/2021/111/article/3/made + 2027-04-06: + value: 12_570 + metadata: + reference: + - title: Finance Act 2021 s. 5 (2), as amended by Finance Act 2023 s. 5 (3) + href: https://www.legislation.gov.uk/ukpga/2023/1/section/5/enacted 2030-04-06: value: 12_570 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 - href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Finance Act 2021 s. 5 (2), as amended by Finance Act 2026 s. 10 (3) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/10 metadata: period: year label: Personal allowance unit: currency-GBP - uprating: gov.economic_assumptions.indices.obr.consumer_price_index reference: - title: Income Tax Act 2007 s. 35 href: https://www.legislation.gov.uk/ukpga/2007/3/section/35 + - title: Income Tax Act 2007 s. 57 (indexation) + href: https://www.legislation.gov.uk/ukpga/2007/3/section/57 + - title: Finance Act 2021 s. 5 + href: https://www.legislation.gov.uk/ukpga/2021/26/section/5 diff --git a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml index dba54d160f..0a2134e404 100644 --- a/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml +++ b/policyengine_uk/parameters/gov/hmrc/income_tax/rates/uk.yaml @@ -31,28 +31,46 @@ brackets: threshold: description: The lower threshold for the higher rate of income tax (and therefore the upper threshold of the basic rate) + # The basic rate limit. Income Tax Act 2007 s21 indexes it by September CPI, + # rounding the result up to a multiple of £100. Finance Act 2021 s5(1) and + # (3)(a), as amended by Finance Act 2023 s5 and Finance Act 2026 s10, fixes + # it at £37,700 and disapplies s21 for 2022-23 to 2030-31. From the tax year + # after the last value below (2031-32), + # policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py applies + # s21 year by year, so this threshold has no uprating. metadata: label: Higher rate threshold period: year reference: - href: https://www.legislation.gov.uk/ukpga/2021/26/section/5 title: Finance Act 2021 s. 5 (1) + - href: https://www.legislation.gov.uk/ukpga/2007/3/section/21 + title: Income Tax Act 2007 s. 21 (indexation) unit: currency-GBP - uprating: gov.benefit_uprating_cpi values: 2015-04-05: 32000 2017-04-01: 33500 2018-04-01: 34500 2019-04-01: 37500 2020-04-01: 37500 - 2021-04-01: 37700 - 2027-04-01: 37700 + 2021-04-01: + value: 37700 + metadata: + reference: + - title: The Income Tax (Indexation) Order 2021 art 2 + href: https://www.legislation.gov.uk/uksi/2021/111/article/2/made + 2027-04-01: + value: 37700 + metadata: + reference: + - title: Finance Act 2021 s. 5 (1), as amended by Finance Act 2023 s. 5 (2) + href: https://www.legislation.gov.uk/ukpga/2023/1/section/5/enacted 2030-04-01: value: 37700 metadata: reference: - - title: OBR Economic and Fiscal Outlook November 2025 - href: https://obr.uk/efo/economic-and-fiscal-outlook-november-2025/ + - title: Finance Act 2021 s. 5 (1), as amended by Finance Act 2026 s. 10 (2) + href: https://www.legislation.gov.uk/ukpga/2026/11/section/10 - rate: description: The additional rate is the highest tax bracket, with no upper bound metadata: diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml index e9c01f55ae..2f934569d5 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/primary_threshold.yaml @@ -6,7 +6,16 @@ description: The Primary Threshold is the lower bound for the main rate of Class # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and # Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. CPI uprating from 2031-32 is a modelling assumption. +# regulations. +# From 2031-32 (the tax year after the last value below) the model keeps the +# threshold at the personal allowance over 52, to the penny, as for 241.73 +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The +# government has kept the PT aligned with the personal allowance, which Income +# Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the PT will +# be uprated by September CPI after 2030-31 (Explanatory Memorandum to SI +# 2026/231 para 5.1). That is announced policy, not law; keeping it equal to +# the allowance, round-up included, is a modelling assumption consistent with +# it. The file has no uprating. values: 2015-04-06: 155 2016-04-06: 155 @@ -74,7 +83,6 @@ values: metadata: label: NI primary threshold unit: currency-GBP - uprating: gov.economic_assumptions.indices.obr.consumer_price_index period: week reference: - title: "GOV.UK | Rates and allowances: National Insurance contributions" diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml index eb92c8362d..5769634610 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/secondary_threshold.yaml @@ -2,7 +2,6 @@ description: Secondary threshold for National Insurance. metadata: label: NI secondary threshold period: week - uprating: gov.economic_assumptions.indices.obr.consumer_price_index reference: - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions @@ -17,7 +16,13 @@ metadata: # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The # 2027-28 to 2030-31 values are announced policy (Autumn Budget 2024 and # Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. CPI uprating from 2031-32 is a modelling assumption. +# regulations. +# From 2031-32 (the tax year after the last value below) the model raises the +# threshold each year by September CPI of the previous year, unrounded +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The +# Autumn Budget 2024 note said the threshold "will be increased in line with +# Consumer Prices Index (CPI)" after its freeze; nothing later sets the rule, +# so this is a modelling assumption, and the file has no uprating. values: 2015-04-06: 155 2016-04-06: 155 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml index ffd2796a4f..62ff414c47 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_earnings_limit.yaml @@ -6,7 +6,18 @@ description: The Upper Earnings Limit is the upper bound for the main rate of Cl # Legal status: SI 2001/1004 reg 10 sets the amounts for 2026-27 only. The # 2027-28 to 2030-31 values are announced policy (Autumn Statement 2022 and # Budget 2025), to be set in each year's rates, limits and thresholds -# regulations. CPI uprating from 2031-32 is a modelling assumption. +# regulations. +# From 2031-32 (the tax year after the last value below) the model keeps the +# limit at the higher rate threshold (personal allowance plus basic rate +# limit) over 52, to the penny, as for 966.73 +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The +# UEL has been aligned with the higher rate threshold since April 2009, and +# for 2026-27 "will remain at a level equivalent to the annual HRT" +# (Explanatory Memorandum to SI 2026/231 paras 5.14-5.15); para 5.1 says it +# will be uprated by September CPI after 2030-31. Income Tax Act 2007 ss21 and +# 57 index the higher rate threshold by September CPI from 2031-32. Keeping +# the UEL equal to it, round-ups included, is a modelling assumption +# consistent with that announced policy, not law. The file has no uprating. values: 2015-04-06: 815 2016-04-06: 827 @@ -62,7 +73,6 @@ values: metadata: label: NI upper earnings limit unit: currency-GBP - uprating: gov.economic_assumptions.indices.obr.consumer_price_index period: week reference: - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml index b36a83b7ba..2d38954fba 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/lower_profits_limit.yaml @@ -1,8 +1,15 @@ description: The Lower Profits Limit is the threshold at which self-employed earners pay the main Class 4 National Insurance rate. # Legal status: £12,570 is the standing figure in SSCBA 1992 s.15(3)(a), which # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. CPI uprating from 2031-32 is a modelling -# assumption. +# keeping it until April 2031. From 2031-32 (the tax year after the last value +# below) the model keeps the limit equal to the personal allowance +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The +# government has kept the LPL aligned with the personal allowance, which +# Income Tax Act 2007 s57 indexes by September CPI from 2031-32, and says the +# LPL will be uprated by September CPI after 2030-31 (Explanatory Memorandum +# to SI 2026/231 para 5.1). That is announced policy, not law; keeping it +# equal to the allowance, round-up included, is a modelling assumption +# consistent with it. The file has no uprating. values: 2015-04-06: 8_060 2017-04-06: 8_164 @@ -76,7 +83,6 @@ metadata: label: NI lower profits limit unit: currency-GBP period: year - uprating: gov.economic_assumptions.indices.obr.consumer_price_index reference: - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml index 37fa1a7b67..e70e8d1ece 100644 --- a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_4/thresholds/upper_profits_limit.yaml @@ -1,8 +1,16 @@ description: The Upper Profits Limit is the threshold at which self-employed earners pay the additional Class 4 National Insurance rate. # Legal status: £50,270 is the standing figure in SSCBA 1992 s.15(3)(b), which # stays until altered by order under SSAA 1992 s.141. Budget 2025 commits to -# keeping it until April 2031. CPI uprating from 2031-32 is a modelling -# assumption. +# keeping it until April 2031. From 2031-32 (the tax year after the last value +# below) the model keeps the limit equal to the higher rate threshold +# (personal allowance plus basic rate limit) +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py). The +# government has kept the UPL in line with the higher rate threshold, which +# Income Tax Act 2007 ss21 and 57 index by September CPI from 2031-32, and +# says the UPL will be uprated by September CPI after 2030-31 (Explanatory +# Memorandum to SI 2026/231 paras 5.1 and 5.25). That is announced policy, not +# law; keeping it equal to the higher rate threshold, round-ups included, is a +# modelling assumption consistent with it. The file has no uprating. values: 2015-04-06: 42_386 2015-06-05: 43_000 @@ -65,7 +73,6 @@ metadata: label: NI upper profits limit unit: currency-GBP period: year - uprating: gov.economic_assumptions.indices.obr.consumer_price_index reference: - https://assets.publishing.service.gov.uk/government/uploads/system/uploads/attachment_data/file/882271/Table-a4.pdf - https://www.gov.uk/government/publications/rates-and-allowances-national-insurance-contributions/rates-and-allowances-national-insurance-contributions diff --git a/policyengine_uk/tax_benefit_system.py b/policyengine_uk/tax_benefit_system.py index c8264232cc..06acdeb86c 100644 --- a/policyengine_uk/tax_benefit_system.py +++ b/policyengine_uk/tax_benefit_system.py @@ -21,12 +21,18 @@ from policyengine_uk.parameters.gov.dwp.state_pension.triple_lock.create_triple_lock import ( add_triple_lock, ) +from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( + add_september_cpi_uprating, +) from policyengine_uk.parameters.gov.economic_assumptions.create_economic_assumption_indices import ( create_economic_assumption_indices, ) from policyengine_uk.parameters.gov.economic_assumptions.create_statutory_uprating_inputs import ( add_statutory_uprating_inputs, ) +from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + add_threshold_indexation, +) from policyengine_uk.parameters.gov.economic_assumptions.lag_average_earnings import ( add_lagged_earnings, ) @@ -91,7 +97,9 @@ def process_parameters(self) -> None: - Lagged earnings and CPI indices - Statutory uprating inputs (September CPI, May-July earnings) - Triple lock calculations for state pensions + - September CPI uprating - Economic assumption indices + - Income tax and NICs threshold indexation after the freeze - Parameter uprating and backdating - Conversion to fiscal year parameters """ @@ -102,7 +110,9 @@ def process_parameters(self) -> None: self.parameters = add_lagged_cpi(self.parameters) self.parameters = add_statutory_uprating_inputs(self.parameters) self.parameters = add_triple_lock(self.parameters) + self.parameters = add_september_cpi_uprating(self.parameters) self.parameters = create_economic_assumption_indices(self.parameters) + self.parameters = add_threshold_indexation(self.parameters) self.parameters = add_lsr_deprecation_aliases(self.parameters) # Create baseline parameters for reform comparisons diff --git a/policyengine_uk/tests/test_ni_threshold_freeze.py b/policyengine_uk/tests/test_ni_threshold_freeze.py index 0ee0151a8e..2976b6b267 100644 --- a/policyengine_uk/tests/test_ni_threshold_freeze.py +++ b/policyengine_uk/tests/test_ni_threshold_freeze.py @@ -19,12 +19,14 @@ stand for 2026-27 and SSCBA 1992 s.15(3), not from the parameter files. 2. Cash freeze: PT, UEL, ST, LPL and UPL are identical in every year 2026-2030. -3. Indexation resumes from the frozen level: from 2031, each threshold is its - 2030 value times CPI(Y) / CPI(2030). There is no catch-up to the path the - threshold would have followed without the freeze. -4. Alignment: 52 x PT = LPL = personal allowance in every year 2024-2040; - 52 x UEL = UPL in every year 2021-2040; and UPL = personal allowance + - basic rate limit in every year 2021-2030. +3. After the freeze: from 2031 the PT, UEL, LPL and UPL stay aligned with + the income tax thresholds, which Income Tax Act 2007 ss21 and 57 index by + September CPI, and the ST rises by September CPI, each from its frozen + level with no catch-up. test_threshold_indexation.py tests this. +4. Alignment, in test_threshold_indexation.py: the UEL and UPL with the + higher rate threshold in every year 2021-2040, and the PT and LPL with the + personal allowance from 2023. The 2021-22 and 2022-23 lower thresholds + are intended exceptions, and the 2023-24 LPL is #1968. 5. No cash cuts: no threshold falls from one year to the next, 2026-2040. 6. Differential: for any earnings or profits >= 0 in 2026-2030, primary and additional Class 1, secondary Class 1 and Class 4 equal an exact-rational @@ -143,19 +145,7 @@ def test_frozen_thresholds_hold_in_cash_terms(system, name): assert len(set(values.values())) == 1, values -# Invariant 3: indexation resumes from the frozen level. -@pytest.mark.parametrize("name", FROZEN) -def test_indexation_resumes_from_frozen_level(system, name): - base = threshold(system, name, 2030) - for year in range(2031, 2041): - expected = base * cpi(system, year) / cpi(system, 2030) - assert threshold(system, name, year) == pytest.approx(expected, rel=1e-9), ( - name, - year, - ) - # One year of CPI in 2031, not the cumulative rise since 2026. - first_step = threshold(system, name, 2031) / base - assert first_step == pytest.approx(cpi(system, 2031) / cpi(system, 2030)) +# Invariants 3 and 4 are in test_threshold_indexation.py. def test_lower_earnings_limit_is_cpi_uprated_from_2026_27(system): @@ -167,33 +157,6 @@ def test_lower_earnings_limit_is_cpi_uprated_from_2026_27(system): ) -# Invariant 4: alignment with the income tax thresholds. -def test_primary_threshold_and_lpl_align_with_personal_allowance(system): - for year in range(2024, 2041): - income_tax = system.get_parameters_at_instant(str(year)).gov.hmrc.income_tax - personal_allowance = float(income_tax.allowances.personal_allowance.amount) - pt_annual = 52 * threshold(system, "primary_threshold", year) - lpl = threshold(system, "lower_profits_limit", year) - assert pt_annual == pytest.approx(personal_allowance, rel=1e-5), year - assert lpl == pytest.approx(personal_allowance, rel=1e-9), year - - -def test_uel_and_upl_align(system): - for year in range(2021, 2041): - uel_annual = 52 * threshold(system, "upper_earnings_limit", year) - upl = threshold(system, "upper_profits_limit", year) - assert uel_annual == pytest.approx(upl, rel=1e-5), year - - -def test_upl_equals_higher_rate_threshold_through_the_freeze(system): - for year in range(2021, 2031): - income_tax = system.get_parameters_at_instant(str(year)).gov.hmrc.income_tax - personal_allowance = float(income_tax.allowances.personal_allowance.amount) - basic_rate_limit = float(income_tax.rates.uk.thresholds[1]) - upl = threshold(system, "upper_profits_limit", year) - assert upl == personal_allowance + basic_rate_limit, year - - # Invariant 5: no cash cuts. @pytest.mark.parametrize("name", list(PATHS)) def test_no_threshold_falls_in_cash_terms(system, name): diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py new file mode 100644 index 0000000000..01d5396e02 --- /dev/null +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -0,0 +1,792 @@ +"""Tests for indexing the income tax and NICs thresholds after the freeze. + +Income Tax Act 2007 s57(3) raises the personal allowance (PA) by the +September CPI increase, with the increase rounded up to a multiple of £10; +s21(3) raises the basic rate limit (BRL) by the same percentage, with the +result rounded up to a multiple of £100. Finance Act 2021 s5, as amended by +Finance Act 2023 s5 and Finance Act 2026 s10, fixes both and disapplies the +indexation to 2030-31. The government keeps the primary threshold (PT) and +lower profits limit (LPL) aligned with the PA, and the upper earnings and +profits limits (UEL, UPL) with the higher rate threshold, PA + BRL. After +2030-31 it says all of them will be uprated by September CPI (Explanatory +Memorandum to SI 2026/231 para 5.1). ``create_threshold_indexation`` writes +those paths; the secondary threshold (ST) rises by September CPI, unrounded. + +Model year Y is the tax year starting 6 April Y. The model stores the PT and +UEL as the annual amount over 52, to the penny (241.73 and 966.73), so 52 +weeks of them differ from the annual amount by at most 52 half-pennies. + +Invariants: + +1. Statutory steps, for any previous amount and any September CPI increase + g: the PA rises by the smallest multiple of £10 at or above PA x g; the + BRL becomes the smallest multiple of £100 at or above BRL x (1 + g); + neither changes when g <= 0. Both never fall and are non-decreasing in g + and in the previous amount. They agree with an independent exact-rational + transcription of the statute (differential). +2. Outturn: from 2020-21's £12,500 and £37,500 and September 2020 CPI of + 0.5%, the steps give £12,570 and £37,700, the 2021-22 amounts in the + Income Tax (Indexation) Order 2021 (SI 2021/111) arts 2 and 3(a). +3. Freeze: the model's PA is £12,570 and BRL £37,700 in 2022-2030. +4. Reference path: in 2031-2039 the model's PA and BRL equal the statute + applied year by year, in exact rationals, to the model's own September + CPI input; 2040 holds 2039, the last year of the economic assumptions. +5. Alignment in every year 2021-2040: UPL = PA + BRL and 52 x UEL is within + £0.26 of the UPL. From 2023: LPL = PA and 52 x PT is within £0.26 of the + LPL. Intended exceptions, as the law stood: in 2021-22 the PT and LPL + were £9,568 a year, below the PA; in 2022-23 the PT rose to the PA only + from 6 July 2022 and the LPL was £11,908 for the whole year. The model's + 2023-24 LPL (£11,908, not £12,570) is #1968. +6. Secondary threshold: from 2031, ST(Y) = ST(Y-1) x (1 + g_Y). +7. For any freeze end and September CPI path (function level): values up to + each parameter's last stated year are unchanged; after it, each NICs + threshold equals its income tax equivalent for that year (taking stated + income tax values where the NICs freeze ends first). The PA, BRL and ST + never fall, and an aligned NICs threshold never falls after its first + indexed year. That first year can fall, by intent, when the stated NICs + amount was above its income tax equivalent: it realigns. Running the + indexation twice changes nothing. Indexed values are keyed to 1 January, + so the unconverted ``parameters.baseline`` copy reads the same amounts. +8. Scenarios: a different September CPI moves the PA, BRL and NICs + thresholds together by the statute; with September CPI <= 0, or with no + economic assumptions, they stay at their frozen levels; a reprocessed + override of the PA or BRL keeps the NICs thresholds aligned with it. +9. No double indexing: none of the seven parameters carries ``uprating`` + metadata, so core uprating does not extend them. +10. The September CPI series: each April's rise is the previous September's + CPI to 0.1 percentage points (halves up), and zero unless positive; its + index compounds those rises. +""" + +import math +from decimal import Decimal +from fractions import Fraction +from pathlib import Path + +import numpy as np +import pytest +import yaml +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.parameters import ParameterNode + +import policyengine_uk +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.model_api import Scenario +from policyengine_uk.parameters.gov.economic_assumptions.create_september_cpi_uprating import ( + september_cpi_uprating_rate, +) +from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( + add_threshold_indexation, + index_basic_rate_limit, + index_personal_allowance, + index_secondary_threshold, + weekly_equivalent, +) +from policyengine_uk.scenarios import no_economic_assumptions + +PARAMETER_DIR = Path(policyengine_uk.__file__).parent / "parameters" +FROZEN_YEARS = range(2022, 2031) +INDEXED_YEARS = range(2031, 2040) +ALIGNMENT_YEARS = range(2021, 2041) +# 52 weeks of an amount rounded to the nearest penny. +WEEKLY_ROUNDING = 52 * 0.005 +# The model stores parameter values as Python floats; arrays are float32. +EXACT = 1e-6 + +PATHS = { + "personal_allowance": "income_tax/allowances/personal_allowance/amount.yaml", + "basic_rate_limit": "income_tax/rates/uk.yaml", + "primary_threshold": "national_insurance/class_1/thresholds/primary_threshold.yaml", + "upper_earnings_limit": "national_insurance/class_1/thresholds/upper_earnings_limit.yaml", + "secondary_threshold": "national_insurance/class_1/thresholds/secondary_threshold.yaml", + "lower_profits_limit": "national_insurance/class_4/thresholds/lower_profits_limit.yaml", + "upper_profits_limit": "national_insurance/class_4/thresholds/upper_profits_limit.yaml", +} + + +@pytest.fixture(scope="module") +def system(): + return CountryTaxBenefitSystem() + + +def threshold_parameters(root): + """The seven parameters under a ``gov`` node.""" + hmrc = root.hmrc + class_1 = hmrc.national_insurance.class_1.thresholds + class_4 = hmrc.national_insurance.class_4.thresholds + return { + "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount, + # Bracket 1 itself: a scale read at an instant sorts its thresholds, + # so its thresholds[1] would be the £125,140 additional rate threshold + # once the basic rate limit passed it. + "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold, + "primary_threshold": class_1.primary_threshold, + "upper_earnings_limit": class_1.upper_earnings_limit, + "secondary_threshold": class_1.secondary_threshold, + "lower_profits_limit": class_4.lower_profits_limit, + "upper_profits_limit": class_4.upper_profits_limit, + } + + +def thresholds(system, year, root=None, instant=None): + """Each threshold in tax year ``year`` (by default from the processed gov).""" + root = system.parameters.gov if root is None else root + at = str(year) if instant is None else instant + return { + name: float(parameter(at)) + for name, parameter in threshold_parameters(root).items() + } + + +# Independent transcription of the statute in exact rationals: integers and +# Fractions only, no Decimal and no code from the module under test. + + +def reference_increase(september_cpi) -> Fraction: + """September CPI as published (0.1pp, halves up); zero unless positive.""" + tenths = math.floor(Fraction(repr(float(september_cpi))) * 1000 + Fraction(1, 2)) + return Fraction(tenths, 1000) if tenths > 0 else Fraction(0) + + +def reference_personal_allowance(previous: Fraction, increase: Fraction) -> Fraction: + """ITA 2007 s57(2)-(3) Steps 1-3.""" + if increase <= 0: + return previous + return previous + 10 * math.ceil(previous * increase / 10) + + +def reference_basic_rate_limit(previous: Fraction, increase: Fraction) -> Fraction: + """ITA 2007 s21(1) and (3) Steps 1-2.""" + if increase <= 0: + return previous + return Fraction(100 * math.ceil(previous * (1 + increase) / 100)) + + +# Invariant 1: statutory steps. + +amount = st.decimals(min_value=0, max_value=1_000_000, places=2) +whole_pounds = st.integers(min_value=0, max_value=1_000_000).map(Decimal) +published_increase = st.integers(min_value=-60, max_value=250).map( + lambda n: Decimal(n) / 1000 +) +any_increase = st.decimals(min_value="-0.1", max_value="0.3", places=6) + + +@given( + previous=st.one_of(amount, whole_pounds), + g=st.one_of(published_increase, any_increase), +) +@example(previous=Decimal(12_570), g=Decimal("0.02")) # rise 251.4 -> 260 +@example(previous=Decimal(12_500), g=Decimal("0.0008")) # rise 10 exactly +@example(previous=Decimal(0), g=Decimal("0.02")) +def test_personal_allowance_step(previous, g): + new = index_personal_allowance(previous, g) + rise = new - previous + if g <= 0: + assert new == previous + return + assert rise % 10 == 0 + assert previous * g <= rise < previous * g + 10 + assert Fraction(new) == reference_personal_allowance( + Fraction(previous), Fraction(g) + ) + + +@given( + previous=st.one_of(amount, whole_pounds), + g=st.one_of(published_increase, any_increase), +) +@example(previous=Decimal(37_700), g=Decimal("0.02")) # 38,454 -> 38,500 +@example(previous=Decimal(37_500), g=Decimal("0.004")) # 37,650 -> 37,700 +def test_basic_rate_limit_step(previous, g): + new = index_basic_rate_limit(previous, g) + if g <= 0: + assert new == previous + return + assert new % 100 == 0 + assert previous * (1 + g) <= new < previous * (1 + g) + 100 + assert Fraction(new) == reference_basic_rate_limit(Fraction(previous), Fraction(g)) + + +@given( + previous=amount, + other=amount, + g=st.one_of(published_increase, any_increase), + h=st.one_of(published_increase, any_increase), +) +def test_steps_never_fall_and_are_monotone(previous, other, g, h): + low, high = sorted([previous, other]) + g_low, g_high = sorted([g, h]) + for step in ( + index_personal_allowance, + index_basic_rate_limit, + index_secondary_threshold, + ): + assert step(previous, g) >= previous + assert step(previous, g_low) <= step(previous, g_high) + assert step(low, g) <= step(high, g) + + +@given(annual=amount) +def test_weekly_equivalent_is_annual_over_52_to_the_penny(annual): + weekly = weekly_equivalent(annual) + assert weekly == weekly.quantize(Decimal("0.01")) + assert abs(52 * weekly - annual) <= Decimal("0.26") + + +def test_weekly_equivalents_reproduce_the_stored_convention(): + assert weekly_equivalent(Decimal(12_570)) == Decimal("241.73") + assert weekly_equivalent(Decimal(50_270)) == Decimal("966.73") + + +# Invariant 2: outturn. + + +def test_steps_reproduce_the_income_tax_indexation_order_2021(system): + # SI 2021/111 arts 2 and 3(a): BRL £37,700 and PA £12,570 for 2021-22, + # indexed from 2020-21 by September 2020 CPI. + cpi = system.parameters.gov.economic_assumptions.statutory_uprating_inputs + september_2020 = Decimal(repr(float(cpi.cpi_september("2020-09-01")))) + assert september_2020 == Decimal("0.005") + assert index_personal_allowance(Decimal(12_500), september_2020) == 12_570 + assert index_basic_rate_limit(Decimal(37_500), september_2020) == 37_700 + previous, current = thresholds(system, 2020), thresholds(system, 2021) + assert previous["personal_allowance"] == 12_500 + assert previous["basic_rate_limit"] == 37_500 + assert current["personal_allowance"] == 12_570 + assert current["basic_rate_limit"] == 37_700 + + +# Invariant 3: freeze. + + +@pytest.mark.parametrize("year", FROZEN_YEARS) +def test_income_tax_thresholds_frozen_to_2030_31(system, year): + values = thresholds(system, year) + assert values["personal_allowance"] == 12_570 + assert values["basic_rate_limit"] == 37_700 + + +# Invariant 4: reference path. + + +def reference_path(system): + cpi = system.parameters.gov.economic_assumptions.statutory_uprating_inputs + pa, brl = Fraction(12_570), Fraction(37_700) + path = {} + for year in INDEXED_YEARS: + g = reference_increase(cpi.cpi_september(f"{year - 1}-09-01")) + pa = reference_personal_allowance(pa, g) + brl = reference_basic_rate_limit(brl, g) + path[year] = (pa, brl, g) + return path + + +def test_income_tax_thresholds_follow_the_statute_from_2031_32(system): + path = reference_path(system) + for year, (pa, brl, _) in path.items(): + values = thresholds(system, year) + assert values["personal_allowance"] == pytest.approx(float(pa), abs=EXACT), year + assert values["basic_rate_limit"] == pytest.approx(float(brl), abs=EXACT), year + # Indexation resumes from the frozen level, with no catch-up. + first_g = path[2031][2] + assert thresholds(system, 2031)["personal_allowance"] == float( + reference_personal_allowance(Fraction(12_570), first_g) + ) + + +def test_thresholds_hold_after_the_last_year_of_economic_assumptions(system): + assert thresholds(system, 2040) == thresholds(system, 2039) + + +def test_indexed_amounts_are_statutory_multiples(system): + for year in INDEXED_YEARS: + values = thresholds(system, year) + assert values["personal_allowance"] % 10 == 0, year + assert values["basic_rate_limit"] % 100 == 0, year + + +# Invariant 5: alignment. + + +@pytest.mark.parametrize("year", ALIGNMENT_YEARS) +def test_upper_thresholds_align_with_the_higher_rate_threshold(system, year): + values = thresholds(system, year) + higher_rate_threshold = values["personal_allowance"] + values["basic_rate_limit"] + assert values["upper_profits_limit"] == pytest.approx( + higher_rate_threshold, abs=EXACT + ) + assert abs(52 * values["upper_earnings_limit"] - values["upper_profits_limit"]) <= ( + WEEKLY_ROUNDING + ) + + +@pytest.mark.parametrize( + "year", + [ + pytest.param( + 2023, + marks=pytest.mark.xfail( + reason="#1968: the model's 2023-24 LPL is £11,908, not £12,570", + strict=False, + ), + ), + *range(2024, 2041), + ], +) +def test_lower_thresholds_align_with_the_personal_allowance(system, year): + values = thresholds(system, year) + assert values["lower_profits_limit"] == pytest.approx( + values["personal_allowance"], abs=EXACT + ) + assert abs(52 * values["primary_threshold"] - values["lower_profits_limit"]) <= ( + WEEKLY_ROUNDING + ) + + +def test_lower_thresholds_before_alignment_are_intended(system): + # 2021-22: PT £184 a week and LPL £9,568, below the £12,570 PA. + values = thresholds(system, 2021) + assert 52 * values["primary_threshold"] == values["lower_profits_limit"] == 9_568 + assert values["personal_allowance"] == 12_570 + # 2022-23: the PT was £190 a week until 5 July 2022 (the model reads the + # April value) and the LPL £11,908 for the year (NICA 2022 s2). + values = thresholds(system, 2022) + assert values["primary_threshold"] == 190 + assert values["lower_profits_limit"] == 11_908 + assert values["personal_allowance"] == 12_570 + + +# Invariant 6: secondary threshold. + + +def test_secondary_threshold_rises_by_september_cpi_from_2031_32(system): + path = reference_path(system) + st_ = Fraction(5_000, 52) + assert thresholds(system, 2030)["secondary_threshold"] == pytest.approx( + float(st_), abs=1e-6 + ) + for year in INDEXED_YEARS: + st_ *= 1 + path[year][2] + assert thresholds(system, year)["secondary_threshold"] == pytest.approx( + float(st_), abs=1e-5 + ), year + + +# Invariant 7: the indexation function on any freeze end and CPI path. + + +def synthetic_parameters(last_years, rates, horizon, bases): + def series(base, first, last, month_day="04-06"): + return { + "values": { + f"{year}-{month_day}": float(base) for year in range(first, last + 1) + } + } + + data = { + "gov": { + "economic_assumptions": { + "yoy_growth": { + "september_cpi_uprating": { + "values": { + "2010-01-01": None, + **{ + f"{2011 + i}-01-01": float(rate) + for i, rate in enumerate(rates) + }, + } + } + }, + "indices": { + "september_cpi_uprating": { + "values": { + f"{year}-01-01": 1.0 for year in range(2010, horizon + 1) + } + } + }, + }, + "hmrc": { + "income_tax": { + "allowances": { + "personal_allowance": { + "amount": series( + bases["personal_allowance"], + 2021, + last_years["personal_allowance"], + ) + } + }, + "rates": { + "uk": { + "brackets": [ + { + "rate": {"values": {"2015-04-06": 0.2}}, + "threshold": {"values": {"2015-04-06": 0}}, + }, + { + "rate": {"values": {"2015-04-06": 0.4}}, + "threshold": series( + bases["basic_rate_limit"], + 2021, + last_years["basic_rate_limit"], + "04-01", + ), + }, + ] + } + }, + }, + "national_insurance": { + "class_1": { + "thresholds": { + name: series(bases[name], 2021, last_years[name]) + for name in ( + "primary_threshold", + "upper_earnings_limit", + "secondary_threshold", + ) + } + }, + "class_4": { + "thresholds": { + name: series(bases[name], 2021, last_years[name]) + for name in ("lower_profits_limit", "upper_profits_limit") + } + }, + }, + }, + } + } + return ParameterNode("", data=data) + + +def synthetic_values(root, year): + hmrc = root.gov.hmrc + instant = f"{year}-04-30" + return { + "personal_allowance": hmrc.income_tax.allowances.personal_allowance.amount( + instant + ), + "basic_rate_limit": hmrc.income_tax.rates.uk.brackets[1].threshold(instant), + "primary_threshold": hmrc.national_insurance.class_1.thresholds.primary_threshold( + instant + ), + "upper_earnings_limit": hmrc.national_insurance.class_1.thresholds.upper_earnings_limit( + instant + ), + "secondary_threshold": hmrc.national_insurance.class_1.thresholds.secondary_threshold( + instant + ), + "lower_profits_limit": hmrc.national_insurance.class_4.thresholds.lower_profits_limit( + instant + ), + "upper_profits_limit": hmrc.national_insurance.class_4.thresholds.upper_profits_limit( + instant + ), + } + + +NAMES = list(PATHS) +# The amounts stated through 2030-31. +FROZEN_AMOUNTS = { + "personal_allowance": 12_570, + "basic_rate_limit": 37_700, + "primary_threshold": 241.73, + "upper_earnings_limit": 966.73, + "secondary_threshold": 96.153846, + "lower_profits_limit": 12_570, + "upper_profits_limit": 50_270, +} +NICS_ALIGNED = { + "lower_profits_limit": lambda pa, brl: pa, + "upper_profits_limit": lambda pa, brl: pa + brl, + "primary_threshold": lambda pa, brl: float(weekly_equivalent(Decimal(repr(pa)))), + "upper_earnings_limit": lambda pa, brl: float( + weekly_equivalent(Decimal(repr(pa + brl))) + ), +} + + +@settings( + max_examples=60, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + last_years=st.fixed_dictionaries( + {name: st.integers(min_value=2024, max_value=2038) for name in NAMES} + ), + rates=st.lists(published_increase.map(float), min_size=64, max_size=64), + horizon=st.integers(min_value=2030, max_value=2045), + bases=st.fixed_dictionaries( + { + "personal_allowance": st.integers(1_000, 2_000).map(lambda n: 10 * n), + "basic_rate_limit": st.integers(300, 450).map(lambda n: 100 * n), + "primary_threshold": st.integers(15_000, 30_000).map(lambda n: n / 100), + "upper_earnings_limit": st.integers(80_000, 120_000).map(lambda n: n / 100), + "secondary_threshold": st.integers(5_000, 20_000).map(lambda n: n / 100), + "lower_profits_limit": st.integers(5_000, 20_000), + "upper_profits_limit": st.integers(40_000, 70_000), + } + ), +) +@example( + # The current freeze: everything stated to 2030, 2% a year. + last_years={name: 2030 for name in NAMES}, + rates=[0.02] * 64, + horizon=2039, + bases=FROZEN_AMOUNTS, +) +def test_indexation_function_on_any_freeze_and_cpi_path( + last_years, rates, horizon, bases +): + root = synthetic_parameters(last_years, rates, horizon, bases) + add_threshold_indexation(root) + years = range(2021, horizon + 3) + values = {year: synthetic_values(root, year) for year in years} + rate = {2011 + i: Decimal(repr(r)) for i, r in enumerate(rates)} + + for name in NAMES: + last = last_years[name] + for year in years: + value = values[year][name] + if year <= last: + # Stated values are unchanged. + assert value == pytest.approx(float(bases[name]), abs=EXACT), ( + name, + year, + ) + elif year <= horizon: + previous = values[year - 1][name] + if name in NICS_ALIGNED: + pa = values[year]["personal_allowance"] + brl = values[year]["basic_rate_limit"] + expected = NICS_ALIGNED[name](pa, brl) + assert value == pytest.approx(expected, abs=EXACT), (name, year) + if year - 1 > last: + assert value >= previous - EXACT, (name, year) + else: + step = { + "personal_allowance": index_personal_allowance, + "basic_rate_limit": index_basic_rate_limit, + "secondary_threshold": index_secondary_threshold, + }[name] + expected = step(Decimal(repr(previous)), rate[year]) + assert value == pytest.approx(float(expected), rel=1e-12), ( + name, + year, + ) + assert value >= previous, (name, year) + else: + # Past the horizon the last value holds. + assert value == values[horizon][name], (name, year) + + # Running again changes nothing. + before = {year: synthetic_values(root, year) for year in years} + add_threshold_indexation(root) + assert {year: synthetic_values(root, year) for year in years} == before + + +def test_a_nics_threshold_stated_above_its_equivalent_realigns(): + # Intended: an LPL stated at £12,571 to 2024-25 realigns to the £12,570 + # personal allowance in 2025-26, even with no CPI rise. + last_years = {name: 2030 for name in NAMES} | {"lower_profits_limit": 2024} + bases = FROZEN_AMOUNTS | {"lower_profits_limit": 12_571} + root = synthetic_parameters(last_years, [0.0] * 64, 2039, bases) + add_threshold_indexation(root) + assert synthetic_values(root, 2024)["lower_profits_limit"] == 12_571 + for year in range(2025, 2041): + assert synthetic_values(root, year)["lower_profits_limit"] == 12_570, year + + +@pytest.mark.parametrize("year", [2030, *INDEXED_YEARS, 2040]) +def test_nested_baseline_reads_the_same_amounts(system, year): + # parameters.baseline is cloned before fiscal-year conversion, so it is + # read at 1 January; indexed values keyed to 1 January give it the same + # amount as the converted gov tree. + nested = thresholds( + system, year, root=system.parameters.baseline.gov, instant=f"{year}-01-01" + ) + assert nested == thresholds(system, year) + + +# Invariant 8: scenarios. + + +def system_with_september_cpi(values): + system = CountryTaxBenefitSystem() + system.reset_parameters() + cpi_september = system.parameters.gov.economic_assumptions.statutory_uprating_inputs.cpi_september + for date, value in values.items(): + cpi_september.update(period=f"year:{date}:1", value=value) + system.process_parameters() + return system + + +def test_a_higher_september_cpi_moves_every_threshold_by_the_statute(): + system = system_with_september_cpi({"2030-09-01": 0.05}) + values = thresholds(system, 2031) + # s57(3): 12,570 x 5% = 628.50, rounded up to 630. s21(3): 37,700 x 1.05 + # = 39,585, rounded up to 39,600. + assert values["personal_allowance"] == 13_200 + assert values["basic_rate_limit"] == 39_600 + assert values["lower_profits_limit"] == 13_200 + assert values["upper_profits_limit"] == 52_800 + assert values["primary_threshold"] == pytest.approx(253.85) # 13,200 / 52 + assert values["upper_earnings_limit"] == pytest.approx(1_015.38) # 52,800 / 52 + assert values["secondary_threshold"] == pytest.approx(5_000 * 1.05 / 52) + + +def test_no_rise_when_september_cpi_falls(): + # s57(2) and s21(1): no indexation unless September CPI is higher. + system = system_with_september_cpi({"2030-09-01": -0.005}) + assert thresholds(system, 2031) == thresholds(system, 2030) + + +def test_a_basic_rate_limit_above_the_additional_rate_threshold_is_kept(): + # 14.2% a year takes the BRL past the fixed £125,140 additional rate + # threshold by 2039; the parameter still follows s21(3). + system = system_with_september_cpi( + {f"{year}-09-01": 0.142 for year in range(2030, 2039)} + ) + for year, (pa, brl, _) in reference_path(system).items(): + values = thresholds(system, year) + assert values["personal_allowance"] == float(pa), year + assert values["basic_rate_limit"] == float(brl), year + assert values["upper_profits_limit"] == float(pa + brl), year + assert thresholds(system, 2039)["basic_rate_limit"] > 125_140 + + +@pytest.mark.parametrize( + "path, name, value", + [ + ( + "gov.hmrc.income_tax.allowances.personal_allowance.amount", + "personal_allowance", + 15_000, + ), + ("gov.hmrc.income_tax.rates.uk[1].threshold", "basic_rate_limit", 40_000), + ], +) +def test_nics_thresholds_follow_a_reprocessed_income_tax_override(path, name, value): + # A scenario's parameter_changes reset and reprocess the parameters, so + # the aligned NICs thresholds follow an override of the PA or BRL. + scenario = Scenario( + parameter_changes={path: {"2031": value}}, applied_before_data_load=True + ) + situation = { + "people": {"person": {"age": {2031: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + system = Simulation(situation=situation, scenario=scenario).tax_benefit_system + assert thresholds(system, 2031)[name] == value + for year in range(2031, 2041): + values = thresholds(system, year) + pa, brl = values["personal_allowance"], values["basic_rate_limit"] + assert values["lower_profits_limit"] == pytest.approx(pa, abs=EXACT), year + assert values["upper_profits_limit"] == pytest.approx(pa + brl, abs=EXACT) + assert abs(52 * values["primary_threshold"] - pa) <= WEEKLY_ROUNDING, year + assert ( + abs(52 * values["upper_earnings_limit"] - (pa + brl)) <= WEEKLY_ROUNDING + ), year + + +def test_thresholds_stay_frozen_without_economic_assumptions(): + situation = { + "people": {"person": {"age": {2025: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": {"household": {"members": ["person"]}}, + } + simulation = Simulation(situation=situation, scenario=no_economic_assumptions) + system = simulation.tax_benefit_system + for year in (2031, 2035, 2040): + assert thresholds(system, year) == thresholds(system, 2030), year + + +# Invariant 9: no double indexing. + + +@pytest.mark.parametrize("name", NAMES) +def test_indexed_parameters_carry_no_uprating(name): + data = yaml.safe_load((PARAMETER_DIR / "gov/hmrc" / PATHS[name]).read_text()) + if name == "basic_rate_limit": + metadata = data["brackets"][1]["threshold"]["metadata"] + else: + metadata = data["metadata"] + assert "uprating" not in metadata + + +# Invariant 10: the September CPI series. + + +@given(raw=st.decimals(min_value="-1", max_value="10", places=6)) +@example(raw=Decimal("0.00049")) # rounds to 0.000: no rise +@example(raw=Decimal("0.0185")) # 0.019 +@example(raw=Decimal("0.0195")) # 0.020 +@example(raw=Decimal("-0.005")) # a fall: no rise +def test_september_cpi_rise_is_the_published_rate_and_never_negative(raw): + rise = september_cpi_uprating_rate(float(raw)) + assert Fraction(repr(rise)) == reference_increase(raw) + + +def test_september_cpi_series_and_index_follow_the_input(system): + economic_assumptions = system.parameters.gov.economic_assumptions + cpi_september = economic_assumptions.statutory_uprating_inputs.cpi_september + rises = economic_assumptions.yoy_growth.september_cpi_uprating + index = economic_assumptions.indices.september_cpi_uprating + for year in range(2011, 2040): + expected = reference_increase(cpi_september(f"{year - 1}-09-01")) + assert Fraction(repr(float(rises(str(year))))) == expected, year + # The index is stored to 5 decimal places. + ratio = float(index(str(year))) / float(index(str(year - 1))) + assert ratio == pytest.approx(1 + float(expected), abs=2e-5), year + + +# End to end: the indexed thresholds reach the tax and NICs calculations. + + +def test_income_tax_and_nics_use_the_indexed_thresholds_in_2031(system): + values = thresholds(system, 2031) + pa = values["personal_allowance"] + hrt = pa + values["basic_rate_limit"] + pt, uel = 52 * values["primary_threshold"], 52 * values["upper_earnings_limit"] + incomes = [pa - 1, pa + 1, hrt - 1, hrt + 1, 60_000.0] + people = { + f"p{i}": { + "age": {2031: 40}, + "employment_income": {2031: income}, + # From 2029 the salary sacrifice cap's broad-base haircut (a + # behavioural assumption) trims employment income before tax. + "salary_sacrifice_broad_base_haircut": {2031: 0}, + "ni_class_1_income": {2031: income}, + "employee_pension_contributions": {2031: 0}, + "employer_pension_contributions": {2031: 0}, + } + for i, income in enumerate(incomes) + } + situation = { + "people": people, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(incomes))}, + "households": { + f"h{i}": {"members": [f"p{i}"], "region": {2031: "LONDON"}} + for i in range(len(incomes)) + }, + } + simulation = Simulation(situation=situation) + income_tax = simulation.calculate("income_tax", 2031) + primary = simulation.calculate("ni_class_1_employee_primary", 2031) + additional = simulation.calculate("ni_class_1_employee_additional", 2031) + for i, income in enumerate(incomes): + expected_tax = 0.2 * min(max(income - pa, 0), hrt - pa) + 0.4 * max( + income - hrt, 0 + ) + expected_primary = 0.08 * min(max(income - pt, 0), uel - pt) + expected_additional = 0.02 * max(income - uel, 0) + assert income_tax[i] == pytest.approx(expected_tax, abs=0.02), income + assert primary[i] == pytest.approx(expected_primary, abs=0.02), income + assert additional[i] == pytest.approx(expected_additional, abs=0.02), income + assert np.all(np.diff(income_tax) >= 0)