diff --git a/changelog.d/fix-secondary-earnings-threshold-donut.fixed.md b/changelog.d/fix-secondary-earnings-threshold-donut.fixed.md new file mode 100644 index 00000000000..60f94ba55b4 --- /dev/null +++ b/changelog.d/fix-secondary-earnings-threshold-donut.fixed.md @@ -0,0 +1 @@ +Fix the CBO Social Security secondary earnings threshold reform to include self-employment earnings, avoid negative SECA and overlapping tax bases, activate for future-dated thresholds, and preserve Connecticut and Delaware paid-leave contribution bases. diff --git a/policyengine_us/parameters/gov/contrib/cbo/payroll/secondary_earnings_threshold.yaml b/policyengine_us/parameters/gov/contrib/cbo/payroll/secondary_earnings_threshold.yaml index bff5972a343..11d28a49ac0 100644 --- a/policyengine_us/parameters/gov/contrib/cbo/payroll/secondary_earnings_threshold.yaml +++ b/policyengine_us/parameters/gov/contrib/cbo/payroll/secondary_earnings_threshold.yaml @@ -1,4 +1,4 @@ -description: The US levies payroll taxes on earnings greater than this amount, in addition to earnings below the maximum taxable amount under current law. +description: The US levies payroll taxes on earnings greater than this amount, in addition to earnings below the maximum taxable amount under current law. With a fixed $250,000 threshold, the model's current-law cap of $254,400 closes the donut in 2035, one year before the 2036 date projected in CBO's 2024 option. values: 2021-01-01: .inf metadata: @@ -6,4 +6,4 @@ metadata: label: Social Security payroll tax secondary earnings threshold reference: - title: Increase the Maximum Taxable Earnings That Are Subject to Social Security Payroll Taxes, option alternative 2 - href: https://www.cbo.gov/budget-options/58630 + href: https://www.cbo.gov/budget-options/60955 diff --git a/policyengine_us/reforms/cbo/payroll/increase_taxable_earnings_for_social_security.py b/policyengine_us/reforms/cbo/payroll/increase_taxable_earnings_for_social_security.py index f7fab3fdf68..b4ec21d581e 100644 --- a/policyengine_us/reforms/cbo/payroll/increase_taxable_earnings_for_social_security.py +++ b/policyengine_us/reforms/cbo/payroll/increase_taxable_earnings_for_social_security.py @@ -8,20 +8,53 @@ class taxable_earnings_for_social_security(Variable): label = "Taxable gross earnings for OASDI FICA" definition_period = YEAR unit = USD + reference = ( + "https://www.cbo.gov/budget-options/60955", + "https://www.ssa.gov/OACT/solvency/provisions_tr2025/charts/chart_run418.html", + ) def formula(person, period, parameters): earnings = person("payroll_tax_gross_wages", period) p = parameters(period).gov - base_taxable = min_(earnings, p.irs.payroll.social_security.cap) - secondary_taxable = max_( - 0, - earnings - p.contrib.cbo.payroll.secondary_earnings_threshold, + cap = p.irs.payroll.social_security.cap + secondary_threshold = max_( + p.contrib.cbo.payroll.secondary_earnings_threshold, cap ) + base_taxable = min_(earnings, cap) + # The bases meet without overlapping once the cap closes the donut. + secondary_taxable = max_(0, earnings - secondary_threshold) + return base_taxable + secondary_taxable + + class social_security_taxable_self_employment_income(Variable): + value_type = float + entity = Person + label = "Taxable self-employment income for computing Social Security tax" + definition_period = YEAR + unit = USD + reference = ( + "https://www.cbo.gov/budget-options/60955", + "https://www.law.cornell.edu/uscode/text/26/1402#b", + "https://www.ssa.gov/OACT/solvency/provisions_tr2025/charts/chart_run418.html", + ) + + def formula(person, period, parameters): + earnings = person("taxable_self_employment_income", period) + wages = person("payroll_tax_gross_wages", period) + p = parameters(period).gov + cap = p.irs.payroll.social_security.cap + secondary_threshold = max_( + p.contrib.cbo.payroll.secondary_earnings_threshold, cap + ) + # Wages fill the combined base first, both below the cap and + # above the secondary threshold. Only the remaining SE is taxed. + base_taxable = min_(earnings, max_(0, cap - wages)) + secondary_taxable = max_(0, earnings - max_(0, secondary_threshold - wages)) return base_taxable + secondary_taxable class reform(Reform): def apply(self): self.update_variable(taxable_earnings_for_social_security) + self.update_variable(social_security_taxable_self_employment_income) return reform @@ -32,9 +65,10 @@ def create_increase_taxable_earnings_for_social_security_reform( if bypass: return create_increase_taxable_earnings_for_social_security() - p = parameters(period).gov.contrib.cbo.payroll - - if p.secondary_earnings_threshold < np.inf: + threshold = parameters.gov.contrib.cbo.payroll.secondary_earnings_threshold + # Inspect dated overrides, as in the CA Proposition 3 reform, rather + # than only the simulation's start instant or a fixed lookahead window. + if any(np.isfinite(value.value) for value in threshold.values_list): return create_increase_taxable_earnings_for_social_security() else: return None diff --git a/policyengine_us/tests/policy/baseline/gov/irs/self_employment/social_security_taxable_self_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/self_employment/social_security_taxable_self_employment_income.yaml new file mode 100644 index 00000000000..cd9866831c4 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/self_employment/social_security_taxable_self_employment_income.yaml @@ -0,0 +1,47 @@ +- name: Default is zero. + period: 2024 + output: + social_security_taxable_self_employment_income: 0 + +- name: Self-employment earnings below the remaining cap are fully taxable. + period: 2024 + input: + payroll_tax_gross_wages: 100_000 + taxable_self_employment_income: 50_000 + output: + social_security_taxable_self_employment_income: 50_000 + +- name: Self-employment earnings fill the remaining current-law cap. + period: 2024 + input: + payroll_tax_gross_wages: 100_000 + taxable_self_employment_income: 100_000 + output: + # The 168,600 cap leaves 68,600 after wages. + social_security_taxable_self_employment_income: 68_600 + +- name: Wages at the current-law cap leave no taxable self-employment earnings. + period: 2024 + input: + payroll_tax_gross_wages: 168_600 + taxable_self_employment_income: 50_000 + output: + social_security_taxable_self_employment_income: 0 + +- name: Wages above the current-law cap leave no taxable self-employment earnings. + period: 2024 + input: + payroll_tax_gross_wages: 400_000 + taxable_self_employment_income: 50_000 + output: + taxable_earnings_for_social_security: 168_600 + social_security_taxable_self_employment_income: 0 + +- name: A reform or direct input exceeding the cap cannot make self-employment tax negative. + period: 2024 + input: + taxable_earnings_for_social_security: 400_000 + taxable_self_employment_income: 50_000 + output: + social_security_taxable_self_employment_income: 0 + self_employment_social_security_tax: 0 diff --git a/policyengine_us/tests/policy/contrib/cbo/payroll/ct_employee_paid_leave_contribution.yaml b/policyengine_us/tests/policy/contrib/cbo/payroll/ct_employee_paid_leave_contribution.yaml new file mode 100644 index 00000000000..5de506f59af --- /dev/null +++ b/policyengine_us/tests/policy/contrib/cbo/payroll/ct_employee_paid_leave_contribution.yaml @@ -0,0 +1,14 @@ +- name: Connecticut paid leave retains the federal wage cap under the secondary threshold reform. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + absolute_error_margin: 0.01 + input: + state_code: CT + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 400_000 + output: + # The federal reform is active, but C.G.S. 31-49g(b)(1) retains the cap. + taxable_earnings_for_social_security: 342_300 + # 192,300 * 0.5% = 961.50. + ct_employee_paid_leave_contribution: 961.50 diff --git a/policyengine_us/tests/policy/contrib/cbo/payroll/de_employee_paid_leave_contribution.yaml b/policyengine_us/tests/policy/contrib/cbo/payroll/de_employee_paid_leave_contribution.yaml new file mode 100644 index 00000000000..b5192a1bcfc --- /dev/null +++ b/policyengine_us/tests/policy/contrib/cbo/payroll/de_employee_paid_leave_contribution.yaml @@ -0,0 +1,17 @@ +- name: Delaware paid leave retains the federal wage cap under the secondary threshold reform. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + absolute_error_margin: 0.01 + input: + state_code: DE + employer_headcount: 25 + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 400_000 + output: + # 19 Del. C. 3701(24) and 19 DE Admin. Code 1401, section 5.5 retain the cap. + taxable_earnings_for_social_security: 342_300 + de_paid_leave_contribution_rate: 0.008 + # Each share is 192,300 * (0.4% + 0.08% + 0.32%) * 50% = 769.20. + de_employee_paid_leave_contribution: 769.20 + de_employer_paid_leave_contribution: 769.20 diff --git a/policyengine_us/tests/policy/contrib/cbo/payroll/social_security_taxable_self_employment_income.yaml b/policyengine_us/tests/policy/contrib/cbo/payroll/social_security_taxable_self_employment_income.yaml new file mode 100644 index 00000000000..593baff9847 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/cbo/payroll/social_security_taxable_self_employment_income.yaml @@ -0,0 +1,81 @@ +- name: Self-employment fills the remaining cap and crosses the upper edge of the donut. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 180_000 + taxable_self_employment_income: 100_000 + output: + # Remaining cap = 12,300; combined earnings exceed 250,000 by 30,000. + social_security_taxable_self_employment_income: 42_300 + +- name: Combined earnings inside the donut do not add taxable self-employment earnings. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 200_000 + taxable_self_employment_income: 40_000 + output: + taxable_earnings_for_social_security: 192_300 + social_security_taxable_self_employment_income: 0 + +- name: Combined earnings exactly at the upper edge of the donut add no taxable self-employment earnings. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 200_000 + taxable_self_employment_income: 50_000 + output: + social_security_taxable_self_employment_income: 0 + +- name: The first dollar above the combined earnings threshold is taxable self-employment income. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 200_000 + taxable_self_employment_income: 50_001 + output: + social_security_taxable_self_employment_income: 1 + +- name: Self-employment earnings below the threshold fill only the remaining cap. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 100_000 + taxable_self_employment_income: 100_000 + output: + taxable_earnings_for_social_security: 100_000 + social_security_taxable_self_employment_income: 92_300 + +- name: Self-employment alone receives both the capped base and earnings above the threshold. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 0 + taxable_self_employment_income: 300_000 + output: + # 192,300 below the cap plus 50,000 above the threshold. + social_security_taxable_self_employment_income: 242_300 + +- name: Self-employment dollars are taxed once after the cap exceeds the threshold. + period: 2036 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 263_100 + payroll_tax_gross_wages: 240_000 + taxable_self_employment_income: 40_000 + output: + # 23,100 of remaining cap plus 16,900 above 263,100 equals all 40,000. + social_security_taxable_self_employment_income: 40_000 diff --git a/policyengine_us/tests/policy/contrib/cbo/payroll/taxable_earnings_for_social_security.yaml b/policyengine_us/tests/policy/contrib/cbo/payroll/taxable_earnings_for_social_security.yaml new file mode 100644 index 00000000000..a58310687d1 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/cbo/payroll/taxable_earnings_for_social_security.yaml @@ -0,0 +1,65 @@ +- name: Earnings above the donut threshold do not create negative SECA without self-employment income. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + absolute_error_margin: 0.01 + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 400_000 + self_employment_income: 0 + output: + # Wage base = 192,300 + (400,000 - 250,000) = 342,300. + taxable_earnings_for_social_security: 342_300 + social_security_taxable_self_employment_income: 0 + # Employee and employer each pay 342,300 * 6.2% = 21,222.60. + employee_social_security_tax: 21_222.60 + employer_social_security_tax: 21_222.60 + self_employment_social_security_tax: 0 + +- name: Self-employment earnings above the threshold join wages in the taxable base. + period: 2027 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + absolute_error_margin: 0.01 + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 192_300 + payroll_tax_gross_wages: 300_000 + self_employment_income: 50_000 + output: + # Wage base = 192,300 + (300,000 - 250,000) = 242,300. + taxable_earnings_for_social_security: 242_300 + # Net SE earnings = 50,000 * 0.9235 = 46,175, all above the threshold. + taxable_self_employment_income: 46_175 + social_security_taxable_self_employment_income: 46_175 + employee_social_security_tax: 15_022.60 + employer_social_security_tax: 15_022.60 + # SECA = 46,175 * 12.4% = 5,725.70. + self_employment_social_security_tax: 5_725.70 + +- name: A cap above the threshold taxes each dollar of wages once. + period: 2036 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + absolute_error_margin: 0.01 + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 263_100 + payroll_tax_gross_wages: 400_000 + self_employment_income: 0 + output: + # Wage base = 263,100 + (400,000 - 263,100) = 400,000. + taxable_earnings_for_social_security: 400_000 + social_security_taxable_self_employment_income: 0 + # Each FICA share = 400,000 * 6.2% = 24,800; SECA remains zero. + employee_social_security_tax: 24_800 + employer_social_security_tax: 24_800 + self_employment_social_security_tax: 0 + +- name: Wages between the threshold and a higher cap are taxed once. + period: 2036 + reforms: policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security.increase_taxable_earnings_for_social_security + input: + gov.contrib.cbo.payroll.secondary_earnings_threshold: 250_000 + gov.irs.payroll.social_security.cap: 263_100 + payroll_tax_gross_wages: 255_000 + output: + taxable_earnings_for_social_security: 255_000 diff --git a/policyengine_us/tests/policy/contrib/cbo/payroll/test_increase_taxable_earnings_for_social_security.py b/policyengine_us/tests/policy/contrib/cbo/payroll/test_increase_taxable_earnings_for_social_security.py new file mode 100644 index 00000000000..741b42c4a80 --- /dev/null +++ b/policyengine_us/tests/policy/contrib/cbo/payroll/test_increase_taxable_earnings_for_social_security.py @@ -0,0 +1,296 @@ +"""Activation and invariants that fixed policy examples cannot exercise. + +Only the activation test constructs a reformed model, once, for one person. +Hypothesis calls the actual variable formulas on small NumPy vectors with +read-only parameter stubs; it does not rebuild models or copy parameter trees. +""" + +from types import SimpleNamespace + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings, strategies as st +from policyengine_core.parameters import Parameter +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.reforms.cbo.payroll.increase_taxable_earnings_for_social_security import ( + create_increase_taxable_earnings_for_social_security, + create_increase_taxable_earnings_for_social_security_reform, +) +from policyengine_us.system import system +from policyengine_us.variables.gov.irs.tax.payroll.social_security.employee_social_security_tax import ( + employee_social_security_tax, +) +from policyengine_us.variables.gov.irs.tax.payroll.social_security.employer_social_security_tax import ( + employer_social_security_tax, +) +from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( + taxable_earnings_for_social_security, +) +from policyengine_us.variables.gov.irs.tax.self_employment.self_employment_social_security_tax import ( + self_employment_social_security_tax, +) +from policyengine_us.variables.gov.irs.tax.self_employment.social_security_taxable_self_employment_income import ( + social_security_taxable_self_employment_income, +) +from policyengine_us.variables.gov.states.ct.tax.payroll.paid_leave.ct_employee_paid_leave_contribution import ( + ct_employee_paid_leave_contribution, +) +from policyengine_us.variables.gov.states.de.tax.payroll.paid_leave.de_employee_paid_leave_contribution import ( + de_employee_paid_leave_contribution, +) +from policyengine_us.variables.gov.states.de.tax.payroll.paid_leave.de_employer_paid_leave_contribution import ( + de_employer_paid_leave_contribution, +) + +WAGE_BASE = "taxable_earnings_for_social_security" +SE_BASE = "social_security_taxable_self_employment_income" +TAXES = ( + "employee_social_security_tax", + "employer_social_security_tax", + "self_employment_social_security_tax", +) +PAID_LEAVE_CONTRIBUTIONS = ( + "ct_employee_paid_leave_contribution", + "de_employee_paid_leave_contribution", + "de_employer_paid_leave_contribution", +) +BASELINE_VARIABLES = { + variable.__name__: variable + for variable in ( + taxable_earnings_for_social_security, + social_security_taxable_self_employment_income, + employee_social_security_tax, + employer_social_security_tax, + self_employment_social_security_tax, + ct_employee_paid_leave_contribution, + de_employee_paid_leave_contribution, + de_employer_paid_leave_contribution, + ) +} + + +def test_future_threshold_activates_with_default_start_instant(): + reform = Reform.from_dict( + { + "gov.contrib.cbo.payroll.secondary_earnings_threshold": { + "2026-01-01.2100-12-31": 250_000 + } + }, + country_id="us", + ) + simulation = Simulation( + # The parameter reform gets an isolated clone of this baseline. + # Reuse it to avoid loading the country a second time; Simulation + # still performs structural detection with its default start_instant. + tax_benefit_system=system, + situation={ + "people": { + "person": { + "payroll_tax_gross_wages": {2025: 400_000, 2026: 400_000}, + "taxable_self_employment_income": {2025: 50_000, 2026: 50_000}, + } + } + }, + reform=reform, + ) + + # The public parameter-only path must discover a 2026 start even though + # Simulation's default start_instant is 2024-01-01. The wage base is the + # current-law cap in 2025, then cap + ($400,000 - $250,000) in 2026. + for year, extra_wages, expected_se in ((2025, 0, 0), (2026, 150_000, 50_000)): + cap = system.parameters(f"{year}-01-01").gov.irs.payroll.social_security.cap + assert simulation.calculate(WAGE_BASE, year)[0] == pytest.approx( + cap + extra_wages + ) + assert simulation.calculate(SE_BASE, year)[0] == pytest.approx(expected_se) + + baseline_payroll = system.parameters("2026-01-01").gov.contrib.cbo.payroll + assert np.isinf(baseline_payroll.secondary_earnings_threshold) + + +@pytest.mark.parametrize( + ("future_threshold", "expected_active"), + [(250_000, True), (np.inf, False)], + ids=["distant-finite-interval", "always-infinite"], +) +def test_factory_detects_distant_finite_threshold(future_threshold, expected_active): + threshold = Parameter( + "gov.contrib.cbo.payroll.secondary_earnings_threshold", + data={ + "2024-01-01": np.inf, + "2036-01-01": future_threshold, + "2037-01-01": np.inf, + }, + ) + + class Parameters(SimpleNamespace): + def __call__(self, period): + return _parameters(0, threshold(period))(period) + + parameters = Parameters( + gov=SimpleNamespace( + contrib=SimpleNamespace( + cbo=SimpleNamespace( + payroll=SimpleNamespace(secondary_earnings_threshold=threshold) + ) + ) + ) + ) + # A finite interval twelve years away still requires the override, even + # when the threshold is infinite both now and after that interval ends. + reform = create_increase_taxable_earnings_for_social_security_reform( + parameters, "2024-01-01" + ) + assert (reform is not None) is expected_active + + +@pytest.fixture(scope="module") +def reform_variables(): + # Capture exactly the variable classes installed by Reform.apply without + # constructing another full country model or mutating the shared system. + overrides = [] + reform = create_increase_taxable_earnings_for_social_security() + reform.apply(SimpleNamespace(update_variable=overrides.append)) + return BASELINE_VARIABLES | {variable.__name__: variable for variable in overrides} + + +def _parameters(cap, threshold, ct_rate=0.005, de_employee_share=0.5): + root = SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace( + payroll=SimpleNamespace( + social_security=SimpleNamespace( + cap=cap, + rate=SimpleNamespace(employee=0.062, employer=0.062), + ) + ), + self_employment=SimpleNamespace( + rate=SimpleNamespace(social_security=0.124) + ), + ), + contrib=SimpleNamespace( + cbo=SimpleNamespace( + payroll=SimpleNamespace(secondary_earnings_threshold=threshold) + ) + ), + states=SimpleNamespace( + ct=SimpleNamespace( + tax=SimpleNamespace( + payroll=SimpleNamespace( + paid_leave=SimpleNamespace(employee_rate=ct_rate) + ) + ) + ), + de=SimpleNamespace( + tax=SimpleNamespace( + payroll=SimpleNamespace( + paid_leave=SimpleNamespace(employee_share=de_employee_share) + ) + ) + ), + ), + ) + ) + return lambda period: root + + +def _calculate(variables, wages, net_self_employment, parameters, de_rate=0.008): + values = { + "payroll_tax_gross_wages": wages, + "taxable_self_employment_income": net_self_employment, + "de_paid_leave_contribution_rate": de_rate, + } + + def person(name, period): + if name not in values: + values[name] = variables[name].formula(person, period, parameters) + return values[name] + + for variable in (WAGE_BASE, SE_BASE, *TAXES, *PAID_LEAVE_CONTRIBUTIONS): + person(variable, "2027") + return values + + +CASE = st.tuples( + st.integers(min_value=0, max_value=2_000_000), # Gross payroll wages. + st.integers(min_value=0, max_value=2_000_000), # Net earnings subject to SECA. + st.integers(min_value=0, max_value=1_000_000), # Current-law wage cap. + st.one_of( + st.integers(min_value=0, max_value=1_000_000), + st.just(np.inf), + ), +) + + +@settings( + max_examples=150, + deadline=None, + derandomize=True, + # The first draw scans constants from thousands of loaded policy modules. + # Filesystem startup cost must not reduce the generated case coverage. + suppress_health_check=[HealthCheck.too_slow], +) +@given( + cases=st.lists(CASE, min_size=1, max_size=12), + ct_rate=st.floats(min_value=0, max_value=1), + de_rate=st.floats(min_value=0, max_value=1), + de_employee_share=st.floats(min_value=0, max_value=1), +) +@example( + cases=[ + (400_000, 0, 192_300, 250_000), + (300_000, 46_175, 192_300, 250_000), + (300_000, 100_000, 300_000, 250_000), + (400_000, 50_000, 192_300, np.inf), + ], + ct_rate=0.005, + de_rate=0.008, + de_employee_share=0.5, +) +def test_donut_invariants(reform_variables, cases, ct_rate, de_rate, de_employee_share): + wages, net_self_employment, cap, threshold = np.asarray(cases, dtype=float).T + parameters = _parameters(cap, threshold, ct_rate, de_employee_share) + baseline = _calculate( + BASELINE_VARIABLES, wages, net_self_employment, parameters, de_rate + ) + reformed = _calculate( + reform_variables, wages, net_self_employment, parameters, de_rate + ) + + # State paid-leave laws use the unchanged federal contribution and benefit + # base, even when the reform taxes wages above that base. Exercise their + # actual formulas with the same wages, cap, rates, and shares in both runs. + for variable in PAID_LEAVE_CONTRIBUTIONS: + np.testing.assert_array_equal(reformed[variable], baseline[variable]) + + baseline_tax = sum(baseline[variable] for variable in TAXES) + reformed_tax = sum(reformed[variable] for variable in TAXES) + assert np.all(reformed_tax >= baseline_tax - 1e-8) + assert np.all(reformed[SE_BASE] >= 0) + assert np.all(reformed["self_employment_social_security_tax"] >= 0) + assert np.all( + reformed[WAGE_BASE] + reformed[SE_BASE] <= wages + net_self_employment + 1e-8 + ) + + # Combining the separately allocated wage and SE bases must recover the + # full policy base: all earnings except the gap between cap and threshold. + earnings = wages + net_self_employment + expected_base = np.minimum(earnings, cap) + np.maximum( + 0, earnings - np.maximum(cap, threshold) + ) + np.testing.assert_allclose(reformed[WAGE_BASE] + reformed[SE_BASE], expected_base) + + # Under current law taxable wages never exceed the cap, so adding the + # zero floor preserves the old baseline SE base for every generated case. + pre_clamp_se_base = np.minimum(net_self_employment, cap - baseline[WAGE_BASE]) + np.testing.assert_array_equal(baseline[SE_BASE], pre_clamp_se_base) + + # An infinite threshold must remain a no-op when the structural override + # is active; this also protects the years before a future reform starts. + inactive = np.isinf(threshold) + for variable in (WAGE_BASE, SE_BASE, *TAXES): + np.testing.assert_array_equal( + reformed[variable][inactive], baseline[variable][inactive] + ) diff --git a/policyengine_us/variables/gov/irs/tax/self_employment/social_security_taxable_self_employment_income.py b/policyengine_us/variables/gov/irs/tax/self_employment/social_security_taxable_self_employment_income.py index 2d033b01157..6a2ed01a082 100644 --- a/policyengine_us/variables/gov/irs/tax/self_employment/social_security_taxable_self_employment_income.py +++ b/policyengine_us/variables/gov/irs/tax/self_employment/social_security_taxable_self_employment_income.py @@ -11,10 +11,11 @@ class social_security_taxable_self_employment_income(Variable): def formula(person, period, parameters): p = parameters(period).gov.irs.payroll.social_security - # This will not be negative, since - # taxable_earnings_for_social_security is capped at the cap. - cap_minus_earnings = p.cap - person( - "taxable_earnings_for_social_security", period + # Reforms may tax wages above the current-law cap. They cannot + # leave negative room for self-employment earnings. + cap_minus_earnings = max_( + 0, + p.cap - person("taxable_earnings_for_social_security", period), ) # Deduct SS payroll taxable wages and salaries. return min_( diff --git a/policyengine_us/variables/gov/states/ct/tax/payroll/paid_leave/ct_employee_paid_leave_contribution.py b/policyengine_us/variables/gov/states/ct/tax/payroll/paid_leave/ct_employee_paid_leave_contribution.py index 693f6ed2634..162ec714797 100644 --- a/policyengine_us/variables/gov/states/ct/tax/payroll/paid_leave/ct_employee_paid_leave_contribution.py +++ b/policyengine_us/variables/gov/states/ct/tax/payroll/paid_leave/ct_employee_paid_leave_contribution.py @@ -9,7 +9,12 @@ class ct_employee_paid_leave_contribution(Variable): definition_period = YEAR unit = USD defined_for = StateCode.CT + reference = "https://www.cga.ct.gov/current/pub/chap_557.htm#sec_31-49g" def formula(person, period, parameters): rate = parameters(period).gov.states.ct.tax.payroll.paid_leave.employee_rate - return rate * person("taxable_earnings_for_social_security", period) + taxable_wages = min_( + person("payroll_tax_gross_wages", period), + parameters(period).gov.irs.payroll.social_security.cap, + ) + return rate * taxable_wages diff --git a/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employee_paid_leave_contribution.py b/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employee_paid_leave_contribution.py index aec23942ac6..d7feceeb13f 100644 --- a/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employee_paid_leave_contribution.py +++ b/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employee_paid_leave_contribution.py @@ -12,10 +12,17 @@ class de_employee_paid_leave_contribution(Variable): definition_period = YEAR unit = USD defined_for = StateCode.DE + reference = ( + "https://delcode.delaware.gov/title19/c037/#3701", + "https://regulations.delaware.gov/api/AdminCode/title19/1401/b74cbe9f-04ec-4148-a954-b6907a8ba00a#page=11", + ) def formula(person, period, parameters): p = parameters(period).gov.states.de.tax.payroll.paid_leave - taxable_wages = person("taxable_earnings_for_social_security", period) + taxable_wages = min_( + person("payroll_tax_gross_wages", period), + parameters(period).gov.irs.payroll.social_security.cap, + ) return ( person("de_paid_leave_contribution_rate", period) * p.employee_share diff --git a/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employer_paid_leave_contribution.py b/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employer_paid_leave_contribution.py index 5ac4a6fce66..f43926b7803 100644 --- a/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employer_paid_leave_contribution.py +++ b/policyengine_us/variables/gov/states/de/tax/payroll/paid_leave/de_employer_paid_leave_contribution.py @@ -12,10 +12,17 @@ class de_employer_paid_leave_contribution(Variable): definition_period = YEAR unit = USD defined_for = StateCode.DE + reference = ( + "https://delcode.delaware.gov/title19/c037/#3701", + "https://regulations.delaware.gov/api/AdminCode/title19/1401/b74cbe9f-04ec-4148-a954-b6907a8ba00a#page=11", + ) def formula(person, period, parameters): p = parameters(period).gov.states.de.tax.payroll.paid_leave - taxable_wages = person("taxable_earnings_for_social_security", period) + taxable_wages = min_( + person("payroll_tax_gross_wages", period), + parameters(period).gov.irs.payroll.social_security.cap, + ) return ( person("de_paid_leave_contribution_rate", period) * (1 - p.employee_share)