Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/add-sstb-self-employment-income.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -118,6 +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_before_lsr": np.array([0.0, 0.0]),
"long_term_capital_gains_before_response": np.array([10_000.0, 500.0]),
}

Expand Down Expand Up @@ -246,6 +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_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]),
}
Expand All @@ -263,14 +267,77 @@ 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, 5_000.0]))
assert np.allclose(
calculate_income_lsr_effect(person, 2026, parameters, measurements),
np.array([3_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([1_500.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(
{
"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_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]),
)


Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
- 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

- 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

- 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
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,102 @@
# 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: 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:
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
Original file line number Diff line number Diff line change
Expand Up @@ -57,3 +57,37 @@
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

- 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
Loading
Loading