diff --git a/changelog.d/2361.fixed.md b/changelog.d/2361.fixed.md new file mode 100644 index 0000000000..713eb286d9 --- /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 amount returned to income: 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 5b36e53d92..6a21aeb98d 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 @@ -106,6 +106,124 @@ # Full excess is redirected (no targeted haircut) salary_sacrifice_returned_to_income: 1_000 +# The cases below set only the pay (employment_income_before_lsr), so +# employment_income is the pay plus the amount returned. The charge is the +# employee Class 1 liability on those earnings less the liability without the +# amount returned. 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_returned_to_income: 4_000 + employment_income: 52_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: 9_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: 14_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_returned_to_income: 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_returned_to_income: 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: 49_000 + ni_class_1_income: 52_000 + # Class 1 earnings are 52,000 with the 3,000 returned 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: 48_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_returned_to_income: 4_000 + # 48,000 + 4,000 returned - 76.80 haircut (0.16% of 48,000). + employment_income: 51_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 4eb0fdf97c..c2aa4cff99 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 @@ -105,3 +105,92 @@ salary_sacrifice_pension_ni_employer: (3_000 - 2_000) * 0.15 # Full excess is redirected (no targeted haircut) salary_sacrifice_returned_to_income: 1_000 + +# The cases below set only the pay (employment_income_before_lsr), so +# employment_income is the pay plus the amount returned. The charge is the +# employer Class 1 liability on those earnings less the liability without the +# amount returned. 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: 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: 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_returned_to_income: 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: 4_500 + # Charged earnings are 4,500 + 2,000 = 6,500 with the 3,000 returned 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_returned_to_income: 4_000 + employment_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..d5b032eb36 --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_measure_properties.py @@ -0,0 +1,344 @@ +"""Property-based tests: the salary sacrifice NI measures equal the change in +the complete Class 1 liability. + +From 6 April 2029 the salary sacrifice above the £2,000 cap returns to +employment income (salary_sacrifice_returned_to_income), 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 the amount returned. + +Every test builds the same people twice: as they are, and with nothing +returned (salary_sacrifice_returned_to_income set to zero, all else equal). +The second simulation gives the liability without the amount returned 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 amount returned 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 amount returned times the + highest rate that can apply to it. +3. Exact zeros: a measure is exactly zero when nothing is returned (so in + every year 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 amount + returned lie in the same band, the measure is that band's rate times the + amount returned. 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) and in a second 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 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}.rates.employee.main": {"2041-07-01.2041-12-31": 0.12}, + f"{CLASS_1}.rates.employee.additional": {"2041-10-01.2041-12-31": 0.05}, + f"{CLASS_1}.thresholds.upper_earnings_limit": { + "2041-04-01.2041-12-31": 1_300 + }, + "gov.contrib.policyengine.employer_ni." + "exempt_employer_pension_contributions": False, + }, + [2030, 2041], + ), +} +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") + +# People the properties must always cover, whatever is drawn. With the £2,000 +# cap: an amount returned 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, and +# one with nothing returned. +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), +] + + +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 amount returned 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)), + ), + min_size=1, + max_size=40, +) + + +def _simulate(people, world, nothing_returned=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), + } + 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 nothing_returned: + for year in years: + simulation.set_input( + "salary_sacrifice_returned_to_income", + 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, returned): + """Invariant 4, for a year whose rates and thresholds do not change.""" + class_1 = _class_1(simulation, f"{year}-01-01") + thresholds = class_1.thresholds + primary_threshold = thresholds.primary_threshold * WEEKS_IN_YEAR + upper_earnings_limit = thresholds.upper_earnings_limit * WEEKS_IN_YEAR + secondary_threshold = thresholds.secondary_threshold * WEEKS_IN_YEAR + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - returned + # 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), + class_1.rates.employee.main, + ), + ( + "employee", + "ni_liable", + without > upper_earnings_limit + margin, + class_1.rates.employee.additional, + ), + ( + "employer", + "ni_class_1_secondary_liable", + earnings < secondary_threshold - margin, + 0, + ), + ( + "employer", + "ni_class_1_secondary_liable", + without > secondary_threshold + margin, + class_1.rates.employer, + ), + ] + 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 * returned[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_returned = _simulate(people, world) + without_returned = _simulate(people, world, nothing_returned=True) + for year in WORLDS[world][1]: + returned = _values(with_returned, "salary_sacrifice_returned_to_income", year) + assert np.all( + _values(without_returned, "salary_sacrifice_returned_to_income", year) == 0 + ) + months = [ + _class_1(with_returned, f"{year}-{month:02d}-01") + for month in range(1, MONTHS_IN_YEAR + 1) + ] + highest_rate = { + "employee": max( + max(month.rates.employee.main, month.rates.employee.additional) + for month in months + ), + # ni_class_1_employer is a yearly variable: it reads the rate at + # the start of the year. + "employer": months[0].rates.employer, + } + for side, liable in ( + ("employee", "ni_liable"), + ("employer", "ni_class_1_secondary_liable"), + ): + measure = _values( + with_returned, 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_returned, liability, year) + - _values(without_returned, liability, year), + rtol=0, + atol=TOLERANCE, + ) + # 2. Bounds. + assert np.all(measure >= 0) + assert np.all(measure <= highest_rate[side] * returned + TOLERANCE) + # 3. Exact zeros. + assert np.all(measure[returned == 0] == 0) + assert np.all(measure[~with_returned.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_returned, year, returned) + + +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 + thresholds = _class_1(simulation, f"{year}-01-01").thresholds + returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + earnings = _values(simulation, "ni_class_1_income", year) + without = earnings - returned + + def crosses(threshold): + return (without < threshold * WEEKS_IN_YEAR) & ( + earnings > threshold * WEEKS_IN_YEAR + ) + + assert crosses(thresholds.upper_earnings_limit).tolist() == [ + True, + False, + False, + False, + False, + False, + True, + False, + ] + assert crosses(thresholds.primary_threshold)[1] + assert crosses(thresholds.secondary_threshold)[2] + assert returned[3] > 0 + assert earnings[3] < thresholds.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 returned[7] == 0 + + +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.rates.employee.main != december.rates.employee.main + assert january.rates.employee.additional != december.rates.employee.additional + assert ( + january.thresholds.upper_earnings_limit + != december.thresholds.upper_earnings_limit + ) + + +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_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/dcms/bbc/tv_licence/free_tv_licence_value.py b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py index 6626fc9842..c07c8118c3 100644 --- a/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py +++ b/policyengine_uk/variables/gov/dcms/bbc/tv_licence/free_tv_licence_value.py @@ -6,7 +6,7 @@ class free_tv_licence_value(Variable): entity = Household definition_period = YEAR value_type = float - unit = "/1" + unit = GBP reference = ( "https://www.legislation.gov.uk/ukpga/2003/21/section/365A", "https://www.tvlicensing.co.uk/reducedfee", 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 d970390993..7dc9128a1c 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 ba71d85791..b4f1a35d68 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 @@ -1,36 +1,64 @@ from policyengine_uk.model_api import * +def _employee_class_1(earnings, class_1): + """Employee Class 1 contributions on a month's earnings. + + The same schedule as ni_class_1_employee_primary plus + ni_class_1_employee_additional. + """ + primary_threshold = ( + class_1.thresholds.primary_threshold * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + upper_earnings_limit = ( + class_1.thresholds.upper_earnings_limit * WEEKS_IN_YEAR / MONTHS_IN_YEAR + ) + above_upper_earnings_limit = max_(earnings - upper_earnings_limit, 0) + main_rate_earnings = ( + max_(earnings - primary_threshold, 0) - above_upper_earnings_limit + ) + return ( + class_1.rates.employee.main * main_rate_earnings + + class_1.rates.employee.additional * above_upper_earnings_limit + ) + + 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 full excess is redirected to regular pension contributions " - "and subject to NI (but gets income tax relief)." + "Additional employee Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap return to employment " + "income. 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 amount returned " + "(salary_sacrifice_returned_to_income). The amount returned 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 " + "amount returned. 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): - # Get the excess that's redirected to regular pension - # This is the amount subject to NI - excess = person("salary_sacrifice_returned_to_income", 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.thresholds.upper_earnings_limit * 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.rates.employee.main, - ni_params.rates.employee.additional, - ) - - 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) + returned = person("salary_sacrifice_returned_to_income", month) + charge += _employee_class_1(earnings, class_1) - _employee_class_1( + earnings - returned, class_1 + ) + 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 ed9de113a4..351ea50555 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,43 @@ 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 full excess is redirected to regular pension contributions " - "and subject to employer NI." + "Additional employer Class 1 National Insurance due because salary " + "sacrifice pension contributions above the cap return to employment " + "income. 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 amount returned " + "(salary_sacrifice_returned_to_income). The amount returned 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 " + "amount returned. 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): - # Get the excess that's redirected to regular pension - # This is the amount subject to employer NI - excess = person("salary_sacrifice_returned_to_income", period) - - # Use existing NI Class 1 employer rate parameter - ni_params = parameters(period).gov.hmrc.national_insurance.class_1 - employer_rate = ni_params.rates.employer - - return excess * employer_rate + gov = parameters(period).gov + class_1 = gov.hmrc.national_insurance.class_1 + returned = person("salary_sacrifice_returned_to_income", period) + # 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) + secondary_threshold = class_1.thresholds.secondary_threshold * WEEKS_IN_YEAR + above_threshold = max_(earnings - secondary_threshold, 0) + above_threshold_without_returned = max_( + earnings - returned - secondary_threshold, 0 + ) + return class_1.rates.employer * ( + above_threshold - above_threshold_without_returned + ) diff --git a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py index e609d02667..ed88a8d32d 100644 --- a/policyengine_uk/variables/gov/hmrc/sdlt_liable.py +++ b/policyengine_uk/variables/gov/hmrc/sdlt_liable.py @@ -7,7 +7,6 @@ class sdlt_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/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 3646ee73fb..3df735698b 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 dcf773f8c3..96ac7fbc96 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"