diff --git a/changelog.d/1918.fixed.md b/changelog.d/1918.fixed.md new file mode 100644 index 0000000000..7f4ae3d41a --- /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. 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/parameters/gov/hmrc/create_threshold_indexation.py b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py index a838ad12dd..02f875ba2e 100644 --- a/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py +++ b/policyengine_uk/parameters/gov/hmrc/create_threshold_indexation.py @@ -66,7 +66,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 @@ -92,7 +96,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 eight, +values are rebuilt with the change: a changed CPI forecast moves all ten, 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: @@ -137,6 +141,8 @@ "gov.hmrc.national_insurance.class_1.employee_scale[1].threshold", "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold", "gov.hmrc.national_insurance.class_1.employer_scale[1].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.contribution_scale[1].threshold", "gov.hmrc.national_insurance.class_4.contribution_scale[2].threshold", ) @@ -339,6 +345,8 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_1.employee_scale.brackets[1].threshold, class_1.employee_scale.brackets[2].threshold, class_1.employer_scale.brackets[1].threshold, + class_1.thresholds.upper_secondary_threshold, + class_1.thresholds.apprentice_upper_secondary_threshold, class_4.contribution_scale.brackets[1].threshold, class_4.contribution_scale.brackets[2].threshold, ] @@ -386,12 +394,17 @@ def add_threshold_indexation(parameters: ParameterNode) -> ParameterNode: class_1.employee_scale.brackets[1].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.employee_scale.brackets[2].threshold, - { - year: weekly_equivalent(value) - for year, value in higher_rate_threshold.items() - }, + class_1.employee_scale.brackets[2].threshold, upper_earnings_limit + ) + write_unstated_tax_years( + class_1.thresholds.upper_secondary_threshold, upper_earnings_limit + ) + write_unstated_tax_years( + class_1.thresholds.apprentice_upper_secondary_threshold, upper_earnings_limit ) write_unstated_tax_years( class_1.employer_scale.brackets[1].threshold, secondary_threshold 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/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/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 d474ca4738..4829f90a65 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 @@ -199,3 +199,74 @@ ni_class_1_income: 51_923.20 # Earnings stay above the secondary threshold: 4,000 x 15% = 600. salary_sacrifice_pension_ni_employer: 4_000 * 0.15 + +# 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). +# The £3,000 of a £5,000 sacrifice above the cap enters NIC earnings only +# (total £33,000), all below them. +- name: No employer NI on the excess 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_pension_ni_excess: 3_000 + salary_sacrifice_returned_to_income: 0 + employment_income: 30_000 + ni_class_1_income: 33_000 + salary_sacrifice_pension_ni_employer: 0 + ni_class_1_employer: 0 + # 30,000 pay + 2,000 employer pension contribution, no employer NI. + adjusted_employer_cost: 32_000 + +- name: No employer NI on the excess 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: 32_000 + +- name: Employer NI on the excess 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 excess sits above the secondary threshold. + salary_sacrifice_pension_ni_employer: 3_000 * 0.15 + +- name: No employer NI on the excess 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 excess; NIC earnings of £44,000 are below the UST (£50,270). + salary_sacrifice_pension_ni_excess: 4_000 + ni_class_1_income: 44_000 + salary_sacrifice_pension_ni_employer: 0 + ni_class_1_employer: 0 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..4e7ddb9cd2 --- /dev/null +++ b/policyengine_uk/tests/test_employer_ni_fixed_cost_properties.py @@ -0,0 +1,273 @@ +"""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 (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) + +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. 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. +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, 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, example, 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, 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 = [ + (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 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) + + +@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), + 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, + ) + ) + return year, reform, people + + +def simulate(year, reform, people): + situation = {"people": {}, "benunits": {}, "households": {}} + for i, ( + (age, apprentice), + pay, + statutory, + statutory_pay, + pension, + sacrifice, + returned, + ) 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}, + "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]} + return Simulation(situation=situation, reform=reform) + + +@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) + 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 + ) + 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, ( + 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 new file mode 100644 index 0000000000..b86f02b510 --- /dev/null +++ b/policyengine_uk/tests/test_employer_ni_zero_rate_consumers.py @@ -0,0 +1,343 @@ +"""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 the £3,000 excess enters NIC earnings only (total £33,000; +pay stays £30,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 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. The same holds with £5,000 of total employer pension +contributions (employer cost £35,000). +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +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} +} +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) + + +# 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, 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: employer_pension}, + } + 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] + excess = values(simulation, "salary_sacrifice_pension_ni_excess", 2029)[0] + nic_earnings = values(simulation, "ni_class_1_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] + + # The excess enters NIC earnings only; it is not returned to pay. + assert returned == pytest.approx(0, abs=0.01) + assert excess == pytest.approx(3_000, abs=0.01) + assert nic_earnings == pytest.approx(33_000, abs=0.01) + assert pay == pytest.approx(PAY, 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(PAY + employer_pension, abs=0.01) + else: + 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 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): + 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 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. + inputs = { + "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) + + 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 + ) + 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}, + **NO_HAIRCUT_2029, + } + people = {"person": (age, apprentice, inputs)} + 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) + + +@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. +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 = RELIEF_THRESHOLD + baseline_cost = values(simulation, "baseline_employer_cost", 2026)[0] + # 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.01) + pay = values(simulation, "employment_income", 2026)[0] + 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.01) + + +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] + # 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.01) + assert values(simulation, "adjusted_employer_cost", 2026)[0] == pytest.approx( + baseline_cost, abs=0.01 + ) 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_salary_sacrifice_ni_measure_properties.py b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py index d2fb437de1..4561a77ac4 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -29,17 +29,21 @@ 4. One band, one rate: when the earnings with and without the excess lie in the same band, the measure is that band's rate times the excess. This is where the earlier single-rate formulas were - right. + right. The employer bands are split at each earner's own secondary + threshold, which is higher for an earner under 21 (SSCBA 1992 s.9A) and + an apprentice under 25 (s.9B). 5. Monotonicity: a measure never falls when the sacrifice rises. The invariants are checked under current law (2028, before the cap, and 2029 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. """ @@ -65,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], ), @@ -84,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 @@ -93,8 +103,11 @@ # crosses the primary threshold, one that crosses the secondary threshold, one # 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, and a full-year Category A director crossing 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, 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), @@ -105,6 +118,9 @@ Row(46_000, 5_000, 0, 40, 3_000, 0), 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), + Row(30_000, 5_000, 0, 23, 0, 0, False, True), ] @@ -133,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, @@ -156,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) }, @@ -182,6 +200,33 @@ def _class_1(simulation, instant): return parameters.gov.hmrc.national_insurance.class_1 +def _own_secondary_threshold(simulation, year, class_1): + """Each earner's annual secondary threshold, from the parameters. + + The secondary threshold, or 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), whichever is highest. + """ + age = _values(simulation, "age", year) + apprentice = simulation.calculate("is_apprentice", year) + limits = class_1.secondary_zero_rate_age_limits + thresholds = class_1.thresholds + weekly = np.maximum.reduce( + [ + np.full_like(age, class_1.employer_scale.components[1].threshold), + np.where( + age < limits.under_21_group, thresholds.upper_secondary_threshold, 0 + ), + np.where( + apprentice & (age < limits.relevant_apprentice), + thresholds.apprentice_upper_secondary_threshold, + 0, + ), + ] + ) + return weekly * WEEKS_IN_YEAR + + def _check_one_band_one_rate(simulation, year, excess): """Invariant 4, for a year whose rates and thresholds do not change.""" class_1 = _class_1(simulation, f"{year}-01-01") @@ -189,7 +234,7 @@ def _check_one_band_one_rate(simulation, year, excess): employer = class_1.employer_scale.components primary_threshold = employee[1].threshold * WEEKS_IN_YEAR upper_earnings_limit = employee[2].threshold * WEEKS_IN_YEAR - secondary_threshold = employer[1].threshold * WEEKS_IN_YEAR + secondary_threshold = _own_secondary_threshold(simulation, year, class_1) earnings = _values(simulation, "ni_class_1_income", year) without = earnings - excess # A pound clear of each threshold, so float32 rounding cannot move a @@ -320,9 +365,20 @@ def crosses(threshold): True, False, True, + True, + False, + 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 + 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) diff --git a/policyengine_uk/tests/test_threshold_indexation.py b/policyengine_uk/tests/test_threshold_indexation.py index 828e3f10e2..fa89093871 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/employee_scale.yaml", "upper_earnings_limit": "national_insurance/class_1/employee_scale.yaml", "secondary_threshold": "national_insurance/class_1/employer_scale.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/contribution_scale.yaml", "upper_profits_limit": "national_insurance/class_4/contribution_scale.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 class_4 = hmrc.national_insurance.class_4 @@ -123,6 +127,10 @@ def threshold_parameters(root): "primary_threshold": class_1.employee_scale.brackets[1].threshold, "upper_earnings_limit": class_1.employee_scale.brackets[2].threshold, "secondary_threshold": class_1.employer_scale.brackets[1].threshold, + "upper_secondary_threshold": class_1.thresholds.upper_secondary_threshold, + "apprentice_upper_secondary_threshold": ( + class_1.thresholds.apprentice_upper_secondary_threshold + ), "lower_profits_limit": class_4.contribution_scale.brackets[1].threshold, "upper_profits_limit": class_4.contribution_scale.brackets[2].threshold, } @@ -472,6 +480,13 @@ def series(base, first, last, month_day="04-06"): }, ] }, + "thresholds": { + name: series(bases[name], 2021, last_years[name]) + for name in ( + "upper_secondary_threshold", + "apprentice_upper_secondary_threshold", + ) + }, }, "class_4": { "contribution_scale": { @@ -521,6 +536,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, } @@ -531,6 +548,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))) + ), } @@ -552,6 +576,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), @@ -833,7 +863,7 @@ def test_income_tax_and_nics_use_the_indexed_thresholds_in_2031(system): def test_canonical_threshold_registration_keeps_incoming_savings_writer(): - """The merged indexation owns eight unique paths, including annual savings.""" + """The merged indexation owns ten unique paths, including annual savings.""" from policyengine_uk.parameters.gov.hmrc.create_threshold_indexation import ( INDEXED_THRESHOLDS, ) @@ -845,6 +875,8 @@ def test_canonical_threshold_registration_keeps_incoming_savings_writer(): "gov.hmrc.national_insurance.class_1.employee_scale[1].threshold", "gov.hmrc.national_insurance.class_1.employee_scale[2].threshold", "gov.hmrc.national_insurance.class_1.employer_scale[1].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.contribution_scale[1].threshold", "gov.hmrc.national_insurance.class_4.contribution_scale[2].threshold", } diff --git a/policyengine_uk/utils/employer_ni.py b/policyengine_uk/utils/employer_ni.py new file mode 100644 index 0000000000..2b3c41d837 --- /dev/null +++ b/policyengine_uk/utils/employer_ni.py @@ -0,0 +1,59 @@ +import numpy as np +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_( + class_1.employer_scale.components[1].threshold, + max_(under_21_threshold, apprentice_threshold), + ) + # 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 d4e2d50547..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 @@ -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): @@ -25,12 +26,16 @@ 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) class_1 = parameters.gov.hmrc.national_insurance.class_1 r_r = class_1.employer_scale.components[1].rate - t_r = class_1.employer_scale.components[1].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: @@ -38,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 0a929b7530..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,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import ( + salary_sacrifice_cost_terms, + secondary_class_1_threshold, +) class baseline_employer_cost(Variable): @@ -9,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 @@ -25,22 +31,29 @@ 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 ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 r_b = baseline_class_1.employer_scale.components[1].rate - t_b = baseline_class_1.employer_scale.components[1].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: @@ -49,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 37af8cc502..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,4 +1,8 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import ( + salary_sacrifice_cost_terms, + secondary_class_1_threshold, +) class employer_ni_fixed_employer_cost_change(Variable): @@ -15,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( @@ -32,28 +39,40 @@ 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 ) baseline_class_1 = baseline_parameters.gov.hmrc.national_insurance.class_1 r_b = baseline_class_1.employer_scale.components[1].rate - t_b = baseline_class_1.employer_scale.components[1].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 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 @@ -61,33 +80,43 @@ 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.employer_scale.components[1].rate - t_r = reform_class_1.employer_scale.components[1].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 - # 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, 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 ) - 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 the secondary threshold, 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) 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 c4ad894ea7..93d29e5979 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,14 +1,35 @@ from policyengine_uk.model_api import * +from policyengine_uk.utils.employer_ni import secondary_class_1_threshold 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,11 +43,10 @@ def formula(person, period, parameters): taxed_earnings = earnings + added_pension_contributions else: taxed_earnings = earnings - secondary_threshold = ( - class_1.employer_scale.components[1].threshold * WEEKS_IN_YEAR + # 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.employer_scale.components[1].rate * max_( + taxed_earnings - threshold, 0 ) - main_earnings = max_( - taxed_earnings - secondary_threshold, - 0, - ) - return class_1.employer_scale.components[1].rate * main_earnings 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 059e6ae4b2..640dc914ec 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): @@ -10,9 +11,12 @@ class salary_sacrifice_pension_ni_employer(Variable): "(ni_class_1_employer) on the employee's earnings, less the liability " "on the same earnings without the NIC-only excess " "(salary_sacrifice_pension_ni_excess). The excess is the " - "top slice of earnings: it bears the employer rate above the " - "secondary threshold, and nothing below it or for an earner on whom " - "no secondary contributions are due (ni_class_1_secondary_liable). " + "top slice of earnings: 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), and nothing " + "for an earner on whom no secondary contributions are due " + "(ni_class_1_secondary_liable). " "\n\n" "This measures part of ni_class_1_employer, which already charges the " "NIC-only excess. Do not add it to a National Insurance total." @@ -25,18 +29,22 @@ class salary_sacrifice_pension_ni_employer(Variable): reference = ( "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", "https://www.legislation.gov.uk/ukpga/1992/4/section/9", + "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): gov = parameters(period).gov class_1 = gov.hmrc.national_insurance.class_1 excess = person("salary_sacrifice_pension_ni_excess", period) - # The earnings ni_class_1_employer charges. + # The earnings ni_class_1_employer charges, which include the excess. earnings = person("ni_class_1_income", period) employer_ni = gov.contrib.policyengine.employer_ni if not employer_ni.exempt_employer_pension_contributions: earnings = earnings + person("employer_pension_contributions", period) - scale = class_1.employer_scale - return scale.calc(earnings, factor=WEEKS_IN_YEAR) - scale.calc( - earnings - excess, factor=WEEKS_IN_YEAR + 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.employer_scale.components[1].rate * ( + above_threshold - above_threshold_without_excess )