From 5367080cb7d6ae310bbfef181a050cf0c0f5a1e6 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 7 Apr 2026 09:54:42 -0400 Subject: [PATCH 01/11] Add sstb_self_employment_income variable and per-category QBID computation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per IRC §199A(d), a specified service trade or business (SSTB) is treated differently from non-SSTB qualified businesses for the §199A deduction: above the §199A(e)(2) threshold, the SSTB component phases to zero while the non-SSTB component still receives the W-2/UBIA capped deduction. The existing PolicyEngine model used a single per-person `business_is_sstb` flag, which forced an all-or-nothing treatment and could not represent mixed filers (e.g., a doctor with rental property). This change introduces a new `sstb_self_employment_income` input variable, splits qualified business income into non-SSTB (`qualified_business_income`) and SSTB (`sstb_qualified_business_income`) components with QBI deductions pro-rated by gross-income share, and refactors `qbid_amount` to compute the §199A(b)(2) per-business limit separately for each category before summing. The legacy `business_is_sstb` flag is preserved for backward compatibility by routing the legacy QBI through the SSTB component when set. `sstb_self_employment_income` is also added to SECA gross income, IRS gross income, market income, and earned income so it flows through the rest of the tax model parallel to `self_employment_income`. Closes PolicyEngine/policyengine-us#7939 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../add-sstb-self-employment-income.added.md | 1 + .../gov/irs/gross_income/sources.yaml | 1 + .../deductions/qbid/qbid_amount.yaml | 75 +++++++++++++++++ .../qbid/qualified_business_income.yaml | 23 ++++++ .../qualified_business_income_deduction.yaml | 71 ++++++++++++++++ .../qbid/sstb_qualified_business_income.yaml | 43 ++++++++++ .../taxable_self_employment_income.yaml | 10 +++ .../earned_income/earned_income.py | 6 +- .../qbid_amount.py | 81 ++++++++++--------- .../qualified_business_income.py | 18 ++++- .../sstb_qualified_business_income.py | 38 +++++++++ .../taxable_self_employment_income.py | 3 +- .../income/person/general/market_income.py | 1 + ...lf_employment_income_would_be_qualified.py | 14 ++++ .../input/sstb_self_employment_income.py | 22 +++++ 15 files changed, 365 insertions(+), 42 deletions(-) create mode 100644 changelog.d/add-sstb-self-employment-income.added.md create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py create mode 100644 policyengine_us/variables/household/income/person/self_employment/sstb_self_employment_income_would_be_qualified.py create mode 100644 policyengine_us/variables/input/sstb_self_employment_income.py diff --git a/changelog.d/add-sstb-self-employment-income.added.md b/changelog.d/add-sstb-self-employment-income.added.md new file mode 100644 index 00000000000..4afc108a512 --- /dev/null +++ b/changelog.d/add-sstb-self-employment-income.added.md @@ -0,0 +1 @@ +Add `sstb_self_employment_income` and split QBID into non-SSTB and SSTB components per IRC §199A(d). diff --git a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml index 1e3306d12cd..7901e9a5634 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/sources.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/sources.yaml @@ -3,6 +3,7 @@ values: 2010-01-01: - irs_employment_income - self_employment_income + - sstb_self_employment_income - partnership_s_corp_income - farm_income - farm_rent_income diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml index 5fc9d2ac348..2528eda2a3c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml @@ -199,3 +199,78 @@ # REIT/PTP component = 0.20 * 25k = $5,000. # Total = $15,000. qbid_amount: 15_000 + +# Mixed SSTB / non-SSTB tests (Form 8995-A, columns A vs B) +- name: Mixed SSTB and non-SSTB above threshold - SSTB phased out, non-SSTB capped + period: 2024 + input: + qualified_business_income: 50_000 + sstb_qualified_business_income: 50_000 + w2_wages_from_qualified_business: 100_000 + unadjusted_basis_qualified_property: 0 + taxable_income_less_qbid: 300_000 + qualified_reit_and_ptp_income: 0 + business_is_sstb: false + filing_status: SINGLE + output: + # Single 2024 threshold $191,950, length $50,000. + # Reduction rate = min(1, (300k - 191.95k)/50k) = 1 → applicable rate = 0. + # Non-SSTB component: + # qbid_max = 0.20 * 50k = $10,000 + # wage_cap = 0.50 * 100k = $50,000 + # min($10k, $50k) = $10,000. + # SSTB component: 0 (applicable rate = 0). + # Total = $10,000. + qbid_amount: 10_000 + +- name: Mixed SSTB and non-SSTB below threshold - both get full 20% deduction + period: 2024 + input: + qualified_business_income: 50_000 + sstb_qualified_business_income: 50_000 + w2_wages_from_qualified_business: 0 + unadjusted_basis_qualified_property: 0 + taxable_income_less_qbid: 100_000 + qualified_reit_and_ptp_income: 0 + business_is_sstb: false + filing_status: SINGLE + output: + # Below threshold, so SSTB phaseout does not apply and W-2/UBIA limits do not bind. + # Non-SSTB: 0.20 * 50k = $10,000. + # SSTB: 0.20 * 50k = $10,000. + # Total = $20,000. + qbid_amount: 20_000 + +- name: Only SSTB QBI fully phased out above threshold + period: 2024 + input: + qualified_business_income: 0 + sstb_qualified_business_income: 100_000 + w2_wages_from_qualified_business: 0 + unadjusted_basis_qualified_property: 0 + taxable_income_less_qbid: 300_000 + qualified_reit_and_ptp_income: 0 + business_is_sstb: false + filing_status: SINGLE + output: + # Above threshold; SSTB applicable rate = 0; non-SSTB QBI = 0. + qbid_amount: 0 + +- name: Only SSTB QBI in phase-in range + period: 2024 + input: + qualified_business_income: 0 + sstb_qualified_business_income: 100_000 + w2_wages_from_qualified_business: 50_000 + unadjusted_basis_qualified_property: 0 + taxable_income_less_qbid: 216_950 + qualified_reit_and_ptp_income: 0 + business_is_sstb: false + filing_status: SINGLE + output: + # Reduction rate = (216_950 - 191_950) / 50_000 = 0.5 → applicable rate = 0.5 + # Reduced QBI = 50_000; reduced W-2 = 25_000. + # qbid_max (reduced) = 0.20 * 50_000 = 10_000. + # wage_cap (reduced) = 0.50 * 25_000 = 12_500. + # min(10_000, 12_500) = 10_000. + qbid_amount: 10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml index 6466d75f1b6..45f97f9e261 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml @@ -57,3 +57,26 @@ self_employed_pension_contribution_ald_person: 0 output: qualified_business_income: 9_000 + +- name: SSTB SE income excluded; deductions pro-rated to non-SSTB + period: 2026 + input: + self_employment_income: 30_000 + sstb_self_employment_income: 70_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + # Non-SSTB share = 30_000 / 100_000 = 0.3 + # Non-SSTB QBI = 30_000 - 0.3 * 1_000 = 29_700 + qualified_business_income: 29_700 + +- name: Only SSTB SE income, non-SSTB QBI is zero + period: 2026 + input: + sstb_self_employment_income: 100_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + qualified_business_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml index 0ef16af721a..807071c2722 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml @@ -57,3 +57,74 @@ qualified_business_income: 1_000 output: qualified_business_income_deduction: 2_000 + +# Integration test: doctor (SSTB) with rental property (non-SSTB) above threshold. +# Demonstrates the §199A(d)(3) phaseout reaching only the SSTB component +# while the non-SSTB business still receives the W-2 capped deduction. +- name: Mixed SSTB and non-SSTB above threshold + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + employment_income: 400_000 + self_employment_income: 50_000 + sstb_self_employment_income: 50_000 + w2_wages_from_qualified_business: 100_000 + # Zero out QBI deductions so the test isolates SSTB / non-SSTB routing + # rather than SE-tax / health-insurance / pension ALD pro-rating. + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + is_tax_unit_head: true + tax_units: + tax_unit: + members: [person1] + families: + family: + members: [person1] + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_fips: 6 + output: + # Single 2025 threshold $197,300, length $50,000. + # Taxable income is far above the phaseout, so applicable rate = 0. + # Non-SSTB QBI = $50,000; W-2 cap = 0.50 * $100,000 = $50,000. + # Non-SSTB component = min(20% * $50k, $50k) = $10,000. + # SSTB QBI = $50,000; SSTB component fully phased out = $0. + qualified_business_income_deduction: 10_000 + +- name: Pure SSTB below threshold gets full 20 percent deduction + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + employment_income: 50_000 + sstb_self_employment_income: 30_000 + # Zero out QBI deductions to isolate SSTB routing. + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + is_tax_unit_head: true + tax_units: + tax_unit: + members: [person1] + families: + family: + members: [person1] + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_fips: 6 + output: + # Below the $197,300 single threshold so the SSTB phaseout does not apply. + # SSTB QBI component = 0.20 * $30,000 = $6,000. + qualified_business_income_deduction: 6_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml new file mode 100644 index 00000000000..ef456479822 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml @@ -0,0 +1,43 @@ +- name: SSTB SE income only, no deductions + period: 2026 + input: + sstb_self_employment_income: 100_000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + sstb_qualified_business_income: 100_000 + +- name: SSTB SE income with would_be_qualified flag false + period: 2026 + input: + sstb_self_employment_income: 100_000 + sstb_self_employment_income_would_be_qualified: false + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + sstb_qualified_business_income: 0 + +- name: Mixed SSTB and non-SSTB SE income, deductions pro-rated by gross share + period: 2026 + input: + self_employment_income: 30_000 + sstb_self_employment_income: 70_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + # SSTB share = 70_000 / 100_000 = 0.7 + # SSTB QBI = 70_000 - 0.7 * 1_000 = 69_300 + sstb_qualified_business_income: 69_300 + +- name: No SSTB SE income returns zero + period: 2026 + input: + self_employment_income: 50_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + sstb_qualified_business_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/self_employment/taxable_self_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/self_employment/taxable_self_employment_income.yaml index 063afa46472..b1875a79a5e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/self_employment/taxable_self_employment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/self_employment/taxable_self_employment_income.yaml @@ -45,3 +45,13 @@ # Both Schedule C ($50k) and K-1 Box 14 ($30k) are subject to SE tax. # 80_000 * (1 - 0.5 * 0.153) = 80_000 * 0.9235 = 73_880 taxable_self_employment_income: 73_880 + +- name: SSTB self-employment income is subject to SE tax. + period: 2024 + input: + self_employment_income: 50_000 + sstb_self_employment_income: 30_000 + output: + # Both non-SSTB ($50k) and SSTB ($30k) Schedule C income are subject to SE tax. + # 80_000 * (1 - 0.5 * 0.153) = 80_000 * 0.9235 = 73_880 + taxable_self_employment_income: 73_880 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/earned_income/earned_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/earned_income/earned_income.py index 73dd3522683..156ce3093fd 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/earned_income/earned_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/earned_income/earned_income.py @@ -9,4 +9,8 @@ class earned_income(Variable): documentation = "Income from wages or self-employment" definition_period = YEAR - adds = ["employment_income", "self_employment_income"] + adds = [ + "employment_income", + "self_employment_income", + "sstb_self_employment_income", + ] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py index a8c0800a33a..967043febd5 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py @@ -9,58 +9,67 @@ class qbid_amount(Variable): definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/199A#b_1", + "https://www.law.cornell.edu/uscode/text/26/199A#d_3", "https://www.irs.gov/pub/irs-prior/p535--2018.pdf", "https://www.irs.gov/pub/irs-pdf/f8995.pdf", + "https://www.irs.gov/pub/irs-pdf/f8995a.pdf", ) def formula(person, period, parameters): - # computations follow logic in 2018 IRS Publication 535, - # Worksheet 12-A (and Schedule A for SSTB) + # Computations follow logic in 2018 IRS Publication 535, + # Worksheet 12-A (and Schedule A for SSTB). The non-SSTB and SSTB + # categories are computed separately so the §199A(d)(3) applicable- + # percentage phaseout reduces only the SSTB component above the + # threshold (Form 8995-A, Part II, columns A vs. B). p = parameters(period).gov.irs.deductions.qbi - # compute maximum QBID amount - qbi = person("qualified_business_income", period) - qbid_max = p.max.rate * qbi # Worksheet 12-A, line 3 - # compute caps - w2_wages = person("w2_wages_from_qualified_business", period) - b_property = person("unadjusted_basis_qualified_property", period) - wage_cap = w2_wages * p.max.w2_wages.rate # Worksheet 12-A, line 5 - alt_cap = ( # Worksheet 12-A, line 9 - w2_wages * p.max.w2_wages.alt_rate - + b_property * p.max.business_property.rate - ) - full_cap = max_(wage_cap, alt_cap) # Worksheet 12-A, line 10 - # compute phase-out ranges + # Phase-out range taxinc_less_qbid = person.tax_unit("taxable_income_less_qbid", period) filing_status = person.tax_unit("filing_status", period) po_start = p.phase_out.start[filing_status] po_length = p.phase_out.length[filing_status] - # compute phase-out limited QBID amount reduction_rate = min_( # Worksheet 12-A, line 24; Schedule A, line 9 1, (max_(0, taxinc_less_qbid - po_start)) / po_length ) applicable_rate = 1 - reduction_rate # Schedule A, line 10 - is_sstb = person("business_is_sstb", period) - # Schedule A, line 11 - sstb_multiplier = where(is_sstb, applicable_rate, 1) - adj_qbid_max = qbid_max * sstb_multiplier - # Schedule A, line 12 and line 13 - adj_cap = full_cap * sstb_multiplier - line11 = min_(adj_qbid_max, adj_cap) # Worksheet 12-A, line 11 - # compute phased reduction - reduction = reduction_rate * max_( # Worksheet 12-A, line 25 - 0, adj_qbid_max - adj_cap + # W-2/UBIA cap (shared across both QBI categories at the person level) + w2_wages = person("w2_wages_from_qualified_business", period) + b_property = person("unadjusted_basis_qualified_property", period) + wage_cap = w2_wages * p.max.w2_wages.rate # Worksheet 12-A, line 5 + alt_cap = ( # Worksheet 12-A, line 9 + w2_wages * p.max.w2_wages.alt_rate + + b_property * p.max.business_property.rate ) - line26 = max_(0, adj_qbid_max - reduction) - line12 = where(adj_cap < adj_qbid_max, line26, 0) - # QBI component (Worksheet 12-A, line 13 / Form 8995 Line 5) - qbi_component = max_(line11, line12) + full_cap = max_(wage_cap, alt_cap) # Worksheet 12-A, line 10 + + def qbi_component(qbi, sstb_multiplier): + # Worksheet 12-A lines 3, 11-13 / Schedule A lines 9-12. + qbid_max = p.max.rate * qbi # Worksheet 12-A, line 3 + adj_qbid_max = qbid_max * sstb_multiplier + adj_cap = full_cap * sstb_multiplier + line11 = min_(adj_qbid_max, adj_cap) + reduction = reduction_rate * max_(0, adj_qbid_max - adj_cap) + line26 = max_(0, adj_qbid_max - reduction) + line12 = where(adj_cap < adj_qbid_max, line26, 0) + return max_(line11, line12) + + # Non-SSTB and SSTB QBI categories. Backward compatibility: + # if the legacy `business_is_sstb` flag is set, route the legacy + # `qualified_business_income` into the SSTB component so the + # phaseout still applies. + non_sstb_qbi = person("qualified_business_income", period) + sstb_qbi_from_se = person("sstb_qualified_business_income", period) + is_sstb_legacy = person("business_is_sstb", period) + sstb_qbi = sstb_qbi_from_se + where(is_sstb_legacy, non_sstb_qbi, 0) + non_sstb_qbi_final = where(is_sstb_legacy, 0, non_sstb_qbi) + + non_sstb_component = qbi_component(non_sstb_qbi_final, 1) + sstb_component = qbi_component(sstb_qbi, applicable_rate) - # REIT/PTP component (Form 8995 Lines 6-9) - # Per §199A(b)(1)(B), qualified REIT dividends and qualified PTP income - # receive a 20% deduction WITHOUT W-2 wage or UBIA limitations + # REIT/PTP component (Form 8995 Lines 6-9). + # Per §199A(b)(1)(B), qualified REIT dividends and qualified PTP + # income receive a 20% deduction WITHOUT W-2 wage or UBIA limitations. reit_ptp_income = person("qualified_reit_and_ptp_income", period) reit_ptp_component = p.max.reit_ptp_rate * max_(0, reit_ptp_income) - # Total QBID = QBI component + REIT/PTP component - # (Form 8995 Line 10: Add lines 5 and 9) - return qbi_component + reit_ptp_component + # Total QBID = non-SSTB + SSTB + REIT/PTP (Form 8995 Line 10). + return non_sstb_component + sstb_component + reit_ptp_component diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py index 7abbfda5c87..d73b578697b 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py @@ -6,7 +6,10 @@ class qualified_business_income(Variable): entity = Person label = "Qualified business income" documentation = ( - "Business income that qualifies for the qualified business income deduction." + "Non-SSTB business income that qualifies for the qualified business " + "income deduction. Excludes sstb_self_employment_income, which is " + "tracked separately so the §199A(d)(3) phaseout can apply only to the " + "SSTB component above the threshold." ) unit = USD definition_period = YEAR @@ -15,10 +18,17 @@ class qualified_business_income(Variable): def formula(person, period, parameters): p = parameters(period).gov.irs.deductions.qbi - gross_qbi = 0 + non_sstb_gross = 0 for var in p.income_definition: - gross_qbi += person(var, period) * person( + non_sstb_gross += person(var, period) * person( var + "_would_be_qualified", period ) + sstb_gross = person("sstb_self_employment_income", period) * person( + "sstb_self_employment_income_would_be_qualified", period + ) + # Pro-rate QBI deductions across non-SSTB and SSTB shares. + # When there is no SSTB QBI, the full deduction is applied to non-SSTB. + gross_total = non_sstb_gross + sstb_gross qbi_deductions = add(person, period, p.deduction_definition) - return max_(0, gross_qbi - qbi_deductions) + non_sstb_share = where(gross_total > 0, non_sstb_gross / gross_total, 1) + return max_(0, non_sstb_gross - qbi_deductions * non_sstb_share) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py new file mode 100644 index 00000000000..de332aef6fb --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py @@ -0,0 +1,38 @@ +from policyengine_us.model_api import * + + +class sstb_qualified_business_income(Variable): + value_type = float + entity = Person + label = "SSTB qualified business income" + documentation = ( + "Qualified business income from a specified service trade or business " + "(SSTB) under IRC §199A(d)(2). Tracked separately from non-SSTB QBI so " + "the §199A(d)(3) applicable-percentage phaseout above the threshold can " + "reduce only the SSTB component." + ) + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/199A#c", + "https://www.law.cornell.edu/uscode/text/26/199A#d_2", + ) + defined_for = "business_is_qualified" + + def formula(person, period, parameters): + p = parameters(period).gov.irs.deductions.qbi + non_sstb_gross = 0 + for var in p.income_definition: + non_sstb_gross += person(var, period) * person( + var + "_would_be_qualified", period + ) + sstb_gross = person("sstb_self_employment_income", period) * person( + "sstb_self_employment_income_would_be_qualified", period + ) + # Pro-rate QBI deductions across non-SSTB and SSTB shares so that + # SE-tax / health-insurance / pension ALDs reduce both QBI categories + # in proportion to their gross income. + gross_total = non_sstb_gross + sstb_gross + qbi_deductions = add(person, period, p.deduction_definition) + sstb_share = where(gross_total > 0, sstb_gross / gross_total, 0) + return max_(0, sstb_gross - qbi_deductions * sstb_share) diff --git a/policyengine_us/variables/gov/irs/tax/self_employment/taxable_self_employment_income.py b/policyengine_us/variables/gov/irs/tax/self_employment/taxable_self_employment_income.py index 1f44a6f5640..38dc9ec4f61 100644 --- a/policyengine_us/variables/gov/irs/tax/self_employment/taxable_self_employment_income.py +++ b/policyengine_us/variables/gov/irs/tax/self_employment/taxable_self_employment_income.py @@ -11,12 +11,13 @@ class taxable_self_employment_income(Variable): def formula(person, period, parameters): # Per 26 USC 1402(a), SE income includes: - # - Schedule C net profit (self_employment_income) + # - Schedule C net profit (self_employment_income, sstb_self_employment_income) # - Schedule F net profit (farm_income) # - General partners' distributive share (partnership_se_income from K-1 Box 14) # S-corp distributions are NOT subject to SE tax. SEI_SOURCES = [ "self_employment_income", + "sstb_self_employment_income", "farm_income", "partnership_se_income", ] diff --git a/policyengine_us/variables/household/income/person/general/market_income.py b/policyengine_us/variables/household/income/person/general/market_income.py index b21a8195400..4d9bf69ef12 100644 --- a/policyengine_us/variables/household/income/person/general/market_income.py +++ b/policyengine_us/variables/household/income/person/general/market_income.py @@ -13,6 +13,7 @@ def formula(person, period, parameters): COMPONENTS = [ "employment_income", "self_employment_income", + "sstb_self_employment_income", "pension_income", "dividend_income", "interest_income", diff --git a/policyengine_us/variables/household/income/person/self_employment/sstb_self_employment_income_would_be_qualified.py b/policyengine_us/variables/household/income/person/self_employment/sstb_self_employment_income_would_be_qualified.py new file mode 100644 index 00000000000..ba0bbc4b2b3 --- /dev/null +++ b/policyengine_us/variables/household/income/person/self_employment/sstb_self_employment_income_would_be_qualified.py @@ -0,0 +1,14 @@ +from policyengine_us.model_api import * + + +class sstb_self_employment_income_would_be_qualified(Variable): + value_type = bool + entity = Person + label = "SSTB self-employment income would be qualified" + documentation = ( + "Whether SSTB self-employment income would count toward qualified " + "business income before the §199A(d)(3) applicable-percentage phaseout." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/199A#c_3_A" + default_value = True diff --git a/policyengine_us/variables/input/sstb_self_employment_income.py b/policyengine_us/variables/input/sstb_self_employment_income.py new file mode 100644 index 00000000000..c6546ef1ed4 --- /dev/null +++ b/policyengine_us/variables/input/sstb_self_employment_income.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class sstb_self_employment_income(Variable): + value_type = float + entity = Person + label = "SSTB self-employment income" + unit = USD + documentation = ( + "Self-employment non-farm income from a specified service trade or " + "business (SSTB) under IRC §199A(d)(2). Subject to SECA tax. For the " + "qualified business income deduction, this income is treated separately " + "from non-SSTB self-employment income because the SSTB applicable " + "percentage phaseout above the threshold can fully eliminate the " + "deduction without affecting non-SSTB QBI." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/1402#a", + "https://www.law.cornell.edu/uscode/text/26/199A#d_2", + ) + uprating = "calibration.gov.irs.soi.self_employment_income" From 7c827849f676032def642c471d6350cb4e80087c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 7 Apr 2026 20:10:32 -0400 Subject: [PATCH 02/11] Fix SSTB downstream QBI consumers --- .../qualified_business_income_deduction.yaml | 30 +++++++++++++++++++ .../mo_business_income_deduction.yaml | 11 +++++++ .../taxsim/outputs/taxsim_outputs.yaml | 26 ++++++++++++++++ .../contrib/taxsim/taxsim_pbusinc.py | 4 ++- .../contrib/taxsim/taxsim_sbusinc.py | 4 ++- .../qualified_business_income_deduction.py | 7 +++-- .../mo_business_income_deduction.py | 9 ++++-- 7 files changed, 84 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml index 807071c2722..b9716befc2f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml @@ -58,6 +58,36 @@ output: qualified_business_income_deduction: 2_000 +- name: Deduction floor in effect with SSTB-only income + absolute_error_margin: 0.01 + period: 2026 + input: + people: + person1: + sstb_self_employment_income: 1_000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + qbid_amount: 200 + is_tax_unit_head: true + tax_units: + tax_unit: + members: [person1] + taxable_income_less_qbid: 600 + adjusted_net_capital_gain: 0 + families: + family: + members: [person1] + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_fips: 6 + output: + qualified_business_income_deduction: 400 + # Integration test: doctor (SSTB) with rental property (non-SSTB) above threshold. # Demonstrates the §199A(d)(3) phaseout reaching only the SSTB component # while the non-SSTB business still receives the W-2 capped deduction. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml index 6c688f79937..53c05ec4d31 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/deductions/mo_business_income_deduction.yaml @@ -30,3 +30,14 @@ state_code: MO output: mo_business_income_deduction: 0 + +- name: 2023 SSTB-only qualified business income + period: 2023 + input: + sstb_self_employment_income: 10_000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + state_code: MO + output: + mo_business_income_deduction: 2_000 diff --git a/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml b/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml index 41ae60e5387..1fd3967d6ae 100644 --- a/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml +++ b/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml @@ -209,6 +209,32 @@ taxsim_sbusinc: 50000 taxsim_scorp: 150000 +- name: SSTB self-employment income contributes to TAXSIM QBI outputs + period: 2024 + input: + people: + head: + age: 50 + sstb_self_employment_income: 100000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + is_tax_unit_head: true + spouse: + age: 48 + sstb_self_employment_income: 50000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + taxsim_pbusinc: 100000 + taxsim_sbusinc: 50000 + - name: State code NY - TAXSIM outputs period: 2024 input: diff --git a/policyengine_us/variables/contrib/taxsim/taxsim_pbusinc.py b/policyengine_us/variables/contrib/taxsim/taxsim_pbusinc.py index 09b9a544153..5771c904489 100644 --- a/policyengine_us/variables/contrib/taxsim/taxsim_pbusinc.py +++ b/policyengine_us/variables/contrib/taxsim/taxsim_pbusinc.py @@ -10,6 +10,8 @@ class taxsim_pbusinc(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members - qbi = person("qualified_business_income", period) + qbi = person("qualified_business_income", period) + person( + "sstb_qualified_business_income", period + ) is_head = person("is_tax_unit_head", period) return tax_unit.sum(qbi * is_head) diff --git a/policyengine_us/variables/contrib/taxsim/taxsim_sbusinc.py b/policyengine_us/variables/contrib/taxsim/taxsim_sbusinc.py index 5e5cb7713c3..c7df67eeaf4 100644 --- a/policyengine_us/variables/contrib/taxsim/taxsim_sbusinc.py +++ b/policyengine_us/variables/contrib/taxsim/taxsim_sbusinc.py @@ -10,6 +10,8 @@ class taxsim_sbusinc(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members - qbi = person("qualified_business_income", period) + qbi = person("qualified_business_income", period) + person( + "sstb_qualified_business_income", period + ) is_spouse = person("is_tax_unit_spouse", period) return tax_unit.sum(qbi * is_spouse) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py index e1cecf38f87..1a2c1193d94 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income_deduction.py @@ -17,6 +17,10 @@ def formula(tax_unit, period, parameters): # logic in 2018 IRS Publication 535, Worksheet 12-A, line 16 person = tax_unit.members qbid_amt = person("qbid_amount", period) + total_qbi = tax_unit.sum( + person("qualified_business_income", period) + + person("sstb_qualified_business_income", period) + ) uncapped_qbid = tax_unit.sum(qbid_amt) # apply taxinc cap at the TaxUnit level following logic # in 2018 IRS Publication 535, Worksheet 12-A, lines 32-37 @@ -26,7 +30,6 @@ def formula(tax_unit, period, parameters): taxinc_cap = p.max.rate * max_(0, taxinc_less_qbid - netcg_qdiv) pre_floor_qbid = min_(uncapped_qbid, taxinc_cap) if p.deduction_floor.in_effect: - qualified_business_income = tax_unit("qualified_business_income", period) - floor = p.deduction_floor.amount.calc(qualified_business_income) + floor = p.deduction_floor.amount.calc(total_qbi) return max_(pre_floor_qbid, floor) return pre_floor_qbid diff --git a/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py b/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py index 72774ffadee..8aeb4eeff4c 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/deductions/mo_business_income_deduction.py @@ -15,6 +15,9 @@ class mo_business_income_deduction(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.mo.tax.income.deductions.business_income - qualified_business_income = add(tax_unit, period, ["qualified_business_income"]) - total_qualified_business_income = tax_unit.sum(qualified_business_income) - return p.rate * qualified_business_income + person = tax_unit.members + total_qualified_business_income = tax_unit.sum( + person("qualified_business_income", period) + + person("sstb_qualified_business_income", period) + ) + return p.rate * total_qualified_business_income From 3e7758ac5b4c04e83ae4cd3142f6176ce03e04a8 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 7 Apr 2026 20:47:32 -0400 Subject: [PATCH 03/11] Align mixed SSTB QBID cap with section 199A --- .../deductions/qbid/qbid_amount.yaml | 24 ++++++ .../qbid_amount.py | 79 ++++++++++++++++--- ...stb_unadjusted_basis_qualified_property.py | 21 +++++ .../sstb_w2_wages_from_qualified_business.py | 21 +++++ 4 files changed, 133 insertions(+), 12 deletions(-) create mode 100644 policyengine_us/variables/household/income/person/self_employment/sstb_unadjusted_basis_qualified_property.py create mode 100644 policyengine_us/variables/household/income/person/self_employment/sstb_w2_wages_from_qualified_business.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml index 2528eda2a3c..3d83878de7e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qbid_amount.yaml @@ -241,6 +241,30 @@ # Total = $20,000. qbid_amount: 20_000 +- name: Mixed SSTB and non-SSTB in phase-in range with separate allocable wages + absolute_error_margin: 0.01 + period: 2024 + input: + qualified_business_income: 100_000 + sstb_qualified_business_income: 100_000 + w2_wages_from_qualified_business: 20_000 + sstb_w2_wages_from_qualified_business: 10_000 + unadjusted_basis_qualified_property: 0 + sstb_unadjusted_basis_qualified_property: 0 + taxable_income_less_qbid: 200_000 + qualified_reit_and_ptp_income: 0 + business_is_sstb: false + filing_status: SINGLE + output: + # Single 2024 threshold $191,950, length $50,000. + # Reduction rate = (200k - 191.95k) / 50k = 0.161; applicable rate = 0.839. + # Non-SSTB W-2 wages = 10k => cap = 5k. + # Non-SSTB deduction = 20k - 0.161 * (20k - 5k) = 17,585.00. + # SSTB W-2 wages = 10k => applicable-percentage cap = 0.839 * 5k = 4,195. + # SSTB deduction = 16,780 - 0.161 * (16,780 - 4,195) = 14,753.82. + # Total = 32,338.82. + qbid_amount: 32_338.82 + - name: Only SSTB QBI fully phased out above threshold period: 2024 input: diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py index 967043febd5..262782294b2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qbid_amount.py @@ -31,17 +31,10 @@ def formula(person, period, parameters): 1, (max_(0, taxinc_less_qbid - po_start)) / po_length ) applicable_rate = 1 - reduction_rate # Schedule A, line 10 - # W-2/UBIA cap (shared across both QBI categories at the person level) - w2_wages = person("w2_wages_from_qualified_business", period) - b_property = person("unadjusted_basis_qualified_property", period) - wage_cap = w2_wages * p.max.w2_wages.rate # Worksheet 12-A, line 5 - alt_cap = ( # Worksheet 12-A, line 9 - w2_wages * p.max.w2_wages.alt_rate - + b_property * p.max.business_property.rate - ) - full_cap = max_(wage_cap, alt_cap) # Worksheet 12-A, line 10 + total_w2_wages = person("w2_wages_from_qualified_business", period) + total_b_property = person("unadjusted_basis_qualified_property", period) - def qbi_component(qbi, sstb_multiplier): + def qbi_component(qbi, full_cap, sstb_multiplier): # Worksheet 12-A lines 3, 11-13 / Schedule A lines 9-12. qbid_max = p.max.rate * qbi # Worksheet 12-A, line 3 adj_qbid_max = qbid_max * sstb_multiplier @@ -62,8 +55,70 @@ def qbi_component(qbi, sstb_multiplier): sstb_qbi = sstb_qbi_from_se + where(is_sstb_legacy, non_sstb_qbi, 0) non_sstb_qbi_final = where(is_sstb_legacy, 0, non_sstb_qbi) - non_sstb_component = qbi_component(non_sstb_qbi_final, 1) - sstb_component = qbi_component(sstb_qbi, applicable_rate) + has_non_sstb = non_sstb_qbi_final > 0 + has_sstb = sstb_qbi > 0 + has_mixed_categories = has_non_sstb & has_sstb + + # Schedule A applies the SSTB applicable percentage to the SSTB's own + # allocable W-2 wages and UBIA. The model stores person-level totals, + # so mixed cases use explicit SSTB allocable inputs to split those + # totals without double counting the same wage/property pool twice. + sstb_w2_wages = where( + is_sstb_legacy, + total_w2_wages, + where( + has_mixed_categories, + person("sstb_w2_wages_from_qualified_business", period), + where(has_sstb, total_w2_wages, 0), + ), + ) + non_sstb_w2_wages = where( + is_sstb_legacy, + 0, + where( + has_mixed_categories, + max_(0, total_w2_wages - sstb_w2_wages), + where(has_non_sstb, total_w2_wages, 0), + ), + ) + + sstb_b_property = where( + is_sstb_legacy, + total_b_property, + where( + has_mixed_categories, + person("sstb_unadjusted_basis_qualified_property", period), + where(has_sstb, total_b_property, 0), + ), + ) + non_sstb_b_property = where( + is_sstb_legacy, + 0, + where( + has_mixed_categories, + max_(0, total_b_property - sstb_b_property), + where(has_non_sstb, total_b_property, 0), + ), + ) + + def full_cap(w2_wages, b_property): + wage_cap = w2_wages * p.max.w2_wages.rate # Worksheet 12-A, line 5 + alt_cap = ( # Worksheet 12-A, line 9 + w2_wages * p.max.w2_wages.alt_rate + + b_property * p.max.business_property.rate + ) + return max_(wage_cap, alt_cap) # Worksheet 12-A, line 10 + + non_sstb_component = qbi_component( + non_sstb_qbi_final, + full_cap(non_sstb_w2_wages, non_sstb_b_property), + 1, + ) + sstb_component = qbi_component( + sstb_qbi, + full_cap(sstb_w2_wages, sstb_b_property), + applicable_rate, + ) # REIT/PTP component (Form 8995 Lines 6-9). # Per §199A(b)(1)(B), qualified REIT dividends and qualified PTP diff --git a/policyengine_us/variables/household/income/person/self_employment/sstb_unadjusted_basis_qualified_property.py b/policyengine_us/variables/household/income/person/self_employment/sstb_unadjusted_basis_qualified_property.py new file mode 100644 index 00000000000..0995258b96c --- /dev/null +++ b/policyengine_us/variables/household/income/person/self_employment/sstb_unadjusted_basis_qualified_property.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class sstb_unadjusted_basis_qualified_property(Variable): + value_type = float + entity = Person + label = "SSTB allocable UBIA of qualified property" + unit = USD + documentation = ( + "Portion of unadjusted_basis_qualified_property allocable to " + "specified service trades or businesses for section 199A. Used to " + "apply the UBIA limitation separately to SSTB and non-SSTB " + "categories in mixed-business cases." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/199A#b_2", + "https://www.law.cornell.edu/uscode/text/26/199A#d_3", + "https://www.irs.gov/pub/irs-pdf/f8995aa.pdf", + ) + default_value = 0 diff --git a/policyengine_us/variables/household/income/person/self_employment/sstb_w2_wages_from_qualified_business.py b/policyengine_us/variables/household/income/person/self_employment/sstb_w2_wages_from_qualified_business.py new file mode 100644 index 00000000000..53509d7e8a5 --- /dev/null +++ b/policyengine_us/variables/household/income/person/self_employment/sstb_w2_wages_from_qualified_business.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class sstb_w2_wages_from_qualified_business(Variable): + value_type = float + entity = Person + label = "SSTB allocable W-2 wages" + unit = USD + documentation = ( + "Portion of w2_wages_from_qualified_business allocable to specified " + "service trades or businesses for section 199A. Used to apply the " + "W-2 wage limitation separately to SSTB and non-SSTB categories in " + "mixed-business cases." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/199A#b_2", + "https://www.law.cornell.edu/uscode/text/26/199A#d_3", + "https://www.irs.gov/pub/irs-pdf/f8995aa.pdf", + ) + default_value = 0 From eeb8893292681b7b3243dddca38648bc0421c70f Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 7 Apr 2026 21:18:38 -0400 Subject: [PATCH 04/11] Fix SSTB QBI gating semantics --- changelog.d/add-sstb-self-employment-income.added.md | 2 +- .../qbid/qualified_business_income_deduction.yaml | 1 + .../qbid/sstb_qualified_business_income.yaml | 11 +++++++++++ .../sstb_qualified_business_income.py | 1 - 4 files changed, 13 insertions(+), 2 deletions(-) diff --git a/changelog.d/add-sstb-self-employment-income.added.md b/changelog.d/add-sstb-self-employment-income.added.md index 4afc108a512..0dd9f473abe 100644 --- a/changelog.d/add-sstb-self-employment-income.added.md +++ b/changelog.d/add-sstb-self-employment-income.added.md @@ -1 +1 @@ -Add `sstb_self_employment_income` and split QBID into non-SSTB and SSTB components per IRC §199A(d). +Add `sstb_self_employment_income` and split QBID into non-SSTB and SSTB components per IRC §199A(d). Mixed SSTB/non-SSTB wage-limited cases can also provide `sstb_w2_wages_from_qualified_business` and `sstb_unadjusted_basis_qualified_property` to match Form 8995-A's separate SSTB wage/UBIA inputs. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml index b9716befc2f..4feef0daf86 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income_deduction.yaml @@ -135,6 +135,7 @@ people: person1: employment_income: 50_000 + business_is_qualified: false sstb_self_employment_income: 30_000 # Zero out QBI deductions to isolate SSTB routing. self_employment_tax_ald_person: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml index ef456479822..ceeb144bac7 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml @@ -19,6 +19,17 @@ output: sstb_qualified_business_income: 0 +- name: SSTB SE income still counts when business_is_qualified is false + period: 2026 + input: + business_is_qualified: false + sstb_self_employment_income: 100_000 + self_employment_tax_ald_person: 0 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + sstb_qualified_business_income: 100_000 + - name: Mixed SSTB and non-SSTB SE income, deductions pro-rated by gross share period: 2026 input: diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py index de332aef6fb..5d1df746b0e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py @@ -17,7 +17,6 @@ class sstb_qualified_business_income(Variable): "https://www.law.cornell.edu/uscode/text/26/199A#c", "https://www.law.cornell.edu/uscode/text/26/199A#d_2", ) - defined_for = "business_is_qualified" def formula(person, period, parameters): p = parameters(period).gov.irs.deductions.qbi From 44d3d9dd1d0890a61ae4ba3ed0e51c418beaf9f1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 11:28:38 -0400 Subject: [PATCH 05/11] Include SSTB income in household market income --- .../gov/household/market_income_sources.yaml | 1 + .../income/household/household_market_income.yaml | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/policyengine_us/parameters/gov/household/market_income_sources.yaml b/policyengine_us/parameters/gov/household/market_income_sources.yaml index 8522d585507..6af5b1e8c92 100644 --- a/policyengine_us/parameters/gov/household/market_income_sources.yaml +++ b/policyengine_us/parameters/gov/household/market_income_sources.yaml @@ -3,6 +3,7 @@ values: 0000-01-01: - employment_income - self_employment_income + - sstb_self_employment_income - partnership_s_corp_income - gi_cash_assistance - farm_income diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml index 1c5a2627ccc..546ce8ffb3a 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_market_income.yaml @@ -34,6 +34,18 @@ output: household_market_income: 50_000 +- name: SSTB self-employment income is market income + period: 2026 + input: + people: + person: + sstb_self_employment_income: 50_000 + households: + household: + members: [person] + output: + household_market_income: 50_000 + - name: Employment income plus UC does not double count period: 2026 input: From cfa610834ca4512cdfdc822046898d754d85cfb5 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 14:55:01 -0400 Subject: [PATCH 06/11] Handle SSTB income in self-employment deductions --- .../self_employment_income.yaml | 30 +++++++++++++++++++ ...nap_self_employment_expense_deduction.yaml | 9 ++++++ ...oyment_income_after_expense_deduction.yaml | 8 +++++ .../above_the_line_deductions/loss_ald.py | 2 +- ...lf_employed_health_insurance_ald_person.py | 2 +- ...mployed_pension_contribution_ald_person.py | 2 +- .../snap_self_employment_expense_deduction.py | 4 ++- ...ployment_income_after_expense_deduction.py | 4 ++- .../total_self_employment_income.py | 16 ++++++++++ 9 files changed, 72 insertions(+), 5 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml create mode 100644 policyengine_us/variables/household/income/person/self_employment/total_self_employment_income.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml new file mode 100644 index 00000000000..90ff4dbf8c9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml @@ -0,0 +1,30 @@ +- name: Total self-employment income includes SSTB income + period: 2024 + input: + self_employment_income_before_lsr: 30_000 + sstb_self_employment_income: 20_000 + output: + total_self_employment_income: 50_000 + +- name: Self-employed health insurance ALD uses SSTB income + period: 2024 + input: + sstb_self_employment_income: 10_000 + self_employed_health_insurance_premiums: 12_000 + output: + self_employed_health_insurance_ald_person: 10_000 + +- name: Self-employed pension ALD uses SSTB income + period: 2024 + input: + sstb_self_employment_income: 8_000 + self_employed_pension_contributions: 9_000 + output: + self_employed_pension_contribution_ald_person: 8_000 + +- name: Loss ALD includes SSTB self-employment losses + period: 2024 + input: + sstb_self_employment_income: -20_000 + output: + loss_ald: 20_000 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml index 1b9717b840a..d68d97b5249 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml @@ -53,3 +53,12 @@ output: # 40% of $1,000 = $400, but actual expenses ($600) are greater snap_self_employment_expense_deduction: 600 + +- name: Alaska simplified deduction includes SSTB self-employment income + period: 2025 + input: + sstb_self_employment_income: 300 + snap_self_employment_income_expense: 100 + state_code: AK + output: + snap_self_employment_expense_deduction: 150 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml index 27b0cf4543b..af0b0fac1a5 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml @@ -5,3 +5,11 @@ snap_self_employment_expense_deduction: 300 output: snap_self_employment_income_after_expense_deduction: 200 + +- name: SSTB income is included after SNAP self-employment deduction + period: 2022 + input: + sstb_self_employment_income: 500 + snap_self_employment_expense_deduction: 300 + output: + snap_self_employment_income_after_expense_deduction: 200 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py index 531880bfa4a..d71816053e8 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald.py @@ -14,7 +14,7 @@ def formula(tax_unit, period, parameters): filing_status = tax_unit("filing_status", period) max_loss = parameters(period).gov.irs.ald.loss.max[filing_status] person = tax_unit.members - indiv_se_loss = max_(0, -person("self_employment_income", period)) + indiv_se_loss = max_(0, -person("total_self_employment_income", period)) self_employment_loss = tax_unit.sum(indiv_se_loss) limited_capital_loss = tax_unit("limited_capital_loss", period) return min_(max_loss, self_employment_loss + limited_capital_loss) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald_person.py index 2d7d333ae23..8ecda97461a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald_person.py @@ -11,6 +11,6 @@ class self_employed_health_insurance_ald_person(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/162#l" def formula(person, period, parameters): - earnings = max_(0, person("self_employment_income", period)) + earnings = max_(0, person("total_self_employment_income", period)) premiums = person("self_employed_health_insurance_premiums", period) return min_(earnings, premiums) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py index 8b630cf6250..e027dd1a7c2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald_person.py @@ -11,6 +11,6 @@ class self_employed_pension_contribution_ald_person(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/162#l" def formula(person, period, parameters): - earnings = max_(0, person("self_employment_income", period)) + earnings = max_(0, person("total_self_employment_income", period)) contributions = person("self_employed_pension_contributions", period) return min_(earnings, contributions) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py index ef60a137ba8..c77d2e11713 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py @@ -14,7 +14,9 @@ class snap_self_employment_expense_deduction(Variable): def formula(spm_unit, period, parameters): self_employment_income = add( - spm_unit, period, ["self_employment_income_before_lsr"] + spm_unit, + period, + ["self_employment_income_before_lsr", "sstb_self_employment_income"], ) expenses = spm_unit("snap_self_employment_income_expense", period) p = parameters(period).gov.usda.snap.income.deductions.self_employment diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py index a7f47cc0adf..c4f7542aafa 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py @@ -10,7 +10,9 @@ class snap_self_employment_income_after_expense_deduction(Variable): def formula(spm_unit, period, parameters): self_employment_income = add( - spm_unit, period, ["self_employment_income_before_lsr"] + spm_unit, + period, + ["self_employment_income_before_lsr", "sstb_self_employment_income"], ) expense_deduction = spm_unit("snap_self_employment_expense_deduction", period) return max_(self_employment_income - expense_deduction, 0) diff --git a/policyengine_us/variables/household/income/person/self_employment/total_self_employment_income.py b/policyengine_us/variables/household/income/person/self_employment/total_self_employment_income.py new file mode 100644 index 00000000000..c5699055e0d --- /dev/null +++ b/policyengine_us/variables/household/income/person/self_employment/total_self_employment_income.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class total_self_employment_income(Variable): + value_type = float + entity = Person + label = "total self-employment income" + unit = USD + documentation = ( + "Total non-farm self-employment income, including both SSTB and " + "non-SSTB Schedule C income." + ) + definition_period = YEAR + adds = ["self_employment_income", "sstb_self_employment_income"] + reference = "https://www.law.cornell.edu/uscode/text/26/1402#a" + uprating = "calibration.gov.irs.soi.self_employment_income" From c0c29257516028bcdcb17c81069ab5e92d028cda Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 16:54:08 -0400 Subject: [PATCH 07/11] Fix SSTB loss and mixed-sign QBI follow-ups --- .../qbid/qualified_business_income.yaml | 11 +++++++++++ .../qbid/sstb_qualified_business_income.yaml | 11 +++++++++++ .../income/dc_self_employment_loss_addition.yaml | 11 +++++++++++ .../qualified_business_income.py | 14 ++++++++++---- .../sstb_qualified_business_income.py | 15 ++++++++++----- .../additions/dc_self_employment_loss_addition.py | 2 +- 6 files changed, 54 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml index 45f97f9e261..391f5e11004 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/qualified_business_income.yaml @@ -80,3 +80,14 @@ self_employed_pension_contribution_ald_person: 0 output: qualified_business_income: 0 + +- name: Mixed-sign SSTB loss does not create a negative non-SSTB deduction share + period: 2026 + input: + self_employment_income: 100_000 + sstb_self_employment_income: -50_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + qualified_business_income: 99_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml index ceeb144bac7..49a7c826630 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/qbid/sstb_qualified_business_income.yaml @@ -52,3 +52,14 @@ self_employed_pension_contribution_ald_person: 0 output: sstb_qualified_business_income: 0 + +- name: Mixed-sign non-SSTB loss does not create a negative SSTB deduction share + period: 2026 + input: + self_employment_income: -50_000 + sstb_self_employment_income: 100_000 + self_employment_tax_ald_person: 1_000 + self_employed_health_insurance_ald_person: 0 + self_employed_pension_contribution_ald_person: 0 + output: + sstb_qualified_business_income: 99_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml index 29ad13b8f3b..8d9f9f2f723 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml @@ -69,6 +69,17 @@ # DC addition: max(0, 12_000 - 12_000) = 0 dc_self_employment_loss_addition: 0 +- name: SSTB self-employment loss contributes to DC add-back + absolute_error_margin: 0.01 + period: 2024 + input: + sstb_self_employment_income: -50_000 + state_code: DC + output: + # SSTB loss also flows through loss_ald and DC's self-employment loss add-back. + # DC addition: max(0, 50_000 - 12_000) = 38_000 + dc_self_employment_loss_addition: 38_000 + - name: dc_self_employment_loss_addition unit test 2 absolute_error_margin: 0.01 period: 2021 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py index d73b578697b..eb62a2d769a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/qualified_business_income.py @@ -26,9 +26,15 @@ def formula(person, period, parameters): sstb_gross = person("sstb_self_employment_income", period) * person( "sstb_self_employment_income_would_be_qualified", period ) - # Pro-rate QBI deductions across non-SSTB and SSTB shares. - # When there is no SSTB QBI, the full deduction is applied to non-SSTB. - gross_total = non_sstb_gross + sstb_gross + # Pro-rate QBI deductions across positive non-SSTB and SSTB income + # only, so mixed-sign categories do not generate negative shares. + positive_non_sstb_gross = max_(0, non_sstb_gross) + positive_sstb_gross = max_(0, sstb_gross) + positive_gross_total = positive_non_sstb_gross + positive_sstb_gross qbi_deductions = add(person, period, p.deduction_definition) - non_sstb_share = where(gross_total > 0, non_sstb_gross / gross_total, 1) + non_sstb_share = where( + positive_gross_total > 0, + positive_non_sstb_gross / positive_gross_total, + 0, + ) return max_(0, non_sstb_gross - qbi_deductions * non_sstb_share) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py index 5d1df746b0e..24e325ea103 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/qualified_business_income_deduction/sstb_qualified_business_income.py @@ -28,10 +28,15 @@ def formula(person, period, parameters): sstb_gross = person("sstb_self_employment_income", period) * person( "sstb_self_employment_income_would_be_qualified", period ) - # Pro-rate QBI deductions across non-SSTB and SSTB shares so that - # SE-tax / health-insurance / pension ALDs reduce both QBI categories - # in proportion to their gross income. - gross_total = non_sstb_gross + sstb_gross + # Pro-rate QBI deductions across positive non-SSTB and SSTB income so + # that mixed-sign categories do not generate negative shares. + positive_non_sstb_gross = max_(0, non_sstb_gross) + positive_sstb_gross = max_(0, sstb_gross) + positive_gross_total = positive_non_sstb_gross + positive_sstb_gross qbi_deductions = add(person, period, p.deduction_definition) - sstb_share = where(gross_total > 0, sstb_gross / gross_total, 0) + sstb_share = where( + positive_gross_total > 0, + positive_sstb_gross / positive_gross_total, + 0, + ) return max_(0, sstb_gross - qbi_deductions * sstb_share) diff --git a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py index 657ead6401b..998e9695a38 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py @@ -14,7 +14,7 @@ class dc_self_employment_loss_addition(Variable): defined_for = StateCode.DC def formula(person, period, parameters): - loss_person = max_(0, -person("self_employment_income", period)) + loss_person = max_(0, -person("total_self_employment_income", period)) loss_taxunit = person.tax_unit.sum(loss_person) # Cap at SE loss actually deducted in federal AGI via loss_ald. # loss_ald includes both SE and capital losses; isolate SE portion. From 9e0c11c6c527084b1423aef502d9c4c4496ade66 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 18:27:04 -0400 Subject: [PATCH 08/11] Fix remaining SSTB self-employment downstream paths --- .../mi/tax/income/household_resources.yaml | 2 +- ...eneral_relief_meets_work_requirements.yaml | 18 ++++++++++++ .../dc_ccsp_qualified_activity_eligible.yaml | 10 +++++++ .../de/dss/poc/de_poc_activity_eligible.yaml | 19 ++++++++++++ ...cap_parent_meets_working_requirements.yaml | 29 +++++++++++++++++++ .../ma_gross_income_loss_adjustment.yaml | 7 +++++ .../mi/tax/income/mi_household_resources.yaml | 9 ++++++ .../gov/states/nd/tax/income/nd_mpc.yaml | 26 +++++++++++++++++ .../ccfap/vt_ccfap_meets_activity_test.yaml | 19 ++++++++++++ .../income/vt_child_care_contributions.yaml | 8 +++++ .../taxsim/outputs/taxsim_outputs.yaml | 20 +++++++++++++ .../household/emp_self_emp_ratio.yaml | 9 ++++++ .../variables/contrib/taxsim/taxsim_psemp.py | 2 +- .../variables/contrib/taxsim/taxsim_ssemp.py | 2 +- ..._general_relief_meets_work_requirements.py | 11 ++++++- .../dc_ccsp_qualified_activity_eligible.py | 6 +++- .../eligibility/de_poc_activity_eligible.py | 7 ++++- ..._ccap_parent_meets_working_requirements.py | 6 +++- .../ma_gross_income_loss_adjustment.py | 2 +- .../mi/tax/income/mi_household_resources.py | 2 +- .../states/nd/tax/income/credits/nd_mpc.py | 2 +- .../vt_ccfap_meets_activity_test.py | 4 ++- .../tax/income/vt_child_care_contributions.py | 2 +- .../variables/household/emp_self_emp_ratio.py | 13 +++++++-- .../variables/household/marginal_tax_rate.py | 26 ++++++++++++++++- ...inal_tax_rate_including_health_benefits.py | 26 ++++++++++++++++- 26 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/vt/dcf/ccfap/vt_ccfap_meets_activity_test.yaml diff --git a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml index 39480a52589..1874281e480 100644 --- a/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml +++ b/policyengine_us/parameters/gov/states/mi/tax/income/household_resources.yaml @@ -6,7 +6,7 @@ values: - dividend_income - interest_income - farm_income - - self_employment_income + - total_self_employment_income - partnership_s_corp_income - rental_income - farm_rent_income diff --git a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.yaml b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.yaml index 1a6ff92b868..e84bff89368 100644 --- a/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.yaml +++ b/policyengine_us/tests/policy/baseline/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.yaml @@ -73,3 +73,21 @@ members: [person1, person2, person3] output: ca_riv_general_relief_meets_work_requirements: [true, true, true] + +- name: Case 4, SSTB self-employment satisfies the work requirement. + period: 2025-01 + absolute_error_margin: 0.2 + input: + people: + person1: + age: 30 + sstb_self_employment_income: 25_000 + households: + household: + members: [person1] + in_riv: true + spm_units: + spm_unit: + members: [person1] + output: + ca_riv_general_relief_meets_work_requirements: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.yaml index 1fb414f0f3b..b86db4a16b3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.yaml @@ -28,6 +28,16 @@ output: dc_ccsp_qualified_activity_eligible: true +- name: Case 3b, SSTB self-employment counts as working. + period: 2022 + input: + is_tax_unit_head_or_spouse: true + sstb_self_employment_income: 100 + is_full_time_student: false + state_code: DC + output: + dc_ccsp_qualified_activity_eligible: true + - name: Case 4, both parents are working, eligible. period: 2023-01 absolute_error_margin: 0.5 diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_activity_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_activity_eligible.yaml index 53c1a5d310d..8d3c3a9bca9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_activity_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/de/dss/poc/de_poc_activity_eligible.yaml @@ -151,3 +151,22 @@ state_code: DE output: de_poc_activity_eligible: true + +- name: Case 5b, SSTB self-employed parent counts as activity eligible. + period: 2025-01 + input: + people: + person1: + age: 30 + sstb_self_employment_income: 18_000 + person2: + age: 4 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: DE + output: + de_poc_activity_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/ccap/il_ccap_parent_meets_working_requirements.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/ccap/il_ccap_parent_meets_working_requirements.yaml index bff1725ba3b..db7f28cb0c1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/dhs/ccap/il_ccap_parent_meets_working_requirements.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/dhs/ccap/il_ccap_parent_meets_working_requirements.yaml @@ -86,3 +86,32 @@ state_code: IL output: il_ccap_parent_meets_working_requirements: false + +- name: Case 4, SSTB self-employment counts as working. + period: 2023-01 + absolute_error_margin: 0.5 + input: + people: + person1: + sstb_self_employment_income: 9_600 + is_tax_unit_head_or_spouse: true + person2: + employment_income: 100 + is_tax_unit_head_or_spouse: true + person3: + age: 1 + employment_income: 0 + is_tax_unit_dependent: true + spm_units: + spm_unit: + members: [person1, person2, person3] + spm_unit_size: 3 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: IL + output: + il_ccap_parent_meets_working_requirements: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml new file mode 100644 index 00000000000..f217778eca9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ma/tax/income/ma_gross_income_loss_adjustment.yaml @@ -0,0 +1,7 @@ +- name: SSTB Schedule C losses reduce Massachusetts gross income + period: 2024 + input: + sstb_self_employment_income: -10_000 + state_code: MA + output: + ma_gross_income_loss_adjustment: -10_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml index 61ef47f9550..6aa31a2b2b4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_household_resources.yaml @@ -45,6 +45,15 @@ # Total: 100,000 - 50,000 = 50,000 mi_household_resources: 50_000 +- name: Negative SSTB business income floored at 0 + period: 2024 + input: + sstb_self_employment_income: -50_000 + irs_employment_income: 100_000 + state_code: MI + output: + mi_household_resources: 50_000 + - name: Negative rental income floored at 0 period: 2024 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/nd_mpc.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/nd_mpc.yaml index d9763404b10..bcbd6cf2fcd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/nd_mpc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/income/nd_mpc.yaml @@ -104,3 +104,29 @@ state_code: ND output: nd_mpc: 286.64 + +- name: Test 5, SSTB self-employment income counts toward qualified income + absolute_error_margin: 0.01 + period: 2023 + input: + people: + person1: + is_tax_unit_head: true + age: 41 + sstb_self_employment_income: 200_000 + person2: + is_tax_unit_spouse: true + age: 41 + irs_employment_income: 180_000 + spm_units: + spm_unit: + members: [person1, person2] + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: ND + output: + nd_mpc: 287 diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/dcf/ccfap/vt_ccfap_meets_activity_test.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/dcf/ccfap/vt_ccfap_meets_activity_test.yaml new file mode 100644 index 00000000000..e25cacd8d6a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/dcf/ccfap/vt_ccfap_meets_activity_test.yaml @@ -0,0 +1,19 @@ +- name: SSTB self-employment satisfies the Vermont activity test + period: 2025-01 + input: + people: + parent: + age: 30 + sstb_self_employment_income: 30_000 + child: + age: 4 + is_tax_unit_dependent: true + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: VT + output: + vt_ccfap_meets_activity_test: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/vt_child_care_contributions.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/vt_child_care_contributions.yaml index 9db64db2077..4a6a4a955e5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/vt_child_care_contributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/vt_child_care_contributions.yaml @@ -39,3 +39,11 @@ state_code: VT output: vt_child_care_contributions: 0 + +- name: SSTB self-employment income is counted toward the child care contributions + period: 2025 + input: + sstb_self_employment_income: 10_000 + state_code: VT + output: + vt_child_care_contributions: 11 diff --git a/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml b/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml index 1fd3967d6ae..e2bac81c6c6 100644 --- a/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml +++ b/policyengine_us/tests/policy/contrib/taxsim/outputs/taxsim_outputs.yaml @@ -168,6 +168,26 @@ taxsim_psemp: 75000 taxsim_ssemp: 25000 +- name: SSTB self-employment income - TAXSIM outputs + period: 2024 + input: + people: + head: + age: 45 + sstb_self_employment_income: 75000 + is_tax_unit_head: true + spouse: + age: 43 + sstb_self_employment_income: 25000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + output: + taxsim_psemp: 75000 + taxsim_ssemp: 25000 + - name: Unemployment income - TAXSIM outputs period: 2024 input: diff --git a/policyengine_us/tests/variables/household/emp_self_emp_ratio.yaml b/policyengine_us/tests/variables/household/emp_self_emp_ratio.yaml index 3def30e52e5..4d36f929969 100644 --- a/policyengine_us/tests/variables/household/emp_self_emp_ratio.yaml +++ b/policyengine_us/tests/variables/household/emp_self_emp_ratio.yaml @@ -29,3 +29,12 @@ self_employment_income: 0 output: emp_self_emp_ratio: 1.0 + +- name: SSTB self-employment income is included in the earnings ratio + period: 2024 + input: + employment_income: 25_000 + self_employment_income: 0 + sstb_self_employment_income: 25_000 + output: + emp_self_emp_ratio: 0.5 diff --git a/policyengine_us/variables/contrib/taxsim/taxsim_psemp.py b/policyengine_us/variables/contrib/taxsim/taxsim_psemp.py index 6b1980b78cd..a4001737486 100644 --- a/policyengine_us/variables/contrib/taxsim/taxsim_psemp.py +++ b/policyengine_us/variables/contrib/taxsim/taxsim_psemp.py @@ -11,5 +11,5 @@ class taxsim_psemp(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members is_primary = person("is_tax_unit_head", period) - semp = person("self_employment_income", period) + semp = person("total_self_employment_income", period) return tax_unit.sum(semp * is_primary) diff --git a/policyengine_us/variables/contrib/taxsim/taxsim_ssemp.py b/policyengine_us/variables/contrib/taxsim/taxsim_ssemp.py index c7ff4ab36a8..cb45a8af50b 100644 --- a/policyengine_us/variables/contrib/taxsim/taxsim_ssemp.py +++ b/policyengine_us/variables/contrib/taxsim/taxsim_ssemp.py @@ -11,5 +11,5 @@ class taxsim_ssemp(Variable): def formula(tax_unit, period, parameters): person = tax_unit.members is_primary = person("is_tax_unit_spouse", period) - semp = person("self_employment_income", period) + semp = person("total_self_employment_income", period) return tax_unit.sum(semp * is_primary) diff --git a/policyengine_us/variables/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.py b/policyengine_us/variables/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.py index 8fcb3d1f81a..6f75782c5fb 100644 --- a/policyengine_us/variables/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.py +++ b/policyengine_us/variables/gov/local/ca/riv/general_relief/eligibility/ca_riv_general_relief_meets_work_requirements.py @@ -12,7 +12,16 @@ def formula(person, period, parameters): p = parameters(period).gov.local.ca.riv.general_relief.work_exempted_age # Person who is actively searching for jobs also qualify for work requirements is_working = ( - add(person, period, ["employment_income", "self_employment_income"]) > 0 + add( + person, + period, + [ + "employment_income", + "self_employment_income", + "sstb_self_employment_income", + ], + ) + > 0 ) # Check if person is a qualifying secondary school student age = person("monthly_age", period) diff --git a/policyengine_us/variables/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.py b/policyengine_us/variables/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.py index 5f244a77c16..8644eda9592 100644 --- a/policyengine_us/variables/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.py +++ b/policyengine_us/variables/gov/states/dc/dhs/ccsp/eligibility/qualified_activity_or_need/dc_ccsp_qualified_activity_eligible.py @@ -18,7 +18,11 @@ def formula(spm_unit, period, parameters): add( person, period, - ["employment_income", "self_employment_income"], + [ + "employment_income", + "self_employment_income", + "sstb_self_employment_income", + ], ) > 0 ) diff --git a/policyengine_us/variables/gov/states/de/dss/poc/eligibility/de_poc_activity_eligible.py b/policyengine_us/variables/gov/states/de/dss/poc/eligibility/de_poc_activity_eligible.py index 56bfb00ffb6..dc47b42cee1 100644 --- a/policyengine_us/variables/gov/states/de/dss/poc/eligibility/de_poc_activity_eligible.py +++ b/policyengine_us/variables/gov/states/de/dss/poc/eligibility/de_poc_activity_eligible.py @@ -14,7 +14,12 @@ def formula(spm_unit, period, parameters): is_head_or_spouse = person("is_tax_unit_head_or_spouse", period.this_year) # Employment (DSSM 11003) has_employment = (person("employment_income", period) > 0) | ( - person("self_employment_income", period) > 0 + add( + person, + period, + ["self_employment_income", "sstb_self_employment_income"], + ) + > 0 ) # Education / training (DSSM 11003) is_student = person("is_full_time_student", period.this_year) diff --git a/policyengine_us/variables/gov/states/il/dhs/ccap/eligibility/il_ccap_parent_meets_working_requirements.py b/policyengine_us/variables/gov/states/il/dhs/ccap/eligibility/il_ccap_parent_meets_working_requirements.py index 9a5bce23abe..f8d3f81a514 100644 --- a/policyengine_us/variables/gov/states/il/dhs/ccap/eligibility/il_ccap_parent_meets_working_requirements.py +++ b/policyengine_us/variables/gov/states/il/dhs/ccap/eligibility/il_ccap_parent_meets_working_requirements.py @@ -16,7 +16,11 @@ def formula(spm_unit, period, parameters): add( person, period, - ["employment_income", "self_employment_income"], + [ + "employment_income", + "self_employment_income", + "sstb_self_employment_income", + ], ) > 0 ) diff --git a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py index fc7251ba9b5..e3e6b78838f 100644 --- a/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py +++ b/policyengine_us/variables/gov/states/ma/tax/income/gross_income/ma_gross_income_loss_adjustment.py @@ -17,7 +17,7 @@ def formula(tax_unit, period, parameters): # Line 10 instruction: "Be sure to subtract any losses # in lines 6 or 7." # Line 6a: Business/profession loss (Schedule C) - se_income = add(tax_unit, period, ["self_employment_income"]) + se_income = add(tax_unit, period, ["total_self_employment_income"]) # Line 6b: Farm loss (Schedule F) farm = add(tax_unit, period, ["farm_income"]) # Line 7: Rental, partnership, S-corp, farm rent losses diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py index 24984404545..9219966b13e 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_household_resources.py @@ -27,7 +27,7 @@ def formula(tax_unit, period, parameters): # "Net royalty or rent income. If negative, enter 0" floored_sources = { "farm_income", - "self_employment_income", + "total_self_employment_income", "partnership_s_corp_income", "rental_income", "farm_rent_income", diff --git a/policyengine_us/variables/gov/states/nd/tax/income/credits/nd_mpc.py b/policyengine_us/variables/gov/states/nd/tax/income/credits/nd_mpc.py index f8427b4ddc5..47a9348ddba 100644 --- a/policyengine_us/variables/gov/states/nd/tax/income/credits/nd_mpc.py +++ b/policyengine_us/variables/gov/states/nd/tax/income/credits/nd_mpc.py @@ -28,7 +28,7 @@ def formula(tax_unit, period, parameters): # determine minimum qualified income between head and spouse qinc_sources = [ "irs_employment_income", - "self_employment_income", + "total_self_employment_income", "taxable_pension_income", ] person = tax_unit.members diff --git a/policyengine_us/variables/gov/states/vt/dcf/ccfap/eligibility/vt_ccfap_meets_activity_test.py b/policyengine_us/variables/gov/states/vt/dcf/ccfap/eligibility/vt_ccfap_meets_activity_test.py index 7359725cd4f..13fbed91af9 100644 --- a/policyengine_us/variables/gov/states/vt/dcf/ccfap/eligibility/vt_ccfap_meets_activity_test.py +++ b/policyengine_us/variables/gov/states/vt/dcf/ccfap/eligibility/vt_ccfap_meets_activity_test.py @@ -14,7 +14,9 @@ def formula(spm_unit, period): is_adult = person("is_adult", period) # Employment (II B 1 a) or Self Employment (II B 1 b) has_employment = person("employment_income", period.this_year) > 0 - has_self_employment = person("self_employment_income", period.this_year) > 0 + has_self_employment = ( + person("total_self_employment_income", period.this_year) > 0 + ) employed = spm_unit.any(is_adult & (has_employment | has_self_employment)) # Training or Education (II B 1 e) in_training = spm_unit.any( diff --git a/policyengine_us/variables/gov/states/vt/tax/income/vt_child_care_contributions.py b/policyengine_us/variables/gov/states/vt/tax/income/vt_child_care_contributions.py index dbc45959532..d3e5b529b46 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/vt_child_care_contributions.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/vt_child_care_contributions.py @@ -12,7 +12,7 @@ class vt_child_care_contributions(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.vt.tax.income.child_care_contributions if p.applies: - income = add(tax_unit, period, ["self_employment_income"]) + income = add(tax_unit, period, ["total_self_employment_income"]) applicable_income = max_(0, income) * p.rate.income return applicable_income * p.rate.contributions return 0 diff --git a/policyengine_us/variables/household/emp_self_emp_ratio.py b/policyengine_us/variables/household/emp_self_emp_ratio.py index 9edd6b2795b..b904346f1d1 100644 --- a/policyengine_us/variables/household/emp_self_emp_ratio.py +++ b/policyengine_us/variables/household/emp_self_emp_ratio.py @@ -10,9 +10,16 @@ class emp_self_emp_ratio(Variable): reference = "https://www.law.cornell.edu/uscode/text/26/1402" def formula(person, period, parameters): - employment_income = person("employment_income", period) - self_employment_income = person("self_employment_income", period) - earnings = employment_income + self_employment_income + employment_income = max_(0, person("employment_income", period)) + self_employment_income = max_(0, person("self_employment_income", period)) + sstb_self_employment_income = max_( + 0, person("sstb_self_employment_income", period) + ) + earnings = ( + employment_income + + self_employment_income + + sstb_self_employment_income + ) res = np.ones_like(earnings) mask = earnings > 0 res[mask] = employment_income[mask] / earnings[mask] diff --git a/policyengine_us/variables/household/marginal_tax_rate.py b/policyengine_us/variables/household/marginal_tax_rate.py index 4315dc1c773..b77bd5e143d 100644 --- a/policyengine_us/variables/household/marginal_tax_rate.py +++ b/policyengine_us/variables/household/marginal_tax_rate.py @@ -20,7 +20,25 @@ def formula(person, period, parameters): adult_indexes = person("adult_earnings_index", period) employment_income = person("employment_income", period) self_employment_income = person("self_employment_income", period) + sstb_self_employment_income = person("sstb_self_employment_income", period) emp_self_emp_ratio = person("emp_self_emp_ratio", period) + positive_self_employment_income = max_(0, self_employment_income) + positive_sstb_self_employment_income = max_( + 0, sstb_self_employment_income + ) + positive_self_employment_total = ( + positive_self_employment_income + positive_sstb_self_employment_income + ) + non_sstb_share = where( + positive_self_employment_total > 0, + positive_self_employment_income / positive_self_employment_total, + 1, + ) + sstb_share = where( + positive_self_employment_total > 0, + positive_sstb_self_employment_income / positive_self_employment_total, + 0, + ) for adult_index in range(1, 1 + adult_count): alt_sim = sim.get_branch(f"mtr_for_adult_{adult_index}") @@ -36,10 +54,16 @@ def formula(person, period, parameters): period, employment_income + mask * delta * emp_self_emp_ratio, ) + self_employment_delta = mask * delta * (1 - emp_self_emp_ratio) alt_sim.set_input( "self_employment_income", period, - self_employment_income + mask * delta * (1 - emp_self_emp_ratio), + self_employment_income + self_employment_delta * non_sstb_share, + ) + alt_sim.set_input( + "sstb_self_employment_income", + period, + sstb_self_employment_income + self_employment_delta * sstb_share, ) alt_person = alt_sim.person netinc_alt = alt_person.household("household_net_income", period) diff --git a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py index 8de84ed69c2..d6877f1068f 100644 --- a/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py +++ b/policyengine_us/variables/household/marginal_tax_rate_including_health_benefits.py @@ -22,7 +22,25 @@ def formula(person, period, parameters): adult_indexes = person("adult_earnings_index", period) employment_income = person("employment_income", period) self_employment_income = person("self_employment_income", period) + sstb_self_employment_income = person("sstb_self_employment_income", period) emp_self_emp_ratio = person("emp_self_emp_ratio", period) + positive_self_employment_income = max_(0, self_employment_income) + positive_sstb_self_employment_income = max_( + 0, sstb_self_employment_income + ) + positive_self_employment_total = ( + positive_self_employment_income + positive_sstb_self_employment_income + ) + non_sstb_share = where( + positive_self_employment_total > 0, + positive_self_employment_income / positive_self_employment_total, + 1, + ) + sstb_share = where( + positive_self_employment_total > 0, + positive_sstb_self_employment_income / positive_self_employment_total, + 0, + ) for adult_index in range(1, 1 + adult_count): alt_sim = sim.get_branch(f"mtr_for_adult_{adult_index}") @@ -38,10 +56,16 @@ def formula(person, period, parameters): period, employment_income + mask * delta * emp_self_emp_ratio, ) + self_employment_delta = mask * delta * (1 - emp_self_emp_ratio) alt_sim.set_input( "self_employment_income", period, - self_employment_income + mask * delta * (1 - emp_self_emp_ratio), + self_employment_income + self_employment_delta * non_sstb_share, + ) + alt_sim.set_input( + "sstb_self_employment_income", + period, + sstb_self_employment_income + self_employment_delta * sstb_share, ) alt_person = alt_sim.person netinc_alt = alt_person.household( From 5b5fc05e0536efcbb7ca356f17fc437f85914860 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 20:35:16 -0400 Subject: [PATCH 09/11] Handle SSTB income in labor supply responses --- .../core/test_behavioral_response_measurements.py | 8 +++++--- .../employment_income_behavioral_response.yaml | 10 ++++++++++ .../substitution_elasticity.yaml | 11 +++++++++++ .../income/person/weekly_hours_worked.yaml | 15 +++++++++++++++ .../behavioral_response_measurements.py | 2 ++ .../employment_income_behavioral_response.py | 1 + .../substitution_elasticity.py | 1 + .../income/person/weekly_hours_worked.py | 6 ++++-- 8 files changed, 49 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/core/test_behavioral_response_measurements.py b/policyengine_us/tests/core/test_behavioral_response_measurements.py index 23812f722d0..e1fa890f860 100644 --- a/policyengine_us/tests/core/test_behavioral_response_measurements.py +++ b/policyengine_us/tests/core/test_behavioral_response_measurements.py @@ -118,6 +118,7 @@ def __init__(self, simulation): self.values = { "employment_income_before_lsr": np.array([50_000.0, 20_000.0]), "self_employment_income_before_lsr": np.array([0.0, 5_000.0]), + "sstb_self_employment_income": np.array([0.0, 0.0]), "long_term_capital_gains_before_response": np.array([10_000.0, 500.0]), } @@ -246,6 +247,7 @@ def test_lsr_effect_helpers_compute_from_measurements(): { "employment_income_before_lsr": np.array([50_000.0, -20_000.0]), "self_employment_income_before_lsr": np.array([10_000.0, 5_000.0]), + "sstb_self_employment_income": np.array([20_000.0, 0.0]), "income_elasticity": np.array([0.5, 1.0]), "substitution_elasticity": np.array([0.2, 0.4]), } @@ -263,14 +265,14 @@ def test_lsr_effect_helpers_compute_from_measurements(): wage_change_bound=0.8, ) - assert np.allclose(earnings_before_lsr(person, 2026), np.array([60_000.0, 0.0])) + assert np.allclose(earnings_before_lsr(person, 2026), np.array([80_000.0, 0.0])) assert np.allclose( calculate_income_lsr_effect(person, 2026, parameters, measurements), - np.array([3_000.0, 0.0]), + np.array([4_000.0, 0.0]), ) assert np.allclose( calculate_substitution_lsr_effect(person, 2026, parameters, measurements), - np.array([1_500.0, 0.0]), + np.array([2_000.0, 0.0]), ) diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml index 1ca2d8f572d..21d102c1363 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml @@ -25,6 +25,16 @@ output: employment_income_behavioral_response: 800 # 1_200 * (40_000 / 60_000) +- name: Mixed income sources including SSTB + period: 2023 + input: + labor_supply_behavioral_response: 1_200 + employment_income_before_lsr: 40_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income: 20_000 + output: + employment_income_behavioral_response: 800 # 1_200 * (40_000 / 60_000) + - name: Negative self-employment income period: 2023 input: diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml index 925bc4b7f42..07b1f65c0f7 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml @@ -46,3 +46,14 @@ gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 # 45k falls in decile 5 output: substitution_elasticity: 0.15 + +- name: Positive net earnings including SSTB income + period: 2023 + input: + employment_income_before_lsr: 45_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income: 5_000 + gov.simulation.labor_supply_responses.elasticities.substitution.all: 0 + gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 + output: + substitution_elasticity: 0.15 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml index 8c732e8e1f7..b6f37bf4dce 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml @@ -57,3 +57,18 @@ weekly_hours_worked_behavioural_response_income_elasticity: 0 weekly_hours_worked_behavioural_response_substitution_elasticity: 0 weekly_hours_worked_behavioural_response: 0 + +- name: Weekly hours worked includes SSTB self-employment earnings + period: 2022 + input: + weekly_hours_worked_before_lsr: 40 + labor_supply_behavioral_response: 1 + income_elasticity_lsr: 0.1 + substitution_elasticity_lsr: 0.2 + employment_income_before_lsr: 100_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income: 50_000 + output: + weekly_hours_worked_behavioural_response_income_elasticity: 2.6666667e-05 + weekly_hours_worked_behavioural_response_substitution_elasticity: 5.3333333e-05 + weekly_hours_worked_behavioural_response: 8.0000000e-05 diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 60240a3d2a2..ccdbdfe4cd0 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -13,6 +13,7 @@ BEHAVIORAL_RESPONSE_INPUT_VARIABLES = ( "employment_income_before_lsr", "self_employment_income_before_lsr", + "sstb_self_employment_income", "long_term_capital_gains_before_response", ) @@ -96,6 +97,7 @@ def earnings_before_lsr(person, period): [ "employment_income_before_lsr", "self_employment_income_before_lsr", + "sstb_self_employment_income", ], ) return max_(raw_earnings, 0) diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py index fa129addcb8..541747ead04 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py @@ -16,6 +16,7 @@ def formula(person, period, parameters): [ "employment_income_before_lsr", "self_employment_income_before_lsr", + "sstb_self_employment_income", ], ) earnings = max_(raw_earnings, 0) diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py index 951c4433c84..bc3a038eed8 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py @@ -36,6 +36,7 @@ def formula(person, period, parameters): [ "employment_income_before_lsr", "self_employment_income_before_lsr", + "sstb_self_employment_income", ], ) earnings = max_(raw_earnings, 0) diff --git a/policyengine_us/variables/household/income/person/weekly_hours_worked.py b/policyengine_us/variables/household/income/person/weekly_hours_worked.py index c88601549f7..8859cf0e7ea 100644 --- a/policyengine_us/variables/household/income/person/weekly_hours_worked.py +++ b/policyengine_us/variables/household/income/person/weekly_hours_worked.py @@ -41,7 +41,8 @@ def formula(person, period, parameters): original_emp = person("employment_income_before_lsr", period) original_self_emp = person("self_employment_income_before_lsr", period) - original_earnings = original_emp + original_self_emp + original_sstb_self_emp = person("sstb_self_employment_income", period) + original_earnings = original_emp + original_self_emp + original_sstb_self_emp lsr_relative_change = np.divide( income_effect, @@ -70,7 +71,8 @@ def formula(person, period, parameters): substitution_effect = np.zeros_like(original) original_emp = person("employment_income_before_lsr", period) original_self_emp = person("self_employment_income_before_lsr", period) - original_earnings = original_emp + original_self_emp + original_sstb_self_emp = person("sstb_self_employment_income", period) + original_earnings = original_emp + original_self_emp + original_sstb_self_emp lsr_relative_change = np.divide( substitution_effect, From 97689cad6d03535c734141105020501e405e860f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 20:42:38 -0400 Subject: [PATCH 10/11] Preserve SSTB routing in labor supply responses --- .../test_behavioral_response_measurements.py | 31 +++++++++++++++++-- .../self_employment_income.yaml | 13 ++++++++ ...employment_income_behavioral_response.yaml | 2 +- .../substitution_elasticity.yaml | 2 +- .../income/person/weekly_hours_worked.yaml | 2 +- .../behavioral_response_measurements.py | 5 +-- .../employment_income_behavioral_response.py | 2 +- ...f_employment_income_behavioral_response.py | 23 ++++++++++++-- ...f_employment_income_behavioral_response.py | 29 +++++++++++++++++ .../substitution_elasticity.py | 2 +- .../income/person/weekly_hours_worked.py | 8 +++-- .../input/sstb_self_employment_income.py | 4 +++ .../sstb_self_employment_income_before_lsr.py | 17 ++++++++++ 13 files changed, 127 insertions(+), 13 deletions(-) create mode 100644 policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py create mode 100644 policyengine_us/variables/input/sstb_self_employment_income_before_lsr.py diff --git a/policyengine_us/tests/core/test_behavioral_response_measurements.py b/policyengine_us/tests/core/test_behavioral_response_measurements.py index e1fa890f860..3f47e7b2a07 100644 --- a/policyengine_us/tests/core/test_behavioral_response_measurements.py +++ b/policyengine_us/tests/core/test_behavioral_response_measurements.py @@ -6,6 +6,8 @@ import policyengine_us.variables.gov.simulation.capital_gains_responses as capital_gains_module import policyengine_us.variables.gov.simulation.labor_supply_response.income_elasticity_lsr as income_lsr_module import policyengine_us.variables.gov.simulation.labor_supply_response.labor_supply_behavioral_response as labor_supply_module +import policyengine_us.variables.gov.simulation.labor_supply_response.self_employment_income_behavioral_response as self_employment_response_module +import policyengine_us.variables.gov.simulation.labor_supply_response.sstb_self_employment_income_behavioral_response as sstb_self_employment_response_module import policyengine_us.variables.gov.simulation.labor_supply_response.substitution_elasticity_lsr as substitution_lsr_module from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( BASELINE_BEHAVIORAL_RESPONSE_MEASUREMENT_BRANCH, @@ -118,7 +120,7 @@ def __init__(self, simulation): self.values = { "employment_income_before_lsr": np.array([50_000.0, 20_000.0]), "self_employment_income_before_lsr": np.array([0.0, 5_000.0]), - "sstb_self_employment_income": np.array([0.0, 0.0]), + "sstb_self_employment_income_before_lsr": np.array([0.0, 0.0]), "long_term_capital_gains_before_response": np.array([10_000.0, 500.0]), } @@ -247,7 +249,7 @@ def test_lsr_effect_helpers_compute_from_measurements(): { "employment_income_before_lsr": np.array([50_000.0, -20_000.0]), "self_employment_income_before_lsr": np.array([10_000.0, 5_000.0]), - "sstb_self_employment_income": np.array([20_000.0, 0.0]), + "sstb_self_employment_income_before_lsr": np.array([20_000.0, 0.0]), "income_elasticity": np.array([0.5, 1.0]), "substitution_elasticity": np.array([0.2, 0.4]), } @@ -276,6 +278,31 @@ def test_lsr_effect_helpers_compute_from_measurements(): ) +def test_behavioral_response_inputs_split_self_employment_between_buckets(): + person = FakePerson(simulation=SimpleNamespace()) + person.values.update( + { + "labor_supply_behavioral_response": np.array([1_000.0, 1_000.0]), + "employment_income_behavioral_response": np.array([0.0, 400.0]), + "self_employment_income_before_lsr": np.array([0.0, 10_000.0]), + "sstb_self_employment_income_before_lsr": np.array([20_000.0, 30_000.0]), + } + ) + + assert np.allclose( + self_employment_response_module.self_employment_income_behavioral_response.formula( + person, 2026, None + ), + np.array([0.0, 150.0]), + ) + assert np.allclose( + sstb_self_employment_response_module.sstb_self_employment_income_behavioral_response.formula( + person, 2026, None + ), + np.array([1_000.0, 450.0]), + ) + + def test_lsr_effect_helpers_load_measurements_when_not_provided(monkeypatch): person = FakePerson(simulation=SimpleNamespace()) person.values.update( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml index 90ff4dbf8c9..8a398836bdf 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml @@ -28,3 +28,16 @@ sstb_self_employment_income: -20_000 output: loss_ald: 20_000 + +- name: Labor supply response preserves SSTB self-employment category + period: 2024 + input: + labor_supply_behavioral_response: 1_000 + employment_income_before_lsr: 0 + self_employment_income_before_lsr: 0 + sstb_self_employment_income_before_lsr: 10_000 + output: + self_employment_income_behavioral_response: 0 + sstb_self_employment_income_behavioral_response: 1_000 + self_employment_income: 0 + sstb_self_employment_income: 11_000 diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml index 21d102c1363..6056c4a4930 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml @@ -31,7 +31,7 @@ labor_supply_behavioral_response: 1_200 employment_income_before_lsr: 40_000 self_employment_income_before_lsr: 0 - sstb_self_employment_income: 20_000 + sstb_self_employment_income_before_lsr: 20_000 output: employment_income_behavioral_response: 800 # 1_200 * (40_000 / 60_000) diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml index 07b1f65c0f7..2a7be3f5c40 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml @@ -52,7 +52,7 @@ input: employment_income_before_lsr: 45_000 self_employment_income_before_lsr: 0 - sstb_self_employment_income: 5_000 + sstb_self_employment_income_before_lsr: 5_000 gov.simulation.labor_supply_responses.elasticities.substitution.all: 0 gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 output: diff --git a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml index b6f37bf4dce..a31c1e7d58b 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml @@ -67,7 +67,7 @@ substitution_elasticity_lsr: 0.2 employment_income_before_lsr: 100_000 self_employment_income_before_lsr: 0 - sstb_self_employment_income: 50_000 + sstb_self_employment_income_before_lsr: 50_000 output: weekly_hours_worked_behavioural_response_income_elasticity: 2.6666667e-05 weekly_hours_worked_behavioural_response_substitution_elasticity: 5.3333333e-05 diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index ccdbdfe4cd0..1ee666cbbf8 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -8,12 +8,13 @@ NEUTRALIZED_BEHAVIORAL_RESPONSE_VARIABLES = ( "employment_income_behavioral_response", "self_employment_income_behavioral_response", + "sstb_self_employment_income_behavioral_response", "capital_gains_behavioral_response", ) BEHAVIORAL_RESPONSE_INPUT_VARIABLES = ( "employment_income_before_lsr", "self_employment_income_before_lsr", - "sstb_self_employment_income", + "sstb_self_employment_income_before_lsr", "long_term_capital_gains_before_response", ) @@ -97,7 +98,7 @@ def earnings_before_lsr(person, period): [ "employment_income_before_lsr", "self_employment_income_before_lsr", - "sstb_self_employment_income", + "sstb_self_employment_income_before_lsr", ], ) return max_(raw_earnings, 0) diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py index 541747ead04..547ec8686b0 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py @@ -16,7 +16,7 @@ def formula(person, period, parameters): [ "employment_income_before_lsr", "self_employment_income_before_lsr", - "sstb_self_employment_income", + "sstb_self_employment_income_before_lsr", ], ) earnings = max_(raw_earnings, 0) diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py index 25a798a7243..c7456db5342 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py @@ -7,5 +7,24 @@ class self_employment_income_behavioral_response(Variable): label = "self-employment income behavioral response" unit = USD definition_period = YEAR - adds = ["labor_supply_behavioral_response"] - subtracts = ["employment_income_behavioral_response"] + + def formula(person, period, parameters): + lsr = person("labor_supply_behavioral_response", period) + employment_response = person("employment_income_behavioral_response", period) + total_self_employment_response = lsr - employment_response + non_sstb_self_employment_income = max_( + 0, person("self_employment_income_before_lsr", period) + ) + sstb_self_employment_income = max_( + 0, person("sstb_self_employment_income_before_lsr", period) + ) + total_positive_self_employment_income = ( + non_sstb_self_employment_income + sstb_self_employment_income + ) + non_sstb_share = where( + total_positive_self_employment_income > 0, + non_sstb_self_employment_income + / total_positive_self_employment_income, + 1, + ) + return total_self_employment_response * non_sstb_share diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py new file mode 100644 index 00000000000..0b68ded1947 --- /dev/null +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class sstb_self_employment_income_behavioral_response(Variable): + value_type = float + entity = Person + label = "SSTB self-employment income behavioral response" + unit = USD + definition_period = YEAR + + def formula(person, period, parameters): + lsr = person("labor_supply_behavioral_response", period) + employment_response = person("employment_income_behavioral_response", period) + total_self_employment_response = lsr - employment_response + non_sstb_self_employment_income = max_( + 0, person("self_employment_income_before_lsr", period) + ) + sstb_self_employment_income = max_( + 0, person("sstb_self_employment_income_before_lsr", period) + ) + total_positive_self_employment_income = ( + non_sstb_self_employment_income + sstb_self_employment_income + ) + sstb_share = where( + total_positive_self_employment_income > 0, + sstb_self_employment_income / total_positive_self_employment_income, + 0, + ) + return total_self_employment_response * sstb_share diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py index bc3a038eed8..6ddff38c483 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py @@ -36,7 +36,7 @@ def formula(person, period, parameters): [ "employment_income_before_lsr", "self_employment_income_before_lsr", - "sstb_self_employment_income", + "sstb_self_employment_income_before_lsr", ], ) earnings = max_(raw_earnings, 0) diff --git a/policyengine_us/variables/household/income/person/weekly_hours_worked.py b/policyengine_us/variables/household/income/person/weekly_hours_worked.py index 8859cf0e7ea..df0c514efc1 100644 --- a/policyengine_us/variables/household/income/person/weekly_hours_worked.py +++ b/policyengine_us/variables/household/income/person/weekly_hours_worked.py @@ -41,7 +41,9 @@ def formula(person, period, parameters): original_emp = person("employment_income_before_lsr", period) original_self_emp = person("self_employment_income_before_lsr", period) - original_sstb_self_emp = person("sstb_self_employment_income", period) + original_sstb_self_emp = person( + "sstb_self_employment_income_before_lsr", period + ) original_earnings = original_emp + original_self_emp + original_sstb_self_emp lsr_relative_change = np.divide( @@ -71,7 +73,9 @@ def formula(person, period, parameters): substitution_effect = np.zeros_like(original) original_emp = person("employment_income_before_lsr", period) original_self_emp = person("self_employment_income_before_lsr", period) - original_sstb_self_emp = person("sstb_self_employment_income", period) + original_sstb_self_emp = person( + "sstb_self_employment_income_before_lsr", period + ) original_earnings = original_emp + original_self_emp + original_sstb_self_emp lsr_relative_change = np.divide( diff --git a/policyengine_us/variables/input/sstb_self_employment_income.py b/policyengine_us/variables/input/sstb_self_employment_income.py index c6546ef1ed4..c0cab37c3ad 100644 --- a/policyengine_us/variables/input/sstb_self_employment_income.py +++ b/policyengine_us/variables/input/sstb_self_employment_income.py @@ -19,4 +19,8 @@ class sstb_self_employment_income(Variable): "https://www.law.cornell.edu/uscode/text/26/1402#a", "https://www.law.cornell.edu/uscode/text/26/199A#d_2", ) + adds = [ + "sstb_self_employment_income_before_lsr", + "sstb_self_employment_income_behavioral_response", + ] uprating = "calibration.gov.irs.soi.self_employment_income" diff --git a/policyengine_us/variables/input/sstb_self_employment_income_before_lsr.py b/policyengine_us/variables/input/sstb_self_employment_income_before_lsr.py new file mode 100644 index 00000000000..f2d5f62f0a7 --- /dev/null +++ b/policyengine_us/variables/input/sstb_self_employment_income_before_lsr.py @@ -0,0 +1,17 @@ +from policyengine_us.model_api import * + + +class sstb_self_employment_income_before_lsr(Variable): + value_type = float + entity = Person + label = "SSTB self-employment income before labor supply responses" + unit = USD + documentation = ( + "SSTB self-employment non-farm income before labor supply responses." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/1402#a", + "https://www.law.cornell.edu/uscode/text/26/199A#d_2", + ) + uprating = "calibration.gov.irs.soi.self_employment_income" From 17247419d6589ec80f7cd1ca922dbf076d933e2d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 10 Apr 2026 21:29:56 -0400 Subject: [PATCH 11/11] Fix SSTB labor-supply and SNAP consistency --- .../test_behavioral_response_measurements.py | 44 +++++++++++++++++-- .../self_employment_income.yaml | 13 ++++++ ...employment_income_behavioral_response.yaml | 14 +++++- .../substitution_elasticity.yaml | 19 ++++++-- ...nap_self_employment_expense_deduction.yaml | 5 ++- ...oyment_income_after_expense_deduction.yaml | 5 ++- .../income/person/weekly_hours_worked.yaml | 15 +++++++ .../behavioral_response_measurements.py | 16 +++---- .../employment_income_behavioral_response.py | 22 ++++------ ...f_employment_income_behavioral_response.py | 15 +++---- ...f_employment_income_behavioral_response.py | 14 +++--- .../substitution_elasticity.py | 14 ++---- .../snap_self_employment_expense_deduction.py | 5 ++- ...ployment_income_after_expense_deduction.py | 5 ++- .../income/person/weekly_hours_worked.py | 17 +++---- 15 files changed, 148 insertions(+), 75 deletions(-) diff --git a/policyengine_us/tests/core/test_behavioral_response_measurements.py b/policyengine_us/tests/core/test_behavioral_response_measurements.py index 3f47e7b2a07..9eb7a0bb6f8 100644 --- a/policyengine_us/tests/core/test_behavioral_response_measurements.py +++ b/policyengine_us/tests/core/test_behavioral_response_measurements.py @@ -267,17 +267,30 @@ def test_lsr_effect_helpers_compute_from_measurements(): wage_change_bound=0.8, ) - assert np.allclose(earnings_before_lsr(person, 2026), np.array([80_000.0, 0.0])) + assert np.allclose(earnings_before_lsr(person, 2026), np.array([80_000.0, 5_000.0])) assert np.allclose( calculate_income_lsr_effect(person, 2026, parameters, measurements), - np.array([4_000.0, 0.0]), + np.array([4_000.0, -2_500.0]), ) assert np.allclose( calculate_substitution_lsr_effect(person, 2026, parameters, measurements), - np.array([2_000.0, 0.0]), + np.array([2_000.0, 1_600.0]), ) +def test_earnings_before_lsr_uses_sstb_loss_magnitude(): + person = FakePerson(simulation=SimpleNamespace()) + person.values.update( + { + "employment_income_before_lsr": np.array([30_000.0, 0.0]), + "self_employment_income_before_lsr": np.array([0.0, 0.0]), + "sstb_self_employment_income_before_lsr": np.array([-20_000.0, -10_000.0]), + } + ) + + assert np.allclose(earnings_before_lsr(person, 2026), np.array([50_000.0, 10_000.0])) + + def test_behavioral_response_inputs_split_self_employment_between_buckets(): person = FakePerson(simulation=SimpleNamespace()) person.values.update( @@ -303,6 +316,31 @@ def test_behavioral_response_inputs_split_self_employment_between_buckets(): ) +def test_behavioral_response_inputs_preserve_sstb_loss_bucket(): + person = FakePerson(simulation=SimpleNamespace()) + person.values.update( + { + "labor_supply_behavioral_response": np.array([1_000.0]), + "employment_income_behavioral_response": np.array([0.0]), + "self_employment_income_before_lsr": np.array([0.0]), + "sstb_self_employment_income_before_lsr": np.array([-10_000.0]), + } + ) + + assert np.allclose( + self_employment_response_module.self_employment_income_behavioral_response.formula( + person, 2026, None + ), + np.array([0.0]), + ) + assert np.allclose( + sstb_self_employment_response_module.sstb_self_employment_income_behavioral_response.formula( + person, 2026, None + ), + np.array([1_000.0]), + ) + + def test_lsr_effect_helpers_load_measurements_when_not_provided(monkeypatch): person = FakePerson(simulation=SimpleNamespace()) person.values.update( diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml index 8a398836bdf..44ce1f557fa 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employment_income.yaml @@ -41,3 +41,16 @@ sstb_self_employment_income_behavioral_response: 1_000 self_employment_income: 0 sstb_self_employment_income: 11_000 + +- name: Labor supply response preserves SSTB loss category + period: 2024 + input: + labor_supply_behavioral_response: 1_000 + employment_income_before_lsr: 0 + self_employment_income_before_lsr: 0 + sstb_self_employment_income_before_lsr: -10_000 + output: + self_employment_income_behavioral_response: 0 + sstb_self_employment_income_behavioral_response: 1_000 + self_employment_income: 0 + sstb_self_employment_income: -9_000 diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml index 6056c4a4930..57a69c8fbf2 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/employment_income_behavioral_response.yaml @@ -42,7 +42,7 @@ employment_income_before_lsr: 50_000 self_employment_income_before_lsr: -10_000 output: - employment_income_behavioral_response: 1_250 # max_(earnings, 0) = 40_000, emp_share = 50_000/40_000 = 1.25 + employment_income_behavioral_response: 833.3333333333334 # 1_000 * (50_000 / 60_000) - name: Zero total earnings period: 2023 @@ -60,4 +60,14 @@ employment_income_before_lsr: 30_000 self_employment_income_before_lsr: -40_000 # Net negative earnings output: - employment_income_behavioral_response: 1_000 # max_(earnings, 0) = 0, defaults emp_share = 1 + employment_income_behavioral_response: 428.57142857142856 # 1_000 * (30_000 / 70_000) + +- name: Negative SSTB self-employment income uses absolute magnitude + period: 2023 + input: + labor_supply_behavioral_response: 1_000 + employment_income_before_lsr: 30_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income_before_lsr: -20_000 + output: + employment_income_behavioral_response: 600 # 1_000 * (30_000 / 50_000) diff --git a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml index 2a7be3f5c40..fa1dc67969f 100644 --- a/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml +++ b/policyengine_us/tests/policy/baseline/gov/simulation/labor_supply_response/substitution_elasticity.yaml @@ -27,15 +27,15 @@ output: substitution_elasticity: 0 # TODO: Debug why single person isn't getting primary earner elasticity -- name: Negative total earnings should have zero elasticity +- name: Negative self-employment income uses earnings magnitude for decile period: 2023 input: employment_income_before_lsr: 50_000 self_employment_income_before_lsr: -60_000 # Net negative earnings gov.simulation.labor_supply_responses.elasticities.substitution.all: 0 - gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.1: 0.2 + gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.9: 0.2 output: - substitution_elasticity: 0 # max_(earnings, 0) = 0, so elasticity = 0 + substitution_elasticity: 0.2 - name: Positive net earnings after self-employment loss period: 2023 @@ -43,7 +43,7 @@ employment_income_before_lsr: 50_000 self_employment_income_before_lsr: -5_000 # Net positive earnings = 45_000 gov.simulation.labor_supply_responses.elasticities.substitution.all: 0 - gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 # 45k falls in decile 5 + gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.6: 0.15 # 55k falls in decile 6 output: substitution_elasticity: 0.15 @@ -57,3 +57,14 @@ gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 output: substitution_elasticity: 0.15 + +- name: Negative SSTB self-employment income uses earnings magnitude for decile + period: 2023 + input: + employment_income_before_lsr: 30_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income_before_lsr: -20_000 + gov.simulation.labor_supply_responses.elasticities.substitution.all: 0 + gov.simulation.labor_supply_responses.elasticities.substitution.by_position_and_decile.primary.5: 0.15 + output: + substitution_elasticity: 0.15 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml index d68d97b5249..9782da87bee 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_expense_deduction.yaml @@ -54,10 +54,11 @@ # 40% of $1,000 = $400, but actual expenses ($600) are greater snap_self_employment_expense_deduction: 600 -- name: Alaska simplified deduction includes SSTB self-employment income +- name: Alaska simplified deduction uses SSTB income before labor supply response period: 2025 input: - sstb_self_employment_income: 300 + sstb_self_employment_income_before_lsr: 300 + sstb_self_employment_income_behavioral_response: 200 snap_self_employment_income_expense: 100 state_code: AK output: diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml index af0b0fac1a5..4c412560920 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/self_employment_income/snap_self_employment_income_after_expense_deduction.yaml @@ -6,10 +6,11 @@ output: snap_self_employment_income_after_expense_deduction: 200 -- name: SSTB income is included after SNAP self-employment deduction +- name: SSTB income uses the pre-response amount after SNAP deduction period: 2022 input: - sstb_self_employment_income: 500 + sstb_self_employment_income_before_lsr: 500 + sstb_self_employment_income_behavioral_response: 200 snap_self_employment_expense_deduction: 300 output: snap_self_employment_income_after_expense_deduction: 200 diff --git a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml index a31c1e7d58b..8b414421d59 100644 --- a/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/person/weekly_hours_worked.yaml @@ -72,3 +72,18 @@ weekly_hours_worked_behavioural_response_income_elasticity: 2.6666667e-05 weekly_hours_worked_behavioural_response_substitution_elasticity: 5.3333333e-05 weekly_hours_worked_behavioural_response: 8.0000000e-05 + +- name: Weekly hours worked uses SSTB loss magnitude + period: 2022 + input: + weekly_hours_worked_before_lsr: 40 + labor_supply_behavioral_response: 1 + income_elasticity_lsr: 0.1 + substitution_elasticity_lsr: 0.2 + employment_income_before_lsr: 100_000 + self_employment_income_before_lsr: 0 + sstb_self_employment_income_before_lsr: -50_000 + output: + weekly_hours_worked_behavioural_response_income_elasticity: 2.6666667e-05 + weekly_hours_worked_behavioural_response_substitution_elasticity: 5.3333333e-05 + weekly_hours_worked_behavioural_response: 8.0000000e-05 diff --git a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py index 1ee666cbbf8..540be551369 100644 --- a/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py +++ b/policyengine_us/variables/gov/simulation/behavioral_response_measurements.py @@ -92,16 +92,14 @@ def get_behavioral_response_measurements(person, period): # pragma: no cover def earnings_before_lsr(person, period): - raw_earnings = add( - person, - period, - [ - "employment_income_before_lsr", - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], + employment_income = max_(person("employment_income_before_lsr", period), 0) + self_employment_income = abs( + person("self_employment_income_before_lsr", period) ) - return max_(raw_earnings, 0) + sstb_self_employment_income = abs( + person("sstb_self_employment_income_before_lsr", period) + ) + return employment_income + self_employment_income + sstb_self_employment_income def calculate_relative_income_change(measurements, bounds): diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py index 547ec8686b0..7134fa11ac0 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/employment_income_behavioral_response.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( + earnings_before_lsr, +) class employment_income_behavioral_response(Variable): @@ -10,18 +13,9 @@ class employment_income_behavioral_response(Variable): def formula(person, period, parameters): lsr = person("labor_supply_behavioral_response", period) - raw_earnings = add( - person, - period, - [ - "employment_income_before_lsr", - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], - ) - earnings = max_(raw_earnings, 0) - employment_income = person("employment_income_before_lsr", period) - emp_share = np.ones_like(earnings) - mask = earnings > 0 - emp_share[mask] = employment_income[mask] / earnings[mask] + employment_income = max_(person("employment_income_before_lsr", period), 0) + total_earnings = earnings_before_lsr(person, period) + emp_share = np.ones_like(total_earnings) + mask = total_earnings > 0 + emp_share[mask] = employment_income[mask] / total_earnings[mask] return lsr * emp_share diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py index c7456db5342..33be578b892 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/self_employment_income_behavioral_response.py @@ -12,19 +12,18 @@ def formula(person, period, parameters): lsr = person("labor_supply_behavioral_response", period) employment_response = person("employment_income_behavioral_response", period) total_self_employment_response = lsr - employment_response - non_sstb_self_employment_income = max_( - 0, person("self_employment_income_before_lsr", period) + non_sstb_self_employment_income = abs( + person("self_employment_income_before_lsr", period) ) - sstb_self_employment_income = max_( - 0, person("sstb_self_employment_income_before_lsr", period) + sstb_self_employment_income = abs( + person("sstb_self_employment_income_before_lsr", period) ) - total_positive_self_employment_income = ( + total_self_employment_income = ( non_sstb_self_employment_income + sstb_self_employment_income ) non_sstb_share = where( - total_positive_self_employment_income > 0, - non_sstb_self_employment_income - / total_positive_self_employment_income, + total_self_employment_income > 0, + non_sstb_self_employment_income / total_self_employment_income, 1, ) return total_self_employment_response * non_sstb_share diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py b/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py index 0b68ded1947..63f62aaf43f 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/sstb_self_employment_income_behavioral_response.py @@ -12,18 +12,18 @@ def formula(person, period, parameters): lsr = person("labor_supply_behavioral_response", period) employment_response = person("employment_income_behavioral_response", period) total_self_employment_response = lsr - employment_response - non_sstb_self_employment_income = max_( - 0, person("self_employment_income_before_lsr", period) + non_sstb_self_employment_income = abs( + person("self_employment_income_before_lsr", period) ) - sstb_self_employment_income = max_( - 0, person("sstb_self_employment_income_before_lsr", period) + sstb_self_employment_income = abs( + person("sstb_self_employment_income_before_lsr", period) ) - total_positive_self_employment_income = ( + total_self_employment_income = ( non_sstb_self_employment_income + sstb_self_employment_income ) sstb_share = where( - total_positive_self_employment_income > 0, - sstb_self_employment_income / total_positive_self_employment_income, + total_self_employment_income > 0, + sstb_self_employment_income / total_self_employment_income, 0, ) return total_self_employment_response * sstb_share diff --git a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py index 6ddff38c483..80243873fbc 100644 --- a/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py +++ b/policyengine_us/variables/gov/simulation/labor_supply_response/substitution_elasticity.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( + earnings_before_lsr, +) class substitution_elasticity(Variable): @@ -30,16 +33,7 @@ def formula(person, period, parameters): 1_726e3, ] - raw_earnings = add( - person, - period, - [ - "employment_income_before_lsr", - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], - ) - earnings = max_(raw_earnings, 0) + earnings = earnings_before_lsr(person, period) earnings_decile = np.searchsorted(EARNINGS_DECILE_MARKERS, earnings) + 1 tax_unit = person.tax_unit diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py index c77d2e11713..f78dab19001 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py @@ -16,7 +16,10 @@ def formula(spm_unit, period, parameters): self_employment_income = add( spm_unit, period, - ["self_employment_income_before_lsr", "sstb_self_employment_income"], + [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ], ) expenses = spm_unit("snap_self_employment_income_expense", period) p = parameters(period).gov.usda.snap.income.deductions.self_employment diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py index c4f7542aafa..eee469b1c9b 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py @@ -12,7 +12,10 @@ def formula(spm_unit, period, parameters): self_employment_income = add( spm_unit, period, - ["self_employment_income_before_lsr", "sstb_self_employment_income"], + [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ], ) expense_deduction = spm_unit("snap_self_employment_expense_deduction", period) return max_(self_employment_income - expense_deduction, 0) diff --git a/policyengine_us/variables/household/income/person/weekly_hours_worked.py b/policyengine_us/variables/household/income/person/weekly_hours_worked.py index df0c514efc1..9781847491f 100644 --- a/policyengine_us/variables/household/income/person/weekly_hours_worked.py +++ b/policyengine_us/variables/household/income/person/weekly_hours_worked.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.simulation.behavioral_response_measurements import ( + earnings_before_lsr, +) class weekly_hours_worked(Variable): @@ -39,12 +42,7 @@ def formula(person, period, parameters): else: income_effect = np.zeros_like(original) - original_emp = person("employment_income_before_lsr", period) - original_self_emp = person("self_employment_income_before_lsr", period) - original_sstb_self_emp = person( - "sstb_self_employment_income_before_lsr", period - ) - original_earnings = original_emp + original_self_emp + original_sstb_self_emp + original_earnings = earnings_before_lsr(person, period) lsr_relative_change = np.divide( income_effect, @@ -71,12 +69,7 @@ def formula(person, period, parameters): substitution_effect = person("substitution_elasticity_lsr", period) else: substitution_effect = np.zeros_like(original) - original_emp = person("employment_income_before_lsr", period) - original_self_emp = person("self_employment_income_before_lsr", period) - original_sstb_self_emp = person( - "sstb_self_employment_income_before_lsr", period - ) - original_earnings = original_emp + original_self_emp + original_sstb_self_emp + original_earnings = earnings_before_lsr(person, period) lsr_relative_change = np.divide( substitution_effect,