From 9387f49258b104a138411c94a22a4c38c86534a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 13:52:37 +0100 Subject: [PATCH 1/9] Derive the UC benefit cap earnings threshold from the national living wage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Universal Credit Regulations 2013 reg. 82(1)(a) sets the earnings exception each year as the pay for 16 hours a week at the national living wage, times 52 and divided by 12, with any fraction of a pound disregarded (reg. 6(1A)(za)). Before 1 April 2017 (SI 2017/138) it was a fixed £430 a month. The model held it at the 2025-26 figure of £10,152 a year for every year. Add uc_benefit_cap_earnings_exception_threshold, read by is_uc_benefit_cap_exempt_earnings, with parameters for the hours and the pre-2017 fixed amount. The threshold is now £9,516 in 2024-25 and £10,572 in 2026-27. Part of #2523; the threshold part of #1986. Co-Authored-By: Claude Opus 5.5 --- ...uc-benefit-cap-earnings-threshold.fixed.md | 1 + .../universal_credit/benefit_cap/README.md | 1 + .../benefit_cap/earnings_exception/README.md | 1 + .../fixed_monthly_amount.yaml | 17 +++ .../benefit_cap/earnings_exception/hours.yaml | 22 ++++ .../claims_as_single_person.yaml | 4 +- ..._cap_earnings_exception_earned_income.yaml | 16 ++- ...efit_cap_earnings_exception_threshold.yaml | 110 ++++++++++++++++++ .../gov/dwp/benefit_cap_by_scheme.yaml | 4 +- .../dwp/universal_credit/uc_trading_loss.yaml | 2 +- .../is_uc_benefit_cap_exempt_earnings.py | 10 +- ...enefit_cap_earnings_exception_threshold.py | 52 +++++++++ 12 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 changelog.d/uc-benefit-cap-earnings-threshold.fixed.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml create mode 100644 policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml create mode 100644 policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py diff --git a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md new file mode 100644 index 0000000000..f453205a73 --- /dev/null +++ b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md @@ -0,0 +1 @@ +- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md new file mode 100644 index 0000000000..e48bb342f4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/README.md @@ -0,0 +1 @@ +# Benefit cap diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md new file mode 100644 index 0000000000..1cd731ff55 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/README.md @@ -0,0 +1 @@ +# Earnings exception diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml new file mode 100644 index 0000000000..5a35f0314c --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/fixed_monthly_amount.yaml @@ -0,0 +1,17 @@ +description: >- + Fixed monthly earnings threshold of the Universal Credit benefit cap's + earnings exception before 1 April 2017. Regulation 82(1)(a) as made lifted + the cap where the claimant's earned income, or a couple's combined earned + income, was at least this amount. From 1 April 2017 the threshold is the pay + for a number of hours a week at the national living wage instead. +values: + 2013-04-29: 430 +metadata: + unit: currency-GBP + period: month + label: Universal Credit benefit cap earnings exception fixed threshold + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a), as made + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82/made + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 reg. 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml new file mode 100644 index 0000000000..29956f0ff4 --- /dev/null +++ b/policyengine_uk/parameters/gov/dwp/universal_credit/benefit_cap/earnings_exception/hours.yaml @@ -0,0 +1,22 @@ +description: >- + Weekly hours at the national living wage that set the earnings threshold of + the Universal Credit benefit cap's earnings exception. Regulation 82(1)(a) + lifts the cap where the claimant's earned income, or a couple's combined + earned income, is at least the pay for these hours at the hourly rate in + regulation 4 of the National Minimum Wage Regulations, converted to a monthly + amount by multiplying by 52 and dividing by 12. Fractions of a pound in that + monthly amount are disregarded (regulation 6(1A)(za)). Before 1 April 2017 the + threshold was a fixed monthly amount instead. +values: + 2017-04-01: 16 +metadata: + unit: hour + period: week + label: Universal Credit benefit cap earnings exception hours + reference: + - title: The Universal Credit Regulations 2013 reg. 82(1)(a) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 + - title: The Universal Credit Regulations 2013 reg. 6(1A)(za) + href: https://www.legislation.gov.uk/uksi/2013/376/regulation/6 + - title: The Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017 regs. 1 and 2 + href: https://www.legislation.gov.uk/uksi/2017/138/made diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml index 66b8c4e5e0..2905d77c7b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/claims_as_single_person.yaml @@ -722,8 +722,8 @@ # "Couple" in the cap includes a single claimant's partner (reg. 78(2)). # 11,000 a year (916.67 a month) exceeds 16 hours at the national living # wage of 12.71 (NMW Regs 2015 reg. 4 from 1 April 2026): - # 12.71 x 16 x 52 / 12 = 881.23 a month. The model uses a fixed - # 10,152 a year; 11,000 clears both. + # 12.71 x 16 x 52 / 12 = 881.23, so 881 a month (UC Regs reg. 6(1A)(za)), + # or 10,572 a year. is_uc_benefit_cap_exempt_earnings: true # Housing Benefit has no earnings exception (HB Regs 2006 regs. # 75E-75F); with no working tax credit the HB cap applies. diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml index ae03b52bdd..11ffa117c5 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/benefit_cap_earnings_exception_earned_income.yaml @@ -5,15 +5,14 @@ # (reg 55(4)), and the deductions are the person's own relievable pension # contributions and the income tax and primary Class 1 contributions paid "in # respect of the employment" (reg 55(5)). Income the minimum income floor -# treats a person as having does not count (reg 82(4)). The threshold is the -# model's 10,152 a year (issue #1986). Unless stated otherwise, each family is +# treats a person as having does not count (reg 82(4)). The 2026 threshold is +# 10,572 a year (881 a month; issue #2523). Unless stated otherwise, each family is # a single claimant aged 40 outside London with an entered award of 18,000 # before the cap, so the cap of 14,753 takes 3,247 unless an exception applies. # Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/55 # https://www.legislation.gov.uk/uksi/2013/376/regulation/82 # https://www.gov.uk/guidance/rates-and-thresholds-for-employers-2026-to-2027 -# The existing model threshold is retained here; 11,000 and 10,000 also lie -# above and below the 2026 NLW-based threshold of 10,574.72 respectively. +# 11,000 and 10,000 lie above and below it. - name: Statutory sick pay is earned income for the earnings exception period: 2026 @@ -59,7 +58,7 @@ region: NORTH_EAST output: # 3,000 + 8,000 = 11,000, under the personal allowance and the primary - # threshold, so no tax or NI: 11,000 >= 10,152. Without the maternity + # threshold, so no tax or NI: 11,000 >= 10,572. Without the maternity # pay the wages alone, 3,000, are under the threshold. uc_individual_earned_income_before_mif: 11_000 is_uc_benefit_cap_exempt_earnings: true @@ -88,7 +87,7 @@ # covered by the personal allowance, leaving all the tax on the pension. # Reg 55(5)(b) requires tax in respect of employment to be deducted but # does not prescribe this allocation between wages and pension income. - # No NI on 11,000. 11,000 >= 10,152. Netting the 1,686 off the wages + # No NI on 11,000. 11,000 >= 10,572. Netting the 1,686 off the wages # would give 9,314, under the threshold. income_tax: 1_686 uc_income_tax_on_earnings: 0 @@ -114,7 +113,7 @@ region: NORTH_EAST output: # Reg 55(5)(a): 11,000 - 1,000 = 10,000, with no tax or NI, is under - # 10,152. The cap applies: 18,000 - 14,753 = 3,247. + # 10,572. The cap applies: 18,000 - 14,753 = 3,247. uc_individual_earned_income_before_mif: 10_000 is_uc_benefit_cap_exempt_earnings: false is_uc_benefit_cap_exempt: false @@ -158,8 +157,7 @@ region: NORTH_EAST output: # The reform includes basic income in UC earned income, so the shared - # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,152. - # The total also exceeds #1820's 2026 NLW-based earnings threshold. + # expression in #1820 also counts it here: 5,500 + 5,500 >= 10,572. # Counting basic income in this test is new in this PR: main's formula # never counted it. Whether reg 82 should count it is an open question # for #1986 and #1820. The award of 25,000 is above the couple cap diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml new file mode 100644 index 0000000000..17e305f5e4 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap/uc_benefit_cap_earnings_exception_threshold.yaml @@ -0,0 +1,110 @@ +# The Universal Credit benefit cap's earnings threshold (UC Regs 2013 reg +# 82(1)(a)). From 1 April 2017 (SI 2017/138) it is the pay for 16 hours a week +# at the national living wage (NMW Regs 2015 reg 4), times 52 and divided by +# 12, with any fraction of a pound disregarded (reg 6(1A)(za)). Before then it +# was a fixed 430 a month. The model compares a year's earned income with +# twelve times the monthly threshold. +# Sources: https://www.legislation.gov.uk/uksi/2013/376/regulation/82 +# https://www.legislation.gov.uk/uksi/2013/376/regulation/6 +# https://www.legislation.gov.uk/uksi/2017/138/made +# +# The boundary cases are a single claimant aged 40 outside London with an +# entered award of 18,000 before the cap. Their earnings are under the +# personal allowance and the primary threshold, so no tax or NI comes off, and +# the cap of 14,753 takes 3,247 unless the exception applies. + +- name: Before April 2017 the threshold is a fixed 430 a month + period: 2016 + output: + # 430 x 12 = 5,160. + uc_benefit_cap_earnings_exception_threshold: 5_160 + +- name: The threshold in 2017-18 is 16 hours at the 7.50 national living wage + period: 2017 + output: + # 16 x 7.50 x 52 / 12 = 520.00 a month; x 12 = 6,240. + uc_benefit_cap_earnings_exception_threshold: 6_240 + +- name: The threshold in 2023-24 is 16 hours at the 10.42 national living wage + period: 2023 + output: + # 16 x 10.42 x 52 / 12 = 722.45, disregarding the fraction 722 a month; + # x 12 = 8,664. + uc_benefit_cap_earnings_exception_threshold: 8_664 + +- name: The threshold in 2024-25 is 16 hours at the 11.44 national living wage + period: 2024 + output: + # 16 x 11.44 x 52 / 12 = 793.17, so 793 a month; x 12 = 9,516. + uc_benefit_cap_earnings_exception_threshold: 9_516 + +- name: The threshold in 2025-26 is 16 hours at the 12.21 national living wage + period: 2025 + output: + # 16 x 12.21 x 52 / 12 = 846.56, so 846 a month; x 12 = 10,152. + uc_benefit_cap_earnings_exception_threshold: 10_152 + +- name: The threshold in 2026-27 is 16 hours at the 12.71 national living wage + period: 2026 + output: + # 16 x 12.71 x 52 / 12 = 881.23, so 881 a month; x 12 = 10,572. + uc_benefit_cap_earnings_exception_threshold: 10_572 + +- name: Earnings equal to the 2024-25 threshold lift the cap + period: 2024 + input: + age: 40 + employment_income: 9_516 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 9_516 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings a pound under the 2024-25 threshold leave the cap in place + period: 2024 + input: + age: 40 + employment_income: 9_515 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 + +- name: Earnings between the 2024-25 threshold and the 2025-26 figure lift the cap in 2024-25 + period: 2024 + input: + age: 40 + employment_income: 10_000 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,000 is at least 9,516. A threshold of 10,152 would leave the cap. + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings equal to the 2026-27 threshold lift the cap + period: 2026 + input: + age: 40 + employment_income: 10_572 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + uc_individual_earned_income_before_mif: 10_572 + is_uc_benefit_cap_exempt_earnings: true + benefit_cap_reduction: 0 + +- name: Earnings between the 2025-26 figure and the 2026-27 threshold leave the cap in 2026-27 + period: 2026 + input: + age: 40 + employment_income: 10_571 + universal_credit_pre_benefit_cap: 18_000 + region: NORTH_EAST + output: + # 10,571 is under 10,572, although it is above 10,152. + is_uc_benefit_cap_exempt_earnings: false + benefit_cap_reduction: 3_247 diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml index 0f37b40de8..53e746bdf2 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/benefit_cap_by_scheme.yaml @@ -89,8 +89,8 @@ working_tax_credit: 0 output: # 2025-26: income tax 20% x (15,000 - 12,570) = 486; Class 1 NI 8% x - # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the model's - # 10,152 a year (846 a month). + # (15,000 - 12,570) = 194.40; earned income 14,319.60, above the 2025-26 + # threshold of 10,152 a year (846 a month). is_uc_benefit_cap_exempt_earnings: true is_uc_benefit_cap_exempt: true is_housing_benefit_benefit_cap_exempt: false diff --git a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml index 9bb761603c..7627060033 100644 --- a/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/dwp/universal_credit/uc_trading_loss.yaml @@ -104,7 +104,7 @@ uc_trading_losses_set_against_profits: 5_000 uc_individual_earned_income_before_mif: 7_000 uc_individual_earned_income: 20_000 - # Actual £7,000 is below £10,152, even though the MIF is £20,000. + # Actual £7,000 is below £10,572, even though the MIF is £20,000. is_uc_benefit_cap_exempt_earnings: false - name: Company earnings without the loss exceed the cap threshold with the same deductions diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py index ec478db213..872611b651 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/is_uc_benefit_cap_exempt_earnings.py @@ -7,7 +7,9 @@ class is_uc_benefit_cap_exempt_earnings(Variable): label = "Exempt from the Universal Credit benefit cap because of earnings" documentation = ( "Whether the claimant's earned income, or a couple's combined earned " - "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a)). " + "income, reaches the earnings threshold (UC Regs 2013 reg. 82(1)(a), " + "uc_benefit_cap_earnings_exception_threshold), which follows the " + "national living wage each year. " "The measure is the assessed claimants' actual net earned income for " "Universal Credit: it includes statutory pay and deducts their own " "relievable pension contributions and tax and National Insurance on " @@ -27,7 +29,6 @@ class is_uc_benefit_cap_exempt_earnings(Variable): def formula(benunit, period, parameters): person = benunit.members - # Earnings exemption for UC (£846/month = £10,152/year) # Only check earned income, not the UC amount, to avoid a circular dependency. # UC Regs 2013 reg. 82(1)(a) tests "the claimant's earned income or, # if the claimant is a member of a couple, the couple's combined @@ -39,5 +40,6 @@ def formula(benunit, period, parameters): uc_earned = benunit.sum( person("uc_individual_earned_income_before_mif", period) * claimant ) - earnings_threshold = 10_152 - return uc_earned >= earnings_threshold + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py new file mode 100644 index 0000000000..e71be2a417 --- /dev/null +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py @@ -0,0 +1,52 @@ +from policyengine_uk.model_api import * + + +class uc_benefit_cap_earnings_exception_threshold(Variable): + value_type = float + entity = BenUnit + label = "Universal Credit benefit cap earnings exception threshold" + documentation = ( + "The earned income over the year at or above which the Universal " + "Credit benefit cap does not apply (UC Regs 2013 reg. 82(1)(a)): " + "twelve times the monthly threshold. From 1 April 2017 the monthly " + "threshold is the pay for 16 hours a week at the national living " + "wage, the hourly rate in regulation 4 of the National Minimum Wage " + "Regulations, multiplied by 52 and divided by 12, with any fraction " + "of a pound disregarded (reg. 6(1A)(za)). Before then it was a fixed " + "£430 a month." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Universal Credit Regulations 2013 reg. 82(1)(a)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/82", + ), + dict( + title="Universal Credit Regulations 2013 reg. 6(1A)(za)", + href="https://www.legislation.gov.uk/uksi/2013/376/regulation/6", + ), + dict( + title="Universal Credit (Benefit Cap Earnings Exception) Amendment Regulations 2017", + href="https://www.legislation.gov.uk/uksi/2017/138/made", + ), + ] + + def formula(benunit, period, parameters): + exception = parameters( + period + ).gov.dwp.universal_credit.benefit_cap.earnings_exception + return exception.fixed_monthly_amount * MONTHS_IN_YEAR + + def formula_2017(benunit, period, parameters): + p = parameters(period) + exception = p.gov.dwp.universal_credit.benefit_cap.earnings_exception + # "The hourly rate set out in regulation 4" is the national living + # wage, the scale's top rate, as in uc_minimum_income_floor_gross. + national_living_wage = p.gov.hmrc.minimum_wage.non_apprentice.calc( + np.full(benunit.count, 200.0) + ) + monthly = np.floor( + national_living_wage * exception.hours * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + return monthly * MONTHS_IN_YEAR From 38cc9c44705569e1a78bef3c65ac7f95dd736fdf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:36:18 +0100 Subject: [PATCH 2/9] Give the old-rules reference the new earnings threshold, and pin a family in the gap The differential test's reference earnings exception still compared with a fixed 10,152 in 2026, so a working-age family earning between 10,152 and 10,572 would fail the invariant on the threshold, not the scheme split; the fixed examples happened to miss that range. The reference now reads uc_benefit_cap_earnings_exception_threshold, and a single claimant earning 10,400 joins the explicit examples. Co-Authored-By: Claude Opus 5.5 --- ...benefit_cap_lha_scheme_split_properties.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py index bc505ec5de..5976d26204 100644 --- a/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py +++ b/policyengine_uk/tests/test_benefit_cap_lha_scheme_split_properties.py @@ -11,7 +11,8 @@ benefits. ``REFERENCE`` re-adds those formulas from the parent commit under ``reference_`` names, so one simulation computes the old and the new values for the same families. They are verbatim except for the names they -read, the inlined stopgap helpers and the earnings exception's dead code. +read, the inlined stopgap helpers, the earnings exception's dead code and its +threshold, which now follows the national living wage (#2523). The reference awards read the current ``uc_deductions``, which these single-household simulations leave at nil. @@ -189,7 +190,11 @@ def formula(benunit, period, parameters): - benunit.members("income_tax", period) - benunit.members("national_insurance", period) ) - return uc_earned >= 10_152 + # The old rule's fixed 10,152 is replaced by the current threshold + # (#2523), so this compares the split, not the threshold. + return uc_earned >= benunit( + "uc_benefit_cap_earnings_exception_threshold", period + ) class reference_is_benefit_cap_exempt(Variable): @@ -523,6 +528,9 @@ def example_families(): region="LONDON", benunit_inputs=dict(income_support=24_000.0), ), + # A single claimant earning between the 2025-26 and 2026-27 earnings + # thresholds (10,152 and 10,572): no earnings exception in 2026. + family(adult_inputs(40, employment_income=10_400.0)), ] @@ -845,7 +853,8 @@ def test_examples_reach_the_cases(): mixed_age, hb_lcwra, hb_floor, - ) = range(8) + earner_in_gap, + ) = range(9) # Reg. 3(3): single for UC, a couple for HB, whatever the legacy claim. for i in (uc_family, legacy_family): assert v["uc_member_of_couple_claims_as_single_person"][i] @@ -903,3 +912,7 @@ def test_examples_reach_the_cases(): ) assert abs(v["housing_benefit"][hb_floor] - 0.5 * 52) < 0.01 assert v["reference_housing_benefit"][hb_floor] == 0 + # Earnings of 10,400 are under 2026's threshold of 10,572 (#2523), and + # the reference reads the same threshold, so neither is exempt. + assert not v["is_uc_benefit_cap_exempt"][earner_in_gap] + assert not v["reference_is_benefit_cap_exempt"][earner_in_gap] From 7bb21efd3ac8a84ff28733f5f0b3911926ac65a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:07:38 +0100 Subject: [PATCH 3/9] Use one relevant-earnings rule for pension relief and adjusted total income adjusted_total_income gave contributions relief up to its own earnings figure (taxable employment income plus trading profits before losses brought forward), while pension_contributions_relief and the annual allowance tests used pay before expenses plus trading profit before capital allowances and the trading allowance. Finance Act 2004 s. 189(2) counts employment income chargeable to income tax, here pay and statutory pay less allowable expenses but before the pension deductions the relief itself gives, and income chargeable under ITTOIA 2005 Part 2, which is after capital allowances and the trading allowance and before losses. Move that definition into utils/pensions.py (relevant_uk_earnings and trading_profits_before_losses) and use it, with pension_contributions_relief_amount, in all four places. The relief-at-source limit on the basic amount is unchanged from #2488. Part of #2523; follows #2488. Co-Authored-By: Claude Opus 5.5 --- changelog.d/relevant-uk-earnings.fixed.md | 1 + docs/engineering/pension-annual-allowance.md | 8 ++ .../hmrc/pensions/relevant_uk_earnings.yaml | 74 +++++++++++++++++++ ...est_pension_annual_allowance_properties.py | 11 ++- policyengine_uk/utils/pensions.py | 61 ++++++++------- .../reliefs/adjusted_total_income.py | 56 +++++--------- .../pensions/pension_contributions_relief.py | 8 +- 7 files changed, 152 insertions(+), 67 deletions(-) create mode 100644 changelog.d/relevant-uk-earnings.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml diff --git a/changelog.d/relevant-uk-earnings.fixed.md b/changelog.d/relevant-uk-earnings.fixed.md new file mode 100644 index 0000000000..7578cc84af --- /dev/null +++ b/changelog.d/relevant-uk-earnings.fixed.md @@ -0,0 +1 @@ +- Use one definition of relevant UK earnings (Finance Act 2004 s. 189(2)) for pension contribution relief, the annual allowance income tests and the pension deduction in adjusted total income: employment income after allowable expenses but before pension deductions, and trading profits after capital allowances and the trading allowance but before losses brought forward. Relief previously counted pay before expenses and trading profits before capital allowances and the trading allowance. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index ac881ae9de..14977e2b09 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -76,6 +76,14 @@ net pay. Threshold income deducts [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. +Ordinary pension relief, the ordinary `adjusted_total_income` and both income +tests take relevant UK earnings +([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)) from +one helper, `relevant_uk_earnings`: employment income chargeable to income +tax before deductions for pension contributions (pay and employer-paid taxable +statutory pay, less allowable expenses), and trading profits after capital +allowances and the trading allowance but before losses brought forward. + An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on actual contributions and earnings. It does not remove an actual supplied diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml new file mode 100644 index 0000000000..434ab77203 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml @@ -0,0 +1,74 @@ +# Relevant UK earnings (FA 2004 s.189(2)) set the annual limit on relief for a +# person's own pension contributions (s.190). They are employment income +# chargeable to income tax, after allowable expenses but before the pension +# deductions this relief gives, and trading income chargeable under ITTOIA +# 2005 Part 2, which is after capital allowances (CAA 2001 s.247) and the +# trading allowance (ITTOIA 2005 s.783AF) but before relief for losses brought +# forward (ITA 2007 s.83, a Step 2 deduction). pension_contributions_relief and +# the pension deduction in adjusted_total_income (ITA 2007 s.24A(8)) use the +# same figure. Each case is a person aged 45 in England with no other income. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/189 +# https://www.legislation.gov.uk/ukpga/2001/2/section/247 +# https://www.legislation.gov.uk/ukpga/2005/5/section/783AF + +- name: Capital allowances reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + capital_allowances: 4_000 + personal_pension_contributions: 9_000 + output: + # Part 2 profit is 10,000 - 4,000 = 6,000, above the 3,600 basic amount, + # so relief stops at 6,000 of the 9,000 paid. + pension_contributions_relief: 6_000 + # Total income 6,000 less the same 6,000 of relief. + adjusted_total_income: 0 + +- name: Allowable employment expenses reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 20_000 + employment_expenses: 5_000 + employee_pension_contributions_reported: 18_000 + output: + # Earnings after the 5,000 of expenses are 15,000, so a net pay + # arrangement relieves 15,000 of the 18,000 paid. + taxable_employment_income: 15_000 + pension_contributions_relief: 15_000 + adjusted_total_income: 0 + +- name: Losses brought forward do not reduce relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + self_employment_income: 10_000 + loss_relief: 6_000 + personal_pension_contributions: 8_000 + output: + # The loss reduces taxable trading income to 4,000 but not the Part 2 + # profit of 10,000, so all 8,000 is relieved. + taxable_self_employment_income: 4_000 + pension_contributions_relief: 8_000 + # Total income adds the 6,000 of brought-forward loss back: 10,000 less + # 8,000 of relief. + adjusted_total_income: 2_000 + +- name: A profit within the trading allowance is not relevant UK earnings + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 1_000 + self_employment_income: 800 + employee_pension_contributions_reported: 1_500 + output: + # The trading allowance fully relieves the 800 profit, so relevant UK + # earnings are the 1,000 of pay. Net pay has no basic-amount increase + # (s.191(7)), so relief stops at 1,000. + taxable_self_employment_income: 0 + pension_contributions_relief: 1_000 diff --git a/policyengine_uk/tests/test_pension_annual_allowance_properties.py b/policyengine_uk/tests/test_pension_annual_allowance_properties.py index 4df2f1f149..2e6c991df9 100644 --- a/policyengine_uk/tests/test_pension_annual_allowance_properties.py +++ b/policyengine_uk/tests/test_pension_annual_allowance_properties.py @@ -164,8 +164,17 @@ def _situation(people, year, overrides=None): return situation +# Without receipts supplied, the model reads a trading profit within the +# trading allowance (£1,000 from 2017-18, so in every year here) as fully +# relieved (ITTOIA 2005 s. 783AF). It is then not chargeable under Part 2 and +# so is not relevant UK earnings (FA 2004 s. 189(2)(b)). +TRADING_ALLOWANCE = 1_000 + + def _relevant_earnings(person): - return person["employment_income"] + max(0, person["self_employment_income"]) + profit = max(0, person["self_employment_income"]) + chargeable_profit = profit if profit > TRADING_ALLOWANCE else 0 + return person["employment_income"] + chargeable_profit def _with_contributions(people, contributions): diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 0d052156e3..41efaf46aa 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -3,16 +3,38 @@ from policyengine_uk.utils.supplied_inputs import supplied_input -def relevant_uk_earnings(person, period): - """FA 2004 s.189 earnings, including employer-paid taxable SSP/SMP. +def trading_profits_before_losses(person, period, hmrc): + """Trading profits chargeable under ITTOIA 2005 Part 2, before losses. - Statutory employment pay is held separately from wages. A trading loss - does not reduce those employment earnings (HMRC PTM044100). + Capital allowances are a trading expense (CAA 2001 s.247) and the trading + allowance replaces expenses (ITTOIA 2005 s.783AC), so both come off. + Losses brought forward (ITA 2007 s.83) are a Step 2 relief, which the + model deducts from trading profits (loss_relief); they stay in here. """ - return ( - person("employment_income", period) - + person("employment_benefits", period) - + np.maximum(0, person("self_employment_income", period)) + from policyengine_core.model_api import add + + deductions = [ + deduction + for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions + if deduction != "loss_relief" + ] + return np.maximum( + 0, person("self_employment_income", period) - add(person, period, deductions) + ) + + +def relevant_uk_earnings(person, period, hmrc): + """FA 2004 s.189(2) relevant UK earnings. + + Employment income chargeable to income tax, before the deductions for + pension contributions that this relief itself gives: pay and + employer-paid taxable SSP/SMP, which the model holds separately from + wages, less allowable expenses. Trading income is the profit chargeable + under ITTOIA 2005 Part 2, before losses. + A trading loss does not reduce employment earnings (HMRC PTM044100). + """ + return person("taxable_employment_income", period) + ( + trading_profits_before_losses(person, period, hmrc) ) @@ -46,11 +68,9 @@ def annual_allowance_net_income(person, period, parameters): relievable net pay. A supplied legacy return is actual pay/member payment; the computed NICs-only excess never enters income or contributions. """ - from policyengine_core.model_api import add - hmrc = parameters(period).gov.hmrc actual_member_contributions = person("pension_contributions", period) - actual_earnings = relevant_uk_earnings(person, period) + actual_earnings = relevant_uk_earnings(person, period, hmrc) supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. @@ -76,29 +96,18 @@ def annual_allowance_net_income(person, period, parameters): if not np.any((relievable_net_pay > 0) & (person("trading_loss", period) > 0)): return net_income - actual_taxable_pay = person("taxable_employment_income", period) income_before_loss = np.maximum( 0, person("net_income_before_trade_loss_relief", period) ) # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member # contributions, retaining its treatment of brought-forward trade losses # and pension relief. Brought-forward losses only reduce trading profits. - other_deductions = [ - deduction - for deduction in hmrc.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = np.maximum( - 0, - person("self_employment_income", period) - - add(person, period, other_deductions), - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) relieved_contributions = pension_contributions_relief_amount( actual_member_contributions, - actual_taxable_pay + profits_before_carry_forward, + actual_earnings, person("personal_pension_contributions", period), person("age", period), hmrc, diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py index 810a99cb83..0d4f98dd91 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/reliefs/adjusted_total_income.py @@ -1,4 +1,9 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import ( + pension_contributions_relief_amount, + relevant_uk_earnings, + trading_profits_before_losses, +) class adjusted_total_income(Variable): @@ -12,8 +17,9 @@ class adjusted_total_income(Variable): "relief the model deducts from trading profits) added back. Steps 3 " "and 4 deduct the pension contributions given relief (FA 2004 " "ss.192-194): the person's own contributions up to relevant UK " - "earnings (s.189(2): taxable employment income, including benefits " - "and statutory pay, and trading profits). The basic amount can " + "earnings (s.189(2): employment income, including statutory pay, " + "less allowable expenses but before pension deductions, and trading " + "profits before losses). The basic amount can " "increase that limit only through relief at source (ss.190(3), " "191(7)), which the model represents with gross personal pension " "contributions; employee contributions represent net pay. None is relieved from " @@ -39,45 +45,21 @@ class adjusted_total_income(Variable): ] def formula(person, period, parameters): - p = parameters(period).gov.hmrc - profits = person("self_employment_income", period) - other_deductions = [ - deduction - for deduction in p.income_tax.bases.taxable_self_employment_income_deductions - if deduction != "loss_relief" - ] - profits_before_carry_forward = max_( - 0, profits - add(person, period, other_deductions) - ) - losses_brought_forward_deducted = profits_before_carry_forward - person( - "taxable_self_employment_income", period - ) + hmrc = parameters(period).gov.hmrc + losses_brought_forward_deducted = trading_profits_before_losses( + person, period, hmrc + ) - person("taxable_self_employment_income", period) total_income = ( person("net_income_before_trade_loss_relief", period) + losses_brought_forward_deducted ) - # Relevant UK earnings (FA 2004 s.189(2)): employment income chargeable - # to tax, including taxable benefits and statutory payments, and - # trading profits chargeable to tax. - relevant_earnings = ( - person("taxable_employment_income", period) + profits_before_carry_forward - ) - # FA 2004 ss.190(3),191(7): only actual relief-at-source contributions - # can use the basic-amount increase above relevant UK earnings. - basic_amount_increase = min_( - max_( - 0, - p.income_tax.reliefs.pension_contribution.basic_amount - - relevant_earnings, - ), + # The same relief as pension_contributions_relief, on the same + # relevant UK earnings (FA 2004 ss.189-191). + relieved_contributions = pension_contributions_relief_amount( + person("pension_contributions", period), + relevant_uk_earnings(person, period, hmrc), person("personal_pension_contributions", period), - ) - relief_limit = relevant_earnings + basic_amount_increase - under_age_limit = ( - person("age", period) < p.pensions.pension_contributions_relief_age_limit - ) - relieved_contributions = ( - min_(person("pension_contributions", period), relief_limit) - * under_age_limit + person("age", period), + hmrc, ) return max_(0, total_income - relieved_contributions) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py index d9afcef026..3ba60f7cd9 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_relief.py @@ -17,8 +17,10 @@ class pension_contributions_relief(Variable): "above earnings (s. 191(7)); the model takes personal pension " "contributions as relief at source and employee contributions as a " "net pay arrangement. The annual allowance does not limit relief. " - "Relevant employment earnings include employer-paid taxable statutory " - "pay through employment_benefits. " + "Relevant UK earnings (s. 189(2)) are employment income, including " + "employer-paid taxable statutory pay, less allowable expenses but " + "before pension deductions, and trading profits after capital " + "allowances and the trading allowance but before losses. " "Contributions above it can be relieved here and separately attract " "the annual allowance charge (personal_pension_contributions_tax) " "under s. 227. The charge need not equal their tax relief." @@ -53,7 +55,7 @@ def formula(person, period, parameters): contributions = person("pension_contributions", period) return pension_contributions_relief_amount( contributions, - relevant_uk_earnings(person, period), + relevant_uk_earnings(person, period, hmrc), person("personal_pension_contributions", period), person("age", period), hmrc, From 628a51d1fdb462037d739d6facc5cdd07d83a8ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:37:12 +0100 Subject: [PATCH 4/9] Uprate water and sewerage charges by country Ofwat's water bills series is the average household bill in England and Wales. Scottish Water sets Scotland's household charges within limits from the Water Industry Commission for Scotland, and they followed a different path: 8.8% in 2024-25, 9.9% in 2025-26 against Ofwat's 26%, and 8.67% in 2026-27. Northern Ireland has no domestic water charges. Add gov.economic_assumptions.yoy_growth.scottish_water.water_bills, with WICS's draft CPI + 2% cap for 2027-28 to 2029-30 until the Final Determination (29 October 2026), and uprate water_and_sewerage_charges by country in apply_single_year_uprating, as council tax is: England, Wales and unknown regions by Ofwat's series, Scotland by Scottish Water's, Northern Ireland not at all. The column was previously held flat to 2030 and then grown by the England and Wales index everywhere. It is kept out of the post-2030 fallback, reset with the other growth-factor inputs, and covered by the projection fingerprint. Fixes #2501. Part of #2523. Overlaps #1863, which lists the column under the Ofwat index: whichever lands second drops that line, and test_water_charges_are_uprated_once fails until it does. Co-Authored-By: Claude Opus 5.5 --- changelog.d/scottish-water-uprating.fixed.md | 1 + docs/book/assumptions/water-bills.md | 23 ++ policyengine_uk/data/economic_assumptions.py | 63 +++++ .../gov/economic_assumptions/yoy_growth.yaml | 41 +++ .../tests/test_projection_horizon.py | 31 ++- policyengine_uk/tests/test_water_uprating.py | 237 ++++++++++++++++++ 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 changelog.d/scottish-water-uprating.fixed.md create mode 100644 policyengine_uk/tests/test_water_uprating.py diff --git a/changelog.d/scottish-water-uprating.fixed.md b/changelog.d/scottish-water-uprating.fixed.md new file mode 100644 index 0000000000..4b15413462 --- /dev/null +++ b/changelog.d/scottish-water-uprating.fixed.md @@ -0,0 +1 @@ +- Uprate water and sewerage charges by country when a dataset is projected: England and Wales by Ofwat's average bills, Scotland by a new Scottish Water series (8.8% in 2024-25, 9.9% in 2025-26 and 8.67% in 2026-27, then the CPI + 2% cap in WICS's draft 2027-33 determination), and Northern Ireland, which has no domestic water charges, not at all. The charges were previously held flat to 2030 and then grown by the England and Wales series everywhere. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index bdc0027549..fce3e461a0 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -81,3 +81,26 @@ The model uses these year-on-year growth rates. Each year is the charging year s ```{warning} The 2027-2029 projections cannot be reproduced from the repository. They take the CCW table, which reflects Ofwat's PR24 *draft* determinations rather than the final ones, weight each company by customer numbers, and add a hardcoded CPIH path. The customer weights were in `ofwat_increases.csv`, which is gitignored and was never committed. ``` + +## Scotland and Northern Ireland + +Ofwat's bills cover England and Wales. When a dataset is projected forward, each household's `water_and_sewerage_charges` grows by its own country's series (`uprate_water_and_sewerage_charges` in `policyengine_uk/data/economic_assumptions.py`): + +- England and Wales, and households with an unknown region, take `ofwat.water_bills`. +- Scotland takes `scottish_water.water_bills`. Scottish Water sets household charges within limits from the [Water Industry Commission for Scotland](https://wics.scot/) (WICS), and every council tax band's charge is a fixed fraction of Band D, so each band rises by the same percentage. +- Northern Ireland has no domestic water charges, so any charge a dataset gives its households is held flat. + +| Year | Scottish Water growth | Source | +|------|-------------|--------| +| 2022 | 4.2% | [WICS, charges scheme 2022-23](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23) | +| 2023 | 5.0% | [WICS, charges scheme 2023-24](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24) | +| 2024 | 8.8% | [WICS, charges scheme 2024-25](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25) | +| 2025 | 9.9% | [Scottish Water, 28 January 2025](https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement) | +| 2026 | 8.67% | [Scottish Water, 22 January 2026](https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026) | +| 2027 | 3.9% | WICS Draft Determination 2027-33: CPI + 2%, with WICS's CPI assumption of 1.9% | +| 2028 | 3.9% | As above | +| 2029 | 4.1% | As above, with CPI at 2.1% | + +Each year is the charging year starting in April, so 2025 is 2025-26. Later years hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. + +`policyengine_uk/utils/create_ahc_deflator.py` still reads the England and Wales index. It is a standalone script that the model does not run. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index ce9419b944..6a665b65bf 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -56,6 +56,26 @@ "UNKNOWN": "england", } +# The growth series (under gov.economic_assumptions.yoy_growth) of each Region +# label's water and sewerage charges (#2501). Ofwat's series is the average +# household bill in England and Wales. Scottish Water's charges follow limits +# set by the Water Industry Commission for Scotland. Northern Ireland has no +# domestic water charges, so any a dataset gives its households are held flat. +# An unknown region takes the England and Wales series, which covers most +# households in Great Britain. +WATER_BILLS_GROWTH = "gov.economic_assumptions.yoy_growth" +_WATER_GROWTH_SERIES = { + **dict.fromkeys(_ENGLAND_REGIONS, "ofwat.water_bills"), + "WALES": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "NORTHERN_IRELAND": None, + "UNKNOWN": "ofwat.water_bills", +} +# Inputs uprated by country in this module rather than through +# uprating_indices.yaml, so the index their variable declares must not grow +# them a second time after LEGACY_PROJECTION_END_YEAR. +COUNTRY_UPRATED_INPUTS = frozenset({"water_and_sewerage_charges"}) + # The last year a single-year dataset is projected to: 2032, fiscal year # 2032-33. An OBR forecast runs five years past the fiscal year it is made in, @@ -102,6 +122,7 @@ def _unmapped_uprating_indices(uprating): if variable.uprating is not None and name not in mapped and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } @@ -133,9 +154,11 @@ def uprating_input_paths() -> list: """ uprating = _uprating_index_map() series = {value for value in _COUNCIL_TAX_GROWTH_SERIES.values() if value} + water_series = {value for value in _WATER_GROWTH_SERIES.values() if value} return sorted( set(uprating) | {f"{COUNCIL_TAX_GROWTH}.{name}" for name in series} + | {f"{WATER_BILLS_GROWTH}.{name}" for name in water_series} | { SOCIAL_RENT_GROWTH, PRIVATE_RENT_GROWTH, @@ -290,6 +313,12 @@ def apply_single_year_uprating( current_year = uprate_council_tax(current_year, previous_year, parameters) + # Water and sewerage charges are uprated by country. + + current_year = uprate_water_and_sewerage_charges( + current_year, previous_year, parameters + ) + # Rent is uprated by OBR forecasts/outturns by region. current_year = uprate_rent(current_year, previous_year, parameters) @@ -393,6 +422,39 @@ def uprate_council_tax( return current_year +def uprate_water_and_sewerage_charges( + current_year: UKSingleYearDataset, + previous_year: UKSingleYearDataset, + parameters: ParameterNode, +): + # Uprate water and sewerage charges for a single year dataset, by country. + + if "water_and_sewerage_charges" not in previous_year.household.columns: + return current_year + region = pd.Series(np.asarray(current_year.household["region"]).astype(str)) + unrecognised = set(region) - set(_WATER_GROWTH_SERIES) + if unrecognised: + raise ValueError( + "Cannot uprate water and sewerage charges for unrecognised regions: " + f"{sorted(unrecognised)}" + ) + growth = parameters.get_child(WATER_BILLS_GROWTH) + growth_by_region = {} + for name, series in _WATER_GROWTH_SERIES.items(): + rate = ( + None + if series is None + else growth.get_child(series)(current_year.time_period) + ) + growth_by_region[name] = 0.0 if rate is None else rate + growth_rates = region.map(growth_by_region).to_numpy() + + current_year.household["water_and_sewerage_charges"] = previous_year.household[ + "water_and_sewerage_charges" + ] * (1 + growth_rates) + return current_year + + def uprate_rent( current_year: UKSingleYearDataset, previous_year: UKSingleYearDataset, @@ -587,6 +649,7 @@ def reset_growthfactor_uprating( variable for variables in uprating.values() for variable in variables } growthfactor_uprated_variables.update({"council_tax", "rent"}) + growthfactor_uprated_variables.update(COUNTRY_UPRATED_INPUTS) growthfactor_uprated_variables.update(_unmapped_uprating_indices(uprating)) first_year = min(dataset.datasets.keys()) diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 7b34cae696..7f6e8f775c 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1252,6 +1252,47 @@ ofwat: href: https://www.ofwat.gov.uk/average-bills-2025-26-press-statement/ - title: CCW, "Breakdown of water companies' bill increases 2025-30" (Ofwat PR24 draft determinations) href: https://www.ccw.org.uk/our-work/price-review/how-much-will-my-water-and-sewerage-bills-increase-by-2030/breakdown-of-water-companies-bill-increases-2025-30/ +scottish_water: + water_bills: + description: Year-on-year growth in Scottish Water's household water and sewerage charges. + # Scottish Water sets Scotland's household charges within limits from the + # Water Industry Commission for Scotland (WICS); Ofwat's series covers + # England and Wales only. Each value is the charging year starting in + # April, so 2025-01-01 holds 2025-26. + # - 2022-2026: the increase in household charges that Scottish Water + # announced and WICS approved for each year. Every council tax band's + # charge is a fixed fraction of Band D, so each band rises by the same + # percentage. + # - 2027-2029: the cap in WICS's Draft Determination for 2027-33 + # (consultation 30 June to 1 September 2026), CPI + 2% a year, with + # WICS's CPI assumption of 1.9%, 1.9% and 2.1%. WICS publishes the + # Final Determination on 29 October 2026; replace these with it. + # Later years hold the 2029-30 rate. + values: + 2022-01-01: 0.042 + 2023-01-01: 0.05 + 2024-01-01: 0.088 + 2025-01-01: 0.099 + 2026-01-01: 0.0867 + 2027-01-01: 0.039 + 2028-01-01: 0.039 + 2029-01-01: 0.041 + metadata: + unit: /1 + label: Scottish Water household charges growth + reference: + - title: WICS, Scottish Water's charges scheme 2022-23 (4.2%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23 + - title: WICS, Scottish Water's charges scheme 2023-24 (5%) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2023-24 + - title: WICS, Scottish Water's charges scheme 2024-25 (8.8%, letter of 30 January 2024) + href: https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25 + - title: Scottish Water, "Charges 2025 to 2026" (28 January 2025; 9.9%) + href: https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement + - title: Scottish Water, "Average water bills to rise £42 in 2026" (22 January 2026; 8.67%) + href: https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026 + - title: WICS, "WICS publishes 2027-33 Draft Determination" (CPI + 2% cap; Final Determination 29 October 2026) + href: https://wics.scot/latest/wics-publishes-2027-33-draft-determination finance_ni: domestic_rates: description: Growth in the average Northern Ireland domestic rates bill (regional rate plus district rate), used to project the domestic_rates dataset input. diff --git a/policyengine_uk/tests/test_projection_horizon.py b/policyengine_uk/tests/test_projection_horizon.py index bed29a66f2..2b6303b898 100644 --- a/policyengine_uk/tests/test_projection_horizon.py +++ b/policyengine_uk/tests/test_projection_horizon.py @@ -25,6 +25,7 @@ from policyengine_uk.data import UKSingleYearDataset from policyengine_uk.data import economic_assumptions from policyengine_uk.data.economic_assumptions import ( + COUNTRY_UPRATED_INPUTS, DATASET_UPRATING_EXCLUSIONS, LEGACY_PROJECTION_END_YEAR, PROJECTION_END_YEAR, @@ -54,6 +55,7 @@ and variable.value_type in (int, float) and name not in INDEX_OF and name not in DATASET_UPRATING_EXCLUSIONS + and name not in COUNTRY_UPRATED_INPUTS } # These survey inputs already use FY2026-27 prices, so projection must not # apply their class-level CPI index a second time. @@ -79,6 +81,14 @@ "SCOTLAND": "scotland", "WALES": "wales", } +# Water and sewerage charges grow by country (#2501): Northern Ireland has +# none to grow. +WATER_SERIES = { + "LONDON": "ofwat.water_bills", + "NORTH_EAST": "ofwat.water_bills", + "SCOTLAND": "scottish_water.water_bills", + "WALES": "ofwat.water_bills", +} def _dataset(amount=1_000.0, base_year=BASE_YEAR): @@ -110,6 +120,7 @@ def _dataset(amount=1_000.0, base_year=BASE_YEAR): "tenure_type": [tenure for _, tenure in HOUSEHOLDS], "rent": amounts, "council_tax": amounts, + "water_and_sewerage_charges": amounts, }, } for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *ENERGY_VARIABLES): @@ -196,8 +207,9 @@ def test_unmapped_inputs_preserve_legacy_years_and_grow_after_2030(projected): """Previously unmapped columns keep their old values through 2030, then grow by the same cumulative-index ratio Core used beyond that year. Columns uprating_indices.yaml now lists (the debts, ISAs, lodger and - boarder rent, gross receipts, salary sacrifice, water bills and service - charges) grow every year instead, so they are no longer fallbacks. + boarder rent, gross receipts, salary sacrifice and service charges), and + water bills, which grow by country, grow every year instead, so they are + no longer fallbacks. """ assert { "rail_usage", @@ -228,12 +240,25 @@ def test_reset_growthfactor_uprating_restores_unmapped_inputs(projected): reset = projected.copy() reset_growthfactor_uprating(reset) for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): - for variable in (*INDEX_OF, *FALLBACK_INDEX_OF): + for variable in (*INDEX_OF, *FALLBACK_INDEX_OF, *COUNTRY_UPRATED_INPUTS): base = _table(projected[BASE_YEAR], variable)[variable].to_numpy() current = _table(reset[year], variable)[variable].to_numpy() np.testing.assert_array_equal(current, base, err_msg=f"{variable}, {year}") +def test_water_charges_grow_by_country_every_projected_year(projected): + # Through the old horizon and after it, each country's own series grows + # the charge once a year; Northern Ireland's stays flat. + for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): + previous, current = projected[year - 1].household, projected[year].household + for i, (region, _) in enumerate(HOUSEHOLDS): + series = WATER_SERIES.get(region) + growth = 0.0 if series is None else GROWTH.get_child(series)(year) + assert current["water_and_sewerage_charges"].iloc[i] == pytest.approx( + previous["water_and_sewerage_charges"].iloc[i] * (1 + growth) + ), (region, year) + + def test_rent_and_council_tax_grow_every_projected_year(projected): for year in range(BASE_YEAR + 1, PROJECTION_END_YEAR + 1): previous, current = projected[year - 1].household, projected[year].household diff --git a/policyengine_uk/tests/test_water_uprating.py b/policyengine_uk/tests/test_water_uprating.py new file mode 100644 index 0000000000..ffc7b610e8 --- /dev/null +++ b/policyengine_uk/tests/test_water_uprating.py @@ -0,0 +1,237 @@ +"""Dataset projection uprates water and sewerage charges by country (#2501). + +Ofwat's series is the average household bill in England and Wales. Scottish +Water's charges are set separately, within limits from the Water Industry +Commission for Scotland, and rose 9.9% in 2025-26 against Ofwat's 26%. +Northern Ireland has no domestic water charges, so any a dataset gives its +households are held flat. +""" + +import functools +from types import SimpleNamespace + +import numpy as np +import pandas as pd +import pytest +from hypothesis import given, settings +from hypothesis import strategies as st + +from policyengine_uk import Microsimulation, Simulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import ( + LEGACY_PROJECTION_END_YEAR, + PROJECTION_END_YEAR, + _unmapped_uprating_indices, + _uprating_index_map, + extend_single_year_dataset, + uprate_water_and_sewerage_charges, + uprating_input_paths, +) +from policyengine_uk.variables.household.demographic.geography import Region + + +REGIONS = [region.name for region in Region] +UPRATED_YEARS = list(range(2023, PROJECTION_END_YEAR + 1)) +GROWTH = parameters.gov.economic_assumptions.yoy_growth +ENGLAND_AND_WALES = GROWTH.ofwat.water_bills +SCOTLAND = GROWTH.scottish_water.water_bills + + +@functools.cache +def _country_of_each_region(): + # The model's own region-to-country mapping (the `country` variable), a + # second path to the one the uprating code takes. + people = {f"person_{region}": {"age": 40} for region in REGIONS} + households = { + f"household_{region}": {"members": [f"person_{region}"], "region": region} + for region in REGIONS + } + sim = Simulation(situation={"people": people, "households": households}) + countries = sim.calculate("country", 2025) + return dict(zip(REGIONS, countries)) + + +def _expected_growth(region, year): + country = _country_of_each_region()[region] + if country == "NORTHERN_IRELAND": + return 0.0 + if country == "SCOTLAND": + return SCOTLAND(year) + # England, Wales and an unknown region take the England and Wales bills. + return ENGLAND_AND_WALES(year) + + +def _uprated(regions, charges, year): + previous_year = SimpleNamespace( + household=pd.DataFrame({"water_and_sewerage_charges": charges}, dtype=float) + ) + current_year = SimpleNamespace( + time_period=str(year), + household=pd.DataFrame( + {"region": regions, "water_and_sewerage_charges": [0.0] * len(charges)} + ), + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + return current_year.household["water_and_sewerage_charges"].to_numpy() + + +def test_scottish_water_growth_matches_its_sources(): + # Scottish Water's announced increases (8.8% approved by WICS on 30 + # January 2024; 9.9% on 28 January 2025; 8.67% on 22 January 2026). + assert SCOTLAND("2024-01-01") == pytest.approx(0.088) + assert SCOTLAND("2025-01-01") == pytest.approx(0.099) + assert SCOTLAND("2026-01-01") == pytest.approx(0.0867) + + +def test_each_country_takes_its_own_water_growth(): + charges = _uprated( + ["LONDON", "WALES", "SCOTLAND", "NORTHERN_IRELAND", "UNKNOWN"], + [500.0] * 5, + 2025, + ) + england_and_wales = 500 * (1 + ENGLAND_AND_WALES("2025-01-01")) + assert charges == pytest.approx( + [england_and_wales, england_and_wales, 500 * 1.099, 500.0, england_and_wales] + ) + + +@pytest.mark.parametrize("region", ["NORTHERN IRELAND", "ATLANTIS", ""]) +def test_unrecognised_regions_are_rejected(region): + with pytest.raises(ValueError, match="unrecognised regions"): + _uprated(["LONDON", region], [500.0, 500.0], 2025) + + +def test_a_dataset_without_water_charges_is_left_alone(): + previous_year = SimpleNamespace(household=pd.DataFrame({"rent": [1.0]})) + current_year = SimpleNamespace( + time_period="2025", household=pd.DataFrame({"region": ["SCOTLAND"]}) + ) + uprate_water_and_sewerage_charges(current_year, previous_year, parameters) + assert "water_and_sewerage_charges" not in current_year.household + + +@settings(max_examples=200, deadline=None, derandomize=True) +@given( + households=st.lists( + st.tuples( + st.sampled_from(REGIONS), + st.floats(0, 5_000, allow_nan=False), + ), + min_size=1, + max_size=30, + ), + year=st.sampled_from(UPRATED_YEARS), + scale=st.floats(0, 10, allow_nan=False), +) +def test_water_uprating_properties(households, year, scale): + """For every Region label, any charge of zero or more and every + projected year: + + - the charge grows by its country's series, with the country taken from + the model's `country` variable: Ofwat's for England, Wales and unknown + regions, Scottish Water's for Scotland, and none for Northern Ireland; + - each household's result depends only on its own row; + - scaling every previous-year charge scales every result by the same + factor. + """ + regions, amounts = (list(column) for column in zip(*households)) + + charges = _uprated(regions, amounts, year) + + expected = [ + amount * (1 + _expected_growth(region, year)) for region, amount in households + ] + assert charges == pytest.approx(expected) + alone = [_uprated([region], [amount], year)[0] for region, amount in households] + assert charges == pytest.approx(alone) + scaled = _uprated(regions, [scale * amount for amount in amounts], year) + assert scaled == pytest.approx(scale * charges) + + +def test_water_charges_are_uprated_once(): + # The country uprating replaces a single index: listing the column in + # uprating_indices.yaml as well, or letting the index its variable + # declares grow it after LEGACY_PROJECTION_END_YEAR, would apply a second + # growth rate on top. + listed = {name for names in _uprating_index_map().values() for name in names} + assert "water_and_sewerage_charges" not in listed + assert "water_and_sewerage_charges" not in _unmapped_uprating_indices( + _uprating_index_map() + ) + + +def test_the_projection_fingerprint_covers_both_water_series(): + paths = uprating_input_paths() + assert "gov.economic_assumptions.yoy_growth.ofwat.water_bills" in paths + assert "gov.economic_assumptions.yoy_growth.scottish_water.water_bills" in paths + + +# A dataset with an unknown-region household cannot be projected forward until +# rent uprating handles Region.UNKNOWN (PolicyEngine/policyengine-uk#1985), so +# the dataset-level tests below use the twelve known regions. +KNOWN_REGIONS = [region for region in REGIONS if region != "UNKNOWN"] + + +def _one_household_per_known_region(charges): + ids = np.arange(1, len(KNOWN_REGIONS) + 1) + return UKSingleYearDataset( + person=pd.DataFrame( + { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 40, + } + ), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": KNOWN_REGIONS, + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + "water_and_sewerage_charges": charges, + } + ), + fiscal_year=2024, + ) + + +def _compounded(amount, region, year, base_year=2024): + return amount * np.prod( + [1 + _expected_growth(region, y) for y in range(base_year + 1, year + 1)] + ) + + +def test_extended_dataset_compounds_each_country_water_growth(): + # Every projected year chains its country's growth, through the old + # dataset horizon (2030) and the two years after it. + extended = extend_single_year_dataset( + _one_household_per_known_region(500.0), parameters, end_year=2032 + ) + + for year in range(2025, PROJECTION_END_YEAR + 1): + charges = extended[year].household["water_and_sewerage_charges"].to_numpy() + assert charges == pytest.approx( + [_compounded(500.0, region, year) for region in KNOWN_REGIONS] + ) + assert PROJECTION_END_YEAR > LEGACY_PROJECTION_END_YEAR + + +def test_microsimulation_gives_scotland_scottish_water_growth(): + sim = Microsimulation(dataset=_one_household_per_known_region(500.0)) + + charges = dict( + zip(KNOWN_REGIONS, sim.calculate("water_and_sewerage_charges", 2026).values) + ) + # 500 x 1.099 x 1.0867 in Scotland; Northern Ireland stays at 500. + assert charges["SCOTLAND"] == pytest.approx(500 * 1.099 * 1.0867) + assert charges["NORTHERN_IRELAND"] == 500.0 + assert charges["WALES"] == pytest.approx(charges["LONDON"]) + assert charges["LONDON"] == pytest.approx( + 500 + * (1 + ENGLAND_AND_WALES("2025-01-01")) + * (1 + ENGLAND_AND_WALES("2026-01-01")) + ) From ffbe1234f29dd41d8b3a517d32947d5ea3a8848c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:28:28 +0100 Subject: [PATCH 5/9] Read each water series by its path; cover water in the fingerprint test; document growth after 2032 The fingerprint read-set test now carries the water column, so it runs uprate_water_and_sewerage_charges; reading the two series by full path keeps every read inside the listed paths (dropping either path from the read set now fails the test). After the projection ends in 2032, policyengine-core grows the column by Ofwat's index in every country, as before; the docs say so. The Ofwat growth table in water-bills.md now matches the parameter. Co-Authored-By: Claude Opus 5.5 --- docs/book/assumptions/water-bills.md | 4 +++- policyengine_uk/data/economic_assumptions.py | 7 +++++-- policyengine_uk/tests/test_uprating_inputs_reach_data.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index fce3e461a0..18115c7c95 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -90,6 +90,8 @@ Ofwat's bills cover England and Wales. When a dataset is projected forward, each - Scotland takes `scottish_water.water_bills`. Scottish Water sets household charges within limits from the [Water Industry Commission for Scotland](https://wics.scot/) (WICS), and every council tax band's charge is a fixed fraction of Band D, so each band rises by the same percentage. - Northern Ireland has no domestic water charges, so any charge a dataset gives its households is held flat. +This holds to the end of the dataset projection (2032). After it, policyengine-core grows every household's charge by the index the variable declares, Ofwat's England and Wales series, as it did before this split. + | Year | Scottish Water growth | Source | |------|-------------|--------| | 2022 | 4.2% | [WICS, charges scheme 2022-23](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2022-23) | @@ -101,6 +103,6 @@ Ofwat's bills cover England and Wales. When a dataset is projected forward, each | 2028 | 3.9% | As above | | 2029 | 4.1% | As above, with CPI at 2.1% | -Each year is the charging year starting in April, so 2025 is 2025-26. Later years hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. +Each year is the charging year starting in April, so 2025 is 2025-26. Later years to 2032 hold the 2029-30 rate. [WICS publishes its Final Determination for 2027-33](https://wics.scot/latest/wics-publishes-2027-33-draft-determination) on 29 October 2026; the 2027-2029 values should then be replaced with it. `policyengine_uk/utils/create_ahc_deflator.py` still reads the England and Wales index. It is a standalone script that the model does not run. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 6a665b65bf..28e992fb87 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -61,6 +61,8 @@ # household bill in England and Wales. Scottish Water's charges follow limits # set by the Water Industry Commission for Scotland. Northern Ireland has no # domestic water charges, so any a dataset gives its households are held flat. +# Past PROJECTION_END_YEAR, policyengine-core grows the column by the index +# its variable declares (Ofwat's) in every country. # An unknown region takes the England and Wales series, which covers most # households in Great Britain. WATER_BILLS_GROWTH = "gov.economic_assumptions.yoy_growth" @@ -438,13 +440,14 @@ def uprate_water_and_sewerage_charges( "Cannot uprate water and sewerage charges for unrecognised regions: " f"{sorted(unrecognised)}" ) - growth = parameters.get_child(WATER_BILLS_GROWTH) growth_by_region = {} for name, series in _WATER_GROWTH_SERIES.items(): rate = ( None if series is None - else growth.get_child(series)(current_year.time_period) + else parameters.get_child(f"{WATER_BILLS_GROWTH}.{series}")( + current_year.time_period + ) ) growth_by_region[name] = 0.0 if rate is None else rate growth_rates = region.map(growth_by_region).to_numpy() diff --git a/policyengine_uk/tests/test_uprating_inputs_reach_data.py b/policyengine_uk/tests/test_uprating_inputs_reach_data.py index 9f7143e2be..d92df6c5af 100644 --- a/policyengine_uk/tests/test_uprating_inputs_reach_data.py +++ b/policyengine_uk/tests/test_uprating_inputs_reach_data.py @@ -104,6 +104,8 @@ def mock_dataset_with_every_uprated_input() -> UKSingleYearDataset: uprating = economic_assumptions._uprating_index_map() names = set(economic_assumptions._unmapped_uprating_indices(uprating)) names |= {name for variables in uprating.values() for name in variables} + # Uprated by country rather than through the index map (#2501). + names |= economic_assumptions.COUNTRY_UPRATED_INPUTS for name in sorted(names): variable = system.variables.get(name) if variable is None or variable.value_type is not float: From 35bec9ccca582a0dace879377345b9243b6c28de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 14:56:53 +0100 Subject: [PATCH 6/9] Defined-benefit input and a carry-forward default for the annual allowance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Survey data records contributions paid this year, but the annual allowance counts a defined-benefit arrangement's increase in the value of rights (Finance Act 2004 s. 234), not the cash paid, and adds unused allowance from the three previous years (s. 228A), which surveys do not record. - defined_benefit_pension_share (input, default nil) says how much of a person's workplace contributions (employer, net pay and salary sacrifice) go to defined-benefit arrangements. defined_benefit_pension_input_amount replaces them in pension_contributions_for_annual_allowance; where it is not supplied it is those contributions times gov.simulation.pensions.defined_benefit_input_per_contribution, set at 1: in the four largest public service schemes in 2024-25 the statutory input of an active member is 0.8 to 1.2 times employer and member contributions together. With no share supplied, results are unchanged. - unused_pension_annual_allowance now defaults, from 2020-21, to an estimate: each of the five previous years' input is this year's times gov.simulation.pensions.prior_year_input_ratio (1), with input and incomes deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). A supplied value overrides it. - The 2024 charge and payers on the certified dataset are checked against HMRC's 2024-25 statistics (30,440 people, £0.3bn) within half to one and a half times, as a strict expected failure: the dataset imputes employer contributions as three times employee contributions (PolicyEngine/policyengine-uk-data#606). The taper moves into utils/pensions.py (annual_allowance_for_year) so the carry-forward estimate can apply earlier years' rules. Part of #2523. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 7 ++ changelog.d/survey-pension-input.added.md | 1 + changelog.d/survey-pension-input.changed.md | 1 + docs/engineering/pension-annual-allowance.md | 72 ++++++++++- policyengine_uk/data/uprating_indices.yaml | 3 + .../gov/simulation/pensions/README.md | 1 + ...efined_benefit_input_per_contribution.yaml | 44 +++++++ .../pensions/prior_year_input_ratio.yaml | 27 +++++ .../test_annual_allowance_against_hmrc.py | 44 +++++++ .../defined_benefit_pension_input.yaml | 113 ++++++++++++++++++ ...used_pension_annual_allowance_default.yaml | 103 ++++++++++++++++ policyengine_uk/utils/pensions.py | 45 +++++++ .../allowances/pension_annual_allowance.py | 31 ++--- .../defined_benefit_pension_contributions.py | 26 ++++ .../defined_benefit_pension_input_amount.py | 39 ++++++ ...sion_contributions_for_annual_allowance.py | 37 ++++-- .../unused_pension_annual_allowance.py | 80 +++++++++++++ .../input/defined_benefit_pension_share.py | 32 +++++ .../input/unused_pension_annual_allowance.py | 23 ---- 19 files changed, 669 insertions(+), 60 deletions(-) create mode 100644 changelog.d/survey-pension-input.added.md create mode 100644 changelog.d/survey-pension-input.changed.md create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/README.md create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml create mode 100644 policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml create mode 100644 policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py create mode 100644 policyengine_uk/variables/input/defined_benefit_pension_share.py delete mode 100644 policyengine_uk/variables/input/unused_pension_annual_allowance.py diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index 9206674edc..f944f2e98f 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -64,3 +64,10 @@ nodeid = "policyengine_uk/tests/test_dwp_published_rates.py::test_projection_con outcome = "skipped" reason = '^(not uprated|series ends before 2026-27|Universal Credit Act 2025 s\.1 departs from CPI uprating)$' why = "The test is parametrised over every DWP rate parameter and skips those it does not apply to: parameters with no uprating, series that end before 2026-27, and the four UC standard allowances, whose minimum the Universal Credit Act 2025 s.1 sets above CPI uprating (#1925). The LCWRA element, frozen by s.3, is checked against its own uprating index (#2284). 64 cases on 12 October 2026." + +[[allow]] +nodeid = "policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py::test_2024_annual_allowance_charge_and_payers_are_near_hmrc" +outcome = "xfailed" +reason = "policyengine-uk-data#606" +data = ["present"] +why = "Known data gap PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance charge several times above HMRC's. Pinned by a strict xfail until the dataset fixes it (#2523)." diff --git a/changelog.d/survey-pension-input.added.md b/changelog.d/survey-pension-input.added.md new file mode 100644 index 0000000000..70b27a10a5 --- /dev/null +++ b/changelog.d/survey-pension-input.added.md @@ -0,0 +1 @@ +- Add `defined_benefit_pension_share`, `defined_benefit_pension_contributions` and `defined_benefit_pension_input_amount`, so data can say which workplace pension contributions go to defined-benefit arrangements and supply their statutory pension input amount (Finance Act 2004 s. 234) for the annual allowance. Where the input amount is not supplied it is those contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, set at 1 from the accrual and contribution rates of the main public service schemes. diff --git a/changelog.d/survey-pension-input.changed.md b/changelog.d/survey-pension-input.changed.md new file mode 100644 index 0000000000..0c107ff5f7 --- /dev/null +++ b/changelog.d/survey-pension-input.changed.md @@ -0,0 +1 @@ +- Estimate unused annual allowance carried forward (Finance Act 2004 s. 228A) from 2020-21 where none is supplied, instead of nil: each of the five previous years' pension input is this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), with incomes and input deflated by average earnings growth, against that year's tapered allowance, used earliest first (s. 228A(7)). diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 14977e2b09..8b803d500b 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -125,12 +125,11 @@ for the whole year according to age on that date; payments are not split at the birthday. Contributions before 75 that exceed the earnings relief limit still count. Adjusted income subtracts all actual member contributions under s. 228ZA(4)(d)(ii), including those excluded from the post-75 DC input. -For defined-benefit -arrangements, supply the statutory total pension input amount, including -valued DB accrual, through this variable. Cash employer funding is not a -valuation of DB rights. Survey estimates retain the existing cash proxy; -this correction does not reconstruct DB rights, overseas adjustments, unused -allowance histories or anti-avoidance arrangements under s. 228ZB. +Defined-benefit +arrangements are counted separately; see the last section. Supplying +`pension_contributions_for_annual_allowance` itself overrides the whole total. +The model does not reconstruct overseas adjustments or anti-avoidance +arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) changes NICs treatment without changing s. 228ZA. From 2029, the model includes @@ -156,3 +155,64 @@ Pension relief retains the model's aggregate earnings cap and allocation conventions. Historical years use calendar-year proxies for the named tax years; the 2015 transitional pension-input alignment rules are outside this taper calculation. + +## Survey data: defined benefits and carry forward + +Surveys record contributions paid this year. Two statutory amounts need more: +the pension input amount of a defined-benefit arrangement, and unused +allowance carried forward from earlier years. + +**Defined benefits.** A defined-benefit arrangement's pension input amount is +the increase in the value of the member's rights over the year +([s. 234](https://www.legislation.gov.uk/ukpga/2004/12/section/234)): 16 times +the annual pension, plus any separate lump sum, at the end less the same at the +start, with the start raised by CPI +([s. 235](https://www.legislation.gov.uk/ukpga/2004/12/section/235)). It is not +the cash paid in. + +- `defined_benefit_pension_share` (an input from 0 to 1, default nil) is the + share of the person's workplace contributions (employer, net pay and salary + sacrifice) paid into defined-benefit arrangements. Personal (relief at + source) contributions are money purchase. +- `defined_benefit_pension_input_amount` replaces those contributions in + `pension_contributions_for_annual_allowance`. Supply it where it is known, + as on a pension savings statement. Otherwise it is the contributions times + `gov.simulation.pensions.defined_benefit_input_per_contribution`, which is + 1. In the four largest public service schemes in 2024-25 the input of an + active member with ten years' service is 0.8 to 1.2 times employer and + member contributions together (the parameter file gives the figures), and + survey employer contributions drawn from regular contribution rates leave + out deficit repair payments. +- With the default share of nil, every contribution is counted as money + purchase input, as before. + +**Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) +adds the allowance left unused in each of the three previous tax years in +which the person was a scheme member, earliest first. `unused_pension_annual_allowance` +can be supplied. From 2020-21 it otherwise defaults to an estimate: + +- In each of the five previous years, the pension input amount was this + year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the + incomes tested for that year's taper were this year's, each deflated by + average earnings growth. +- Each year's allowance follows that year's parameters, tapered on those + incomes. Each year's excess uses the unused allowance of the three years + before it, earliest first (s. 228A(7)), and the unused allowance of the + three years before this one is carried forward. +- A person with pension input this year is taken to have been a scheme member + in each of those years; with none, nothing is carried forward. + +With a ratio of 1 the estimate matters mainly where the allowance has risen, +as it did to £60,000 in 2023-24, or where pay has grown. A ratio of 0, no input +in earlier years, gives the most there could be. + +**Against HMRC.** HMRC's Private pension statistics (July 2026) count 30,440 +individuals reporting contributions above their allowance through Self +Assessment in 2024-25 (provisional, Table 7), after carry forward, and +estimate annual allowance charges of £0.3bn (Table 6). On the certified +Enhanced FRS the model's 2024 count and charge are several times these, +because the dataset imputes employer contributions as three times employee +contributions ([policyengine-uk-data#606](https://github.com/PolicyEngine/policyengine-uk-data/issues/606)). +`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks both +against half to one and a half times HMRC's figures, as a strict expected +failure until that is fixed. diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index e7f1a5e300..03ab55fdf8 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -28,6 +28,9 @@ gov.economic_assumptions.yoy_growth.finance_ni.domestic_rates: - domestic_rates gov.economic_assumptions.yoy_growth.obr.average_earnings: +# A formula of defined_benefit_pension_contributions; listed so a dataset that +# stores a valued defined-benefit input amount grows it with pay. +- defined_benefit_pension_input_amount - employee_pension_contributions - employer_pension_contributions - employment_income diff --git a/policyengine_uk/parameters/gov/simulation/pensions/README.md b/policyengine_uk/parameters/gov/simulation/pensions/README.md new file mode 100644 index 0000000000..bade491b98 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/README.md @@ -0,0 +1 @@ +# Pensions diff --git a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml new file mode 100644 index 0000000000..5d7ec93b0e --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml @@ -0,0 +1,44 @@ +description: >- + Defined-benefit pension input amount per pound of contributions paid into + defined-benefit arrangements, used for the annual allowance where the input + amount itself is not supplied. +# The statutory input amount values the increase in a member's rights over +# the year (Finance Act 2004 s. 234): 16 times the annual pension, plus any +# separate lump sum, at the end of the year less the same at the start, the +# start raised by CPI (s. 235). It is not the cash paid in. For an active +# member with ten years' service in 2024-25 (opening value raised by CPI of +# 6.7%), it is about this share of pensionable pay in the four largest public +# service schemes, against employer plus member contributions: +# - NHS 2015 (1/54 accrual, revalued at CPI + 1.5%): 34%; contributions 23.7% +# plus 5.2-12.5%, a ratio of 0.94 to 1.18. +# - Teachers 2015 (1/57, CPI + 1.6%): 33%; 28.68% plus 7.4-11.7%, 0.81 to 0.90. +# - Civil Service alpha (2.32%, CPI): 37%; 28.97% plus 4.6-8.05%, 1.00 to 1.10. +# - LGPS 2014 (1/49, CPI): 33%; about 21.1% plus 5.5-12.5%, 0.97 to 1.23. +# The ratio is lower where employer rates are higher (Police 2015, 35.3%; +# Armed Forces 2015, 73.5%). Survey employer contributions drawn from +# employers' regular contribution rates leave out deficit repair payments, so +# contributions are counted at their face value: a ratio of 1. Data that +# values defined-benefit accrual supplies defined_benefit_pension_input_amount. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Defined-benefit pension input per pound of contributions + reference: + - title: Finance Act 2004 s. 234 (defined benefits arrangements) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/234 + - title: Finance Act 2004 s. 235 (CPI increase in the opening value) + href: https://www.legislation.gov.uk/ukpga/2004/12/section/235 + - title: NHS Pension Scheme Regulations 2015 Sch. 9 para. 13 (1/54 accrual) + href: https://www.legislation.gov.uk/uksi/2015/94/schedule/9/paragraph/13/made + - title: NHS Pension Scheme annual report and accounts 2024-25 (23.7% employer; 5.2% to 12.5% member) + href: https://assets.publishing.service.gov.uk/media/687a5e455f0f5104b9806baa/nhs-pension-scheme-annual-report-and-accounts-2024-to-2025.pdf + - title: Teachers' Pension Scheme annual report and accounts 2024-25 (1/57 accrual, CPI + 1.6%; 28.6% employer plus 0.08% levy; 7.4% to 11.7% member) + href: https://assets.publishing.service.gov.uk/media/687610ae55c4bd0544dcae36/DfE_TPS_Annual_Report_2024-25.pdf + - title: Civil Superannuation annual account 2024-25 (alpha 2.32% accrual; 28.97% employer) + href: https://www.gov.uk/government/publications/civil-superannuation-annual-account-2024-to-2025/civil-superannuation-annual-account-2024-to-2025-html + - title: LGPS Scheme Advisory Board, 2022 valuations report (21.1% average employer rate) + href: https://joomla.lgpsboard.org/index.php/2022-valuations-report + - title: Local Government Pension Scheme Regulations 2013 reg. 23 (1/49 accrual) + href: https://www.legislation.gov.uk/uksi/2013/2356/regulation/23/made diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml new file mode 100644 index 0000000000..7c42ce9f90 --- /dev/null +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -0,0 +1,27 @@ +description: >- + Pension input amount in each of the three previous tax years as a share of + this year's, after average earnings growth, used to estimate unused annual + allowance carried forward where data has no pension history. +# Finance Act 2004 s. 228A carries forward the annual allowance that the +# pension input amount left unused in each of the three previous tax years in +# which the individual was a member of a registered pension scheme, earliest +# year first. Surveys record this year's contributions only. With a ratio of +# 1, each previous year's input is this year's deflated by average earnings +# growth, as for a member saving the same share of pay each year, and the +# income tested for that year's taper is deflated the same way. A ratio of 0 +# assumes no input in those years, the most allowance there could be to +# carry forward. HMRC publishes no count of carry-forward use: Self Assessment +# reports only the excess over the allowance once it is applied. +values: + 2004-04-06: 1 +metadata: + unit: /1 + period: year + label: Pension input in each of the three previous years relative to this year + reference: + - title: Finance Act 2004 s. 228A + href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A + - title: HMRC Pensions Tax Manual PTM055100 (carry forward) + href: https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + - title: HMRC, HS345 Pension savings tax charges (2025) + href: https://www.gov.uk/government/publications/pensions-tax-charges-on-any-excess-over-the-lifetime-allowance-annual-allowance-special-annual-allowance-and-on-unauthorised-payments-hs345-self/hs345-pension-savings-tax-charges-2025 diff --git a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py new file mode 100644 index 0000000000..38749baae3 --- /dev/null +++ b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py @@ -0,0 +1,44 @@ +"""The 2024-25 annual allowance charge on the certified dataset against HMRC. + +HMRC, Private pension statistics (July 2026), 2024-25 provisional: +- Table 7: 30,440 individuals reported pension contributions exceeding their + annual allowance through Self Assessment. Self Assessment reports only the + excess over the allowance after unused allowance is carried forward (HMRC, + HS345 Pension savings tax charges, 2025). +- Table 6: annual allowance charges of £0.3bn, rounded to the nearest £0.1bn. + +The model's count of payers and its charge in 2024 must each lie within half +to one and a half times these. The certified Enhanced FRS imputes employer +pension contributions as three times employee contributions +(PolicyEngine/policyengine-uk-data#606), which puts both several times above +HMRC's, so this is a strict expected failure until the dataset fixes that. The +test reports no model value: the counts rest on survey records. +""" + +import pytest + +from policyengine_uk import Microsimulation + +YEAR = 2024 +HMRC_PAYERS = 30_440 +HMRC_CHARGE = 0.3e9 +TOLERANCE = 0.5 + + +def _within(value, target): + return (1 - TOLERANCE) * target <= value <= (1 + TOLERANCE) * target + + +@pytest.mark.microsimulation +@pytest.mark.xfail( + strict=True, + raises=AssertionError, + reason="PolicyEngine/policyengine-uk-data#606: employer pension " + "contributions imputed as three times employee contributions", +) +def test_2024_annual_allowance_charge_and_payers_are_near_hmrc(): + charge = Microsimulation().calculate("personal_pension_contributions_tax", YEAR) + near_hmrc = bool( + _within((charge > 0).sum(), HMRC_PAYERS) and _within(charge.sum(), HMRC_CHARGE) + ) + assert near_hmrc, "The 2024 annual allowance charge is outside HMRC's band." diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml new file mode 100644 index 0000000000..ae631ab66c --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/defined_benefit_pension_input.yaml @@ -0,0 +1,113 @@ +# The total pension input amount (FA 2004 s. 229) adds each arrangement's +# input amount. A money purchase arrangement's is the contributions paid +# (s. 233); a defined-benefit arrangement's is the increase in the value of +# the member's rights (s. 234), whatever was paid in. defined_benefit_pension_share +# says how much of the workplace contributions (employer, net pay and salary +# sacrifice) go to defined-benefit arrangements; defined_benefit_pension_input_amount +# replaces them, and defaults to them times a ratio of 1. Unused allowance +# brought forward is set to nil where a case tests the charge. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/229 +# https://www.legislation.gov.uk/ukpga/2004/12/section/233 +# https://www.legislation.gov.uk/ukpga/2004/12/section/234 + +- name: Without a scheme type every workplace contribution is money purchase + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + output: + defined_benefit_pension_contributions: 0 + defined_benefit_pension_input_amount: 0 + # 20,000 + 5,000 + 2,000. + pension_contributions_for_annual_allowance: 27_000 + +- name: A defined-benefit member's contributions count at face value by default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + output: + # The employer's 20,000 and the 5,000 of net pay go to the defined-benefit + # scheme; the 2,000 of relief at source is money purchase. + defined_benefit_pension_contributions: 25_000 + defined_benefit_pension_input_amount: 25_000 + pension_contributions_for_annual_allowance: 27_000 + +- name: A supplied defined-benefit input amount replaces the contributions + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 45_000 + output: + # 2,000 of money purchase input plus the 45,000 statement value. + pension_contributions_for_annual_allowance: 47_000 + +- name: Half the workplace contributions go to a defined-benefit arrangement + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 0.5 + defined_benefit_pension_input_amount: 30_000 + output: + # Half of 25,000 goes to the defined-benefit scheme. Money purchase input + # is 2,000 + 2,500 of net pay + 10,000 from the employer, plus 30,000. + defined_benefit_pension_contributions: 12_500 + pension_contributions_for_annual_allowance: 44_500 + +- name: From age 75 the defined-benefit input still counts + period: 2026 + absolute_error_margin: 0.01 + input: + age: 76 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + personal_pension_contributions: 2_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 30_000 + output: + # The 2,000 paid personally from 75 is unrelievable and not a money + # purchase input (s. 188(3)(a)); the net pay is in the defined-benefit + # input instead, which is not excluded. + pension_contributions_for_annual_allowance: 30_000 + +- name: The charge falls on the defined-benefit input above the allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 20_000 + employee_pension_contributions_reported: 5_000 + defined_benefit_pension_share: 1 + defined_benefit_pension_input_amount: 80_000 + unused_pension_annual_allowance: 0 + output: + pension_contributions_for_annual_allowance: 80_000 + # Adjusted income 100,000 + 80,000 - 5,000 of member contributions is + # under 260,000, so the allowance is 60,000. + pension_annual_allowance: 60_000 + pension_annual_allowance_chargeable_amount: 20_000 + # Reduced net income is 100,000 - 5,000 - 12,570 = 82,430, in the higher + # rate band, so the 20,000 is charged at 40%. + personal_pension_contributions_tax: 8_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml new file mode 100644 index 0000000000..89a0ef8a77 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml @@ -0,0 +1,103 @@ +# Unused annual allowance carried forward (FA 2004 s. 228A) where no history +# is supplied. From 2020-21 the default assumes the person's pension input in +# each of the five previous years was this year's (prior_year_input_ratio 1) +# deflated by average earnings growth, the incomes tested for the taper +# likewise, and that they were a scheme member in each year. Each year's +# excess uses the unused allowance of the three years before it, earliest +# first (s. 228A(7)), and the unused allowance of the three years before this +# one is carried forward. +# +# For 2026 the deflators are average earnings growth of 3.4% (2026), 5.3% +# (2025), 5.1% (2024), 6.22% (2023) and 6.14% (2022): 0.967118 for 2025, +# 0.918441 for 2024, 0.873873 for 2023, 0.822701 for 2022 and 0.775109 for +# 2021. The standard allowance is 40,000 to 2022-23 and 60,000 from 2023-24. +# Every case is a person aged 45 in London with employment income of 100,000. +# Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/228A +# https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100 + +- name: Steady saving under the allowance carries forward three years of unused allowance + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 50_000 + output: + # Inputs of 43,693.66 (2023), 45,922.03 (2024) and 48,355.90 (2025) + # leave 16,306.34 + 14,077.97 + 11,644.10 of their 60,000 allowances. + unused_pension_annual_allowance: 42_028.41 + pension_annual_allowance_chargeable_amount: 0 + +- name: An excess in a later year uses the earliest unused allowance first + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 63_000 + output: + # 2023 input 55,054.01 leaves 4,945.99 unused and 2024 input 57,861.76 + # leaves 2,138.24. 2025 input 60,928.43 exceeds 60,000 by 928.43, which + # uses 2023's unused allowance (2021 and 2022 have none). 4,017.56 + + # 2,138.24 = 6,155.80, against 7,084.23 without that ordering. + unused_pension_annual_allowance: 6_155.80 + # 63,000 - 60,000 is within the 6,155.80 carried forward. + pension_annual_allowance_chargeable_amount: 0 + +- name: Saving above the allowance every year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + output: + # Deflated inputs of 54,258 to 67,698 exceed each year's allowance. + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 10_000 + +- name: A supplied unused allowance overrides the default + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 70_000 + unused_pension_annual_allowance: 4_000 + output: + pension_annual_allowance_chargeable_amount: 6_000 + +- name: No pension input this year means no carry-forward is assumed + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + output: + unused_pension_annual_allowance: 0 + +- name: A tapered allowance in every earlier year leaves nothing to carry forward + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 400_000 + employer_pension_contributions: 50_000 + output: + # Adjusted income of 450,000 tapers the allowance to the 10,000 minimum + # (60,000 - (450,000 - 260,000) / 2 is below it). Deflated, the earlier + # years' incomes are tapered to their minimums too (4,000 in 2021-22 and + # 2022-23), all below the deflated inputs. + pension_annual_allowance: 10_000 + unused_pension_annual_allowance: 0 + pension_annual_allowance_chargeable_amount: 40_000 + +- name: Before 2020-21 the default is nil + period: 2019 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 100_000 + employer_pension_contributions: 30_000 + output: + unused_pension_annual_allowance: 0 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 41efaf46aa..0ac720fb60 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -139,3 +139,48 @@ def annual_allowance_net_income(person, period, parameters): - actual_loss_relief, ) return np.where(relievable_net_pay > 0, reconstructed, net_income) + + +def annual_allowance_for_year(adjusted_income, threshold_income, allowance, year): + """The annual allowance (FA 2004 s.228) under one tax year's parameters. + + From 2016-17 the standard allowance is tapered only when both adjusted + income and threshold income exceed their limits (s.228ZA), and the + reduction rounds down to whole pounds before the tapered minimum + applies. The original multiplier is the standard allowance less £10,000 + over £60,000, a half at the statutory £40,000; from 2020-21 the + multiplier is expressly a half. + """ + adjusted_income = np.asarray(adjusted_income, dtype=float) + if year < 2016: + return np.full(adjusted_income.shape, float(allowance.default)) + if year < 2020: + rate = (allowance.default - allowance.minimum) / allowance.taper_income_range + else: + rate = allowance.reduction_rate + # s.228ZA(3)(b) derives the threshold limit from the standard allowance. + high_income = (adjusted_income > allowance.taper) & ( + np.asarray(threshold_income) > allowance.taper - allowance.default + ) + reduction = np.floor(np.maximum(0, adjusted_income - allowance.taper) * rate) + return np.where( + high_income, + np.maximum(allowance.minimum, allowance.default - reduction), + allowance.default, + ) + + +def workplace_pension_contributions(person, period): + """Contributions to workplace arrangements. + + Employer contributions, employee contributions under a net pay + arrangement (including any supplied legacy return to ordinary pay, an + actual member payment) and the full salary sacrifice. The NICs-only + salary-sacrifice excess is not a contribution. Personal pension + contributions (relief at source) are not included. + """ + return ( + person("employee_pension_contributions", period) + + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) diff --git a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py index aa3cd8ecf3..4c1e3f25db 100644 --- a/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/income_tax/allowances/pension_annual_allowance.py @@ -1,19 +1,5 @@ from policyengine_uk.model_api import * - - -def _tapered_allowance(person, period, allowance, reduction_rate): - adjusted = person("pension_annual_allowance_adjusted_income", period) - threshold = person("pension_annual_allowance_threshold_income", period) - # s. 228ZA(3)(b) derives the threshold limit from the standard allowance. - high_income = (adjusted > allowance.taper) & ( - threshold > allowance.taper - allowance.default - ) - reduction = np.floor(max_(0, adjusted - allowance.taper) * reduction_rate) - return where( - high_income, - max_(allowance.minimum, allowance.default - reduction), - allowance.default, - ) +from policyengine_uk.utils.pensions import annual_allowance_for_year class pension_annual_allowance(Variable): @@ -37,12 +23,9 @@ def formula(person, period, parameters): ).gov.hmrc.income_tax.allowances.annual_allowance.default def formula_2016(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - # Original s. 228ZA(1): (A - £10,000) / £60,000, equal to one half - # at the statutory £40,000 standard allowance. - rate = (allowance.default - allowance.minimum) / allowance.taper_income_range - return _tapered_allowance(person, period, allowance, rate) - - def formula_2020(person, period, parameters): - allowance = parameters(period).gov.hmrc.income_tax.allowances.annual_allowance - return _tapered_allowance(person, period, allowance, allowance.reduction_rate) + return annual_allowance_for_year( + person("pension_annual_allowance_adjusted_income", period), + person("pension_annual_allowance_threshold_income", period), + parameters(period).gov.hmrc.income_tax.allowances.annual_allowance, + period.start.year, + ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py new file mode 100644 index 0000000000..73ac4b743c --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_contributions.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import workplace_pension_contributions + + +class defined_benefit_pension_contributions(Variable): + value_type = float + entity = Person + label = "Workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The defined_benefit_pension_share of the person's workplace pension " + "contributions: employer contributions, actual employee " + "contributions under a net pay arrangement and full salary " + "sacrifice. These contributions are not a pension input amount: the " + "annual allowance counts defined_benefit_pension_input_amount for " + "them instead." + ) + definition_period = YEAR + unit = GBP + reference = dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ) + + def formula(person, period, parameters): + share = clip(person("defined_benefit_pension_share", period), 0, 1) + return share * workplace_pension_contributions(person, period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py new file mode 100644 index 0000000000..25e4a9ee66 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py @@ -0,0 +1,39 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_input_amount(Variable): + value_type = float + entity = Person + label = "Pension input amount under defined-benefit arrangements" + documentation = ( + "The increase over the pension input period in the value of the " + "individual's rights under defined-benefit arrangements (FA 2004 " + "s. 234): 16 times the annual pension, plus any separate lump sum, at " + "the end of the period less the same at its start, with the start " + "value raised by CPI (s. 235), and nil if negative. Supply it where " + "known, as on a scheme's pension savings statement. Otherwise it is " + "defined_benefit_pension_contributions times " + "gov.simulation.pensions.defined_benefit_input_per_contribution." + ) + definition_period = YEAR + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + dict( + title="Finance Act 2004 s. 235", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/235", + ), + dict( + title="HMRC Pensions Tax Manual PTM053301", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm053301", + ), + ] + + def formula(person, period, parameters): + ratio = parameters( + period + ).gov.simulation.pensions.defined_benefit_input_per_contribution + return ratio * person("defined_benefit_pension_contributions", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 7c4eace161..f300757607 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -6,21 +6,31 @@ class pension_contributions_for_annual_allowance(Variable): entity = Person label = "Total pension contributions counting toward the Annual Allowance" documentation = ( - "The default DC pension input amount under FA 2004 s. 233: member " + "The total pension input amount (FA 2004 s. 229). For money purchase " + "arrangements it is the input amount under s. 233: member " "contributions made before age 75, employer contributions and full " "salary sacrifice. Member contributions from age 75 are unrelievable " "under s. 188(3)(a) and excluded, while employer contributions still " "count. Contributions before 75 above the earnings relief limit " - "still count. Supply the statutory total pension input amount, " - "including valued defined-benefit accrual, to override this cash " - "proxy. The same input feeds adjusted income and the AA charge." + "still count. The defined_benefit_pension_share of workplace " + "contributions is replaced by defined_benefit_pension_input_amount, " + "the s. 234 input amount. Supply this variable to override the whole " + "total. The same input feeds adjusted income and the AA charge." ) definition_period = YEAR reference = [ + dict( + title="Finance Act 2004 s. 229", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), dict( title="Finance Act 2004 s. 233", href="https://www.legislation.gov.uk/ukpga/2004/12/section/233", ), + dict( + title="Finance Act 2004 s. 234", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), dict( title="Finance Act 2004 s. 188(3)(a)", href="https://www.legislation.gov.uk/ukpga/2004/12/section/188", @@ -36,7 +46,16 @@ def formula(person, period, parameters): # A supplied ordinary return is an actual member payment, while the # NICs-only excess is not. Include full employer sacrifice separately # once at every age, retaining the age exclusion for member payments. - member_contributions = person("pension_contributions", period) + defined_benefit_share = clip( + person("defined_benefit_pension_share", period), 0, 1 + ) + money_purchase = 1 - defined_benefit_share + # Net pay contributions to defined-benefit arrangements are counted + # through the defined-benefit input amount instead. + net_pay = person("employee_pension_contributions", period) + member_contributions = ( + person("pension_contributions", period) - net_pay * defined_benefit_share + ) under_age_limit = ( person("age", period) < parameters( @@ -45,6 +64,10 @@ def formula(person, period, parameters): ) return ( member_contributions * under_age_limit - + person("employer_pension_contributions", period) - + person("pension_contributions_via_salary_sacrifice_from_pay", period) + + ( + person("employer_pension_contributions", period) + + person("pension_contributions_via_salary_sacrifice_from_pay", period) + ) + * money_purchase + + person("defined_benefit_pension_input_amount", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py new file mode 100644 index 0000000000..fdd811675f --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -0,0 +1,80 @@ +from policyengine_uk.model_api import * +from policyengine_uk.utils.pensions import annual_allowance_for_year + +# Years of assumed history: the three years whose unused allowance is carried +# forward, and the two before them, whose unused allowance an excess in those +# three years uses first. +HISTORY_YEARS = 5 + + +class unused_pension_annual_allowance(Variable): + label = "unused pension annual allowance brought forward" + documentation = ( + "Unused annual allowance from the three previous tax years that is " + "available for this tax year (FA 2004 s. 228A(5)): the amount by which " + "each of those years' annual allowance exceeded the total pension " + "input amount, for years in which the individual was a member of a " + "registered pension scheme, less any part already used up in an " + "intervening year (earliest year first). It increases this year's " + "annual allowance (s. 228A(2)). Supply it where the history is known. " + "Survey data records no past pension input, so from 2020-21 the " + "default assumes that in each of the five previous years the input " + "was this year's times gov.simulation.pensions.prior_year_input_ratio " + "and the incomes tested for the taper were this year's, each deflated " + "by average earnings growth, and that a person with pension input " + "this year was a member in each of those years. Each year's excess " + "over its allowance uses the unused allowance of the three years " + "before it, earliest first (s. 228A(7)). Before 2020-21 the default " + "is nil." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = [ + dict( + title="Finance Act 2004 s. 228A", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", + ), + dict( + title="HMRC Pensions Tax Manual PTM055100", + href="https://www.gov.uk/hmrc-internal-manuals/pensions-tax-manual/ptm055100", + ), + ] + + def formula_2020(person, period, parameters): + ratio = parameters(period).gov.simulation.pensions.prior_year_input_ratio + pension_input = person("pension_contributions_for_annual_allowance", period) + adjusted_income = person("pension_annual_allowance_adjusted_income", period) + threshold_income = person("pension_annual_allowance_threshold_income", period) + # Each earlier year's amounts are this year's over the average + # earnings growth since then. + level = {0: 1.0} + for years_back in range(1, HISTORY_YEARS + 1): + growth = parameters( + period.offset(1 - years_back, YEAR) + ).gov.economic_assumptions.yoy_growth.obr.average_earnings + level[years_back] = level[years_back - 1] / (1 + growth) + unused = {} + for years_back in range(HISTORY_YEARS, 0, -1): + year = period.offset(-years_back, YEAR) + scale = level[years_back] + allowance = annual_allowance_for_year( + adjusted_income * scale, + threshold_income * scale, + parameters(year).gov.hmrc.income_tax.allowances.annual_allowance, + year.start.year, + ) + prior_input = ratio * pension_input * scale + # s. 228A(7): an excess uses the unused allowance of the three + # years before it, earliest first. + excess = max_(0, prior_input - allowance) + for earlier in (years_back + 3, years_back + 2, years_back + 1): + if earlier in unused: + used = min_(unused[earlier], excess) + unused[earlier] = unused[earlier] - used + excess = excess - used + unused[years_back] = max_(0, allowance - prior_input) + # s. 228A(4): a year counts only if the individual was a member of a + # registered pension scheme then. + return where(pension_input > 0, unused[1] + unused[2] + unused[3], 0) diff --git a/policyengine_uk/variables/input/defined_benefit_pension_share.py b/policyengine_uk/variables/input/defined_benefit_pension_share.py new file mode 100644 index 0000000000..a5bd1f4f29 --- /dev/null +++ b/policyengine_uk/variables/input/defined_benefit_pension_share.py @@ -0,0 +1,32 @@ +from policyengine_uk.model_api import * + + +class defined_benefit_pension_share(Variable): + value_type = float + entity = Person + label = "Share of workplace pension contributions paid into defined-benefit arrangements" + documentation = ( + "The share, from 0 to 1, of the person's workplace pension " + "contributions that is paid into defined-benefit arrangements: " + "employer contributions, employee contributions under a net pay " + "arrangement and salary sacrifice. Personal pension contributions " + "(relief at source) are money purchase. The pension input amount of " + "a defined-benefit arrangement is the increase in the value of the " + "member's rights (FA 2004 s. 234), not the contributions paid, so the " + "annual allowance counts defined_benefit_pension_input_amount in " + "place of this share of the contributions. Data that records scheme " + "type supplies it. The default of nil treats every contribution as " + "money purchase." + ) + definition_period = YEAR + unit = "/1" + reference = [ + dict( + title="Finance Act 2004 s. 229 (total pension input amount)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/229", + ), + dict( + title="Finance Act 2004 s. 234 (defined benefits arrangements)", + href="https://www.legislation.gov.uk/ukpga/2004/12/section/234", + ), + ] diff --git a/policyengine_uk/variables/input/unused_pension_annual_allowance.py b/policyengine_uk/variables/input/unused_pension_annual_allowance.py deleted file mode 100644 index 59b2549769..0000000000 --- a/policyengine_uk/variables/input/unused_pension_annual_allowance.py +++ /dev/null @@ -1,23 +0,0 @@ -from policyengine_uk.model_api import * - - -class unused_pension_annual_allowance(Variable): - label = "unused pension annual allowance brought forward" - documentation = ( - "Unused annual allowance from the three previous tax years that is " - "available for this tax year (FA 2004 s. 228A(5)): the amount by which " - "each of those years' annual allowance exceeded the total pension " - "input amount, for years in which the individual was a member of a " - "registered pension scheme, less any part already used up in an " - "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Survey data does not record past " - "pension input amounts, so this defaults to nil." - ) - entity = Person - definition_period = YEAR - value_type = float - unit = GBP - reference = dict( - title="Finance Act 2004 s. 228A", - href="https://www.legislation.gov.uk/ukpga/2004/12/section/228A", - ) From ed1902c2cc7926b7b87eaf4aef11a2db5511a0ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:20:24 +0100 Subject: [PATCH 7/9] Fix carry-forward at nil in the UC deductions case; document how supplied pension inputs apply The UC earnings deductions case expects the whole excess charged, so it now fixes unused_pension_annual_allowance at nil. The prior-year ratio applies to all five years the estimate uses. A supplied unused allowance applies to its own year, and past the projection horizon the estimate replaces a stored one. A supplied defined-benefit input amount should come with the share of the contributions that fund it. Co-Authored-By: Claude Opus 5.5 --- docs/engineering/pension-annual-allowance.md | 8 ++++++-- .../gov/simulation/pensions/prior_year_input_ratio.yaml | 8 +++++--- .../universal_credit/income/uc_earnings_deductions.yaml | 2 ++ .../hmrc/pensions/defined_benefit_pension_input_amount.py | 5 ++++- .../gov/hmrc/pensions/unused_pension_annual_allowance.py | 5 ++++- 5 files changed, 21 insertions(+), 7 deletions(-) diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 8b803d500b..a57ef76623 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -176,7 +176,9 @@ the cash paid in. source) contributions are money purchase. - `defined_benefit_pension_input_amount` replaces those contributions in `pension_contributions_for_annual_allowance`. Supply it where it is known, - as on a pension savings statement. Otherwise it is the contributions times + as on a pension savings statement, with the share of the contributions that + fund it: contributions outside the share also count as money purchase + input. Otherwise it is the contributions times `gov.simulation.pensions.defined_benefit_input_per_contribution`, which is 1. In the four largest public service schemes in 2024-25 the input of an active member with ten years' service is 0.8 to 1.2 times employer and @@ -189,7 +191,9 @@ the cash paid in. **Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) adds the allowance left unused in each of the three previous tax years in which the person was a scheme member, earliest first. `unused_pension_annual_allowance` -can be supplied. From 2020-21 it otherwise defaults to an estimate: +can be supplied for a year; the supplied value is not carried into later +years, whose window differs. From 2020-21 it otherwise defaults to an +estimate: - In each of the five previous years, the pension input amount was this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml index 7c42ce9f90..c21975e31c 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -1,7 +1,9 @@ description: >- - Pension input amount in each of the three previous tax years as a share of - this year's, after average earnings growth, used to estimate unused annual - allowance carried forward where data has no pension history. + Pension input amount in each previous tax year as a share of this year's, + after average earnings growth, used to estimate unused annual allowance + carried forward where data has no pension history. The estimate looks back + five years: the three whose unused allowance is carried forward and the two + before them, whose unused allowance an excess in those three uses first. # Finance Act 2004 s. 228A carries forward the annual allowance that the # pension input amount left unused in each of the three previous tax years in # which the individual was a member of a registered pension scheme, earliest diff --git a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml index 99132fcbd3..309195440b 100644 --- a/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml +++ b/policyengine_uk/tests/policy/baseline/finance/benefit/family/universal_credit/income/uc_earnings_deductions.yaml @@ -355,6 +355,8 @@ age: 92 employment_income: 20_000 employer_pension_contributions: 65_000 + # No allowance carried forward, so the whole excess is charged. + unused_pension_annual_allowance: 0 married_couples_allowance: 11_000 would_claim_marriage_allowance: false partner: diff --git a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py index 25e4a9ee66..01101fbff7 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/defined_benefit_pension_input_amount.py @@ -11,7 +11,10 @@ class defined_benefit_pension_input_amount(Variable): "s. 234): 16 times the annual pension, plus any separate lump sum, at " "the end of the period less the same at its start, with the start " "value raised by CPI (s. 235), and nil if negative. Supply it where " - "known, as on a scheme's pension savings statement. Otherwise it is " + "known, as on a scheme's pension savings statement, together with " + "defined_benefit_pension_share for the workplace contributions that " + "fund it: contributions outside that share are counted as money " + "purchase input as well. Otherwise it is " "defined_benefit_pension_contributions times " "gov.simulation.pensions.defined_benefit_input_per_contribution." ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py index fdd811675f..5dd5edef15 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -16,7 +16,10 @@ class unused_pension_annual_allowance(Variable): "input amount, for years in which the individual was a member of a " "registered pension scheme, less any part already used up in an " "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Supply it where the history is known. " + "annual allowance (s. 228A(2)). Supply it where the history is known; " + "a supplied value applies to its own year only, since the window of " + "years moves on, and after the dataset projection ends (2032) this " + "estimate replaces a stored value. " "Survey data records no past pension input, so from 2020-21 the " "default assumes that in each of the five previous years the input " "was this year's times gov.simulation.pensions.prior_year_input_ratio " From 70ef5a77618a7063832256002ef717c1a0b8ecff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:57:31 +0100 Subject: [PATCH 8/9] Uprate water charges by country instead of through the Ofwat list #1863 lists water_and_sewerage_charges under the Ofwat index in uprating_indices.yaml. uprate_water_and_sewerage_charges now grows it by country, so the list entry would grow it a second time: drop it, and record the column among the code-health test's deliberate exceptions. Co-Authored-By: Claude Opus 5.5 --- policyengine_uk/data/uprating_indices.yaml | 2 -- .../tests/code_health/test_uprating_declarations.py | 4 ++++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/policyengine_uk/data/uprating_indices.yaml b/policyengine_uk/data/uprating_indices.yaml index 03ab55fdf8..2c39e2c19c 100644 --- a/policyengine_uk/data/uprating_indices.yaml +++ b/policyengine_uk/data/uprating_indices.yaml @@ -159,7 +159,5 @@ gov.economic_assumptions.yoy_growth.obr.private_pension_index: - private_pension_income gov.economic_assumptions.yoy_growth.obr.lagged_average_earnings: - housing_service_charges -gov.economic_assumptions.yoy_growth.ofwat.water_bills: -- water_and_sewerage_charges gov.economic_assumptions.yoy_growth.ons.population: - household_weight diff --git a/policyengine_uk/tests/code_health/test_uprating_declarations.py b/policyengine_uk/tests/code_health/test_uprating_declarations.py index b4b2572614..d5ba4fb1f0 100644 --- a/policyengine_uk/tests/code_health/test_uprating_declarations.py +++ b/policyengine_uk/tests/code_health/test_uprating_declarations.py @@ -38,6 +38,10 @@ "domestic_energy_consumption", "electricity_consumption", "gas_consumption", + # Grown by country in uprate_water_and_sewerage_charges (#2501): Ofwat's + # series for England and Wales, Scottish Water's for Scotland, none for + # Northern Ireland. A YAML entry would grow it a second time. + "water_and_sewerage_charges", } # Variables the YAML deliberately uprates by an index other than the one they From baf2d54ffb99f39166e4f71183a6380975786801 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Juaristi?= <127882282+juaristi22@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:36:56 +0100 Subject: [PATCH 9/9] Address review: keep stored carry-forward to its own year, state the assumptions, test against Table 7's excess MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Dataset projection no longer copies a stored unused_pension_annual_allowance into later years (YEAR_SPECIFIC_INPUTS): its three-year window moves each year, so projected years use the estimate. - The HMRC check also compares the chargeable amount with Table 7's £672m of excess, and names the gap as known data gaps, chiefly policyengine-uk-data#606: with employer contributions at a third of their stored values, count, charge and excess all fall within the band. - Parameters and docs say the defined-benefit ratio of 1 is a chosen midpoint (ten years' service; valid from 2023-24, when public service schemes revalue on 6 April; private schemes would push it below 1), that the carry-forward ratio of 1 is a convention which probably understates carry forward, and what a supplied share alone changes (only net pay to a defined-benefit scheme from age 75). The carry-forward ratio's label now matches its five-year use. - relevant_uk_earnings cites ITEPA 2003 ss. 9(2) and 11 for allowable expenses and says why pension deductions stay in; it and the carry-forward estimate note the limits left (furnished holiday lettings before 2025-26, the five-year window, 2015-16 alignment). - A carry-forward case where the taper binds only in an earlier year. - The UC benefit cap threshold notes that the national living wage parameter ends at April 2026, so the threshold holds at £10,572 after it. - Scottish water 2027-29 cite the Draft Determination's core cap (para. 9.1.3) and CPI forecast (Table 68); the HBAI validation row for water is updated, as in #2527. Co-Authored-By: Claude Opus 5.5 --- .github/test_outcome_allowlist.toml | 2 +- ...uc-benefit-cap-earnings-threshold.fixed.md | 2 +- docs/book/assumptions/water-bills.md | 2 +- docs/book/validation/hbai.md | 2 +- docs/engineering/pension-annual-allowance.md | 34 ++++++--- policyengine_uk/data/economic_assumptions.py | 12 ++++ .../gov/economic_assumptions/yoy_growth.yaml | 14 ++-- ...efined_benefit_input_per_contribution.yaml | 13 ++-- .../pensions/prior_year_input_ratio.yaml | 8 ++- .../test_annual_allowance_against_hmrc.py | 36 ++++++---- .../hmrc/pensions/relevant_uk_earnings.yaml | 8 ++- ...used_pension_annual_allowance_default.yaml | 19 +++++ ...test_unused_annual_allowance_projection.py | 71 +++++++++++++++++++ policyengine_uk/utils/pensions.py | 16 +++-- ...enefit_cap_earnings_exception_threshold.py | 5 +- .../unused_pension_annual_allowance.py | 15 ++-- 16 files changed, 205 insertions(+), 54 deletions(-) create mode 100644 policyengine_uk/tests/test_unused_annual_allowance_projection.py diff --git a/.github/test_outcome_allowlist.toml b/.github/test_outcome_allowlist.toml index f944f2e98f..7e521a6724 100644 --- a/.github/test_outcome_allowlist.toml +++ b/.github/test_outcome_allowlist.toml @@ -70,4 +70,4 @@ nodeid = "policyengine_uk/tests/microsimulation/test_annual_allowance_against_hm outcome = "xfailed" reason = "policyengine-uk-data#606" data = ["present"] -why = "Known data gap PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance charge several times above HMRC's. Pinned by a strict xfail until the dataset fixes it (#2523)." +why = "Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: the certified Enhanced FRS imputes employer pension contributions as three times employee contributions, which puts the 2024 annual allowance count, charge and excess several times above HMRC's; with a third of those contributions all three fall within the band. Pinned by a strict xfail until the data is fixed (#2523)." diff --git a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md index f453205a73..0a5a36c390 100644 --- a/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md +++ b/changelog.d/uc-benefit-cap-earnings-threshold.fixed.md @@ -1 +1 @@ -- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. +- Derive the Universal Credit benefit cap's earnings exception threshold each year from the national living wage: the pay for 16 hours a week, times 52 and divided by 12, with any fraction of a pound disregarded (Universal Credit Regulations 2013 regs. 82(1)(a) and 6(1A)(za)), and a fixed £430 a month before April 2017. It was fixed at the 2025-26 figure of £10,152 a year for every year; it is now £9,516 in 2024-25 and £10,572 in 2026-27. The national living wage parameter ends at its April 2026 rate, so the threshold stays at £10,572 in later years until that parameter is projected. diff --git a/docs/book/assumptions/water-bills.md b/docs/book/assumptions/water-bills.md index 18115c7c95..a97a5a6df4 100644 --- a/docs/book/assumptions/water-bills.md +++ b/docs/book/assumptions/water-bills.md @@ -99,7 +99,7 @@ This holds to the end of the dataset projection (2032). After it, policyengine-c | 2024 | 8.8% | [WICS, charges scheme 2024-25](https://wics.scot/publications/price-setting/strategic-review-charges-2021-27/scottish-waters-charges-scheme/charges-scheme-2024-25) | | 2025 | 9.9% | [Scottish Water, 28 January 2025](https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement) | | 2026 | 8.67% | [Scottish Water, 22 January 2026](https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026) | -| 2027 | 3.9% | WICS Draft Determination 2027-33: CPI + 2%, with WICS's CPI assumption of 1.9% | +| 2027 | 3.9% | [WICS Draft Determination 2027-33](https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf): the CPI + 2% core cap (para. 9.1.3) with WICS's CPI forecast of 1.9% (Table 68) | | 2028 | 3.9% | As above | | 2029 | 4.1% | As above, with CPI at 2.1% | diff --git a/docs/book/validation/hbai.md b/docs/book/validation/hbai.md index f9ccfc6609..557ea35f81 100644 --- a/docs/book/validation/hbai.md +++ b/docs/book/validation/hbai.md @@ -108,6 +108,6 @@ Each of the variables above is either derived from other variables, or is a dire | `maintenance_expenses` | gov.obr.consumer_price_index | Uprated based on CPI inflation | | `student_loan_repayments` | gov.economic_assumptions.indices.obr.average_earnings | Uprated based on OBR average earnings projections | | `rent` | gov.obr.rent | Social rents uprated by CPI+1%, private rents uprated with outturn data by region to 2025, then with backed-out private rent level growth from OBR aggregate rent forecasts (assuming social rent grows at CPI+1%) | -| `water_and_sewerage_charges` | gov.obr.consumer_price_index | Uprated based on CPI as proxy | +| `water_and_sewerage_charges` | gov.economic_assumptions.yoy_growth.ofwat.water_bills (England and Wales), gov.economic_assumptions.yoy_growth.scottish_water.water_bills (Scotland) | Uprated by each country's published bill increases, then projections; Northern Ireland has no domestic water charges and is held flat | | `mortgage_interest_repayment` | gov.obr.mortgage_interest | Uprated based on outturn Ofwat data to 2025, then Ofwat projections onwards | | `housing_service_charges` | gov.economic_assumptions.indices.obr.lagged_average_earnings | Uprated based on lagged average earnings as proxy | diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index a57ef76623..93f8ec037f 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -80,9 +80,12 @@ Ordinary pension relief, the ordinary `adjusted_total_income` and both income tests take relevant UK earnings ([s. 189(2)](https://www.legislation.gov.uk/ukpga/2004/12/section/189)) from one helper, `relevant_uk_earnings`: employment income chargeable to income -tax before deductions for pension contributions (pay and employer-paid taxable -statutory pay, less allowable expenses), and trading profits after capital -allowances and the trading allowance but before losses brought forward. +tax, which is net taxable earnings ([ITEPA 2003 ss. 9(2) and +11](https://www.legislation.gov.uk/ukpga/2003/1/section/11)), so pay and +employer-paid taxable statutory pay less allowable expenses; and trading +profits after capital allowances and the trading allowance but before losses +brought forward. The pension deductions that s. 327(5) also lists are the +relief these earnings limit, so they are not taken off. An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on @@ -186,14 +189,18 @@ the cash paid in. survey employer contributions drawn from regular contribution rates leave out deficit repair payments. - With the default share of nil, every contribution is counted as money - purchase input, as before. + purchase input, as before. With the ratio at 1, a supplied share alone + changes the input only for net-pay contributions to a defined-benefit + scheme from age 75, which now count, since the defined-benefit input has + no age limit. Results move only once data supplies a valued input amount. **Carry forward.** [s. 228A](https://www.legislation.gov.uk/ukpga/2004/12/section/228A) adds the allowance left unused in each of the three previous tax years in which the person was a scheme member, earliest first. `unused_pension_annual_allowance` -can be supplied for a year; the supplied value is not carried into later -years, whose window differs. From 2020-21 it otherwise defaults to an -estimate: +can be supplied for a year. A supplied or stored value applies to that year +only: the window of years moves on, so dataset projection does not copy it +into later years (`YEAR_SPECIFIC_INPUTS` in `data/economic_assumptions.py`), +which use the estimate. From 2020-21 it otherwise defaults to an estimate: - In each of the five previous years, the pension input amount was this year's times `gov.simulation.pensions.prior_year_input_ratio` (1), and the @@ -208,7 +215,10 @@ estimate: With a ratio of 1 the estimate matters mainly where the allowance has risen, as it did to £60,000 in 2023-24, or where pay has grown. A ratio of 0, no input -in earlier years, gives the most there could be. +in earlier years, gives the most there could be. The ratio of 1 is a +convention, since HMRC publishes no statistics on carry forward. Real +excesses are often lumpy (a defined-benefit promotion, a one-off +contribution), so a steady history probably understates carry forward. **Against HMRC.** HMRC's Private pension statistics (July 2026) count 30,440 individuals reporting contributions above their allowance through Self @@ -217,6 +227,8 @@ estimate annual allowance charges of £0.3bn (Table 6). On the certified Enhanced FRS the model's 2024 count and charge are several times these, because the dataset imputes employer contributions as three times employee contributions ([policyengine-uk-data#606](https://github.com/PolicyEngine/policyengine-uk-data/issues/606)). -`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks both -against half to one and a half times HMRC's figures, as a strict expected -failure until that is fixed. +With employer contributions at a third of their stored values, the count, +the charge and the chargeable amount (against Table 7's £672m of excess) all +fall within half to one and a half times HMRC's figures. +`tests/microsimulation/test_annual_allowance_against_hmrc.py` checks all three +against that band, as a strict expected failure until the data is fixed. diff --git a/policyengine_uk/data/economic_assumptions.py b/policyengine_uk/data/economic_assumptions.py index 28e992fb87..a59ebd9920 100644 --- a/policyengine_uk/data/economic_assumptions.py +++ b/policyengine_uk/data/economic_assumptions.py @@ -112,6 +112,11 @@ "gas_consumption", } ) +# Inputs that describe only the year they are stored for, so are not carried +# into projected years. Unused annual allowance comes from the three tax years +# before this one (Finance Act 2004 s. 228A), a window that moves each year; a +# projected year uses the variable's own estimate instead (#2523). +YEAR_SPECIFIC_INPUTS = frozenset({"unused_pension_annual_allowance"}) def _unmapped_uprating_indices(uprating): @@ -294,6 +299,13 @@ def apply_single_year_uprating( f"not {source_year}–{target_year}." ) + # Year-specific inputs are dropped from every projected year. + for table_name in current_year.table_names: + table = getattr(current_year, table_name) + stored = [name for name in YEAR_SPECIFIC_INPUTS if name in table.columns] + if stored: + table.drop(columns=stored, inplace=True) + # First, apply standard variable-YoY growth based uprating. uprating = _uprating_index_map() diff --git a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml index 7f6e8f775c..0c3d6284ca 100644 --- a/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml +++ b/policyengine_uk/parameters/gov/economic_assumptions/yoy_growth.yaml @@ -1263,10 +1263,12 @@ scottish_water: # announced and WICS approved for each year. Every council tax band's # charge is a fixed fraction of Band D, so each band rises by the same # percentage. - # - 2027-2029: the cap in WICS's Draft Determination for 2027-33 - # (consultation 30 June to 1 September 2026), CPI + 2% a year, with - # WICS's CPI assumption of 1.9%, 1.9% and 2.1%. WICS publishes the - # Final Determination on 29 October 2026; replace these with it. + # - 2027-2029: the core cap in WICS's Draft Determination for 2027-33 + # (consultation 30 June to 1 September 2026), CPI + 2% in each of the + # first three years (para. 9.1.3), with CPI from WICS's forecast of + # October CPI in the previous year, 1.9%, 1.9% and 2.1% (Table 68, from + # OBR's March 2026 forecast). WICS publishes the Final Determination on + # 29 October 2026; replace these with it. # Later years hold the 2029-30 rate. values: 2022-01-01: 0.042 @@ -1291,7 +1293,9 @@ scottish_water: href: https://www.scottishwater.co.uk/About-Us/News-and-Views/2025/01/Charges-2025-to-2026-Announcement - title: Scottish Water, "Average water bills to rise £42 in 2026" (22 January 2026; 8.67%) href: https://www.scottishwater.co.uk/about-us/news-and-views/2026/01/230126-charges-2026 - - title: WICS, "WICS publishes 2027-33 Draft Determination" (CPI + 2% cap; Final Determination 29 October 2026) + - title: WICS, Strategic Review of Charges 2027-33 Draft Determination (June 2026), para. 9.1.3 (CPI + 2% core cap) and Table 68 (CPI forecast, p. 258) + href: https://wics.scot/system/files/2026-06/Draft-Determination-2027-33.pdf + - title: WICS, "WICS publishes 2027-33 Draft Determination" (Final Determination 29 October 2026) href: https://wics.scot/latest/wics-publishes-2027-33-draft-determination finance_ni: domestic_rates: diff --git a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml index 5d7ec93b0e..d28a31aa1c 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/defined_benefit_input_per_contribution.yaml @@ -15,10 +15,15 @@ description: >- # - Civil Service alpha (2.32%, CPI): 37%; 28.97% plus 4.6-8.05%, 1.00 to 1.10. # - LGPS 2014 (1/49, CPI): 33%; about 21.1% plus 5.5-12.5%, 0.97 to 1.23. # The ratio is lower where employer rates are higher (Police 2015, 35.3%; -# Armed Forces 2015, 73.5%). Survey employer contributions drawn from -# employers' regular contribution rates leave out deficit repair payments, so -# contributions are counted at their face value: a ratio of 1. Data that -# values defined-benefit accrual supplies defined_benefit_pension_input_amount. +# Armed Forces 2015, 73.5%), and private-sector schemes, whose employer rates +# are often much higher, would push it below 1 too. Survey employer +# contributions drawn from employers' regular contribution rates leave out +# deficit repair payments. A ratio of 1, counting contributions at face value, +# is a chosen midpoint: the ten years' service and the step to exactly 1 are +# judgements. The comparison holds from 2023-24, since when these schemes +# revalue on 6 April with the same September CPI as s. 235's opening value; +# it does not hold for pension input periods before then. Data that values +# defined-benefit accrual supplies defined_benefit_pension_input_amount. values: 2004-04-06: 1 metadata: diff --git a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml index c21975e31c..c57821b4c6 100644 --- a/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml +++ b/policyengine_uk/parameters/gov/simulation/pensions/prior_year_input_ratio.yaml @@ -13,13 +13,17 @@ description: >- # income tested for that year's taper is deflated the same way. A ratio of 0 # assumes no input in those years, the most allowance there could be to # carry forward. HMRC publishes no count of carry-forward use: Self Assessment -# reports only the excess over the allowance once it is applied. +# reports only the excess over the allowance once it is applied. So 1 is a +# convention, not an estimate from statistics. With smooth earnings growth it +# leaves almost nothing to carry forward for anyone over this year's +# allowance, while real excesses are often lumpy (a defined-benefit promotion, +# a one-off contribution), so it probably understates carry forward. values: 2004-04-06: 1 metadata: unit: /1 period: year - label: Pension input in each of the three previous years relative to this year + label: Pension input in each previous year relative to this year, for carry forward reference: - title: Finance Act 2004 s. 228A href: https://www.legislation.gov.uk/ukpga/2004/12/section/228A diff --git a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py index 38749baae3..65148488ff 100644 --- a/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py +++ b/policyengine_uk/tests/microsimulation/test_annual_allowance_against_hmrc.py @@ -2,17 +2,21 @@ HMRC, Private pension statistics (July 2026), 2024-25 provisional: - Table 7: 30,440 individuals reported pension contributions exceeding their - annual allowance through Self Assessment. Self Assessment reports only the - excess over the allowance after unused allowance is carried forward (HMRC, - HS345 Pension savings tax charges, 2025). + annual allowance through Self Assessment, with £672m of contributions above + it. Self Assessment reports only the excess over the allowance after unused + allowance is carried forward (HMRC, HS345 Pension savings tax charges, 2025). - Table 6: annual allowance charges of £0.3bn, rounded to the nearest £0.1bn. -The model's count of payers and its charge in 2024 must each lie within half -to one and a half times these. The certified Enhanced FRS imputes employer -pension contributions as three times employee contributions -(PolicyEngine/policyengine-uk-data#606), which puts both several times above -HMRC's, so this is a strict expected failure until the dataset fixes that. The -test reports no model value: the counts rest on survey records. +The model's count of payers, its charge and its chargeable amount in 2024 must +each lie within half to one and a half times these. The excess does not depend +on marginal rates, so it is the cleaner of the two amounts. + +On the certified Enhanced FRS all three are several times HMRC's. The known +data gaps behind that are chiefly policyengine-uk-data#606: the dataset +imputes employer pension contributions as three times employee contributions. +With employer contributions at a third of their stored values all three fall +within the band, so this is a strict expected failure until the data is +fixed. The test reports no model value: the counts rest on survey records. """ import pytest @@ -21,6 +25,7 @@ YEAR = 2024 HMRC_PAYERS = 30_440 +HMRC_EXCESS = 672e6 HMRC_CHARGE = 0.3e9 TOLERANCE = 0.5 @@ -33,12 +38,17 @@ def _within(value, target): @pytest.mark.xfail( strict=True, raises=AssertionError, - reason="PolicyEngine/policyengine-uk-data#606: employer pension " - "contributions imputed as three times employee contributions", + reason="Known data gaps, chiefly PolicyEngine/policyengine-uk-data#606: " + "employer pension contributions imputed as three times employee " + "contributions", ) def test_2024_annual_allowance_charge_and_payers_are_near_hmrc(): - charge = Microsimulation().calculate("personal_pension_contributions_tax", YEAR) + sim = Microsimulation() + charge = sim.calculate("personal_pension_contributions_tax", YEAR) + excess = sim.calculate("pension_annual_allowance_chargeable_amount", YEAR) near_hmrc = bool( - _within((charge > 0).sum(), HMRC_PAYERS) and _within(charge.sum(), HMRC_CHARGE) + _within((charge > 0).sum(), HMRC_PAYERS) + and _within(charge.sum(), HMRC_CHARGE) + and _within(excess.sum(), HMRC_EXCESS) ) assert near_hmrc, "The 2024 annual allowance charge is outside HMRC's band." diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml index 434ab77203..26fdbe1c6c 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/relevant_uk_earnings.yaml @@ -1,13 +1,17 @@ # Relevant UK earnings (FA 2004 s.189(2)) set the annual limit on relief for a # person's own pension contributions (s.190). They are employment income -# chargeable to income tax, after allowable expenses but before the pension -# deductions this relief gives, and trading income chargeable under ITTOIA +# chargeable to income tax, which is net taxable earnings (ITEPA 2003 ss.9(2) +# and 11), so allowable expenses (s.327(3)) come off; the pension deductions +# s.327(5) also lists are the relief these earnings limit, so they do not. +# They also include trading income chargeable under ITTOIA # 2005 Part 2, which is after capital allowances (CAA 2001 s.247) and the # trading allowance (ITTOIA 2005 s.783AF) but before relief for losses brought # forward (ITA 2007 s.83, a Step 2 deduction). pension_contributions_relief and # the pension deduction in adjusted_total_income (ITA 2007 s.24A(8)) use the # same figure. Each case is a person aged 45 in England with no other income. # Sources: https://www.legislation.gov.uk/ukpga/2004/12/section/189 +# https://www.legislation.gov.uk/ukpga/2003/1/section/11 +# https://www.legislation.gov.uk/ukpga/2003/1/section/327 # https://www.legislation.gov.uk/ukpga/2001/2/section/247 # https://www.legislation.gov.uk/ukpga/2005/5/section/783AF diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml index 89a0ef8a77..bc5ff5b182 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/unused_pension_annual_allowance_default.yaml @@ -101,3 +101,22 @@ employer_pension_contributions: 30_000 output: unused_pension_annual_allowance: 0 + +- name: A taper that binds only in an earlier year cuts what that year leaves + period: 2022 + absolute_error_margin: 0.01 + input: + age: 45 + employment_income: 180_000 + employer_pension_contributions: 20_000 + output: + # Adjusted income of 200,000 is under 2022-23's taper limit of 240,000. + # Deflated by earnings growth of 6.14% (2022), 5.9% (2021) and 0.3% + # (2020), the 2019-20 adjusted income is 177,400.16 and threshold income + # 159,660.15, over that year's limits of 150,000 and 110,000, so its + # allowance is 40,000 - floor((177,400.16 - 150,000) / 2) = 26,300, and + # its input of 17,740.02 leaves 8,559.98. 2020-21 and 2021-22 are under + # 240,000 and leave 22,206.76 and 21,156.96 of 40,000. Untapered, 2019-20 + # would leave 22,259.98, for 65,623.70 in all. + pension_annual_allowance: 40_000 + unused_pension_annual_allowance: 51_923.71 diff --git a/policyengine_uk/tests/test_unused_annual_allowance_projection.py b/policyengine_uk/tests/test_unused_annual_allowance_projection.py new file mode 100644 index 0000000000..dd8b78c249 --- /dev/null +++ b/policyengine_uk/tests/test_unused_annual_allowance_projection.py @@ -0,0 +1,71 @@ +"""A stored unused annual allowance is for its own year only (#2523). + +Unused allowance carried forward (Finance Act 2004 s. 228A) comes from the +three tax years before the one it is for, a window that moves each year. So a +dataset's stored value stays in its own year, and each projected year uses the +variable's estimate instead of a copy. +""" + +import numpy as np +import pandas as pd +import pytest + +from policyengine_uk import Microsimulation, parameters +from policyengine_uk.data import UKSingleYearDataset +from policyengine_uk.data.economic_assumptions import extend_single_year_dataset + +STORED = 50_000.0 + + +def _dataset(stored=None): + ids = np.arange(1, 2) + person = { + "person_id": ids, + "person_benunit_id": ids, + "person_household_id": ids, + "age": 45, + "employment_income": 100_000.0, + "employer_pension_contributions": 70_000.0, + } + if stored is not None: + person["unused_pension_annual_allowance"] = stored + return UKSingleYearDataset( + person=pd.DataFrame(person), + benunit=pd.DataFrame({"benunit_id": ids}), + household=pd.DataFrame( + { + "household_id": ids, + "household_weight": 1.0, + "region": "LONDON", + "tenure_type": "OWNED_OUTRIGHT", + "rent": 0.0, + "council_tax": 0.0, + } + ), + fiscal_year=2024, + ) + + +def test_projected_years_do_not_copy_a_stored_unused_allowance(): + extended = extend_single_year_dataset(_dataset(STORED), parameters, end_year=2027) + + assert extended[2024].person["unused_pension_annual_allowance"].tolist() == [STORED] + for year in (2025, 2026, 2027): + assert "unused_pension_annual_allowance" not in extended[year].person.columns + + +def test_a_projected_year_uses_the_estimate_not_the_stored_value(): + stored = Microsimulation(dataset=_dataset(STORED)) + estimated = Microsimulation(dataset=_dataset()) + + assert stored.calculate("unused_pension_annual_allowance", 2024).values == ( + pytest.approx([STORED]) + ) + # Saving 70,000 a year, grown with pay, exceeds each earlier allowance, so + # the estimate for 2026 carries nothing forward; a copy would give 50,000. + assert stored.calculate( + "unused_pension_annual_allowance", 2026 + ).values == pytest.approx( + estimated.calculate("unused_pension_annual_allowance", 2026).values + ) + assert stored.calculate("unused_pension_annual_allowance", 2026).values[0] == 0 diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 0ac720fb60..8b5ecb3ff0 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -26,12 +26,16 @@ def trading_profits_before_losses(person, period, hmrc): def relevant_uk_earnings(person, period, hmrc): """FA 2004 s.189(2) relevant UK earnings. - Employment income chargeable to income tax, before the deductions for - pension contributions that this relief itself gives: pay and - employer-paid taxable SSP/SMP, which the model holds separately from - wages, less allowable expenses. Trading income is the profit chargeable - under ITTOIA 2005 Part 2, before losses. - A trading loss does not reduce employment earnings (HMRC PTM044100). + Employment income chargeable to income tax is net taxable earnings + (ITEPA 2003 ss.9(2) and 11): pay and employer-paid taxable SSP/SMP, which + the model holds separately from wages, less the allowable expenses in + s.327(3). s.327(5) also lists the deductions for pension contributions + (FA 2004 ss.188-194), but those are the relief that these earnings limit, + so they are not taken off; the limit would otherwise depend on itself. + Trading income is the profit chargeable under ITTOIA 2005 Part 2, before + losses. A trading loss does not reduce employment earnings (HMRC + PTM044100). Furnished holiday lettings profits, relevant UK earnings + before 2025-26 (s.189(2)(ba) and (bb)), are not modelled separately. """ return person("taxable_employment_income", period) + ( trading_profits_before_losses(person, period, hmrc) diff --git a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py index e71be2a417..ec6b360596 100644 --- a/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py +++ b/policyengine_uk/variables/gov/dwp/universal_credit/benefit_cap/uc_benefit_cap_earnings_exception_threshold.py @@ -13,7 +13,10 @@ class uc_benefit_cap_earnings_exception_threshold(Variable): "wage, the hourly rate in regulation 4 of the National Minimum Wage " "Regulations, multiplied by 52 and divided by 12, with any fraction " "of a pound disregarded (reg. 6(1A)(za)). Before then it was a fixed " - "£430 a month." + "£430 a month. gov.hmrc.minimum_wage.non_apprentice holds its April " + "2026 rate in later years, so the threshold stays at £10,572 a year " + "from 2026-27 until that parameter is projected, as the minimum " + "income floor does." ) definition_period = YEAR unit = GBP diff --git a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py index 5dd5edef15..3ed25308a6 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/unused_pension_annual_allowance.py @@ -16,10 +16,10 @@ class unused_pension_annual_allowance(Variable): "input amount, for years in which the individual was a member of a " "registered pension scheme, less any part already used up in an " "intervening year (earliest year first). It increases this year's " - "annual allowance (s. 228A(2)). Supply it where the history is known; " - "a supplied value applies to its own year only, since the window of " - "years moves on, and after the dataset projection ends (2032) this " - "estimate replaces a stored value. " + "annual allowance (s. 228A(2)). Supply it where the history is known. " + "A value supplied or stored for a year applies to that year only, " + "since the window of years moves on: dataset projection does not copy " + "it into later years, which use this estimate. " "Survey data records no past pension input, so from 2020-21 the " "default assumes that in each of the five previous years the input " "was this year's times gov.simulation.pensions.prior_year_input_ratio " @@ -27,8 +27,11 @@ class unused_pension_annual_allowance(Variable): "by average earnings growth, and that a person with pension input " "this year was a member in each of those years. Each year's excess " "over its allowance uses the unused allowance of the three years " - "before it, earliest first (s. 228A(7)). Before 2020-21 the default " - "is nil." + "before it, earliest first (s. 228A(7)). The five years leave out " + "the unused allowance an excess three years back would have used " + "first, so the estimate can carry slightly too little forward, and " + "the 2015-16 pension input period alignment rules are not applied. " + "Before 2020-21 the default is nil." ) entity = Person definition_period = YEAR