diff --git a/changelog.d/2454.fixed.md b/changelog.d/2454.fixed.md new file mode 100644 index 0000000000..c4bf9c227a --- /dev/null +++ b/changelog.d/2454.fixed.md @@ -0,0 +1 @@ +- Kept the pension salary-sacrifice cap in National Insurance earnings without inflating ordinary pay or member pension contributions, retained actual supplied ordinary returns in the annual-allowance calculations, and preserved the childcare income tests in #2173. diff --git a/docs/engineering/pension-annual-allowance.md b/docs/engineering/pension-annual-allowance.md index 906d7d60d9..ac881ae9de 100644 --- a/docs/engineering/pension-annual-allowance.md +++ b/docs/engineering/pension-annual-allowance.md @@ -27,9 +27,11 @@ is deducted separately through `allowances`. Employee contributions represent net pay and personal contributions represent gross relief at source (RAS). Both annual-allowance income tests use [`annual_allowance_net_income`](../../policyengine_uk/utils/pensions.py). -This shared helper reconstructs the model's net-income convention from actual -income, before separately modeled pension relief, excluding the NICs-only -salary-sacrifice return. Accordingly: +This shared helper retains the model's net-income convention before separately +modeled pension relief and applies net-pay/trading-loss ordering to actual +income and member payments. The NICs-only excess never enters these amounts; +a positive supplied legacy ordinary return is actual pay/member payment. +Accordingly: - `pension_annual_allowance_adjusted_income` adds the total pension input amount to that reconstructed income, then subtracts the individual's actual @@ -69,20 +71,19 @@ prevents Step 2 relief from exceeding the remaining income. This limit applies in every model year, including before the NICs cap begins in 2029. It can therefore change pre-2029 allowances for people whose trading-loss relief is limited by income and who make relievable net-pay contributions. -Removing synthetic pay after deducting the -original relief would retain relief calculated on income the person did not -receive. Threshold income also recomputes [FA 2004 ss. 188–191 pension +The helper replaces the original loss with the loss allowed after relievable +net pay. Threshold income deducts [FA 2004 ss. 188–191 pension relief](https://www.legislation.gov.uk/ukpga/2004/12/section/188) on actual earnings and contributions. An explicitly supplied `adjusted_net_income` is authoritative. For this input path, the helper restores gross Gift Aid and pension relief recomputed on -actual contributions and earnings. It does not remove returned pay or -reconstruct trading-loss relief: the supplied ANI contains neither synthetic -pay nor relief recalculated by the NICs reclassification. The annual-allowance -base is therefore independent of the NICs cap on this path too. +actual contributions and earnings. It does not remove an actual supplied +ordinary return or reconstruct trading-loss relief: the supplied ANI already +determines the income after those losses. The annual-allowance base is +independent of the NICs cap on this path too. -Other modeled deductions do not depend on the returned pay: brought-forward +Other modeled deductions retain their existing treatment: brought-forward trade losses, capital allowances and the trading allowance depend on trading profits. Property finance-cost relief is a downstream tax reducer. Gift Aid's adjusted-net-income deduction and rate-limit extension do not reduce either @@ -124,24 +125,23 @@ this correction does not reconstruct DB rights, overseas adjustments, unused allowance histories or anti-avoidance arrangements under s. 228ZB. [The NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) -changes NICs treatment without changing s. 228ZA. From 2029, the model moves -sacrifice above the NICs exemption cap into -employee contributions and employment income. The AA calculations remove -that synthetic return from both pay and member contributions and recompute -income-dependent relief as described above. Pension relief preserves the +changes NICs treatment without changing s. 228ZA. From 2029, the model includes +sacrifice above the NICs exemption cap only in Class 1 earnings through +`salary_sacrifice_pension_ni_excess`. It does not create ordinary pay or member +contributions. The legacy `salary_sacrifice_returned_to_income` defaults to +zero; a positive explicit input represents actual ordinary pay/member payment +and is retained in the AA calculations. Pension relief preserves the basic RAS amount even when cash earnings are below £3,600 and gives no member relief from age 75. The DC input keeps the full employer sacrifice at every age. The threshold-income add-back uses the full -sacrifice before this reclassification. The separate employer-cost wage +sacrifice before the NICs cap. The separate employer-cost wage response can still change income; it is held fixed when testing the cap's statutory effect. -Cap invariance covers the two taper incomes, pension input and allowance. -Charge comparisons additionally hold ordinary income-tax inputs fixed: the -separate ANI and personal-allowance interaction can change the marginal tax -charged on the same excess. Ordinary income tax retains its existing -reclassification behavior; [#2454](https://github.com/PolicyEngine/policyengine-uk/issues/2454) -tracks that separate defect. +With the separate wage response held fixed, the cap does not change ordinary +pay, member payments, ANI or the personal allowance. Cap-invariance checks +cover those paths alongside the two taper incomes, pension input, allowance +and charge. Actual NI liability changes through the existing Class 1 curves. The taxable death-benefit and grandfathered-sacrifice inputs default to zero. Pension relief retains the model's aggregate earnings cap and allocation diff --git a/docs/engineering/salary-sacrifice-nics.md b/docs/engineering/salary-sacrifice-nics.md new file mode 100644 index 0000000000..86da793f53 --- /dev/null +++ b/docs/engineering/salary-sacrifice-nics.md @@ -0,0 +1,31 @@ +# Salary sacrifice and the NICs cap + +`employment_income` supplies cash pay after contractual salary sacrifice, and +`pension_contributions_via_salary_sacrifice` supplies the employer contribution +separately. UKSimulation preserves this existing input convention; the model +does not deduct that sacrifice a second time from cash pay. + +The cap limits NIC exemption, rather than returning ordinary pay or creating a +member pension payment. `salary_sacrifice_pension_ni_excess` adds the excess once +to `ni_class_1_income`. Full actual sacrifice still counts once in the current +DC pension-input proxy. The capped `..._adjusted` measure is the NIC-exempt +amount. The two NI reporting measures remain approximations; actual liability +uses the Class 1 curves and age flags. + +The legacy `salary_sacrifice_returned_to_income` defaults to zero. Explicit +ordinary-return inputs retain their previous pay/member-payment semantics. +Actual reported member payments and the separate broad-base haircut assumption +are unchanged. The merged #2441 age-75 member accounting and annual-allowance +taper rules are retained: member payments before age 75, employer payments and +full sacrifice enter the default DC input, while member payments from age 75 +are excluded. A positive supplied ordinary return is actual pay and a member +payment in these readers too; it is not removed as an unreceived NICs return. +The AA helper still applies the net-pay/trading-loss ordering, RAS earnings and +age limits, supplied-ANI restoration and statutory pension-input overrides. + +The [NICs Act 2026](https://www.legislation.gov.uk/ukpga/2026/15/enacted) +provides the contributions-law machinery for 2029–30 onwards. The existing +2029-04-06 parameter and annual sampling are retained. This is not a complete +implementation of future payroll-interval regulations. Employer contributions +remain subject to [ITEPA 2003 s308](https://www.legislation.gov.uk/ukpga/2003/1/section/308); +member relief separately follows [FA 2004 ss188–191](https://www.legislation.gov.uk/ukpga/2004/12/section/188). diff --git a/policyengine_uk/data/fiscal_partition.yaml b/policyengine_uk/data/fiscal_partition.yaml index cb5d415d0d..169920271d 100644 --- a/policyengine_uk/data/fiscal_partition.yaml +++ b/policyengine_uk/data/fiscal_partition.yaml @@ -481,7 +481,7 @@ exclusions: - {variable: ni_class_3, kind: component, head: nics_individual} - {variable: ni_class_4, kind: component, head: nics_individual} - {variable: ni_class_1_employer, kind: component, head: nics_employer} - - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to employment_income, so Class 1 already charges it."} + - {variable: salary_sacrifice_pension_ni_employee, kind: measure_within, head: nics_individual, note: "The excess sacrifice is added back to ni_class_1_income, so Class 1 already charges it."} - {variable: salary_sacrifice_pension_ni_employer, kind: measure_within, head: nics_employer, note: "As above, for employer Class 1."} - {variable: tax, kind: composite, spans: [income_tax, nics_individual]} - {variable: benunit_tax, kind: composite, spans: [income_tax, nics_individual]} 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 f6fe96de52..d292b0c2a8 100644 --- a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -4,11 +4,11 @@ This tests the £2,000 salary sacrifice cap policy announced in Autumn Budget 2025, which takes effect from April 2029. -Methodology (matching blog at https://policyengine.org/uk/research/uk-salary-sacrifice-cap): -- Employees redirect excess above £2,000 to regular pension contributions -- Regular pension contributions get income tax relief but NOT NI relief +Current NI-only model routing (historical population benchmarks below): +- Excess above £2,000 is NIC-only earnings; pension contributions stay employer payments +- Ordinary income-tax treatment is unchanged; only NIC exemption is capped - Broad-base haircut: employers spread NI costs across ALL workers (~0.16% of employment income) -- Revenue comes from NI on the redirected excess +- Revenue comes from NI on the excess included in NI earnings Expected revenue: ~£3.3 billion in 2029-30 @@ -60,10 +60,10 @@ def test_salary_sacrifice_cap_revenue_impact(baseline_simulation, reform_simulat """ Test that the £2,000 salary sacrifice cap raises ~£3.3 billion. - This matches the PolicyEngine blog methodology: - - Employees redirect excess to regular pension contributions - - Full excess subject to NI (broad-base haircut reduces all workers' income) - - Income tax relief preserved via regular pension contributions + The current NI-only model routing is: + - Excess is included only in NI earnings + - Broad-base haircut reduces all workers' income as a separate assumption + - Employer pension contributions retain their income-tax treatment """ baseline_gov_balance = baseline_simulation.calculate( "gov_balance", POLICY_YEAR @@ -95,7 +95,7 @@ def test_ni_increases_with_reform(baseline_simulation, reform_simulation): """ Test that total NI increases when the cap is applied. - The reform adds excess salary sacrifice back to employment income, + The reform adds excess salary sacrifice only to NIC earnings, which is then subject to NI through the normal Class 1 calculation. """ baseline_ni = baseline_simulation.calculate( @@ -127,9 +127,8 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): """ Test the income tax impact of the reform. - The excess is added to employment income and to employee pension - contributions for relief. Due to pension relief caps, some people - don't get full relief, resulting in a small positive income tax impact. + The excess is NIC-only and creates no member payment or ordinary tax. + The separate broad-base haircut can still affect ordinary tax. """ baseline_tax = baseline_simulation.calculate("income_tax", POLICY_YEAR).sum() reform_tax = reform_simulation.calculate("income_tax", POLICY_YEAR).sum() @@ -141,25 +140,22 @@ def test_income_tax_impact(baseline_simulation, reform_simulation): print(f"Income tax change: £{tax_change / 1e9:.3f}bn") # Income tax change should be small and approximately neutral. - # The reform redirects excess salary sacrifice to employee pension - # contributions (which get income tax relief), while the total pension - # input for Annual Allowance purposes stays the same (no AA charge - # difference). Net effect is a small income tax decrease. + # NIC reclassification preserves actual pension input and ordinary pay. + # The unchanged broad-base haircut remains a separate economic effect. assert abs(tax_change) < 1e9, ( f"Income tax change should be small (<£1bn), got £{tax_change / 1e9:.3f}bn" ) @pytest.mark.microsimulation -def test_excess_redirected_to_pension(reform_simulation): +def test_excess_included_in_ni_earnings(reform_simulation): """ - Test that full excess is redirected to employee pension contributions. + Test that full excess is included in NIC earnings. - The blog assumes employees maintain their total pension contributions - by redirecting the full excess to regular pension contributions. + Physical sacrifice remains an employer pension contribution. """ redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR + "salary_sacrifice_pension_ni_excess", POLICY_YEAR ).sum() # Should be significant (blog says £13.8bn excess - updated to current model) @@ -215,12 +211,11 @@ def test_affected_population(reform_simulation): @pytest.mark.microsimulation -def test_full_excess_redirected(reform_simulation): +def test_full_excess_included_in_ni_earnings(reform_simulation): """ - Test that the full excess is redirected (no targeted haircut). + Test that the full excess is included in NIC earnings (no targeted haircut). - The broad-base haircut reduces ALL workers' employment income, - but the full excess above cap is redirected to regular pension contributions. + The separate broad-base haircut still reduces ALL workers' employment income. """ # Get weighted totals using map_to for proper aggregation. Only people # with pay have a salary sacrifice, so the excess is measured on that. @@ -231,7 +226,7 @@ def test_full_excess_redirected(reform_simulation): ) weights = reform_simulation.calculate("person_weight", POLICY_YEAR) redirected = reform_simulation.calculate( - "salary_sacrifice_returned_to_income", POLICY_YEAR, map_to="person" + "salary_sacrifice_pension_ni_excess", POLICY_YEAR, map_to="person" ) cap = 2000 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..6c065272de 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 @@ -91,7 +91,7 @@ # £100 excess * 8% = £8 salary_sacrifice_pension_ni_employee: 100 * 0.08 -- name: Employee NI on full redirected excess +- name: Employee NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,8 +103,9 @@ output: # £1,000 excess * 8% = £80 salary_sacrifice_pension_ni_employee: (3_000 - 2_000) * 0.08 - # Full excess is redirected (no targeted haircut) - salary_sacrifice_returned_to_income: 1_000 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 - name: Broad-base haircut reduces all workers employment income period: 2025 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..669a26ce24 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 @@ -91,7 +91,7 @@ # £3,000 excess * 15% = £450 salary_sacrifice_pension_ni_employer: (5_000 - 2_000) * 0.15 -- name: Employer NI on full redirected excess +- name: Employer NI on full NIC-only excess period: 2025 absolute_error_margin: 0.01 input: @@ -103,5 +103,6 @@ output: # £1,000 excess * 15% = £150 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 + # Full excess enters NIC earnings (no targeted haircut) + salary_sacrifice_pension_ni_excess: 1_000 + salary_sacrifice_returned_to_income: 0 diff --git a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml index 1630f917da..0ed741ac8e 100644 --- a/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml +++ b/policyengine_uk/tests/policy/baseline/gov/hmrc/pensions/pension_annual_allowance.yaml @@ -241,11 +241,11 @@ pension_annual_allowance_threshold_income: 220_000 pension_annual_allowance: 40_000 -# The NICs cap redirects 28,000 to employee contributions and the broad-base -# haircut reduces pay by 280. Threshold income still adds back all 30,000: -# 202,720 - 28,000 + 30,000 = 204,720. Pension input remains 130,000, -# adjusted income is 202,720 + 130,000 - 28,000 = 304,720, and the charge -# is (130,000 - 37,640) * 45% = 41,562. +# The unchanged broad-base haircut reduces actual pay by 280. The 28,000 +# NICs excess creates neither ordinary pay nor a member payment. Threshold +# income still adds back all 30,000: 174,720 + 30,000 = 204,720. Pension +# input remains 130,000, adjusted income is 174,720 + 130,000 = 304,720, +# and the charge is (130,000 - 37,640) * 45% = 41,562. - name: The 2029 NICs cap does not limit the salary sacrifice threshold income addback period: 2029 input: @@ -254,8 +254,10 @@ pension_contributions_via_salary_sacrifice: 30_000 employer_pension_contributions: 100_000 output: - employment_income: 202_720 - pension_contributions_relief: 28_000 + employment_income: 174_720 + pension_contributions_relief: 0 + salary_sacrifice_pension_ni_excess: 28_000 + ni_class_1_income: 202_720 pension_contributions_for_annual_allowance: 130_000 pension_annual_allowance_threshold_income: 204_720 pension_annual_allowance_adjusted_income: 304_720 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 2f3573564d..8ac4e451ce 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,4 @@ -# From 6 April 2029 the salary sacrifice above the £2,000 cap returns to -# employment income. Only people with pay have a salary sacrifice, so the cap -# never gives pay to a person who has none. +# The cap creates NIC earnings only; ordinary pay/member payments stay unchanged. - name: No pay and a salary sacrifice input gives no employment income, 2029 period: 2029 @@ -34,7 +32,7 @@ salary_sacrifice_returned_to_income: 0 employment_income: 0 -- name: Pay above the sacrifice returns the excess over the cap, 2029 +- name: Pay above the sacrifice has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -43,11 +41,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 5,000 - 2,000 cap - salary_sacrifice_returned_to_income: 3_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 3_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 43_000 + employment_income: 40_000 -- name: A whole-bonus sacrifice above the remaining salary returns its full excess, 2030 +- name: A whole-bonus sacrifice above the remaining salary has its full NIC excess, 2030 period: 2030 absolute_error_margin: 0.01 input: @@ -56,11 +55,12 @@ gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: # 50,000 - 2,000 cap - salary_sacrifice_returned_to_income: 48_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 48_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 88_000 + employment_income: 40_000 -- name: Low pay with a large sacrifice still returns the excess over the cap, 2029 +- name: Low pay with a large sacrifice still has NIC-only excess over the cap, 2029 period: 2029 absolute_error_margin: 0.01 input: @@ -68,9 +68,10 @@ pension_contributions_via_salary_sacrifice: 30_000 gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_returned_to_income: 28_000 + salary_sacrifice_returned_to_income: 0 + salary_sacrifice_pension_ni_excess: 28_000 pension_contributions_via_salary_sacrifice_adjusted: 2_000 - employment_income: 29_500 + employment_income: 1_500 - name: Nothing returns before the cap starts, 2028 period: 2028 @@ -82,3 +83,63 @@ salary_sacrifice_returned_to_income: 0 pension_contributions_via_salary_sacrifice_adjusted: 5_000 employment_income: 40_000 + +# Issue #2173: cash pay is already after the contractual employer sacrifice. +# The unchanged 0.16% broad-base haircut reduces 97,000 by 155.20; the 8,000 +# NIC-only excess does not enter ANI or become a member pension payment. +- name: NI-only sacrifice excess preserves both childcare income tests, 2173 + period: 2029 + absolute_error_margin: 0.02 + input: + people: + parent: + age: 40 + employment_income_before_lsr: 97_000 + pension_contributions_via_salary_sacrifice: 10_000 + child: + age: 2 + benunits: + family: + members: [parent, child] + households: + household: + members: [parent, child] + region: LONDON + output: + employment_income: [96_844.80, 0] + adjusted_net_income: [96_844.80, 0] + ni_class_1_income: [104_844.80, 0] + salary_sacrifice_pension_ni_excess: [8_000, 0] + salary_sacrifice_returned_to_income: [0, 0] + employee_pension_contributions: [0, 0] + tax_free_childcare_meets_income_requirements: [true, false] + extended_childcare_entitlement_meets_income_requirements: [true, false] + +# Conditional supplied-ANI contract, paired with the parent's 2026 SSP case. +# The cap is inactive, so there is no broad-base haircut. The supplied return +# is actual pay/member cash: 19,500 + 500 pay and 21,500 + 500 member payments. +- name: Actual returned pay and SSP retain supplied-ANI annual allowance treatment + period: 2026 + absolute_error_margin: 0.01 + input: + region: LONDON + age: 50 + employment_income_before_lsr: 19_500 + salary_sacrifice_returned_to_income: 500 + statutory_sick_pay: 2_000 + statutory_maternity_pay: 0 + self_employment_income: 0 + employee_pension_contributions_reported: 21_500 + personal_pension_contributions: 0 + employer_pension_contributions: 0 + pension_contributions_via_salary_sacrifice: 0 + adjusted_net_income: 5_000 + output: + employment_income: 20_000 + employment_benefits: 2_000 + employee_pension_contributions: 22_000 + pension_contributions_relief: 22_000 + net_income: 27_000 + pension_contributions_for_annual_allowance: 22_000 + pension_annual_allowance_threshold_income: 5_000 + pension_annual_allowance_adjusted_income: 27_000 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 043e485ff6..cc51cdb718 100644 --- a/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py +++ b/policyengine_uk/tests/test_salary_sacrifice_needs_pay_properties.py @@ -1,31 +1,9 @@ -"""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). 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. 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 - 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 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. - -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. +"""Only people with pay have salary sacrifice and its NIC-only excess. + +The capped exempt amount plus NIC-only excess conserves the full employer +contribution. Ordinary employment income remains cash pay plus the existing +broad-base haircut. Generated pay, no-pay and whole-bonus inputs retain the +existing UKSimulation input convention and branch/reset compatibility. """ import numpy as np @@ -100,7 +78,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): adjusted = _values( simulation, "pension_contributions_via_salary_sacrifice_adjusted", year ) - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) employment_income = _values(simulation, "employment_income", year) haircut = _values(simulation, "salary_sacrifice_broad_base_haircut", year) cap = _cap(simulation, year) @@ -124,9 +102,9 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): # 4. Nothing is returned before the cap starts. if np.isinf(cap): assert np.all(returned == 0) - # 5. Accounting. + # 5. Ordinary cash-pay accounting; the excess is NIC-only. np.testing.assert_allclose( - employment_income, pays + returned + haircut, rtol=1e-6, atol=1e-2 + employment_income, pays + haircut, rtol=1e-6, atol=1e-2 ) # 6. Unchanged with pay. paid = pays > 0 @@ -141,7 +119,7 @@ def test_only_people_with_pay_have_a_salary_sacrifice(rows): @PROPERTY_SETTINGS @given(people) -def test_returned_income_never_falls_with_sacrifice_or_pay(rows): +def test_ni_excess_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]) @@ -154,7 +132,7 @@ def test_returned_income_never_falls_with_sacrifice_or_pay(rows): np.concatenate([sacrifices, sacrifices + more_sacrifice, sacrifices]), ) for year in YEARS: - returned = _values(simulation, "salary_sacrifice_returned_to_income", year) + returned = _values(simulation, "salary_sacrifice_pension_ni_excess", year) base, larger_sacrifice, larger_pay = ( returned[:n], returned[n : 2 * n], @@ -176,4 +154,4 @@ def test_pay_set_after_construction_keeps_the_sacrifice(): assert _values( simulation, "pension_contributions_via_salary_sacrifice_from_pay", 2030 ) == [6_000] - assert _values(simulation, "salary_sacrifice_returned_to_income", 2030) == [4_000] + assert _values(simulation, "salary_sacrifice_pension_ni_excess", 2030) == [4_000] diff --git a/policyengine_uk/tests/test_salary_sacrifice_ni_only.py b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py new file mode 100644 index 0000000000..b00849226e --- /dev/null +++ b/policyengine_uk/tests/test_salary_sacrifice_ni_only.py @@ -0,0 +1,388 @@ +"""The pension sacrifice cap changes NIC earnings, not pay or member payments. + +NI Act 2026 ss1–2 and ITEPA 2003 s308 preserve the employer contribution. +FA 2004 ss188/190/191 make a synthetic member-payment offset unsafe at 75 +and where relief is limited. Haircut zero isolates classification from the +separate, unchanged employer-incidence assumption. Cash pay is already net +of contractual sacrifice, as required by the existing UKSimulation adapter. +""" + +import numpy as np +import pytest + +from policyengine_uk import Simulation + +YEARS = (2028, 2029, 2030) +CAP = "gov.hmrc.national_insurance.salary_sacrifice_pension_cap" +HAIRCUT = "gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate" +# age, cash wages, employer sacrifice, actual net-pay member payment, gross RAS +CASES = [ + (80, 160_000, 30_000, 0, 0), + (80, 100_000, 30_000, 0, 0), + (75, 100_000, 30_000, 1_000, 0), + (74, 1_500, 30_000, 0, 3_600), + (40, 49_000, 5_000, 0, 0), + (40, 0, 30_000, 0, 0), + (40, 40_000, 2_000, 0, 0), + (40, 40_000, 1_500, 0, 0), + (40, 40_000, 5_000, 1_000, 2_000), +] + + +def simulations(): + people = {} + for i, (age, wages, sacrifice, member, ras) in enumerate(CASES): + people[f"p{i}"] = { + name: {str(year): value for year in YEARS} + for name, value in { + "age": age, + "employment_income": wages, + "pension_contributions_via_salary_sacrifice": sacrifice, + "employee_pension_contributions": member, + "personal_pension_contributions": ras, + "employer_pension_contributions": 2_000, + }.items() + } + situation = { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + isolated = {HAIRCUT: {"2028-01-01.2030-12-31": 0}} + return ( + Simulation(situation=situation, reform=isolated), + Simulation( + situation=situation, + reform={**isolated, CAP: {"2029-01-01.2030-12-31": float("inf")}}, + ), + ) + + +@pytest.fixture(scope="module") +def pair(): + return simulations() + + +def values(sim, variable, year): + return np.asarray(sim.calculate(variable, year), dtype=float) + + +def ordinary_tax(sim, year): + return values(sim, "income_tax", year) - values( + sim, "personal_pension_contributions_tax", year + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_nic_cap_preserves_ordinary_tax_pay_member_payments_and_allowances(pair, year): + capped, unlimited = pair + # These reach existing final variables, so RED must be a numerical failure, + # never a missing new variable or a forced final/intermediate value. + np.testing.assert_allclose( + ordinary_tax(capped, year), ordinary_tax(unlimited, year), atol=0.02, rtol=1e-6 + ) + for name in ( + "employment_income", + "employee_pension_contributions", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_relief", + ): + np.testing.assert_allclose( + values(capped, name, year), + values(unlimited, name, year), + atol=0.02, + rtol=1e-6, + err_msg=name, + ) + np.testing.assert_allclose( + values(capped, "employment_income", year), [c[1] for c in CASES] + ) + np.testing.assert_allclose( + values(capped, "employee_pension_contributions", year), [c[3] for c in CASES] + ) + assert values(capped, "pension_contributions_relief", year)[3] == 3_600 + + +def test_no_cap_before_2029_preserves_real_inputs(pair): + capped, unlimited = pair + np.testing.assert_allclose( + ordinary_tax(capped, 2028), ordinary_tax(unlimited, 2028) + ) + np.testing.assert_allclose( + values(capped, "employment_income", 2028), [c[1] for c in CASES] + ) + + +@pytest.mark.parametrize("year", (2029, 2030)) +def test_actual_class_1_curves_charge_the_excess_once(pair, year): + capped, unlimited = pair + excess = np.array([28_000, 28_000, 28_000, 28_000, 3_000, 0, 0, 0, 3_000]) + wages = np.array([c[1] for c in CASES]) + np.testing.assert_allclose( + values(capped, "ni_class_1_income", year), wages + excess + ) + 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. + def employee(earnings): + pt, uel = ( + p.thresholds.primary_threshold * 52, + p.thresholds.upper_earnings_limit * 52, + ) + liability = ( + np.minimum(np.maximum(earnings - pt, 0), uel - pt) * p.rates.employee.main + + np.maximum(earnings - uel, 0) * p.rates.employee.additional + ) + return np.where( + np.array([False, False, False, False, True, True, True, True, True]), + liability, + 0, + ) + + expected_employee = employee(wages + excess) - employee(wages) + actual_employee = values(capped, "national_insurance", year) - values( + unlimited, "national_insurance", year + ) + np.testing.assert_allclose(actual_employee, expected_employee, atol=0.02, rtol=1e-6) + st = p.thresholds.secondary_threshold * 52 + expected_employer = ( + np.maximum(wages + excess - st, 0) - np.maximum(wages - st, 0) + ) * p.rates.employer + np.testing.assert_allclose( + values(capped, "ni_class_1_employer", year) + - values(unlimited, "ni_class_1_employer", year), + expected_employer, + atol=0.02, + rtol=1e-6, + ) + # £49k crosses the UEL: each slice, rather than all £3k at one rate. + assert 0 < expected_employee[4] < 3_000 * p.rates.employee.main + + +def test_dc_inputs_and_final_household_effect_conserve_real_payments(pair): + capped, unlimited = pair + for year in (2029, 2030): + expected = [ + (member + ras if age < 75 else 0) + 2_000 + (sacrifice if wages > 0 else 0) + for age, wages, sacrifice, member, ras in CASES + ] + np.testing.assert_allclose( + values(capped, "pension_contributions_for_annual_allowance", year), expected + ) + np.testing.assert_allclose( + values(unlimited, "pension_contributions_for_annual_allowance", year), + expected, + ) + # At £49k there is no AA excess, no benefit entitlement and no relief: + # the cap's only final household effect is actual employee NICs. + i = 4 + ni_delta = ( + values(capped, "national_insurance", year)[i] + - values(unlimited, "national_insurance", year)[i] + ) + for name, sign in ( + ("household_tax", 1), + ("household_net_income", -1), + ("hbai_household_net_income", -1), + ): + actual = values(capped, name, year)[i] - values(unlimited, name, year)[i] + assert actual == pytest.approx(sign * ni_delta, abs=0.02), name + + +def test_explicit_ordinary_return_input_remains_supported(): + sim = Simulation( + situation={ + "people": { + "p": { + "age": {"2026": 40}, + "employment_income": {"2026": 40_000}, + "employee_pension_contributions": {"2026": 1_000}, + "salary_sacrifice_returned_to_income": {"2026": 500}, + } + }, + "benunits": {"b": {"members": ["p"]}}, + "households": {"h": {"members": ["p"]}}, + } + ) + assert values(sim, "employment_income", 2026)[0] == 40_500 + assert values(sim, "employee_pension_contributions", 2026)[0] == 1_500 + + +def test_ni_reporting_readers_retain_their_existing_approximation(pair): + capped, _ = 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. + 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) + assert values(capped, "salary_sacrifice_pension_ni_employer", year)[ + 0 + ] == pytest.approx(28_000 * p.rates.employer, abs=0.02) + assert np.all(values(capped, "salary_sacrifice_returned_to_income", year) == 0) + + +# A supplied legacy return is actual pay plus an actual member payment. It +# must agree with entering those amounts directly, including in the merged +# annual-allowance readers. These are distinct from a computed NICs excess. +ACTUAL_RETURN_CASES = [ + dict(age=40, pay=40_000, member=1_000, returned=500), + dict(age=75, pay=40_000, member=1_000, returned=500), + dict(age=74, pay=1_500, member=0, returned=500, ras=3_600), + dict(age=40, pay=40_000, member=1_000, returned=500, ani=39_000), + dict(age=75, pay=40_000, member=1_000, returned=500, ani=40_500), + dict(age=40, pay=200_000, member=5_000, returned=5_000, loss=400_000), + dict(age=40, pay=40_000, member=1_000, returned=500, dc=90_000), + dict( + age=40, + pay=200_000, + member=5_000, + returned=5_000, + employer=30_000, + sacrifice=30_000, + ), + dict(age=74, pay=40_000, member=1_000, returned=0, loss=100_000), +] +ACTUAL_RETURN_OUTPUTS = ( + "employment_income", + "employee_pension_contributions", + "pension_contributions", + "pension_contributions_relief", + "net_income", + "adjusted_net_income", + "personal_allowance", + "pension_contributions_for_annual_allowance", + "pension_annual_allowance_adjusted_income", + "pension_annual_allowance_threshold_income", + "pension_annual_allowance", + "personal_pension_contributions_tax", + "income_tax", + "ni_class_1_income", + "national_insurance", + "household_tax", + "household_net_income", + "hbai_household_net_income", +) + + +def actual_return_simulations(cases): + def situation(explicit_return): + people = {} + for i, case in enumerate(cases): + returned = case["returned"] + inputs = { + "age": case["age"], + "employment_income": case["pay"] + (0 if explicit_return else returned), + "employee_pension_contributions": case["member"] + + (0 if explicit_return else returned), + "personal_pension_contributions": case.get("ras", 0), + "employer_pension_contributions": case.get("employer", 0), + "pension_contributions_via_salary_sacrifice": case.get("sacrifice", 0), + } + if explicit_return: + inputs["salary_sacrifice_returned_to_income"] = returned + for key, variable in ( + ("ani", "adjusted_net_income"), + ("loss", "trading_loss"), + ("dc", "pension_contributions_for_annual_allowance"), + ): + if key in case: + inputs[variable] = case[key] + people[f"p{i}"] = { + name: {"2026": amount} for name, amount in inputs.items() + } + return { + "people": people, + "benunits": {f"b{i}": {"members": [name]} for i, name in enumerate(people)}, + "households": { + f"h{i}": {"members": [name], "region": "LONDON"} + for i, name in enumerate(people) + }, + } + + return tuple( + Simulation( + situation=situation(explicit_return), + reform={HAIRCUT: {"2026-01-01.2026-12-31": 0}}, + ) + for explicit_return in (True, False) + ) + + +@pytest.fixture(scope="module") +def actual_return_pair(): + return actual_return_groups() + + +def actual_return_groups(): + # A supplied variable is stored for the whole Person array. Keep natural + # cases separate from ANI and DC overrides so absent rows are not filled + # with zero inputs that mask the very formulas under test. + return [ + (indices, actual_return_simulations([ACTUAL_RETURN_CASES[i] for i in indices])) + for indices in ([0, 1, 2, 5, 7, 8], [3, 4], [6]) + ] + + +def actual_return_values(groups, member, variable): + result = np.empty(len(ACTUAL_RETURN_CASES)) + for indices, pair in groups: + result[indices] = values(pair[member], variable, 2026) + return result + + +@pytest.mark.parametrize("variable", ACTUAL_RETURN_OUTPUTS) +def test_explicit_actual_return_matches_actual_pay_and_member_inputs( + actual_return_pair, variable +): + np.testing.assert_allclose( + actual_return_values(actual_return_pair, 0, variable), + actual_return_values(actual_return_pair, 1, variable), + atol=0.02, + rtol=1e-6, + err_msg=variable, + ) + + +def test_explicit_actual_return_has_independent_aa_and_final_charge_oracles( + actual_return_pair, +): + # 2026 actual pay205000/member10000, employer30000 and sacrifice30000: + # DC70000; adjusted265000; threshold225000; taper(265000-260000)/2; + # allowance57500; excess12500, charged at the unchanged45% marginal rate. + expected = { + "employment_income": 205_000, + "employee_pension_contributions": 10_000, + "pension_contributions_for_annual_allowance": 70_000, + "pension_annual_allowance_adjusted_income": 265_000, + "pension_annual_allowance_threshold_income": 225_000, + "pension_annual_allowance": 57_500, + "personal_pension_contributions_tax": 5_625, + } + for member in (0, 1): + for variable, amount in expected.items(): + assert actual_return_values(actual_return_pair, member, variable)[ + 7 + ] == pytest.approx(amount, abs=0.02), variable + assert actual_return_values(actual_return_pair, member, "income_tax")[7] > 5_625 + + +def test_explicit_actual_return_is_query_order_independent(): + groups = actual_return_groups() + actual = { + name: actual_return_values(groups, 0, name) + for name in reversed(ACTUAL_RETURN_OUTPUTS) + } + for name in ACTUAL_RETURN_OUTPUTS: + np.testing.assert_allclose( + actual[name], actual_return_values(groups, 1, name), atol=0.02, rtol=1e-6 + ) diff --git a/policyengine_uk/utils/pensions.py b/policyengine_uk/utils/pensions.py index 4c9b81729c..0d052156e3 100644 --- a/policyengine_uk/utils/pensions.py +++ b/policyengine_uk/utils/pensions.py @@ -23,8 +23,7 @@ def pension_contributions_relief_amount( Only RAS can use the basic-amount increase above earnings (s. 191(7)). The annual allowance does not cap relief, and payments from age 75 are - unrelievable. Keeping this calculation shared lets the AA income tests - exclude the NICs-only salary-sacrifice reclassification exactly. + unrelievable. The AA income tests use the same actual-payment convention. """ basic_amount_increase = np.minimum( np.maximum( @@ -40,24 +39,23 @@ def pension_contributions_relief_amount( def annual_allowance_net_income(person, period, parameters): - """Model net income rebuilt without NICs-only returned salary sacrifice. + """Model net income with the annual-allowance net-pay/loss ordering. Like net_income, this is before the separately modelled pension relief. - ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction, not - subtracting returned pay after an income-dependent deduction was taken. - Ordinary income tax keeps its existing calculation (issue #2454). + ITA 2007 ss.24A, 25 and 64 require recomputing the loss deduction after + relievable net pay. A supplied legacy return is actual pay/member payment; + the computed NICs-only excess never enters income or contributions. """ from policyengine_core.model_api import add - returned = person("salary_sacrifice_returned_to_income", period) hmrc = parameters(period).gov.hmrc - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned + actual_member_contributions = person("pension_contributions", period) + actual_earnings = relevant_uk_earnings(person, period) supplied_ani = supplied_input(person, "adjusted_net_income", period) if supplied_ani is not None: # Supplied ANI already determines net income, including its losses. # Restore only Gift Aid and pension relief on actual earnings/payments; - # neither returned pay nor a replacement loss deduction belongs here. + # A replacement loss deduction does not belong on this input path. return ( supplied_ani + person("gift_aid_grossed_up", period) @@ -71,21 +69,16 @@ def annual_allowance_net_income(person, period, parameters): ) net_income = person("net_income", period) - actual_net_pay = person("employee_pension_contributions", period) - returned + actual_net_pay = person("employee_pension_contributions", period) relievable_net_pay = pension_contributions_relief_amount( actual_net_pay, actual_earnings, 0, person("age", period), hmrc ) - if not np.any(returned) and not np.any( - (relievable_net_pay > 0) & (person("trading_loss", period) > 0) - ): + if not np.any((relievable_net_pay > 0) & (person("trading_loss", period) > 0)): return net_income - taxable_pay = person("taxable_employment_income", period) - actual_taxable_pay = np.maximum(0, taxable_pay - returned) - removed_taxable_pay = taxable_pay - actual_taxable_pay + actual_taxable_pay = person("taxable_employment_income", period) income_before_loss = np.maximum( - 0, - person("net_income_before_trade_loss_relief", period) - removed_taxable_pay, + 0, person("net_income_before_trade_loss_relief", period) ) # Rebuild adjusted_total_income (s.24A(8)) from actual pay and member # contributions, retaining its treatment of brought-forward trade losses @@ -127,16 +120,13 @@ def annual_allowance_net_income(person, period, parameters): # below so the callers can deduct or restore member relief once. income_after_net_pay = np.maximum(0, income_before_loss - relievable_net_pay) actual_loss_relief = np.minimum(loss, np.minimum(loss_cap, income_after_net_pay)) - # Add back the loss computed using synthetic pay before deducting the - # actual-pay loss. Supplied ANI has its own path above. For other people, - # net-pay/loss ordering also applies before the NICs cap starts. + # Replace the original loss with the loss after relievable net pay. + # Supplied ANI has its own path above. This ordering also applies before + # the NICs cap starts and when the actual legacy return is zero. reconstructed = np.maximum( 0, net_income + person("trade_loss_relief_against_general_income", period) - - removed_taxable_pay - actual_loss_relief, ) - return np.where( - (returned > 0) | (relievable_net_pay > 0), reconstructed, net_income - ) + return np.where(relievable_net_pay > 0, reconstructed, net_income) diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py index a54fc2da39..184c12e3a9 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/class_1/ni_class_1_income.py @@ -10,6 +10,7 @@ class ni_class_1_income(Variable): unit = GBP adds = [ "employment_income", + "salary_sacrifice_pension_ni_excess", "statutory_sick_pay", "statutory_maternity_pay", "statutory_paternity_pay", 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..d9a8cce55d 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 @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employee(Variable): 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)." + "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." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employee(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" 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) + # 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 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..c20736b37c 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 @@ -6,8 +6,9 @@ class salary_sacrifice_pension_ni_employer(Variable): 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." + "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." ) entity = Person definition_period = YEAR @@ -16,9 +17,8 @@ class salary_sacrifice_pension_ni_employer(Variable): reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" 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) + # NIC-only earnings; this does not become an actual member payment. + 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 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py new file mode 100644 index 0000000000..452ebf4841 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_pension_ni_excess.py @@ -0,0 +1,26 @@ +from policyengine_uk.model_api import * + + +class salary_sacrifice_pension_ni_excess(Variable): + label = "Salary sacrifice pension contributions subject to NICs" + documentation = ( + "Employer pension contributions through salary sacrifice above the NICs " + "exemption cap. This is added only to Class 1 earnings: it is neither " + "cash employment income nor an actual member pension contribution. " + "The existing annual model samples the 2029-04-06 cap; payroll interval " + "rules and individual transaction dates are not modelled." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + reference = "https://www.legislation.gov.uk/ukpga/2026/15/section/1/enacted" + + def formula(person, period, parameters): + sacrifice = person( + "pension_contributions_via_salary_sacrifice_from_pay", period + ) + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + return max_(sacrifice - cap, 0) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py index 4d84d8c253..2d2a3ab5fe 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_adjusted_income.py @@ -13,8 +13,8 @@ class pension_annual_allowance_adjusted_income(Variable): "and exclude taxable lump-sum death benefits already included in " "income. The pension input amount can be supplied to include statutory " "defined-benefit accrual; its default is a cash-contribution proxy. " - "The NICs-only returned salary sacrifice is removed from both model " - "income and member contributions. All actual own contributions are " + "The NICs-only excess enters neither model income nor member " + "contributions. All actual own contributions are " "subtracted, including unrelievable contributions from age 75." ) definition_period = YEAR @@ -22,9 +22,8 @@ class pension_annual_allowance_adjusted_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/4" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_income = annual_allowance_net_income(person, period, parameters) - actual_member_contributions = person("pension_contributions", period) - returned + actual_member_contributions = person("pension_contributions", period) return ( actual_income + person("pension_contributions_for_annual_allowance", period) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py index 374353361c..1bcdba74a7 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_annual_allowance_threshold_income.py @@ -1,9 +1,5 @@ from policyengine_uk.model_api import * -from policyengine_uk.utils.pensions import ( - annual_allowance_net_income, - pension_contributions_relief_amount, - relevant_uk_earnings, -) +from policyengine_uk.utils.pensions import annual_allowance_net_income class pension_annual_allowance_threshold_income(Variable): @@ -11,9 +7,9 @@ class pension_annual_allowance_threshold_income(Variable): entity = Person label = "Threshold income for the pension annual allowance" documentation = ( - "FA 2004 s. 228ZA(5) threshold income. Remove the NICs-only returned " - "salary sacrifice and recompute income-dependent Step 2 relief on " - "actual member contributions and relevant earnings. This combines " + "FA 2004 s. 228ZA(5) threshold income. Use actual pay and member " + "contributions, retaining income-dependent Step 2 relief ordering. " + "The NICs-only excess is not ordinary income. This combines " "the net-pay deduction from statutory net income with gross RAS " "relief, including the basic amount and age limit. Add back full " "salary sacrifice before the NICs cap, except the supplied amount " @@ -26,7 +22,6 @@ class pension_annual_allowance_threshold_income(Variable): reference = "https://www.legislation.gov.uk/ukpga/2004/12/section/228ZA/5" def formula(person, period, parameters): - returned = person("salary_sacrifice_returned_to_income", period) actual_sacrifice = person( "pension_contributions_via_salary_sacrifice_from_pay", period ) @@ -34,25 +29,9 @@ def formula(person, period, parameters): 0, person("pension_contributions_via_salary_sacrifice_pre_2015", period) ) relevant_sacrifice = max_(0, actual_sacrifice - grandfathered) - # NICs reclassification changes neither income-tax earnings nor the - # actual member contributions. Its relief need not equal the returned - # amount: after 75 there is none, and low earnings can use basic RAS. - actual_member_contributions = person("pension_contributions", period) - returned - actual_earnings = relevant_uk_earnings(person, period) - returned - relief = where( - returned > 0, - pension_contributions_relief_amount( - actual_member_contributions, - actual_earnings, - person("personal_pension_contributions", period), - person("age", period), - parameters(period).gov.hmrc, - ), - person("pension_contributions_relief", period), - ) return ( annual_allowance_net_income(person, period, parameters) - - relief + - person("pension_contributions_relief", period) + relevant_sacrifice - person("pension_annual_allowance_taxable_lump_sum_death_benefits", period) ) diff --git a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py index 9463d75e7f..7c4eace161 100644 --- a/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py +++ b/policyengine_uk/variables/gov/hmrc/pensions/pension_contributions_for_annual_allowance.py @@ -33,12 +33,10 @@ class pension_contributions_for_annual_allowance(Variable): unit = GBP def formula(person, period, parameters): - # The returned sacrifice is still an employer payment in income-tax - # law. Remove it from member contributions before the age exclusion, - # then include the full employer sacrifice once at every age. - member_contributions = person("pension_contributions", period) - person( - "salary_sacrifice_returned_to_income", period - ) + # A supplied ordinary return is an actual member payment, while the + # NICs-only excess is not. Include full employer sacrifice separately + # once at every age, retaining the age exclusion for member payments. + member_contributions = person("pension_contributions", period) under_age_limit = ( person("age", period) < parameters( 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 3334cfa70c..09184f00c9 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 @@ -4,10 +4,10 @@ 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 " + "The NIC-exempt amount of salary sacrifice pension contributions after " "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." + "Contributions above the cap remain employer pension contributions, " + "but are subject to NICs through salary_sacrifice_pension_ni_excess." ) entity = Person definition_period = YEAR @@ -28,5 +28,5 @@ def formula(person, period, parameters): return intended_ss # Cap the salary sacrifice at the limit - # Excess is redirected to regular pension contributions + # The excess is separately included in NIC earnings only. return min_(intended_ss, 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 index 62cb1a357e..fd8ec12eda 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 @@ -11,9 +11,9 @@ class pension_contributions_via_salary_sacrifice_from_pay(Variable): "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." + "above the National Minimum Wage. The cap includes excess sacrifice " + "only in NIC earnings. Without this pay condition a sacrifice " + "input with no pay behind it would create a NIC earnings base." ) entity = Person definition_period = YEAR 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 72c7ce0335..386eb3f960 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 @@ -2,44 +2,20 @@ class salary_sacrifice_returned_to_income(Variable): - label = "Amount of salary sacrifice redirected to employee pension contributions" + """Legacy supplied input, defaulting to zero; never computed by the model.""" + + label = "Salary sacrifice actually returned to ordinary income" documentation = ( - "The amount of excess salary sacrifice (above the cap) that is redirected " - "to regular employee pension contributions. This maintains total pension savings " - "while subjecting the excess to National Insurance (but not income tax, since " - "regular pension contributions receive income tax relief). " - "\n\n" - "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" - "Only people with pay have a salary sacrifice " - "(pension_contributions_via_salary_sacrifice_from_pay), so nothing is " - "returned to a person without pay." + "Legacy ordinary-pay/member-payment input, defaulting to zero. The " + "pension salary-sacrifice cap changes only NIC earnings, through " + "salary_sacrifice_pension_ni_excess; it does not return cash pay or " + "create a member contribution. Explicit inputs remain supported for " + "callers representing an actual return to ordinary pay and member " + "payments under the existing input convention." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" - - def formula(person, period, parameters): - intended_ss = person( - "pension_contributions_via_salary_sacrifice_from_pay", period - ) - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, no excess to redirect - if np.isinf(cap): - return 0 - - # Calculate excess above cap - full excess is redirected to regular - # employee pension contributions (no targeted haircut on the individual) - excess = max_(intended_ss - cap, 0) - - # Full excess is redirected to employment income (and then to employee - # pension contributions for income tax relief). The employer NI cost - # increase is spread across ALL workers via salary_sacrifice_broad_base_haircut. - return excess + default_value = 0 + reference = "https://www.legislation.gov.uk/ukpga/2003/1/section/308" diff --git a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py index b0da36b0bf..f7aba9e1ba 100644 --- a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py +++ b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py @@ -4,8 +4,9 @@ class employee_pension_contributions(Variable): label = "employee pension contributions" documentation = ( - "Total employee pension contributions including reported contributions " - "and any excess salary sacrifice redirected to regular pension contributions." + "Actual reported employee pension contributions, plus any explicitly " + "supplied legacy return to ordinary pay/member payments. The " + "NIC-only salary-sacrifice excess is not a member contribution." ) entity = Person definition_period = YEAR