diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..807d5983d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ + +# Use bd merge for beads JSONL files +.beads/issues.jsonl merge=beads diff --git a/policyengine_uk/parameters/gov/contrib/behavioral_responses/salary_sacrifice_broad_base_haircut_rate.yaml b/policyengine_uk/parameters/gov/contrib/behavioral_responses/salary_sacrifice_broad_base_haircut_rate.yaml new file mode 100644 index 000000000..41b6d9d69 --- /dev/null +++ b/policyengine_uk/parameters/gov/contrib/behavioral_responses/salary_sacrifice_broad_base_haircut_rate.yaml @@ -0,0 +1,22 @@ +# The rate at which ALL workers' employment income is reduced due to the salary sacrifice cap. +# When the salary sacrifice cap is active, employers face increased NI costs on excess contributions. +# They spread these costs across ALL employees (not just affected workers), reducing everyone's pay. +# +# Calculation from blog (https://policyengine.org/uk/research/uk-salary-sacrifice-cap): +# - Total excess above £2,000 cap: £13.8 billion +# - Employer NI on excess (at 15%): £2.1 billion +# - Total UK employment income: ~£1.3 trillion +# - Broad-base haircut rate: £2.1bn / £1.3tn ≈ 0.0016 (0.16%) +# +# This is applied to ALL workers' employment income, not just salary sacrificers. +# The economic logic: employers cannot target only affected workers (who would negotiate +# to recoup the loss), so they spread the cost across all employees. +description: Rate at which all workers' employment income is reduced when salary sacrifice cap is active. +values: + 2010-01-01: 0.0016 +metadata: + unit: /1 + label: Salary sacrifice broad-base haircut rate + reference: + - title: PolicyEngine UK Salary Sacrifice Cap Analysis + href: https://policyengine.org/uk/research/uk-salary-sacrifice-cap diff --git a/policyengine_uk/simulation.py b/policyengine_uk/simulation.py index 236d58af2..301219595 100644 --- a/policyengine_uk/simulation.py +++ b/policyengine_uk/simulation.py @@ -123,6 +123,10 @@ def __init__( self.move_values("capital_gains", "capital_gains_before_response") self.move_values("employment_income", "employment_income_before_lsr") + self.move_values( + "employee_pension_contributions", + "employee_pension_contributions_reported", + ) self.input_variables = self.get_known_variables() diff --git a/policyengine_uk/tests/microsimulation/reforms_config.yaml b/policyengine_uk/tests/microsimulation/reforms_config.yaml index 246e0e271..a93c8f056 100644 --- a/policyengine_uk/tests/microsimulation/reforms_config.yaml +++ b/policyengine_uk/tests/microsimulation/reforms_config.yaml @@ -16,7 +16,7 @@ reforms: parameters: gov.hmrc.child_benefit.amount.additional: 25 - name: Reduce Universal Credit taper rate to 20% - expected_impact: -29.3 + expected_impact: -29.4 parameters: gov.dwp.universal_credit.means_test.reduction_rate: 0.2 - name: Raise Class 1 main employee NICs rate to 10% @@ -24,7 +24,7 @@ reforms: parameters: gov.hmrc.national_insurance.class_1.rates.employee.main: 0.1 - name: Raise VAT standard rate by 2pp - expected_impact: 20.7 + expected_impact: 20.9 parameters: gov.hmrc.vat.standard_rate: 0.22 - name: Raise additional rate by 3pp diff --git a/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py new file mode 100644 index 000000000..80d517dfc --- /dev/null +++ b/policyengine_uk/tests/microsimulation/test_salary_sacrifice_cap_reform.py @@ -0,0 +1,401 @@ +""" +Test suite for salary sacrifice pension cap reform fiscal impacts. + +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 +- Broad-base haircut: employers spread NI costs across ALL workers (~0.16% of employment income) +- Revenue comes from NI on the redirected excess + +Expected revenue: ~£3.3 billion in 2029-30 + +Reference: https://policyengine.org/uk/research/uk-salary-sacrifice-cap +""" + +import pytest +import numpy as np +import pandas as pd +from policyengine_uk import Microsimulation + + +# Policy year when the salary sacrifice cap takes effect +POLICY_YEAR = 2030 # Use 2030 to ensure cap is active (cap starts 2029-04-06) + +# Expected revenue impact in billions (from blog) +# PolicyEngine baseline estimate: £3.3 billion +# OBR static estimate: £4.9 billion +# OBR post-behavioural: £4.7 billion +EXPECTED_REVENUE_BILLION = 3.3 +TOLERANCE_BILLION = ( + 1.5 # Allow reasonable tolerance for year/methodology differences +) + + +def _create_no_cap_baseline(): + """Create baseline simulation without the salary sacrifice cap.""" + return { + "gov.hmrc.national_insurance.salary_sacrifice_pension_cap": { + "2029": float("inf"), + "2030": float("inf"), + } + } + + +@pytest.fixture(scope="module") +def baseline_simulation(): + """Create baseline simulation without the salary sacrifice cap.""" + return Microsimulation(reform=_create_no_cap_baseline()) + + +@pytest.fixture(scope="module") +def reform_simulation(): + """Create reform simulation with the £2,000 salary sacrifice cap. + + Current law already has the cap from 2029-04-06, so we use + a plain Microsimulation without additional reform parameters. + """ + return Microsimulation() + + +def test_salary_sacrifice_cap_revenue_impact( + baseline_simulation, reform_simulation +): + """ + 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 + """ + baseline_gov_balance = baseline_simulation.calculate( + "gov_balance", POLICY_YEAR + ).sum() + reform_gov_balance = reform_simulation.calculate( + "gov_balance", POLICY_YEAR + ).sum() + + revenue_impact_billion = (reform_gov_balance - baseline_gov_balance) / 1e9 + + print(f"\nBaseline gov_balance: £{baseline_gov_balance/1e9:.3f} billion") + print(f"Reform gov_balance: £{reform_gov_balance/1e9:.3f} billion") + print(f"Revenue impact: £{revenue_impact_billion:.3f} billion") + + # The reform should raise revenue (positive impact) + assert revenue_impact_billion > 0, ( + f"Salary sacrifice cap should raise revenue, " + f"but impact is {revenue_impact_billion:.2f} billion" + ) + + # Revenue should be approximately £3.3 billion + assert ( + abs(revenue_impact_billion - EXPECTED_REVENUE_BILLION) + < TOLERANCE_BILLION + ), ( + f"Salary sacrifice cap revenue is {revenue_impact_billion:.2f} billion, " + f"expected ~{EXPECTED_REVENUE_BILLION:.1f} billion " + f"(±{TOLERANCE_BILLION:.1f} billion tolerance)" + ) + + +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, + which is then subject to NI through the normal Class 1 calculation. + """ + baseline_ni = baseline_simulation.calculate( + "total_national_insurance", POLICY_YEAR + ).sum() + reform_ni = reform_simulation.calculate( + "total_national_insurance", POLICY_YEAR + ).sum() + + ni_increase = reform_ni - baseline_ni + + print(f"\nBaseline NI: £{baseline_ni/1e9:.3f}bn") + print(f"Reform NI: £{reform_ni/1e9:.3f}bn") + print(f"NI increase: £{ni_increase/1e9:.3f}bn") + + # NI should increase with the reform + assert ( + ni_increase > 0 + ), f"NI should increase with cap, but change is £{ni_increase/1e9:.3f}bn" + + # NI increase should be significant (at least £1bn) + assert ( + ni_increase > 1e9 + ), f"NI increase should be >£1bn, got £{ni_increase/1e9:.3f}bn" + + +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. + """ + baseline_tax = baseline_simulation.calculate( + "income_tax", POLICY_YEAR + ).sum() + reform_tax = reform_simulation.calculate("income_tax", POLICY_YEAR).sum() + + tax_change = reform_tax - baseline_tax + + print(f"\nBaseline income tax: £{baseline_tax/1e9:.3f}bn") + print(f"Reform income tax: £{reform_tax/1e9:.3f}bn") + print(f"Income tax change: £{tax_change/1e9:.3f}bn") + + # Income tax should increase slightly (due to pension relief caps) + # Expected to be around £1-2bn + assert ( + tax_change > 0 + ), f"Income tax should increase, got £{tax_change/1e9:.3f}bn" + assert ( + tax_change < 3e9 + ), f"Income tax increase should be <£3bn, got £{tax_change/1e9:.3f}bn" + + +def test_excess_redirected_to_pension(reform_simulation): + """ + Test that full excess is redirected to employee pension contributions. + + The blog assumes employees maintain their total pension contributions + by redirecting the full excess to regular pension contributions. + """ + redirected = reform_simulation.calculate( + "salary_sacrifice_returned_to_income", POLICY_YEAR + ).sum() + + # Should be significant (blog says £13.8bn excess - full amount redirected) + assert ( + redirected > 12e9 + ), f"Redirected amount should be >£12bn, got £{redirected/1e9:.2f}bn" + + +def test_salary_sacrifice_data_exists(reform_simulation): + """ + Test that salary sacrifice data exists in the simulation. + + Blog: 4.9 million workers with SS contributions, £22.7 billion total. + """ + ss_contributions = reform_simulation.calculate( + "pension_contributions_via_salary_sacrifice", POLICY_YEAR + ) + + total_ss = ss_contributions.sum() + num_contributors = (ss_contributions > 0).sum() + + # Should have significant SS contributions + assert ( + total_ss > 20e9 + ), f"Total SS contributions should be >£20bn, got £{total_ss/1e9:.2f}bn" + assert ( + num_contributors > 4e6 + ), f"Should have >4 million contributors, got {num_contributors/1e6:.1f}m" + + +def test_affected_population(reform_simulation): + """ + Test that a reasonable number of people are affected by the cap. + + Blog: 3.3 million workers exceed the £2,000 cap (68% of contributors). + """ + ss_contributions = reform_simulation.calculate( + "pension_contributions_via_salary_sacrifice", POLICY_YEAR + ) + + cap = 2000 + affected_count = (ss_contributions > cap).sum() + + # Should be around 3.3 million + assert ( + affected_count > 2.5e6 + ), f"Expected >2.5 million affected, got {affected_count/1e6:.1f}m" + assert ( + affected_count < 5e6 + ), f"Expected <5 million affected, got {affected_count/1e6:.1f}m" + + +def test_full_excess_redirected(reform_simulation): + """ + Test that the full excess is redirected (no targeted haircut). + + 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 + ss_contributions = reform_simulation.calculate( + "pension_contributions_via_salary_sacrifice", + POLICY_YEAR, + map_to="person", + ) + weights = reform_simulation.calculate("person_weight", POLICY_YEAR) + redirected = reform_simulation.calculate( + "salary_sacrifice_returned_to_income", POLICY_YEAR, map_to="person" + ) + + cap = 2000 + raw_excess = (np.maximum(ss_contributions - cap, 0) * weights).sum() + redirected_total = (redirected * weights).sum() + + # Redirected should be 100% of raw excess (no targeted haircut) + ratio = redirected_total / raw_excess if raw_excess > 0 else 0 + + assert 0.95 < ratio < 1.05, ( + f"Full excess should be redirected (ratio ~1.0). " + f"Raw excess: £{raw_excess/1e9:.2f}bn, " + f"Redirected: £{redirected_total/1e9:.2f}bn, " + f"Ratio: {ratio:.2f}" + ) + + +def test_broad_base_haircut_affects_all_workers(reform_simulation): + """ + Test that the broad-base haircut reduces employment income for all workers. + + The broad-base haircut (default 0.16%) applies to ALL workers, + not just those with salary sacrifice. + """ + haircut = reform_simulation.calculate( + "salary_sacrifice_broad_base_haircut", POLICY_YEAR + ) + weights = reform_simulation.calculate("person_weight", POLICY_YEAR) + employment_income = reform_simulation.calculate( + "employment_income_before_lsr", POLICY_YEAR + ) + + # All workers with employment income should have a haircut + has_employment = employment_income > 0 + has_haircut = haircut < 0 # Haircut is negative + + # Count workers with employment income but no haircut + workers_with_employment = (has_employment * weights).sum() + workers_with_haircut = (has_haircut * weights).sum() + + print( + f"\nWorkers with employment income: {workers_with_employment/1e6:.1f}m" + ) + print(f"Workers with haircut: {workers_with_haircut/1e6:.1f}m") + + # Most workers with employment income should have a haircut + haircut_coverage = workers_with_haircut / workers_with_employment + assert haircut_coverage > 0.9, ( + f"Broad-base haircut should affect most workers, " + f"but only {haircut_coverage:.1%} are affected" + ) + + +def test_decile_impact_negative_for_higher_earners( + baseline_simulation, reform_simulation +): + """ + Test that higher income deciles experience negative income changes. + + The salary sacrifice cap reduces household net income for affected workers + (those with SS contributions > £2,000) because they now pay NI on the excess. + Higher deciles should have larger negative impacts as they're more likely + to have high salary sacrifice contributions. + + This validates the distributional impact methodology used in uk-budget-data. + """ + # Calculate household net income for baseline and reform + baseline_income = baseline_simulation.calculate( + "household_net_income", period=POLICY_YEAR, map_to="household" + ) + reform_income = reform_simulation.calculate( + "household_net_income", period=POLICY_YEAR, map_to="household" + ) + household_decile = baseline_simulation.calculate( + "household_income_decile", period=POLICY_YEAR, map_to="household" + ) + household_weight = baseline_simulation.calculate( + "household_weight", period=POLICY_YEAR, map_to="household" + ) + + # Build decile DataFrame + decile_df = pd.DataFrame( + { + "household_income_decile": household_decile.values, + "baseline_income": baseline_income.values, + "reform_income": reform_income.values, + "income_change": (reform_income - baseline_income).values, + "household_weight": household_weight.values, + } + ) + decile_df = decile_df[decile_df["household_income_decile"] >= 1] + + # Calculate weighted relative change by decile + decile_names = [ + "1st", + "2nd", + "3rd", + "4th", + "5th", + "6th", + "7th", + "8th", + "9th", + "10th", + ] + results = [] + + for decile_num in range(1, 11): + decile_data = decile_df[ + decile_df["household_income_decile"] == decile_num + ] + if len(decile_data) > 0: + weighted_change = ( + decile_data["income_change"] * decile_data["household_weight"] + ).sum() + weighted_baseline = ( + decile_data["baseline_income"] + * decile_data["household_weight"] + ).sum() + rel_change = ( + (weighted_change / weighted_baseline) * 100 + if weighted_baseline > 0 + else 0 + ) + results.append( + { + "decile": decile_names[decile_num - 1], + "decile_num": decile_num, + "rel_change_pct": rel_change, + "abs_change": weighted_change, + } + ) + + print("\nDecile Impact (relative % change in household net income):") + for r in results: + print(f" {r['decile']}: {r['rel_change_pct']:.3f}%") + + # Higher deciles (8th, 9th, 10th) should have negative impacts + # These workers are more likely to have high salary sacrifice + high_decile_results = [r for r in results if r["decile_num"] >= 8] + + for r in high_decile_results: + assert r["rel_change_pct"] < 0, ( + f"Decile {r['decile']} should have negative impact " + f"(losers from reform), got {r['rel_change_pct']:.3f}%" + ) + + # The 10th decile should have the largest negative impact + decile_10 = next(r for r in results if r["decile_num"] == 10) + assert decile_10["rel_change_pct"] < -0.1, ( + f"10th decile should have significant negative impact (<-0.1%), " + f"got {decile_10['rel_change_pct']:.3f}%" + ) + + # Overall impact should be negative (reform takes money from households) + total_change = sum(r["abs_change"] for r in results) + print(f"\nTotal household income change: £{total_change/1e9:.3f}bn") + assert total_change < 0, ( + f"Total household income should decrease, " + f"got £{total_change/1e9:.3f}bn change" + ) 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 1b03d184f..39785efd9 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 @@ -5,7 +5,7 @@ employment_income: 40_000 pension_contributions_via_salary_sacrifice: 1_500 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: salary_sacrifice_pension_ni_employee: 0 @@ -16,9 +16,10 @@ employment_income: 45_000 pension_contributions_via_salary_sacrifice: 2_250 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: (2_250 - 2_000) * 0.08 # £250 excess * 8% + # £250 excess * 8% = £20 + salary_sacrifice_pension_ni_employee: (2_250 - 2_000) * 0.08 - name: Employee NI charge at 8% main rate for earner just below UEL period: 2025 @@ -27,9 +28,10 @@ employment_income: 50_000 pension_contributions_via_salary_sacrifice: 10_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: (10_000 - 2_000) * 0.08 # £8,000 excess * 8% + # £8,000 excess * 8% = £640 + salary_sacrifice_pension_ni_employee: (10_000 - 2_000) * 0.08 - name: Employee NI charge at 2% additional rate for high earner above UEL period: 2025 @@ -38,9 +40,10 @@ employment_income: 125_000 pension_contributions_via_salary_sacrifice: 25_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: (25_000 - 2_000) * 0.02 # £23,000 excess * 2% + # £23,000 excess * 2% = £460 + salary_sacrifice_pension_ni_employee: (25_000 - 2_000) * 0.02 - name: Employee NI charge at 2% for earner well above UEL period: 2025 @@ -49,9 +52,10 @@ employment_income: 80_000 pension_contributions_via_salary_sacrifice: 5_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: (5_000 - 2_000) * 0.02 # £3,000 excess * 2% + # £3,000 excess * 2% = £60 + salary_sacrifice_pension_ni_employee: (5_000 - 2_000) * 0.02 - name: No employee NI charge when exactly at cap period: 2025 @@ -60,7 +64,7 @@ employment_income: 60_000 pension_contributions_via_salary_sacrifice: 2_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: salary_sacrifice_pension_ni_employee: 0 @@ -71,19 +75,33 @@ employment_income: 35_000 pension_contributions_via_salary_sacrifice: 2_100 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: 100 * 0.08 # £100 excess * 8% + # £100 excess * 8% = £8 + salary_sacrifice_pension_ni_employee: 100 * 0.08 -- name: Employee optimizes salary sacrifice to cap (100% behavioral response) +- name: Employee NI on full redirected excess period: 2025 absolute_error_margin: 0.01 input: employment_income: 45_000 pension_contributions_via_salary_sacrifice: 3_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 1.0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employee: 0 - pension_contributions_via_salary_sacrifice_adjusted: 2_000 + # £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 + +- name: Broad-base haircut reduces all workers employment income + period: 2025 + absolute_error_margin: 1 + input: + employment_income_before_lsr: 50_000 + pension_contributions_via_salary_sacrifice: 0 + gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0.0016 + output: + # Worker with no salary sacrifice still has employment income reduced by 0.16% + salary_sacrifice_broad_base_haircut: -80 # -50_000 * 0.0016 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 f4b9bd74f..ac76d1cb4 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 @@ -5,7 +5,7 @@ employment_income: 40_000 pension_contributions_via_salary_sacrifice: 1_500 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: salary_sacrifice_pension_ni_employer: 0 @@ -16,9 +16,10 @@ employment_income: 45_000 pension_contributions_via_salary_sacrifice: 2_250 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: (2_250 - 2_000) * 0.15 # £250 excess * 15% + # £250 excess * 15% = £37.50 + salary_sacrifice_pension_ni_employer: (2_250 - 2_000) * 0.15 - name: Employer NI charge at 15% for high earner with large excess period: 2025 @@ -27,9 +28,10 @@ employment_income: 125_000 pension_contributions_via_salary_sacrifice: 25_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: (25_000 - 2_000) * 0.15 # £23,000 excess * 15% + # £23,000 excess * 15% = £3,450 + salary_sacrifice_pension_ni_employer: (25_000 - 2_000) * 0.15 - name: Employer NI charge for large salary sacrifice period: 2025 @@ -38,9 +40,10 @@ employment_income: 80_000 pension_contributions_via_salary_sacrifice: 15_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: (15_000 - 2_000) * 0.15 # £13,000 excess * 15% + # £13,000 excess * 15% = £1,950 + salary_sacrifice_pension_ni_employer: (15_000 - 2_000) * 0.15 - name: No employer NI charge when exactly at cap period: 2025 @@ -49,7 +52,7 @@ employment_income: 60_000 pension_contributions_via_salary_sacrifice: 2_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: salary_sacrifice_pension_ni_employer: 0 @@ -60,9 +63,10 @@ employment_income: 35_000 pension_contributions_via_salary_sacrifice: 2_100 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: 100 * 0.15 # £100 excess * 15% + # £100 excess * 15% = £15 + salary_sacrifice_pension_ni_employer: 100 * 0.15 - name: Employer NI charge for low earner with high pension contribution period: 2025 @@ -71,18 +75,21 @@ employment_income: 30_000 pension_contributions_via_salary_sacrifice: 5_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: (5_000 - 2_000) * 0.15 # £3,000 excess * 15% + # £3,000 excess * 15% = £450 + salary_sacrifice_pension_ni_employer: (5_000 - 2_000) * 0.15 -- name: No employer NI charge when employee optimizes (100% behavioral response) +- name: Employer NI on full redirected excess period: 2025 absolute_error_margin: 0.01 input: employment_income: 45_000 pension_contributions_via_salary_sacrifice: 3_000 gov.hmrc.national_insurance.salary_sacrifice_pension_cap: 2_000 - gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate: 1.0 + gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate: 0 output: - salary_sacrifice_pension_ni_employer: 0 - pension_contributions_via_salary_sacrifice_adjusted: 2_000 + # £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 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/national_insurance.py b/policyengine_uk/variables/gov/hmrc/national_insurance/national_insurance.py index d91df2967..429a27730 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/national_insurance.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/national_insurance.py @@ -14,5 +14,4 @@ class national_insurance(Variable): "ni_class_2", "ni_class_3", "ni_class_4", - "salary_sacrifice_pension_ni_employee", ] diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py new file mode 100644 index 000000000..54ab7ea41 --- /dev/null +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/salary_sacrifice_broad_base_haircut.py @@ -0,0 +1,43 @@ +from policyengine_uk.model_api import * + + +class salary_sacrifice_broad_base_haircut(Variable): + label = "Salary sacrifice broad-base employment income haircut" + documentation = ( + "Reduction in employment income for ALL workers due to employers spreading " + "the increased NI costs from the salary sacrifice cap across all employees. " + "This is a negative value that reduces employment_income. " + "\n\n" + "When the salary sacrifice cap is active, employers face increased NI costs " + "on excess contributions. They spread these costs across ALL employees (not " + "just salary sacrificers), as they cannot target only affected workers without " + "those workers negotiating to recoup the loss. " + "\n\n" + "See https://policyengine.org/uk/research/uk-salary-sacrifice-cap for methodology." + ) + 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): + cap = parameters( + period + ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap + + # If cap is infinite, no haircut applies + if np.isinf(cap): + return 0 + + # Get the broad-base haircut rate (applies to ALL workers) + haircut_rate = parameters( + period + ).gov.contrib.behavioral_responses.salary_sacrifice_broad_base_haircut_rate + + # Apply haircut to employment income before any salary sacrifice adjustments + # Use employment_income_before_lsr to avoid circular dependency + employment_income = person("employment_income_before_lsr", period) + + # Return negative value (this reduces employment income for everyone) + return -employment_income * haircut_rate 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 8206d1736..563009178 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 @@ -5,31 +5,20 @@ class salary_sacrifice_pension_ni_employee(Variable): label = "Employee NI on salary sacrifice pension contributions above cap" documentation = ( "Additional employee National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the £2,000 cap. " - "The excess is subject to standard NI rates: the main rate for " - "earnings below the Upper Earnings Limit, and the additional rate " - "for earnings above it." + "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)." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://docs.google.com/document/d/1Rhrfrg7A_oZHudmA775otAn1EE4-YthgeyS9nL-PrE8/edit?tab=t.0" + reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Use adjusted salary sacrifice after behavioral response - ss_contributions = person( - "pension_contributions_via_salary_sacrifice_adjusted", period - ) - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, scheme is inactive (no charge) - if np.isinf(cap): - return 0 - - excess = max_(ss_contributions - cap, 0) + # Get the excess that's redirected to regular pension + # This is the amount subject to NI + excess = person("salary_sacrifice_returned_to_income", period) # Use existing NI Class 1 parameters ni_params = parameters(period).gov.hmrc.national_insurance.class_1 @@ -39,7 +28,7 @@ def formula(person, period, parameters): ) # Apply appropriate NI rate based on income level - # Main rate (8%) for income ≤ UEL, additional rate (2%) for income > UEL + # Main rate (8%) for income <= UEL, additional rate (2%) for income > UEL ni_rate = where( employment_income <= upper_earnings_limit, ni_params.rates.employee.main, 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 ec3c01bb8..ed9de113a 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 @@ -5,29 +5,20 @@ class salary_sacrifice_pension_ni_employer(Variable): label = "Employer NI on salary sacrifice pension contributions above cap" documentation = ( "Additional employer National Insurance contributions due to " - "salary sacrifice pension contributions exceeding the £2,000 cap. " - "The excess is subject to the standard employer NI rate of 15%." + "salary sacrifice pension contributions exceeding the cap. " + "The full excess is redirected to regular pension contributions " + "and subject to employer NI." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://docs.google.com/document/d/1Rhrfrg7A_oZHudmA775otAn1EE4-YthgeyS9nL-PrE8/edit?tab=t.0" + reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): - # Use adjusted salary sacrifice after behavioral response - ss_contributions = person( - "pension_contributions_via_salary_sacrifice_adjusted", period - ) - cap = parameters( - period - ).gov.hmrc.national_insurance.salary_sacrifice_pension_cap - - # If cap is infinite, scheme is inactive (no charge) - if np.isinf(cap): - return 0 - - excess = max_(ss_contributions - cap, 0) + # Get the excess that's redirected to regular pension + # This is the amount subject to employer NI + excess = person("salary_sacrifice_returned_to_income", period) # Use existing NI Class 1 employer rate parameter ni_params = parameters(period).gov.hmrc.national_insurance.class_1 diff --git a/policyengine_uk/variables/gov/hmrc/national_insurance/total_national_insurance.py b/policyengine_uk/variables/gov/hmrc/national_insurance/total_national_insurance.py index 668f2b89c..e97222431 100644 --- a/policyengine_uk/variables/gov/hmrc/national_insurance/total_national_insurance.py +++ b/policyengine_uk/variables/gov/hmrc/national_insurance/total_national_insurance.py @@ -11,5 +11,4 @@ class total_national_insurance(Variable): adds = [ "national_insurance", "ni_class_1_employer", - "salary_sacrifice_pension_ni_employer", ] 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 e42ee5637..1a35f459d 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 @@ -2,19 +2,17 @@ class pension_contributions_via_salary_sacrifice_adjusted(Variable): - label = "Adjusted salary sacrifice pension contributions after behavioral response" + label = "Adjusted salary sacrifice pension contributions (capped)" documentation = ( - "The actual amount of salary sacrifice pension contributions after employees " - "adjust their behavior in response to the salary sacrifice cap. When the cap " - "is active and employees face NI charges on excess contributions, they may " - "reduce their salary sacrifice to the cap level. The reduction rate is " - "controlled by the employee_salary_sacrifice_reduction_rate parameter." + "The actual amount of salary sacrifice pension contributions after " + "applying the cap. Contributions above the cap are redirected to " + "regular employee pension contributions and subject to NI." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://docs.google.com/document/d/1Rhrfrg7A_oZHudmA775otAn1EE4-YthgeyS9nL-PrE8/edit?tab=t.0" + reference = "https://policyengine.org/uk/research/salary-sacrifice-cap" def formula(person, period, parameters): intended_ss = person( @@ -28,16 +26,6 @@ def formula(person, period, parameters): if np.isinf(cap): return intended_ss - # Calculate excess above cap - excess = max_(intended_ss - cap, 0) - - # Get behavioral response rate - reduction_rate = parameters( - period - ).gov.contrib.behavioral_responses.employee_salary_sacrifice_reduction_rate - - # Amount employee reduces their salary sacrifice - reduction = excess * reduction_rate - - # Adjusted salary sacrifice (cannot go below zero) - return max_(intended_ss - reduction, 0) + # Cap the salary sacrifice at the limit + # Excess is redirected to regular pension contributions + return min_(intended_ss, cap) 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 1cf50760a..e316979b9 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,25 +2,40 @@ class salary_sacrifice_returned_to_income(Variable): - label = "Amount of salary sacrifice returned to employment income" + label = "Amount of salary sacrifice redirected to employee pension contributions" documentation = ( - "The amount of salary sacrifice that is returned to regular employment income " - "when employees reduce their salary sacrifice in response to the pension cap. " - "This amount becomes subject to regular income tax and NI as employment income." + "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." ) entity = Person definition_period = YEAR value_type = float unit = GBP - reference = "https://docs.google.com/document/d/1Rhrfrg7A_oZHudmA775otAn1EE4-YthgeyS9nL-PrE8/edit?tab=t.0" + reference = "https://policyengine.org/uk/research/uk-salary-sacrifice-cap" def formula(person, period, parameters): intended_ss = person( "pension_contributions_via_salary_sacrifice", period ) - adjusted_ss = person( - "pension_contributions_via_salary_sacrifice_adjusted", 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) - # The difference is returned to employment income (cannot be negative) - return max_(intended_ss - adjusted_ss, 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 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 341eab20c..b0da36b0b 100644 --- a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py +++ b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions.py @@ -3,9 +3,15 @@ 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." + ) entity = Person definition_period = YEAR value_type = float unit = GBP - uprating = "gov.economic_assumptions.indices.obr.average_earnings" - adds = ["salary_sacrifice_returned_to_income"] + adds = [ + "employee_pension_contributions_reported", + "salary_sacrifice_returned_to_income", + ] diff --git a/policyengine_uk/variables/input/consumption/property/employee_pension_contributions_reported.py b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions_reported.py new file mode 100644 index 000000000..df551660e --- /dev/null +++ b/policyengine_uk/variables/input/consumption/property/employee_pension_contributions_reported.py @@ -0,0 +1,16 @@ +from policyengine_uk.model_api import * + + +class employee_pension_contributions_reported(Variable): + label = "employee pension contributions (reported)" + documentation = ( + "The reported employee pension contributions from survey data, " + "before any adjustments for salary sacrifice cap reforms. " + "Note: This variable is populated from the 'employee_pension_contributions' " + "field in the microdata via input aliasing." + ) + entity = Person + definition_period = YEAR + value_type = float + unit = GBP + uprating = "gov.economic_assumptions.indices.obr.average_earnings" diff --git a/policyengine_uk/variables/input/employment_income.py b/policyengine_uk/variables/input/employment_income.py index ad6a2666f..ce09eaa5e 100644 --- a/policyengine_uk/variables/input/employment_income.py +++ b/policyengine_uk/variables/input/employment_income.py @@ -15,5 +15,6 @@ class employment_income(Variable): "employment_income_behavioral_response", "employer_ni_fixed_employer_cost_change", "salary_sacrifice_returned_to_income", + "salary_sacrifice_broad_base_haircut", ] uprating = "gov.economic_assumptions.indices.obr.average_earnings" diff --git a/uv.lock b/uv.lock index 22f2fc5e0..f668d9358 100644 --- a/uv.lock +++ b/uv.lock @@ -1197,7 +1197,7 @@ wheels = [ [[package]] name = "policyengine-uk" -version = "2.61.2" +version = "2.65.0" source = { editable = "." } dependencies = [ { name = "microdf-python" },