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..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 @@ -13,3 +13,91 @@ 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 + +# 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 + is_SP_age: false + employment_income: 40_000 + output: + 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 + +- name: At state pension age, only the employer side is charged + period: 2026 + absolute_error_margin: 0.01 + input: + age: 66 + is_SP_age: true + employment_income: 40_000 + output: + ni_liable: false + ni_class_1_secondary_liable: 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..aa7dbfa708 --- /dev/null +++ b/policyengine_uk/tests/test_ni_class_1_age_properties.py @@ -0,0 +1,167 @@ +"""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 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 + 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, example, 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) + + +# 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()) +@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): + model = float(values["ni_class_1_employer"][i]) + earnings = float(values["ni_class_1_income"][i]) + 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"] + ) + assert abs(model - float(expected)) <= tolerance(expected, earnings), ( + policy, + age, + earnings, + model, + float(expected), + ) + + +@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) + 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)