From d1f5d1ff8edc414cf61ced8de274378d52201b3e Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 17:18:48 +0200 Subject: [PATCH 01/10] Add failing tests for the under-21 and apprentice employer NI zero rates --- .../class_1/ni_class_1_employer.yaml | 107 ++++++++++++++++++ uv.lock | 8 +- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_employer.yaml index f164638623..a2024a648e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_employer.yaml @@ -217,3 +217,110 @@ employment_income: 100_000 output: ni_class_1_employer: 13_680.45 # 0.1505 x (100,000 - 9,100) + +# SSCBA 1992 s.9A: the secondary percentage is 0% for an earner under 21 on +# earnings up to the upper secondary threshold (UST). s.9B: the same for a +# relevant apprentice under 25 up to the apprentice upper secondary threshold +# (AUST). Earnings above the UST or AUST bear the ordinary secondary +# percentage (s.9A(9)-(10), s.9B(7)). SI 2001/1004 reg 10(e)-(f) sets both at +# £967 a week for 2026-27, and reg 11(3B)-(3C) at £50,270 for an annual +# earnings period; the model stores 50,270 / 52 = 966.73 a week, as it does +# for the upper earnings limit. + +- name: No employer NI on an earner aged 20 below the upper secondary threshold (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 20 + employment_income: 30_000 + output: + ni_class_1_employer: 0 + +- name: Employer NI on an earner aged 20 only above the upper secondary threshold (2026) + period: 2026 + absolute_error_margin: 0.1 + input: + age: 20 + employment_income: 60_000 + output: + ni_class_1_employer: 1_459.50 # 0.15 x (60,000 - 50,270) + +- name: An earner aged 21 bears the full employer rate (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 21 + employment_income: 30_000 + output: + ni_class_1_employer: 3_750 # 0.15 x (30,000 - 5,000) + +- name: No employer NI on an apprentice aged 24 below the apprentice upper secondary threshold (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 24 + is_apprentice: true + employment_income: 30_000 + output: + ni_class_1_employer: 0 + +- name: An apprentice aged 25 bears the full employer rate (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 25 + is_apprentice: true + employment_income: 30_000 + output: + ni_class_1_employer: 3_750 # 0.15 x (30,000 - 5,000) + +- name: A non-apprentice aged 22 bears the full employer rate (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 22 + is_apprentice: false + employment_income: 30_000 + output: + ni_class_1_employer: 3_750 + +- name: No employer NI on an earner aged 18 in 2024-25 below the upper secondary threshold + period: 2024 + absolute_error_margin: 0.01 + input: + age: 18 + employment_income: 30_000 + output: + ni_class_1_employer: 0 + +- name: The under-21 zero rate applies from 2015-16 (UST £815 a week) + period: 2015 + absolute_error_margin: 0.01 + input: + age: 19 + ni_class_1_income: 50_000 + output: + ni_class_1_employer: 1_051.56 # 0.138 x (50,000 - 815 x 52 = 42,380) + +# s.9B took effect from 6 April 2016, so a 2015-16 apprentice aged 23 bears +# the full rate above the £8,112 secondary threshold. + +- name: No apprentice zero rate in 2015-16 + period: 2015 + absolute_error_margin: 0.01 + input: + age: 23 + is_apprentice: true + ni_class_1_income: 30_000 + output: + ni_class_1_employer: 3_020.54 # 0.138 x (30,000 - 8,112) = 3,020.544 + +- name: The apprentice zero rate applies from 2016-17 (AUST £827 a week) + period: 2016 + absolute_error_margin: 0.01 + input: + age: 23 + is_apprentice: true + ni_class_1_income: 30_000 + output: + ni_class_1_employer: 0 diff --git a/uv.lock b/uv.lock index 3ed34fa540..2dca48a931 100644 --- a/uv.lock +++ b/uv.lock @@ -880,8 +880,8 @@ name = "httpcore2" version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11", marker = "sys_platform != 'emscripten'" }, - { name = "truststore", marker = "sys_platform != 'emscripten'" }, + { name = "h11" }, + { name = "truststore" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ @@ -2108,7 +2108,7 @@ name = "pexpect" version = "4.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "ptyprocess", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" }, + { name = "ptyprocess" }, ] sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450, upload-time = "2023-11-25T09:07:26.339Z" } wheels = [ @@ -2176,7 +2176,7 @@ wheels = [ [[package]] name = "policyengine-uk" -version = "2.126.8" +version = "2.139.0" source = { editable = "." } dependencies = [ { name = "microdf-python" }, From a66fd641d4fc02d9eb4f4875d8bda113e0c59e8a Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Mon, 19 Oct 2026 17:39:03 +0200 Subject: [PATCH 02/10] Zero-rate employer NI for under-21s and young apprentices Apply SSCBA 1992 s.9A (0% up to the upper secondary threshold for earners under 21) and s.9B (0% up to the apprentice upper secondary threshold for apprentices under 25) in ni_class_1_employer, with the thresholds from SI 2001/1004 reg 10(e)-(f) and kept equal to the UEL after 2030-31. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1918.fixed.md | 1 + .../gov/hmrc/create_threshold_indexation.py | 23 ++-- .../secondary_zero_rate_age_limits/README.md | 1 + .../relevant_apprentice.yaml | 11 ++ .../under_21_group.yaml | 11 ++ .../apprentice_upper_secondary_threshold.yaml | 106 ++++++++++++++++++ .../thresholds/upper_secondary_threshold.yaml | 103 +++++++++++++++++ .../tests/test_ni_class_1_age_properties.py | 18 +-- .../tests/test_threshold_indexation.py | 35 +++++- .../class_1/ni_class_1_employer.py | 53 ++++++++- 10 files changed, 339 insertions(+), 23 deletions(-) create mode 100644 changelog.d/1918.fixed.md create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/README.md create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/relevant_apprentice.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/under_21_group.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/apprentice_upper_secondary_threshold.yaml create mode 100644 policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_secondary_threshold.yaml diff --git a/changelog.d/1918.fixed.md b/changelog.d/1918.fixed.md new file mode 100644 index 0000000000..f8ea51c42e --- /dev/null +++ b/changelog.d/1918.fixed.md @@ -0,0 +1 @@ +- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. diff --git a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index e6be77d34e..0d24b85168 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -59,7 +59,11 @@ - upper profits limit = personal allowance + basic rate limit; - primary threshold and upper earnings limit = those annual amounts divided by 52 and rounded to the penny, the weekly convention the parameter files - already use (12,570 / 52 = 241.73 and 50,270 / 52 = 966.73). + already use (12,570 / 52 = 241.73 and 50,270 / 52 = 966.73); +- upper secondary threshold and apprentice upper secondary threshold = the + upper earnings limit. HMRC's Budget 2025 note says they "will remain aligned + to the National Insurance contributions UEL" to 2030-31, and they have + equalled it in every year since they were introduced. The secondary threshold is not aligned with an income tax threshold. HMRC's Autumn Budget 2024 note says that after its freeze it "will be increased in @@ -85,7 +89,7 @@ (a reform dict, ``parameter_changes``, ``Reform.from_dict``) writes its changes onto the parameters before they are processed, as does a simulation modifier that resets and reprocesses them (as ``no_economic_assumptions`` does), so the -values are rebuilt with the change: a changed CPI forecast moves all seven, +values are rebuilt with the change: a changed CPI forecast moves all nine, and a change to the personal allowance or basic rate limit after 2030-31 also moves the aligned NICs thresholds (see ``policyengine_uk.utils.dated_reforms``). The exceptions edit the processed parameters, so they do not move the others: @@ -127,6 +131,8 @@ "gov.hmrc.national_insurance.class_1.thresholds.primary_threshold", "gov.hmrc.national_insurance.class_1.thresholds.upper_earnings_limit", "gov.hmrc.national_insurance.class_1.thresholds.secondary_threshold", + "gov.hmrc.national_insurance.class_1.thresholds.upper_secondary_threshold", + "gov.hmrc.national_insurance.class_1.thresholds.apprentice_upper_secondary_threshold", "gov.hmrc.national_insurance.class_4.thresholds.lower_profits_limit", "gov.hmrc.national_insurance.class_4.thresholds.upper_profits_limit", ) @@ -317,6 +323,8 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_1.primary_threshold, class_1.upper_earnings_limit, class_1.secondary_threshold, + class_1.upper_secondary_threshold, + class_1.apprentice_upper_secondary_threshold, class_4.lower_profits_limit, class_4.upper_profits_limit, ] @@ -348,12 +356,13 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_1.primary_threshold, {year: weekly_equivalent(value) for year, value in personal_allowance.items()}, ) + upper_earnings_limit = { + year: weekly_equivalent(value) for year, value in higher_rate_threshold.items() + } + write_unstated_tax_years(class_1.upper_earnings_limit, upper_earnings_limit) + write_unstated_tax_years(class_1.upper_secondary_threshold, upper_earnings_limit) write_unstated_tax_years( - class_1.upper_earnings_limit, - { - year: weekly_equivalent(value) - for year, value in higher_rate_threshold.items() - }, + class_1.apprentice_upper_secondary_threshold, upper_earnings_limit ) write_unstated_tax_years(class_1.secondary_threshold, secondary_threshold) for parameter in indexed: diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/README.md b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/README.md new file mode 100644 index 0000000000..30c8b4f120 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/README.md @@ -0,0 +1 @@ +# Secondary zero rate age limits diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/relevant_apprentice.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/relevant_apprentice.yaml new file mode 100644 index 0000000000..95acffc5f9 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/relevant_apprentice.yaml @@ -0,0 +1,11 @@ +description: Age below which an apprentice is a relevant apprentice, whose secondary Class 1 contributions are zero-rated up to the apprentice upper secondary threshold. +values: + 2015-04-06: + value: 25 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.9B(2)(a) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/9B +metadata: + label: NI relevant apprentice age limit + unit: year diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/under_21_group.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/under_21_group.yaml new file mode 100644 index 0000000000..7abda7ec28 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/secondary_zero_rate_age_limits/under_21_group.yaml @@ -0,0 +1,11 @@ +description: Age below which an earner falls in the under-21 group, whose age-related secondary percentage is 0% up to the upper secondary threshold. +values: + 2015-04-06: + value: 21 + metadata: + reference: + - title: Social Security Contributions and Benefits Act 1992 s.9A(3) + href: https://www.legislation.gov.uk/ukpga/1992/4/section/9A +metadata: + label: NI under-21 zero rate age limit + unit: year diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/apprentice_upper_secondary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/apprentice_upper_secondary_threshold.yaml new file mode 100644 index 0000000000..99f799d10b --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/apprentice_upper_secondary_threshold.yaml @@ -0,0 +1,106 @@ +description: Apprentice upper secondary threshold, up to which secondary Class 1 contributions are zero-rated for a relevant apprentice under 25. +# The threshold has equalled the upper earnings limit (UEL) in every year since +# it was introduced. From 2021-22 the model stores the annual earnings period +# amount divided by 52 (50,270 / 52, rounded to 966.73), as for the UEL; reg +# 10 states £967 a week and reg 11(3C) £50,270 for an annual earnings period. +# Legal status: SI 2001/1004 reg 10 sets the amount for 2026-27 only. The +# 2027-28 to 2030-31 values are announced policy: HMRC's Budget 2025 note says +# the UST and AUST "will remain aligned to the National Insurance +# contributions UEL at £967 per week up until the 2030 to 2031 tax year". +# From 2031-32 the model keeps the threshold equal to the UEL +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py), a +# modelling assumption consistent with that alignment. The file has no +# uprating. +# s.9B took effect from 6 April 2016 (National Insurance Contributions Act +# 2015 s.1). The 2015-16 value of 0 gives no zero-rate band in that year: the +# model backdates a parameter's earliest value, so it is stated explicitly. +values: + 2015-04-06: + value: 0 + metadata: + reference: + - title: National Insurance Contributions Act 2015 s.1 (inserting SSCBA 1992 s.9B), in force from 6 April 2016 + href: https://www.legislation.gov.uk/ukpga/2015/5/section/1 + 2016-04-06: + value: 827 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2016-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 + 2017-04-06: + value: 866 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2017-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2017-04-06 + 2018-04-06: + value: 892 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2018-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2018-04-06 + 2019-04-06: + value: 962 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2019-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2019-04-06 + 2020-04-06: + value: 962 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2020-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2020-04-06 + 2021-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f), as at 2021-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2021-04-06 + 2026-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(f) and reg 11(3C), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + 2027-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2028-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2029-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2030-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 +metadata: + label: NI apprentice upper secondary threshold + unit: currency-GBP + period: week + reference: + - title: Social Security Contributions and Benefits Act 1992 s.9B + href: https://www.legislation.gov.uk/ukpga/1992/4/section/9B + - title: The Social Security (Contributions) Regulations 2001 reg 10(f) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10 diff --git a/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_secondary_threshold.yaml b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_secondary_threshold.yaml new file mode 100644 index 0000000000..a31581e530 --- /dev/null +++ b/policyengine_uk/parameters/gov/hmrc/national_insurance/class_1/thresholds/upper_secondary_threshold.yaml @@ -0,0 +1,103 @@ +description: Upper secondary threshold for the under-21 group, up to which the age-related secondary percentage of 0% applies to an earner under 21. +# The threshold has equalled the upper earnings limit (UEL) in every year since +# it was introduced. From 2021-22 the model stores the annual earnings period +# amount divided by 52 (50,270 / 52, rounded to 966.73), as for the UEL; reg +# 10 states £967 a week and reg 11(3B) £50,270 for an annual earnings period. +# Legal status: SI 2001/1004 reg 10 sets the amount for 2026-27 only. The +# 2027-28 to 2030-31 values are announced policy: HMRC's Budget 2025 note says +# the UST and AUST "will remain aligned to the National Insurance +# contributions UEL at £967 per week up until the 2030 to 2031 tax year". +# From 2031-32 the model keeps the threshold equal to the UEL +# (policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py), a +# modelling assumption consistent with that alignment. The file has no +# uprating. +values: + 2015-04-06: + value: 815 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2015-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2015-04-06 + 2016-04-06: + value: 827 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2016-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2016-04-06 + 2017-04-06: + value: 866 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2017-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2017-04-06 + 2018-04-06: + value: 892 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2018-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2018-04-06 + 2019-04-06: + value: 962 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2019-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2019-04-06 + 2020-04-06: + value: 962 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2020-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2020-04-06 + 2021-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e), as at 2021-04-06 + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10/2021-04-06 + 2026-04-06: + value: 966.73 + metadata: + reference: + - title: The Social Security (Contributions) Regulations 2001 reg 10(e) and reg 11(3B), for 2026-27 (reg 10's tax year set by SI 2026/231 reg 5(2)(a)) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/11/2026-04-06 + 2027-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2028-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2029-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 + 2030-04-06: + value: 966.73 + metadata: + reference: + - title: "HMRC: Maintaining Income Tax and equivalent National Insurance contributions thresholds until 5 April 2031" + href: https://www.gov.uk/government/publications/maintaining-income-tax-and-equivalent-national-insurance-contributions-thresholds-until-5-april-2031/income-tax-maintaining-the-personal-allowance-and-the-basic-rate-limit-for-income-tax-and-equivalent-national-insurance-contributions-thresholds-unt + - title: Budget 2025 (HC 1492) para 4.111 + href: https://assets.publishing.service.gov.uk/media/6929b353345e31ab14ecf735/E03444720_Budget_2025_Web_Accessible.pdf#page=113 +metadata: + label: NI upper secondary threshold (under 21) + unit: currency-GBP + period: week + reference: + - title: Social Security Contributions and Benefits Act 1992 s.9A + href: https://www.legislation.gov.uk/ukpga/1992/4/section/9A + - title: The Social Security (Contributions) Regulations 2001 reg 10(e) + href: https://www.legislation.gov.uk/uksi/2001/1004/regulation/10 diff --git a/policyengine_uk/tests/test_ni_class_1_age_properties.py b/policyengine_uk/tests/test_ni_class_1_age_properties.py index aa7dbfa708..0a2ccfb79c 100644 --- a/policyengine_uk/tests/test_ni_class_1_age_properties.py +++ b/policyengine_uk/tests/test_ni_class_1_age_properties.py @@ -12,9 +12,9 @@ earner who is not an apprentice, before the elective veterans and freeport reliefs and the employer-level Employment Allowance. The test people are not apprentices and have no employer pension contributions, so e is their - Class 1 earnings. Ages 16 to 20 are not checked against it: s.9A sets - a 0% rate for them up to the upper secondary threshold, which the model - does not implement. + Class 1 earnings. At ages 16 to 20, s.9A sets a 0% rate up to the upper + secondary threshold UST, so ni_class_1_employer equals + r * max(e - 52 * max(ST, UST), 0). 2. Nothing under 16: for a < 16, both ni_class_1_employer and ni_class_1_employee are zero. 3. Primary Class 1 stops at state pension age: when is_SP_age holds, @@ -108,6 +108,7 @@ def simulate(policy, people): f"{year}-01-01" ).gov.hmrc.national_insurance.class_1 values["secondary_threshold"] = class_1.thresholds.secondary_threshold + values["upper_secondary_threshold"] = class_1.thresholds.upper_secondary_threshold values["employer_rate"] = class_1.rates.employer return values @@ -125,7 +126,7 @@ def statutory_secondary(earnings, weekly_threshold, rate): # working-age employee on the same pay. REGRESSION_EXAMPLE = ( dict(year=2026, secondary_threshold=None, employer_rate=None), - [(70, 40_000.0), (35, 40_000.0)], + [(70, 40_000.0), (35, 40_000.0), (20, 40_000.0), (20, 60_000.0)], ) @@ -141,12 +142,11 @@ def test_secondary_class_1_continues_past_state_pension_age(population): if age < 16: assert model == 0 continue + threshold = values["secondary_threshold"] if age < 21: - # s.9A under-21 relief is not modelled; see the module docstring. - continue - expected = statutory_secondary( - earnings, values["secondary_threshold"], values["employer_rate"] - ) + # s.9A: 0% up to the upper secondary threshold. + threshold = max(threshold, values["upper_secondary_threshold"]) + expected = statutory_secondary(earnings, threshold, values["employer_rate"]) assert abs(model - float(expected)) <= tolerance(expected, earnings), ( policy, age, diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 23211c9960..b36b16a6b7 100644 --- a/policyengine_uk/tests/test_threshold_indexation.py +++ b/policyengine_uk/tests/test_threshold_indexation.py @@ -11,6 +11,8 @@ 2030-31 it says all of them will be uprated by September CPI (Explanatory Memorandum to SI 2026/231 para 5.1). ``create_threshold_indexation`` writes those paths; the secondary threshold (ST) rises by September CPI, unrounded. +The upper secondary thresholds for the under-21 group and for apprentices +(UST, AUST) stay equal to the UEL, as they have been since introduced. Model year Y is the tax year starting 6 April Y. The model stores the PT and UEL as the annual amount over 52, to the penny (241.73 and 966.73), so 52 @@ -50,7 +52,7 @@ thresholds together by the statute; with September CPI <= 0, or with no economic assumptions, they stay at their frozen levels; a reprocessed override of the PA or BRL keeps the NICs thresholds aligned with it. -9. No double indexing: none of the seven parameters carries ``uprating`` +9. No double indexing: none of the nine parameters carries ``uprating`` metadata, so core uprating does not extend them. 10. The September CPI series: each April's rise is the previous September's CPI to 0.1 percentage points (halves up), and zero unless positive; its @@ -99,6 +101,8 @@ "primary_threshold": "national_insurance/class_1/thresholds/primary_threshold.yaml", "upper_earnings_limit": "national_insurance/class_1/thresholds/upper_earnings_limit.yaml", "secondary_threshold": "national_insurance/class_1/thresholds/secondary_threshold.yaml", + "upper_secondary_threshold": "national_insurance/class_1/thresholds/upper_secondary_threshold.yaml", + "apprentice_upper_secondary_threshold": "national_insurance/class_1/thresholds/apprentice_upper_secondary_threshold.yaml", "lower_profits_limit": "national_insurance/class_4/thresholds/lower_profits_limit.yaml", "upper_profits_limit": "national_insurance/class_4/thresholds/upper_profits_limit.yaml", } @@ -110,7 +114,7 @@ def system(): def threshold_parameters(root): - """The seven parameters under a ``gov`` node.""" + """The nine parameters under a ``gov`` node.""" hmrc = root.hmrc class_1 = hmrc.national_insurance.class_1.thresholds class_4 = hmrc.national_insurance.class_4.thresholds @@ -123,6 +127,10 @@ def threshold_parameters(root): "primary_threshold": class_1.primary_threshold, "upper_earnings_limit": class_1.upper_earnings_limit, "secondary_threshold": class_1.secondary_threshold, + "upper_secondary_threshold": class_1.upper_secondary_threshold, + "apprentice_upper_secondary_threshold": ( + class_1.apprentice_upper_secondary_threshold + ), "lower_profits_limit": class_4.lower_profits_limit, "upper_profits_limit": class_4.upper_profits_limit, } @@ -435,6 +443,8 @@ def series(base, first, last, month_day="04-06"): "primary_threshold", "upper_earnings_limit", "secondary_threshold", + "upper_secondary_threshold", + "apprentice_upper_secondary_threshold", ) } }, @@ -468,6 +478,12 @@ def synthetic_values(root, year): "secondary_threshold": hmrc.national_insurance.class_1.thresholds.secondary_threshold( instant ), + "upper_secondary_threshold": hmrc.national_insurance.class_1.thresholds.upper_secondary_threshold( + instant + ), + "apprentice_upper_secondary_threshold": hmrc.national_insurance.class_1.thresholds.apprentice_upper_secondary_threshold( + instant + ), "lower_profits_limit": hmrc.national_insurance.class_4.thresholds.lower_profits_limit( instant ), @@ -485,6 +501,8 @@ def synthetic_values(root, year): "primary_threshold": 241.73, "upper_earnings_limit": 966.73, "secondary_threshold": 96.153846, + "upper_secondary_threshold": 966.73, + "apprentice_upper_secondary_threshold": 966.73, "lower_profits_limit": 12_570, "upper_profits_limit": 50_270, } @@ -495,6 +513,13 @@ def synthetic_values(root, year): "upper_earnings_limit": lambda pa, brl: float( weekly_equivalent(Decimal(repr(pa + brl))) ), + # The upper secondary thresholds stay equal to the UEL. + "upper_secondary_threshold": lambda pa, brl: float( + weekly_equivalent(Decimal(repr(pa + brl))) + ), + "apprentice_upper_secondary_threshold": lambda pa, brl: float( + weekly_equivalent(Decimal(repr(pa + brl))) + ), } @@ -516,6 +541,12 @@ def synthetic_values(root, year): "basic_rate_limit": st.integers(300, 450).map(lambda n: 100 * n), "primary_threshold": st.integers(15_000, 30_000).map(lambda n: n / 100), "upper_earnings_limit": st.integers(80_000, 120_000).map(lambda n: n / 100), + "upper_secondary_threshold": st.integers(80_000, 120_000).map( + lambda n: n / 100 + ), + "apprentice_upper_secondary_threshold": st.integers(80_000, 120_000).map( + lambda n: n / 100 + ), "secondary_threshold": st.integers(5_000, 20_000).map(lambda n: n / 100), "lower_profits_limit": st.integers(5_000, 20_000), "upper_profits_limit": st.integers(40_000, 70_000), diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py index a179befa83..76e41e31b5 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py @@ -5,10 +5,30 @@ class ni_class_1_employer(Variable): value_type = float entity = Person label = "NI Class 1 employer-side contributions" + documentation = ( + "Secondary Class 1 contributions at the secondary percentage on " + "earnings above the secondary threshold (SSCBA 1992 s.9). For an " + "earner under 21 (s.9A) or a relevant apprentice under 25 (s.9B), " + "earnings up to the upper secondary threshold, or the apprentice upper " + "secondary threshold, are zero-rated." + ) definition_period = YEAR unit = GBP defined_for = "ni_class_1_secondary_liable" - reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/9" + reference = [ + dict( + title="Social Security Contributions and Benefits Act 1992 s.9", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/9", + ), + dict( + title="Social Security Contributions and Benefits Act 1992 s.9A", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/9A", + ), + dict( + title="Social Security Contributions and Benefits Act 1992 s.9B", + href="https://www.legislation.gov.uk/ukpga/1992/4/section/9B", + ), + ] def formula(person, period, parameters): class_1 = parameters(period).gov.hmrc.national_insurance.class_1 @@ -22,9 +42,32 @@ def formula(person, period, parameters): taxed_earnings = earnings + added_pension_contributions else: taxed_earnings = earnings - secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR - main_earnings = max_( - taxed_earnings - secondary_threshold, + thresholds = class_1.thresholds + age_limits = class_1.secondary_zero_rate_age_limits + secondary_threshold = thresholds.secondary_threshold * WEEKS_IN_YEAR + age = person("age", period) + # s.9A: 0% up to the upper secondary threshold for an earner under 21. + under_21_threshold = where( + age < age_limits.under_21_group, + thresholds.upper_secondary_threshold * WEEKS_IN_YEAR, 0, ) - return class_1.rates.employer * main_earnings + # s.9B: 0% up to the apprentice upper secondary threshold for a + # relevant apprentice (an apprentice under 25). s.9A(1A) gives s.9B + # priority, and s.9A still zero-rates an under-21 apprentice's earnings + # above the apprentice threshold up to the under-21 one, so the higher + # of the two applies. + relevant_apprentice = person("is_apprentice", period) & ( + age < age_limits.relevant_apprentice + ) + apprentice_threshold = where( + relevant_apprentice, + thresholds.apprentice_upper_secondary_threshold * WEEKS_IN_YEAR, + 0, + ) + # Earnings above the higher of these thresholds and the secondary + # threshold bear the secondary percentage (s.9A(9)-(10), s.9B(7)). + threshold = max_( + secondary_threshold, max_(under_21_threshold, apprentice_threshold) + ) + return class_1.rates.employer * max_(taxed_earnings - threshold, 0) From af626641e532c8eba8eb9a0ccecf82b542345bfc Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 20 Oct 2026 09:34:22 +0200 Subject: [PATCH 03/10] Test the employer NI zero rates in the employer cost and salary sacrifice variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regressions for review C1 on #2505: an earner aged 20 and an apprentice aged 24 on £30,000 keep their pay when the employer rate rises to 16% (2026), and pay returned from a capped salary sacrifice below the UST/AUST bears no employer NI (2029). Ages 21 and 25 are ordinary-rate controls. Co-Authored-By: Claude Opus 5.5 --- .../salary_sacrifice_pension_ni_employer.yaml | 52 +++++++ .../test_employer_ni_zero_rate_consumers.py | 140 ++++++++++++++++++ 2 files changed, 192 insertions(+) create mode 100644 policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml index 4eb0fdf97c..765a100e56 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml @@ -105,3 +105,55 @@ salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 # Full excess is redirected (no targeted haircut) salary_sacrifice_returned_to_income: 1_000 + +# SSCBA 1992 s.9A and s.9B: the secondary percentage is 0% up to the upper +# secondary threshold for an earner under 21, and up to the apprentice upper +# secondary threshold for an apprentice under 25 (both £50,270 in 2029-30). +# £3,000 of a £5,000 sacrifice returns to pay (total £33,000), all below them. +- name: No employer NI on returned pay for an earner aged 20 below the upper secondary threshold + period: 2029 + absolute_error_margin: 0.01 + input: + age: 20 + employment_income_before_lsr: 30_000 + pension_contributions_via_salary_sacrifice: 5_000 + employer_pension_contributions: 2_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 3_000 + employment_income: 33_000 + salary_sacrifice_pension_ni_employer: 0 + ni_class_1_employer: 0 + # 33,000 pay + 2,000 employer pension contribution, no employer NI. + adjusted_employer_cost: 35_000 + +- name: No employer NI on returned pay for an apprentice aged 24 below the apprentice upper secondary threshold + period: 2029 + absolute_error_margin: 0.01 + input: + age: 24 + is_apprentice: true + employment_income_before_lsr: 30_000 + pension_contributions_via_salary_sacrifice: 5_000 + employer_pension_contributions: 2_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_pension_ni_employer: 0 + ni_class_1_employer: 0 + adjusted_employer_cost: 35_000 + +- name: Employer NI on returned pay for an earner aged 21 + period: 2029 + absolute_error_margin: 0.01 + input: + age: 21 + employment_income_before_lsr: 30_000 + pension_contributions_via_salary_sacrifice: 5_000 + employer_pension_contributions: 2_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # The £3,000 returned sits above the secondary threshold. + salary_sacrifice_pension_ni_employer: 3_000 * 0.15 diff --git a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py new file mode 100644 index 0000000000..cbdf471c29 --- /dev/null +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -0,0 +1,140 @@ +"""The employer cost and salary sacrifice variables apply the employer NI +zero rates. + +ni_class_1_employer zero-rates earnings up to the upper secondary threshold +for an earner under 21 (SSCBA 1992 s.9A) and up to the apprentice upper +secondary threshold for an apprentice under 25 (s.9B). The variables that +recompute employer NI themselves (baseline_employer_cost, +adjusted_employer_cost, employer_ni_fixed_employer_cost_change and +salary_sacrifice_pension_ni_employer) must apply the same schedule. + +Case 1 (2026): pay of £30,000, no pension contributions, employer rate raised +from 15% to 16% with the default fixed-cost incidence. Earnings are below the +UST and AUST (£50,270), so an earner aged 20 or an apprentice aged 24 pays no +employer NI before or after the rise, and pay does not change. An earner aged +21, or an apprentice aged 25, is charged on earnings above the secondary +threshold (£5,000): employer cost 30,000 + 0.15 x 25,000 = £33,750, and pay +at fixed cost solves P + 0.16 x (P - 5,000) = 33,750, so +P = (33,750 + 800) / 1.16 = £29,784.48, a cut of £215.52. + +Case 2 (2029): pay of £30,000 with a £5,000 salary sacrifice against the +£2,000 cap, so £3,000 returns to pay (total £33,000), and a £2,000 employer +pension contribution. For an earner aged 20 or an apprentice aged 24 all of +it is below the UST and AUST, so the amount returned bears no employer NI and +employer cost is 33,000 + 2,000 = £35,000. At age 21 the £3,000 returned sits +above the secondary threshold and bears 0.15 x 3,000 = £450. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") + +RATE_RISE = {"gov.hmrc.national_insurance.class_1.rates.employer": {"2026": 0.16}} +PAY = 30_000 +CASES = { + # name: (age, is_apprentice, zero_rated) + "aged 20": (20, False, True), + "aged 21": (21, False, False), + "apprentice aged 24": (24, True, True), + "apprentice aged 25": (25, True, False), +} + + +def situation(people, year): + return { + "people": { + name: { + "age": {year: age}, + "is_apprentice": {year: apprentice}, + **inputs, + } + for name, (age, apprentice, inputs) in people.items() + }, + "benunits": {f"benunit_{name}": {"members": [name]} for name in people}, + "households": {f"household_{name}": {"members": [name]} for name in people}, + } + + +def values(simulation, variable, year): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +@pytest.mark.parametrize("case", CASES) +def test_rate_rise_leaves_zero_rated_pay_unchanged(case): + age, apprentice, zero_rated = CASES[case] + people = {"person": (age, apprentice, {"employment_income": {2026: PAY}})} + simulation = Simulation(situation=situation(people, 2026), reform=RATE_RISE) + + pay = values(simulation, "employment_income", 2026)[0] + change = values(simulation, "employer_ni_fixed_employer_cost_change", 2026)[0] + baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] + adjusted_cost = values(simulation, "adjusted_employer_cost", 2026)[0] + employer_ni = values(simulation, "ni_class_1_employer", 2026)[0] + + if zero_rated: + assert change == pytest.approx(0, abs=0.01) + assert pay == pytest.approx(PAY, abs=0.01) + assert baseline_cost == pytest.approx(PAY, abs=0.01) + assert adjusted_cost == pytest.approx(PAY, abs=0.01) + assert employer_ni == pytest.approx(0, abs=0.01) + else: + new_pay = (PAY + 0.15 * (PAY - 5_000) + 0.16 * 5_000) / 1.16 + assert pay == pytest.approx(new_pay, abs=0.01) + assert change == pytest.approx(new_pay - PAY, abs=0.01) + assert change == pytest.approx(-215.52, abs=0.01) + assert baseline_cost == pytest.approx(33_750, abs=0.01) + # Fixed employer cost: the reformed cost equals the baseline cost. + assert adjusted_cost == pytest.approx(33_750, abs=0.01) + assert employer_ni == pytest.approx(0.16 * (new_pay - 5_000), abs=0.01) + + +def test_rate_rise_with_mixed_ages_in_one_simulation(): + # Per-person thresholds must not break the no-change shortcut for a + # population of more than one person. + people = { + case.replace(" ", "_"): (age, apprentice, {"employment_income": {2026: PAY}}) + for case, (age, apprentice, _) in CASES.items() + } + simulation = Simulation(situation=situation(people, 2026), reform=RATE_RISE) + change = values(simulation, "employer_ni_fixed_employer_cost_change", 2026) + expected = [0 if zero_rated else -215.52 for (_, _, zero_rated) in CASES.values()] + np.testing.assert_allclose(change, expected, atol=0.01) + + +@pytest.mark.parametrize("case", CASES) +def test_salary_sacrifice_returned_pay_is_zero_rated(case): + age, apprentice, zero_rated = CASES[case] + inputs = { + "employment_income": {2029: PAY}, + "pension_contributions_via_salary_sacrifice": {2029: 5_000}, + "employer_pension_contributions": {2029: 2_000}, + } + people = {"person": (age, apprentice, inputs)} + simulation = Simulation( + situation=situation(people, 2029), + reform={ + "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate": { + "2029": 0 + }, + }, + ) + + returned = values(simulation, "salary_sacrifice_returned_to_income", 2029)[0] + pay = values(simulation, "employment_income", 2029)[0] + extra_ni = values(simulation, "salary_sacrifice_pension_ni_employer", 2029)[0] + adjusted_cost = values(simulation, "adjusted_employer_cost", 2029)[0] + employer_ni = values(simulation, "ni_class_1_employer", 2029)[0] + + assert returned == pytest.approx(3_000, abs=0.01) + assert pay == pytest.approx(33_000, abs=0.01) + if zero_rated: + assert extra_ni == pytest.approx(0, abs=0.01) + assert employer_ni == pytest.approx(0, abs=0.01) + assert adjusted_cost == pytest.approx(35_000, abs=0.01) + else: + assert extra_ni == pytest.approx(0.15 * 3_000, abs=0.01) + # The cost variable charges what ni_class_1_employer charges. + assert adjusted_cost == pytest.approx(33_000 + 2_000 + employer_ni, abs=0.01) From 5a1846cbc9586a07addcf14bd60e21523a0fc92a Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Tue, 20 Oct 2026 13:50:01 +0100 Subject: [PATCH 04/10] Apply the employer NI zero rates in the employer cost and salary sacrifice variables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit baseline_employer_cost, adjusted_employer_cost, employer_ni_fixed_employer_cost_change and salary_sacrifice_pension_ni_employer recomputed employer NI from the ordinary secondary threshold, so a rate rise cut the pay of a 20-year-old on £30,000 who pays no employer NI (review C1 on #2505). They and ni_class_1_employer now take the threshold from one helper, secondary_class_1_threshold (SSCBA 1992 ss.9, 9A, 9B). The salary sacrifice measure charges the returned pay as the top slice of the earnings ni_class_1_employer charges. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1918.fixed.md | 2 +- .../salary_sacrifice_pension_ni_employer.yaml | 15 ++++++++ policyengine_uk/utils/employer_ni.py | 38 +++++++++++++++++++ .../employer_ni/adjusted_employer_cost.py | 4 +- .../employer_ni/baseline_employer_cost.py | 4 +- .../employer_ni_fixed_employer_cost_change.py | 12 ++++-- .../class_1/ni_class_1_employer.py | 33 +++------------- .../salary_sacrifice_pension_ni_employer.py | 32 ++++++++++++---- 8 files changed, 98 insertions(+), 42 deletions(-) create mode 100644 policyengine_uk/utils/employer_ni.py diff --git a/changelog.d/1918.fixed.md b/changelog.d/1918.fixed.md index f8ea51c42e..1017349bde 100644 --- a/changelog.d/1918.fixed.md +++ b/changelog.d/1918.fixed.md @@ -1 +1 @@ -- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. +- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. The employer cost variables, the fixed-employer-cost pay response and the salary sacrifice employer NI measure apply the same schedule through one shared helper. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml index 765a100e56..61289711ca 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.yaml @@ -157,3 +157,18 @@ output: # The £3,000 returned sits above the secondary threshold. salary_sacrifice_pension_ni_employer: 3_000 * 0.15 + +- name: No employer NI on returned pay for an earner aged 19 on £40,000 + period: 2029 + absolute_error_margin: 0.01 + input: + age: 19 + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 6_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # £4,000 returned; total pay £44,000 is below the UST (£50,270). + salary_sacrifice_returned_to_income: 4_000 + salary_sacrifice_pension_ni_employer: 0 + ni_class_1_employer: 0 diff --git a/policyengine_uk/utils/employer_ni.py b/policyengine_uk/utils/employer_ni.py new file mode 100644 index 0000000000..25b782e366 --- /dev/null +++ b/policyengine_uk/utils/employer_ni.py @@ -0,0 +1,38 @@ +from policyengine_core.model_api import WEEKS_IN_YEAR, max_, where + + +def secondary_class_1_threshold(person, period, class_1): + """Annual earnings above which the secondary percentage applies. + + The secondary threshold (SSCBA 1992 s.9), raised to the upper secondary + threshold for an earner under 21 (s.9A) and to the apprentice upper + secondary threshold for a relevant apprentice under 25 (s.9B). s.9A(1A) + gives s.9B priority, and s.9A still zero-rates an under-21 apprentice's + earnings above the apprentice threshold up to the under-21 one, so the + higher threshold applies. Earnings above it bear the secondary percentage + (s.9A(9)-(10), s.9B(7)). + + `class_1` is a Class 1 parameter node, so a variable can apply the + baseline and the reformed schedule with the same function. + """ + thresholds = class_1.thresholds + age_limits = class_1.secondary_zero_rate_age_limits + age = person("age", period) + under_21_threshold = where( + age < age_limits.under_21_group, + thresholds.upper_secondary_threshold, + 0, + ) + relevant_apprentice = person("is_apprentice", period) & ( + age < age_limits.relevant_apprentice + ) + apprentice_threshold = where( + relevant_apprentice, + thresholds.apprentice_upper_secondary_threshold, + 0, + ) + weekly = max_( + thresholds.secondary_threshold, + max_(under_21_threshold, apprentice_threshold), + ) + return weekly * WEEKS_IN_YEAR diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py index 863a35adfa..084ebad5d7 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold class adjusted_employer_cost(Variable): @@ -30,7 +31,8 @@ def formula(person, period, parameters): parameters = parameters(period) class_1 = parameters.gov.hmrc.national_insurance.class_1 r_r = class_1.rates.employer - t_r = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + # The secondary threshold, or the UST/AUST where a zero rate applies. + t_r = secondary_class_1_threshold(person, period, class_1) p_r = parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py index 3f32b7291e..611ad02c43 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold class baseline_employer_cost(Variable): @@ -40,7 +41,8 @@ def formula(person, period, parameters): ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + # The secondary threshold, or the UST/AUST where a zero rate applies. + t_b = secondary_class_1_threshold(person, period, baseline_class_1) p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b: diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py index be4aa17063..ed49c928b8 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold class employer_ni_fixed_employer_cost_change(Variable): @@ -48,7 +49,8 @@ def formula(person, period, parameters): baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 r_b = baseline_class_1.rates.employer - t_b = baseline_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + # The secondary threshold, or the UST/AUST where a zero rate applies. + t_b = secondary_class_1_threshold(person, period, baseline_class_1) p_b = baseline_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_b = employer_pension_contributions if p_b else 0 @@ -61,12 +63,12 @@ def formula(person, period, parameters): reform_parameters = parameters(period) reform_class_1 = reform_parameters.gov.hmrc.national_insurance.class_1 r_r = reform_class_1.rates.employer - t_r = reform_class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + t_r = secondary_class_1_threshold(person, period, reform_class_1) p_r = reform_parameters.gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions pen_con_subtracted_r = employer_pension_contributions if p_r else 0 # Early return if no change in parameters - if r_b == r_r and t_b == t_r and p_b == p_r: + if r_b == r_r and p_b == p_r and np.array_equal(t_b, t_r): return 0 # Calculate new employment income keeping employer cost constant @@ -87,7 +89,9 @@ def formula(person, period, parameters): # Apply incidence percentage interpolated_pay_change = pay_change * employee_incidence - # Where a person's prior employment income was below the secondary threshold, the formula doesn't hold, so assume no change. + # Where a person's prior employment income was below their threshold + # (the secondary threshold, or the UST/AUST where a zero rate applies), + # the formula doesn't hold, so assume no change. below_threshold = previous_employment_income < t_b return where(below_threshold, 0, interpolated_pay_change) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py index 76e41e31b5..103f360c8b 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_employer.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold class ni_class_1_employer(Variable): @@ -42,32 +43,8 @@ def formula(person, period, parameters): taxed_earnings = earnings + added_pension_contributions else: taxed_earnings = earnings - thresholds = class_1.thresholds - age_limits = class_1.secondary_zero_rate_age_limits - secondary_threshold = thresholds.secondary_threshold * WEEKS_IN_YEAR - age = person("age", period) - # s.9A: 0% up to the upper secondary threshold for an earner under 21. - under_21_threshold = where( - age < age_limits.under_21_group, - thresholds.upper_secondary_threshold * WEEKS_IN_YEAR, - 0, - ) - # s.9B: 0% up to the apprentice upper secondary threshold for a - # relevant apprentice (an apprentice under 25). s.9A(1A) gives s.9B - # priority, and s.9A still zero-rates an under-21 apprentice's earnings - # above the apprentice threshold up to the under-21 one, so the higher - # of the two applies. - relevant_apprentice = person("is_apprentice", period) & ( - age < age_limits.relevant_apprentice - ) - apprentice_threshold = where( - relevant_apprentice, - thresholds.apprentice_upper_secondary_threshold * WEEKS_IN_YEAR, - 0, - ) - # Earnings above the higher of these thresholds and the secondary - # threshold bear the secondary percentage (s.9A(9)-(10), s.9B(7)). - threshold = max_( - secondary_threshold, max_(under_21_threshold, apprentice_threshold) - ) + # Earnings above the secondary threshold, or the UST/AUST for an + # earner under 21 or a relevant apprentice, bear the secondary + # percentage (s.9A, s.9B). + threshold = secondary_class_1_threshold(person, period, class_1) return class_1.rates.employer * max_(taxed_earnings - threshold, 0) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py index ed9de113a4..98b185cee6 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employer.py @@ -1,4 +1,5 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold class salary_sacrifice_pension_ni_employer(Variable): @@ -7,21 +8,38 @@ class salary_sacrifice_pension_ni_employer(Variable): "Additional employer National Insurance contributions due to " "salary sacrifice pension contributions exceeding the cap. " "The full excess is redirected to regular pension contributions " - "and subject to employer NI." + "and subject to employer NI. The excess is the top slice of the " + "earnings ni_class_1_employer charges, so it bears the secondary " + "percentage only above the secondary threshold, or above the upper " + "secondary threshold for an earner under 21 (SSCBA 1992 s.9A) or the " + "apprentice upper secondary threshold for an apprentice under 25 " + "(s.9B). This measures part of ni_class_1_employer; do not add it to " + "a National Insurance total." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + reference = [ + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", + "https://www.legislation.gov.uk/ukpga/1992/4/section/9A", + "https://www.legislation.gov.uk/ukpga/1992/4/section/9B", + ] def formula(person, period, parameters): # Get the excess that's redirected to regular pension # This is the amount subject to employer NI excess = person("salary_sacrifice_returned_to_income", period) - # Use existing NI Class 1 employer rate parameter - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.rates.employer - - return excess * employer_rate + gov = parameters(period).gov + class_1 = gov.hmrc.national_insurance.class_1 + # The earnings ni_class_1_employer charges, which include the excess. + earnings = person("ni_class_1_income", period) + if not gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions: + earnings = earnings + person("employer_pension_contributions", period) + threshold = secondary_class_1_threshold(person, period, class_1) + above_threshold = max_(earnings - threshold, 0) + above_threshold_without_excess = max_(earnings - excess - threshold, 0) + return class_1.rates.employer * ( + above_threshold - above_threshold_without_excess + ) From 1b171486839b65070188f0cf8502b9f32a0b943b Mon Sep 17 00:00:00 2001 From: vahid-ahmadi Date: Fri, 23 Oct 2026 16:40:53 +0100 Subject: [PATCH 05/10] Solve the fixed employer cost pay change piecewise on NIC earnings The shortcut compared pay alone with the baseline threshold and used the above-threshold solution everywhere, so it missed statutory pay that takes NIC earnings over the UST/AUST and reforms that move a threshold across a person's earnings. Solve c_b = x + r * max(x - k, 0) on each side of the reformed threshold instead, with regressions for both zero-rated groups. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1918.fixed.md | 2 +- .../test_employer_ni_zero_rate_consumers.py | 75 +++++++++++++++++++ .../employer_ni_fixed_employer_cost_change.py | 29 ++++--- 3 files changed, 94 insertions(+), 12 deletions(-) diff --git a/changelog.d/1918.fixed.md b/changelog.d/1918.fixed.md index 1017349bde..f91983f596 100644 --- a/changelog.d/1918.fixed.md +++ b/changelog.d/1918.fixed.md @@ -1 +1 @@ -- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. The employer cost variables, the fixed-employer-cost pay response and the salary sacrifice employer NI measure apply the same schedule through one shared helper. +- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. The employer cost variables, the fixed-employer-cost pay response and the salary sacrifice employer NI measure apply the same schedule through one shared helper. The fixed-employer-cost pay response now solves for NIC earnings (pay plus statutory pay) on each side of the reformed threshold, so it also holds where statutory pay takes earnings over a threshold or a reform moves a threshold across a person's earnings. diff --git a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py index d3271cdaf9..c153920f6a 100644 --- a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -144,3 +144,78 @@ def test_salary_sacrifice_excess_is_zero_rated(case): assert adjusted_cost == pytest.approx(32_000, abs=0.01) else: assert extra_ni == pytest.approx(0.15 * 3_000, abs=0.01) + + +# The fixed-cost pay change must solve the employer cost equation on NIC +# earnings (pay plus statutory pay), piecewise around the reformed threshold, +# for earners whose pay alone is below their baseline threshold. +ZERO_RATED = {case: CASES[case] for case in CASES if CASES[case][2]} + + +@pytest.mark.parametrize("case", ZERO_RATED) +def test_rate_rise_with_statutory_pay_over_the_threshold(case): + # 2026: £49,000 pay plus £2,000 statutory sick pay. Pay alone is below the + # UST/AUST (£50,270), but NIC earnings of £51,000 are above it, so the 15% + # to 16% rise raises employer NI and pay must fall to hold cost fixed. + age, apprentice, _ = ZERO_RATED[case] + inputs = {"employment_income": {2026: 49_000}, "statutory_sick_pay": {2026: 2_000}} + people = {"person": (age, apprentice, inputs)} + simulation = Simulation(situation=situation(people, 2026), reform=RATE_RISE) + + threshold = 50_270 + baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] + assert baseline_cost == pytest.approx(51_000 + 0.15 * (51_000 - threshold), abs=0.1) + # Solve W + 2,000 + 0.16 x (W + 2,000 - T) = baseline cost for pay W. + new_pay = (baseline_cost + 0.16 * threshold) / 1.16 - 2_000 + assert new_pay == pytest.approx(48_993.71, abs=0.1) + pay = values(simulation, "employment_income", 2026)[0] + assert pay == pytest.approx(new_pay, abs=0.1) + adjusted_cost = values(simulation, "adjusted_employer_cost", 2026)[0] + assert adjusted_cost == pytest.approx(baseline_cost, abs=0.1) + + +LOWER_RELIEF_THRESHOLDS = { + f"gov.hmrc.national_insurance.class_1.thresholds.{name}": {"2026": 20_000 / 52} + for name in ["upper_secondary_threshold", "apprentice_upper_secondary_threshold"] +} + + +@pytest.mark.parametrize("case", ZERO_RATED) +def test_lowered_relief_threshold_below_pay(case): + # 2026: a reform lowers the UST and AUST to £20,000. Pay of £30,000 was + # zero-rated (employer cost £30,000) and now crosses the reformed + # threshold: P + 0.15 x (P - 20,000) = 30,000, so P = 33,000 / 1.15. + age, apprentice, _ = ZERO_RATED[case] + people = {"person": (age, apprentice, {"employment_income": {2026: PAY}})} + simulation = Simulation( + situation=situation(people, 2026), reform=LOWER_RELIEF_THRESHOLDS + ) + + pay = values(simulation, "employment_income", 2026)[0] + assert pay == pytest.approx(33_000 / 1.15, abs=0.01) + assert pay == pytest.approx(28_695.65, abs=0.01) + assert values(simulation, "baseline_employer_cost", 2026)[0] == pytest.approx( + PAY, abs=0.01 + ) + assert values(simulation, "adjusted_employer_cost", 2026)[0] == pytest.approx( + PAY, abs=0.01 + ) + + +@pytest.mark.parametrize("case", ZERO_RATED) +def test_raised_relief_threshold_above_pay(case): + # The reverse crossing: pay of £60,000 bears 0.15 x (60,000 - 50,270) in + # the baseline. Raising the UST and AUST to £70,000 removes the charge, so + # pay rises by the whole saving to hold employer cost fixed. + age, apprentice, _ = ZERO_RATED[case] + people = {"person": (age, apprentice, {"employment_income": {2026: 60_000}})} + reform = {name: {"2026": 70_000 / 52} for name in LOWER_RELIEF_THRESHOLDS} + simulation = Simulation(situation=situation(people, 2026), reform=reform) + + baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] + assert baseline_cost == pytest.approx(60_000 + 0.15 * 9_730, abs=0.1) + pay = values(simulation, "employment_income", 2026)[0] + assert pay == pytest.approx(baseline_cost, abs=0.1) + assert values(simulation, "adjusted_employer_cost", 2026)[0] == pytest.approx( + baseline_cost, abs=0.1 + ) diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py index 84d4a74621..bd46b64aeb 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py @@ -71,10 +71,16 @@ def formula(person, period, parameters): if r_b == r_r and p_b == p_r and np.array_equal(t_b, t_r): return 0 - # Calculate new employment income keeping employer cost constant - # Solve: c_b = new_ni_class_1_income + r_r * max(new_ni_class_1_income - pen_con_subtracted_r - t_r, 0) - # Rearranging: new_ni_class_1_income = (c_b + r_r * (pen_con_subtracted_r + t_r)) / (1 + r_r) - new_ni_class_1_income = (c_b + r_r * (pen_con_subtracted_r + t_r)) / (1 + r_r) + # Calculate new NIC earnings keeping employer cost constant. Solve + # c_b = x + r_r * max(x - k_r, 0) for x, where k_r is the point at + # which the reformed charge starts. The cost is continuous and + # strictly increasing in x, so x = c_b where c_b is at or below k_r + # (no reformed charge), and otherwise the linear solution above it. + # NIC earnings include statutory pay, so this also holds where pay + # alone is below a threshold but pay plus statutory pay is above it, + # and where a reform moves the threshold across a person's earnings. + k_r = pen_con_subtracted_r + t_r + new_ni_class_1_income = where(c_b <= k_r, c_b, (c_b + r_r * k_r) / (1 + r_r)) # Find difference in employment income previous_employment_income = ( @@ -84,14 +90,15 @@ def formula(person, period, parameters): new_ni_class_1_income - benefits - employer_pension_contributions ) - pay_change = new_employment_income - previous_employment_income + # Pay cannot fall below zero. + pay_change = max_( + new_employment_income - previous_employment_income, + -previous_employment_income, + ) # Apply incidence percentage interpolated_pay_change = pay_change * employee_incidence - # Where a person's prior employment income was below their threshold - # (the secondary threshold, or the UST/AUST where a zero rate applies), - # the formula doesn't hold, so assume no change. - below_threshold = previous_employment_income < t_b - - return where(below_threshold, 0, interpolated_pay_change) + # Statutory pay and pension contributions are not adjusted, so a person + # with no employment income has no pay to change. + return where(previous_employment_income > 0, interpolated_pay_change, 0) From 4f47cbf4dbcd67a0e34d2e76b4e9287ae2b4e64b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 14:12:00 -0400 Subject: [PATCH 06/10] Test the fixed employer cost to the penny, with a conservation property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use the stored 2026 relief threshold (£966.73 x 52 = £50,269.96) and penny tolerances in the statutory-pay and raised-threshold regressions, which allowed £0.10. - Add María's original salary sacrifice case: £5,000 of total employer pension with the £5,000 sacrifice, employer cost £35,000 for a zero-rated earner. - Add a Hypothesis property over mixed under-21, apprentice and ordinary earners with statutory pay and employer pensions, under random employer rate, secondary threshold and UST/AUST reforms: reformed employer cost equals baseline cost whenever reformed pay is positive, and pay floors at zero (cost at least baseline) when statutory pay and pensions alone cost more. Co-Authored-By: Claude Opus 5.5 --- .../test_employer_ni_fixed_cost_properties.py | 152 ++++++++++++++++++ .../test_employer_ni_zero_rate_consumers.py | 44 +++-- 2 files changed, 184 insertions(+), 12 deletions(-) create mode 100644 policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py diff --git a/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py new file mode 100644 index 0000000000..6017bc5e05 --- /dev/null +++ b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py @@ -0,0 +1,152 @@ +"""Property-based test of the fixed employer cost response to an employer NI +reform. + +With full employee incidence (the default), employer_ni_fixed_employer_cost_change +moves pay so that the employer's cost is unchanged by the reform. For a person +with pay P, statutory pay S and employer pension contributions E, the cost under +a schedule with employer rate r and secondary threshold T (the secondary +threshold, or the UST or AUST where SSCBA 1992 s.9A or s.9B zero-rates the +earner) is + + cost = P + S + E + r * max(P + S + E - E_exempt - T, 0) + +with E_exempt = E when employer pension contributions are NI-exempt. The cost is +continuous and increasing in P, so for any reform of r, T or the zero-rate +thresholds, and any person with pay before the reform: + +1. Conservation: if the reformed pay is positive, adjusted_employer_cost equals + baseline_employer_cost. +2. The zero-pay floor: if holding cost fixed would need negative pay, because + statutory pay and pension contributions alone cost more under the reform, + reformed pay is zero, so the reformed cost is at least the baseline cost. + +The population mixes earners under 21, relevant apprentices under 25 and other +earners, each with statutory pay that can carry NIC earnings over a threshold +that pay alone is below. + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +CLASS_1 = "gov.hmrc.national_insurance.class_1" +YEARS = [2026, 2027, 2028] +# (age, is_apprentice): zero-rated under 21 (s.9A), zero-rated relevant +# apprentices under 25 (s.9B), and earners with the ordinary threshold. +AGE_GROUPS = [ + (18, False), + (20, False), + (21, False), + (24, True), + (25, True), + (40, False), +] +STATUTORY_PAY = [ + "statutory_sick_pay", + "statutory_maternity_pay", + "statutory_paternity_pay", +] +PROPERTY_SETTINGS = settings( + max_examples=25, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def tolerance(*amounts): + return 0.01 + 4e-7 * max(abs(float(a)) for a in amounts) + + +@st.composite +def populations(draw): + year = draw(st.sampled_from(YEARS)) + # The reform always sets the employer rate (a reform is needed for the + # baseline employer cost; a rate equal to the baseline's leaves cost + # unchanged). None keeps the baseline threshold. Thresholds are weekly, as + # the parameters store them, and apply from the year drawn. + period = f"{year}-01-01.2100-12-31" + reform = { + f"{CLASS_1}.employer_scale[1].rate": { + period: draw(st.floats(0, 0.3, allow_nan=False)) + } + } + secondary = draw(st.one_of(st.none(), st.floats(0, 1_500, allow_nan=False))) + if secondary is not None: + reform[f"{CLASS_1}.employer_scale[1].threshold"] = {period: secondary} + relief = draw(st.one_of(st.none(), st.floats(0, 2_000, allow_nan=False))) + if relief is not None: + for name in [ + "upper_secondary_threshold", + "apprentice_upper_secondary_threshold", + ]: + reform[f"{CLASS_1}.thresholds.{name}"] = {period: relief} + people = draw( + st.lists( + st.tuples( + st.sampled_from(AGE_GROUPS), + st.floats(1, 150_000, allow_nan=False), + st.sampled_from(STATUTORY_PAY), + st.floats(0, 10_000, allow_nan=False), + st.floats(0, 10_000, allow_nan=False), + ), + min_size=1, + max_size=12, + ) + ) + return year, reform, people + + +def simulate(year, reform, people): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, ((age, apprentice), pay, statutory, statutory_pay, pension) in enumerate( + people + ): + name = f"person_{i}" + situation["people"][name] = { + "age": {year: age}, + "is_apprentice": {year: apprentice}, + "employment_income": {year: pay}, + statutory: {year: statutory_pay}, + "employer_pension_contributions": {year: pension}, + } + situation["benunits"][f"benunit_{i}"] = {"members": [name]} + situation["households"][f"household_{i}"] = {"members": [name]} + return Simulation(situation=situation, reform=reform) + + +@PROPERTY_SETTINGS +@given(populations()) +def test_fixed_employer_cost_is_conserved_or_pay_is_floored(population): + year, reform, people = population + simulation = simulate(year, reform, people) + pay = np.asarray(simulation.calculate("employment_income", year), dtype=float) + baseline_cost = np.asarray( + simulation.calculate("baseline_employer_cost", year), dtype=float + ) + adjusted_cost = np.asarray( + simulation.calculate("adjusted_employer_cost", year), dtype=float + ) + for i in range(len(people)): + tol = tolerance(baseline_cost[i], adjusted_cost[i]) + assert pay[i] >= -tol + if pay[i] > tol: + assert abs(adjusted_cost[i] - baseline_cost[i]) <= tol, ( + people[i], + reform, + pay[i], + baseline_cost[i], + adjusted_cost[i], + ) + else: + assert adjusted_cost[i] >= baseline_cost[i] - tol, ( + people[i], + reform, + pay[i], + baseline_cost[i], + adjusted_cost[i], + ) diff --git a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py index c153920f6a..ad8570889e 100644 --- a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -23,7 +23,8 @@ earner aged 20 or an apprentice aged 24 all of it is below the UST and AUST, so the excess bears no employer NI and employer cost is 30,000 + 2,000 = £32,000. At age 21 the £3,000 excess sits above the secondary threshold and -bears 0.15 x 3,000 = £450. +bears 0.15 x 3,000 = £450. The same holds with £5,000 of total employer pension +contributions (employer cost £35,000). """ import numpy as np @@ -33,6 +34,9 @@ pytestmark = pytest.mark.usefixtures("cloned_uk_tax_benefit_system") +# The 2026 upper secondary and apprentice upper secondary thresholds as +# stored: £966.73 a week, £50,269.96 a year. +RELIEF_THRESHOLD = 966.73 * 52 RATE_RISE = { "gov.hmrc.national_insurance.class_1.employer_scale[1].rate": {"2026": 0.16} } @@ -107,13 +111,21 @@ def test_rate_rise_with_mixed_ages_in_one_simulation(): np.testing.assert_allclose(change, expected, atol=0.01) +# Total employer pension contributions with the £5,000 sacrifice: £2,000, and +# María's original case of £5,000 (the whole sacrifice). Employer cost is pay +# plus employer pension for a zero-rated earner: £32,000 or £35,000. +EMPLOYER_PENSIONS = {"£2,000 employer pension": 2_000, "£5,000 employer pension": 5_000} + + +@pytest.mark.parametrize("pension", EMPLOYER_PENSIONS) @pytest.mark.parametrize("case", CASES) -def test_salary_sacrifice_excess_is_zero_rated(case): +def test_salary_sacrifice_excess_is_zero_rated(case, pension): age, apprentice, zero_rated = CASES[case] + employer_pension = EMPLOYER_PENSIONS[pension] inputs = { "employment_income": {2029: PAY}, "pension_contributions_via_salary_sacrifice": {2029: 5_000}, - "employer_pension_contributions": {2029: 2_000}, + "employer_pension_contributions": {2029: employer_pension}, } people = {"person": (age, apprentice, inputs)} simulation = Simulation( @@ -141,7 +153,7 @@ def test_salary_sacrifice_excess_is_zero_rated(case): if zero_rated: assert extra_ni == pytest.approx(0, abs=0.01) assert employer_ni == pytest.approx(0, abs=0.01) - assert adjusted_cost == pytest.approx(32_000, abs=0.01) + assert adjusted_cost == pytest.approx(PAY + employer_pension, abs=0.01) else: assert extra_ni == pytest.approx(0.15 * 3_000, abs=0.01) @@ -162,16 +174,20 @@ def test_rate_rise_with_statutory_pay_over_the_threshold(case): people = {"person": (age, apprentice, inputs)} simulation = Simulation(situation=situation(people, 2026), reform=RATE_RISE) - threshold = 50_270 + threshold = RELIEF_THRESHOLD baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] - assert baseline_cost == pytest.approx(51_000 + 0.15 * (51_000 - threshold), abs=0.1) + # 51,000 + 0.15 x (51,000 - 50,269.96) = 51,109.51 + assert baseline_cost == pytest.approx( + 51_000 + 0.15 * (51_000 - threshold), abs=0.01 + ) + assert baseline_cost == pytest.approx(51_109.51, abs=0.01) # Solve W + 2,000 + 0.16 x (W + 2,000 - T) = baseline cost for pay W. new_pay = (baseline_cost + 0.16 * threshold) / 1.16 - 2_000 - assert new_pay == pytest.approx(48_993.71, abs=0.1) + assert new_pay == pytest.approx(48_993.71, abs=0.01) pay = values(simulation, "employment_income", 2026)[0] - assert pay == pytest.approx(new_pay, abs=0.1) + assert pay == pytest.approx(new_pay, abs=0.01) adjusted_cost = values(simulation, "adjusted_employer_cost", 2026)[0] - assert adjusted_cost == pytest.approx(baseline_cost, abs=0.1) + assert adjusted_cost == pytest.approx(baseline_cost, abs=0.01) LOWER_RELIEF_THRESHOLDS = { @@ -213,9 +229,13 @@ def test_raised_relief_threshold_above_pay(case): simulation = Simulation(situation=situation(people, 2026), reform=reform) baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] - assert baseline_cost == pytest.approx(60_000 + 0.15 * 9_730, abs=0.1) + # 60,000 + 0.15 x (60,000 - 50,269.96) = 61,459.51 + assert baseline_cost == pytest.approx( + 60_000 + 0.15 * (60_000 - RELIEF_THRESHOLD), abs=0.01 + ) + assert baseline_cost == pytest.approx(61_459.51, abs=0.01) pay = values(simulation, "employment_income", 2026)[0] - assert pay == pytest.approx(baseline_cost, abs=0.1) + assert pay == pytest.approx(baseline_cost, abs=0.01) assert values(simulation, "adjusted_employer_cost", 2026)[0] == pytest.approx( - baseline_cost, abs=0.1 + baseline_cost, abs=0.01 ) From 5774899a4b445afd475664001cb0810bc7edab77 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 19:08:00 -0400 Subject: [PATCH 07/10] Charge the salary sacrifice excess in the fixed employer cost equations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit María's re-review of 4f47cbf4d (C1): baseline_employer_cost, adjusted_employer_cost and employer_ni_fixed_employer_cost_change rebuilt NIC earnings without salary_sacrifice_pension_ni_excess, although ni_class_1_employer charges it. From 2029, a 15% to 16% rate rise left pay unchanged for an earner aged 20 with £49,000 pay and a £5,000 sacrifice, and actual cost rose by £17.30. The equations also left out salary_sacrifice_returned_to_income and salary_sacrifice_broad_base_haircut, which employment_income adds. So from 2029, with the default 0.16% haircut, the costs missed every worker's haircut. A shared helper returns each schedule's pay terms and NIC-only excess. The baseline cost uses the baseline simulation's terms, and the reform uses its own. The solver's kink moves down by the excess. Regressions: - María's case to the penny: pay £48,985.09, cost £51,259.51; - an above-threshold control; - a default-haircut case. Each also checks against employer_cost in both simulations. The Hypothesis property now covers 2029-30 with a drawn sacrifice and checks that agreement. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1918.fixed.md | 2 +- .../test_employer_ni_fixed_cost_properties.py | 56 ++++++++--- .../test_employer_ni_zero_rate_consumers.py | 99 +++++++++++++++++++ policyengine_uk/utils/employer_ni.py | 18 ++++ .../employer_ni/adjusted_employer_cost.py | 7 +- .../employer_ni/baseline_employer_cost.py | 19 +++- .../employer_ni_fixed_employer_cost_change.py | 54 ++++++---- 7 files changed, 217 insertions(+), 38 deletions(-) diff --git a/changelog.d/1918.fixed.md b/changelog.d/1918.fixed.md index f91983f596..7f4ae3d41a 100644 --- a/changelog.d/1918.fixed.md +++ b/changelog.d/1918.fixed.md @@ -1 +1 @@ -- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. The employer cost variables, the fixed-employer-cost pay response and the salary sacrifice employer NI measure apply the same schedule through one shared helper. The fixed-employer-cost pay response now solves for NIC earnings (pay plus statutory pay) on each side of the reformed threshold, so it also holds where statutory pay takes earnings over a threshold or a reform moves a threshold across a person's earnings. +- Zero-rate employer Class 1 National Insurance for earners under 21 up to the upper secondary threshold (SSCBA 1992 s.9A) and for apprentices under 25 up to the apprentice upper secondary threshold (s.9B), with both thresholds kept equal to the upper earnings limit after 2030-31. The employer cost variables, the fixed-employer-cost pay response and the salary sacrifice employer NI measure apply the same schedule through one shared helper. The fixed-employer-cost pay response now solves for NIC earnings (pay plus statutory pay) on each side of the reformed threshold, so it also holds where statutory pay takes earnings over a threshold or a reform moves a threshold across a person's earnings. From 2029 the employer cost variables and the pay response also charge employer NI on salary sacrifice above the £2,000 cap, as Class 1 does, and count the broad-base haircut and returned pay as pay, so the baseline and reformed costs equal `employer_cost` in their simulations. diff --git a/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py index 6017bc5e05..289c03988a 100644 --- a/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py +++ b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py @@ -3,26 +3,31 @@ With full employee incidence (the default), employer_ni_fixed_employer_cost_change moves pay so that the employer's cost is unchanged by the reform. For a person -with pay P, statutory pay S and employer pension contributions E, the cost under -a schedule with employer rate r and secondary threshold T (the secondary -threshold, or the UST or AUST where SSCBA 1992 s.9A or s.9B zero-rates the -earner) is +with pay P (including the salary sacrifice cap's broad-base haircut), statutory +pay S, employer pension contributions E and salary sacrifice above the cap X +(NIC earnings only, from 2029), the cost under a schedule with employer rate r +and secondary threshold T (the secondary threshold, or the UST or AUST where +SSCBA 1992 s.9A or s.9B zero-rates the earner) is - cost = P + S + E + r * max(P + S + E - E_exempt - T, 0) + cost = P + S + E + r * max(P + S + X + E - E_exempt - T, 0) with E_exempt = E when employer pension contributions are NI-exempt. The cost is continuous and increasing in P, so for any reform of r, T or the zero-rate thresholds, and any person with pay before the reform: -1. Conservation: if the reformed pay is positive, adjusted_employer_cost equals +1. Agreement: baseline_employer_cost equals the baseline simulation's + employer_cost, and adjusted_employer_cost the reformed simulation's. +2. Conservation: if the reformed pay is positive, adjusted_employer_cost equals baseline_employer_cost. -2. The zero-pay floor: if holding cost fixed would need negative pay, because - statutory pay and pension contributions alone cost more under the reform, - reformed pay is zero, so the reformed cost is at least the baseline cost. +3. The zero-pay floor: if holding cost fixed would need negative pay, because + statutory pay, pension contributions and the excess alone cost more under + the reform, reformed pay is zero, so the reformed cost is at least the + baseline cost. The population mixes earners under 21, relevant apprentices under 25 and other earners, each with statutory pay that can carry NIC earnings over a threshold -that pay alone is below. +that pay alone is below, and a salary sacrifice that the 2029 cap can make +partly NIC-able. Comparisons allow float32 rounding: the model stores values as float32. """ @@ -34,7 +39,7 @@ from policyengine_uk import Simulation CLASS_1 = "gov.hmrc.national_insurance.class_1" -YEARS = [2026, 2027, 2028] +YEARS = [2026, 2027, 2028, 2029, 2030] # (age, is_apprentice): zero-rated under 21 (s.9A), zero-rated relevant # apprentices under 25 (s.9B), and earners with the ordinary threshold. AGE_GROUPS = [ @@ -93,6 +98,7 @@ def populations(draw): st.sampled_from(STATUTORY_PAY), st.floats(0, 10_000, allow_nan=False), st.floats(0, 10_000, allow_nan=False), + st.floats(0, 10_000, allow_nan=False), ), min_size=1, max_size=12, @@ -103,9 +109,14 @@ def populations(draw): def simulate(year, reform, people): situation = {"people": {}, "benunits": {}, "households": {}} - for i, ((age, apprentice), pay, statutory, statutory_pay, pension) in enumerate( - people - ): + for i, ( + (age, apprentice), + pay, + statutory, + statutory_pay, + pension, + sacrifice, + ) in enumerate(people): name = f"person_{i}" situation["people"][name] = { "age": {year: age}, @@ -113,6 +124,7 @@ def simulate(year, reform, people): "employment_income": {year: pay}, statutory: {year: statutory_pay}, "employer_pension_contributions": {year: pension}, + "pension_contributions_via_salary_sacrifice": {year: sacrifice}, } situation["benunits"][f"benunit_{i}"] = {"members": [name]} situation["households"][f"household_{i}"] = {"members": [name]} @@ -131,8 +143,24 @@ def test_fixed_employer_cost_is_conserved_or_pay_is_floored(population): adjusted_cost = np.asarray( simulation.calculate("adjusted_employer_cost", year), dtype=float ) + actual_baseline_cost = np.asarray( + simulation.baseline.calculate("employer_cost", year), dtype=float + ) + actual_cost = np.asarray(simulation.calculate("employer_cost", year), dtype=float) for i in range(len(people)): tol = tolerance(baseline_cost[i], adjusted_cost[i]) + assert abs(baseline_cost[i] - actual_baseline_cost[i]) <= tol, ( + people[i], + reform, + baseline_cost[i], + actual_baseline_cost[i], + ) + assert abs(adjusted_cost[i] - actual_cost[i]) <= tol, ( + people[i], + reform, + adjusted_cost[i], + actual_cost[i], + ) assert pay[i] >= -tol if pay[i] > tol: assert abs(adjusted_cost[i] - baseline_cost[i]) <= tol, ( diff --git a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py index ad8570889e..76a6763af3 100644 --- a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -158,6 +158,105 @@ def test_salary_sacrifice_excess_is_zero_rated(case, pension): assert extra_ni == pytest.approx(0.15 * 3_000, abs=0.01) +# The fixed-cost equations must charge the sacrifice above the cap as NIC +# earnings, and count returned pay and the broad-base haircut as pay, as +# ni_class_1_employer and employer_cost do. baseline_employer_cost must equal +# the baseline simulation's employer_cost, and adjusted_employer_cost the +# reformed simulation's. +RATE_RISE_2029 = { + "gov.hmrc.national_insurance.class_1.employer_scale[1].rate": {"2029": 0.16} +} +NO_HAIRCUT_2029 = { + "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate": { + "2029": 0 + } +} + + +def assert_costs_match_employer_cost(simulation, year): + baseline_cost = values(simulation, "baseline_employer_cost", year) + adjusted_cost = values(simulation, "adjusted_employer_cost", year) + np.testing.assert_allclose( + baseline_cost, values(simulation.baseline, "employer_cost", year), atol=0.01 + ) + np.testing.assert_allclose( + adjusted_cost, values(simulation, "employer_cost", year), atol=0.01 + ) + # Fixed employer cost: the reformed cost equals the baseline cost. + np.testing.assert_allclose(adjusted_cost, baseline_cost, atol=0.01) + + +def test_rate_rise_charges_the_sacrifice_excess_at_fixed_cost(): + # María's 2029 case: aged 20, £49,000 pay, a £5,000 sacrifice against the + # £2,000 cap and £2,000 of employer pension, no haircut. NIC earnings are + # 49,000 + 3,000 = £52,000, above the UST, so the baseline cost is + # 51,000 + 0.15 x (52,000 - 50,269.96) = £51,259.51. At 16% the charge + # starts where pay plus pension reaches k = 2,000 + 50,269.96 - 3,000, so + # pay plus pension is (51,259.51 + 0.16 x k) / 1.16 = 50,985.09 and pay + # falls to £48,985.09, holding cost at £51,259.51. + inputs = { + "employment_income": {2029: 49_000}, + "pension_contributions_via_salary_sacrifice": {2029: 5_000}, + "employer_pension_contributions": {2029: 2_000}, + } + people = {"person": (20, False, inputs)} + simulation = Simulation( + situation=situation(people, 2029), reform={**RATE_RISE_2029, **NO_HAIRCUT_2029} + ) + + assert values(simulation, "salary_sacrifice_pension_ni_excess", 2029)[ + 0 + ] == pytest.approx(3_000, abs=0.01) + baseline_cost = values(simulation, "baseline_employer_cost", 2029)[0] + assert baseline_cost == pytest.approx( + 51_000 + 0.15 * (52_000 - RELIEF_THRESHOLD), abs=0.01 + ) + assert baseline_cost == pytest.approx(51_259.51, abs=0.01) + k = 2_000 + RELIEF_THRESHOLD - 3_000 + new_pay = (baseline_cost + 0.16 * k) / 1.16 - 2_000 + assert new_pay == pytest.approx(48_985.09, abs=0.01) + assert values(simulation, "employment_income", 2029)[0] == pytest.approx( + new_pay, abs=0.01 + ) + assert values(simulation, "employer_ni_fixed_employer_cost_change", 2029)[ + 0 + ] == pytest.approx(new_pay - 49_000, abs=0.01) + assert_costs_match_employer_cost(simulation, 2029) + + +@pytest.mark.parametrize("case", CASES) +def test_rate_rise_with_sacrifice_excess_above_the_threshold(case): + # The control above every threshold: £60,000 pay with the same sacrifice + # and pension. The excess bears employer NI in both schedules. + age, apprentice, _ = CASES[case] + inputs = { + "employment_income": {2029: 60_000}, + "pension_contributions_via_salary_sacrifice": {2029: 5_000}, + "employer_pension_contributions": {2029: 2_000}, + } + people = {"person": (age, apprentice, inputs)} + simulation = Simulation( + situation=situation(people, 2029), reform={**RATE_RISE_2029, **NO_HAIRCUT_2029} + ) + assert values(simulation, "employment_income", 2029)[0] < 60_000 + assert_costs_match_employer_cost(simulation, 2029) + + +@pytest.mark.parametrize("case", CASES) +def test_rate_rise_with_the_broad_base_haircut(case): + # 2029 with the default haircut: pay before the reform is + # 40,000 x (1 - 0.0016) = £39,936, and the cost equations must use it. + age, apprentice, _ = CASES[case] + inputs = {"employment_income": {2029: 40_000}} + people = {"person": (age, apprentice, inputs)} + baseline = Simulation(situation=situation(people, 2029)) + assert values(baseline, "employment_income", 2029)[0] == pytest.approx( + 39_936, abs=0.01 + ) + simulation = Simulation(situation=situation(people, 2029), reform=RATE_RISE_2029) + assert_costs_match_employer_cost(simulation, 2029) + + # The fixed-cost pay change must solve the employer cost equation on NIC # earnings (pay plus statutory pay), piecewise around the reformed threshold, # for earners whose pay alone is below their baseline threshold. diff --git a/policyengine_uk/utils/employer_ni.py b/policyengine_uk/utils/employer_ni.py index 4f38b08f4d..2b3c41d837 100644 --- a/policyengine_uk/utils/employer_ni.py +++ b/policyengine_uk/utils/employer_ni.py @@ -39,3 +39,21 @@ def secondary_class_1_threshold(person, period, class_1): # In the earnings' float32, so that where no zero rate applies the charge # is computed exactly as with the scalar secondary threshold. return np.asarray(weekly * WEEKS_IN_YEAR, dtype=np.float32) + + +def salary_sacrifice_cost_terms(person, period): + """The salary sacrifice terms in an employer's cost and NIC earnings. + + Employment income includes salary sacrifice returned to ordinary pay and + the broad-base haircut, so both are pay. Class 1 earnings also include the + sacrifice above the cap (salary_sacrifice_pension_ni_excess), which bears + employer NI but is neither cash pay nor an actual pension payment. + + `person` is the Person population of either the reformed or the baseline + simulation, so a variable can take each schedule's own terms. Returns the + pay terms and the NIC-only earnings. + """ + pay = person("salary_sacrifice_returned_to_income", period) + person( + "salary_sacrifice_broad_base_haircut", period + ) + return pay, person("salary_sacrifice_pension_ni_excess", period) diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py index 3805532e78..0419288337 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/adjusted_employer_cost.py @@ -26,6 +26,9 @@ def formula(person, period, parameters): ni_class_1_income = ( employment_income + benefits + employer_pension_contributions ) + # The sacrifice above the cap bears employer NI but is not cash pay or + # an actual pension payment. + ni_only_earnings = person("salary_sacrifice_pension_ni_excess", period) # Calculate employer cost parameters = parameters(period) @@ -40,5 +43,7 @@ def formula(person, period, parameters): else: pen_con_subtracted_r = 0 - employer_ni = r_r * max_(0, ni_class_1_income - pen_con_subtracted_r - t_r) + employer_ni = r_r * max_( + 0, ni_class_1_income - pen_con_subtracted_r + ni_only_earnings - t_r + ) return ni_class_1_income + employer_ni diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py index 1cce92922d..612d3c522f 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/baseline_employer_cost.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.employer_ni import secondary_class_1_threshold +from policyengine_uk.utils.employer_ni import ( + salary_sacrifice_cost_terms, + secondary_class_1_threshold, +) class baseline_employer_cost(Variable): @@ -10,6 +13,8 @@ class baseline_employer_cost(Variable): unit = GBP def formula(person, period, parameters): + if person.simulation.baseline is None: + return 0 prior_employment_income = person("employment_income_before_lsr", period) employment_income_behavioral_response = person( "employment_income_behavioral_response", period @@ -26,16 +31,22 @@ def formula(person, period, parameters): employer_pension_contributions = person( "employer_pension_contributions", period ) + # The baseline's salary sacrifice terms: returned pay and the + # broad-base haircut are pay; the excess over the cap is NIC earnings + # only. + baseline_person = person.simulation.baseline.populations["person"] + sacrifice_pay, ni_only_earnings = salary_sacrifice_cost_terms( + baseline_person, period + ) ni_class_1_income = ( prior_employment_income + employment_income_behavioral_response + + sacrifice_pay + benefits + employer_pension_contributions ) # Calculate baseline employer cost - if person.simulation.baseline is None: - return 0 baseline_parameters = person.simulation.baseline.tax_benefit_system.parameters( period ) @@ -51,6 +62,6 @@ def formula(person, period, parameters): pen_con_subtracted_b = 0 baseline_employer_ni = r_b * max_( - 0, ni_class_1_income - pen_con_subtracted_b - t_b + 0, ni_class_1_income - pen_con_subtracted_b + ni_only_earnings - t_b ) return ni_class_1_income + baseline_employer_ni diff --git a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py index bd46b64aeb..5ab6c89d52 100644 --- a/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py +++ b/policyengine_uk/variables/contrib/policyengine/employer_ni/employer_ni_fixed_employer_cost_change.py @@ -1,5 +1,8 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.employer_ni import secondary_class_1_threshold +from policyengine_uk.utils.employer_ni import ( + salary_sacrifice_cost_terms, + secondary_class_1_threshold, +) class employer_ni_fixed_employer_cost_change(Variable): @@ -16,6 +19,9 @@ def formula(person, period, parameters): if employee_incidence == 0: return 0 + if person.simulation.baseline is None: + return 0 + # First, calculate baseline and reformed employer NI contributions. prior_employment_income = person("employment_income_before_lsr", period) employment_income_behavioral_response = person( @@ -33,16 +39,27 @@ def formula(person, period, parameters): employer_pension_contributions = person( "employer_pension_contributions", period ) + # Each schedule's salary sacrifice terms: returned pay and the + # broad-base haircut are pay; the excess over the cap is NIC earnings + # only, so it bears employer NI but is neither pay nor pension. + baseline_person = person.simulation.baseline.populations["person"] + sacrifice_pay_b, ni_only_earnings_b = salary_sacrifice_cost_terms( + baseline_person, period + ) + sacrifice_pay_r, ni_only_earnings_r = salary_sacrifice_cost_terms( + person, period + ) + pay_before_sacrifice = ( + prior_employment_income + employment_income_behavioral_response + ) ni_class_1_income = ( - prior_employment_income - + employment_income_behavioral_response + pay_before_sacrifice + + sacrifice_pay_b + benefits + employer_pension_contributions ) # Calculate baseline employer cost - if person.simulation.baseline is None: - return 0 baseline_parameters = person.simulation.baseline.tax_benefit_system.parameters( period ) @@ -55,7 +72,7 @@ def formula(person, period, parameters): pen_con_subtracted_b = employer_pension_contributions if p_b else 0 baseline_employer_ni = r_b * max_( - ni_class_1_income - pen_con_subtracted_b - t_b, 0 + ni_class_1_income - pen_con_subtracted_b + ni_only_earnings_b - t_b, 0 ) c_b = ni_class_1_income + baseline_employer_ni @@ -72,20 +89,21 @@ def formula(person, period, parameters): return 0 # Calculate new NIC earnings keeping employer cost constant. Solve - # c_b = x + r_r * max(x - k_r, 0) for x, where k_r is the point at - # which the reformed charge starts. The cost is continuous and - # strictly increasing in x, so x = c_b where c_b is at or below k_r - # (no reformed charge), and otherwise the linear solution above it. - # NIC earnings include statutory pay, so this also holds where pay - # alone is below a threshold but pay plus statutory pay is above it, - # and where a reform moves the threshold across a person's earnings. - k_r = pen_con_subtracted_r + t_r + # c_b = x + r_r * max(x - k_r, 0) for x, the cost before employer NI, + # where k_r is the point at which the reformed charge starts: the + # threshold plus exempt pension contributions, less the NIC-only + # excess. The cost is continuous and strictly increasing in x, so + # x = c_b where c_b is at or below k_r (no reformed charge), and + # otherwise the linear solution above it. NIC earnings include + # statutory pay and the excess, so this also holds where pay alone is + # below a threshold but NIC earnings are above it, and where a reform + # moves the threshold across a person's earnings. + k_r = pen_con_subtracted_r + t_r - ni_only_earnings_r new_ni_class_1_income = where(c_b <= k_r, c_b, (c_b + r_r * k_r) / (1 + r_r)) - # Find difference in employment income - previous_employment_income = ( - ni_class_1_income - benefits - employer_pension_contributions - ) + # Find difference in employment income: the reformed pay before this + # change includes the reform's own salary sacrifice pay terms. + previous_employment_income = pay_before_sacrifice + sacrifice_pay_r new_employment_income = ( new_ni_class_1_income - benefits - employer_pension_contributions ) From a1cd832565b8076393cefa5211f1f0792d7e01d7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 20:11:06 -0400 Subject: [PATCH 08/10] Hold the haircut out of both populations in the fixed-cost regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit María's 2029 case assumes no broad-base haircut. The test removed it with a parameter reform, which changes the reformed population only: a reform's baseline simulation is built from the situation under current law, so the baseline kept the 0.16% haircut, its pay was £48,921.60 and its cost £51,169.35 rather than £51,259.51. The haircut is now a situation input of zero, which both populations share, and the test checks the baseline pay. The fixed-cost property also draws salary sacrifice returned to pay, and four explicit examples make every run cover a sacrifice above the cap in 2029 and 2030, returned pay and the zero-pay floor. Co-Authored-By: Claude Opus 5.5 --- .../test_employer_ni_fixed_cost_properties.py | 109 ++++++++++++++++-- .../test_employer_ni_zero_rate_consumers.py | 31 ++--- 2 files changed, 118 insertions(+), 22 deletions(-) diff --git a/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py index 289c03988a..4e7ddb9cd2 100644 --- a/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py +++ b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py @@ -3,11 +3,12 @@ With full employee incidence (the default), employer_ni_fixed_employer_cost_change moves pay so that the employer's cost is unchanged by the reform. For a person -with pay P (including the salary sacrifice cap's broad-base haircut), statutory -pay S, employer pension contributions E and salary sacrifice above the cap X -(NIC earnings only, from 2029), the cost under a schedule with employer rate r -and secondary threshold T (the secondary threshold, or the UST or AUST where -SSCBA 1992 s.9A or s.9B zero-rates the earner) is +with pay P (including salary sacrifice returned to ordinary pay and the salary +sacrifice cap's broad-base haircut), statutory pay S, employer pension +contributions E and salary sacrifice above the cap X (NIC earnings only, from +2029), the cost under a schedule with employer rate r and secondary threshold T +(the secondary threshold, or the UST or AUST where SSCBA 1992 s.9A or s.9B +zero-rates the earner) is cost = P + S + E + r * max(P + S + X + E - E_exempt - T, 0) @@ -26,14 +27,16 @@ The population mixes earners under 21, relevant apprentices under 25 and other earners, each with statutory pay that can carry NIC earnings over a threshold -that pay alone is below, and a salary sacrifice that the 2029 cap can make -partly NIC-able. +that pay alone is below, a salary sacrifice that the 2029 cap can make partly +NIC-able, and pay returned from salary sacrifice. Explicit examples make sure +every run covers a sacrifice above the cap in 2029 and 2030, returned pay and +the zero-pay floor, which 25 drawn examples need not reach. Comparisons allow float32 rounding: the model stores values as float32. """ import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -63,6 +66,89 @@ ) +def rate_reform(year, rate, secondary=None): + period = f"{year}-01-01.2100-12-31" + reform = {f"{CLASS_1}.employer_scale[1].rate": {period: rate}} + if secondary is not None: + reform[f"{CLASS_1}.employer_scale[1].threshold"] = {period: secondary} + return reform + + +# Each person: (age, is_apprentice), pay, statutory pay variable and amount, +# employer pension contributions, salary sacrifice, returned pay. +EXAMPLES = [ + # A sacrifice above the £2,000 cap in 2029: María's case (aged 20, + # zero-rated up to the UST) and an earner aged 40. + ( + 2029, + rate_reform(2029, 0.16), + [ + ((20, False), 49_000.0, "statutory_sick_pay", 0.0, 2_000.0, 5_000.0, 0.0), + ((40, False), 60_000.0, "statutory_sick_pay", 0.0, 2_000.0, 8_000.0, 0.0), + ], + ), + # 2030, with returned pay and a new secondary threshold of £100 a week. + ( + 2030, + rate_reform(2030, 0.2, 100.0), + [ + ( + (24, True), + 45_000.0, + "statutory_paternity_pay", + 1_000.0, + 3_000.0, + 6_000.0, + 1_500.0, + ), + ( + (21, False), + 30_000.0, + "statutory_sick_pay", + 0.0, + 0.0, + 4_000.0, + 2_500.0, + ), + ], + ), + # The zero-pay floor: £1 of pay with £10,000 each of statutory maternity + # pay and employer pension, at 30% from a zero threshold. In 2026 the + # baseline cost is 20,001 + 0.15 x (10,001 - 5,000) = £20,751.15, and at + # zero pay the reformed cost is 20,000 + 0.3 x 10,000 = £23,000. In 2029 a + # £10,000 sacrifice adds an £8,000 excess, and returned pay is floored too. + ( + 2026, + rate_reform(2026, 0.3, 0.0), + [((40, False), 1.0, "statutory_maternity_pay", 10_000.0, 10_000.0, 0.0, 0.0)], + ), + ( + 2029, + rate_reform(2029, 0.3, 0.0), + [ + ( + (40, False), + 1.0, + "statutory_maternity_pay", + 10_000.0, + 10_000.0, + 10_000.0, + 0.0, + ), + ( + (40, False), + 1.0, + "statutory_maternity_pay", + 10_000.0, + 10_000.0, + 10_000.0, + 500.0, + ), + ], + ), +] + + def tolerance(*amounts): return 0.01 + 4e-7 * max(abs(float(a)) for a in amounts) @@ -99,6 +185,7 @@ def populations(draw): st.floats(0, 10_000, allow_nan=False), st.floats(0, 10_000, allow_nan=False), st.floats(0, 10_000, allow_nan=False), + st.one_of(st.just(0.0), st.floats(0, 10_000, allow_nan=False)), ), min_size=1, max_size=12, @@ -116,6 +203,7 @@ def simulate(year, reform, people): statutory_pay, pension, sacrifice, + returned, ) in enumerate(people): name = f"person_{i}" situation["people"][name] = { @@ -125,6 +213,7 @@ def simulate(year, reform, people): statutory: {year: statutory_pay}, "employer_pension_contributions": {year: pension}, "pension_contributions_via_salary_sacrifice": {year: sacrifice}, + "salary_sacrifice_returned_to_income": {year: returned}, } situation["benunits"][f"benunit_{i}"] = {"members": [name]} situation["households"][f"household_{i}"] = {"members": [name]} @@ -133,6 +222,10 @@ def simulate(year, reform, people): @PROPERTY_SETTINGS @given(populations()) +@example(population=EXAMPLES[0]) +@example(population=EXAMPLES[1]) +@example(population=EXAMPLES[2]) +@example(population=EXAMPLES[3]) def test_fixed_employer_cost_is_conserved_or_pay_is_floored(population): year, reform, people = population simulation = simulate(year, reform, people) diff --git a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py index 76a6763af3..b86f02b510 100644 --- a/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -166,11 +166,11 @@ def test_salary_sacrifice_excess_is_zero_rated(case, pension): RATE_RISE_2029 = { "gov.hmrc.national_insurance.class_1.employer_scale[1].rate": {"2029": 0.16} } -NO_HAIRCUT_2029 = { - "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate": { - "2029": 0 - } -} +# No broad-base haircut in either population. A reform's baseline simulation +# is built from the situation under current law, so a reform setting the +# haircut rate to zero would remove it from the reformed population only. The +# haircut is an input here instead, which both populations share. +NO_HAIRCUT_2029 = {"salary_sacrifice_broad_base_haircut": {2029: 0}} def assert_costs_match_employer_cost(simulation, year): @@ -188,9 +188,11 @@ def assert_costs_match_employer_cost(simulation, year): def test_rate_rise_charges_the_sacrifice_excess_at_fixed_cost(): # María's 2029 case: aged 20, £49,000 pay, a £5,000 sacrifice against the - # £2,000 cap and £2,000 of employer pension, no haircut. NIC earnings are - # 49,000 + 3,000 = £52,000, above the UST, so the baseline cost is - # 51,000 + 0.15 x (52,000 - 50,269.96) = £51,259.51. At 16% the charge + # £2,000 cap and £2,000 of employer pension, no haircut in the baseline or + # the reform. NIC earnings are 49,000 + 3,000 = £52,000, above the UST, so + # the baseline cost is 51,000 + 0.15 x (52,000 - 50,269.96) = £51,259.51. + # (With the 0.16% haircut in the baseline alone, pay there would be + # £48,921.60 and the cost £51,169.35.) At 16% the charge # starts where pay plus pension reaches k = 2,000 + 50,269.96 - 3,000, so # pay plus pension is (51,259.51 + 0.16 x k) / 1.16 = 50,985.09 and pay # falls to £48,985.09, holding cost at £51,259.51. @@ -198,15 +200,17 @@ def test_rate_rise_charges_the_sacrifice_excess_at_fixed_cost(): "employment_income": {2029: 49_000}, "pension_contributions_via_salary_sacrifice": {2029: 5_000}, "employer_pension_contributions": {2029: 2_000}, + **NO_HAIRCUT_2029, } people = {"person": (20, False, inputs)} - simulation = Simulation( - situation=situation(people, 2029), reform={**RATE_RISE_2029, **NO_HAIRCUT_2029} - ) + simulation = Simulation(situation=situation(people, 2029), reform=RATE_RISE_2029) assert values(simulation, "salary_sacrifice_pension_ni_excess", 2029)[ 0 ] == pytest.approx(3_000, abs=0.01) + assert values(simulation.baseline, "employment_income", 2029)[0] == pytest.approx( + 49_000, abs=0.01 + ) baseline_cost = values(simulation, "baseline_employer_cost", 2029)[0] assert baseline_cost == pytest.approx( 51_000 + 0.15 * (52_000 - RELIEF_THRESHOLD), abs=0.01 @@ -233,11 +237,10 @@ def test_rate_rise_with_sacrifice_excess_above_the_threshold(case): "employment_income": {2029: 60_000}, "pension_contributions_via_salary_sacrifice": {2029: 5_000}, "employer_pension_contributions": {2029: 2_000}, + **NO_HAIRCUT_2029, } people = {"person": (age, apprentice, inputs)} - simulation = Simulation( - situation=situation(people, 2029), reform={**RATE_RISE_2029, **NO_HAIRCUT_2029} - ) + simulation = Simulation(situation=situation(people, 2029), reform=RATE_RISE_2029) assert values(simulation, "employment_income", 2029)[0] < 60_000 assert_costs_match_employer_cost(simulation, 2029) From e449f21b6882c74f8df98d4230cd32efaa6bbf9a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 22:22:54 -0400 Subject: [PATCH 09/10] Hold pay fixed in the measure test's within-year world The "rates change within the year" world turns off the employer pension contribution exemption, which switches on the fixed-employer-cost pay change. The test removes the excess from the reformed simulation only, so that change passed employer NI on the excess into pay there, and pay differed between the simulations with and without the excess (invariant 1 failed for most known cases). Employee incidence is 0 in that world, so pay is the same in both. A known case aged 20 now has its excess between the secondary and upper secondary thresholds, so the zero-rate band of invariant 4 always sees it. Co-Authored-By: Claude Opus 5.5 --- ..._salary_sacrifice_ni_measure_properties.py | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py index 208d3dd397..57d00b2a38 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -38,10 +38,12 @@ and 2030 under it), with a hypothetical cap in 2022 to exercise the annual full-year Category A director schedule, and in a world where the employee rates and the upper earnings limit change within 2041 and employer pension -contributions are charged (invariant 4 under current law only: it needs one -rate and schedule for the year). Fiscal-year conversion gives every parameter -one value for each year from 2015 to 2040, so a year after 2040 is needed for -rates that differ between months; ni_class_1_employee is a monthly variable. +contributions are charged, with no employee incidence of the fixed employer +cost so that pay is the same with and without the excess (invariant 4 under +current law only: it needs one rate and schedule for the year). Fiscal-year +conversion gives every parameter one value for each year from 2015 to 2040, +so a year after 2040 is needed for rates that differ between months; +ni_class_1_employee is a monthly variable. Comparisons allow float32 rounding: the model stores values as float32. """ @@ -67,6 +69,12 @@ f"{CLASS_1}.employee_scale[2].threshold": {"2041-04-01.2041-12-31": 1_300}, "gov.contrib.policyengine.employer_ni." "exempt_employer_pension_contributions": False, + # Changing the exemption switches on the fixed-employer-cost pay + # change, which would pass employer NI on the excess into pay. + # Only the reformed simulation's excess is zeroed (its baseline + # keeps it), so pay would differ between the simulations with and + # without the excess. With no employee incidence it does not. + "gov.contrib.policyengine.employer_ni.employee_incidence": 0, }, [2030, 2041], ), @@ -96,8 +104,9 @@ # below every threshold, a person over State Pension age, a person under 16, # one whose statutory sick pay takes them over the upper earnings limit, # one with no excess, a full-year Category A director crossing the upper -# earnings limit, and an earner aged 20 whose excess crosses the upper -# secondary threshold. +# earnings limit, an earner aged 20 whose excess crosses the upper secondary +# threshold, and one aged 20 whose excess lies between the secondary and upper +# secondary thresholds. KNOWN_CASES = [ Row(48_000, 6_000, 10_000, 40, 0, 0), Row(11_000, 5_000, 3_000, 40, 0, 0), @@ -109,6 +118,7 @@ Row(60_000, 1_500, 0, 40, 0, 0), Row(48_000, 6_000, 10_000, 40, 0, 0, True), Row(48_000, 6_000, 10_000, 20, 0, 0), + Row(30_000, 5_000, 0, 20, 0, 0), ] @@ -352,10 +362,14 @@ def crosses(threshold): False, True, True, + False, ] assert crosses(employee[1].threshold)[1] assert crosses(secondary_threshold)[2] assert crosses(class_1.thresholds.upper_secondary_threshold)[9] + upper_secondary_threshold = class_1.thresholds.upper_secondary_threshold + assert without[10] > secondary_threshold * WEEKS_IN_YEAR + assert earnings[10] < upper_secondary_threshold * WEEKS_IN_YEAR assert excess[3] > 0 assert earnings[3] < secondary_threshold * WEEKS_IN_YEAR liable = simulation.calculate("ni_liable", year) From 63dbac7d19a083c2036d94feb38b94a7f557b4df Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 23 Oct 2026 22:40:07 -0400 Subject: [PATCH 10/10] Cover the apprentice threshold in the measure property test Rows can now be apprentices, drawn and pinned. The new known row is an apprentice aged 23 whose excess lies between the secondary threshold and the apprentice upper secondary threshold (SSCBA 1992 s.9B), so invariant 4's rate-0 employer band checks the s.9B arm on every run. Before this, no row set is_apprentice, and _own_secondary_threshold's apprentice arm was never exercised (round 7 review, P3). Co-Authored-By: Claude Opus 5.5 --- ...st_salary_sacrifice_ni_measure_properties.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py index 57d00b2a38..4561a77ac4 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -94,8 +94,8 @@ Row = namedtuple( "Row", - "pay sacrifice more_sacrifice age sick_pay employer_pension director", - defaults=[False], + "pay sacrifice more_sacrifice age sick_pay employer_pension director apprentice", + defaults=[False, False], ) # People the properties must always cover, whatever is drawn. With the £2,000 @@ -105,8 +105,9 @@ # one whose statutory sick pay takes them over the upper earnings limit, # one with no excess, a full-year Category A director crossing the upper # earnings limit, an earner aged 20 whose excess crosses the upper secondary -# threshold, and one aged 20 whose excess lies between the secondary and upper -# secondary thresholds. +# threshold, one aged 20 whose excess lies between the secondary and upper +# secondary thresholds, and an apprentice aged 23 whose excess lies between +# the secondary and apprentice upper secondary thresholds. KNOWN_CASES = [ Row(48_000, 6_000, 10_000, 40, 0, 0), Row(11_000, 5_000, 3_000, 40, 0, 0), @@ -119,6 +120,7 @@ Row(48_000, 6_000, 10_000, 40, 0, 0, True), Row(48_000, 6_000, 10_000, 20, 0, 0), Row(30_000, 5_000, 0, 20, 0, 0), + Row(30_000, 5_000, 0, 23, 0, 0, False, True), ] @@ -147,6 +149,7 @@ def _amount(maximum): sick_pay=st.one_of(st.just(0.0), _amount(8_000)), employer_pension=st.one_of(st.just(0.0), _amount(20_000)), director=st.booleans(), + apprentice=st.booleans(), ), min_size=1, max_size=40, @@ -170,6 +173,7 @@ def each_year(value): "statutory_sick_pay": each_year(row.sick_pay), "employer_pension_contributions": each_year(row.employer_pension), "is_full_year_category_a_director": each_year(row.director), + "is_apprentice": each_year(row.apprentice), } for name, row in zip(names, people) }, @@ -363,6 +367,7 @@ def crosses(threshold): True, True, False, + False, ] assert crosses(employee[1].threshold)[1] assert crosses(secondary_threshold)[2] @@ -370,6 +375,10 @@ def crosses(threshold): upper_secondary_threshold = class_1.thresholds.upper_secondary_threshold assert without[10] > secondary_threshold * WEEKS_IN_YEAR assert earnings[10] < upper_secondary_threshold * WEEKS_IN_YEAR + apprentice_threshold = class_1.thresholds.apprentice_upper_secondary_threshold + assert simulation.calculate("is_apprentice", year)[11] + assert without[11] > secondary_threshold * WEEKS_IN_YEAR + assert earnings[11] < apprentice_threshold * WEEKS_IN_YEAR assert excess[3] > 0 assert earnings[3] < secondary_threshold * WEEKS_IN_YEAR liable = simulation.calculate("ni_liable", year)