Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/1894.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion docs/book/programs/gov/hmrc/national-insurance.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
167 changes: 167 additions & 0 deletions policyengine_uk/tests/test_ni_class_1_age_properties.py
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Loading