diff --git a/changelog.d/2361.fixed.md b/changelog.d/2361.fixed.md new file mode 100644 index 0000000000..f32636621f --- /dev/null +++ b/changelog.d/2361.fixed.md @@ -0,0 +1 @@ +- The salary sacrifice National Insurance measures (`salary_sacrifice_pension_ni_employee` and `salary_sacrifice_pension_ni_employer`) now equal the change in the Class 1 liability from the NIC-only salary sacrifice pension excess: an amount crossing the upper earnings limit is split between the main and additional rates, and the thresholds and age conditions apply. No tax total changes. diff --git a/changelog.d/2362.fixed.md b/changelog.d/2362.fixed.md new file mode 100644 index 0000000000..af6bf90fa1 --- /dev/null +++ b/changelog.d/2362.fixed.md @@ -0,0 +1 @@ +- Corrected units: `shareholding` and `consumption_shareholding` are shares (`/1`, so a month's value is no longer a twelfth of the share), `free_tv_licence_value` is in pounds, and seven true/false variables (`sdlt_liable`, `lbtt_liable`, `benunit_is_rent_liable`, the three Pension Credit eligibility flags and `car_is_ved_exempt`) no longer declare a unit. No yearly value changes. diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml index 6c065272de..ecbbb7f721 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.yaml @@ -27,7 +27,7 @@ # £250 excess * 8% = £20 salary_sacrifice_pension_ni_employee: (2_250 - 2_000) * 0.08 -- name: Employee NI charge at 8% main rate for earner just below UEL +- name: Employee NI excess crosses UEL when pay is just below it period: 2025 absolute_error_margin: 0.01 input: @@ -37,8 +37,8 @@ gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - # £8,000 excess * 8% = £640 - salary_sacrifice_pension_ni_employee: (10_000 - 2_000) * 0.08 + # The excess spans both the main and additional rate bands. + salary_sacrifice_pension_ni_employee: (50_269.96 - 50_000) * 0.08 + (58_000 - 50_269.96) * 0.02 - name: Employee NI charge at 2% additional rate for high earner above UEL period: 2025 @@ -107,6 +107,124 @@ salary_sacrifice_pension_ni_excess: 1_000 salary_sacrifice_returned_to_income: 0 +# The cases below set only the pay (employment_income_before_lsr), so +# ni_class_1_income is pay plus NIC-only excess. The charge is the +# employee Class 1 liability on those earnings less the liability without the +# NIC-only excess. 2025 thresholds: primary threshold 241.73 x 52 = 12,569.96, +# upper earnings limit 966.73 x 52 = 50,269.96. +- name: Amount returned straddles the upper earnings limit + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_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: + salary_sacrifice_pension_ni_excess: 4_000 + employment_income: 48_000 + # 8% from 48,000 to the limit, 2% from the limit to 52,000 = 216.20. + # (A single rate on the whole amount would give 4,000 x 2% = 80.) + salary_sacrifice_pension_ni_employee: (50_269.96 - 48_000) * 0.08 + (52_000 - 50_269.96) * 0.02 + +- name: Amount returned stays below the primary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 6_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 6_000 + # Earnings of 9,000 are below the threshold, so no contributions are due. + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: Amount returned straddles the primary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 11_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 11_000 + # Only the part above the threshold is charged: 1,430.04 x 8% = 114.40. + ni_class_1_employee: (14_000 - 12_569.96) * 0.08 + salary_sacrifice_pension_ni_employee: (14_000 - 12_569.96) * 0.08 + +- name: No employee charge over State Pension age + period: 2025 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_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 + # Primary contributions end at pensionable age (SSCBA 1992 s.6(3)). + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: No employee charge under 16 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income_before_lsr: 20_000 + pension_contributions_via_salary_sacrifice: 5_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 + ni_class_1_employee: 0 + salary_sacrifice_pension_ni_employee: 0 + +- name: Statutory pay counts towards the upper earnings limit + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 46_000 + statutory_sick_pay: 3_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 46_000 + ni_class_1_income: 52_000 + # Class 1 earnings are 52,000 with the 3,000 excess and 49,000 without. + salary_sacrifice_pension_ni_employee: (50_269.96 - 49_000) * 0.08 + (52_000 - 50_269.96) * 0.02 + +- name: Worked example in the salary sacrifice cap research post + period: 2025 + absolute_error_margin: 0.01 + input: + # A 50,000 salary with 5,000 sacrificed leaves pay of 45,000. + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 45_000 + # Earnings stay below the upper earnings limit: 3,000 x 8% = 240. + salary_sacrifice_pension_ni_employee: 240 + +- name: Current law in 2029, with the cap and the broad-base haircut + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_000 + pension_contributions_via_salary_sacrifice: 6_000 + output: + salary_sacrifice_pension_ni_excess: 4_000 + # Cash earnings exclude the £4,000 NIC-only excess and lose the £76.80 haircut. + employment_income: 47_923.20 + salary_sacrifice_pension_ni_employee: (50_269.96 - 47_923.20) * 0.08 + (51_923.20 - 50_269.96) * 0.02 + - name: Broad-base haircut reduces all workers employment income period: 2025 absolute_error_margin: 1 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 669a26ce24..d474ca4738 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 @@ -103,6 +103,99 @@ output: # £1,000 excess * 15% = £150 salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 - # Full excess enters NIC earnings (no targeted haircut) + # Full excess enters NIC-only earnings (no targeted haircut) salary_sacrifice_pension_ni_excess: 1_000 salary_sacrifice_returned_to_income: 0 + +# The cases below set only the pay (employment_income_before_lsr), so +# ni_class_1_income is pay plus NIC-only excess. The charge is the +# employer Class 1 liability on those earnings less the liability without the +# NIC-only excess. 2025 secondary threshold: 96.153846 x 52 = 5,000. +- name: Amount returned stays below the secondary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 1_500 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 1_500 + ni_class_1_income: 4_500 + # Earnings of 4,500 are below the threshold, so no contributions are due. + # (The rate on the whole amount would give 3,000 x 15% = 450.) + ni_class_1_employer: 0 + salary_sacrifice_pension_ni_employer: 0 + +- name: Amount returned straddles the secondary threshold + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 3_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + employment_income: 3_000 + ni_class_1_income: 6_000 + # Only the part above the threshold is charged: 1,000 x 15% = 150. + ni_class_1_employer: (6_000 - 5_000) * 0.15 + salary_sacrifice_pension_ni_employer: (6_000 - 5_000) * 0.15 + +- name: Employer charge continues over State Pension age + period: 2025 + absolute_error_margin: 0.01 + input: + age: 70 + employment_income_before_lsr: 45_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # Secondary contributions have no upper age limit (SSCBA 1992 s.6(3)). + salary_sacrifice_pension_ni_employer: 3_000 * 0.15 + +- name: No employer charge under 16 + period: 2025 + absolute_error_margin: 0.01 + input: + age: 15 + employment_income_before_lsr: 20_000 + pension_contributions_via_salary_sacrifice: 5_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 + ni_class_1_employer: 0 + salary_sacrifice_pension_ni_employer: 0 + +- name: Employer pension contributions count when they are not exempt + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 1_500 + employer_pension_contributions: 2_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + gov.contrib.policyengine.employer_ni.exempt_employer_pension_contributions: false + output: + employment_income: 1_500 + ni_class_1_income: 4_500 + # Charged earnings are 4,500 + 2,000 = 6,500 with the 3,000 excess and + # 3,500 without: (6,500 - 5,000) x 15% = 225. + ni_class_1_employer: (6_500 - 5_000) * 0.15 + salary_sacrifice_pension_ni_employer: (6_500 - 5_000) * 0.15 + +- name: Current law in 2029, with the cap and the broad-base haircut + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 48_000 + pension_contributions_via_salary_sacrifice: 6_000 + output: + salary_sacrifice_pension_ni_excess: 4_000 + employment_income: 47_923.20 + 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 diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py new file mode 100644 index 0000000000..d2fb437de1 --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -0,0 +1,370 @@ +"""Property-based tests: the salary sacrifice NI measures equal the change in +the complete Class 1 liability. + +From 6 April 2029 salary sacrifice above the £2,000 cap enters only NIC +earnings (salary_sacrifice_pension_ni_excess), where Class 1 National +Insurance charges it. salary_sacrifice_pension_ni_employee and +salary_sacrifice_pension_ni_employer report how much of the employee and +employer liability is due to that excess. + +Every test builds the same people twice: as they are, and with the NIC-only +excess removed (salary_sacrifice_pension_ni_excess set to zero, all else equal). +The second simulation gives the liability without the excess from +the model's own liability variables, so it does not depend on the formulas +under test. + +Invariants, for any pay, salary sacrifice, age, statutory sick pay and +employer pension contribution: + +1. Difference of complete liabilities: the employee measure equals + ni_class_1_employee with the excess less ni_class_1_employee + without it, and the employer measure equals the same difference in + ni_class_1_employer. +2. Bounds: each measure lies between zero and the excess times the + highest rate that can apply to it. +3. Exact zeros: a measure is exactly zero when there is no excess (so under + current law before the cap), for a person not liable to primary + contributions (employee) and for an earner on whom no secondary + contributions are due (employer). +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. +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. + +Comparisons allow float32 rounding: the model stores values as float32. +""" + +from collections import namedtuple + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation +from policyengine_uk.model_api import MONTHS_IN_YEAR, WEEKS_IN_YEAR + +CLASS_1 = "gov.hmrc.national_insurance.class_1" +# name: (parameter changes, years) +WORLDS = { + "current law": (None, [2028, 2029, 2030]), + "rates change within the year": ( + { + f"{CLASS_1}.employee_scale[1].rate": {"2041-07-01.2041-12-31": 0.12}, + f"{CLASS_1}.employee_scale[2].rate": {"2041-10-01.2041-12-31": 0.05}, + 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, + }, + [2030, 2041], + ), + "full-year Category A directors": ( + {"gov.hmrc.national_insurance.salary_sacrifice_pension_cap": {"2022": 2_000}}, + [2022], + ), +} +PROPERTY_SETTINGS = settings( + max_examples=6, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Pounds. Liabilities are float32 sums of up to about £50,000. +TOLERANCE = 0.03 + +Row = namedtuple( + "Row", + "pay sacrifice more_sacrifice age sick_pay employer_pension director", + defaults=[False], +) + +# People the properties must always cover, whatever is drawn. With the £2,000 +# cap: an excess that crosses the upper earnings limit, one that +# 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. +KNOWN_CASES = [ + Row(48_000, 6_000, 10_000, 40, 0, 0), + Row(11_000, 5_000, 3_000, 40, 0, 0), + Row(3_000, 5_000, 4_000, 40, 0, 2_000), + Row(1_000, 3_000, 500, 40, 0, 0), + Row(45_000, 5_000, 20_000, 70, 0, 0), + Row(20_000, 5_000, 5_000, 15, 0, 0), + 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), +] + + +def _amount(maximum): + return st.floats(0, maximum, allow_nan=False, allow_infinity=False) + + +# Annual thresholds in the years tested lie near these amounts (secondary +# threshold, primary threshold, upper earnings limit), so pay near them makes +# the excess cross a threshold often. +near_a_threshold = st.builds( + lambda threshold, offset: max(threshold + offset, 0.0), + st.sampled_from([5_000.0, 6_000.0, 12_570.0, 15_100.0, 50_270.0, 60_600.0]), + st.floats(-12_000, 6_000, allow_nan=False, allow_infinity=False), +) +rows = st.lists( + st.builds( + Row, + pay=st.one_of(st.just(0.0), _amount(200_000), near_a_threshold), + sacrifice=st.one_of(st.just(0.0), _amount(60_000), _amount(6_000)), + more_sacrifice=_amount(30_000), + age=st.one_of( + st.sampled_from([15, 16, 17, 64, 65, 66, 67, 68, 69, 70]), + st.integers(10, 95), + ), + 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(), + ), + min_size=1, + max_size=40, +) + + +def _simulate(people, world, no_excess=False): + """One single-person household for each of the people.""" + changes, years = WORLDS[world] + names = [f"person_{i}" for i in range(len(people))] + + def each_year(value): + return {str(year): value for year in years} + + situation = { + "people": { + name: { + "age": each_year(row.age), + "employment_income": each_year(row.pay), + "pension_contributions_via_salary_sacrifice": each_year(row.sacrifice), + "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), + } + for name, row in zip(names, people) + }, + "benunits": {f"benunit_{i}": {"members": [n]} for i, n in enumerate(names)}, + "households": {f"household_{i}": {"members": [n]} for i, n in enumerate(names)}, + } + simulation = Simulation(situation=situation, reform=changes) + if no_excess: + for year in years: + simulation.set_input( + "salary_sacrifice_pension_ni_excess", + year, + np.zeros(len(people), dtype=np.float32), + ) + return simulation + + +def _values(simulation, variable, year): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def _class_1(simulation, instant): + parameters = simulation.tax_benefit_system.parameters(instant) + return parameters.gov.hmrc.national_insurance.class_1 + + +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") + employee = class_1.employee_scale.components + 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 + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - excess + # A pound clear of each threshold, so float32 rounding cannot move a + # person across it. + margin = 1 + bands = [ + ("employee", "ni_liable", earnings < primary_threshold - margin, 0), + ( + "employee", + "ni_liable", + (without > primary_threshold + margin) + & (earnings < upper_earnings_limit - margin), + employee[1].rate, + ), + ( + "employee", + "ni_liable", + without > upper_earnings_limit + margin, + employee[2].rate, + ), + ( + "employer", + "ni_class_1_secondary_liable", + earnings < secondary_threshold - margin, + 0, + ), + ( + "employer", + "ni_class_1_secondary_liable", + without > secondary_threshold + margin, + employer[1].rate, + ), + ] + for side, liable, in_band, rate in bands: + applies = in_band & simulation.calculate(liable, year) + measure = _values(simulation, f"salary_sacrifice_pension_ni_{side}", year) + np.testing.assert_allclose( + measure[applies], rate * excess[applies], rtol=0, atol=TOLERANCE + ) + + +@pytest.mark.parametrize("world", list(WORLDS)) +@PROPERTY_SETTINGS +@example(KNOWN_CASES) +@given(rows) +def test_measures_equal_the_difference_of_complete_liabilities(world, drawn): + # The people as drawn, then the same people sacrificing more. + people = drawn + [ + row._replace(sacrifice=row.sacrifice + row.more_sacrifice) for row in drawn + ] + n = len(drawn) + with_excess = _simulate(people, world) + without_excess = _simulate(people, world, no_excess=True) + for year in WORLDS[world][1]: + excess = _values(with_excess, "salary_sacrifice_pension_ni_excess", year) + assert np.all( + _values(without_excess, "salary_sacrifice_pension_ni_excess", year) == 0 + ) + months = [ + _class_1(with_excess, f"{year}-{month:02d}-01") + for month in range(1, MONTHS_IN_YEAR + 1) + ] + employee_rates = [ + component.rate + for month in months + for component in month.employee_scale.components + ] + for month in months: + directors = month.directors.full_year_category_a_2022_23 + if directors.in_effect: + employee_rates.extend(directors.employee_scale.rates) + highest_rate = { + "employee": max(employee_rates), + # ni_class_1_employer is a yearly variable: it reads the rate at + # the start of the year. + "employer": months[0].employer_scale.components[1].rate, + } + for side, liable in ( + ("employee", "ni_liable"), + ("employer", "ni_class_1_secondary_liable"), + ): + measure = _values(with_excess, f"salary_sacrifice_pension_ni_{side}", year) + liability = f"ni_class_1_{side}" + # 1. Difference of complete liabilities. + np.testing.assert_allclose( + measure, + _values(with_excess, liability, year) + - _values(without_excess, liability, year), + rtol=0, + atol=TOLERANCE, + ) + # 2. Bounds. + assert np.all(measure >= 0) + assert np.all(measure <= highest_rate[side] * excess + TOLERANCE) + # 3. Exact zeros. + assert np.all(measure[excess == 0] == 0) + assert np.all(measure[~with_excess.calculate(liable, year)] == 0) + # 5. Monotonicity. + assert np.all(measure[n:] >= measure[:n] - TOLERANCE) + # 4. One band, one rate. + if world == "current law": + _check_one_band_one_rate(with_excess, year, excess) + + +def test_known_cases_cross_each_threshold_and_age_condition(): + """The pinned people are the cases their comment says they are.""" + simulation = _simulate(KNOWN_CASES, "current law") + year = 2029 + class_1 = _class_1(simulation, f"{year}-01-01") + employee = class_1.employee_scale.components + secondary_threshold = class_1.employer_scale.components[1].threshold + excess = _values(simulation, "salary_sacrifice_pension_ni_excess", year) + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - excess + + def crosses(threshold): + return (without < threshold * WEEKS_IN_YEAR) & ( + earnings > threshold * WEEKS_IN_YEAR + ) + + assert crosses(employee[2].threshold).tolist() == [ + True, + False, + False, + False, + False, + False, + True, + False, + True, + ] + assert crosses(employee[1].threshold)[1] + assert crosses(secondary_threshold)[2] + assert excess[3] > 0 + assert earnings[3] < secondary_threshold * WEEKS_IN_YEAR + liable = simulation.calculate("ni_liable", year) + secondary_liable = simulation.calculate("ni_class_1_secondary_liable", year) + assert not liable[4] and secondary_liable[4] + assert not liable[5] and not secondary_liable[5] + assert excess[7] == 0 + assert simulation.calculate("is_full_year_category_a_director", year)[8] + + +def test_rates_change_within_the_year_in_the_second_world(): + """The second world keeps the changes dated within 2041. + + If fiscal-year conversion came to cover 2041, the changes would be + replaced by one value for the year and the property above would no longer + test a liability whose rates differ between months. + """ + simulation = _simulate(KNOWN_CASES[:1], "rates change within the year") + january, december = ( + _class_1(simulation, f"2041-{month}-01") for month in ("01", "12") + ) + assert ( + january.employee_scale.components[1].rate + != december.employee_scale.components[1].rate + ) + assert ( + january.employee_scale.components[2].rate + != december.employee_scale.components[2].rate + ) + assert ( + january.employee_scale.components[2].threshold + != december.employee_scale.components[2].threshold + ) + + +def test_measures_are_deterministic(): + """Two builds of the same people give identical measures.""" + world = "current law" + first, second = _simulate(KNOWN_CASES, world), _simulate(KNOWN_CASES, world) + for year in WORLDS[world][1]: + for side in ("employee", "employer"): + variable = f"salary_sacrifice_pension_ni_{side}" + assert np.array_equal( + _values(first, variable, year), _values(second, variable, year) + ) diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py index b00849226e..91d597a862 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py @@ -128,7 +128,7 @@ def test_actual_class_1_curves_charge_the_excess_once(pair, year): np.testing.assert_allclose(values(unlimited, "ni_class_1_income", year), wages) p = capped.tax_benefit_system.parameters(year).gov.hmrc.national_insurance.class_1 - # Independent piecewise annual curves, not the approximate reporting helper. + # Independent piecewise annual curves, not the reporting measures. def employee(earnings): pt, uel = ( p.thresholds.primary_threshold * 52, @@ -213,21 +213,36 @@ def test_explicit_ordinary_return_input_remains_supported(): assert values(sim, "employee_pension_contributions", 2026)[0] == 1_500 -def test_ni_reporting_readers_retain_their_existing_approximation(pair): - capped, _ = pair +def test_ni_reporting_measures_equal_the_change_in_class_1_liability(pair): + capped, unlimited = pair for year in (2029, 2030): - # These reporting measures deliberately retain the known #2363 - # approximation, including no pension-age gate. Actual NICs above - # are independently checked through national_insurance instead. + # Each reporting measure is the part of a Class 1 liability that the + # NIC-only excess causes: the liability with the cap less the + # liability without it. It is part of that liability, not an addition. + for measure, liability in ( + ("salary_sacrifice_pension_ni_employee", "ni_class_1_employee"), + ("salary_sacrifice_pension_ni_employer", "ni_class_1_employer"), + ): + np.testing.assert_allclose( + values(capped, measure, year), + values(capped, liability, year) - values(unlimited, liability, year), + atol=0.03, + err_msg=measure, + ) + assert np.all(values(unlimited, measure, year) == 0), measure p = capped.tax_benefit_system.parameters( year ).gov.hmrc.national_insurance.class_1 - assert values(capped, "salary_sacrifice_pension_ni_employee", year)[ - 0 - ] == pytest.approx(28_000 * p.rates.employee.additional, abs=0.02) + # The 80-year-old with £160,000 cash wages and a £30,000 sacrifice has + # a £28,000 excess over the £2,000 cap. Primary contributions end at + # pensionable age (SSCBA 1992 s.6(3)), so the employee measure is £0. + # Secondary contributions continue, and all of the excess is above the + # secondary threshold: £28,000 at 15% is £4,200. + assert values(capped, "salary_sacrifice_pension_ni_employee", year)[0] == 0 assert values(capped, "salary_sacrifice_pension_ni_employer", year)[ 0 ] == pytest.approx(28_000 * p.rates.employer, abs=0.02) + assert 28_000 * p.rates.employer == pytest.approx(4_200) assert np.all(values(capped, "salary_sacrifice_returned_to_income", year) == 0) diff --git a/policyengine_uk/tests/test_variable_units.py b/policyengine_uk/tests/test_variable_units.py new file mode 100644 index 0000000000..44589b0750 --- /dev/null +++ b/policyengine_uk/tests/test_variable_units.py @@ -0,0 +1,73 @@ +"""Units of variables whose values are shares, pound amounts or flags. + +A variable's unit is served in the model metadata and decides how a value is +displayed: a "/1" variable as a percentage, a currency variable in pounds. In +policyengine-core it also sets the default quantity type. A float with the +unit "/1" is a stock and any other float is a flow, and a yearly flow read +for a month is divided by twelve, where a yearly stock is not. + +So a share must declare "/1" (a month's share of the national total is the +share, not a twelfth of it), a pound amount must declare pounds, and a +true/false variable declares no unit. +""" + +import pytest + +from policyengine_uk import CountryTaxBenefitSystem, Simulation +from policyengine_uk.model_api import FLOW, GBP, MONTHS_IN_YEAR, STOCK + +YEAR = 2025 +SHARES = ["shareholding", "consumption_shareholding"] + + +@pytest.fixture(scope="module") +def variables(): + return CountryTaxBenefitSystem().variables + + +def _household(variable, value): + """One household with the variable entered directly for the year.""" + return Simulation( + situation={ + "people": {"person": {"age": {YEAR: 40}}}, + "benunits": {"benunit": {"members": ["person"]}}, + "households": { + "household": {"members": ["person"], variable: {YEAR: value}} + }, + } + ) + + +@pytest.mark.parametrize("share", SHARES) +def test_a_share_declares_a_ratio_and_is_a_stock(variables, share): + assert variables[share].unit == "/1" + assert variables[share].quantity_type == STOCK + + +@pytest.mark.parametrize("share", SHARES) +def test_a_share_read_for_a_month_is_the_share(share): + simulation = _household(share, 0.25) + assert simulation.calculate(share, YEAR).tolist() == [0.25] + assert simulation.calculate(share, f"{YEAR}-01").tolist() == [0.25] + + +def test_free_tv_licence_value_is_in_pounds_and_is_a_flow(variables): + assert variables["free_tv_licence_value"].unit == GBP + assert variables["free_tv_licence_value"].quantity_type == FLOW + + +def test_free_tv_licence_value_read_for_a_month_is_a_twelfth(): + simulation = _household("free_tv_licence_value", 180) + assert simulation.calculate("free_tv_licence_value", YEAR).tolist() == [180] + assert simulation.calculate("free_tv_licence_value", f"{YEAR}-01").tolist() == [ + 180 / MONTHS_IN_YEAR + ] + + +def test_no_true_or_false_variable_declares_a_unit(variables): + with_a_unit = { + name: variable.unit + for name, variable in variables.items() + if variable.value_type is bool and variable.unit is not None + } + assert with_a_unit == {} diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py index 094f112c06..1c7d1aac45 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/guarantee_credit/is_guarantee_credit_eligible.py @@ -6,7 +6,6 @@ class is_guarantee_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/2", "https://www.legislation.gov.uk/nia/2002/14/section/2", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py index e89ca49424..01e186cebf 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/is_pension_credit_eligible.py @@ -6,7 +6,6 @@ class is_pension_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2002/16/section/1", "https://www.legislation.gov.uk/ukpga/2002/16/section/4", diff --git a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py index a7263e8b74..526f3fa347 100644 --- a/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py +++ b/policyengine_uk/variables/gov/dwp/pension_credit/savings_credit/is_savings_credit_eligible.py @@ -6,7 +6,6 @@ class is_savings_credit_eligible(Variable): entity = BenUnit definition_period = YEAR value_type = bool - unit = GBP reference = "https://www.legislation.gov.uk/ukpga/2002/16/section/3" def formula(benunit, period, parameters): diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py index 661c99bce2..dc17eb2f57 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_employee.py @@ -4,35 +4,54 @@ class salary_sacrifice_pension_ni_employee(Variable): label = "Employee NI on salary sacrifice pension contributions above cap" documentation = ( - "Additional employee National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the cap. " - "The excess remains an employer pension contribution and is included " - "only in NIC earnings. This reporting approximation is separate " - "from the actual Class 1 contribution curves and liability flags." + "Additional employee Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap enter NIC-only " + "earnings. It is the employee's complete Class 1 liability " + "(ni_class_1_employee) on their 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 main rate between the primary " + "threshold and the upper earnings limit, the additional rate above " + "the limit, and nothing below the threshold or for a person who is " + "not liable to primary contributions (ni_liable). " + "\n\n" + "This measures part of ni_class_1_employee, which already charges the " + "NIC-only excess. Do not add it to a National Insurance total." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + defined_for = "ni_liable" + reference = ( + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", + "https://www.legislation.gov.uk/ukpga/1992/4/section/8", + ) def formula(person, period, parameters): - # NIC-only earnings; this does not become an actual member payment. - excess = person("salary_sacrifice_pension_ni_excess", period) - - # Use existing NI Class 1 parameters - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employment_income = person("employment_income", period) - upper_earnings_limit = ( - ni_params.employee_scale.components[2].threshold * WEEKS_IN_YEAR - ) - - # Apply appropriate NI rate based on income level - # Main rate (8%) for income <= UEL, additional rate (2%) for income > UEL - ni_rate = where( - employment_income <= upper_earnings_limit, - ni_params.employee_scale.components[1].rate, - ni_params.employee_scale.components[2].rate, - ) - - return excess * ni_rate + # ni_class_1_employee is a monthly variable, so the difference is taken + # month by month, with each month's earnings and parameters. + charge = 0 + for month in period.get_subperiods(MONTH): + class_1 = parameters(month).gov.hmrc.national_insurance.class_1 + earnings = person("ni_class_1_income", month) + excess = person("salary_sacrifice_pension_ni_excess", month) + scale = class_1.employee_scale + factor = WEEKS_IN_YEAR / MONTHS_IN_YEAR + monthly_charge = scale.calc(earnings, factor=factor) - scale.calc( + earnings - excess, factor=factor + ) + directors = class_1.directors.full_year_category_a_2022_23 + if directors.in_effect: + annual_earnings = person("ni_class_1_income", period) + annual_excess = person("salary_sacrifice_pension_ni_excess", period) + annual_charge = directors.employee_scale.calc( + annual_earnings + ) - directors.employee_scale.calc(annual_earnings - annual_excess) + monthly_charge = where( + person("is_full_year_category_a_director", period), + annual_charge / MONTHS_IN_YEAR, + monthly_charge, + ) + charge += monthly_charge + return charge 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 43593e344e..059e6ae4b2 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 @@ -4,24 +4,39 @@ class salary_sacrifice_pension_ni_employer(Variable): label = "Employer NI on salary sacrifice pension contributions above cap" documentation = ( - "Additional employer National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the cap. " - "The excess remains an employer pension contribution and is included " - "only in NIC earnings. This reporting measure is separate from " - "the actual Class 1 contribution curve and liability flags." + "Additional employer Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap enter NIC-only " + "earnings. It is the employer's complete Class 1 liability " + "(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). " + "\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." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" + defined_for = "ni_class_1_secondary_liable" + reference = ( + "https://policyengine.org/uk/research/uk-salary-sacrifice-cap", + "https://www.legislation.gov.uk/ukpga/1992/4/section/9", + ) def formula(person, period, parameters): - # NIC-only earnings; this does not become an actual member payment. + gov = parameters(period).gov + class_1 = gov.hmrc.national_insurance.class_1 excess = person("salary_sacrifice_pension_ni_excess", period) - - # Use existing NI Class 1 employer rate parameter - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.employer_scale.components[1].rate - - return excess * employer_rate + # The earnings ni_class_1_employer charges. + 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 + ) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index 2e5e7146a4..e450df98a9 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -10,7 +10,6 @@ class sdlt_liable(Variable): entity = Household definition_period = YEAR value_type = bool - unit = GBP def formula(household, period, parameters): country = household("country", period) diff --git a/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py b/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py index a9c1487c6b..e4cc9daacc 100644 --- a/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py +++ b/policyengine_uk/variables/gov/revenue_scotland/lbtt_liable.py @@ -9,7 +9,6 @@ class lbtt_liable(Variable): entity = Household definition_period = YEAR value_type = bool - unit = GBP def formula(household, period): country = household("country", period) diff --git a/policyengine_uk/variables/household/consumption/consumption_share.py b/policyengine_uk/variables/household/consumption/consumption_share.py index b15f94e6c4..42d151dd2e 100644 --- a/policyengine_uk/variables/household/consumption/consumption_share.py +++ b/policyengine_uk/variables/household/consumption/consumption_share.py @@ -7,7 +7,7 @@ class consumption_shareholding(Variable): entity = Household definition_period = YEAR value_type = float - unit = GBP + unit = "/1" def formula(household, period, parameters): consumption = household("consumption", period) diff --git a/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py b/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py index 62dad6cbe1..93c636f573 100644 --- a/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py +++ b/policyengine_uk/variables/household/consumption/rent/benunit_pays_rent.py @@ -6,7 +6,6 @@ class benunit_is_rent_liable(Variable): entity = BenUnit label = "Whether the benefit unit is liable to pay rent" definition_period = YEAR - unit = GBP def formula(benunit, period, parameters): rent = benunit("benunit_rent", period) diff --git a/policyengine_uk/variables/household/wealth/shareholding.py b/policyengine_uk/variables/household/wealth/shareholding.py index ecbb56cb26..febd95d58d 100644 --- a/policyengine_uk/variables/household/wealth/shareholding.py +++ b/policyengine_uk/variables/household/wealth/shareholding.py @@ -7,7 +7,7 @@ class shareholding(Variable): entity = Household definition_period = YEAR value_type = float - unit = GBP + unit = "/1" def formula(household, period, parameters): wealth = household("corporate_sector_wealth", period) diff --git a/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py b/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py index 7599208f45..38293a8baa 100644 --- a/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py +++ b/policyengine_uk/variables/input/vehicle/car_is_ved_exempt.py @@ -6,4 +6,3 @@ class car_is_ved_exempt(Variable): entity = Person definition_period = YEAR label = "Car has a vehicle excise duty exemption" - unit = "/1"