From c30fc136d77407bb7cf87d9185572dfccc6dc1f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 28 Sep 2026 15:01:33 -0400 Subject: [PATCH 1/4] Charge employer NI on employees over state pension age ni_class_1_employer was gated on ni_liable (over 16 and under state pension age), so secondary Class 1 was zero for anyone at or above state pension age. SSCBA 1992 s.6(3) ends only primary contributions at pensionable age, "without prejudice to any liability to pay secondary Class 1 contributions". Secondary contributions are due on earners over 16 with no upper age (s.6(1)(b); HMRC NIM36001). Add ni_class_1_secondary_liable (over 16) and gate ni_class_1_employer on it. ni_liable keeps gating primary Class 1, Class 2 and Class 4, and its documentation now cites the provision behind each upper age. Co-Authored-By: Claude Opus 5.5 --- changelog.d/1894.fixed.md | 1 + .../gov/hmrc/national-insurance.ipynb | 2 +- .../class_1/ni_class_1_employer.yaml | 83 ++++++++++ .../class_1/ni_class_1_secondary_liable.yaml | 32 ++++ .../national_insurance/class_1/ni_liable.yaml | 2 +- .../tests/test_ni_class_1_age_properties.py | 149 ++++++++++++++++++ .../class_1/ni_class_1_employer.py | 2 +- .../class_1/ni_class_1_secondary_liable.py | 18 +++ .../national_insurance/class_1/ni_liable.py | 18 ++- 9 files changed, 302 insertions(+), 5 deletions(-) create mode 100644 changelog.d/1894.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.yaml create mode 100644 policyengine_uk/tests/test_ni_class_1_age_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.py diff --git a/changelog.d/1894.fixed.md b/changelog.d/1894.fixed.md new file mode 100644 index 0000000000..22d377fe3b --- /dev/null +++ b/changelog.d/1894.fixed.md @@ -0,0 +1 @@ +Charge secondary (employer) Class 1 National Insurance on employees over state pension age. SSCBA 1992 s.6(3) ends only primary (employee) contributions at pensionable age. diff --git a/docs/book/programs/gov/hmrc/national-insurance.ipynb b/docs/book/programs/gov/hmrc/national-insurance.ipynb index 8c475d72b8..8a3e68d368 100644 --- a/docs/book/programs/gov/hmrc/national-insurance.ipynb +++ b/docs/book/programs/gov/hmrc/national-insurance.ipynb @@ -171,7 +171,7 @@ { "cell_type": "markdown", "metadata": {}, - "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL).\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable.\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." + "source": "## How PolicyEngine computes National Insurance liability\n\nFor each person, PolicyEngine computes NI contributions class-by-class:\n\n- **Class 1 employee** (`ni_class_1_employee`): the sum of `ni_class_1_employee_primary` (main rate on earnings between the Primary Threshold and the Upper Earnings Limit) and `ni_class_1_employee_additional` (additional rate on earnings above the UEL). These primary contributions are due from age 16 until state pension age (`ni_liable`): SSCBA 1992 s.6(3) ends them at pensionable age.\n- **Class 1 employer** (`ni_class_1_employer`): paid on earnings above the Secondary Threshold; surfaced through the `ni_employer` variable. These secondary contributions are due on earners over 16 with no upper age limit (`ni_class_1_secondary_liable`), so employers keep paying them for employees over state pension age (SSCBA 1992 s.6(1)(b) and s.6(3)).\n- **Class 2** (`ni_class_2`): a flat weekly rate paid by the self-employed with profits above the Small Profits Threshold.\n- **Class 3** (`ni_class_3`): a voluntary flat weekly rate; the variable is exposed but defaults to zero unless explicitly set.\n- **Class 4** (`ni_class_4`): main rate between the Lower and Upper Profits Limits plus additional rate above the UPL. Where the person also pays primary Class 1 contributions (or, before 6 April 2024, Class 2 contributions), liability is capped at the annual maximum in regulation 100 of the Social Security (Contributions) Regulations 2001 (`ni_class_4_maximum`).\n\nThe individual total `national_insurance` sums the four employee and self-employed components directly — `ni_class_1_employee + ni_class_2 + ni_class_3 + ni_class_4` — so voluntary Class 3 contributions are included in the household-side liability. The convenience aggregates `ni_employee` (Class 1 employee only) and `ni_self_employed` (Class 2 + Class 4) are also exposed for reporting. The Class 1 *employer* charge (`ni_class_1_employer`, surfaced as `ni_employer`) is an employer-side cost rather than part of the individual's `national_insurance`, so it is tracked separately and feeds the government revenue aggregates on its own.\n\nParameters live in `policyengine_uk/parameters/gov/hmrc/national_insurance/` and the per-class formulas in `policyengine_uk/variables/gov/hmrc/national_insurance/`." }, { "cell_type": "markdown", 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 7b73d917ec..a4e70118bc 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 @@ -13,3 +13,86 @@ ni_class_1_income: 30_000 # Annual income above secondary threshold but below upper limit output: ni_class_1_employer: (30_000 - 175 * 52) * 0.138 # Expected employer contributions + +# SSCBA 1992 s.6(3) ends primary Class 1 at pensionable age "without prejudice +# to any liability to pay secondary Class 1 contributions". Secondary +# contributions are due on earners over 16 (s.6(1)(b)) with no upper age. +# The model annualises the weekly secondary threshold as 52 weeks: 2026-27 is +# £96 x 52 = £4,992 at 15%; 2024-25 is £175 x 52 = £9,100 at 13.8%. + +- name: Employer NI is charged on an employee aged 70 (2026) + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income: 40_000 + output: + ni_class_1_employee: 0 + ni_class_1_employer: 5_251.20 # 0.15 x (40,000 - 4,992) + ni_employer: 5_251.20 + national_insurance: 0 + total_national_insurance: 5_251.20 + employer_cost: 45_251.20 + +- name: Employer NI is charged on an employee aged 70 (2024) + period: 2024 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income: 40_000 + output: + ni_class_1_employee: 0 + ni_class_1_employer: 4_264.20 # 0.138 x (40,000 - 9,100) + +- name: Employer NI has no upper earnings limit over state pension age + period: 2026 + absolute_error_margin: 0.01 + input: + age: 75 + employment_income: 60_000 + output: + ni_class_1_employee: 0 + ni_class_1_employer: 8_251.20 # 0.15 x (60,000 - 4,992) + +- name: Earnings below the secondary threshold over state pension age + period: 2026 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income: 4_000 + output: + ni_class_1_employer: 0 + +- name: One year below state pension age, both sides are charged + period: 2026 + absolute_error_margin: 0.01 + input: + age: 66 + state_pension_age: 67 + employment_income: 40_000 + output: + is_SP_age: false + ni_class_1_employee: 2_194.40 # 0.08 x (40,000 - 12,570) + ni_class_1_employer: 5_251.20 + +- name: At state pension age, only the employer side is charged + period: 2026 + absolute_error_margin: 0.01 + input: + age: 67 + state_pension_age: 67 + employment_income: 40_000 + output: + is_SP_age: true + ni_class_1_employee: 0 + ni_class_1_employer: 5_251.20 + +- name: No employer NI on an earner under 16 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income: 40_000 + output: + ni_class_1_employee: 0 + ni_class_1_employer: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.yaml new file mode 100644 index 0000000000..968cf14ee7 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.yaml @@ -0,0 +1,32 @@ +# SSCBA 1992 s.6(1)(b) and s.6(3): secondary Class 1 contributions are due on +# earners over 16 and continue after pensionable age. + +- name: Under 16 is not liable for secondary Class 1 + period: 2026 + input: + age: 15 + output: + ni_class_1_secondary_liable: false + +- name: Aged 16 is liable for secondary Class 1 + period: 2026 + input: + age: 16 + output: + ni_class_1_secondary_liable: true + +- name: Working-age adult is liable for secondary Class 1 + period: 2026 + input: + age: 35 + output: + ni_class_1_secondary_liable: true + +- name: Adult over state pension age remains liable for secondary Class 1 + period: 2026 + input: + age: 70 + output: + is_SP_age: true + ni_liable: false + ni_class_1_secondary_liable: true diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_liable.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_liable.yaml index a0834d81c3..78bf866e78 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_liable.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/class_1/ni_liable.yaml @@ -12,7 +12,7 @@ output: ni_liable: true -- name: Retired adult isn't liable for NI. +- name: Adult over state pension age isn't liable for primary NI. period: 2023 input: age: 70 diff --git a/policyengine_uk/tests/test_ni_class_1_age_properties.py b/policyengine_uk/tests/test_ni_class_1_age_properties.py new file mode 100644 index 0000000000..903b0c34a4 --- /dev/null +++ b/policyengine_uk/tests/test_ni_class_1_age_properties.py @@ -0,0 +1,149 @@ +"""Property-based tests for the age conditions on Class 1 National Insurance. + +SSCBA 1992 s.6(1) makes primary and secondary Class 1 contributions payable on +earnings of an earner over 16. s.6(3) ends primary contributions at +pensionable age "without prejudice to any liability to pay secondary Class 1 +contributions". So, for any secondary threshold ST >= 0, employer rate r >= 0, +earnings e >= 0 and age a: + +1. Secondary Class 1 ignores age above 16: for a >= 16, ni_class_1_employer + equals r * max(e - 52 * ST, 0), computed exactly. That holds above state + pension age too, so it equals the amount for a working-age earner with the + same earnings. +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, + ni_class_1_employee is zero. + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +from fractions import Fraction + +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 = list(range(2016, 2031)) +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): + policy = dict( + year=draw(st.sampled_from(YEARS)), + # None keeps the baseline threshold and rate for that year. + secondary_threshold=draw( + st.one_of(st.none(), st.floats(0, 1_000, allow_nan=False)) + ), + employer_rate=draw(st.one_of(st.none(), st.floats(0, 0.3, allow_nan=False))), + ) + people = draw( + st.lists( + st.tuples( + st.integers(0, 100), + st.one_of( + st.just(0.0), + st.floats(0, 500_000, allow_nan=False, allow_infinity=False), + st.integers(0, 500_000).map(float), + ), + ), + min_size=1, + max_size=12, + ) + ) + return policy, people + + +def simulate(policy, people): + year = policy["year"] + situation = { + "people": { + f"p{i}": { + "age": {year: age}, + "employment_income": {year: earnings}, + } + for i, (age, earnings) in enumerate(people) + }, + "benunits": {f"b{i}": {"members": [f"p{i}"]} for i in range(len(people))}, + "households": {f"h{i}": {"members": [f"p{i}"]} for i in range(len(people))}, + } + # A bare reform value only applies from 2023, so give an explicit range. + all_years = "2000-01-01.2100-12-31" + reform = {} + if policy["secondary_threshold"] is not None: + reform[f"{CLASS_1}.thresholds.secondary_threshold"] = { + all_years: policy["secondary_threshold"] + } + if policy["employer_rate"] is not None: + reform[f"{CLASS_1}.rates.employer"] = {all_years: policy["employer_rate"]} + sim = Simulation(situation=situation, reform=reform or None) + values = { + variable: sim.calculate(variable, year) + for variable in [ + "age", + "is_SP_age", + "ni_class_1_income", + "ni_class_1_employee", + "ni_class_1_employer", + ] + } + class_1 = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.hmrc.national_insurance.class_1 + values["secondary_threshold"] = class_1.thresholds.secondary_threshold + values["employer_rate"] = class_1.rates.employer + return values + + +def statutory_secondary(earnings, weekly_threshold, rate): + """Secondary Class 1 on annual earnings, in exact arithmetic. + + The model annualises the weekly secondary threshold as 52 weeks. + """ + threshold = 52 * Fraction(float(weekly_threshold)) + return Fraction(float(rate)) * max(Fraction(float(earnings)) - threshold, 0) + + +@PROPERTY_SETTINGS +@given(populations()) +def test_secondary_class_1_ignores_age_above_16(population): + policy, people = population + values = simulate(policy, people) + for i, (age, _) in enumerate(people): + model = float(values["ni_class_1_employer"][i]) + earnings = float(values["ni_class_1_income"][i]) + if age < 16: + assert model == 0 + continue + expected = statutory_secondary( + earnings, values["secondary_threshold"], values["employer_rate"] + ) + assert abs(model - float(expected)) <= tolerance(expected, earnings), ( + policy, + age, + earnings, + model, + float(expected), + ) + + +@PROPERTY_SETTINGS +@given(populations()) +def test_primary_class_1_stops_at_state_pension_age(population): + policy, people = population + values = simulate(policy, people) + for i, (age, _) in enumerate(people): + if age < 16 or values["is_SP_age"][i]: + assert float(values["ni_class_1_employee"][i]) == 0, (policy, age) 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 d4f11fc59a..a179befa83 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 @@ -7,7 +7,7 @@ class ni_class_1_employer(Variable): label = "NI Class 1 employer-side contributions" definition_period = YEAR unit = GBP - defined_for = "ni_liable" + defined_for = "ni_class_1_secondary_liable" reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/9" def formula(person, period, parameters): diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.py new file mode 100644 index 0000000000..9d02a085cc --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_secondary_liable.py @@ -0,0 +1,18 @@ +from policyengine_uk.model_api import * + + +class ni_class_1_secondary_liable(Variable): + label = "age-liable for secondary Class 1 NI" + documentation = ( + "Whether this person's age allows secondary (employer) Class 1 " + "contributions on their earnings. Secondary contributions are due on " + "earners over 16 (SSCBA 1992 s.6(1)(b)). Unlike primary contributions, " + "they continue after the earner reaches pensionable age (s.6(3))." + ) + entity = Person + definition_period = YEAR + value_type = bool + reference = "https://www.legislation.gov.uk/ukpga/1992/4/section/6" + + def formula(person, period, parameters): + return person("over_16", period) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_liable.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_liable.py index cdea63ac92..f1aba43503 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_liable.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_liable.py @@ -2,11 +2,25 @@ class ni_liable(Variable): - label = "NI liable" - documentation = "Whether this person is liable for NI contributions by their age." + label = "age-liable for primary Class 1, Class 2 and Class 4 NI" + documentation = ( + "Whether this person's age allows primary (employee) Class 1, Class 2 " + "and Class 4 contributions: 16 or over and under state pension age. " + "Primary Class 1 ends at pensionable age (SSCBA 1992 s.6(3)), Class 2 " + "after the week the earner reaches it (s.11(7)(b)) and Class 4 for " + "anyone over it at the start of the tax year (SI 2001/1004 reg 91(a)). " + "The model applies all three with the annual is_SP_age flag. Secondary " + "(employer) Class 1 has no upper age limit; see " + "ni_class_1_secondary_liable." + ) entity = Person definition_period = YEAR value_type = bool + reference = [ + "https://www.legislation.gov.uk/ukpga/1992/4/section/6", + "https://www.legislation.gov.uk/ukpga/1992/4/section/11", + "https://www.legislation.gov.uk/uksi/2001/1004/regulation/91", + ] def formula(person, period, parameters): return person("over_16", period) & ~person("is_SP_age", period) From 5dd457d46dba6032178d8e1211c2620b8d9bc67c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:30:26 -0400 Subject: [PATCH 2/4] Limit the statutory employer-NI property to ages 21 and over MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSCBA 1992 s.9A sets a 0% secondary rate for earners under 21 up to the upper secondary threshold, and the model does not implement it, so the property no longer presents the model's rate for ages 16 to 20 as the law. Pin the #1894 case (aged 70 on £40,000 in 2026) as an explicit example so every run exercises it. Addresses the independent review. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_ni_class_1_age_properties.py | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) 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 903b0c34a4..c729c368e6 100644 --- a/policyengine_uk/tests/test_ni_class_1_age_properties.py +++ b/policyengine_uk/tests/test_ni_class_1_age_properties.py @@ -6,10 +6,14 @@ contributions". So, for any secondary threshold ST >= 0, employer rate r >= 0, earnings e >= 0 and age a: -1. Secondary Class 1 ignores age above 16: for a >= 16, ni_class_1_employer - equals r * max(e - 52 * ST, 0), computed exactly. That holds above state - pension age too, so it equals the amount for a working-age earner with the - same earnings. +1. Secondary Class 1 continues past state pension age: for a >= 21, + ni_class_1_employer equals r * max(e - 52 * ST, 0), computed exactly, at + every age, including at and over state pension age. That is the s.9 charge + for an earner who is not an apprentice (the test people are not), before + the elective veterans and freeport reliefs and the employer-level + Employment Allowance. 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. 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, @@ -20,7 +24,7 @@ from fractions import Fraction -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_uk import Simulation @@ -116,9 +120,18 @@ def statutory_secondary(earnings, weekly_threshold, rate): return Fraction(float(rate)) * max(Fraction(float(earnings)) - threshold, 0) +# The #1894 regression: an employee aged 70 on £40,000 in 2026, next to a +# 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)], +) + + @PROPERTY_SETTINGS @given(populations()) -def test_secondary_class_1_ignores_age_above_16(population): +@example(REGRESSION_EXAMPLE) +def test_secondary_class_1_continues_past_state_pension_age(population): policy, people = population values = simulate(policy, people) for i, (age, _) in enumerate(people): @@ -127,6 +140,9 @@ def test_secondary_class_1_ignores_age_above_16(population): if age < 16: assert model == 0 continue + 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"] ) @@ -141,6 +157,7 @@ def test_secondary_class_1_ignores_age_above_16(population): @PROPERTY_SETTINGS @given(populations()) +@example(REGRESSION_EXAMPLE) def test_primary_class_1_stops_at_state_pension_age(population): policy, people = population values = simulate(policy, people) From a5255a94ceef68cd62214dbd5564030bf1b0957b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:33:45 -0400 Subject: [PATCH 3/4] Set is_SP_age directly in the Class 1 age-boundary tests The two boundary cases test how primary and secondary Class 1 split at state pension age, not how that age is derived. Setting is_SP_age as an input keeps them valid under #1899, which derives it from date of birth (a 66-year-old in 2026-27 is then usually over it). Co-Authored-By: Claude Opus 5.5 --- .../class_1/ni_class_1_employer.yaml | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 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 a4e70118bc..bbf695a5b9 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 @@ -63,15 +63,19 @@ output: ni_class_1_employer: 0 -- name: One year below state pension age, both sides are charged +# The next two cases set is_SP_age directly, so they test the split between +# the two sides rather than how state pension age is derived. + +- name: Below state pension age, both sides are charged period: 2026 absolute_error_margin: 0.01 input: age: 66 - state_pension_age: 67 + is_SP_age: false employment_income: 40_000 output: - is_SP_age: false + ni_liable: true + ni_class_1_secondary_liable: true ni_class_1_employee: 2_194.40 # 0.08 x (40,000 - 12,570) ni_class_1_employer: 5_251.20 @@ -79,11 +83,12 @@ period: 2026 absolute_error_margin: 0.01 input: - age: 67 - state_pension_age: 67 + age: 66 + is_SP_age: true employment_income: 40_000 output: - is_SP_age: true + ni_liable: false + ni_class_1_secondary_liable: true ni_class_1_employee: 0 ni_class_1_employer: 5_251.20 From 2022615e8bac2d3e971a7dcb046cd0762f804f75 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 30 Sep 2026 08:37:27 -0400 Subject: [PATCH 4/4] State the pension-contribution premise in the employer-NI property The exact s.9 comparison uses Class 1 earnings alone, which is right only because the test people have no employer pension contributions. Also say "at every age from 21" rather than "at every age". Co-Authored-By: Claude Opus 5.5 --- .../tests/test_ni_class_1_age_properties.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) 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 c729c368e6..aa7dbfa708 100644 --- a/policyengine_uk/tests/test_ni_class_1_age_properties.py +++ b/policyengine_uk/tests/test_ni_class_1_age_properties.py @@ -6,12 +6,13 @@ contributions". So, for any secondary threshold ST >= 0, employer rate r >= 0, earnings e >= 0 and age a: -1. Secondary Class 1 continues past state pension age: for a >= 21, - ni_class_1_employer equals r * max(e - 52 * ST, 0), computed exactly, at - every age, including at and over state pension age. That is the s.9 charge - for an earner who is not an apprentice (the test people are not), before - the elective veterans and freeport reliefs and the employer-level - Employment Allowance. Ages 16 to 20 are not checked against it: s.9A sets +1. Secondary Class 1 continues past state pension age: at every age from 21, + including at and over state pension age, ni_class_1_employer equals + r * max(e - 52 * ST, 0), computed exactly. That is the s.9 charge for an + 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. 2. Nothing under 16: for a < 16, both ni_class_1_employer and