From ae88f551b70e900d6a0c98525ad0a6757bb3dd5e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 2 Oct 2026 08:20:31 -0400 Subject: [PATCH 1/3] Limit salary sacrifice to the pay it comes out of MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 6 April 2029 salary_sacrifice_returned_to_income adds the sacrifice above the £2,000 cap to employment_income. The formula read the input pension_contributions_via_salary_sacrifice alone, so a sacrifice with no pay behind it gave a person without a job employment income, National Insurance on it, and lower means-tested benefits. The enhanced FRS has such records (policyengine-uk-data imputed a sacrifice to people without pay), which inflated 2029-30 results. - pension_contributions_via_salary_sacrifice_from_pay: the input limited to employment_income_before_lsr (zero without pay). - salary_sacrifice_returned_to_income and pension_contributions_via_salary_sacrifice_adjusted read it. - YAML: no pay and a sacrifice input gives no employment income in 2029 and 2030; partial pay; unchanged within pay; nothing before 2029. Six of the new cases fail on the old formulas. - Existing salary sacrifice YAML cases set employment_income_before_lsr beside employment_income: the YAML runner does not copy one to the other as policyengine_uk.Simulation does. Expected values unchanged. - Hypothesis properties through policyengine_uk.Simulation: bounds, conservation, no pay no gain, accounting, unchanged within pay, monotonicity. Co-Authored-By: Claude Opus 5.5 --- .../salary-sacrifice-needs-pay.fixed.md | 1 + .../salary_sacrifice_pension_ni_employee.yaml | 11 ++ .../salary_sacrifice_pension_ni_employer.yaml | 11 ++ ...on_contributions_for_annual_allowance.yaml | 1 + ...butions_via_salary_sacrifice_from_pay.yaml | 39 +++++ .../salary_sacrifice_returned_to_income.yaml | 84 +++++++++ ...t_salary_sacrifice_pay_limit_properties.py | 159 ++++++++++++++++++ ...ributions_via_salary_sacrifice_adjusted.py | 7 +- ...ributions_via_salary_sacrifice_from_pay.py | 24 +++ .../salary_sacrifice_returned_to_income.py | 8 +- 10 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 changelog.d/salary-sacrifice-needs-pay.fixed.md create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml create mode 100644 policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml create mode 100644 policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py create mode 100644 policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md new file mode 100644 index 0000000000..0dbc14db6f --- /dev/null +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -0,0 +1 @@ +- Limit salary sacrifice pension contributions to the pay they are sacrificed from (`pension_contributions_via_salary_sacrifice_from_pay`, the input limited to `employment_income_before_lsr`). From 2029 the excess over the £2,000 cap returns to employment income, so a salary sacrifice input with no pay behind it gave a person without a job positive employment income, National Insurance on it, and lower means-tested benefits. 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 39785efd90..a82affd7d1 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 @@ -1,8 +1,12 @@ +# The salary sacrifice is limited to employment_income_before_lsr, the pay it +# comes out of. This runner does not copy an employment_income input there +# (policyengine_uk.Simulation does), so each case sets both. - name: No employee NI charge when salary sacrifice below £2,000 cap period: 2025 absolute_error_margin: 0.01 input: employment_income: 40_000 + employment_income_before_lsr: 40_000 pension_contributions_via_salary_sacrifice: 1_500 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -14,6 +18,7 @@ absolute_error_margin: 0.01 input: employment_income: 45_000 + employment_income_before_lsr: 45_000 pension_contributions_via_salary_sacrifice: 2_250 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -26,6 +31,7 @@ absolute_error_margin: 0.01 input: employment_income: 50_000 + employment_income_before_lsr: 50_000 pension_contributions_via_salary_sacrifice: 10_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -38,6 +44,7 @@ absolute_error_margin: 0.01 input: employment_income: 125_000 + employment_income_before_lsr: 125_000 pension_contributions_via_salary_sacrifice: 25_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -50,6 +57,7 @@ absolute_error_margin: 0.01 input: employment_income: 80_000 + employment_income_before_lsr: 80_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 @@ -62,6 +70,7 @@ absolute_error_margin: 0.01 input: employment_income: 60_000 + employment_income_before_lsr: 60_000 pension_contributions_via_salary_sacrifice: 2_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -73,6 +82,7 @@ absolute_error_margin: 0.01 input: employment_income: 35_000 + employment_income_before_lsr: 35_000 pension_contributions_via_salary_sacrifice: 2_100 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -85,6 +95,7 @@ absolute_error_margin: 0.01 input: employment_income: 45_000 + employment_income_before_lsr: 45_000 pension_contributions_via_salary_sacrifice: 3_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 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 ac76d1cb41..c3ac6f572a 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 @@ -1,8 +1,12 @@ +# The salary sacrifice is limited to employment_income_before_lsr, the pay it +# comes out of. This runner does not copy an employment_income input there +# (policyengine_uk.Simulation does), so each case sets both. - name: No employer NI charge when salary sacrifice below £2,000 cap period: 2025 absolute_error_margin: 0.01 input: employment_income: 40_000 + employment_income_before_lsr: 40_000 pension_contributions_via_salary_sacrifice: 1_500 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -14,6 +18,7 @@ absolute_error_margin: 0.01 input: employment_income: 45_000 + employment_income_before_lsr: 45_000 pension_contributions_via_salary_sacrifice: 2_250 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -26,6 +31,7 @@ absolute_error_margin: 0.01 input: employment_income: 125_000 + employment_income_before_lsr: 125_000 pension_contributions_via_salary_sacrifice: 25_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -38,6 +44,7 @@ absolute_error_margin: 0.01 input: employment_income: 80_000 + employment_income_before_lsr: 80_000 pension_contributions_via_salary_sacrifice: 15_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -50,6 +57,7 @@ absolute_error_margin: 0.01 input: employment_income: 60_000 + employment_income_before_lsr: 60_000 pension_contributions_via_salary_sacrifice: 2_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -61,6 +69,7 @@ absolute_error_margin: 0.01 input: employment_income: 35_000 + employment_income_before_lsr: 35_000 pension_contributions_via_salary_sacrifice: 2_100 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 @@ -73,6 +82,7 @@ absolute_error_margin: 0.01 input: employment_income: 30_000 + employment_income_before_lsr: 30_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 @@ -85,6 +95,7 @@ absolute_error_margin: 0.01 input: employment_income: 45_000 + employment_income_before_lsr: 45_000 pension_contributions_via_salary_sacrifice: 3_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml index db2ae81320..bc247412d8 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_for_annual_allowance.yaml @@ -59,6 +59,7 @@ employee_pension_contributions_reported: 5_000 personal_pension_contributions: 0 employer_pension_contributions: 2_000 + employment_income_before_lsr: 60_000 pension_contributions_via_salary_sacrifice: 8_000 output: # employee_pension_contributions = 5_000 + 0 (no excess) = 5_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml new file mode 100644 index 0000000000..1ac2883211 --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml @@ -0,0 +1,39 @@ +- name: A sacrifice within pay is kept in full + period: 2029 + input: + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 5_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 5_000 + +- name: A sacrifice above pay is limited to the pay + period: 2029 + input: + employment_income_before_lsr: 10_000 + pension_contributions_via_salary_sacrifice: 30_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 10_000 + +- name: A person without pay sacrifices nothing + period: 2029 + input: + pension_contributions_via_salary_sacrifice: 27_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 0 + +- name: Negative pay supports no sacrifice + period: 2029 + input: + employment_income_before_lsr: -1_000 + pension_contributions_via_salary_sacrifice: 27_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 0 + +- name: The limit applies before the cap exists, 2028 + period: 2028 + input: + pension_contributions_via_salary_sacrifice: 27_000 + output: + pension_contributions_via_salary_sacrifice_from_pay: 0 + pension_contributions_via_salary_sacrifice_adjusted: 0 + salary_sacrifice_returned_to_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml new file mode 100644 index 0000000000..546004066b --- /dev/null +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml @@ -0,0 +1,84 @@ +# From 6 April 2029 the salary sacrifice above the £2,000 cap returns to +# employment income. The sacrifice is limited to the person's pay first, so +# the cap never gives pay to a person who has none. + +- name: No pay and a salary sacrifice input gives no employment income, 2029 + period: 2029 + absolute_error_margin: 0.01 + input: + pension_contributions_via_salary_sacrifice: 27_000 + output: + salary_sacrifice_returned_to_income: 0 + pension_contributions_via_salary_sacrifice_adjusted: 0 + employment_income: 0 + salary_sacrifice_pension_ni_employee: 0 + salary_sacrifice_pension_ni_employer: 0 + employee_pension_contributions: 0 + +- name: No pay and a salary sacrifice input gives no employment income, 2030 + period: 2030 + absolute_error_margin: 0.01 + input: + pension_contributions_via_salary_sacrifice: 27_000 + output: + salary_sacrifice_returned_to_income: 0 + employment_income: 0 + +- name: Self-employed person with no pay gets no employment income, 2029 + period: 2029 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + pension_contributions_via_salary_sacrifice: 27_000 + output: + salary_sacrifice_returned_to_income: 0 + employment_income: 0 + +- name: Pay above the sacrifice returns the excess over the cap, 2029 + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 5_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # 5,000 - 2,000 cap + salary_sacrifice_returned_to_income: 3_000 + pension_contributions_via_salary_sacrifice_adjusted: 2_000 + employment_income: 43_000 + +- name: Pay below the sacrifice returns only what the pay supports, 2029 + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 10_000 + pension_contributions_via_salary_sacrifice: 30_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + # min(30,000, 10,000) - 2,000 cap + salary_sacrifice_returned_to_income: 8_000 + pension_contributions_via_salary_sacrifice_adjusted: 2_000 + employment_income: 18_000 + +- name: Pay below the cap returns nothing, 2029 + period: 2029 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 1_500 + pension_contributions_via_salary_sacrifice: 30_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 + output: + salary_sacrifice_returned_to_income: 0 + pension_contributions_via_salary_sacrifice_adjusted: 1_500 + employment_income: 1_500 + +- name: Nothing returns before the cap starts, 2028 + period: 2028 + absolute_error_margin: 0.01 + input: + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 5_000 + output: + salary_sacrifice_returned_to_income: 0 + pension_contributions_via_salary_sacrifice_adjusted: 5_000 + employment_income: 40_000 diff --git a/policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py b/policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py new file mode 100644 index 0000000000..03a02b4e1e --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py @@ -0,0 +1,159 @@ +"""Property-based tests: a salary sacrifice is limited to the pay behind it. + +From 6 April 2029 the salary sacrifice above the £2,000 cap returns to +employment income (salary_sacrifice_returned_to_income). The sacrifice is +first limited to the person's pay +(pension_contributions_via_salary_sacrifice_from_pay), so an input sacrifice +with no pay behind it cannot create pay. + +Invariants, for any generated pay and salary sacrifice, in a year before the +cap (2028) and two years under it (2029, 2030): + +1. Bounds: the sacrifice from pay equals min(max(sacrifice, 0), max(pay, 0)). +2. Conservation: the capped sacrifice plus the amount returned to income + equals the sacrifice from pay. +3. No pay, no gain: a person without pay has no employment income, nothing + returned and no National Insurance charge on a returned amount, whatever + their sacrifice input. +4. Nothing is returned before the cap starts. +5. Accounting: employment income equals pay plus the amount returned plus + the broad-base haircut. +6. Unchanged within pay: when the sacrifice is within pay, the amount + returned is the sacrifice above the cap, as before the limit. +7. Monotonicity: the amount returned never falls when the sacrifice or the + pay rises. + +The simulations go through policyengine_uk.Simulation, which moves an +employment_income input to employment_income_before_lsr as it does for a +dataset or a household calculation. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_uk import Simulation + +YEARS = [2028, 2029, 2030] +PROPERTY_SETTINGS = settings( + max_examples=10, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +# Zero pay and a zero sacrifice are the cases the limit exists for, so they +# are drawn often. +pay = st.one_of( + st.just(0.0), st.floats(0, 200_000, allow_nan=False, allow_infinity=False) +) +sacrifice = st.one_of( + st.just(0.0), st.floats(0, 100_000, allow_nan=False, allow_infinity=False) +) +increase = st.floats(0, 50_000, allow_nan=False, allow_infinity=False) +people = st.lists(st.tuples(pay, sacrifice, increase, increase), min_size=1, max_size=8) + + +def _simulate(pays, sacrifices): + """One single-adult household per (pay, sacrifice) pair.""" + names = [f"person_{i}" for i in range(len(pays))] + situation = { + "people": { + name: { + "age": {str(year): 40 for year in YEARS}, + "employment_income": {str(year): pays[i] for year in YEARS}, + "pension_contributions_via_salary_sacrifice": { + str(year): sacrifices[i] for year in YEARS + }, + } + for i, name in enumerate(names) + }, + "benunits": {f"benunit_{i}": {"members": [n]} for i, n in enumerate(names)}, + "households": {f"household_{i}": {"members": [n]} for i, n in enumerate(names)}, + } + return Simulation(situation=situation) + + +def _values(simulation, variable, year): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def _cap(simulation, year): + parameters = simulation.tax_benefit_system.parameters(year) + return parameters.gov.hmrc.national_insurance.salary_sacrifice_pension_cap + + +@PROPERTY_SETTINGS +@given(people) +def test_salary_sacrifice_is_limited_to_pay(rows): + pays = np.array([row[0] for row in rows]) + sacrifices = np.array([row[1] for row in rows]) + simulation = _simulate(pays, sacrifices) + for year in YEARS: + from_pay = _values( + simulation, "pension_contributions_via_salary_sacrifice_from_pay", year + ) + adjusted = _values( + simulation, "pension_contributions_via_salary_sacrifice_adjusted", year + ) + returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + employment_income = _values(simulation, "employment_income", year) + haircut = _values(simulation, "salary_sacrifice_broad_base_haircut", year) + cap = _cap(simulation, year) + + # 1. Bounds. + np.testing.assert_allclose( + from_pay, np.minimum(sacrifices, pays), rtol=1e-6, atol=1e-2 + ) + assert np.all(returned >= 0) + # 2. Conservation. + np.testing.assert_allclose(adjusted + returned, from_pay, rtol=1e-6, atol=1e-2) + # 3. No pay, no gain. + unpaid = pays <= 0 + assert np.all(employment_income[unpaid] == 0) + assert np.all(returned[unpaid] == 0) + for charge in ( + "salary_sacrifice_pension_ni_employee", + "salary_sacrifice_pension_ni_employer", + ): + assert np.all(_values(simulation, charge, year)[unpaid] == 0) + # 4. Nothing is returned before the cap starts. + if np.isinf(cap): + assert np.all(returned == 0) + # 5. Accounting. + np.testing.assert_allclose( + employment_income, pays + returned + haircut, rtol=1e-6, atol=1e-2 + ) + # 6. Unchanged within pay. + within = sacrifices <= pays + if not np.isinf(cap): + np.testing.assert_allclose( + returned[within], + np.maximum(sacrifices[within] - cap, 0), + rtol=1e-6, + atol=1e-2, + ) + + +@PROPERTY_SETTINGS +@given(people) +def test_returned_income_never_falls_with_sacrifice_or_pay(rows): + n = len(rows) + pays = np.array([row[0] for row in rows]) + sacrifices = np.array([row[1] for row in rows]) + more_sacrifice = np.array([row[2] for row in rows]) + more_pay = np.array([row[3] for row in rows]) + # The same people three times: as drawn, with a larger sacrifice, and + # with more pay. + simulation = _simulate( + np.concatenate([pays, pays, pays + more_pay]), + np.concatenate([sacrifices, sacrifices + more_sacrifice, sacrifices]), + ) + for year in YEARS: + returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + base, larger_sacrifice, larger_pay = ( + returned[:n], + returned[n : 2 * n], + returned[2 * n :], + ) + assert np.all(larger_sacrifice >= base - 1e-2) + assert np.all(larger_pay >= base - 1e-2) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py index ec101cce42..caf18a6a06 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py @@ -5,7 +5,8 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): label = "Adjusted salary sacrifice pension contributions (capped)" documentation = ( "The actual amount of salary sacrifice pension contributions after " - "applying the cap. Contributions above the cap are redirected to " + "limiting them to the person's pay and applying the cap. " + "Contributions above the cap are redirected to " "regular employee pension contributions and subject to NI." ) entity = Person @@ -15,7 +16,9 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): reference = "https://policyengine.org/uk/research/salary-sacrifice-cap" def formula(person, period, parameters): - intended_ss = person("pension_contributions_via_salary_sacrifice", period) + intended_ss = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) cap = parameters( period ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py new file mode 100644 index 0000000000..5332523bb1 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py @@ -0,0 +1,24 @@ +from policyengine_uk.model_api import * + + +class pension_contributions_via_salary_sacrifice_from_pay(Variable): + label = "Salary sacrifice pension contributions limited to pay" + documentation = ( + "Salary sacrifice pension contributions limited to the pay they are " + "sacrificed from. A salary sacrifice is pay given up in exchange for " + "an employer pension contribution, so a person without pay sacrifices " + "nothing, and the model allows no sacrifice above the pay the person " + "still receives (employment income before labour supply responses, " + "which is net of the sacrifice). The cap on salary sacrifice returns " + "the excess over the cap to employment income, so without this limit " + "an input sacrifice with no pay behind it would create pay." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + + def formula(person, period, parameters): + sacrifice = person("pension_contributions_via_salary_sacrifice", period) + pay = person("employment_income_before_lsr", period) + return min_(max_(sacrifice, 0), max_(pay, 0)) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py index 82bca27858..117bbbb358 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py @@ -12,6 +12,10 @@ class salary_sacrifice_returned_to_income(Variable): "The full excess is redirected - the employer cost increase is handled via " "the broad-base haircut (salary_sacrifice_broad_base_haircut) which reduces " "ALL workers' employment income by ~0.16%, not just affected workers." + "\n\n" + "The salary sacrifice is limited to the person's pay " + "(pension_contributions_via_salary_sacrifice_from_pay), so nothing is " + "returned to a person without pay." ) entity = Person definition_period = YEAR @@ -20,7 +24,9 @@ class salary_sacrifice_returned_to_income(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - intended_ss = person("pension_contributions_via_salary_sacrifice", period) + intended_ss = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) cap = parameters( period ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap From 7ca3bcba959794e646dc9c1b76e1274e6329ccf6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 08:15:16 -0400 Subject: [PATCH 2/3] Count salary sacrifice only for people with pay, not up to their pay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review (subfleet 20261002-093645-peuk-2063-ss-review) found that limiting the sacrifice to the remaining pay is not the law: HMRC allows a whole bonus to be sacrificed as long as cash pay stays above the National Minimum Wage, so a £40,000 salary with a £50,000 bonus sacrifice is valid. The guard now only zeroes a sacrifice with no pay behind it. - pension_contributions_via_salary_sacrifice_from_pay: max(sacrifice, 0) where employment_income_before_lsr > 0, else 0. - YAML: whole-bonus sacrifice above the remaining salary kept in full and its full excess returned in 2030; negative sacrifice input; low pay with a large sacrifice. - Properties: definition, conservation, no pay no gain, accounting, unchanged for people with pay, monotonicity; conditions use the pay the model stores (float32). New example: setting employment_income after construction, as the marginal tax rate branches and the participation response do, keeps the sacrifice (the original pay stays in employment_income_before_lsr). - test_full_excess_redirected measures the excess on the sacrifice of people with pay. - Changelog fragment in the past tense. Co-Authored-By: Claude Opus 5.5 --- .../salary-sacrifice-needs-pay.fixed.md | 2 +- .../test_salary_sacrifice_cap_reform.py | 5 +- ...butions_via_salary_sacrifice_from_pay.yaml | 20 ++++++-- .../salary_sacrifice_returned_to_income.yaml | 26 +++++----- ..._salary_sacrifice_needs_pay_properties.py} | 47 +++++++++++++------ ...ributions_via_salary_sacrifice_adjusted.py | 2 +- ...ributions_via_salary_sacrifice_from_pay.py | 23 +++++---- .../salary_sacrifice_returned_to_income.py | 2 +- 8 files changed, 79 insertions(+), 48 deletions(-) rename policyengine_uk/tests/{test_salary_sacrifice_pay_limit_properties.py => test_salary_sacrifice_needs_pay_properties.py} (75%) diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md index 0dbc14db6f..4c6cde50b3 100644 --- a/changelog.d/salary-sacrifice-needs-pay.fixed.md +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -1 +1 @@ -- Limit salary sacrifice pension contributions to the pay they are sacrificed from (`pension_contributions_via_salary_sacrifice_from_pay`, the input limited to `employment_income_before_lsr`). From 2029 the excess over the £2,000 cap returns to employment income, so a salary sacrifice input with no pay behind it gave a person without a job positive employment income, National Insurance on it, and lower means-tested benefits. +- Counted salary sacrifice pension contributions only for people with pay, so from 2029 the excess over the £2,000 cap no longer gives employment income to people without a job. diff --git a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py index 74d9c2a758..f6fe96de52 100644 --- a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -222,9 +222,10 @@ def test_full_excess_redirected(reform_simulation): The broad-base haircut reduces ALL workers' employment income, but the full excess above cap is redirected to regular pension contributions. """ - # Get weighted totals using map_to for proper aggregation + # Get weighted totals using map_to for proper aggregation. Only people + # with pay have a salary sacrifice, so the excess is measured on that. ss_contributions = reform_simulation.calculate( - "pension_contributions_via_salary_sacrifice", + "pension_contributions_via_salary_sacrifice_from_pay", POLICY_YEAR, map_to="person", ) diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml index 1ac2883211..1925d8af86 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.yaml @@ -6,13 +6,15 @@ output: pension_contributions_via_salary_sacrifice_from_pay: 5_000 -- name: A sacrifice above pay is limited to the pay +- name: A whole-bonus sacrifice above the remaining salary is kept in full period: 2029 input: - employment_income_before_lsr: 10_000 - pension_contributions_via_salary_sacrifice: 30_000 + # £40,000 salary kept, £50,000 bonus sacrificed (HMRC allows this while + # cash pay stays above the National Minimum Wage). + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 50_000 output: - pension_contributions_via_salary_sacrifice_from_pay: 10_000 + pension_contributions_via_salary_sacrifice_from_pay: 50_000 - name: A person without pay sacrifices nothing period: 2029 @@ -29,7 +31,15 @@ output: pension_contributions_via_salary_sacrifice_from_pay: 0 -- name: The limit applies before the cap exists, 2028 +- name: A negative sacrifice input counts as none + period: 2029 + input: + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: -500 + output: + pension_contributions_via_salary_sacrifice_from_pay: 0 + +- name: The condition applies before the cap exists, 2028 period: 2028 input: pension_contributions_via_salary_sacrifice: 27_000 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml index 546004066b..2f3573564d 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/salary_sacrifice_returned_to_income.yaml @@ -1,6 +1,6 @@ # From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -# employment income. The sacrifice is limited to the person's pay first, so -# the cap never gives pay to a person who has none. +# employment income. Only people with pay have a salary sacrifice, so the cap +# never gives pay to a person who has none. - name: No pay and a salary sacrifice input gives no employment income, 2029 period: 2029 @@ -47,20 +47,20 @@ pension_contributions_via_salary_sacrifice_adjusted: 2_000 employment_income: 43_000 -- name: Pay below the sacrifice returns only what the pay supports, 2029 - period: 2029 +- name: A whole-bonus sacrifice above the remaining salary returns its full excess, 2030 + period: 2030 absolute_error_margin: 0.01 input: - employment_income_before_lsr: 10_000 - pension_contributions_via_salary_sacrifice: 30_000 + employment_income_before_lsr: 40_000 + pension_contributions_via_salary_sacrifice: 50_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - # min(30,000, 10,000) - 2,000 cap - salary_sacrifice_returned_to_income: 8_000 + # 50,000 - 2,000 cap + salary_sacrifice_returned_to_income: 48_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 18_000 + employment_income: 88_000 -- name: Pay below the cap returns nothing, 2029 +- name: Low pay with a large sacrifice still returns the excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -68,9 +68,9 @@ pension_contributions_via_salary_sacrifice: 30_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_returned_to_income: 0 - pension_contributions_via_salary_sacrifice_adjusted: 1_500 - employment_income: 1_500 + salary_sacrifice_returned_to_income: 28_000 + pension_contributions_via_salary_sacrifice_adjusted: 2_000 + employment_income: 29_500 - name: Nothing returns before the cap starts, 2028 period: 2028 diff --git a/policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py similarity index 75% rename from policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py rename to policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py index 03a02b4e1e..05d2e58f49 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_pay_limit_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py @@ -1,15 +1,15 @@ -"""Property-based tests: a salary sacrifice is limited to the pay behind it. +"""Property-based tests: only people with pay have a salary sacrifice. From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -employment income (salary_sacrifice_returned_to_income). The sacrifice is -first limited to the person's pay -(pension_contributions_via_salary_sacrifice_from_pay), so an input sacrifice -with no pay behind it cannot create pay. +employment income (salary_sacrifice_returned_to_income). Only people with pay +have a salary sacrifice (pension_contributions_via_salary_sacrifice_from_pay), +so an input sacrifice with no pay behind it cannot create pay. A sacrifice +above the pay that remains (a whole bonus, say) is kept in full. Invariants, for any generated pay and salary sacrifice, in a year before the cap (2028) and two years under it (2029, 2030): -1. Bounds: the sacrifice from pay equals min(max(sacrifice, 0), max(pay, 0)). +1. Definition: the sacrifice from pay is max(sacrifice, 0) with pay, else 0. 2. Conservation: the capped sacrifice plus the amount returned to income equals the sacrifice from pay. 3. No pay, no gain: a person without pay has no employment income, nothing @@ -18,8 +18,8 @@ 4. Nothing is returned before the cap starts. 5. Accounting: employment income equals pay plus the amount returned plus the broad-base haircut. -6. Unchanged within pay: when the sacrifice is within pay, the amount - returned is the sacrifice above the cap, as before the limit. +6. Unchanged with pay: for a person with pay, the amount returned is the + sacrifice above the cap, as before this condition. 7. Monotonicity: the amount returned never falls when the sacrifice or the pay rises. @@ -84,11 +84,13 @@ def _cap(simulation, year): @PROPERTY_SETTINGS @given(people) -def test_salary_sacrifice_is_limited_to_pay(rows): +def test_only_people_with_pay_have_a_salary_sacrifice(rows): pays = np.array([row[0] for row in rows]) sacrifices = np.array([row[1] for row in rows]) simulation = _simulate(pays, sacrifices) for year in YEARS: + # The pay the model stores (float32: tiny draws round to zero). + pays = _values(simulation, "employment_income_before_lsr", year) from_pay = _values( simulation, "pension_contributions_via_salary_sacrifice_from_pay", year ) @@ -100,9 +102,9 @@ def test_salary_sacrifice_is_limited_to_pay(rows): haircut = _values(simulation, "salary_sacrifice_broad_base_haircut", year) cap = _cap(simulation, year) - # 1. Bounds. + # 1. Definition. np.testing.assert_allclose( - from_pay, np.minimum(sacrifices, pays), rtol=1e-6, atol=1e-2 + from_pay, np.where(pays > 0, sacrifices, 0), rtol=1e-6, atol=1e-2 ) assert np.all(returned >= 0) # 2. Conservation. @@ -123,12 +125,12 @@ def test_salary_sacrifice_is_limited_to_pay(rows): np.testing.assert_allclose( employment_income, pays + returned + haircut, rtol=1e-6, atol=1e-2 ) - # 6. Unchanged within pay. - within = sacrifices <= pays + # 6. Unchanged with pay. + paid = pays > 0 if not np.isinf(cap): np.testing.assert_allclose( - returned[within], - np.maximum(sacrifices[within] - cap, 0), + returned[paid], + np.maximum(sacrifices[paid] - cap, 0), rtol=1e-6, atol=1e-2, ) @@ -157,3 +159,18 @@ def test_returned_income_never_falls_with_sacrifice_or_pay(rows): ) assert np.all(larger_sacrifice >= base - 1e-2) assert np.all(larger_pay >= base - 1e-2) + + +def test_pay_set_after_construction_keeps_the_sacrifice(): + """Branches that reset employment_income keep the pay the sacrifice needs. + + The marginal tax rate branches and the participation response set + employment_income on a simulation built with pay; the original pay stays + in employment_income_before_lsr, so the sacrifice is still recognised. + """ + simulation = _simulate(np.array([30_000.0]), np.array([6_000.0])) + simulation.set_input("employment_income", 2030, np.array([0.0])) + assert _values( + simulation, "pension_contributions_via_salary_sacrifice_from_pay", 2030 + ) == [6_000] + assert _values(simulation, "salary_sacrifice_returned_to_income", 2030) == [4_000] diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py index caf18a6a06..3334cfa70c 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_adjusted.py @@ -5,7 +5,7 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): label = "Adjusted salary sacrifice pension contributions (capped)" documentation = ( "The actual amount of salary sacrifice pension contributions after " - "limiting them to the person's pay and applying the cap. " + "counting them only for people with pay and applying the cap. " "Contributions above the cap are redirected to " "regular employee pension contributions and subject to NI." ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py index 5332523bb1..62cb1a357e 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_via_salary_sacrifice_from_pay.py @@ -2,23 +2,26 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): - label = "Salary sacrifice pension contributions limited to pay" + label = "Salary sacrifice pension contributions of people with pay" documentation = ( - "Salary sacrifice pension contributions limited to the pay they are " - "sacrificed from. A salary sacrifice is pay given up in exchange for " - "an employer pension contribution, so a person without pay sacrifices " - "nothing, and the model allows no sacrifice above the pay the person " - "still receives (employment income before labour supply responses, " - "which is net of the sacrifice). The cap on salary sacrifice returns " - "the excess over the cap to employment income, so without this limit " - "an input sacrifice with no pay behind it would create pay." + "Salary sacrifice pension contributions, counted only for people with " + "pay. A salary sacrifice is pay given up in exchange for an employer " + "pension contribution, so a person without pay (employment income " + "before labour supply responses, which policyengine_uk.Simulation fills " + "from an employment_income input) has nothing to sacrifice. The " + "sacrifice is not limited to the pay that remains: an employee may " + "sacrifice, for example, a whole bonus, as long as their cash pay stays " + "above the National Minimum Wage. The cap on salary sacrifice returns " + "the excess over the cap to employment income, so without this " + "condition an input sacrifice with no pay behind it would create pay." ) entity = Person definition_period = YEAR value_type = float unit = GBP + reference = "https://www.gov.uk/guidance/salary-sacrifice-and-the-effects-on-paye" def formula(person, period, parameters): sacrifice = person("pension_contributions_via_salary_sacrifice", period) pay = person("employment_income_before_lsr", period) - return min_(max_(sacrifice, 0), max_(pay, 0)) + return where(pay > 0, max_(sacrifice, 0), 0) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py index 117bbbb358..72c7ce0335 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/salary_sacrifice_returned_to_income.py @@ -13,7 +13,7 @@ class salary_sacrifice_returned_to_income(Variable): "the broad-base haircut (salary_sacrifice_broad_base_haircut) which reduces " "ALL workers' employment income by ~0.16%, not just affected workers." "\n\n" - "The salary sacrifice is limited to the person's pay " + "Only people with pay have a salary sacrifice " "(pension_contributions_via_salary_sacrifice_from_pay), so nothing is " "returned to a person without pay." ) From 823477c3ca8e6e0874327cb8b9fb6074fdee9596 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 3 Oct 2026 14:38:08 -0400 Subject: [PATCH 3/3] Bring the NI test headers and property test in line with the guard Review r2 (subfleet 20261003-090152-peuk-2063-ss-review-r2, APPROVE): - The two NI YAML headers still described the removed limit to remaining pay; they now say salary sacrifice counts only for people with pay. - The property test checks that the pay the model stores is the pay put in, so it cannot pass if Simulation stopped moving employment_income to employment_income_before_lsr. - Changelog: "people without pay", not "without a job". Co-Authored-By: Claude Opus 5.5 --- changelog.d/salary-sacrifice-needs-pay.fixed.md | 2 +- .../salary_sacrifice_pension_ni_employee.yaml | 7 ++++--- .../salary_sacrifice_pension_ni_employer.yaml | 7 ++++--- .../tests/test_salary_sacrifice_needs_pay_properties.py | 7 +++++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/changelog.d/salary-sacrifice-needs-pay.fixed.md b/changelog.d/salary-sacrifice-needs-pay.fixed.md index 4c6cde50b3..ce4684f9a4 100644 --- a/changelog.d/salary-sacrifice-needs-pay.fixed.md +++ b/changelog.d/salary-sacrifice-needs-pay.fixed.md @@ -1 +1 @@ -- Counted salary sacrifice pension contributions only for people with pay, so from 2029 the excess over the £2,000 cap no longer gives employment income to people without a job. +- Counted salary sacrifice pension contributions only for people with pay, so from 2029 the excess over the £2,000 cap no longer gives employment income to people without pay. 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 a82affd7d1..5b36e53d92 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 @@ -1,6 +1,7 @@ -# The salary sacrifice is limited to employment_income_before_lsr, the pay it -# comes out of. This runner does not copy an employment_income input there -# (policyengine_uk.Simulation does), so each case sets both. +# Salary sacrifice counts only for people with pay +# (employment_income_before_lsr > 0). This runner does not copy an +# employment_income input there (policyengine_uk.Simulation does), so each +# case sets both. - name: No employee NI charge when salary sacrifice below £2,000 cap period: 2025 absolute_error_margin: 0.01 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 c3ac6f572a..4eb0fdf97c 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 @@ -1,6 +1,7 @@ -# The salary sacrifice is limited to employment_income_before_lsr, the pay it -# comes out of. This runner does not copy an employment_income input there -# (policyengine_uk.Simulation does), so each case sets both. +# Salary sacrifice counts only for people with pay +# (employment_income_before_lsr > 0). This runner does not copy an +# employment_income input there (policyengine_uk.Simulation does), so each +# case sets both. - name: No employer NI charge when salary sacrifice below £2,000 cap period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py index 05d2e58f49..043e485ff6 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py @@ -87,10 +87,13 @@ def _cap(simulation, year): def test_only_people_with_pay_have_a_salary_sacrifice(rows): pays = np.array([row[0] for row in rows]) sacrifices = np.array([row[1] for row in rows]) - simulation = _simulate(pays, sacrifices) + drawn_pays = pays + simulation = _simulate(drawn_pays, sacrifices) for year in YEARS: - # The pay the model stores (float32: tiny draws round to zero). + # The pay the model stores (float32: tiny draws round to zero). It + # must be the pay put in: Simulation moves employment_income there. pays = _values(simulation, "employment_income_before_lsr", year) + np.testing.assert_allclose(pays, drawn_pays, rtol=1e-6, atol=1e-2) from_pay = _values( simulation, "pension_contributions_via_salary_sacrifice_from_pay", year )