From 6f9c3b949a03d27807986c9c13b3f864146e9ef4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:07:00 -0400 Subject: [PATCH 01/41] Limit the self-employed health insurance deduction to 401(c) earned income 26 U.S.C. 162(l)(2)(A) caps the deduction at earned income within the meaning of section 401(c), which section 401(c)(2)(A)(v) and (vi) reduce by the section 404 plan deduction and the section 164(f) deduction for half of self-employment tax. Form 7206 (and Pub. 535 Worksheet 6-A before 2023) subtracts the share of Schedule 1 line 15 attributable to the plan business and the line 16 plan deduction from that business's net profit. The model used net profit alone. Co-Authored-By: Claude Opus 5.5 --- .../fix-sehi-earned-income-limit.fixed.md | 1 + ...lf_employed_health_insurance_ald_person.py | 20 ++++-- ...ed_health_insurance_earned_income_limit.py | 64 +++++++++++++++++++ 3 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 changelog.d/fix-sehi-earned-income-limit.fixed.md create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py diff --git a/changelog.d/fix-sehi-earned-income-limit.fixed.md b/changelog.d/fix-sehi-earned-income-limit.fixed.md new file mode 100644 index 00000000000..23e7a80bdb3 --- /dev/null +++ b/changelog.d/fix-sehi-earned-income-limit.fixed.md @@ -0,0 +1 @@ +Limit the self-employed health insurance deduction to net profit less the deductible part of self-employment tax and self-employed retirement plan deductions, as 26 U.S.C. 162(l)(2)(A) and Form 7206 require. 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 8ecda97461a..939b3ba1e96 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 @@ -6,11 +6,23 @@ class self_employed_health_insurance_ald_person(Variable): entity = Person label = "Self-employed health insurance ALD for each person" unit = USD - documentation = "Personal above-the-line deduction for self-employed health insurance contributions." + documentation = ( + "Personal above-the-line deduction for self-employed health insurance " + "premiums: the smaller of the premiums and the earned income from the " + "business under which the plan is established (Form 7206 line 14). " + "The premiums exclude qualified long-term care insurance (line 2), " + "which the model does not include in self-employed premiums." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/162#l" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/162#l", + "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + "https://www.irs.gov/pub/irs-prior/i1040gi--2022.pdf#page=89", + ) def formula(person, period, parameters): - earnings = max_(0, person("total_self_employment_income", period)) premiums = person("self_employed_health_insurance_premiums", period) - return min_(earnings, premiums) + earned_income_limit = person( + "self_employed_health_insurance_earned_income_limit", period + ) + return min_(premiums, earned_income_limit) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py new file mode 100644 index 00000000000..91cb5423d44 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -0,0 +1,64 @@ +from policyengine_us.model_api import * + + +class self_employed_health_insurance_earned_income_limit(Variable): + value_type = float + entity = Person + label = "Self-employed health insurance deduction earned income limit" + unit = USD + documentation = ( + "Earned income, within the meaning of 26 U.S.C. 401(c), from the " + "business under which the health plan is established; the " + "self-employed health insurance deduction cannot exceed it (Form " + "7206 line 13). The model treats the person's Schedule C business as " + "that business. Its net profit is reduced by the part of the " + "deductible part of self-employment tax attributable to it (lines 5 " + "to 7) and by the self-employed SEP, SIMPLE and qualified plan " + "deduction (line 9), which the model also bases on Schedule C income. " + "S corporation wages (line 11) are not modeled, and self-employment " + "income inputs are already net of any section 911 exclusion (line 12)." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", + "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", + "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + # The worksheet Form 7206 replaced, used before 2023. + "https://www.irs.gov/pub/irs-prior/p535--2022.pdf#page=23", + # The single-business worksheet in the Form 1040 instructions. + "https://www.irs.gov/pub/irs-prior/i1040gi--2022.pdf#page=89", + ) + + def formula(person, period, parameters): + # Line 4: net profit from the business under which the plan is + # established. + plan_business_profit = max_(0, person("total_self_employment_income", period)) + # Line 5: net profits of every business whose earnings are subject to + # self-employment tax, leaving out net losses. + other_business_profits = max_( + 0, person("farm_operations_income", period) + ) + max_(0, person("partnership_self_employment_net_earnings", period)) + all_business_profits = plan_business_profit + other_business_profits + # Line 6: the plan business's share of those profits. + plan_business_share = np.divide( + plan_business_profit, + all_business_profits, + out=np.zeros_like(all_business_profits), + where=all_business_profits > 0, + ) + # Lines 7 and 8: the deductible part of self-employment tax + # (Schedule 1 line 15) attributable to the plan business. + self_employment_tax_deduction = ( + person("self_employment_tax_ald_person", period) * plan_business_share + ) + # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction + # (Schedule 1 line 16) for the plan business. + retirement_plan_deduction = person( + "self_employed_pension_contribution_ald_person", period + ) + return max_( + 0, + plan_business_profit + - self_employment_tax_deduction + - retirement_plan_deduction, + ) From a885a16f125c143e06aca12424630c34b24bf249 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:09:34 -0400 Subject: [PATCH 02/41] Test the self-employed health insurance earned income limit against Form 7206 Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_ald.yaml | 102 ++++++++++++++++++ ..._health_insurance_earned_income_limit.yaml | 95 ++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml new file mode 100644 index 00000000000..2937a6441e7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -0,0 +1,102 @@ +# Form 7206 line 14 (the Form 1040 instructions worksheet line 3 before 2023): +# the smaller of the premiums and the earned income limit. Expected values are +# hand-computed from the form. + +- name: Case 1, premiums above the limit are capped at net profit less half of self-employment tax. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_self_employed: true + self_employment_income: 2_000 + health_insurance_premiums: 6_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + # 2022 Form 1040 instructions worksheet: line 1 is 6,000; line 2 is + # 2,000 - 141.2955 (Schedule 1 line 15) = 1,858.7045. + self_employed_health_insurance_ald_person: [1_858.7045] + self_employed_health_insurance_ald: 1_858.7045 + # 2,000 - 141.2955 - 1,858.7045. + adjusted_gross_income: 0 + +- name: Case 2, premiums below the limit are deducted in full. + period: 2022 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 50_000 + health_insurance_premiums: 6_000 + output: + # Line 2: 50,000 - 3,532.3875 = 46,467.6125. + self_employed_health_insurance_earned_income_limit: 46_467.6125 + self_employed_health_insurance_ald_person: 6_000 + +- name: Case 3, the plan deduction and half of self-employment tax both reduce the cap. + period: 2023 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + health_insurance_premiums: 20_000 + output: + # Form 7206: line 3 is 20,000; line 13 is 20,000 - 1,412.955 - 3,000. + self_employed_health_insurance_ald_person: 15_587.045 + +- name: Case 4, a farm business takes its share of the self-employment tax deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 30_000 + farm_operations_income: 10_000 + health_insurance_premiums: 40_000 + output: + # Form 7206 line 13: 30,000 - 0.75 x 2,825.91 = 27,880.5675. + self_employed_health_insurance_ald_person: 27_880.5675 + +- name: Case 5, each spouse's deduction is capped by their own earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + is_self_employed: true + self_employment_income: 3_000 + health_insurance_premiums: 8_000 + person2: + is_self_employed: true + self_employment_income: 40_000 + health_insurance_premiums: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # person1: 3,000 - 3,000 x 0.9235 x 0.153 / 2 = 3,000 - 211.94325. + # person2: premiums of 2,000 are under the limit. + self_employed_health_insurance_ald_person: [2_788.05675, 2_000] + self_employed_health_insurance_ald: 4_788.05675 + +- name: Case 6, no deduction with a Schedule C loss. + period: 2025 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: -5_000 + health_insurance_premiums: 3_000 + output: + self_employed_health_insurance_ald_person: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml new file mode 100644 index 00000000000..4c9af5e3662 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -0,0 +1,95 @@ +# Form 7206 lines 4-13 (Pub. 535 Worksheet 6-A before 2023). Expected values +# are hand-computed from the form: the deductible part of self-employment tax +# is net profit x 0.9235 x 15.3% / 2 below the Social Security wage base. + +- name: Case 1, one business, the limit is net profit less half of self-employment tax. + period: 2022 + absolute_error_margin: 0.01 + input: + self_employment_income: 2_000 + output: + # Line 15: 2,000 x 0.9235 x 0.153 / 2 = 141.2955. + self_employment_tax_ald_person: 141.2955 + # Line 13: 2,000 - 141.2955. + self_employed_health_insurance_earned_income_limit: 1_858.7045 + +- name: Case 2, the SEP, SIMPLE and qualified plan deduction also reduces the limit. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + output: + # Line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. + self_employment_tax_ald_person: 1_412.955 + # Line 16 (within the $66,000 annual additions limit). + self_employed_pension_contribution_ald_person: 3_000 + # Lines 8 to 10: 20,000 - 1,412.955 - 3,000. + self_employed_health_insurance_earned_income_limit: 15_587.045 + +- name: Case 3, a farm business takes its share of the self-employment tax deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + farm_operations_income: 10_000 + output: + # Line 15: 40,000 x 0.9235 x 0.153 / 2 = 2,825.91. + self_employment_tax_ald_person: 2_825.91 + # Lines 5 to 8: 30,000 / 40,000 = 0.75; 30,000 - 0.75 x 2,825.91. + self_employed_health_insurance_earned_income_limit: 27_880.5675 + +- name: Case 4, a partnership's self-employment earnings take their share too. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + partnership_self_employment_net_earnings: 30_000 + output: + # Line 15: 60,000 x 0.9235 x 0.153 / 2 = 4,238.865. + self_employment_tax_ald_person: 4_238.865 + # Lines 5 to 8: 30,000 / 60,000 = 0.5; 30,000 - 0.5 x 4,238.865. + self_employed_health_insurance_earned_income_limit: 27_880.5675 + +- name: Case 5, a farm loss is left out of line 5, so the plan business takes the whole deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + farm_operations_income: -10_000 + output: + # Line 15: (30,000 - 10,000) x 0.9235 x 0.153 / 2 = 1,412.955. + self_employment_tax_ald_person: 1_412.955 + # Lines 5 to 8: line 5 is 30,000, so line 6 is 1; 30,000 - 1,412.955. + self_employed_health_insurance_earned_income_limit: 28_587.045 + +- name: Case 6, wages above the Social Security wage base leave only the Medicare part. + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 200_000 + self_employment_income: 10_000 + output: + # Wages exceed the $176,100 wage base, so line 15 is + # 10,000 x 0.9235 x 0.029 / 2 = 133.9075. + self_employment_tax_ald_person: 133.9075 + self_employed_health_insurance_earned_income_limit: 9_866.0925 + +- name: Case 7, net earnings under $400 owe no self-employment tax, so the limit is net profit. + period: 2025 + absolute_error_margin: 0.01 + input: + self_employment_income: 400 + output: + # 400 x 0.9235 = 369.40, under the $400 threshold of section 1402(b)(2). + self_employment_tax_ald_person: 0 + self_employed_health_insurance_earned_income_limit: 400 + +- name: Case 8, a Schedule C loss leaves no limit. + period: 2025 + absolute_error_margin: 0.01 + input: + self_employment_income: -5_000 + partnership_self_employment_net_earnings: 20_000 + output: + self_employed_health_insurance_earned_income_limit: 0 From 06f215b992e3a335e62fddc8d1df9c2f21bd93fc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:16:37 -0400 Subject: [PATCH 03/41] Add Form 7206 property tests for the self-employed health insurance deduction Co-Authored-By: Claude Opus 5.5 --- ...lf_employed_health_insurance_properties.py | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py new file mode 100644 index 00000000000..9d6cf8ffe5a --- /dev/null +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -0,0 +1,199 @@ +"""Properties of the self-employed health insurance deduction. + +26 U.S.C. 162(l)(2)(A) limits the deduction to the taxpayer's earned income, +within the meaning of section 401(c), from the business under which the plan +is established. Section 401(c)(2)(A)(v) and (vi) determine that earned income +with regard to the section 404 plan deduction and the section 164(f) deduction +for half of self-employment tax. Form 7206 (Pub. 535 Worksheet 6-A before +2023) computes it as the plan business's net profit (line 4), less the part of +Schedule 1 line 15 attributable to that business by its share of all net +profits subject to self-employment tax (lines 5 to 8), less the Schedule 1 line +16 plan deduction (lines 9 and 10); the deduction is the smaller of the +premiums and that amount (line 14). The model treats a person's Schedule C +income as the plan business. + +Hypothesis draws batches of self-employed single filers and runs each batch as +one vectorized baseline simulation. Each drawn filer appears twice, the second +time with higher premiums. For every filer and year: + +1. Reference: the limit equals a closed-form Form 7206 computation that takes + lines 15 and 16 from the model, and line 15 equals half of the + self-employment tax. +2. Bounds: 0 <= deduction <= premiums, and deduction <= Schedule C profit less + the attributable part of line 15 (earned income after the self-employment + tax deduction), which never exceeds Schedule C profit. +3. Monotone in premiums: raising premiums by d raises the deduction by at + least 0 and at most d, and leaves the limit unchanged. +4. The deduction never makes adjusted gross income negative for a filer whose + only income is Schedule C profit: such a filer with negative AGI (from the + plan deduction) has no health insurance deduction. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 +YEARS = [2022, 2024, 2026] +PREMIUM_RAISE = 2_500 + + +def every_year(value): + return {year: value for year in YEARS} + + +def build_situation(filers): + """One single-filer household per drawn filer, then again with higher premiums.""" + people, tax_units, households = {}, {}, {} + for raised in (False, True): + for i, filer in enumerate(filers): + name = f"{'r' if raised else 'b'}{i}" + premiums = filer["premiums"] + (PREMIUM_RAISE if raised else 0) + people[name] = { + "age": every_year(45), + "is_self_employed": every_year(True), + "self_employment_income": every_year(filer["schedule_c"]), + "farm_operations_income": every_year(filer["farm"]), + "partnership_self_employment_net_earnings": every_year( + filer["partnership"] + ), + "employment_income": every_year(filer["wages"]), + "self_employed_pension_contributions_desired": every_year( + filer["plan"] + ), + "health_insurance_premiums": every_year(premiums), + } + tax_units[name] = {"members": [name]} + households[name] = {"members": [name], "state_code": every_year("TX")} + situation = {"people": people, "tax_units": tax_units, "households": households} + return situation, len(filers) + + +def form_7206_limit(schedule_c, farm, partnership, line_15, line_16): + """Form 7206 lines 4 to 10 and 13, with lines 15 and 16 as given amounts.""" + line_4 = np.maximum(schedule_c, 0) + line_5 = line_4 + np.maximum(farm, 0) + np.maximum(partnership, 0) + line_6 = np.where(line_5 > 0, line_4 / np.where(line_5 > 0, line_5, 1), 0) + line_8 = line_4 - line_15 * line_6 + return np.maximum(line_8 - line_16, 0), line_8 + + +def column(filers, key, n_copies=2): + return np.tile(np.array([float(f[key]) for f in filers]), n_copies) + + +def check_properties(sim, filers, n): + schedule_c = column(filers, "schedule_c") + farm = column(filers, "farm") + partnership = column(filers, "partnership") + wages = column(filers, "wages") + for year in YEARS: + premiums = sim.calculate("self_employed_health_insurance_premiums", year) + limit = sim.calculate( + "self_employed_health_insurance_earned_income_limit", year + ) + deduction = sim.calculate("self_employed_health_insurance_ald_person", year) + line_15 = sim.calculate("self_employment_tax_ald_person", year) + line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) + se_tax = sim.calculate("self_employment_tax", year) + + # 1. Reference. + np.testing.assert_allclose(line_15, se_tax / 2, atol=TOLERANCE) + expected_limit, after_se_tax = form_7206_limit( + schedule_c, farm, partnership, line_15, line_16 + ) + np.testing.assert_allclose(limit, expected_limit, atol=TOLERANCE) + np.testing.assert_allclose( + deduction, np.minimum(premiums, expected_limit), atol=TOLERANCE + ) + + # 2. Bounds. + assert np.all(deduction >= 0) + assert np.all(deduction <= premiums + TOLERANCE) + assert np.all(deduction <= np.maximum(after_se_tax, 0) + TOLERANCE) + assert np.all(after_se_tax <= np.maximum(schedule_c, 0) + TOLERANCE) + + # 3. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. + base, raised = deduction[:n], deduction[n:] + assert np.all(raised >= base - TOLERANCE) + assert np.all(raised <= base + PREMIUM_RAISE + TOLERANCE) + np.testing.assert_allclose(limit[:n], limit[n:], atol=TOLERANCE) + + # 4. The deduction never takes Schedule C-only AGI below zero. + schedule_c_only = (farm == 0) & (partnership == 0) & (wages == 0) + agi = sim.calculate("adjusted_gross_income", year) + negative_agi = schedule_c_only & (agi < -TOLERANCE) + assert np.all(deduction[negative_agi] <= TOLERANCE) + + +money = st.integers(0, 120_000) +filer_strategy = st.fixed_dictionaries( + { + "schedule_c": st.one_of( + st.sampled_from([-5_000, 0, 400, 434, 2_000, 160_000, 250_000]), + st.integers(-20_000, 300_000), + ), + "farm": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "partnership": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "wages": st.one_of(st.just(0), st.integers(0, 250_000)), + "plan": st.one_of(st.just(0), money), + "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), + } +) +SETTINGS = dict( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(filer_strategy, min_size=4, max_size=20)) +def test_deduction_follows_form_7206(filers): + situation, n = build_situation(filers) + check_properties(Simulation(situation=situation), filers, n) + + +def test_reviewer_example_and_boundaries(): + """Fixed filers: the $2,000 example, the $400 threshold, and a wage-base case.""" + filers = [ + dict(schedule_c=2_000, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), + dict(schedule_c=434, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), + dict(schedule_c=433, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), + dict( + schedule_c=10_000, + farm=0, + partnership=0, + wages=250_000, + plan=0, + premiums=20_000, + ), + dict( + schedule_c=30_000, + farm=10_000, + partnership=0, + wages=0, + plan=0, + premiums=40_000, + ), + dict( + schedule_c=20_000, + farm=0, + partnership=0, + wages=0, + plan=20_000, + premiums=5_000, + ), + ] + situation, n = build_situation(filers) + sim = Simulation(situation=situation) + check_properties(sim, filers, n) + deduction = sim.calculate("self_employed_health_insurance_ald_person", 2022) + # 2,000 - 2,000 x 0.9235 x 0.153 / 2. + assert abs(deduction[0] - 1_858.7045) < TOLERANCE + # 434 x 0.9235 = 400.80 owes self-employment tax; 433 x 0.9235 = 399.88 does not. + assert deduction[1] < 434 - TOLERANCE + assert abs(deduction[2] - 433) < TOLERANCE From 1bad2f16f64dc4cd23e46c01d2a3097e4dd0ef1b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 13:23:57 -0400 Subject: [PATCH 04/41] Update SSTB and Ohio expectations for the 401(c) earned income limit Co-Authored-By: Claude Opus 5.5 --- .../self_employment_income.yaml | 4 +++- .../oh_medical_care_insurance_premiums.yaml | 6 ++++-- ...h_uninsured_unreimbursed_medical_care_expenses.yaml | 10 ++++++---- 3 files changed, 13 insertions(+), 7 deletions(-) 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 526b7ca01e0..11b233376f0 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 @@ -8,11 +8,13 @@ - name: Self-employed health insurance ALD uses SSTB income period: 2024 + absolute_error_margin: 0.01 input: sstb_self_employment_income: 10_000 self_employed_health_insurance_premiums: 12_000 output: - self_employed_health_insurance_ald_person: 10_000 + # Form 7206 line 13: 10,000 - 10,000 x 0.9235 x 0.153 / 2. + self_employed_health_insurance_ald_person: 9_293.5225 - name: Self-employed pension ALD uses SSTB income period: 2024 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml index 36c1c46fd9c..77175d10352 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml @@ -33,14 +33,16 @@ - name: Premiums deducted as self-employed health insurance are left out period: 2025 + absolute_error_margin: 0.01 input: state_code: OH is_self_employed: true self_employment_income: 3_000 health_insurance_premiums: 5_000 output: - # 5,000 - min(3,000, 5,000). - oh_medical_care_insurance_premiums: 2_000 + # The federal deduction is min(5,000, 3,000 - 3,000 x 0.9235 x 0.153 / 2) + # = 2,788.05675 (Form 7206 line 14), so 5,000 - 2,788.05675 remain. + oh_medical_care_insurance_premiums: 2_211.94325 - name: The self-employed deduction cannot make premiums negative period: 2025 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml index d9eac668dff..b6853f57ac3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml @@ -179,6 +179,7 @@ # Worksheet line 1 note: "You must reduce the amount you enter on this line # by your federal self-employed health insurance deduction". period: 2025 + absolute_error_margin: 0.01 input: people: person1: @@ -194,7 +195,8 @@ members: [person1] state_code: OH output: - # min(3,000 of earnings, 5,000 of premiums). - self_employed_health_insurance_ald: 3_000 - # 5,000 - 3,000. - oh_uninsured_unreimbursed_medical_care_expenses: 2_000 + # Form 7206 line 14: min(5,000 of premiums, 3,000 of earnings less the + # 211.94325 deductible part of self-employment tax). + self_employed_health_insurance_ald: 2_788.05675 + # 5,000 - 2,788.05675. + oh_uninsured_unreimbursed_medical_care_expenses: 2_211.94325 From 32aec888d8031574fed2c093e62d264ba8057a90 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:15:21 -0400 Subject: [PATCH 05/41] Widen the self-employed health insurance limit to farm, partnership and S corporation plans Form 7206 allows the plan to be established under a Schedule F farm, a partnership with K-1 box 14 code A earnings, or an S corporation in which the person is a more-than-2% shareholder (162(l)(5), line 11), one form per plan. Add self_employed_health_insurance_plan_business (default: the business with the largest earned income) and s_corp_shareholder_medicare_wages. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_ald.yaml | 52 ++++++++ ..._health_insurance_earned_income_limit.yaml | 114 +++++++++++++++++- ...ed_health_insurance_earned_income_limit.py | 80 ++++++++---- ...employed_health_insurance_plan_business.py | 35 ++++++ .../s_corp_shareholder_medicare_wages.py | 25 ++++ 5 files changed, 280 insertions(+), 26 deletions(-) create mode 100644 policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py create mode 100644 policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index 2937a6441e7..e1683e2ce58 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -100,3 +100,55 @@ health_insurance_premiums: 3_000 output: self_employed_health_insurance_ald_person: 0 + +- name: Case 7, a farmer whose plan is established under the farm deducts up to farm earned income. + period: 2024 + absolute_error_margin: 0.01 + input: + is_self_employed: true + farm_operations_income: 20_000 + health_insurance_premiums: 25_000 + output: + # Form 7206: line 3 is 25,000; line 13 is 20,000 - 1,412.955. + self_employed_health_insurance_ald_person: 18_587.045 + # 20,000 - 1,412.955 - 18,587.045. + adjusted_gross_income: 0 + +- name: Case 8, a partner deducts up to their K-1 box 14 code A earnings less half of self-employment tax. + period: 2025 + absolute_error_margin: 0.01 + input: + is_self_employed: true + partnership_income: 30_000 + partnership_self_employment_net_earnings: 30_000 + health_insurance_premiums: 40_000 + output: + # Form 7206 line 13: 30,000 - 2,119.4325. + self_employed_health_insurance_ald_person: 27_880.5675 + adjusted_gross_income: 0 + +- name: Case 9, an S corporation shareholder deducts up to their wages from the corporation. + period: 2025 + absolute_error_margin: 0.01 + input: + is_self_employed: true + employment_income: 10_000 + s_corp_shareholder_medicare_wages: 10_000 + health_insurance_premiums: 12_000 + output: + # Form 7206: line 3 is 12,000; line 13 is line 11, 10,000. + self_employed_health_insurance_ald_person: 10_000 + adjusted_gross_income: 0 + +- name: Case 10, a plan named under the smaller farm is limited to the farm's earned income. + period: 2024 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 30_000 + farm_operations_income: 10_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + health_insurance_premiums: 20_000 + output: + # Form 7206 line 13: 10,000 - 0.25 x 2,825.91. + self_employed_health_insurance_ald_person: 9_293.5225 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 4c9af5e3662..8c2390748c0 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -1,6 +1,10 @@ # Form 7206 lines 4-13 (Pub. 535 Worksheet 6-A before 2023). Expected values # are hand-computed from the form: the deductible part of self-employment tax # is net profit x 0.9235 x 15.3% / 2 below the Social Security wage base. +# One Form 7206 per plan: the limit is the earned income of the business under +# which the plan is established (self_employed_health_insurance_plan_business), +# or of the person's business with the largest earned income when that input +# is not supplied. - name: Case 1, one business, the limit is net profit less half of self-employment tax. period: 2022 @@ -85,11 +89,119 @@ self_employment_tax_ald_person: 0 self_employed_health_insurance_earned_income_limit: 400 -- name: Case 8, a Schedule C loss leaves no limit. +- name: Case 8, a plan established under a Schedule C business with a loss has no limit. period: 2025 absolute_error_margin: 0.01 input: self_employment_income: -5_000 partnership_self_employment_net_earnings: 20_000 + self_employed_health_insurance_plan_business: SCHEDULE_C output: self_employed_health_insurance_earned_income_limit: 0 + +- name: Case 9, a Schedule C loss leaves the partnership as the plan business. + period: 2025 + absolute_error_margin: 0.01 + input: + self_employment_income: -5_000 + partnership_self_employment_net_earnings: 20_000 + output: + # Line 15: (20,000 - 5,000) x 0.9235 x 0.153 / 2 = 1,059.71625. + self_employment_tax_ald_person: 1_059.71625 + # Line 5 leaves out the loss, so line 6 is 1: 20,000 - 1,059.71625. + self_employed_health_insurance_earned_income_limit: 18_940.28375 + +- name: Case 10, a farm is the only business. + period: 2024 + absolute_error_margin: 0.01 + input: + farm_operations_income: 20_000 + output: + # Line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. + self_employment_tax_ald_person: 1_412.955 + # Lines 4 to 8 for Schedule F: 20,000 - 1,412.955. + self_employed_health_insurance_earned_income_limit: 18_587.045 + +- name: Case 11, a partnership with K-1 box 14 code A earnings is the only business. + period: 2025 + absolute_error_margin: 0.01 + input: + partnership_self_employment_net_earnings: 30_000 + output: + # Line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. + self_employment_tax_ald_person: 2_119.4325 + self_employed_health_insurance_earned_income_limit: 27_880.5675 + +- name: Case 12, without a named business the larger farm is the plan business. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 5_000 + farm_operations_income: 40_000 + output: + # Line 15: 45,000 x 0.9235 x 0.153 / 2 = 3,179.14875. + self_employment_tax_ald_person: 3_179.14875 + # Farm: 40,000 - 40,000 / 45,000 x 3,179.14875 = 37,174.09. + # Schedule C: 5,000 - 5,000 / 45,000 x 3,179.14875 = 4,646.76125. + self_employed_health_insurance_earned_income_limit: 37_174.09 + +- name: Case 13, a named Schedule C business sets the limit even when the farm earns more. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 5_000 + farm_operations_income: 40_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + self_employed_health_insurance_earned_income_limit: 4_646.76125 + +- name: Case 14, a named farm business takes its own share of the self-employment tax deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + farm_operations_income: 10_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # Line 15: 40,000 x 0.9235 x 0.153 / 2 = 2,825.91. + # Lines 5 to 8: 10,000 / 40,000 = 0.25; 10,000 - 0.25 x 2,825.91. + self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 15, the plan deduction is attributable to Schedule C, so the partnership earns more. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 15_000 + partnership_self_employment_net_earnings: 10_000 + output: + # Line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. + self_employment_tax_ald_person: 2_119.4325 + self_employed_pension_contribution_ald_person: 15_000 + # Schedule C: 20,000 - 2 / 3 x 2,119.4325 - 15,000 = 3,587.045. + # Partnership (line 9 is 0): 10,000 - 1 / 3 x 2,119.4325 = 9,293.5225. + self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 16, an S corporation shareholder's wages are the earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 50_000 + s_corp_shareholder_medicare_wages: 50_000 + self_employment_income: 10_000 + output: + # Line 11: 50,000. The Schedule C business would give + # 10,000 - 10,000 x 0.9235 x 0.153 / 2 = 9,293.5225. + self_employed_health_insurance_earned_income_limit: 50_000 + +- name: Case 17, a named Schedule C business ignores the S corporation wages. + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 50_000 + s_corp_shareholder_medicare_wages: 50_000 + self_employment_income: 10_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + self_employment_tax_ald_person: 706.4775 + self_employed_health_insurance_earned_income_limit: 9_293.5225 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index 91cb5423d44..c58807e606f 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -10,19 +10,31 @@ class self_employed_health_insurance_earned_income_limit(Variable): "Earned income, within the meaning of 26 U.S.C. 401(c), from the " "business under which the health plan is established; the " "self-employed health insurance deduction cannot exceed it (Form " - "7206 line 13). The model treats the person's Schedule C business as " - "that business. Its net profit is reduced by the part of the " - "deductible part of self-employment tax attributable to it (lines 5 " - "to 7) and by the self-employed SEP, SIMPLE and qualified plan " - "deduction (line 9), which the model also bases on Schedule C income. " - "S corporation wages (line 11) are not modeled, and self-employment " + "7206 line 13). The plan may be established under a Schedule C " + "business, a Schedule F farm, a partnership that reports net earnings " + "on Schedule K-1 box 14 code A, or an S corporation in which the " + "person is a more-than-2% shareholder, and earned income from other " + "businesses does not count. For each of the first three, earned income " + "is net profit less the part of the deductible part of " + "self-employment tax attributable to that business (lines 4 to 8) and " + "less the self-employed SEP, SIMPLE and qualified plan deduction " + "attributable to it (lines 9 and 10); the model bases that plan " + "deduction on Schedule C income, so only the Schedule C business " + "subtracts it. For an S corporation it is the shareholder's wages " + "from the corporation (line 11). self_employed_health_insurance_plan_" + "business names the business; when it is not supplied, the limit uses " + "the business with the largest earned income, as one plan established " + "under the person's most profitable business would. Self-employment " "income inputs are already net of any section 911 exclusion (line 12)." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", + "https://www.law.cornell.edu/uscode/text/26/162#l_5", "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + # Where a plan may be established, and one Form 7206 per plan. + "https://www.irs.gov/pub/irs-prior/i7206--2023.pdf#page=1", # The worksheet Form 7206 replaced, used before 2023. "https://www.irs.gov/pub/irs-prior/p535--2022.pdf#page=23", # The single-business worksheet in the Form 1040 instructions. @@ -30,35 +42,53 @@ class self_employed_health_insurance_earned_income_limit(Variable): ) def formula(person, period, parameters): - # Line 4: net profit from the business under which the plan is - # established. - plan_business_profit = max_(0, person("total_self_employment_income", period)) + # Line 4 for each business that can establish a plan: its net profit, + # or a partner's net earnings from self-employment. + schedule_c_profit = max_(0, person("total_self_employment_income", period)) + farm_profit = max_(0, person("farm_operations_income", period)) + partnership_profit = max_( + 0, person("partnership_self_employment_net_earnings", period) + ) # Line 5: net profits of every business whose earnings are subject to # self-employment tax, leaving out net losses. - other_business_profits = max_( - 0, person("farm_operations_income", period) - ) + max_(0, person("partnership_self_employment_net_earnings", period)) - all_business_profits = plan_business_profit + other_business_profits - # Line 6: the plan business's share of those profits. - plan_business_share = np.divide( - plan_business_profit, + all_business_profits = schedule_c_profit + farm_profit + partnership_profit + # Lines 6 and 7: each business takes the deductible part of + # self-employment tax (Schedule 1 line 15) in proportion to its share + # of line 5. + self_employment_tax_deduction = person("self_employment_tax_ald_person", period) + self_employment_tax_deduction_rate = np.divide( + self_employment_tax_deduction, all_business_profits, out=np.zeros_like(all_business_profits), where=all_business_profits > 0, ) - # Lines 7 and 8: the deductible part of self-employment tax - # (Schedule 1 line 15) attributable to the plan business. - self_employment_tax_deduction = ( - person("self_employment_tax_ald_person", period) * plan_business_share - ) # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction - # (Schedule 1 line 16) for the plan business. + # (Schedule 1 line 16) is attributable to the Schedule C business. retirement_plan_deduction = person( "self_employed_pension_contribution_ald_person", period ) - return max_( + # Line 10 for each self-employment business. + schedule_c = max_( 0, - plan_business_profit - - self_employment_tax_deduction + schedule_c_profit * (1 - self_employment_tax_deduction_rate) - retirement_plan_deduction, ) + farm = max_(0, farm_profit * (1 - self_employment_tax_deduction_rate)) + partnership = max_( + 0, partnership_profit * (1 - self_employment_tax_deduction_rate) + ) + # Line 11: an S corporation shareholder's wages from the corporation. + s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) + plan_business = person("self_employed_health_insurance_plan_business", period) + businesses = plan_business.possible_values + return select( + [ + plan_business == businesses.SCHEDULE_C, + plan_business == businesses.SCHEDULE_F, + plan_business == businesses.PARTNERSHIP, + plan_business == businesses.S_CORPORATION, + ], + [schedule_c, farm, partnership, s_corporation], + # UNSPECIFIED: the business with the largest earned income. + default=max_(max_(schedule_c, farm), max_(partnership, s_corporation)), + ) diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py new file mode 100644 index 00000000000..1d665c415fa --- /dev/null +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class SelfEmployedHealthInsurancePlanBusiness(Enum): + SCHEDULE_C = "Schedule C business" + SCHEDULE_F = "Schedule F farm" + PARTNERSHIP = "Partnership" + S_CORPORATION = "S corporation" + UNSPECIFIED = "Unspecified" + + +class self_employed_health_insurance_plan_business(Variable): + value_type = Enum + entity = Person + possible_values = SelfEmployedHealthInsurancePlanBusiness + default_value = SelfEmployedHealthInsurancePlanBusiness.UNSPECIFIED + definition_period = YEAR + label = ( + "Business under which the self-employed health insurance plan is established" + ) + documentation = ( + "The trade or business under which the person's health insurance plan " + "is established, or considered to be established: the Schedule C " + "business, the Schedule F farm, a partnership in which the person has " + "Schedule K-1 box 14 code A net earnings, or an S corporation in which " + "the person is a more-than-2% shareholder. The self-employed health " + "insurance deduction is limited to the earned income from that " + "business alone (one Form 7206 per plan). UNSPECIFIED (the default) " + "means the business was not supplied, and the limit uses the person's " + "business with the largest earned income." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", + "https://www.irs.gov/pub/irs-prior/i7206--2023.pdf#page=1", + ) diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py new file mode 100644 index 00000000000..7b1d2c67f3a --- /dev/null +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class s_corp_shareholder_medicare_wages(Variable): + value_type = float + entity = Person + label = "S corporation shareholder Medicare wages" + unit = USD + documentation = ( + "Medicare wages (Form W-2 box 5) from an S corporation in which the " + "person is a more-than-2% shareholder (section 1372(b)). Section " + "162(l)(5) treats these wages as the shareholder's earned income for " + "the self-employed health insurance deduction (Form 7206 line 11). " + "Like the other income inputs, enter the amount left after any " + "section 911 foreign earned income exclusion attributable to these " + "wages (line 12). They are part of employment_income, not in addition " + "to it." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/162#l_5", + "https://www.law.cornell.edu/uscode/text/26/1372", + "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + ) + uprating = "calibration.gov.irs.soi.employment_income" From 6a5735ea1212ad7bee8ed8e71b3d77ae098133cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:16:17 -0400 Subject: [PATCH 06/41] Limit self-employed plan contributions to the Pub. 560 maximum deduction The limit used 100% of Schedule C net profit. Plan compensation for a self-employed person is 401(c)(2) earned income, net of the deductible part of self-employment tax and of the plan deduction itself, and 404(a)(8)(C) caps the deduction at that earned income before the plan deduction (Pub. 560 Deduction Worksheet for Self-Employed). Co-Authored-By: Claude Opus 5.5 --- ...ix-se-pension-earned-income-limit.fixed.md | 1 + .../limit/profit_sharing_deduction_rate.yaml | 18 +++++ ...mployed_pension_contribution_ald_person.py | 16 +++-- ...elf_employed_pension_contribution_limit.py | 72 ++++++++++++++++--- .../self_employed_pension_contributions.py | 10 ++- .../self_employed_pension_earned_income.py | 50 +++++++++++++ 6 files changed, 151 insertions(+), 16 deletions(-) create mode 100644 changelog.d/fix-se-pension-earned-income-limit.fixed.md create mode 100644 policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml create mode 100644 policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py diff --git a/changelog.d/fix-se-pension-earned-income-limit.fixed.md b/changelog.d/fix-se-pension-earned-income-limit.fixed.md new file mode 100644 index 00000000000..a3429675655 --- /dev/null +++ b/changelog.d/fix-se-pension-earned-income-limit.fixed.md @@ -0,0 +1 @@ +Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum, based on Schedule C net profit less the deductible part of self-employment tax, so it can no longer reach 100% of profit or make AGI negative. diff --git a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml new file mode 100644 index 00000000000..44ea52978bb --- /dev/null +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml @@ -0,0 +1,18 @@ +description: The US limits the deduction for employer contributions to a SEP or profit-sharing plan to this share of participants' compensation. +values: + 2002-01-01: 0.25 +metadata: + unit: /1 + period: year + label: SEP and profit-sharing plan employer deduction limit rate + reference: + - title: 26 U.S. Code § 404(a)(3)(A)(i)(I) - Stock bonus and profit-sharing trusts + href: https://www.law.cornell.edu/uscode/text/26/404#a_3_A_i_I + - title: 26 U.S. Code § 404(h)(1)(C) - Simplified employee pensions + href: https://www.law.cornell.edu/uscode/text/26/404#h_1_C + - title: Economic Growth and Tax Relief Reconciliation Act of 2001, Pub. L. 107-16, § 616 (15 to 25 percent, years beginning after 2001) + href: https://www.congress.gov/107/plaws/publ16/PLAW-107publ16.pdf#page=103 + - title: IRS Publication 560 (2025), Rate Table for Self-Employed + href: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=40 + - title: IRS Publication 560 (2022), Rate Table for Self-Employed + href: https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=35 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 33b247938f7..ece0c96b280 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 @@ -6,14 +6,22 @@ class self_employed_pension_contribution_ald_person(Variable): entity = Person label = "Self-employed pension contribution ALD for each person" unit = USD - documentation = "Personal above-the-line deduction for self-employed pension plan contributions." + documentation = ( + "Personal above-the-line deduction for self-employed SEP, SIMPLE and " + "qualified plan contributions (Schedule 1 line 16), capped at the " + "Pub. 560 maximum deductible contribution." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/62#a_6", - "https://www.law.cornell.edu/uscode/text/26/404", + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", ) def formula(person, period, parameters): - earnings = max_(0, person("total_self_employment_income", period)) contributions = person("self_employed_pension_contributions", period) - return min_(earnings, contributions) + # The limit applies again here so the deduction stays within it when + # contributions are supplied directly rather than from desired + # contributions. + limit = person("self_employed_pension_contribution_limit", period) + return min_(contributions, limit) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index 66a81e8e63d..b24352a2de6 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -4,18 +4,72 @@ class self_employed_pension_contribution_limit(Variable): value_type = float entity = Person - label = "self-employed pension contribution annual additions limit" + label = "Self-employed retirement plan contribution limit" unit = USD documentation = ( - "Section 415(c) annual additions limit for modeled self-employed " - "pension contributions. Regular IRA contributions are subject to " - "separate IRA limits, and employee 401(k)/403(b) deferrals are " - "capped separately by the elective deferral limit." + "Largest deduction for the person's own SEP, SIMPLE and qualified " + "defined contribution plan contributions (Schedule 1 line 16), from " + "the Pub. 560 Deduction Worksheet for Self-Employed. Plan " + "compensation for a self-employed person is earned income net of " + "the plan deduction itself, so the 25% employer rate becomes 20% of " + "earned income before that deduction, and the 100%-of-compensation " + "limit becomes half of it less elective deferrals. The modeled input " + "does not say which kind of plan the person has, so the limit is the " + "most any of them allows: a profit-sharing plan with a 401(k) " + "arrangement into which the person defers all of the elective " + "deferral room that deferrals to other employers' plans leave. With " + "no room left, it is the SEP and profit-sharing limit. Catch-up " + "contributions sit outside the dollar limit, and the total never " + "exceeds earned income. Defined benefit plans are not modeled." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/415#c" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/404#a_3_A_i_I", + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.law.cornell.edu/uscode/text/26/404#n", + "https://www.law.cornell.edu/uscode/text/26/415#c_1", + "https://www.law.cornell.edu/uscode/text/26/415#c_3_B", + "https://www.law.cornell.edu/uscode/text/26/414#v_3_A", + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", + ) def formula(person, period, parameters): - p = parameters(period).gov.irs.gross_income.retirement_contributions - compensation = max_(0, person("total_self_employment_income", period)) - return min_(p.limit.annual_additions, compensation) + p = parameters(period).gov.irs.gross_income.retirement_contributions.limit + # Step 3: earned income before the plan deduction. + earned_income = person("self_employed_pension_earned_income", period) + # Steps 4 and 5: the employer contribution rate, reduced because + # compensation is net of the contribution. Steps 6 and 7 cap the rate + # at the section 401(a)(17) compensation limit, which never binds: + # 25% of it has exceeded the step 8 dollar limit in every year. + rate = p.profit_sharing_deduction_rate + employer_limit = earned_income * rate / (1 + rate) + # Step 9: elective deferrals, up to the section 402(g) room left + # after deferrals to other employers' plans, which use the regular + # limit before the catch-up amount. + other_deferrals = add( + person, + period, + [ + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_403b_contributions", + "roth_403b_contributions", + ], + ) + deferral_room = max_( + 0, person("elective_deferral_limit", period) - other_deferrals + ) + catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) + regular_deferral_room = deferral_room - catch_up_room + # Steps 10 to 15: employer contributions and regular deferrals + # together stay within the dollar limit (step 8) and within + # compensation, which also excludes the employer contribution + # (step 12). + annual_additions = min_( + min_(p.annual_additions, employer_limit + regular_deferral_room), + (earned_income + regular_deferral_room) / 2, + ) + # Steps 16 to 19: catch-up contributions, outside the dollar limit. + # The total never exceeds earned income. + return min_(earned_income, annual_additions + catch_up_room) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contributions.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contributions.py index 25f9a4b78e1..2efe7b18724 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contributions.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contributions.py @@ -7,11 +7,15 @@ class self_employed_pension_contributions(Variable): label = "Self-employed pension contributions" unit = USD documentation = ( - "Self-employed pension contributions after applying the section " - "415(c) annual additions limit." + "Self-employed SEP, SIMPLE and qualified plan contributions after " + "applying the Pub. 560 maximum deductible contribution, which " + "includes the section 415(c) annual additions limit." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/415#c" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/415#c", + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + ) def formula(person, period, parameters): contributions = person("self_employed_pension_contributions_desired", period) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py new file mode 100644 index 00000000000..3fb98c26ad5 --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -0,0 +1,50 @@ +from policyengine_us.model_api import * + + +class self_employed_pension_earned_income(Variable): + value_type = float + entity = Person + label = "Earned income for self-employed retirement plan limits" + unit = USD + documentation = ( + "Earned income, within the meaning of 26 U.S.C. 401(c)(2), from the " + "business that maintains the self-employed SEP, SIMPLE or qualified " + "plan, determined without regard to the plan deduction itself: net " + "profit less the deductible part of self-employment tax (Pub. 560 " + "Deduction Worksheet for Self-Employed, step 3). The model treats the " + "person's Schedule C business as the plan's business. When the person " + "also has farm or partnership self-employment earnings, the deductible " + "part of self-employment tax is split by each business's share of the " + "net profits, as on Form 7206 lines 5 to 8." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", + "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + ) + + def formula(person, period, parameters): + # Step 1: net profit from the business with the plan. + plan_business_profit = max_(0, person("total_self_employment_income", period)) + # Net profits of every business whose earnings are subject to + # self-employment tax, leaving out net losses. + other_business_profits = max_( + 0, person("farm_operations_income", period) + ) + max_(0, person("partnership_self_employment_net_earnings", period)) + all_business_profits = plan_business_profit + other_business_profits + plan_business_share = np.divide( + plan_business_profit, + all_business_profits, + out=np.zeros_like(all_business_profits), + where=all_business_profits > 0, + ) + # Step 2: the deductible part of self-employment tax (Schedule 1 + # line 15) attributable to the plan's business. + self_employment_tax_deduction = ( + person("self_employment_tax_ald_person", period) * plan_business_share + ) + # Step 3. + return max_(0, plan_business_profit - self_employment_tax_deduction) From a18ed734325a8c71feae9672cc9a8bddab07bf78 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:21:27 -0400 Subject: [PATCH 07/41] Test the self-employed plan limit against the Pub. 560 worksheet Co-Authored-By: Claude Opus 5.5 --- ...elf_employed_pension_contribution_ald.yaml | 55 ++++ .../self_employment_income.yaml | 5 +- ...f_employed_pension_contribution_limit.yaml | 253 +++++++++++++++++- 3 files changed, 301 insertions(+), 12 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml new file mode 100644 index 00000000000..a9f2a58f3bb --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml @@ -0,0 +1,55 @@ +- name: Case 1, the deduction cannot make AGI negative when Schedule C profit is the only income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + # Half of self-employment tax: 20,000 x 92.35% x 15.3% / 2 = 1,412.96. + # Pub. 560 Deduction Worksheet step 3: 20,000 - 1,412.96 = 18,587.05, + # the most the plan deduction can be (26 U.S.C. 404(a)(8)(C)). + self_employment_tax_ald: 1_412.96 + self_employed_pension_contribution_ald_person: [18_587.05] + self_employed_pension_contribution_ald: 18_587.05 + adjusted_gross_income: 0 + +- name: Case 2, contributions supplied directly are still limited. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 20_000 + self_employed_pension_contributions: 20_000 + output: + self_employed_pension_contribution_ald_person: [18_587.05] + adjusted_gross_income: 0 + +- name: Case 3, a married couple's limits are each spouse's own. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + person2: + age: 40 + self_employment_income: 30_000 + self_employed_pension_contributions_desired: 30_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + output: + # Person 1: 20% x 186,403.65 + 23,500 = 60,780.73 (the Pub. 560 2025 + # example's step 3). Person 2: (27,880.57 + 23,500) / 2 = 25,690.28. + self_employed_pension_contribution_ald_person: [60_780.73, 25_690.28] + self_employed_pension_contribution_ald: 86_471.01 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 526b7ca01e0..080a4839eee 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 @@ -16,11 +16,14 @@ - name: Self-employed pension ALD uses SSTB income period: 2024 + absolute_error_margin: 0.01 input: sstb_self_employment_income: 8_000 self_employed_pension_contributions_desired: 9_000 output: - self_employed_pension_contribution_ald_person: 8_000 + # Earned income: 8,000 - 7,388 x 15.3% / 2 = 7,434.82, all of which + # elective deferrals can cover (Pub. 560 Deduction Worksheet steps 9-19). + self_employed_pension_contribution_ald_person: 7_434.82 - name: Loss ALD includes SSTB self-employment losses period: 2024 diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index 885e4783141..528277d9deb 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -1,29 +1,47 @@ +# Expected values follow the Pub. 560 Deduction Worksheet for Self-Employed +# (2025: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39; 2022: +# https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34), with the 25% plan +# rate (step 4 rate 0.2) and, unless the case says otherwise, elective +# deferrals (step 9) and catch-up contributions (step 17) equal to the +# person's remaining section 402(g) room. Step 3 is Schedule C net profit less +# half of self-employment tax: 92.35% of profit at 15.3% (12.4% up to the +# Social Security wage base), halved. + - name: Self-employed pension contributions are limited by the 2026 annual additions dollar limit. period: 2026 + absolute_error_margin: 0.01 input: people: person1: - self_employment_income: 100_000 + self_employment_income: 500_000 self_employed_pension_contributions_desired: 100_000 output: + # Step 3: 500,000 - (184,500 x 12.4% + 461,750 x 2.9%) / 2 = 481,865.63. + # Step 7: 20% x 481,865.63 = 96,373.13; steps 13 + 15 stop at the $72,000 + # dollar limit. self_employed_pension_contribution_limit: 72_000 self_employed_pension_contributions: 72_000 self_employed_pension_contribution_ald_person: 72_000 - name: Self-employed pension contributions are also limited by self-employment compensation. period: 2026 + absolute_error_margin: 0.01 input: people: person1: self_employment_income: 50_000 self_employed_pension_contributions_desired: 100_000 output: - self_employed_pension_contribution_limit: 50_000 - self_employed_pension_contributions: 50_000 - self_employed_pension_contribution_ald_person: 50_000 + # Step 3: 50,000 - 46,175 x 15.3% / 2 = 46,467.61. Step 7: 9,293.52. + # Step 9: 24,500. Step 12: (46,467.61 - 24,500) / 2 = 10,983.81. + # Steps 13 + 15: 9,293.52 + 24,500 = 33,793.52. + self_employed_pension_contribution_limit: 33_793.52 + self_employed_pension_contributions: 33_793.52 + self_employed_pension_contribution_ald_person: 33_793.52 -- name: Unrelated employee elective deferrals do not reduce the self-employed pension limit. +- name: Elective deferrals to an employer's 401(k) use up the deferral room, leaving the 20% SEP and profit-sharing rate. period: 2026 + absolute_error_margin: 0.01 input: people: person1: @@ -35,11 +53,16 @@ output: elective_deferral_limit: 35_750 traditional_401k_contributions: 35_750 - self_employed_pension_contribution_limit: 72_000 - self_employed_pension_contributions: 72_000 + # Wages of 100,000 leave 84,500 of the 184,500 wage base. Step 3: + # 100,000 - (84,500 x 12.4% + 92,350 x 2.9%) / 2 = 93,421.93. + # No deferral room is left, so the limit is step 7: 20% x 93,421.93. + self_employed_pension_earned_income: 93_421.93 + self_employed_pension_contribution_limit: 18_684.39 + self_employed_pension_contributions: 18_684.39 - name: Annual additions cap does not apply to IRA contributions. period: 2026 + absolute_error_margin: 0.01 input: people: person1: @@ -50,16 +73,224 @@ output: ira_contribution_limit: 8_600 traditional_ira_contributions: 8_600 - self_employed_pension_contribution_limit: 72_000 - self_employed_pension_contributions: 72_000 + # Step 3: 100,000 - 92,350 x 15.3% / 2 = 92,935.23. Steps 13 + 15: + # 18,587.05 + 24,500 = 43,087.05. Step 18: the 8,000 catch-up. + self_employed_pension_contribution_limit: 51_087.05 + self_employed_pension_contributions: 51_087.05 - name: Self-employed pension contributions use desired input. period: 2026 + absolute_error_margin: 0.01 input: people: person1: self_employment_income: 100_000 self_employed_pension_contributions_desired: 100_000 output: - self_employed_pension_contribution_limit: 72_000 - self_employed_pension_contributions: 72_000 + self_employed_pension_contribution_limit: 43_087.05 + self_employed_pension_contributions: 43_087.05 + +- name: Pub. 560 2025 example net profit, with elective deferrals at the 402(g) limit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 1 to 3 as in the Pub. 560 example: 200,000 - 13,596.35 = 186,403.65. + self_employment_tax_ald_person: 13_596.35 + self_employed_pension_earned_income: 186_403.65 + # Step 7: 20% x 186,403.65 = 37,280.73. Step 9: 23,500. Step 10: 46,500. + # Step 12: (186,403.65 - 23,500) / 2 = 81,451.83. Step 13: 37,280.73. + # Step 15: 23,500. Step 19: 60,780.73. + self_employed_pension_contribution_limit: 60_780.73 + self_employed_pension_contribution_ald_person: 60_780.73 + +- name: Pub. 560 2025 example net profit, with no elective deferral room left (SEP and profit-sharing path). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 23_500 + traditional_401k_contributions_desired: 23_500 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Wages of 23,500 leave 152,600 of the 176,100 wage base. Step 2: + # (152,600 x 12.4% + 184,700 x 2.9%) / 2 = 12,139.35. + self_employed_pension_earned_income: 187_860.65 + # Steps 9 to 20 do not apply: steps 7 and 8 give 20% x 187,860.65. + self_employed_pension_contribution_limit: 37_572.13 + +- name: Catch-up contributions at age 55 sit outside the dollar limit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 55 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 13 and 15 as in the age 40 case (60,780.73). Step 16: + # 186,403.65 - 37,280.73 - 23,500 = 125,622.92. Step 18: 7,500. + self_employed_pension_contribution_limit: 68_280.73 + +- name: The age 60 to 63 catch-up amount applies from 2025. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 61 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + output: + self_employed_pension_contribution_limit: 72_030.73 + +- name: Catch-up contributions add to the dollar limit when it binds. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 55 + self_employment_income: 500_000 + self_employed_pension_contributions_desired: 100_000 + output: + self_employed_pension_contribution_limit: 80_000 + +- name: With low profit, deferrals can reach all of earned income but no more. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + # Step 3: 20,000 - 18,470 x 15.3% / 2 = 18,587.05. Step 9 at 18,587.05 + # leaves steps 11 to 13 at zero, so step 19 equals step 3. + self_employed_pension_earned_income: 18_587.05 + self_employed_pension_contribution_limit: 18_587.05 + self_employed_pension_contributions: 18_587.05 + +- name: The 100%-of-compensation limit binds when deferrals are large relative to profit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 30_000 + self_employed_pension_contributions_desired: 30_000 + output: + # Step 3: 30,000 - 27,705 x 15.3% / 2 = 27,880.57. Step 7: 5,576.11. + # Step 12: (27,880.57 - 23,500) / 2 = 2,190.28, the smallest, so step 13 + # is 2,190.28. Step 14: 25,690.28. Step 15: 23,500. Step 19: 25,690.28. + self_employed_pension_earned_income: 27_880.57 + self_employed_pension_contribution_limit: 25_690.28 + +- name: Deferrals to an employer's plan use the regular limit first and leave the catch-up room. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 52 + employment_income: 50_000 + traditional_401k_contributions_desired: 20_000 + self_employment_income: 100_000 + self_employed_pension_contributions_desired: 100_000 + output: + # 402(g) limit 24,500 + 8,000 catch-up = 32,500, less 20,000 of deferrals: + # 4,500 of regular room and the 8,000 catch-up. Step 3: 92,935.23. + # Steps 13 + 15: 18,587.05 + 4,500. Step 18: 8,000. + self_employed_pension_earned_income: 92_935.23 + self_employed_pension_contribution_limit: 31_087.05 + +- name: Farm earnings take part of the self-employment tax deduction, which is split by net profit. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Self-employment tax deduction: 92,350 x 15.3% / 2 = 7,064.78, of which + # 60% (4,238.87) is attributable to the Schedule C business. + self_employment_tax_ald_person: 7_064.78 + self_employed_pension_earned_income: 55_761.14 + # Steps 13 + 15: 20% x 55,761.14 + 23,500 = 34,652.23. + self_employed_pension_contribution_limit: 34_652.23 + +- name: Partnership self-employment earnings alone do not support a contribution, since the model bases the plan on the Schedule C business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + partnership_self_employment_net_earnings: 100_000 + self_employed_pension_contributions_desired: 10_000 + output: + self_employed_pension_earned_income: 0 + self_employed_pension_contribution_limit: 0 + self_employed_pension_contributions: 0 + +- name: Net profit under the self-employment tax threshold is all earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 400 + self_employed_pension_contributions_desired: 1_000 + output: + # 92.35% of 400 is under $400, so there is no self-employment tax. + self_employment_tax_ald_person: 0 + self_employed_pension_earned_income: 400 + self_employed_pension_contribution_limit: 400 + +- name: Pub. 560 2022 example net profit, with elective deferrals at the 402(g) limit. + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 200_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Step 2: (147,000 x 12.4% + 184,700 x 2.9%) / 2 = 11,792.15. The Pub. + # 560 (2022) example's step 21, 0.078 x step 3, is 14,680. + self_employment_tax_ald_person: 11_792.15 + self_employed_pension_earned_income: 188_207.85 + # Step 7: 37,641.57. Step 9: 20,500. Steps 13 + 15: 58,141.57, under the + # $61,000 dollar limit. + self_employed_pension_contribution_limit: 58_141.57 + +- name: A Schedule C loss allows no contribution. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: -5_000 + self_employed_pension_contributions_desired: 5_000 + output: + self_employed_pension_earned_income: 0 + self_employed_pension_contribution_limit: 0 + self_employed_pension_contributions: 0 + self_employed_pension_contribution_ald_person: 0 From 97e05a7935f3a0e6eee7f9c35fe31297b55f9f3e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:28:17 -0400 Subject: [PATCH 08/41] Extend the Form 7206 properties to farm, partnership and S corporation plans Co-Authored-By: Claude Opus 5.5 --- ...lf_employed_health_insurance_properties.py | 259 ++++++++++++------ 1 file changed, 168 insertions(+), 91 deletions(-) diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 9d6cf8ffe5a..b2e7398e6c9 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -4,29 +4,44 @@ within the meaning of section 401(c), from the business under which the plan is established. Section 401(c)(2)(A)(v) and (vi) determine that earned income with regard to the section 404 plan deduction and the section 164(f) deduction -for half of self-employment tax. Form 7206 (Pub. 535 Worksheet 6-A before -2023) computes it as the plan business's net profit (line 4), less the part of -Schedule 1 line 15 attributable to that business by its share of all net -profits subject to self-employment tax (lines 5 to 8), less the Schedule 1 line -16 plan deduction (lines 9 and 10); the deduction is the smaller of the -premiums and that amount (line 14). The model treats a person's Schedule C -income as the plan business. +for half of self-employment tax, and section 162(l)(5) makes an S corporation +shareholder's wages from the corporation their earned income. Form 7206 (Pub. +535 Worksheet 6-A before 2023), one form per plan, computes it as the plan +business's net profit (line 4), less the part of Schedule 1 line 15 +attributable to that business by its share of all net profits subject to +self-employment tax (lines 5 to 8), less the Schedule 1 line 16 plan deduction +attributable to it (lines 9 and 10), or as the S corporation wages (line 11); +the deduction is the smaller of the premiums and that amount (line 14). The +plan business may be a Schedule C business, a Schedule F farm, a partnership +(K-1 box 14 code A) or an S corporation. When +self_employed_health_insurance_plan_business is not supplied, the model uses +the business with the largest earned income. The model bases the line 16 plan +deduction on Schedule C income, so only the Schedule C business subtracts it. Hypothesis draws batches of self-employed single filers and runs each batch as -one vectorized baseline simulation. Each drawn filer appears twice, the second -time with higher premiums. For every filer and year: - -1. Reference: the limit equals a closed-form Form 7206 computation that takes - lines 15 and 16 from the model, and line 15 equals half of the - self-employment tax. -2. Bounds: 0 <= deduction <= premiums, and deduction <= Schedule C profit less - the attributable part of line 15 (earned income after the self-employment - tax deduction), which never exceeds Schedule C profit. -3. Monotone in premiums: raising premiums by d raises the deduction by at +one vectorized baseline simulation. Each drawn filer appears six times: with +no named plan business, again with higher premiums, and once naming each of +the four businesses. For every filer and year: + +1. Reference: each limit equals a closed-form Form 7206 computation for its + plan business that takes lines 15 and 16 from the model, and line 15 + equals half of the self-employment tax. Without a named business, the + limit is the largest of the four. +2. Differential: the limit without a named business equals the largest of the + limits the model computes when each business is named. +3. Bounds: 0 <= deduction <= premiums; the limit is at least the Schedule C + limit (widening the base never lowers the deduction) and at most the sum + of the four businesses' limits (one plan cannot pool businesses). +4. Monotone in premiums: raising premiums by d raises the deduction by at least 0 and at most d, and leaves the limit unchanged. -4. The deduction never makes adjusted gross income negative for a filer whose - only income is Schedule C profit: such a filer with negative AGI (from the - plan deduction) has no health insurance deduction. +5. For a filer with no business losses whose income is wages and + self-employment income, the deduction never takes adjusted gross income + below the Schedule C line 10 amount before its floor at zero. That amount + is negative only when the plan deduction exceeds Schedule C earned income, + which the plan contribution limit (based on Schedule C profit, not + 401(c) earned income) allows; otherwise adjusted gross income stays at or + above zero. Intended: a plan under the farm or partnership does not + subtract the Schedule C plan deduction. """ import numpy as np @@ -36,8 +51,15 @@ from policyengine_us import Simulation TOLERANCE = 0.01 +# Model outputs are float32, whose spacing exceeds a cent above $131,072. +RTOL = 1e-6 YEARS = [2022, 2024, 2026] PREMIUM_RAISE = 2_500 +BUSINESSES = ["SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP", "S_CORPORATION"] +# (name prefix, named plan business, premium raise) for each copy of a filer. +COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ + (business.lower(), business, 0) for business in BUSINESSES +] def every_year(value): @@ -45,12 +67,11 @@ def every_year(value): def build_situation(filers): - """One single-filer household per drawn filer, then again with higher premiums.""" + """One single-filer household for each copy of each drawn filer.""" people, tax_units, households = {}, {}, {} - for raised in (False, True): + for prefix, plan_business, raise_ in COPIES: for i, filer in enumerate(filers): - name = f"{'r' if raised else 'b'}{i}" - premiums = filer["premiums"] + (PREMIUM_RAISE if raised else 0) + name = f"{prefix}{i}" people[name] = { "age": every_year(45), "is_self_employed": every_year(True), @@ -59,11 +80,18 @@ def build_situation(filers): "partnership_self_employment_net_earnings": every_year( filer["partnership"] ), - "employment_income": every_year(filer["wages"]), + # The partnership's earnings also enter gross income. + "partnership_income": every_year(filer["partnership"]), + # S corporation wages are part of employment income. + "employment_income": every_year(filer["wages"] + filer["s_corp"]), + "s_corp_shareholder_medicare_wages": every_year(filer["s_corp"]), "self_employed_pension_contributions_desired": every_year( filer["plan"] ), - "health_insurance_premiums": every_year(premiums), + "health_insurance_premiums": every_year(filer["premiums"] + raise_), + "self_employed_health_insurance_plan_business": every_year( + plan_business + ), } tax_units[name] = {"members": [name]} households[name] = {"members": [name], "state_code": every_year("TX")} @@ -71,24 +99,50 @@ def build_situation(filers): return situation, len(filers) -def form_7206_limit(schedule_c, farm, partnership, line_15, line_16): - """Form 7206 lines 4 to 10 and 13, with lines 15 and 16 as given amounts.""" - line_4 = np.maximum(schedule_c, 0) - line_5 = line_4 + np.maximum(farm, 0) + np.maximum(partnership, 0) - line_6 = np.where(line_5 > 0, line_4 / np.where(line_5 > 0, line_5, 1), 0) - line_8 = line_4 - line_15 * line_6 - return np.maximum(line_8 - line_16, 0), line_8 +def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): + """Form 7206 line 13 for each plan business, with lines 15 and 16 given. + + Also returns the Schedule C line 10 amount before its floor at zero. + """ + line_4 = { + "SCHEDULE_C": np.maximum(schedule_c, 0), + "SCHEDULE_F": np.maximum(farm, 0), + "PARTNERSHIP": np.maximum(partnership, 0), + } + line_5 = sum(line_4.values()) + safe_line_5 = np.where(line_5 > 0, line_5, 1) + line_10 = {} + for business, profit in line_4.items(): + line_6 = np.where(line_5 > 0, profit / safe_line_5, 0) + line_8 = profit - line_15 * line_6 + line_9 = line_16 if business == "SCHEDULE_C" else 0 + line_10[business] = line_8 - line_9 + line_13 = {business: np.maximum(amount, 0) for business, amount in line_10.items()} + line_13["S_CORPORATION"] = np.maximum(s_corp, 0) + return line_13, line_10["SCHEDULE_C"] + + +def close(actual, expected): + np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=TOLERANCE) -def column(filers, key, n_copies=2): - return np.tile(np.array([float(f[key]) for f in filers]), n_copies) +def at_least(actual, bound): + return np.all(actual >= bound - TOLERANCE - RTOL * np.abs(bound)) + + +def column(filers, key): + return np.array([float(f[key]) for f in filers]) def check_properties(sim, filers, n): + def copy(array, prefix): + index = [p for p, _, _ in COPIES].index(prefix) + return array[index * n : (index + 1) * n] + schedule_c = column(filers, "schedule_c") farm = column(filers, "farm") partnership = column(filers, "partnership") - wages = column(filers, "wages") + s_corp = column(filers, "s_corp") for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) limit = sim.calculate( @@ -98,34 +152,51 @@ def check_properties(sim, filers, n): line_15 = sim.calculate("self_employment_tax_ald_person", year) line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) se_tax = sim.calculate("self_employment_tax", year) + agi = sim.calculate("adjusted_gross_income", year) - # 1. Reference. - np.testing.assert_allclose(line_15, se_tax / 2, atol=TOLERANCE) - expected_limit, after_se_tax = form_7206_limit( - schedule_c, farm, partnership, line_15, line_16 - ) - np.testing.assert_allclose(limit, expected_limit, atol=TOLERANCE) - np.testing.assert_allclose( - deduction, np.minimum(premiums, expected_limit), atol=TOLERANCE + # Lines 15 and 16 and the deduction for every copy. + close(line_15, se_tax / 2) + close(deduction, np.minimum(premiums, limit)) + # Lines 15 and 16 do not depend on premiums or the named business. + for prefix, _, _ in COPIES[1:]: + close(copy(line_15, prefix), copy(line_15, "u")) + close(copy(line_16, prefix), copy(line_16, "u")) + line_13, schedule_c_line_10 = form_7206_limits( + schedule_c, + farm, + partnership, + s_corp, + copy(line_15, "u"), + copy(line_16, "u"), ) + largest = np.max(np.stack(list(line_13.values())), axis=0) - # 2. Bounds. + # 1. Reference. + close(copy(limit, "u"), largest) + for business in BUSINESSES: + close(copy(limit, business.lower()), line_13[business]) + + # 2. Differential: the default equals the best named business. + named = np.stack([copy(limit, b.lower()) for b in BUSINESSES]) + close(copy(limit, "u"), named.max(axis=0)) + + # 3. Bounds. assert np.all(deduction >= 0) - assert np.all(deduction <= premiums + TOLERANCE) - assert np.all(deduction <= np.maximum(after_se_tax, 0) + TOLERANCE) - assert np.all(after_se_tax <= np.maximum(schedule_c, 0) + TOLERANCE) - - # 3. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. - base, raised = deduction[:n], deduction[n:] - assert np.all(raised >= base - TOLERANCE) - assert np.all(raised <= base + PREMIUM_RAISE + TOLERANCE) - np.testing.assert_allclose(limit[:n], limit[n:], atol=TOLERANCE) - - # 4. The deduction never takes Schedule C-only AGI below zero. - schedule_c_only = (farm == 0) & (partnership == 0) & (wages == 0) - agi = sim.calculate("adjusted_gross_income", year) - negative_agi = schedule_c_only & (agi < -TOLERANCE) - assert np.all(deduction[negative_agi] <= TOLERANCE) + assert at_least(premiums, deduction) + assert at_least(copy(limit, "u"), line_13["SCHEDULE_C"]) + assert at_least(named.sum(axis=0), copy(limit, "u")) + + # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. + base, raised = copy(deduction, "u"), copy(deduction, "r") + assert at_least(raised, base) + assert at_least(base + PREMIUM_RAISE, raised) + close(copy(limit, "u"), copy(limit, "r")) + + # 5. Adjusted gross income floor for filers with no business losses. + no_losses = (schedule_c >= 0) & (farm >= 0) & (partnership >= 0) + floor = np.minimum(0, schedule_c_line_10) + for prefix, _, _ in COPIES: + assert at_least(copy(agi, prefix)[no_losses], floor[no_losses]) money = st.integers(0, 120_000) @@ -137,6 +208,7 @@ def check_properties(sim, filers, n): ), "farm": st.one_of(st.just(0), st.integers(-30_000, 100_000)), "partnership": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "s_corp": st.one_of(st.just(0), st.integers(0, 150_000)), "wages": st.one_of(st.just(0), st.integers(0, 250_000)), "plan": st.one_of(st.just(0), money), "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), @@ -157,43 +229,48 @@ def test_deduction_follows_form_7206(filers): check_properties(Simulation(situation=situation), filers, n) +def filer(schedule_c=0, farm=0, partnership=0, s_corp=0, wages=0, plan=0, premiums=0): + return dict( + schedule_c=schedule_c, + farm=farm, + partnership=partnership, + s_corp=s_corp, + wages=wages, + plan=plan, + premiums=premiums, + ) + + def test_reviewer_example_and_boundaries(): - """Fixed filers: the $2,000 example, the $400 threshold, and a wage-base case.""" + """Fixed filers: the $2,000 example, the $400 threshold, and the new bases.""" filers = [ - dict(schedule_c=2_000, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), - dict(schedule_c=434, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), - dict(schedule_c=433, farm=0, partnership=0, wages=0, plan=0, premiums=6_000), - dict( - schedule_c=10_000, - farm=0, - partnership=0, - wages=250_000, - plan=0, - premiums=20_000, - ), - dict( - schedule_c=30_000, - farm=10_000, - partnership=0, - wages=0, - plan=0, - premiums=40_000, - ), - dict( - schedule_c=20_000, - farm=0, - partnership=0, - wages=0, - plan=20_000, - premiums=5_000, - ), + filer(schedule_c=2_000, premiums=6_000), + filer(schedule_c=434, premiums=6_000), + filer(schedule_c=433, premiums=6_000), + filer(schedule_c=10_000, wages=250_000, premiums=20_000), + filer(schedule_c=30_000, farm=10_000, premiums=40_000), + filer(schedule_c=20_000, plan=20_000, premiums=5_000), + # A farm or a partnership as the only business. + filer(farm=2_000, premiums=6_000), + filer(partnership=2_000, premiums=6_000), + # S corporation wages. + filer(s_corp=5_000, premiums=6_000), + # A plan deduction at the Schedule C profit; the farm is the plan + # business, so adjusted gross income falls below zero (property 5). + filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) check_properties(sim, filers, n) - deduction = sim.calculate("self_employed_health_insurance_ald_person", 2022) - # 2,000 - 2,000 x 0.9235 x 0.153 / 2. - assert abs(deduction[0] - 1_858.7045) < TOLERANCE + deduction = sim.calculate("self_employed_health_insurance_ald_person", 2022)[:n] + # 2,000 - 2,000 x 0.9235 x 0.153 / 2, for a Schedule C business, a farm and + # a partnership alike. + for i in (0, 6, 7): + assert abs(deduction[i] - 1_858.7045) < TOLERANCE # 434 x 0.9235 = 400.80 owes self-employment tax; 433 x 0.9235 = 399.88 does not. assert deduction[1] < 434 - TOLERANCE assert abs(deduction[2] - 433) < TOLERANCE + # Line 11: the S corporation wages, with no self-employment tax. + assert abs(deduction[8] - 5_000) < TOLERANCE + # Farm line 13: 50,000 - 50,000 / 70,000 x (70,000 x 0.9235 x 0.153 / 2). + assert abs(deduction[9] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE From 3fd9f1ff2fe6d16dd4c41d93275350f3b4575f6d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:30:07 -0400 Subject: [PATCH 09/41] Add property and worksheet differential tests for the self-employed plan limit Co-Authored-By: Claude Opus 5.5 --- ..._self_employed_pension_limit_properties.py | 312 ++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py diff --git a/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py new file mode 100644 index 00000000000..11d31098632 --- /dev/null +++ b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py @@ -0,0 +1,312 @@ +"""Properties of the self-employed SEP, SIMPLE and qualified plan deduction limit. + +A self-employed person's plan compensation is earned income under 26 U.S.C. +401(c)(2), which is net of the deductible part of self-employment tax and of +the plan deduction itself, and 404(a)(8)(C) caps the deduction at that earned +income before the plan deduction. IRS Publication 560 turns this into the +Deduction Worksheet for Self-Employed (2025 edition, page 39). The model's +single input does not say which plan the person has, so the model's limit is +the worksheet's largest total over the elective deferrals and catch-up +contributions the person could still make (`self_employed_pension_contribution_limit`). + +The reference below runs the worksheet's steps literally, with the IRS's +published dollar amounts rather than model parameters, and takes the largest +step 19 over a grid of elective deferrals (step 9) and catch-up contributions +(step 17). Only step 2, the deductible part of self-employment tax, comes from +the model. Hypothesis draws batches of single filers across three years, and +each batch runs as one vectorized simulation. For every person and year: + +1. The limit equals the worksheet maximum, and step 3 equals Schedule C net + profit less the part of the self-employment tax deduction attributable to + it. +2. The deduction is min(desired contributions, limit), is at most earned + income (404(a)(8)(C)), which is at most Schedule C net profit, and is at + most the dollar limit plus the person's catch-up amount. +3. For a filer whose only income is Schedule C profit, AGI is never negative + when the profit is not. + +A deterministic grid checks that the limit rises with Schedule C profit and +falls with deferrals to an employer's plan. +""" + +import itertools + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 +# Plan contribution rate: 26 U.S.C. 404(a)(3)(A)(i)(I) and 404(h)(1)(C). +PLAN_RATE = 0.25 +# Published amounts: IRS Notices 2021-61 (2022), 2024-80 (2025) and 2025-67 +# (2026); section 414(v)(2)(E) for ages 60 to 63 from 2025. +PUBLISHED = { + 2022: dict( + annual_additions=61_000, + compensation_limit=305_000, + deferral=20_500, + catch_up=6_500, + catch_up_60_to_63=6_500, + ), + 2025: dict( + annual_additions=70_000, + compensation_limit=350_000, + deferral=23_500, + catch_up=7_500, + catch_up_60_to_63=11_250, + ), + 2026: dict( + annual_additions=72_000, + compensation_limit=360_000, + deferral=24_500, + catch_up=8_000, + catch_up_60_to_63=11_250, + ), +} +YEARS = list(PUBLISHED) + + +def catch_up_amount(year, age): + amounts = PUBLISHED[year] + return np.select( + [age < 50, (age >= 60) & (age <= 63)], + [0, amounts["catch_up_60_to_63"]], + amounts["catch_up"], + ) + + +def worksheet_step_19(step_3, deferrals, catch_up, amounts): + """Pub. 560 Deduction Worksheet for Self-Employed, steps 4 to 19.""" + step_4 = PLAN_RATE / (1 + PLAN_RATE) # Rate Table: 25% -> 0.2 + step_5 = step_3 * step_4 + step_6 = amounts["compensation_limit"] * PLAN_RATE + step_7 = np.minimum(step_5, step_6) + step_8 = amounts["annual_additions"] + step_9 = deferrals + step_10 = step_8 - step_9 + step_11 = step_3 - step_9 + step_12 = step_11 / 2 + step_13 = np.minimum(np.minimum(step_7, step_10), step_12) + step_14 = step_3 - step_13 + step_15 = np.minimum(step_9, step_14) + step_16 = step_14 - step_15 + step_18 = np.minimum(step_16, catch_up) + with_deferrals = step_13 + step_15 + step_18 + # Without elective deferrals the worksheet skips to step 21. + without_deferrals = np.minimum(step_7, step_8) + return np.where((deferrals > 0) | (catch_up > 0), with_deferrals, without_deferrals) + + +def worksheet_maximum(step_3, regular_room, catch_up_room, amounts, points=41): + """Largest step 19 over deferrals and catch-up contributions in their room.""" + best = np.zeros_like(step_3) + for d, c in itertools.product(np.linspace(0, 1, points), repeat=2): + # A deferral can't exceed compensation, so the grid stops at step 3. + deferrals = d * np.minimum(regular_room, step_3) + catch_up = c * catch_up_room + best = np.maximum(best, worksheet_step_19(step_3, deferrals, catch_up, amounts)) + return best + + +def every_year(value): + return {year: value for year in YEARS} + + +def build_situation(people_inputs): + people, tax_units, households = {}, {}, {} + for i, inputs in enumerate(people_inputs): + name = f"p{i}" + people[name] = {variable: every_year(v) for variable, v in inputs.items()} + tax_units[f"t{i}"] = {"members": [name]} + households[f"h{i}"] = {"members": [name], "state_code": every_year("TX")} + return {"people": people, "tax_units": tax_units, "households": households} + + +def column(people_inputs, variable): + return np.array([p.get(variable, 0) for p in people_inputs], dtype=float) + + +def reference(sim, people_inputs, year): + amounts = PUBLISHED[year] + age = column(people_inputs, "age") + schedule_c = np.maximum( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income"), + 0, + ) + others = np.maximum( + column(people_inputs, "farm_operations_income"), 0 + ) + np.maximum(column(people_inputs, "partnership_self_employment_net_earnings"), 0) + share = np.divide( + schedule_c, + schedule_c + others, + out=np.zeros_like(schedule_c), + where=schedule_c + others > 0, + ) + # Step 2 is the one model output the reference takes as given. + step_2 = sim.calculate("self_employment_tax_ald_person", year) * share + step_3 = np.maximum(schedule_c - step_2, 0) + # Section 402(g) room left by deferrals to an employer's plan, which use + # the regular limit first. + catch_up = catch_up_amount(year, age) + deferral_limit = amounts["deferral"] + catch_up + desired = column(people_inputs, "traditional_401k_contributions_desired") + column( + people_inputs, "roth_401k_contributions_desired" + ) + room = deferral_limit - np.minimum(desired, deferral_limit) + catch_up_room = np.minimum(room, catch_up) + regular_room = room - catch_up_room + limit = worksheet_maximum(step_3, regular_room, catch_up_room, amounts) + return schedule_c, step_3, limit, catch_up + + +def check(sim, people_inputs): + desired = column(people_inputs, "self_employed_pension_contributions_desired") + schedule_c_only = np.array([p["schedule_c_only"] for p in people_inputs]) + for year in YEARS: + schedule_c, step_3, expected, catch_up = reference(sim, people_inputs, year) + earned_income = sim.calculate("self_employed_pension_earned_income", year) + limit = sim.calculate("self_employed_pension_contribution_limit", year) + deduction = sim.calculate("self_employed_pension_contribution_ald_person", year) + message = f"year {year}" + # 1. The worksheet. + np.testing.assert_allclose( + earned_income, step_3, atol=TOLERANCE, err_msg=message + ) + np.testing.assert_allclose(limit, expected, atol=TOLERANCE, err_msg=message) + # 2. Bounds. + np.testing.assert_allclose( + deduction, np.minimum(desired, limit), atol=TOLERANCE, err_msg=message + ) + assert np.all(deduction >= 0), message + assert np.all(deduction <= earned_income + TOLERANCE), message + assert np.all(earned_income <= schedule_c + TOLERANCE), message + cap = PUBLISHED[year]["annual_additions"] + catch_up + assert np.all(limit <= cap + TOLERANCE), message + # 3. AGI from Schedule C alone. + agi = sim.calculate("adjusted_gross_income", year) + net_profit = column(people_inputs, "self_employment_income") + column( + people_inputs, "sstb_self_employment_income" + ) + rows = schedule_c_only & (net_profit >= 0) + assert np.all(agi[rows] >= -TOLERANCE), (message, agi[rows]) + + +profits = st.one_of( + st.sampled_from([0, 400, 433, 434, 2_000, 20_000, 30_000, 200_000, 500_000]), + st.integers(-20_000, 600_000), +) +smaller = st.one_of(st.just(0), st.integers(-10_000, 150_000)) +contributions = st.one_of(st.sampled_from([0, 1_000_000]), st.integers(0, 100_000)) +person_strategy = st.fixed_dictionaries( + { + "schedule_c_only": st.booleans(), + "age": st.sampled_from([30, 49, 50, 55, 59, 60, 63, 64, 70]), + "self_employment_income": profits, + "sstb_self_employment_income": st.one_of(st.just(0), profits), + "farm_operations_income": smaller, + "partnership_self_employment_net_earnings": st.one_of( + st.just(0), st.integers(0, 150_000) + ), + "employment_income": st.one_of(st.just(0), st.integers(0, 250_000)), + "traditional_401k_contributions_desired": st.one_of( + st.just(0), st.integers(0, 40_000) + ), + "roth_401k_contributions_desired": st.one_of( + st.just(0), st.integers(0, 20_000) + ), + "self_employed_pension_contributions_desired": contributions, + } +) + + +def normalize(person): + person = dict(person) + if person["schedule_c_only"]: + for variable in ( + "farm_operations_income", + "partnership_self_employment_net_earnings", + "employment_income", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + ): + person[variable] = 0 + return person + + +def simulation_inputs(people_inputs): + return [ + {k: float(v) for k, v in p.items() if k != "schedule_c_only"} + for p in people_inputs + ] + + +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(person_strategy.map(normalize), min_size=4, max_size=15)) +def test_limit_matches_the_pub_560_worksheet(people_inputs): + sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) + check(sim, people_inputs) + + +GRID_PROFITS = [ + -1_000, + 0, + 434, + 5_000, + 20_000, + 30_000, + 60_000, + 100_000, + 200_000, + 600_000, +] +GRID_DEFERRALS = [0, 10_000, 23_500, 40_000] + + +@pytest.fixture(scope="module") +def grid(): + people_inputs = [ + { + "schedule_c_only": False, + "age": 55, + "self_employment_income": profit, + "employment_income": 50_000, + "traditional_401k_contributions_desired": deferrals, + "self_employed_pension_contributions_desired": 1_000_000, + } + for deferrals, profit in itertools.product(GRID_DEFERRALS, GRID_PROFITS) + ] + sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) + return sim, people_inputs + + +@pytest.mark.parametrize("year", YEARS) +def test_grid_matches_the_worksheet_and_is_monotone(grid, year): + sim, people_inputs = grid + check(sim, people_inputs) + limit = sim.calculate("self_employed_pension_contribution_limit", year).reshape( + len(GRID_DEFERRALS), len(GRID_PROFITS) + ) + # The limit rises with Schedule C profit... + assert np.all(np.diff(limit, axis=1) >= -TOLERANCE) + # ...and falls as deferrals to an employer's plan use up the room. + assert np.all(np.diff(limit, axis=0) <= TOLERANCE) + # No room left: steps 7 and 8, the SEP and profit-sharing limit. + earned_income = sim.calculate("self_employed_pension_earned_income", year) + no_room = earned_income.reshape(limit.shape)[-1] + np.testing.assert_allclose( + limit[-1], + np.minimum(PUBLISHED[year]["annual_additions"], 0.2 * no_room), + atol=TOLERANCE, + ) From 319445c2e7d86788e4dec8cbd4e2b7d50d27a219 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:35:37 -0400 Subject: [PATCH 10/41] Add changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/sehi-plan-business-base.fixed.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 changelog.d/sehi-plan-business-base.fixed.md diff --git a/changelog.d/sehi-plan-business-base.fixed.md b/changelog.d/sehi-plan-business-base.fixed.md new file mode 100644 index 00000000000..bc168c32248 --- /dev/null +++ b/changelog.d/sehi-plan-business-base.fixed.md @@ -0,0 +1 @@ +Let the self-employed health insurance deduction's earned income limit come from a Schedule F farm, a partnership's K-1 box 14 code A earnings, or a more-than-2% S corporation shareholder's wages when the plan is established under that business (26 U.S.C. 162(l)(2)(A) and (l)(5), Form 7206); the business can be named, and otherwise the person's business with the largest earned income is used. From 572c882d14d1c70f96eb22bf5aaf12a49c511343 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:37:02 -0400 Subject: [PATCH 11/41] State the property 5 floor without depending on the plan contribution limit Co-Authored-By: Claude Opus 5.5 --- ...est_self_employed_health_insurance_properties.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index b2e7398e6c9..0e4a16eb642 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -37,11 +37,10 @@ 5. For a filer with no business losses whose income is wages and self-employment income, the deduction never takes adjusted gross income below the Schedule C line 10 amount before its floor at zero. That amount - is negative only when the plan deduction exceeds Schedule C earned income, - which the plan contribution limit (based on Schedule C profit, not - 401(c) earned income) allows; otherwise adjusted gross income stays at or - above zero. Intended: a plan under the farm or partnership does not - subtract the Schedule C plan deduction. + is negative only when the Schedule 1 line 16 plan deduction exceeds the + Schedule C business's net profit less its share of line 15; otherwise + adjusted gross income stays at or above zero. Intended: a plan under the + farm or partnership does not subtract the Schedule C plan deduction. """ import numpy as np @@ -255,8 +254,8 @@ def test_reviewer_example_and_boundaries(): filer(partnership=2_000, premiums=6_000), # S corporation wages. filer(s_corp=5_000, premiums=6_000), - # A plan deduction at the Schedule C profit; the farm is the plan - # business, so adjusted gross income falls below zero (property 5). + # A plan deduction as large as the Schedule C profit, with the farm as + # the plan business (property 5). filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), ] situation, n = build_situation(filers) From 47bba5e89375986057686ff7fc2f1952644436bc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:38:07 -0400 Subject: [PATCH 12/41] Clarify the default plan business and the S corporation premium gate Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_earned_income_limit.py | 6 +++--- .../self_employment/s_corp_shareholder_medicare_wages.py | 3 ++- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index c58807e606f..9fda60d1bcb 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -23,9 +23,9 @@ class self_employed_health_insurance_earned_income_limit(Variable): "subtracts it. For an S corporation it is the shareholder's wages " "from the corporation (line 11). self_employed_health_insurance_plan_" "business names the business; when it is not supplied, the limit uses " - "the business with the largest earned income, as one plan established " - "under the person's most profitable business would. Self-employment " - "income inputs are already net of any section 911 exclusion (line 12)." + "the business with the largest earned income, the most that one plan " + "can deduct. Self-employment income inputs are already net of any " + "section 911 exclusion (line 12)." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py index 7b1d2c67f3a..c9958cc6877 100644 --- a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -14,7 +14,8 @@ class s_corp_shareholder_medicare_wages(Variable): "Like the other income inputs, enter the amount left after any " "section 911 foreign earned income exclusion attributable to these " "wages (line 12). They are part of employment_income, not in addition " - "to it." + "to it. As for other self-employed people, the deduction counts " + "health_insurance_premiums only when is_self_employed is true." ) definition_period = YEAR reference = ( From 16a81b9c2d72975646b2a0807a02f45f73431a56 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:39:52 -0400 Subject: [PATCH 13/41] Point the EGTRRA reference at section 616's page Co-Authored-By: Claude Opus 5.5 --- .../limit/profit_sharing_deduction_rate.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml index 44ea52978bb..d74d72522cf 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml @@ -11,7 +11,7 @@ metadata: - title: 26 U.S. Code § 404(h)(1)(C) - Simplified employee pensions href: https://www.law.cornell.edu/uscode/text/26/404#h_1_C - title: Economic Growth and Tax Relief Reconciliation Act of 2001, Pub. L. 107-16, § 616 (15 to 25 percent, years beginning after 2001) - href: https://www.congress.gov/107/plaws/publ16/PLAW-107publ16.pdf#page=103 + href: https://www.congress.gov/107/plaws/publ16/PLAW-107publ16.pdf#page=66 - title: IRS Publication 560 (2025), Rate Table for Self-Employed href: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=40 - title: IRS Publication 560 (2022), Rate Table for Self-Employed From f59fbe5ec91093a082774fe9554836874bd61cfc Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:15:50 -0400 Subject: [PATCH 14/41] Update saver's credit and Utah cases for the self-employed plan limit Co-Authored-By: Claude Opus 5.5 --- .../savers_credit_qualified_contributions.yaml | 8 ++++++-- .../ut_homeowner_renter_relief.yaml | 7 +++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml index 672916a2ac5..51a37418c6a 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/credits/retirement_saving/savers_credit_qualified_contributions.yaml @@ -10,7 +10,9 @@ roth_ira_contributions_desired: 1_000 roth_401k_contributions_desired: 1_000 roth_403b_contributions_desired: 1_000 - self_employment_income: 1_000 + # Enough profit for the self-employed plan limit (earned income of + # 9,293.52) to allow the full 1,000. + self_employment_income: 10_000 self_employed_pension_contributions_desired: 1_000 able_contributions_person: 100 retirement_distributions: 1_000 @@ -36,7 +38,9 @@ - name: Self-employed pension contributions count after applying the annual additions cap period: 2026 input: - self_employment_income: 100_000 + # 20% of earned income plus the 24,500 deferral limit exceeds the 72,000 + # annual additions limit at this profit. + self_employment_income: 500_000 self_employed_pension_contributions_desired: 100_000 output: self_employed_pension_contributions: 72_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml index 4471bfc9683..57144640fae 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml @@ -215,6 +215,7 @@ - name: Case 12, nontaxable income uses self-employed pension contributions. period: 2026 + absolute_error_margin: 0.01 input: people: person1: @@ -230,5 +231,7 @@ members: [person1] state_code: UT output: - self_employed_pension_contributions: 72_000 - ut_homeowner_renter_relief_nontaxable_income: 72_000 + # Pub. 560 worksheet: 20% x 92,935.23 of earned income + 24,500 of + # deferrals + the 8,000 catch-up at age 66. + self_employed_pension_contributions: 51_087.05 + ut_homeowner_renter_relief_nontaxable_income: 51_087.05 From c391e7bde37df05601909d5160b3eb0eb9e3b468 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:16:05 -0400 Subject: [PATCH 15/41] Let farm and partnership plans take the self-employed plan deduction self_employed_pension_earned_income (Pub. 560 worksheet steps 1-3) now uses the business or businesses under which the plan is established: by default every profitable Schedule C, Schedule F and partnership business, or one business named by the new self_employed_pension_plan_business input. 404(a)(8)(C) limits the deduction to earned income from the plan's business; Pub. 560 step 1 allows Schedule F and K-1 box 14 code A profit. Form 7206 lines 9-10 now subtract only the part of Schedule 1 line 16 attributable to each business, in proportion to its earned income. Co-Authored-By: Claude Opus 5.5 --- ...se-plan-business-farm-partnership.fixed.md | 1 + ...ed_health_insurance_earned_income_limit.py | 54 +++++++++++--- ...elf_employed_pension_contribution_limit.py | 15 +++- .../self_employed_pension_earned_income.py | 71 ++++++++++++------- .../self_employed_pension_plan_business.py | 39 ++++++++++ 5 files changed, 146 insertions(+), 34 deletions(-) create mode 100644 changelog.d/se-plan-business-farm-partnership.fixed.md create mode 100644 policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py diff --git a/changelog.d/se-plan-business-farm-partnership.fixed.md b/changelog.d/se-plan-business-farm-partnership.fixed.md new file mode 100644 index 00000000000..e6f67e34374 --- /dev/null +++ b/changelog.d/se-plan-business-farm-partnership.fixed.md @@ -0,0 +1 @@ +Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business, with the self-employed health insurance limit subtracting only the plan deduction attributable to the health plan's business. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index 9fda60d1bcb..3aa6c17292c 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -18,9 +18,11 @@ class self_employed_health_insurance_earned_income_limit(Variable): "is net profit less the part of the deductible part of " "self-employment tax attributable to that business (lines 4 to 8) and " "less the self-employed SEP, SIMPLE and qualified plan deduction " - "attributable to it (lines 9 and 10); the model bases that plan " - "deduction on Schedule C income, so only the Schedule C business " - "subtracts it. For an S corporation it is the shareholder's wages " + "attributable to it (lines 9 and 10). That plan deduction is " + "attributable to the businesses under which the retirement plan is " + "established (self_employed_pension_plan_business), in proportion to " + "each one's earned income, so a business outside the retirement plan " + "subtracts none of it. For an S corporation it is the shareholder's wages " "from the corporation (line 11). self_employed_health_insurance_plan_" "business names the business; when it is not supplied, the limit uses " "the business with the largest earned income, the most that one plan " @@ -62,20 +64,54 @@ def formula(person, period, parameters): out=np.zeros_like(all_business_profits), where=all_business_profits > 0, ) - # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction - # (Schedule 1 line 16) is attributable to the Schedule C business. + # Line 8 for each self-employment business. + schedule_c_line_8 = schedule_c_profit * (1 - self_employment_tax_deduction_rate) + farm_line_8 = farm_profit * (1 - self_employment_tax_deduction_rate) + partnership_line_8 = partnership_profit * ( + 1 - self_employment_tax_deduction_rate + ) + # Line 9: the SEP, SIMPLE and qualified plan deduction (Schedule 1 + # line 16) attributable to each business. The retirement plan's + # earned income is the sum of its businesses' line 8 amounts (Pub. + # 560 Deduction Worksheet step 3), and each of them takes the + # deduction in proportion to its share of that sum. retirement_plan_deduction = person( "self_employed_pension_contribution_ald_person", period ) + retirement_plan_earned_income = person( + "self_employed_pension_earned_income", period + ) + retirement_plan_deduction_rate = np.divide( + retirement_plan_deduction, + retirement_plan_earned_income, + out=np.zeros_like(retirement_plan_earned_income), + where=retirement_plan_earned_income > 0, + ) + retirement_plan_business = person("self_employed_pension_plan_business", period) + retirement_plan_businesses = retirement_plan_business.possible_values + all_businesses = retirement_plan_business == retirement_plan_businesses.ALL + schedule_c_in_plan = all_businesses | ( + retirement_plan_business == retirement_plan_businesses.SCHEDULE_C + ) + farm_in_plan = all_businesses | ( + retirement_plan_business == retirement_plan_businesses.SCHEDULE_F + ) + partnership_in_plan = all_businesses | ( + retirement_plan_business == retirement_plan_businesses.PARTNERSHIP + ) # Line 10 for each self-employment business. schedule_c = max_( 0, - schedule_c_profit * (1 - self_employment_tax_deduction_rate) - - retirement_plan_deduction, + schedule_c_line_8 + * (1 - schedule_c_in_plan * retirement_plan_deduction_rate), + ) + farm = max_( + 0, farm_line_8 * (1 - farm_in_plan * retirement_plan_deduction_rate) ) - farm = max_(0, farm_profit * (1 - self_employment_tax_deduction_rate)) partnership = max_( - 0, partnership_profit * (1 - self_employment_tax_deduction_rate) + 0, + partnership_line_8 + * (1 - partnership_in_plan * retirement_plan_deduction_rate), ) # Line 11: an S corporation shareholder's wages from the corporation. s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index b24352a2de6..7c467b28a3b 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -20,7 +20,15 @@ class self_employed_pension_contribution_limit(Variable): "deferral room that deferrals to other employers' plans leave. With " "no room left, it is the SEP and profit-sharing limit. Catch-up " "contributions sit outside the dollar limit, and the total never " - "exceeds earned income. Defined benefit plans are not modeled." + "exceeds earned income. Earned income comes from every business " + "under which a plan is established (self_employed_pension_earned_" + "income), under one dollar limit and one elective deferral limit. A " + "sole proprietor is the employer for all of their own businesses, so " + "their plans share one dollar limit. A partnership is a separate " + "employer, and unless it is under common control with the person's " + "own business, its plan has a dollar limit of its own. The model " + "applies one, which understates the limit only when that dollar limit " + "binds. Defined benefit plans are not modeled." ) definition_period = YEAR reference = ( @@ -30,6 +38,11 @@ class self_employed_pension_contribution_limit(Variable): "https://www.law.cornell.edu/uscode/text/26/415#c_1", "https://www.law.cornell.edu/uscode/text/26/415#c_3_B", "https://www.law.cornell.edu/uscode/text/26/414#v_3_A", + # Employers under common control are one employer, and section 415 + # aggregates the plans of one employer. + "https://www.law.cornell.edu/uscode/text/26/414#c", + "https://www.law.cornell.edu/uscode/text/26/415#f", + "https://www.law.cornell.edu/uscode/text/26/415#h", "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", ) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py index 3fb98c26ad5..539ccc8373e 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -8,43 +8,66 @@ class self_employed_pension_earned_income(Variable): unit = USD documentation = ( "Earned income, within the meaning of 26 U.S.C. 401(c)(2), from the " - "business that maintains the self-employed SEP, SIMPLE or qualified " - "plan, determined without regard to the plan deduction itself: net " - "profit less the deductible part of self-employment tax (Pub. 560 " - "Deduction Worksheet for Self-Employed, step 3). The model treats the " - "person's Schedule C business as the plan's business. When the person " - "also has farm or partnership self-employment earnings, the deductible " - "part of self-employment tax is split by each business's share of the " - "net profits, as on Form 7206 lines 5 to 8." + "business or businesses under which the self-employed SEP, SIMPLE or " + "qualified plan is established, determined without regard to the plan " + "deduction itself: net profit less the deductible part of " + "self-employment tax (Pub. 560 Deduction Worksheet for Self-Employed, " + "steps 1 to 3). The plan may be established under the Schedule C " + "business, the Schedule F farm or a partnership that reports net " + "earnings on Schedule K-1 box 14 code A " + "(self_employed_pension_plan_business). By default every one of these " + "businesses with a net profit has a plan, and their earned income is " + "added up. The deductible part of self-employment tax is split by each " + "business's share of the net profits, as on Form 7206 lines 5 to 8, so " + "with every profitable business in the plan step 2 is all of Schedule " + "1 line 15." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=7", "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", ) def formula(person, period, parameters): - # Step 1: net profit from the business with the plan. - plan_business_profit = max_(0, person("total_self_employment_income", period)) - # Net profits of every business whose earnings are subject to - # self-employment tax, leaving out net losses. - other_business_profits = max_( - 0, person("farm_operations_income", period) - ) + max_(0, person("partnership_self_employment_net_earnings", period)) - all_business_profits = plan_business_profit + other_business_profits - plan_business_share = np.divide( - plan_business_profit, + # Step 1: net profit of each business that can establish a plan, + # leaving out net losses. + schedule_c_profit = max_(0, person("total_self_employment_income", period)) + farm_profit = max_(0, person("farm_operations_income", period)) + partnership_profit = max_( + 0, person("partnership_self_employment_net_earnings", period) + ) + all_business_profits = schedule_c_profit + farm_profit + partnership_profit + plan_business = person("self_employed_pension_plan_business", period) + businesses = plan_business.possible_values + plan_business_profit = select( + [ + plan_business == businesses.SCHEDULE_C, + plan_business == businesses.SCHEDULE_F, + plan_business == businesses.PARTNERSHIP, + plan_business == businesses.ALL, + ], + [ + schedule_c_profit, + farm_profit, + partnership_profit, + all_business_profits, + ], + default=all_business_profits, + ) + # Step 2: the deductible part of self-employment tax (Schedule 1 + # line 15) attributable to the plan's businesses, by their share of + # the net profits of every business whose earnings are subject to + # self-employment tax. + self_employment_tax_deduction = person("self_employment_tax_ald_person", period) + self_employment_tax_deduction_rate = np.divide( + self_employment_tax_deduction, all_business_profits, out=np.zeros_like(all_business_profits), where=all_business_profits > 0, ) - # Step 2: the deductible part of self-employment tax (Schedule 1 - # line 15) attributable to the plan's business. - self_employment_tax_deduction = ( - person("self_employment_tax_ald_person", period) * plan_business_share - ) # Step 3. - return max_(0, plan_business_profit - self_employment_tax_deduction) + return max_(0, plan_business_profit * (1 - self_employment_tax_deduction_rate)) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py new file mode 100644 index 00000000000..d88eb35fdc9 --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py @@ -0,0 +1,39 @@ +from policyengine_us.model_api import * + + +class SelfEmployedPensionPlanBusiness(Enum): + SCHEDULE_C = "Schedule C business" + SCHEDULE_F = "Schedule F farm" + PARTNERSHIP = "Partnership" + ALL = "All self-employment businesses" + + +class self_employed_pension_plan_business(Variable): + value_type = Enum + entity = Person + possible_values = SelfEmployedPensionPlanBusiness + default_value = SelfEmployedPensionPlanBusiness.ALL + definition_period = YEAR + label = "Business under which the self-employed retirement plan is established" + documentation = ( + "The trade or business under which the person's SEP, SIMPLE or " + "qualified plan (Schedule 1 line 16) is established: the Schedule C " + "business, the Schedule F farm, or a partnership in which the person " + "has Schedule K-1 box 14 code A net earnings. The plan deduction is " + "limited to earned income from the plan's business. ALL (the default) " + "means every one of these businesses with a net profit has a plan, " + "which is the largest limit the person's businesses allow; a " + "business with a net loss is left out. A named business limits the " + "deduction to that business's earned income, as when the person has " + "more than one business but only one has a plan." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.law.cornell.edu/uscode/text/26/401#c_4", + # Compensation: with more than one business but only one plan, only + # that business's earned income counts. Employer: a sole proprietor + # is its own employer, and a partnership is each partner's employer. + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=7", + # Deduction Worksheet step 1: Schedule C, Schedule F or K-1 box 14 A. + "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + ) From dc14b17d33e046bfdc830e101c12eff5821d9004 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:26:33 -0400 Subject: [PATCH 16/41] Test farm and partnership plans against the Pub. 560 worksheet and Form 7206 Pension limit: farm only, partnership only, both businesses by default, a named farm or partnership, a farm loss, and one dollar limit across businesses. Two existing cases now name the Schedule C business to keep their meaning. Form 7206: line 9 split by earned income, a retirement plan under the partnership or the farm, and a farmer with AGI of zero. Step 3 again subtracts the plan businesses' share of line 15, which is exact in float32 for one business. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_ald.yaml | 15 ++ ..._health_insurance_earned_income_limit.yaml | 68 +++++++- ...elf_employed_pension_contribution_ald.yaml | 32 ++++ ...f_employed_pension_contribution_limit.yaml | 152 +++++++++++++++++- .../self_employed_pension_earned_income.py | 10 +- 5 files changed, 266 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index e1683e2ce58..b253ea0fdb5 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -152,3 +152,18 @@ output: # Form 7206 line 13: 10,000 - 0.25 x 2,825.91. self_employed_health_insurance_ald_person: 9_293.5225 + +- name: Case 11, a farmer with health and retirement plans under the farm ends with AGI of zero. + period: 2023 + absolute_error_margin: 0.01 + input: + is_self_employed: true + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + health_insurance_premiums: 20_000 + output: + # Form 7206 line 13: 20,000 - 1,412.955 - 3,000 = 15,587.045. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_ald_person: 15_587.045 + # 20,000 - 1,412.955 - 3,000 - 15,587.045. + adjusted_gross_income: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 8c2390748c0..250438cb6fd 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -4,7 +4,10 @@ # One Form 7206 per plan: the limit is the earned income of the business under # which the plan is established (self_employed_health_insurance_plan_business), # or of the person's business with the largest earned income when that input -# is not supplied. +# is not supplied. Line 9 is the part of the Schedule 1 line 16 retirement plan +# deduction attributable to that business: the retirement plan's businesses +# (self_employed_pension_plan_business, by default every profitable one) take +# it in proportion to their line 8 amounts. - name: Case 1, one business, the limit is net profit less half of self-employment tax. period: 2022 @@ -167,12 +170,13 @@ # Lines 5 to 8: 10,000 / 40,000 = 0.25; 10,000 - 0.25 x 2,825.91. self_employed_health_insurance_earned_income_limit: 9_293.5225 -- name: Case 15, the plan deduction is attributable to Schedule C, so the partnership earns more. +- name: Case 15, a retirement plan under the Schedule C business leaves the partnership earning more. period: 2023 absolute_error_margin: 0.01 input: self_employment_income: 20_000 self_employed_pension_contributions_desired: 15_000 + self_employed_pension_plan_business: SCHEDULE_C partnership_self_employment_net_earnings: 10_000 output: # Line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. @@ -205,3 +209,63 @@ output: self_employment_tax_ald_person: 706.4775 self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 18, retirement plans under both businesses split the plan deduction by earned income. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 15_000 + partnership_self_employment_net_earnings: 10_000 + output: + # Line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. + self_employment_tax_ald_person: 2_119.4325 + # Line 8: Schedule C 18,587.045 and partnership 9,293.5225, together the + # retirement plan's earned income (Pub. 560 step 3), 27,880.5675. + self_employed_pension_earned_income: 27_880.5675 + # Within the 2023 limit of min(66,000, 5,576.11 + 22,500, + # (27,880.57 + 22,500) / 2) = 25,190.28. + self_employed_pension_contribution_ald_person: 15_000 + # Line 9: Schedule C 2 / 3 x 15,000 = 10,000; partnership 5,000. + # Line 10: Schedule C 8,587.045; partnership 4,293.5225. + self_employed_health_insurance_earned_income_limit: 8_587.045 + +- name: Case 19, a retirement plan under the partnership leaves the Schedule C business's earned income whole. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 15_000 + self_employed_pension_plan_business: PARTNERSHIP + partnership_self_employment_net_earnings: 10_000 + output: + # The partnership's earned income, 9,293.5225, caps the plan deduction. + self_employed_pension_contribution_ald_person: 9_293.5225 + # Line 10: Schedule C 18,587.045 (line 9 is 0); partnership 0. + self_employed_health_insurance_earned_income_limit: 18_587.045 + +- name: Case 20, a farm's own retirement plan reduces the farm's limit. + period: 2023 + absolute_error_margin: 0.01 + input: + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + output: + # Line 8: 20,000 - 1,412.955. Line 9: the farm's plan deduction, 3,000. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_earned_income_limit: 15_587.045 + +- name: Case 21, a health plan under the farm subtracts only the farm's share of the retirement plan deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + farm_operations_income: 10_000 + self_employed_pension_contributions_desired: 4_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # Line 15: 2,825.91. Line 8: Schedule C 27,880.5675 and farm 9,293.5225. + self_employed_pension_contribution_ald_person: 4_000 + # Line 9 for the farm: 9,293.5225 / 37,174.09 x 4,000 = 1,000. + # Line 10: 9,293.5225 - 1,000. + self_employed_health_insurance_earned_income_limit: 8_293.5225 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml index a9f2a58f3bb..2fdd4ed36a9 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml @@ -53,3 +53,35 @@ # example's step 3). Person 2: (27,880.57 + 23,500) / 2 = 25,690.28. self_employed_pension_contribution_ald_person: [60_780.73, 25_690.28] self_employed_pension_contribution_ald: 86_471.01 + +- name: Case 4, a farm's own plan is deductible and cannot make AGI negative. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + # Pub. 560 step 1 is Schedule F line 34: 20,000. Step 3: + # 20,000 - 1,412.955 = 18,587.045, all of which deferrals can cover. + self_employed_pension_contribution_ald_person: [18_587.045] + adjusted_gross_income: 0 + +- name: Case 5, a partner's plan is deductible from K-1 box 14 code A earnings. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + # The partnership's earnings enter gross income through + # partnership_income and self-employment tax through + # partnership_self_employment_net_earnings. + partnership_income: 20_000 + partnership_self_employment_net_earnings: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + self_employed_pension_contribution_ald_person: [18_587.045] + adjusted_gross_income: 0 diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index 528277d9deb..89ed78dae7e 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -3,9 +3,11 @@ # https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34), with the 25% plan # rate (step 4 rate 0.2) and, unless the case says otherwise, elective # deferrals (step 9) and catch-up contributions (step 17) equal to the -# person's remaining section 402(g) room. Step 3 is Schedule C net profit less -# half of self-employment tax: 92.35% of profit at 15.3% (12.4% up to the -# Social Security wage base), halved. +# person's remaining section 402(g) room. Step 3 is the net profit of the +# plan's businesses less their share of half of self-employment tax: 92.35% of +# profit at 15.3% (12.4% up to the Social Security wage base), halved. Unless +# the case names one (self_employed_pension_plan_business), every business +# with a net profit has a plan. - name: Self-employed pension contributions are limited by the 2026 annual additions dollar limit. period: 2026 @@ -215,7 +217,7 @@ self_employed_pension_earned_income: 92_935.23 self_employed_pension_contribution_limit: 31_087.05 -- name: Farm earnings take part of the self-employment tax deduction, which is split by net profit. +- name: Farm earnings take part of the self-employment tax deduction, which is split by net profit, from a plan under the Schedule C business. period: 2025 absolute_error_margin: 0.01 input: @@ -225,6 +227,7 @@ self_employment_income: 60_000 farm_operations_income: 40_000 self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: SCHEDULE_C output: # Self-employment tax deduction: 92,350 x 15.3% / 2 = 7,064.78, of which # 60% (4,238.87) is attributable to the Schedule C business. @@ -233,7 +236,7 @@ # Steps 13 + 15: 20% x 55,761.14 + 23,500 = 34,652.23. self_employed_pension_contribution_limit: 34_652.23 -- name: Partnership self-employment earnings alone do not support a contribution, since the model bases the plan on the Schedule C business. +- name: Partnership self-employment earnings do not support a contribution to a plan under the Schedule C business. period: 2025 absolute_error_margin: 0.01 input: @@ -242,6 +245,7 @@ age: 40 partnership_self_employment_net_earnings: 100_000 self_employed_pension_contributions_desired: 10_000 + self_employed_pension_plan_business: SCHEDULE_C output: self_employed_pension_earned_income: 0 self_employed_pension_contribution_limit: 0 @@ -294,3 +298,141 @@ self_employed_pension_contribution_limit: 0 self_employed_pension_contributions: 0 self_employed_pension_contribution_ald_person: 0 + +- name: A farm (Schedule F) is the only business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + farm_operations_income: 100_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 1 to 3: 100,000 - 92,350 x 15.3% / 2 = 100,000 - 7,064.7775. + self_employment_tax_ald_person: 7_064.7775 + self_employed_pension_earned_income: 92_935.2225 + # Step 7: 18,587.0445. Steps 13 + 15: 18,587.0445 + 23,500. + self_employed_pension_contribution_limit: 42_087.0445 + self_employed_pension_contributions: 42_087.0445 + self_employed_pension_contribution_ald_person: 42_087.0445 + +- name: A partnership with K-1 box 14 code A earnings is the only business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + partnership_self_employment_net_earnings: 100_000 + self_employed_pension_contributions_desired: 10_000 + output: + # Step 3 and the limit as for the farm alone. + self_employed_pension_earned_income: 92_935.2225 + self_employed_pension_contribution_limit: 42_087.0445 + self_employed_pension_contributions: 10_000 + +- name: By default a Schedule C business and a farm both have plans, and their earned income adds up. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 1 to 3: 100,000 - 7,064.7775 (all of Schedule 1 line 15). + self_employed_pension_earned_income: 92_935.2225 + # Steps 13 + 15: 18,587.0445 + 23,500, against 34,652.23 with the plan + # under the Schedule C business alone. + self_employed_pension_contribution_limit: 42_087.0445 + +- name: A plan under the farm alone uses the farm's earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: SCHEDULE_F + output: + # Steps 1 to 3: 40,000 - 40% x 7,064.7775 = 37,174.089. + self_employed_pension_earned_income: 37_174.089 + # Step 7: 7,434.82. Step 9: 23,500. Step 12: (37,174.089 - 23,500) / 2 = + # 6,837.0445, the smallest, so step 13 is 6,837.0445. Step 19: 30,337.0445. + self_employed_pension_contribution_limit: 30_337.0445 + +- name: A plan under the partnership alone uses the partnership's earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 50_000 + partnership_self_employment_net_earnings: 50_000 + self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: PARTNERSHIP + output: + # Steps 1 to 3: 50,000 - 50% x 7,064.7775 = 46,467.61125. + self_employed_pension_earned_income: 46_467.6113 + # Steps 13 + 15: 9,293.5223 + 23,500. + self_employed_pension_contribution_limit: 32_793.5223 + +- name: A farm with a net loss has no plan by default and does not reduce the Schedule C business's earned income. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 30_000 + farm_operations_income: -10_000 + self_employed_pension_contributions_desired: 30_000 + output: + # Line 15: (30,000 - 10,000) x 0.9235 x 15.3% / 2 = 1,412.955, all + # attributable to the profitable Schedule C business. + self_employment_tax_ald_person: 1_412.955 + self_employed_pension_earned_income: 28_587.045 + # Step 12: (28,587.045 - 23,000) / 2 = 2,793.5225, the smallest, so + # steps 13 + 15 are 2,793.5225 + 23,000. + self_employed_pension_contribution_limit: 25_793.5225 + self_employed_pension_contributions: 25_793.5225 + +- name: A plan under a farm with a net loss allows no contribution. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 30_000 + farm_operations_income: -10_000 + self_employed_pension_contributions_desired: 30_000 + self_employed_pension_plan_business: SCHEDULE_F + output: + self_employed_pension_earned_income: 0 + self_employed_pension_contribution_limit: 0 + self_employed_pension_contributions: 0 + +- name: Plans under several businesses share one dollar limit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 300_000 + partnership_self_employment_net_earnings: 300_000 + self_employed_pension_contributions_desired: 150_000 + output: + # Line 15: (184,500 x 12.4% + 554,100 x 2.9%) / 2 = 19,473.45. Step 3: + # 600,000 - 19,473.45 = 580,526.55. Step 7 (116,105.31) exceeds the + # $72,000 dollar limit, which binds once for both businesses. + self_employment_tax_ald_person: 19_473.45 + self_employed_pension_contribution_limit: 72_000 diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py index 539ccc8373e..0d50fb7b664 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -62,12 +62,14 @@ def formula(person, period, parameters): # line 15) attributable to the plan's businesses, by their share of # the net profits of every business whose earnings are subject to # self-employment tax. - self_employment_tax_deduction = person("self_employment_tax_ald_person", period) - self_employment_tax_deduction_rate = np.divide( - self_employment_tax_deduction, + plan_business_share = np.divide( + plan_business_profit, all_business_profits, out=np.zeros_like(all_business_profits), where=all_business_profits > 0, ) + self_employment_tax_deduction = ( + person("self_employment_tax_ald_person", period) * plan_business_share + ) # Step 3. - return max_(0, plan_business_profit * (1 - self_employment_tax_deduction_rate)) + return max_(0, plan_business_profit - self_employment_tax_deduction) From c1401819d6afd736dceb63089669d18e4990ad03 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 17:31:16 -0400 Subject: [PATCH 17/41] Extend the plan limit and Form 7206 property tests to farm and partnership plans Pension: every drawn filer runs under each plan business; the reference's step 1 is the plan businesses' net profit; the default's earned income is the sum of the named businesses', its limit is between their largest and their sum, and it never lowers the deduction from a Schedule C plan. AGI stays at or above zero for any filer with only self-employment profit, and a farm grid checks the limit rises with farm profit. Form 7206: filers draw a retirement plan business; line 9 is each business's share of line 16 by line 8, and the shares add up to line 16. Property 5 tightens to AGI >= 0 for filers with no business losses. Co-Authored-By: Claude Opus 5.5 --- ...lf_employed_health_insurance_properties.py | 129 ++++++++--- ..._self_employed_pension_limit_properties.py | 214 +++++++++++++----- 2 files changed, 258 insertions(+), 85 deletions(-) diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 0e4a16eb642..e12803234e2 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -15,18 +15,25 @@ plan business may be a Schedule C business, a Schedule F farm, a partnership (K-1 box 14 code A) or an S corporation. When self_employed_health_insurance_plan_business is not supplied, the model uses -the business with the largest earned income. The model bases the line 16 plan -deduction on Schedule C income, so only the Schedule C business subtracts it. - -Hypothesis draws batches of self-employed single filers and runs each batch as -one vectorized baseline simulation. Each drawn filer appears six times: with -no named plan business, again with higher premiums, and once naming each of -the four businesses. For every filer and year: +the business with the largest earned income. The Schedule 1 line 16 plan +deduction is attributable to the businesses under which the retirement plan is +established (self_employed_pension_plan_business: by default every profitable +business, or one named business), in proportion to each one's line 8 amount, +whose sum is the retirement plan's earned income (Pub. 560 Deduction Worksheet +step 3). + +Hypothesis draws batches of self-employed single filers, each with a +retirement plan business, and runs each batch as one vectorized baseline +simulation. Each drawn filer appears six times: with no named health plan +business, again with higher premiums, and once naming each of the four +businesses. For every filer and year: 1. Reference: each limit equals a closed-form Form 7206 computation for its plan business that takes lines 15 and 16 from the model, and line 15 equals half of the self-employment tax. Without a named business, the - limit is the largest of the four. + limit is the largest of the four. The retirement plan's earned income + equals the sum of its businesses' line 8 amounts, and line 9 across the + businesses adds up to line 16. 2. Differential: the limit without a named business equals the largest of the limits the model computes when each business is named. 3. Bounds: 0 <= deduction <= premiums; the limit is at least the Schedule C @@ -35,12 +42,9 @@ 4. Monotone in premiums: raising premiums by d raises the deduction by at least 0 and at most d, and leaves the limit unchanged. 5. For a filer with no business losses whose income is wages and - self-employment income, the deduction never takes adjusted gross income - below the Schedule C line 10 amount before its floor at zero. That amount - is negative only when the Schedule 1 line 16 plan deduction exceeds the - Schedule C business's net profit less its share of line 15; otherwise - adjusted gross income stays at or above zero. Intended: a plan under the - farm or partnership does not subtract the Schedule C plan deduction. + self-employment income, adjusted gross income is never negative: line 16 + never exceeds the retirement plan's earned income, and the health plan's + business subtracts its share of it. """ import numpy as np @@ -56,6 +60,8 @@ PREMIUM_RAISE = 2_500 BUSINESSES = ["SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP", "S_CORPORATION"] # (name prefix, named plan business, premium raise) for each copy of a filer. +# Businesses under which the retirement plan may be established. +PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ (business.lower(), business, 0) for business in BUSINESSES ] @@ -87,6 +93,9 @@ def build_situation(filers): "self_employed_pension_contributions_desired": every_year( filer["plan"] ), + "self_employed_pension_plan_business": every_year( + filer["plan_business"] + ), "health_insurance_premiums": every_year(filer["premiums"] + raise_), "self_employed_health_insurance_plan_business": every_year( plan_business @@ -98,10 +107,14 @@ def build_situation(filers): return situation, len(filers) -def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): +def form_7206_limits( + schedule_c, farm, partnership, s_corp, plan_business, line_15, line_16 +): """Form 7206 line 13 for each plan business, with lines 15 and 16 given. - Also returns the Schedule C line 10 amount before its floor at zero. + Also returns line 9 for each self-employment business and the retirement + plan's earned income (Pub. 560 step 3), the sum of line 8 over the + businesses under which the retirement plan is established. """ line_4 = { "SCHEDULE_C": np.maximum(schedule_c, 0), @@ -110,15 +123,27 @@ def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): } line_5 = sum(line_4.values()) safe_line_5 = np.where(line_5 > 0, line_5, 1) - line_10 = {} - for business, profit in line_4.items(): - line_6 = np.where(line_5 > 0, profit / safe_line_5, 0) - line_8 = profit - line_15 * line_6 - line_9 = line_16 if business == "SCHEDULE_C" else 0 - line_10[business] = line_8 - line_9 - line_13 = {business: np.maximum(amount, 0) for business, amount in line_10.items()} + line_8 = { + business: profit - line_15 * np.where(line_5 > 0, profit / safe_line_5, 0) + for business, profit in line_4.items() + } + in_plan = { + business: (plan_business == "ALL") | (plan_business == business) + for business in line_4 + } + plan_earned_income = sum(line_8[b] * in_plan[b] for b in line_4) + safe_plan_earned_income = np.where(plan_earned_income > 0, plan_earned_income, 1) + line_9 = { + business: np.where( + in_plan[business] & (plan_earned_income > 0), + line_16 * line_8[business] / safe_plan_earned_income, + 0, + ) + for business in line_4 + } + line_13 = {b: np.maximum(line_8[b] - line_9[b], 0) for b in line_4} line_13["S_CORPORATION"] = np.maximum(s_corp, 0) - return line_13, line_10["SCHEDULE_C"] + return line_13, line_9, plan_earned_income def close(actual, expected): @@ -142,6 +167,7 @@ def copy(array, prefix): farm = column(filers, "farm") partnership = column(filers, "partnership") s_corp = column(filers, "s_corp") + plan_business = np.array([f["plan_business"] for f in filers]) for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) limit = sim.calculate( @@ -152,6 +178,7 @@ def copy(array, prefix): line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) se_tax = sim.calculate("self_employment_tax", year) agi = sim.calculate("adjusted_gross_income", year) + plan_earned_income = sim.calculate("self_employed_pension_earned_income", year) # Lines 15 and 16 and the deduction for every copy. close(line_15, se_tax / 2) @@ -160,11 +187,12 @@ def copy(array, prefix): for prefix, _, _ in COPIES[1:]: close(copy(line_15, prefix), copy(line_15, "u")) close(copy(line_16, prefix), copy(line_16, "u")) - line_13, schedule_c_line_10 = form_7206_limits( + line_13, line_9, reference_plan_earned_income = form_7206_limits( schedule_c, farm, partnership, s_corp, + plan_business, copy(line_15, "u"), copy(line_16, "u"), ) @@ -174,6 +202,8 @@ def copy(array, prefix): close(copy(limit, "u"), largest) for business in BUSINESSES: close(copy(limit, business.lower()), line_13[business]) + close(copy(plan_earned_income, "u"), reference_plan_earned_income) + close(sum(line_9.values()), copy(line_16, "u")) # 2. Differential: the default equals the best named business. named = np.stack([copy(limit, b.lower()) for b in BUSINESSES]) @@ -193,9 +223,8 @@ def copy(array, prefix): # 5. Adjusted gross income floor for filers with no business losses. no_losses = (schedule_c >= 0) & (farm >= 0) & (partnership >= 0) - floor = np.minimum(0, schedule_c_line_10) for prefix, _, _ in COPIES: - assert at_least(copy(agi, prefix)[no_losses], floor[no_losses]) + assert at_least(copy(agi, prefix)[no_losses], 0) money = st.integers(0, 120_000) @@ -210,6 +239,7 @@ def copy(array, prefix): "s_corp": st.one_of(st.just(0), st.integers(0, 150_000)), "wages": st.one_of(st.just(0), st.integers(0, 250_000)), "plan": st.one_of(st.just(0), money), + "plan_business": st.sampled_from(PLAN_BUSINESSES), "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), } ) @@ -228,7 +258,16 @@ def test_deduction_follows_form_7206(filers): check_properties(Simulation(situation=situation), filers, n) -def filer(schedule_c=0, farm=0, partnership=0, s_corp=0, wages=0, plan=0, premiums=0): +def filer( + schedule_c=0, + farm=0, + partnership=0, + s_corp=0, + wages=0, + plan=0, + plan_business="ALL", + premiums=0, +): return dict( schedule_c=schedule_c, farm=farm, @@ -236,6 +275,7 @@ def filer(schedule_c=0, farm=0, partnership=0, s_corp=0, wages=0, plan=0, premiu s_corp=s_corp, wages=wages, plan=plan, + plan_business=plan_business, premiums=premiums, ) @@ -254,9 +294,20 @@ def test_reviewer_example_and_boundaries(): filer(partnership=2_000, premiums=6_000), # S corporation wages. filer(s_corp=5_000, premiums=6_000), - # A plan deduction as large as the Schedule C profit, with the farm as - # the plan business (property 5). + # A plan deduction as large as the Schedule C profit, with retirement + # plans under both businesses and then under the Schedule C business + # alone; the health plan is under the larger farm. filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), + filer( + schedule_c=20_000, + plan=20_000, + plan_business="SCHEDULE_C", + farm=50_000, + premiums=100_000, + ), + # A farm's own retirement plan and a partner's. + filer(farm=20_000, plan=3_000, premiums=100_000), + filer(partnership=20_000, plan=3_000, premiums=100_000), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) @@ -271,5 +322,19 @@ def test_reviewer_example_and_boundaries(): assert abs(deduction[2] - 433) < TOLERANCE # Line 11: the S corporation wages, with no self-employment tax. assert abs(deduction[8] - 5_000) < TOLERANCE - # Farm line 13: 50,000 - 50,000 / 70,000 x (70,000 x 0.9235 x 0.153 / 2). - assert abs(deduction[9] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE + # Line 15: 70,000 x 0.9235 x 0.153 / 2. Line 8: Schedule C 18,587.045 and + # farm 46,467.6125. With retirement plans under both businesses, the + # 2022 limit (13,010.93 + 20,500) allows the 20,000 contribution, and the + # farm's line 9 is 20,000 x 46,467.6125 / 65,054.6575. + farm_line_8 = 50_000 - 50_000 * 0.9235 * 0.153 / 2 + plan_earned_income = 70_000 - 70_000 * 0.9235 * 0.153 / 2 + farm_line_9 = 20_000 * farm_line_8 / plan_earned_income + assert abs(deduction[9] - (farm_line_8 - farm_line_9)) < TOLERANCE + # With the retirement plan under the Schedule C business alone, line 16 + # is capped at that business's earned income and the farm subtracts none + # of it. + assert abs(deduction[10] - farm_line_8) < TOLERANCE + # Line 13 for a farm or a partnership with its own retirement plan: + # 20,000 - 1,412.955 - 3,000. + for i in (11, 12): + assert abs(deduction[i] - 15_587.045) < TOLERANCE diff --git a/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py index 11d31098632..1393c6320a1 100644 --- a/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py @@ -9,24 +9,35 @@ the worksheet's largest total over the elective deferrals and catch-up contributions the person could still make (`self_employed_pension_contribution_limit`). +The plan may be established under the Schedule C business, the Schedule F +farm or a partnership (K-1 box 14 code A), and step 1 is the net profit of the +plan's business (`self_employed_pension_plan_business`). By default every +profitable business has a plan, under one dollar limit and one elective +deferral limit, and their earned income adds up. + The reference below runs the worksheet's steps literally, with the IRS's published dollar amounts rather than model parameters, and takes the largest step 19 over a grid of elective deferrals (step 9) and catch-up contributions -(step 17). Only step 2, the deductible part of self-employment tax, comes from -the model. Hypothesis draws batches of single filers across three years, and -each batch runs as one vectorized simulation. For every person and year: - -1. The limit equals the worksheet maximum, and step 3 equals Schedule C net - profit less the part of the self-employment tax deduction attributable to - it. +(step 17). Only Schedule 1 line 15, the deductible part of self-employment +tax, comes from the model. Hypothesis draws batches of single filers across +three years, and each batch runs as one vectorized simulation in which every +filer appears once for each plan business. For every person and year: + +1. The limit equals the worksheet maximum, and step 3 equals the plan + businesses' net profit less their share of line 15, split by net profit + as on Form 7206 lines 5 to 8. 2. The deduction is min(desired contributions, limit), is at most earned - income (404(a)(8)(C)), which is at most Schedule C net profit, and is at - most the dollar limit plus the person's catch-up amount. -3. For a filer whose only income is Schedule C profit, AGI is never negative - when the profit is not. + income (404(a)(8)(C)), which is at most the plan businesses' net profit, + and is at most the dollar limit plus the person's catch-up amount. +3. For a filer whose only income is self-employment profit, AGI is never + negative when no business has a loss. +4. Across plan businesses: the default's earned income is the sum of the + named businesses' earned income; its limit is at least each named + business's limit, so naming no business never lowers the deduction from + a plan under the Schedule C business alone, and at most their sum. A deterministic grid checks that the limit rises with Schedule C profit and -falls with deferrals to an employer's plan. +with farm profit, and falls with deferrals to an employer's plan. """ import itertools @@ -41,6 +52,10 @@ TOLERANCE = 0.01 # Plan contribution rate: 26 U.S.C. 404(a)(3)(A)(i)(I) and 404(h)(1)(C). PLAN_RATE = 0.25 +# Model outputs are float32, whose spacing exceeds a cent above $131,072. +RTOL = 1e-6 +# Businesses under which the plan may be established; ALL is the default. +PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] # Published amounts: IRS Notices 2021-61 (2022), 2024-80 (2025) and 2025-67 # (2026); section 414(v)(2)(E) for ages 60 to 63 from 2025. PUBLISHED = { @@ -120,6 +135,10 @@ def build_situation(people_inputs): for i, inputs in enumerate(people_inputs): name = f"p{i}" people[name] = {variable: every_year(v) for variable, v in inputs.items()} + # The partnership's earnings also enter gross income. + people[name]["partnership_income"] = every_year( + inputs.get("partnership_self_employment_net_earnings", 0) + ) tax_units[f"t{i}"] = {"members": [name]} households[f"h{i}"] = {"members": [name], "state_code": every_year("TX")} return {"people": people, "tax_units": tax_units, "households": households} @@ -129,26 +148,42 @@ def column(people_inputs, variable): return np.array([p.get(variable, 0) for p in people_inputs], dtype=float) +def plan_business_profit(people_inputs): + """Step 1: the net profit of the businesses under which the plan is established.""" + profits = { + "SCHEDULE_C": np.maximum( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income"), + 0, + ), + "SCHEDULE_F": np.maximum(column(people_inputs, "farm_operations_income"), 0), + "PARTNERSHIP": np.maximum( + column(people_inputs, "partnership_self_employment_net_earnings"), 0 + ), + } + all_profits = sum(profits.values()) + plan_business = np.array([p["plan_business"] for p in people_inputs]) + plan_profit = np.select( + [plan_business == business for business in profits], + list(profits.values()), + all_profits, + ) + return plan_profit, all_profits + + def reference(sim, people_inputs, year): amounts = PUBLISHED[year] age = column(people_inputs, "age") - schedule_c = np.maximum( - column(people_inputs, "self_employment_income") - + column(people_inputs, "sstb_self_employment_income"), - 0, - ) - others = np.maximum( - column(people_inputs, "farm_operations_income"), 0 - ) + np.maximum(column(people_inputs, "partnership_self_employment_net_earnings"), 0) + plan_profit, all_profits = plan_business_profit(people_inputs) share = np.divide( - schedule_c, - schedule_c + others, - out=np.zeros_like(schedule_c), - where=schedule_c + others > 0, + plan_profit, + all_profits, + out=np.zeros_like(plan_profit), + where=all_profits > 0, ) # Step 2 is the one model output the reference takes as given. step_2 = sim.calculate("self_employment_tax_ald_person", year) * share - step_3 = np.maximum(schedule_c - step_2, 0) + step_3 = np.maximum(plan_profit - step_2, 0) # Section 402(g) room left by deferrals to an employer's plan, which use # the regular limit first. catch_up = catch_up_amount(year, age) @@ -160,41 +195,74 @@ def reference(sim, people_inputs, year): catch_up_room = np.minimum(room, catch_up) regular_room = room - catch_up_room limit = worksheet_maximum(step_3, regular_room, catch_up_room, amounts) - return schedule_c, step_3, limit, catch_up + return plan_profit, step_3, limit, catch_up + + +def close(actual, expected, message): + np.testing.assert_allclose( + actual, expected, rtol=RTOL, atol=TOLERANCE, err_msg=message + ) + + +def at_most(actual, bound): + return np.all(actual <= bound + TOLERANCE + RTOL * np.abs(bound)) def check(sim, people_inputs): desired = column(people_inputs, "self_employed_pension_contributions_desired") - schedule_c_only = np.array([p["schedule_c_only"] for p in people_inputs]) + self_employment_only = np.array([p["self_employment_only"] for p in people_inputs]) + no_losses = ( + ( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income") + >= 0 + ) + & (column(people_inputs, "farm_operations_income") >= 0) + & (column(people_inputs, "partnership_self_employment_net_earnings") >= 0) + ) for year in YEARS: - schedule_c, step_3, expected, catch_up = reference(sim, people_inputs, year) + plan_profit, step_3, expected, catch_up = reference(sim, people_inputs, year) earned_income = sim.calculate("self_employed_pension_earned_income", year) limit = sim.calculate("self_employed_pension_contribution_limit", year) deduction = sim.calculate("self_employed_pension_contribution_ald_person", year) message = f"year {year}" # 1. The worksheet. - np.testing.assert_allclose( - earned_income, step_3, atol=TOLERANCE, err_msg=message - ) - np.testing.assert_allclose(limit, expected, atol=TOLERANCE, err_msg=message) + close(earned_income, step_3, message) + close(limit, expected, message) # 2. Bounds. - np.testing.assert_allclose( - deduction, np.minimum(desired, limit), atol=TOLERANCE, err_msg=message - ) + close(deduction, np.minimum(desired, limit), message) assert np.all(deduction >= 0), message - assert np.all(deduction <= earned_income + TOLERANCE), message - assert np.all(earned_income <= schedule_c + TOLERANCE), message + assert at_most(deduction, earned_income), message + assert at_most(earned_income, plan_profit), message cap = PUBLISHED[year]["annual_additions"] + catch_up - assert np.all(limit <= cap + TOLERANCE), message - # 3. AGI from Schedule C alone. + assert at_most(limit, cap), message + # 3. AGI from self-employment profit alone. agi = sim.calculate("adjusted_gross_income", year) - net_profit = column(people_inputs, "self_employment_income") + column( - people_inputs, "sstb_self_employment_income" - ) - rows = schedule_c_only & (net_profit >= 0) + rows = self_employment_only & no_losses assert np.all(agi[rows] >= -TOLERANCE), (message, agi[rows]) +def check_across_plan_businesses(sim, people_inputs): + """Property 4, for filers laid out by every_plan_business.""" + width = len(PLAN_BUSINESSES) + for year in YEARS: + message = f"year {year}" + earned, limits, deductions = ( + sim.calculate(variable, year).reshape(-1, width) + for variable in ( + "self_employed_pension_earned_income", + "self_employed_pension_contribution_limit", + "self_employed_pension_contribution_ald_person", + ) + ) + # Column 0 is the default (every profitable business), column 1 the + # Schedule C business alone. + close(earned[:, 0], earned[:, 1:].sum(axis=1), message) + assert at_most(limits[:, 1:].max(axis=1), limits[:, 0]), message + assert at_most(deductions[:, 1], deductions[:, 0]), message + assert at_most(limits[:, 0], limits[:, 1:].sum(axis=1)), message + + profits = st.one_of( st.sampled_from([0, 400, 433, 434, 2_000, 20_000, 30_000, 200_000, 500_000]), st.integers(-20_000, 600_000), @@ -203,14 +271,12 @@ def check(sim, people_inputs): contributions = st.one_of(st.sampled_from([0, 1_000_000]), st.integers(0, 100_000)) person_strategy = st.fixed_dictionaries( { - "schedule_c_only": st.booleans(), + "self_employment_only": st.booleans(), "age": st.sampled_from([30, 49, 50, 55, 59, 60, 63, 64, 70]), "self_employment_income": profits, "sstb_self_employment_income": st.one_of(st.just(0), profits), "farm_operations_income": smaller, - "partnership_self_employment_net_earnings": st.one_of( - st.just(0), st.integers(0, 150_000) - ), + "partnership_self_employment_net_earnings": st.one_of(st.just(0), smaller), "employment_income": st.one_of(st.just(0), st.integers(0, 250_000)), "traditional_401k_contributions_desired": st.one_of( st.just(0), st.integers(0, 40_000) @@ -225,10 +291,8 @@ def check(sim, people_inputs): def normalize(person): person = dict(person) - if person["schedule_c_only"]: + if person["self_employment_only"]: for variable in ( - "farm_operations_income", - "partnership_self_employment_net_earnings", "employment_income", "traditional_401k_contributions_desired", "roth_401k_contributions_desired", @@ -237,9 +301,25 @@ def normalize(person): return person +def every_plan_business(people_inputs): + """Each filer once for each plan business, in the order of PLAN_BUSINESSES.""" + return [ + dict(person, plan_business=business) + for person in people_inputs + for business in PLAN_BUSINESSES + ] + + def simulation_inputs(people_inputs): return [ - {k: float(v) for k, v in p.items() if k != "schedule_c_only"} + { + **{ + k: float(v) + for k, v in p.items() + if k not in ("self_employment_only", "plan_business") + }, + "self_employed_pension_plan_business": p["plan_business"], + } for p in people_inputs ] @@ -253,10 +333,12 @@ def simulation_inputs(people_inputs): @settings(**SETTINGS) -@given(st.lists(person_strategy.map(normalize), min_size=4, max_size=15)) +@given(st.lists(person_strategy.map(normalize), min_size=2, max_size=8)) def test_limit_matches_the_pub_560_worksheet(people_inputs): + people_inputs = every_plan_business(people_inputs) sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) check(sim, people_inputs) + check_across_plan_businesses(sim, people_inputs) GRID_PROFITS = [ @@ -278,7 +360,8 @@ def test_limit_matches_the_pub_560_worksheet(people_inputs): def grid(): people_inputs = [ { - "schedule_c_only": False, + "self_employment_only": False, + "plan_business": "ALL", "age": 55, "self_employment_income": profit, "employment_income": 50_000, @@ -310,3 +393,28 @@ def test_grid_matches_the_worksheet_and_is_monotone(grid, year): np.minimum(PUBLISHED[year]["annual_additions"], 0.2 * no_room), atol=TOLERANCE, ) + + +GRID_FARM_PROFITS = [0, 1_000, 5_000, 20_000, 60_000, 200_000, 600_000] + + +@pytest.mark.parametrize("year", YEARS) +def test_limit_rises_with_farm_profit(year): + """A profitable farm adds to the default plan's earned income.""" + people_inputs = [ + { + "self_employment_only": True, + "plan_business": "ALL", + "age": 45, + "self_employment_income": 20_000, + "farm_operations_income": farm, + "self_employed_pension_contributions_desired": 1_000_000, + } + for farm in GRID_FARM_PROFITS + ] + sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) + check(sim, people_inputs) + earned_income = sim.calculate("self_employed_pension_earned_income", year) + limit = sim.calculate("self_employed_pension_contribution_limit", year) + assert np.all(np.diff(earned_income) > 0) + assert np.all(np.diff(limit) >= -TOLERANCE) From b03f96f65f592b63fc086346777a60e88ed6e35e Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 13 Oct 2026 14:00:14 -0400 Subject: [PATCH 18/41] Address review: name partnership and S corporation in YAML, document the default's limits - Limit YAML Cases 18 and 19 name PARTNERSHIP and S_CORPORATION. - The plan business input says when a partner's or shareholder's own policy counts, and that all Schedule C income is one business. - Cite PDF pages 1-2 of the Form 7206 instructions. - Drop the sum-of-businesses bound, which the differential property implies. Co-Authored-By: Claude Opus 5.5 --- ..._health_insurance_earned_income_limit.yaml | 26 +++++++++++++++++++ ...lf_employed_health_insurance_properties.py | 9 +++---- ...ed_health_insurance_earned_income_limit.py | 1 + ...employed_health_insurance_plan_business.py | 15 ++++++++--- 4 files changed, 43 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 8c2390748c0..1a18a962f0e 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -205,3 +205,29 @@ output: self_employment_tax_ald_person: 706.4775 self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 18, a named partnership sets the limit even when the farm earns more. + period: 2025 + absolute_error_margin: 0.01 + input: + farm_operations_income: 40_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_health_insurance_plan_business: PARTNERSHIP + output: + # Line 15: 50,000 x 0.9235 x 0.153 / 2 = 3,532.3875. + self_employment_tax_ald_person: 3_532.3875 + # Lines 5 to 8: 10,000 / 50,000 = 0.2; 10,000 - 0.2 x 3,532.3875. + self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 19, a named S corporation sets the limit even when the Schedule C business earns more. + period: 2025 + absolute_error_margin: 0.01 + input: + employment_income: 10_000 + s_corp_shareholder_medicare_wages: 10_000 + self_employment_income: 50_000 + self_employed_health_insurance_plan_business: S_CORPORATION + output: + # Line 11: 10,000. The Schedule C business would give + # 50,000 - 50,000 x 0.9235 x 0.153 / 2 = 46,467.6125. + self_employed_health_insurance_earned_income_limit: 10_000 diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 0e4a16eb642..1349c09c55e 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -28,10 +28,10 @@ equals half of the self-employment tax. Without a named business, the limit is the largest of the four. 2. Differential: the limit without a named business equals the largest of the - limits the model computes when each business is named. -3. Bounds: 0 <= deduction <= premiums; the limit is at least the Schedule C - limit (widening the base never lowers the deduction) and at most the sum - of the four businesses' limits (one plan cannot pool businesses). + limits the model computes when each business is named, so one plan never + pools the earned income of two businesses. +3. Bounds: 0 <= deduction <= premiums, and the limit is at least the Schedule + C limit (widening the base never lowers the deduction). 4. Monotone in premiums: raising premiums by d raises the deduction by at least 0 and at most d, and leaves the limit unchanged. 5. For a filer with no business losses whose income is wages and @@ -183,7 +183,6 @@ def copy(array, prefix): assert np.all(deduction >= 0) assert at_least(premiums, deduction) assert at_least(copy(limit, "u"), line_13["SCHEDULE_C"]) - assert at_least(named.sum(axis=0), copy(limit, "u")) # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. base, raised = copy(deduction, "u"), copy(deduction, "r") diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index 9fda60d1bcb..8dd5db0052e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -34,6 +34,7 @@ class self_employed_health_insurance_earned_income_limit(Variable): "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", # Where a plan may be established, and one Form 7206 per plan. + # PDF pages 1-2 "https://www.irs.gov/pub/irs-prior/i7206--2023.pdf#page=1", # The worksheet Form 7206 replaced, used before 2023. "https://www.irs.gov/pub/irs-prior/p535--2022.pdf#page=23", diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py index 1d665c415fa..10dd54031ef 100644 --- a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -25,11 +25,20 @@ class self_employed_health_insurance_plan_business(Variable): "Schedule K-1 box 14 code A net earnings, or an S corporation in which " "the person is a more-than-2% shareholder. The self-employed health " "insurance deduction is limited to the earned income from that " - "business alone (one Form 7206 per plan). UNSPECIFIED (the default) " - "means the business was not supplied, and the limit uses the person's " - "business with the largest earned income." + "business alone (one Form 7206 per plan). The Schedule C business is " + "all of the person's Schedule C income, SSTB and non-SSTB together, " + "because the inputs do not separate one Schedule C business from " + "another. A partner's or shareholder's own policy counts as " + "established under the partnership or S corporation only when that " + "business pays or reimburses the premiums and reports them as " + "guaranteed payments or wages. UNSPECIFIED (the default) means the " + "business was not supplied, and the limit uses the person's business " + "with the largest earned income; name the business when a partnership " + "or S corporation with the largest earned income does not pay or " + "reimburse the premiums." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", + # PDF pages 1-2 "https://www.irs.gov/pub/irs-prior/i7206--2023.pdf#page=1", ) From b4a9c15f5cc8702a3535452b56e76e95b457244f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:51:14 +0000 Subject: [PATCH 19/41] Expect the Pub. 560 plan deduction in the Form 7206 plan-deduction case Main's Case 9 (2023, $20,000 of profit, $20,000 desired) expected the old plan deduction of $20,000. The worksheet caps it at earned income, 20,000 - 1,412.955 = 18,587.045, so line 10 is still 0. Co-Authored-By: Claude Opus 5.5 --- ...lf_employed_health_insurance_earned_income_limit.yaml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index ab97a8aa061..dbf3f7562e2 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -94,7 +94,7 @@ output: self_employed_health_insurance_earned_income_limit: 0 -- name: Case 9, a plan deduction as large as net profit leaves no limit. +- name: Case 9, a plan deduction as large as earned income leaves no limit. period: 2023 absolute_error_margin: 0.01 input: @@ -103,9 +103,10 @@ output: # Schedule 1 line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. self_employment_tax_ald_person: 1_412.955 - # Schedule 1 line 16: the model caps the plan deduction at net profit. - self_employed_pension_contribution_ald_person: 20_000 - # Lines 8 to 13: max(0, 20,000 - 1,412.955 - 20,000). + # Schedule 1 line 16: the Pub. 560 maximum, which elective deferrals let + # reach all of earned income: 20,000 - 1,412.955 = 18,587.045. + self_employed_pension_contribution_ald_person: 18_587.045 + # Lines 8 to 13: max(0, 20,000 - 1,412.955 - 18,587.045). self_employed_health_insurance_earned_income_limit: 0 - name: Case 10, SSTB and non-SSTB Schedule C income are one plan business, so an SSTB loss reduces line 4. From 7810fd1d8197d20e98e1fb593ada74da299c1178 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:04:41 +0000 Subject: [PATCH 20/41] Test that catch-ups can't take the plan limit above earned income A mutant that added catch-up contributions outside the earned income cap passed both YAML files and failed only the property test. The new case (2025, age 55, $20,000 of profit) has step 3 of $18,587.05 and leaves no room in step 16 for the $7,500 catch-up, so the limit is $18,587.05; the mutant gives $26,087.05. Also correct worksheet comments: case 1's 20% figure is step 5 (step 6 caps step 7 at $90,000), the header now says step 3 subtracts only the Schedule C share of line 15, and the ALD comments use the exact $1,412.955. The changelog no longer says the deduction can't reach 100% of profit, which it can below the $400 threshold. Co-Authored-By: Claude Opus 5.5 --- ...ix-se-pension-earned-income-limit.fixed.md | 2 +- ...elf_employed_pension_contribution_ald.yaml | 8 +++--- ...f_employed_pension_contribution_limit.yaml | 27 ++++++++++++++++--- 3 files changed, 28 insertions(+), 9 deletions(-) diff --git a/changelog.d/fix-se-pension-earned-income-limit.fixed.md b/changelog.d/fix-se-pension-earned-income-limit.fixed.md index a3429675655..a7b63817ad0 100644 --- a/changelog.d/fix-se-pension-earned-income-limit.fixed.md +++ b/changelog.d/fix-se-pension-earned-income-limit.fixed.md @@ -1 +1 @@ -Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum, based on Schedule C net profit less the deductible part of self-employment tax, so it can no longer reach 100% of profit or make AGI negative. +Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum, based on Schedule C net profit less its share of the deductible part of self-employment tax, so the deduction can no longer exceed that earned income or make AGI negative. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml index a9f2a58f3bb..8c61e21b77c 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_pension_contribution_ald.yaml @@ -8,8 +8,8 @@ self_employment_income: 20_000 self_employed_pension_contributions_desired: 20_000 output: - # Half of self-employment tax: 20,000 x 92.35% x 15.3% / 2 = 1,412.96. - # Pub. 560 Deduction Worksheet step 3: 20,000 - 1,412.96 = 18,587.05, + # Half of self-employment tax: 20,000 x 92.35% x 15.3% / 2 = 1,412.955. + # Pub. 560 Deduction Worksheet step 3: 20,000 - 1,412.955 = 18,587.045, # the most the plan deduction can be (26 U.S.C. 404(a)(8)(C)). self_employment_tax_ald: 1_412.96 self_employed_pension_contribution_ald_person: [18_587.05] @@ -49,7 +49,7 @@ marital_unit: members: [person1, person2] output: - # Person 1: 20% x 186,403.65 + 23,500 = 60,780.73 (the Pub. 560 2025 - # example's step 3). Person 2: (27,880.57 + 23,500) / 2 = 25,690.28. + # Person 1: 20% x 186,403.65 (step 3 in the Pub. 560 2025 example) + + # 23,500 = 60,780.73. Person 2: (27,880.5675 + 23,500) / 2 = 25,690.28. self_employed_pension_contribution_ald_person: [60_780.73, 25_690.28] self_employed_pension_contribution_ald: 86_471.01 diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index 528277d9deb..cf08cd24cef 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -4,8 +4,9 @@ # rate (step 4 rate 0.2) and, unless the case says otherwise, elective # deferrals (step 9) and catch-up contributions (step 17) equal to the # person's remaining section 402(g) room. Step 3 is Schedule C net profit less -# half of self-employment tax: 92.35% of profit at 15.3% (12.4% up to the -# Social Security wage base), halved. +# its share of the deductible part of self-employment tax: half of 15.3% (12.4% +# only up to the Social Security wage base less wages) of 92.35% of all net +# profit, split by each business's share of the positive net profits. - name: Self-employed pension contributions are limited by the 2026 annual additions dollar limit. period: 2026 @@ -17,8 +18,8 @@ self_employed_pension_contributions_desired: 100_000 output: # Step 3: 500,000 - (184,500 x 12.4% + 461,750 x 2.9%) / 2 = 481,865.63. - # Step 7: 20% x 481,865.63 = 96,373.13; steps 13 + 15 stop at the $72,000 - # dollar limit. + # Step 5: 20% x 481,865.63 = 96,373.13; step 6 caps it at 360,000 x 25% = + # 90,000 (step 7). Steps 13 + 15 stop at the $72,000 dollar limit. self_employed_pension_contribution_limit: 72_000 self_employed_pension_contributions: 72_000 self_employed_pension_contribution_ald_person: 72_000 @@ -165,6 +166,24 @@ output: self_employed_pension_contribution_limit: 80_000 +- name: Catch-up contributions can't take the limit above earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 55 + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 30_000 + output: + # Step 3: 20,000 - 18,470 x 15.3% / 2 = 18,587.05. Step 9 at 18,587.05 + # leaves steps 11 to 13 at zero, and step 16 is 18,587.05 - 18,587.05 = 0, + # so step 18 adds none of the 7,500 catch-up: step 19 is 18,587.05 + # (sections 404(a)(8)(C) and 414(v)(2)(A)(ii)). + self_employed_pension_earned_income: 18_587.05 + self_employed_pension_contribution_limit: 18_587.05 + self_employed_pension_contributions: 18_587.05 + - name: With low profit, deferrals can reach all of earned income but no more. period: 2025 absolute_error_margin: 0.01 From 4c3a6c79a77db98da821c434ddb7301bb38aa82e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:13:47 +0000 Subject: [PATCH 21/41] Allocate line 15 by each business's share, as main does The per-business rewrite computed profit x (1 - line 15 / line 5), which adds two float32 roundings. For one-business filers it moved the limit from main's value by up to $0.03 (92 of 400 Schedule C-only test filers). Each business now subtracts line 15 x its own share of line 5, the same operations main uses, so Schedule C-only limits match main bit for bit. The limit also documents that each kind of business is one total per person, and that CRP payments, the optional methods and the personal-services test are not modeled. The two 162 references are one source, so they become one entry. Co-Authored-By: Claude Opus 5.5 --- ...ed_health_insurance_earned_income_limit.py | 55 ++++++++++++------- 1 file changed, 34 insertions(+), 21 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index b1a30ed7146..5f1c19768e3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -20,21 +20,25 @@ class self_employed_health_insurance_earned_income_limit(Variable): "less the self-employed SEP, SIMPLE and qualified plan deduction " "attributable to it (lines 9 and 10); the model bases that plan " "deduction on Schedule C income, so only the Schedule C business " - "subtracts it. The Schedule C business is all of the person's " - "Schedule C income, SSTB and non-SSTB together, so a loss in one " - "reduces line 4. Schedule 1 line 15 is read per person: each person's " - "own deductible self-employment tax. For an S corporation it is the " - "shareholder's wages from the corporation (line 11). " - "self_employed_health_insurance_plan_business names the business; " - "when it is not supplied, the limit uses the business with the " - "largest earned income, the most that one plan can deduct. " - "Self-employment income inputs are already net of any section 911 " - "exclusion (line 12)." + "subtracts it. Each kind of business is one total per person: all " + "Schedule C income (SSTB and non-SSTB), all Schedule F profit, all " + "K-1 box 14 code A earnings and all more-than-2% shareholder wages, " + "so a loss in one business of a kind reduces line 4. Schedule 1 line " + "15 is read per person: each person's own deductible self-employment " + "tax. For an S corporation it is the shareholder's wages from the " + "corporation (line 11). self_employed_health_insurance_plan_business " + "names the business; when it is not supplied, the limit uses the " + "business with the largest earned income, the most that one plan can " + "deduct. Conservation Reserve Program payments, the optional methods " + "of figuring net earnings and the personal-services test of section " + "401(c)(2)(A)(i) are not modeled, so each business's net profit enters " + "lines 4 and 5 in full. Self-employment income inputs are already net " + "of any section 911 exclusion (line 12)." ) definition_period = YEAR reference = ( - "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", - "https://www.law.cornell.edu/uscode/text/26/162#l_5", + # Subsection (l)(2)(A), and (l)(5) for S corporation shareholders. + "https://www.law.cornell.edu/uscode/text/26/162#l", "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", # Where a plan may be established, and one Form 7206 per plan. @@ -61,12 +65,16 @@ def formula(person, period, parameters): # self-employment tax (Schedule 1 line 15) in proportion to its share # of line 5. self_employment_tax_deduction = person("self_employment_tax_ald_person", period) - self_employment_tax_deduction_rate = np.divide( - self_employment_tax_deduction, - all_business_profits, - out=np.zeros_like(all_business_profits), - where=all_business_profits > 0, - ) + + def attributable_self_employment_tax_deduction(profit): + share = np.divide( + profit, + all_business_profits, + out=np.zeros_like(all_business_profits), + where=all_business_profits > 0, + ) + return self_employment_tax_deduction * share + # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction # (Schedule 1 line 16) is attributable to the Schedule C business. retirement_plan_deduction = person( @@ -75,12 +83,17 @@ def formula(person, period, parameters): # Line 10 for each self-employment business. schedule_c = max_( 0, - schedule_c_profit * (1 - self_employment_tax_deduction_rate) + schedule_c_profit + - attributable_self_employment_tax_deduction(schedule_c_profit) - retirement_plan_deduction, ) - farm = max_(0, farm_profit * (1 - self_employment_tax_deduction_rate)) + farm = max_( + 0, farm_profit - attributable_self_employment_tax_deduction(farm_profit) + ) partnership = max_( - 0, partnership_profit * (1 - self_employment_tax_deduction_rate) + 0, + partnership_profit + - attributable_self_employment_tax_deduction(partnership_profit), ) # Line 11: an S corporation shareholder's wages from the corporation. s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) From 3019e6c741c61ee15560ff4a8b8bb9da366086fd Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:13:47 +0000 Subject: [PATCH 22/41] Say where entity-paid premiums go and that every business kind is pooled The plan business input now says that a farm, a partnership and an S corporation are each one total per person, like the Schedule C business, and that premiums a partnership or S corporation pays or reimburses go in health_insurance_premiums. The S corporation input says those premiums are box 1 wages but not box 5 wages (26 U.S.C. 3121(a)(2)(B)), so they belong in employment_income but not here, and that is_self_employed must be set even when the shareholder has no other earned income. Co-Authored-By: Claude Opus 5.5 --- ...employed_health_insurance_plan_business.py | 20 ++++++++++++------- .../s_corp_shareholder_medicare_wages.py | 13 ++++++++++-- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py index 10dd54031ef..76b435eb4c5 100644 --- a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -25,13 +25,19 @@ class self_employed_health_insurance_plan_business(Variable): "Schedule K-1 box 14 code A net earnings, or an S corporation in which " "the person is a more-than-2% shareholder. The self-employed health " "insurance deduction is limited to the earned income from that " - "business alone (one Form 7206 per plan). The Schedule C business is " - "all of the person's Schedule C income, SSTB and non-SSTB together, " - "because the inputs do not separate one Schedule C business from " - "another. A partner's or shareholder's own policy counts as " - "established under the partnership or S corporation only when that " - "business pays or reimburses the premiums and reports them as " - "guaranteed payments or wages. UNSPECIFIED (the default) means the " + "business alone (one Form 7206 per plan). Each kind of business is one " + "total per person, because the inputs do not separate one business of " + "a kind from another: the Schedule C business is all of the person's " + "Schedule C income, SSTB and non-SSTB together, the farm all of their " + "Schedule F profit, the partnership all of their K-1 box 14 code A " + "earnings, and the S corporation all of their wages as a " + "more-than-2% shareholder. A partner's or shareholder's own policy " + "counts as established under the partnership or S corporation only " + "when that business pays or reimburses the premiums and reports them " + "as guaranteed payments or wages; enter those premiums in " + "health_insurance_premiums. The deduction counts premiums only when " + "is_self_employed is true, including for a shareholder whose only " + "earned income is S corporation wages. UNSPECIFIED (the default) means the " "business was not supplied, and the limit uses the person's business " "with the largest earned income; name the business when a partnership " "or S corporation with the largest earned income does not pay or " diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py index c9958cc6877..e8d32d14e4e 100644 --- a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -14,13 +14,22 @@ class s_corp_shareholder_medicare_wages(Variable): "Like the other income inputs, enter the amount left after any " "section 911 foreign earned income exclusion attributable to these " "wages (line 12). They are part of employment_income, not in addition " - "to it. As for other self-employed people, the deduction counts " - "health_insurance_premiums only when is_self_employed is true." + "to it. Premiums the corporation pays or reimburses for the " + "shareholder and reports in box 1 as wages count as the " + "shareholder's own: include them in employment_income and in " + "health_insurance_premiums, but not here, because box 5 leaves out " + "premiums paid under a plan for employees generally (section " + "3121(a)(2)(B)). The model has no wage input that is in box 1 but " + "not box 5, so those premiums also count as payroll tax wages. The " + "deduction counts health_insurance_premiums only when " + "is_self_employed is true, so set it for a shareholder even when " + "these wages are their only earned income." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/162#l_5", "https://www.law.cornell.edu/uscode/text/26/1372", + "https://www.law.cornell.edu/uscode/text/26/3121#a_2", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", ) uprating = "calibration.gov.irs.soi.employment_income" From 95b566c37a95a33db3251c819fd4be7a3c20621f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:13:47 +0000 Subject: [PATCH 23/41] Make the S corporation case a valid return and check the reference limit ALD Case 11 kept the reimbursed premiums out of box 1 wages, which the Form 7206 instructions require for the plan to count as established under the S corporation, so its AGI of 0 couldn't occur. Box 1 wages are now 22,000 and AGI 12,000. The property test compared the deduction with the model's own limit; it now uses the reference limit for each copy. Limit Cases 15 and 19 gain their hand computations. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_ald.yaml | 8 ++++++-- ...ed_health_insurance_earned_income_limit.yaml | 3 +++ ...self_employed_health_insurance_properties.py | 17 +++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index bbbc5d7e289..8cc9658a4be 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -194,13 +194,17 @@ absolute_error_margin: 0.01 input: is_self_employed: true - employment_income: 10_000 + # The corporation reimburses the 12,000 of premiums and reports them in + # W-2 box 1, so box 1 wages are 22,000. Box 5 Medicare wages leave them + # out (26 U.S.C. 3121(a)(2)(B)): 10,000. + employment_income: 22_000 s_corp_shareholder_medicare_wages: 10_000 health_insurance_premiums: 12_000 output: # Form 7206: line 3 is 12,000; line 13 is line 11, 10,000. self_employed_health_insurance_ald_person: 10_000 - adjusted_gross_income: 0 + # 22,000 - 10,000. + adjusted_gross_income: 12_000 - name: Case 12, a plan named under the smaller farm is limited to the farm's earned income. period: 2024 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index f8de1fb7653..0594a350c08 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -179,6 +179,7 @@ farm_operations_income: 40_000 self_employed_health_insurance_plan_business: SCHEDULE_C output: + # Schedule C line 13, as in Case 14: 5,000 - 5,000 / 45,000 x 3,179.14875. self_employed_health_insurance_earned_income_limit: 4_646.76125 - name: Case 16, a named farm business takes its own share of the self-employment tax deduction. @@ -229,6 +230,8 @@ self_employment_income: 10_000 self_employed_health_insurance_plan_business: SCHEDULE_C output: + # Schedule 1 line 15: 10,000 x 0.9235 x 0.153 / 2 = 706.4775. Schedule C + # line 13: 10,000 - 706.4775; line 11 does not apply. self_employment_tax_ald_person: 706.4775 self_employed_health_insurance_earned_income_limit: 9_293.5225 diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index a230c10777e..9da77e8c0e8 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -29,9 +29,11 @@ computed here from Schedule SE, and each limit equals a closed-form Form 7206 computation for its plan business from that line 15 and the model's line 16. Without a named business, the limit is the largest of the four. + The deduction is the smaller of the premiums and that reference limit. 2. Differential: the limit without a named business equals the largest of the - limits the model computes when each business is named, so one plan never - pools the earned income of two businesses. + limits the model computes when each business is named. With property 1, + which ties each named limit to that business's own Form 7206, one plan + never pools the earned income of two businesses. 3. Bounds: 0 <= deduction <= premiums, and the limit is at least the Schedule C limit (widening the base never lowers the deduction). 4. Monotone in premiums: raising premiums by d raises the deduction by at @@ -183,13 +185,12 @@ def copy(array, prefix): line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) agi = sim.calculate("adjusted_gross_income", year) - # Line 15 from Schedule SE, line 16, and the deduction for every copy. + # Line 15 from Schedule SE, and line 16, for every copy. expected_line_15 = schedule_se_deduction( year, schedule_c, farm, partnership, wages ) for prefix, _, _ in COPIES: close(copy(line_15, prefix), expected_line_15) - close(deduction, np.minimum(premiums, limit)) # Line 16 does not depend on premiums or the named business. for prefix, _, _ in COPIES[1:]: close(copy(line_16, prefix), copy(line_16, "u")) @@ -202,8 +203,16 @@ def copy(array, prefix): copy(line_16, "u"), ) largest = np.max(np.stack(list(line_13.values())), axis=0) + # The reference limit for every copy, in the simulation's order. + reference_limit = np.concatenate( + [ + largest if business == "UNSPECIFIED" else line_13[business] + for _, business, _ in COPIES + ] + ) # 1. Reference. + close(deduction, np.minimum(premiums, reference_limit)) close(copy(limit, "u"), largest) for business in BUSINESSES: close(copy(limit, business.lower()), line_13[business]) From 418e5353eb4b5d41e5469c99fd2d5f24be5e8618 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:28:01 +0000 Subject: [PATCH 24/41] Record the catch-up and step 2 readings, and say what the limit leaves out Two readings go to the source-conflict backlog for the register: - SECURE 2.0 section 603(b)(1) struck 402(g)(1)(C) for years after 2023, but Notice 2023-62 and Treas. Reg. 1.402(g)-2 keep pre-tax catch-ups excludable. The model follows them, in elective_deferral_limit and now in this limit's catch-up room. - With more than one business, Pub. 560's step 2 enters all of Schedule 1 line 15; the model splits it by net profit as Form 7206 does. The limit's documentation now says it is the most a defined contribution plan allows, so it overstates a SEP or SIMPLE IRA and cuts defined benefit contributions above it, and that steps 6 and 7 are left out only because they never bind at current law. Earned income says it assumes the personal-services test is met. References name each source once, with the 2025 Pub. 560 at its stable URL. Co-Authored-By: Claude Opus 5.5 --- .../limit/profit_sharing_deduction_rate.yaml | 6 +- ...mployed_pension_contribution_ald_person.py | 2 +- ...elf_employed_pension_contribution_limit.py | 41 ++++++---- .../self_employed_pension_earned_income.py | 8 +- source_conflicts/candidates.yaml | 77 +++++++++++++++++++ 5 files changed, 110 insertions(+), 24 deletions(-) diff --git a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml index d74d72522cf..ebb046eb324 100644 --- a/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml @@ -6,13 +6,11 @@ metadata: period: year label: SEP and profit-sharing plan employer deduction limit rate reference: - - title: 26 U.S. Code § 404(a)(3)(A)(i)(I) - Stock bonus and profit-sharing trusts + - title: 26 U.S. Code § 404(a)(3)(A)(i)(I) and (h)(1)(C) - Profit-sharing trusts and simplified employee pensions href: https://www.law.cornell.edu/uscode/text/26/404#a_3_A_i_I - - title: 26 U.S. Code § 404(h)(1)(C) - Simplified employee pensions - href: https://www.law.cornell.edu/uscode/text/26/404#h_1_C - title: Economic Growth and Tax Relief Reconciliation Act of 2001, Pub. L. 107-16, § 616 (15 to 25 percent, years beginning after 2001) href: https://www.congress.gov/107/plaws/publ16/PLAW-107publ16.pdf#page=66 - title: IRS Publication 560 (2025), Rate Table for Self-Employed - href: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=40 + href: https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=40 - title: IRS Publication 560 (2022), Rate Table for Self-Employed href: https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=35 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 ece0c96b280..acfc115e9a0 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 @@ -15,7 +15,7 @@ class self_employed_pension_contribution_ald_person(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/26/62#a_6", "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", - "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=39", ) def formula(person, period, parameters): diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index b24352a2de6..8a848835e0c 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -13,24 +13,28 @@ class self_employed_pension_contribution_limit(Variable): "compensation for a self-employed person is earned income net of " "the plan deduction itself, so the 25% employer rate becomes 20% of " "earned income before that deduction, and the 100%-of-compensation " - "limit becomes half of it less elective deferrals. The modeled input " + "limit caps employer contributions at half of earned income less " + "elective deferrals (step 12). The modeled input " "does not say which kind of plan the person has, so the limit is the " - "most any of them allows: a profit-sharing plan with a 401(k) " - "arrangement into which the person defers all of the elective " - "deferral room that deferrals to other employers' plans leave. With " - "no room left, it is the SEP and profit-sharing limit. Catch-up " - "contributions sit outside the dollar limit, and the total never " - "exceeds earned income. Defined benefit plans are not modeled." + "most any defined contribution plan allows: a profit-sharing plan " + "with a 401(k) arrangement into which the person defers, before tax, " + "all of the elective deferral room that deferrals to other employers' " + "401(k) and 403(b) plans leave. With no room left, it is the SEP and " + "profit-sharing limit. Catch-up contributions sit outside the dollar " + "limit, and the total never exceeds earned income. A SEP or a SIMPLE " + "IRA allows less, so the limit overstates their deductions. Defined " + "benefit plans are not modeled, so contributions to them above this " + "limit are cut." ) definition_period = YEAR reference = ( - "https://www.law.cornell.edu/uscode/text/26/404#a_3_A_i_I", - "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", - "https://www.law.cornell.edu/uscode/text/26/404#n", - "https://www.law.cornell.edu/uscode/text/26/415#c_1", - "https://www.law.cornell.edu/uscode/text/26/415#c_3_B", - "https://www.law.cornell.edu/uscode/text/26/414#v_3_A", - "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + # Subsections (a)(3)(A)(i)(I), (a)(8)(C) and (D), (h)(1)(C) and (n). + "https://www.law.cornell.edu/uscode/text/26/404", + # Subsections (c)(1) and (c)(3)(B) and (D). + "https://www.law.cornell.edu/uscode/text/26/415#c", + # Subsections (v)(2)(A) and (v)(3)(A). + "https://www.law.cornell.edu/uscode/text/26/414#v", + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=39", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", ) @@ -39,9 +43,12 @@ def formula(person, period, parameters): # Step 3: earned income before the plan deduction. earned_income = person("self_employed_pension_earned_income", period) # Steps 4 and 5: the employer contribution rate, reduced because - # compensation is net of the contribution. Steps 6 and 7 cap the rate - # at the section 401(a)(17) compensation limit, which never binds: - # 25% of it has exceeded the step 8 dollar limit in every year. + # compensation is net of the contribution. Steps 6 and 7 cap the + # employer contribution at the plan rate times the section + # 401(a)(17) compensation limit. At current law that never binds the + # result, because 25% of the compensation limit is 1.25 times the + # step 8 dollar limit, so the model leaves it out; a reform that set + # the rate times that limit below the dollar limit would need it. rate = p.profit_sharing_deduction_rate employer_limit = earned_income * rate / (1 + rate) # Step 9: elective deferrals, up to the section 402(g) room left diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py index 3fb98c26ad5..6fd348a6ba2 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -15,13 +15,17 @@ class self_employed_pension_earned_income(Variable): "person's Schedule C business as the plan's business. When the person " "also has farm or partnership self-employment earnings, the deductible " "part of self-employment tax is split by each business's share of the " - "net profits, as on Form 7206 lines 5 to 8." + "net profits, as on Form 7206 lines 5 to 8; the worksheet's step 2 " + "enters all of Schedule 1 line 15. All Schedule C profit counts as " + "earned income: the model assumes the personal-services test of " + "section 401(c)(2)(A)(i) is met." ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", - "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + # PDF pages 7, 39 + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", ) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 025bb5dd42b..c5bacabe79a 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -10159,6 +10159,48 @@ candidates: refundable/fully_refundable.yaml, refundable/foreign_earned_income_exclusion_bar_applies.yaml). The statute-first rule requires revising the deduction-only ACTC bar; verify that interpretation separately from EITC, whose statutory wording differs.' +- id: us:catch-up-deferrals-after-402g1c-repeal + program: income-tax + years: + from: 2024 + type: tax + question: Are pre-tax catch-up contributions above the section 402(g)(1)(B) amount excluded from gross income, and + deductible in a self-employed plan, now that SECURE 2.0 section 603(b)(1) has struck section 402(g)(1)(C)? + sources: + - kind: statute + citation: 26 U.S.C. 402(g)(1) as amended by Pub. L. 117-328 section 603(b)(1) and (c); 414(v)(3)(A)(i) + url: https://www.law.cornell.edu/uscode/text/26/402#g_1 + - kind: regulation + citation: 26 CFR 1.402(g)-2(a) + url: https://www.law.cornell.edu/cfr/text/26/1.402(g)-2 + - kind: guidance + citation: Notice 2023-62, section III.A, PDF pages 4-5 + url: https://www.irs.gov/pub/irs-drop/n-23-62.pdf#page=4 + - kind: worksheet + citation: Publication 560 (2025), Deduction Worksheet for Self-Employed, steps 16-18 + url: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39 + readings: + statute: For taxable years beginning after 2023, section 402(g)(1)(A) includes elective deferrals above the + applicable dollar amount in gross income, except designated Roth contributions; the catch-up exclusion in former + subparagraph (C) is struck, and section 414(v)(3)(A)(i) does not list section 402(g). + other: Notice 2023-62 says non-Roth catch-up contributions are not includible under section 402(g)(1)(A), reading + section 414(v)(3)(A)(i); section 1.402(g)-2(a) still raises the 402(g)(1)(B) limit by the catch-up amount; and + Pub. 560 adds catch-up contributions to the self-employed maximum deductible contribution. + example: In 2025 a 55-year-old with $200,000 of Schedule C profit and $100,000 of desired contributions gets a + $68,280.73 plan limit, including the $7,500 catch-up. Including that $7,500 in gross income under the amended + section 402(g)(1)(A) would leave a net deduction of $60,780.73. + pe_us: + follows: conflicting-source + code: + - policyengine_us/variables/household/expense/retirement/elective_deferral_limit.py + - policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10121 + state: unreviewed + note: elective_deferral_limit adds k401_catch_up_limit to the section 402(g)(1)(B) amount in every year, and the + self-employed plan limit adds the same catch-up room to its maximum. The regulation binds unless it flatly + contradicts the amended statute, and the notice reads section 414(v)(3)(A)(i) to keep catch-ups excludable, so + the project lead should rule before any model change. - id: us:charitable-non-itemizer-2020-separate-cap program: income-tax years: @@ -10533,6 +10575,41 @@ candidates: state: ruled note: Ruled October 2026; verify the worksheet version and whether claiming the rebate defeats the refund-only joint-return exception. +- id: us:se-plan-step-2-multiple-businesses + program: income-tax + years: + from: 2022 + type: tax + question: When a self-employed person has more than one business, does Pub. 560's step 2 subtract all of Schedule 1 + line 15 from the plan business's net profit, or only the part attributable to that business? + sources: + - kind: statute + citation: 26 U.S.C. 401(c)(2)(A)(vi); 404(a)(8)(C) + url: https://www.law.cornell.edu/uscode/text/26/401#c_2_A + - kind: worksheet + citation: Publication 560 (2025), Deduction Worksheet for Self-Employed, step 2 + url: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39 + - kind: form + citation: Form 7206 (2023), lines 5-8 + url: https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1 + readings: + statute: Earned income is figured with regard to the section 164(f) deduction and only from the business that + has the plan; the statute does not say how to split the deduction among businesses. + other: Pub. 560's step 2 enters the deduction from Schedule 1 line 15; Form 7206 lines 5 to 8 split line 15 by + each business's share of the profitable businesses' net profits for the same section 401(c) earned income. + example: In 2025, Schedule C profit of $60,000 and farm profit of $40,000 give a Schedule 1 line 15 of $7,064.78. + Splitting it gives step 3 of $55,761.14 and a $34,652.23 limit; subtracting all of it gives $52,935.23 and + $34,087.05. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10121 + state: unreviewed + note: 'The code follows Form 7206: the Schedule C business takes Schedule 1 line 15 in proportion to its share of + the positive net profits of Schedule C, Schedule F and K-1 box 14 code A, the split the self-employed health + insurance limit already uses for the same earned income. Code not yet on main: self_employed_pension_earned_income.' - id: us:separate-filer-zero-income-threshold program: income-tax years: From e0185582c82f6826d42bd74bf213f9451a051b22 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:28:01 +0000 Subject: [PATCH 25/41] Test 403(b) room and a plan beside a partnership; check line 15 from SE The property test took Schedule 1 line 15 from the model, so a Schedule SE error would have passed it. It now computes line 15 from Schedule SE with the published wage bases, as the SEHI test does, allows float32 rounding explicitly, and checks one year per grid case. New and changed YAML cases: - Deferrals to another employer's 403(b) plan use up the room (2026: $20,000 deferred leaves $4,500, so the limit is $23,087.05). - The partnership-only case asserted a $0 limit that Pub. 560 step 1 and 401(c)(4) contradict. It becomes a Schedule C plan beside a $50,000 partnership share, which counts only the Schedule C business's $929.35 of earned income (Pub. 560 page 7). - The age 55 case adds the catch-up under the dollar limit, so its name no longer claims to show the catch-up sitting outside it. Co-Authored-By: Claude Opus 5.5 --- ..._health_insurance_earned_income_limit.yaml | 2 +- ...f_employed_pension_contribution_limit.yaml | 45 ++++++-- ..._self_employed_pension_limit_properties.py | 108 +++++++++++++----- 3 files changed, 115 insertions(+), 40 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index dbf3f7562e2..b3dc39c9877 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -22,7 +22,7 @@ output: # Schedule 1 line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. self_employment_tax_ald_person: 1_412.955 - # Schedule 1 line 16 (within the $66,000 annual additions limit). + # Schedule 1 line 16: 3,000, within the Pub. 560 maximum of 18,587.045. self_employed_pension_contribution_ald_person: 3_000 # Lines 8 to 10: 20,000 - 1,412.955 - 3,000. self_employed_health_insurance_earned_income_limit: 15_587.045 diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index cf08cd24cef..c72207ecb54 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -1,12 +1,14 @@ # Expected values follow the Pub. 560 Deduction Worksheet for Self-Employed -# (2025: https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39; 2022: +# (2025: https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=39; 2022: # https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34), with the 25% plan # rate (step 4 rate 0.2) and, unless the case says otherwise, elective # deferrals (step 9) and catch-up contributions (step 17) equal to the # person's remaining section 402(g) room. Step 3 is Schedule C net profit less # its share of the deductible part of self-employment tax: half of 15.3% (12.4% # only up to the Social Security wage base less wages) of 92.35% of all net -# profit, split by each business's share of the positive net profits. +# profit, split by each business's share of the positive net profits. With more +# than one business, step 2 is that share, as on Form 7206 lines 5 to 8; the +# worksheet as printed enters all of Schedule 1 line 15. - name: Self-employed pension contributions are limited by the 2026 annual additions dollar limit. period: 2026 @@ -128,7 +130,7 @@ # Steps 9 to 20 do not apply: steps 7 and 8 give 20% x 187,860.65. self_employed_pension_contribution_limit: 37_572.13 -- name: Catch-up contributions at age 55 sit outside the dollar limit. +- name: Catch-up contributions at age 55 add to the limit (steps 16 to 18). period: 2025 absolute_error_margin: 0.01 input: @@ -234,6 +236,25 @@ self_employed_pension_earned_income: 92_935.23 self_employed_pension_contribution_limit: 31_087.05 +- name: Deferrals to another employer's 403(b) plan use up the deferral room too. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 60_000 + traditional_403b_contributions_desired: 20_000 + self_employment_income: 100_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Section 402(g) aggregates 401(k) and 403(b) deferrals: 24,500 - 20,000 + # leaves 4,500 of room. Wages of 60,000 leave 124,500 of the wage base, so + # step 3 is 100,000 - 92,350 x 15.3% / 2 = 92,935.23. Steps 13 + 15: + # 18,587.05 + 4,500 = 23,087.05. + self_employed_pension_earned_income: 92_935.23 + self_employed_pension_contribution_limit: 23_087.05 + - name: Farm earnings take part of the self-employment tax deduction, which is split by net profit. period: 2025 absolute_error_margin: 0.01 @@ -252,19 +273,25 @@ # Steps 13 + 15: 20% x 55,761.14 + 23,500 = 34,652.23. self_employed_pension_contribution_limit: 34_652.23 -- name: Partnership self-employment earnings alone do not support a contribution, since the model bases the plan on the Schedule C business. +- name: A plan under the Schedule C business counts only that business's earned income, not a partnership's. period: 2025 absolute_error_margin: 0.01 input: people: person1: age: 40 - partnership_self_employment_net_earnings: 100_000 - self_employed_pension_contributions_desired: 10_000 + self_employment_income: 1_000 + partnership_self_employment_net_earnings: 50_000 + self_employed_pension_contributions_desired: 5_000 output: - self_employed_pension_earned_income: 0 - self_employed_pension_contribution_limit: 0 - self_employed_pension_contributions: 0 + # Schedule 1 line 15: 51,000 x 0.9235 x 0.153 / 2 = 3,603.04, of which + # 1 / 51 (70.65) is the Schedule C business's. Step 3: 1,000 - 70.65 = + # 929.35, which elective deferrals can cover in full. Pub. 560 (2025), + # page 7: with one plan, only that business's earned income counts. + self_employment_tax_ald_person: 3_603.04 + self_employed_pension_earned_income: 929.35 + self_employed_pension_contribution_limit: 929.35 + self_employed_pension_contributions: 929.35 - name: Net profit under the self-employment tax threshold is all earned income. period: 2025 diff --git a/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py index 11d31098632..670d4b06637 100644 --- a/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py @@ -9,24 +9,31 @@ the worksheet's largest total over the elective deferrals and catch-up contributions the person could still make (`self_employed_pension_contribution_limit`). -The reference below runs the worksheet's steps literally, with the IRS's +The reference below runs worksheet steps 4 to 19 literally, with the IRS's published dollar amounts rather than model parameters, and takes the largest step 19 over a grid of elective deferrals (step 9) and catch-up contributions -(step 17). Only step 2, the deductible part of self-employment tax, comes from -the model. Hypothesis draws batches of single filers across three years, and -each batch runs as one vectorized simulation. For every person and year: - -1. The limit equals the worksheet maximum, and step 3 equals Schedule C net - profit less the part of the self-employment tax deduction attributable to - it. +(step 17). Schedule 1 line 15 is computed here from Schedule SE with the +published wage bases, and step 2 is Schedule C's share of it, split by net +profit as on Form 7206 lines 5 to 8. Hypothesis draws batches of single filers +across three years, and each batch runs as one vectorized simulation. For +every person and year: + +1. Line 15 equals half of the Schedule SE tax, the limit equals the worksheet + maximum, and step 3 equals Schedule C net profit less step 2. 2. The deduction is min(desired contributions, limit), is at most earned income (404(a)(8)(C)), which is at most Schedule C net profit, and is at - most the dollar limit plus the person's catch-up amount. + most the dollar limit plus the person's catch-up amount, which is all + 414(v)(3)(A) puts outside 415(c). 3. For a filer whose only income is Schedule C profit, AGI is never negative - when the profit is not. + when the profit is not, because the deduction is at most profit less line + 15. + +A deterministic grid checks, at its points, that the limit rises with Schedule +C profit (it dips just past $433 of profit, where self-employment tax starts) +and falls as deferrals to an employer's plan use up the room. -A deterministic grid checks that the limit rises with Schedule C profit and -falls with deferrals to an employer's plan. +The model computes in float32, so comparisons allow a relative error of 1e-6 +on top of the $0.01 tolerance. """ import itertools @@ -39,6 +46,7 @@ from policyengine_us import Simulation TOLERANCE = 0.01 +RTOL = 1e-6 # Plan contribution rate: 26 U.S.C. 404(a)(3)(A)(i)(I) and 404(h)(1)(C). PLAN_RATE = 0.25 # Published amounts: IRS Notices 2021-61 (2022), 2024-80 (2025) and 2025-67 @@ -68,6 +76,38 @@ } YEARS = list(PUBLISHED) +# Schedule SE, as test data rather than model parameters. +# 26 U.S.C. 1402(a)(12): net earnings are 92.35% of net profit. +NET_EARNINGS_SHARE = 0.9235 +# 26 U.S.C. 1402(b)(2): no net earnings below $400. +NET_EARNINGS_EXEMPTION = 400 +# 26 U.S.C. 1401(a) and (b). +SOCIAL_SECURITY_RATE = 0.124 +MEDICARE_RATE = 0.029 +# Contribution and benefit bases, https://www.ssa.gov/oact/cola/cbb.html. +WAGE_BASE = {2022: 147_000, 2025: 176_100, 2026: 184_500} + + +def schedule_se_deduction(year, net_profit, wages): + """Schedule 1 line 15: half of Schedule SE self-employment tax.""" + net_earnings = net_profit * NET_EARNINGS_SHARE + net_earnings = np.where(net_earnings < NET_EARNINGS_EXEMPTION, 0, net_earnings) + social_security_room = np.maximum(WAGE_BASE[year] - wages, 0) + tax = SOCIAL_SECURITY_RATE * np.minimum( + net_earnings, social_security_room + ) + MEDICARE_RATE * np.maximum(net_earnings, 0) + return tax / 2 + + +def close(actual, expected, message): + np.testing.assert_allclose( + actual, expected, rtol=RTOL, atol=TOLERANCE, err_msg=message + ) + + +def at_most(actual, bound): + return np.all(actual <= bound + TOLERANCE + RTOL * np.abs(bound)) + def catch_up_amount(year, age): amounts = PUBLISHED[year] @@ -146,8 +186,17 @@ def reference(sim, people_inputs, year): out=np.zeros_like(schedule_c), where=schedule_c + others > 0, ) - # Step 2 is the one model output the reference takes as given. - step_2 = sim.calculate("self_employment_tax_ald_person", year) * share + # Schedule 1 line 15 from Schedule SE; step 2 is Schedule C's share. + net_profit = ( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income") + + column(people_inputs, "farm_operations_income") + + column(people_inputs, "partnership_self_employment_net_earnings") + ) + line_15 = schedule_se_deduction( + year, net_profit, column(people_inputs, "employment_income") + ) + step_2 = line_15 * share step_3 = np.maximum(schedule_c - step_2, 0) # Section 402(g) room left by deferrals to an employer's plan, which use # the regular limit first. @@ -160,32 +209,31 @@ def reference(sim, people_inputs, year): catch_up_room = np.minimum(room, catch_up) regular_room = room - catch_up_room limit = worksheet_maximum(step_3, regular_room, catch_up_room, amounts) - return schedule_c, step_3, limit, catch_up + return schedule_c, line_15, step_3, limit, catch_up -def check(sim, people_inputs): +def check(sim, people_inputs, years=YEARS): desired = column(people_inputs, "self_employed_pension_contributions_desired") schedule_c_only = np.array([p["schedule_c_only"] for p in people_inputs]) - for year in YEARS: - schedule_c, step_3, expected, catch_up = reference(sim, people_inputs, year) + for year in years: + schedule_c, line_15, step_3, expected, catch_up = reference( + sim, people_inputs, year + ) earned_income = sim.calculate("self_employed_pension_earned_income", year) limit = sim.calculate("self_employed_pension_contribution_limit", year) deduction = sim.calculate("self_employed_pension_contribution_ald_person", year) message = f"year {year}" - # 1. The worksheet. - np.testing.assert_allclose( - earned_income, step_3, atol=TOLERANCE, err_msg=message - ) - np.testing.assert_allclose(limit, expected, atol=TOLERANCE, err_msg=message) + # 1. Schedule SE and the worksheet. + close(sim.calculate("self_employment_tax_ald_person", year), line_15, message) + close(earned_income, step_3, message) + close(limit, expected, message) # 2. Bounds. - np.testing.assert_allclose( - deduction, np.minimum(desired, limit), atol=TOLERANCE, err_msg=message - ) + close(deduction, np.minimum(desired, limit), message) assert np.all(deduction >= 0), message - assert np.all(deduction <= earned_income + TOLERANCE), message - assert np.all(earned_income <= schedule_c + TOLERANCE), message + assert at_most(deduction, earned_income), message + assert at_most(earned_income, schedule_c), message cap = PUBLISHED[year]["annual_additions"] + catch_up - assert np.all(limit <= cap + TOLERANCE), message + assert at_most(limit, cap), message # 3. AGI from Schedule C alone. agi = sim.calculate("adjusted_gross_income", year) net_profit = column(people_inputs, "self_employment_income") + column( @@ -294,7 +342,7 @@ def grid(): @pytest.mark.parametrize("year", YEARS) def test_grid_matches_the_worksheet_and_is_monotone(grid, year): sim, people_inputs = grid - check(sim, people_inputs) + check(sim, people_inputs, [year]) limit = sim.calculate("self_employed_pension_contribution_limit", year).reshape( len(GRID_DEFERRALS), len(GRID_PROFITS) ) From 1c8823f230e6ba54ca8c0e0d36e1d5b877aead0c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:47:50 +0000 Subject: [PATCH 26/41] Scope the changelog to a defined contribution plan The limit is the most a defined contribution plan allows, so defined benefit contributions above it are cut, as the variable's documentation says. Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-se-pension-earned-income-limit.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-se-pension-earned-income-limit.fixed.md b/changelog.d/fix-se-pension-earned-income-limit.fixed.md index a7b63817ad0..c59f67618a5 100644 --- a/changelog.d/fix-se-pension-earned-income-limit.fixed.md +++ b/changelog.d/fix-se-pension-earned-income-limit.fixed.md @@ -1 +1 @@ -Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum, based on Schedule C net profit less its share of the deductible part of self-employment tax, so the deduction can no longer exceed that earned income or make AGI negative. +Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum for a defined contribution plan, based on Schedule C net profit less its share of the deductible part of self-employment tax, so the deduction can no longer exceed that earned income or make AGI negative. From da6240b48953b02d28713610dcbfb55b079a7258 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:29:24 +0000 Subject: [PATCH 27/41] Carry the plan business choice into the owners' draw reform's SEHI limit #10571 overrode self_employed_health_insurance_earned_income_limit with a copy of main's Schedule C-only formula plus the draw profits in line 5, so with the reform on, the farm, partnership and S corporation plan businesses this branch adds were ignored. The baseline formula is now a module-level Form 7206 line 13 helper that takes extra line 5 profits, and the reform calls it with the SECA-taxed draw profits. Three new reform cases cover a named farm, the unnamed default, and a named Schedule C beside a larger farm. The pure harness names the Schedule C business, which its oracle assumes, and supplies S corporation wages of zero. Co-Authored-By: Claude Opus 5.5 --- docs/contributed_reforms/owners_draw.md | 6 +- policyengine_us/reforms/ssa/owners_draw.py | 32 +---- ..._health_insurance_earned_income_limit.yaml | 62 +++++++++ .../tests/reforms/test_owners_draw.py | 14 ++ ...ed_health_insurance_earned_income_limit.py | 124 ++++++++++-------- 5 files changed, 155 insertions(+), 83 deletions(-) diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 34dc304c919..381054a0cc5 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -117,7 +117,11 @@ on. These are the draw profits whose SECA the deduction includes. Draw profits beyond the OASDI room with HI off stay out of line 5. With `half_seca_deduction: false` the draw's SECA is not deductible, so line 5 omits the draw and the limit follows the baseline formula. Disabled years -use the baseline formula. +use the baseline formula. The draw enters line 5 only: the plan business is +chosen as in the baseline, from the Schedule C business, the farm, the +partnership or the S corporation named by +`self_employed_health_insurance_plan_business`, or the one with the largest +line 13 when none is named. ## Benefit credit and active NIIT interaction diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 8d676167dda..e341114e371 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -17,6 +17,7 @@ ) from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employed_health_insurance_earned_income_limit import ( self_employed_health_insurance_earned_income_limit as baseline_sehi_limit, + form_7206_line_13, ) from policyengine_us.variables.gov.ssa.social_security.ss_aime import ( ss_aime as baseline_aime, @@ -305,32 +306,11 @@ def formula(person, period, parameters): person("owners_draw_niit_exclusion", period), 0, ) - plan_business_profit = max_( - 0, person("total_self_employment_income", period) - ) - all_business_profits = ( - plan_business_profit - + max_(0, person("farm_operations_income", period)) - + max_(0, person("partnership_self_employment_net_earnings", period)) - + draw_profits_in_line_7 - ) - plan_business_share = np.divide( - plan_business_profit, - all_business_profits, - out=np.zeros_like(all_business_profits), - where=all_business_profits > 0, - ) - self_employment_tax_deduction = ( - person("self_employment_tax_ald_person", period) * plan_business_share - ) - retirement_plan_deduction = person( - "self_employed_pension_contribution_ald_person", period - ) - return max_( - 0, - plan_business_profit - - self_employment_tax_deduction - - retirement_plan_deduction, + # The draw profits enter line 5 only: the plan business is still + # chosen among the baseline's Schedule C, farm, partnership and + # S corporation businesses. + return form_7206_line_13( + person, period, other_line_5_profits=draw_profits_in_line_7 ) class ss_aime(baseline_aime): diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml index 8a53763100e..5f37c8f4582 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml @@ -253,3 +253,65 @@ # Line 5: 72,485.11; line 6: 0.5518375; 40,000 - 2,585.86 = 37,414.14. # Main gives 35,314.09; the whole S-corp profit in line 5 would give 38,214.38. self_employed_health_insurance_earned_income_limit: 37_414.14 + +- name: A farm plan business with an owners' draw takes its share of line 15 + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 10_000 + farm_operations_income: 60_000 + s_corp_income: 64_970.22 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # SE tax on Schedule C and the farm: 70,000 x 0.9235 x 15.3% = + # 9,890.685. Draw OASDI: 59,999.998 x 12.4% = 7,440.00. Half of + # 17,330.685 = 8,665.34. + self_employment_tax_ald_person: 8_665.34 + # Line 5: 10,000 + 60,000 + 64,970.22 = 134,970.22. The farm's line 6: + # 60,000 / 134,970.22 = 0.4445425. Line 13: 60,000 - 8,665.34 x + # 0.4445425 = 56,147.89. + self_employed_health_insurance_earned_income_limit: 56_147.89 + +- name: With an owners' draw and no named plan business the largest business sets the limit + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 10_000 + farm_operations_income: 60_000 + s_corp_income: 64_970.22 + output: + # As above, the farm's line 13 is 56,147.89. The Schedule C business's + # is 10,000 - 8,665.34 x 10,000 / 134,970.22 = 9,357.98, so the farm + # sets the limit. + self_employed_health_insurance_earned_income_limit: 56_147.89 + +- name: A named Schedule C plan business with an owners' draw beside a larger farm + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 10_000 + farm_operations_income: 60_000 + s_corp_income: 64_970.22 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + # 10,000 - 8,665.34 x 10,000 / 134,970.22 = 9,357.98. Leaving the + # draw out of line 5 would give 10,000 - 8,665.34 / 7 = 8,762.09. + self_employed_health_insurance_earned_income_limit: 9_357.98 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index f8e483ede75..eb94cd18480 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -36,6 +36,9 @@ from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employed_health_insurance_earned_income_limit import ( self_employed_health_insurance_earned_income_limit, ) +from policyengine_us.variables.household.expense.health.self_employed_health_insurance_plan_business import ( + SelfEmployedHealthInsurancePlanBusiness, +) from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( self_employment_tax_ald_person, ) @@ -150,6 +153,7 @@ def _calculate(variables, parameters, inputs, outputs=None): "farm_operations_income": 0, "payroll_tax_gross_wages": 0, "self_employed_pension_contribution_ald_person": 0, + "s_corp_shareholder_medicare_wages": 0, **inputs, } @@ -166,6 +170,16 @@ def _calculate(variables, parameters, inputs, outputs=None): "total_self_employment_income", values["self_employment_income"] + values["sstb_self_employment_income"], ) + # The oracles below take the Schedule C business as the plan business. + values.setdefault( + "self_employed_health_insurance_plan_business", + SelfEmployedHealthInsurancePlanBusiness.encode( + np.full( + np.atleast_1d(values["total_self_employment_income"]).shape, + SelfEmployedHealthInsurancePlanBusiness.SCHEDULE_C, + ) + ), + ) def person(name, instant, options=None): if name not in values: diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index 5f1c19768e3..4fc27185a47 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -51,62 +51,74 @@ class self_employed_health_insurance_earned_income_limit(Variable): ) def formula(person, period, parameters): - # Line 4 for each business that can establish a plan: its net profit, - # or a partner's net earnings from self-employment. - schedule_c_profit = max_(0, person("total_self_employment_income", period)) - farm_profit = max_(0, person("farm_operations_income", period)) - partnership_profit = max_( - 0, person("partnership_self_employment_net_earnings", period) - ) - # Line 5: net profits of every business whose earnings are subject to - # self-employment tax, leaving out net losses. - all_business_profits = schedule_c_profit + farm_profit + partnership_profit - # Lines 6 and 7: each business takes the deductible part of - # self-employment tax (Schedule 1 line 15) in proportion to its share - # of line 5. - self_employment_tax_deduction = person("self_employment_tax_ald_person", period) + return form_7206_line_13(person, period) - def attributable_self_employment_tax_deduction(profit): - share = np.divide( - profit, - all_business_profits, - out=np.zeros_like(all_business_profits), - where=all_business_profits > 0, - ) - return self_employment_tax_deduction * share - # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction - # (Schedule 1 line 16) is attributable to the Schedule C business. - retirement_plan_deduction = person( - "self_employed_pension_contribution_ald_person", period - ) - # Line 10 for each self-employment business. - schedule_c = max_( - 0, - schedule_c_profit - - attributable_self_employment_tax_deduction(schedule_c_profit) - - retirement_plan_deduction, - ) - farm = max_( - 0, farm_profit - attributable_self_employment_tax_deduction(farm_profit) - ) - partnership = max_( - 0, - partnership_profit - - attributable_self_employment_tax_deduction(partnership_profit), - ) - # Line 11: an S corporation shareholder's wages from the corporation. - s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) - plan_business = person("self_employed_health_insurance_plan_business", period) - businesses = plan_business.possible_values - return select( - [ - plan_business == businesses.SCHEDULE_C, - plan_business == businesses.SCHEDULE_F, - plan_business == businesses.PARTNERSHIP, - plan_business == businesses.S_CORPORATION, - ], - [schedule_c, farm, partnership, s_corporation], - # UNSPECIFIED: the business with the largest earned income. - default=max_(max_(schedule_c, farm), max_(partnership, s_corporation)), +def form_7206_line_13(person, period, other_line_5_profits=0): + """Form 7206 line 13 for the plan business. + + other_line_5_profits adds the profits of businesses that cannot + establish the plan here but whose self-employment tax is in Schedule 1 + line 15, such as the owners' draw reform's draw profits. + """ + # Line 4 for each business that can establish a plan: its net profit, + # or a partner's net earnings from self-employment. + schedule_c_profit = max_(0, person("total_self_employment_income", period)) + farm_profit = max_(0, person("farm_operations_income", period)) + partnership_profit = max_( + 0, person("partnership_self_employment_net_earnings", period) + ) + # Line 5: net profits of every business whose earnings are subject to + # self-employment tax, leaving out net losses. + all_business_profits = ( + schedule_c_profit + farm_profit + partnership_profit + other_line_5_profits + ) + # Lines 6 and 7: each business takes the deductible part of + # self-employment tax (Schedule 1 line 15) in proportion to its share + # of line 5. + self_employment_tax_deduction = person("self_employment_tax_ald_person", period) + + def attributable_self_employment_tax_deduction(profit): + share = np.divide( + profit, + all_business_profits, + out=np.zeros_like(all_business_profits), + where=all_business_profits > 0, ) + return self_employment_tax_deduction * share + + # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction + # (Schedule 1 line 16) is attributable to the Schedule C business. + retirement_plan_deduction = person( + "self_employed_pension_contribution_ald_person", period + ) + # Line 10 for each self-employment business. + schedule_c = max_( + 0, + schedule_c_profit + - attributable_self_employment_tax_deduction(schedule_c_profit) + - retirement_plan_deduction, + ) + farm = max_( + 0, farm_profit - attributable_self_employment_tax_deduction(farm_profit) + ) + partnership = max_( + 0, + partnership_profit + - attributable_self_employment_tax_deduction(partnership_profit), + ) + # Line 11: an S corporation shareholder's wages from the corporation. + s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) + plan_business = person("self_employed_health_insurance_plan_business", period) + businesses = plan_business.possible_values + return select( + [ + plan_business == businesses.SCHEDULE_C, + plan_business == businesses.SCHEDULE_F, + plan_business == businesses.PARTNERSHIP, + plan_business == businesses.S_CORPORATION, + ], + [schedule_c, farm, partnership, s_corporation], + # UNSPECIFIED: the business with the largest earned income. + default=max_(max_(schedule_c, farm), max_(partnership, s_corporation)), + ) From 0b8a50d42d94645dac810ae31d9c65b61b728aba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:38:20 +0000 Subject: [PATCH 28/41] Split line 15 over the owners' draw profits in the plan's earned income #10571 added the SECA-taxed draw profits to the profits that the SEHI limit splits Schedule 1 line 15 over, but the plan's earned income still split line 15 over Schedule C, farm and partnership profits only, so under the reform the Schedule C plan business took the draw's deductible SECA as well (40,000 Schedule C with a 60,000 net-earnings draw: 33,454.09 instead of 37,505.61). Step 3 is now a module-level helper that takes extra line 5 profits, and the reform overrides self_employed_pension_earned_income to pass the same draw term the SEHI override uses. Co-Authored-By: Claude Opus 5.5 --- docs/contributed_reforms/owners_draw.md | 5 ++ policyengine_us/reforms/ssa/owners_draw.py | 24 +++++++++ ...w_self_employed_pension_earned_income.yaml | 47 ++++++++++++++++ .../self_employed_pension_earned_income.py | 54 +++++++++++-------- 4 files changed, 109 insertions(+), 21 deletions(-) create mode 100644 policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_pension_earned_income.yaml diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 34dc304c919..34d5019be6d 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -119,6 +119,11 @@ profits beyond the OASDI room with HI off stay out of line 5. With omits the draw and the limit follows the baseline formula. Disabled years use the baseline formula. +The reform also replaces `self_employed_pension_earned_income` (Pub. 560 +Deduction Worksheet for Self-Employed, step 3). Step 2 takes the Schedule C +business's share of the deductible SE tax, split by net profit as above, and +the same draw profits join the split. + ## Benefit credit and active NIIT interaction `owners_draw_credited_earnings` exposes N_D when benefit credit is enabled, diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 8d676167dda..e41ba70bd03 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -18,6 +18,10 @@ from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employed_health_insurance_earned_income_limit import ( self_employed_health_insurance_earned_income_limit as baseline_sehi_limit, ) +from policyengine_us.variables.household.expense.retirement.self_employed_pension_earned_income import ( + self_employed_pension_earned_income as baseline_pension_earned_income, + pension_plan_earned_income, +) from policyengine_us.variables.gov.ssa.social_security.ss_aime import ( ss_aime as baseline_aime, _compute_aime, @@ -333,6 +337,25 @@ def formula(person, period, parameters): - retirement_plan_deduction, ) + class self_employed_pension_earned_income(baseline_pension_earned_income): + def formula(person, period, parameters): + if not _enabled(period, parameters): + return baseline_pension_earned_income.formula( + person, period, parameters + ) + # Pub. 560 step 2 takes the Schedule C business's share of + # Schedule 1 line 15, split by net profit as on Form 7206 lines 5 + # to 8. As in the health insurance limit above, the draw profits + # whose SECA is in line 15 join the profits it is split over. + draw_profits_in_line_15 = where( + parameters(period).gov.contrib.ssa.owners_draw.half_seca_deduction, + person("owners_draw_niit_exclusion", period), + 0, + ) + return pension_plan_earned_income( + person, period, other_line_5_profits=draw_profits_in_line_15 + ) + class ss_aime(baseline_aime): def formula(person, period, parameters): supplied = person("ss_aime_input", period) @@ -366,6 +389,7 @@ def apply(self): self.update_variable(self_employment_medicare_tax) self.update_variable(self_employment_tax_ald_person) self.update_variable(self_employed_health_insurance_earned_income_limit) + self.update_variable(self_employed_pension_earned_income) self.update_variable(ss_aime) return reform diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_pension_earned_income.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_pension_earned_income.yaml new file mode 100644 index 00000000000..174577d748c --- /dev/null +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_pension_earned_income.yaml @@ -0,0 +1,47 @@ +# Pub. 560 Deduction Worksheet for Self-Employed, step 3, with the owners' +# draw reform on. Step 2 takes the Schedule C business's share of Schedule 1 +# line 15, split by net profit as on Form 7206 lines 5 to 8. The reform adds +# the gross draw profits that bear SECA when half_seca_deduction puts that +# SECA into line 15, as it does for the self-employed health insurance limit. +# Synthetic caps follow owners_draw.yaml; q = 0.9235. Hand-computed. + +- name: The draw's profits join the profits line 15 is split over + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 40_000 + # 64,970.22 x 0.9235 = 59,999.998 of draw net earnings. + s_corp_income: 64_970.22 + output: + # Schedule C SE tax 40,000 x 0.9235 x 15.3% = 5,651.82; draw OASDI + # 59,999.998 x 12.4% = 7,440.00; half of 13,091.82 = 6,545.91. + self_employment_tax_ald_person: 6_545.91 + # Share 40,000 / 104,970.22 = 0.3810605; step 2 = 2,494.39; step 3 = + # 40,000 - 2,494.39. The whole line 15 would give 33,454.09. + self_employed_pension_earned_income: 37_505.61 + +- name: Nondeductible draw SECA keeps the draw out of the split + period: 2027 + absolute_error_margin: 0.02 + reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw + input: + gov.contrib.ssa.owners_draw.current_law_cap: 200_000 + gov.contrib.ssa.owners_draw.in_effect: true + gov.contrib.ssa.owners_draw.phase_in_share: 1 + gov.contrib.ssa.owners_draw.half_seca_deduction: false + gov.irs.payroll.social_security.cap: 200_000 + gov.simulation.aime_lookback_years: 0 + self_employment_income: 40_000 + s_corp_income: 64_970.22 + output: + # Only the Schedule C SE tax is deductible: 5,651.82 / 2 = 2,825.91. + self_employment_tax_ald_person: 2_825.91 + # The Schedule C business is the only profit in the split: 40,000 - + # 2,825.91. + self_employed_pension_earned_income: 37_174.09 diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py index 6fd348a6ba2..ea530c07aa5 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -31,24 +31,36 @@ class self_employed_pension_earned_income(Variable): ) def formula(person, period, parameters): - # Step 1: net profit from the business with the plan. - plan_business_profit = max_(0, person("total_self_employment_income", period)) - # Net profits of every business whose earnings are subject to - # self-employment tax, leaving out net losses. - other_business_profits = max_( - 0, person("farm_operations_income", period) - ) + max_(0, person("partnership_self_employment_net_earnings", period)) - all_business_profits = plan_business_profit + other_business_profits - plan_business_share = np.divide( - plan_business_profit, - all_business_profits, - out=np.zeros_like(all_business_profits), - where=all_business_profits > 0, - ) - # Step 2: the deductible part of self-employment tax (Schedule 1 - # line 15) attributable to the plan's business. - self_employment_tax_deduction = ( - person("self_employment_tax_ald_person", period) * plan_business_share - ) - # Step 3. - return max_(0, plan_business_profit - self_employment_tax_deduction) + return pension_plan_earned_income(person, period) + + +def pension_plan_earned_income(person, period, other_line_5_profits=0): + """Pub. 560 step 3 for the Schedule C plan business. + + other_line_5_profits adds the profits of other businesses whose + self-employment tax is in Schedule 1 line 15, such as the owners' draw + reform's draw profits, so the plan business takes only its share. + """ + # Step 1: net profit from the business with the plan. + plan_business_profit = max_(0, person("total_self_employment_income", period)) + # Net profits of every business whose earnings are subject to + # self-employment tax, leaving out net losses. + other_business_profits = max_(0, person("farm_operations_income", period)) + max_( + 0, person("partnership_self_employment_net_earnings", period) + ) + all_business_profits = ( + plan_business_profit + other_business_profits + other_line_5_profits + ) + plan_business_share = np.divide( + plan_business_profit, + all_business_profits, + out=np.zeros_like(all_business_profits), + where=all_business_profits > 0, + ) + # Step 2: the deductible part of self-employment tax (Schedule 1 + # line 15) attributable to the plan's business. + self_employment_tax_deduction = ( + person("self_employment_tax_ald_person", period) * plan_business_share + ) + # Step 3. + return max_(0, plan_business_profit - self_employment_tax_deduction) From 5f05f3a3ece995bc8cb7c8937567f909068304f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 22:08:07 +0000 Subject: [PATCH 29/41] Check the reform's plan earned income against the baseline when disabled The disabled-reform test treats every overridden variable outside BASELINE_VARIABLES as reform-only and expects it to be zero. The plan's earned income is a baseline variable the reform now overrides, so it joins BASELINE_VARIABLES and must equal the baseline formula when the reform is off. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/tests/reforms/test_owners_draw.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index f8e483ede75..2413846fedc 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -39,6 +39,9 @@ from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employment_tax_ald_person import ( self_employment_tax_ald_person, ) +from policyengine_us.variables.household.expense.retirement.self_employed_pension_earned_income import ( + self_employed_pension_earned_income, +) from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( taxable_earnings_for_social_security, ) @@ -70,6 +73,7 @@ self_employment_tax, self_employment_tax_ald_person, self_employed_health_insurance_earned_income_limit, + self_employed_pension_earned_income, ) } PERIOD = period("2027") From ffcd227b72754fb09c5f76c793507156977bca34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 00:47:49 +0000 Subject: [PATCH 30/41] Say where a SIMPLE IRA or minister's account can exceed the plan limit The limit is the most a SEP or a qualified defined contribution plan allows. A SIMPLE IRA measures compensation before its own contributions and is outside section 415, so with deferral room near earned income it can allow slightly more; a minister's 403(b)(9) account is limited only by 402(g) or 415. Reword step 12, cite 415(a)(2), 404(a)(10) and Pub. 560 page 15, add a 2023 case that pins the step 12 band, and drop the changelog's AGI claim, which a loss outside the plan's business breaks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HnENQuQRTYAsGyTgG --- ...ix-se-pension-earned-income-limit.fixed.md | 2 +- ...f_employed_pension_contribution_limit.yaml | 25 +++++++++++ ...elf_employed_pension_contribution_limit.py | 44 ++++++++++++------- 3 files changed, 53 insertions(+), 18 deletions(-) diff --git a/changelog.d/fix-se-pension-earned-income-limit.fixed.md b/changelog.d/fix-se-pension-earned-income-limit.fixed.md index c59f67618a5..a6a53eca924 100644 --- a/changelog.d/fix-se-pension-earned-income-limit.fixed.md +++ b/changelog.d/fix-se-pension-earned-income-limit.fixed.md @@ -1 +1 @@ -Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum for a defined contribution plan, based on Schedule C net profit less its share of the deductible part of self-employment tax, so the deduction can no longer exceed that earned income or make AGI negative. +Limit the self-employed SEP, SIMPLE and qualified plan deduction to the Pub. 560 maximum for a SEP or a qualified defined contribution plan, based on Schedule C net profit less its share of the deductible part of self-employment tax, so the deduction can no longer exceed that earned income. A SIMPLE IRA can allow slightly more than this limit in a narrow band, which the model doesn't yet capture. diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index c72207ecb54..5428b074081 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -255,6 +255,31 @@ self_employed_pension_earned_income: 92_935.23 self_employed_pension_contribution_limit: 23_087.05 +- name: With deferral room close to earned income, the limit is step 12, which a SIMPLE IRA can exceed. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 60_000 + traditional_401k_contributions_desired: 17_500 + self_employment_income: 5_600 + self_employed_pension_contributions_desired: 10_000 + output: + # 402(g) limit 22,500 less 17,500 deferred leaves 5,000 of room. Schedule + # SE line 4: 5,600 x 0.9235 = 5,171.60. Line 15: 5,171.60 x 15.3% / 2 = + # 395.63. Step 3: 5,600 - 395.63 = 5,204.37. Step 7: 867.40. Step 12: + # (5,204.37 - 5,000) / 2 = 102.19, the smallest, so steps 13 + 15 are + # (5,204.37 + 5,000) / 2 = 5,102.19. + # A SIMPLE IRA would allow more (Pub. 560 (2025), page 15; section + # 415(a)(2)): the 5,000 deferral, under its 15,500 limit for 2023, plus a + # 3% match of Schedule SE line 4 before the contributions (0.03 x + # 5,171.60 = 155.15), 5,155.15 in all. This limit cuts that by 52.96. + self_employed_pension_earned_income: 5_204.37 + self_employed_pension_contribution_limit: 5_102.19 + self_employed_pension_contributions: 5_102.19 + - name: Farm earnings take part of the self-employment tax deduction, which is split by net profit. period: 2025 absolute_error_margin: 0.01 diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index 8a848835e0c..ccaf664a5d7 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -13,28 +13,37 @@ class self_employed_pension_contribution_limit(Variable): "compensation for a self-employed person is earned income net of " "the plan deduction itself, so the 25% employer rate becomes 20% of " "earned income before that deduction, and the 100%-of-compensation " - "limit caps employer contributions at half of earned income less " - "elective deferrals (step 12). The modeled input " + "limit caps employer contributions at half of what is left of " + "earned income after elective deferrals (step 12). The modeled input " "does not say which kind of plan the person has, so the limit is the " - "most any defined contribution plan allows: a profit-sharing plan " - "with a 401(k) arrangement into which the person defers, before tax, " - "all of the elective deferral room that deferrals to other employers' " - "401(k) and 403(b) plans leave. With no room left, it is the SEP and " - "profit-sharing limit. Catch-up contributions sit outside the dollar " - "limit, and the total never exceeds earned income. A SEP or a SIMPLE " - "IRA allows less, so the limit overstates their deductions. Defined " + "most a SEP or a qualified defined contribution plan allows: a " + "profit-sharing plan with a 401(k) arrangement into which the person " + "defers, before tax, all of the elective deferral room that " + "deferrals to other employers' 401(k) and 403(b) plans leave. With no " + "room left, it is the SEP and profit-sharing limit. Catch-up " + "contributions sit outside the dollar limit, and the total never " + "exceeds earned income. A SEP allows less, so the limit overstates " + "its deductions. A SIMPLE IRA usually allows less too, but the " + "contributions don't reduce its compensation, and section 415 " + "doesn't apply to it. So when deferrals to other employers' plans " + "leave elective deferral room close to earned income, a SIMPLE IRA " + "can allow slightly more. A self-employed minister's section " + "403(b)(9) account can also allow more, since section 404(a)(10) " + "limits it only by the section 402(g) or 415 limit. Defined " "benefit plans are not modeled, so contributions to them above this " "limit are cut." ) definition_period = YEAR reference = ( - # Subsections (a)(3)(A)(i)(I), (a)(8)(C) and (D), (h)(1)(C) and (n). + # Subsections (a)(3)(A)(i)(I), (a)(8)(C) and (D), (a)(10), (h)(1)(C) + # and (n). "https://www.law.cornell.edu/uscode/text/26/404", - # Subsections (c)(1) and (c)(3)(B) and (D). - "https://www.law.cornell.edu/uscode/text/26/415#c", + # Subsections (a)(2), (c)(1) and (c)(3)(B) and (D). + "https://www.law.cornell.edu/uscode/text/26/415", # Subsections (v)(2)(A) and (v)(3)(A). "https://www.law.cornell.edu/uscode/text/26/414#v", - "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=39", + # PDF pages 15, 39: SIMPLE IRA compensation and the worksheet. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=15", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", ) @@ -69,10 +78,11 @@ def formula(person, period, parameters): ) catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) regular_deferral_room = deferral_room - catch_up_room - # Steps 10 to 15: employer contributions and regular deferrals - # together stay within the dollar limit (step 8) and within - # compensation, which also excludes the employer contribution - # (step 12). + # Steps 10 to 15: employer contributions stay within step 7 and + # within half of what is left of earned income after the regular + # deferrals (step 12), because compensation also excludes the + # employer contribution. With the regular deferrals, they stay + # within the dollar limit (step 8). annual_additions = min_( min_(p.annual_additions, employer_limit + regular_deferral_room), (earned_income + regular_deferral_room) / 2, From 735cb03ef8237cb00951eaad57a14116098a3cd4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 12:57:47 +0000 Subject: [PATCH 31/41] Name the smaller-plan-business case in the plan business input's docs The default takes the business with the largest earned income, so it also overstates when the plan sits under a smaller business (issue #10528's Case 3). The documentation named only the partnership or S corporation case; it now tells users to name the business in both. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6HCapAFW41PEgRBjqB --- .../health/self_employed_health_insurance_plan_business.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py index 76b435eb4c5..329892469e7 100644 --- a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -41,7 +41,8 @@ class self_employed_health_insurance_plan_business(Variable): "business was not supplied, and the limit uses the person's business " "with the largest earned income; name the business when a partnership " "or S corporation with the largest earned income does not pay or " - "reimburse the premiums." + "reimburse the premiums, or when the plan is established under a " + "business other than the one with the largest earned income." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", From 69a8b3b48b807d0525c0e78f7682a83f67069c95 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 14:27:33 -0500 Subject: [PATCH 32/41] Test long-term care premiums under every plan business Since #10265, the deduction is the smaller of Form 7206 line 3 (the medical premiums plus eligible long-term care premiums) and line 13. The property test still capped it at the medical premiums, and no test put long-term care premiums under a farm, partnership or S corporation plan. - ALD YAML Cases 13 to 16 add long-term care premiums under an S corporation, a farm, a partnership and the default business. Each checks the long-term care part and Schedule A as well as the deduction. - The property test draws long-term care premiums, checks the deduction against line 3 and adds property 6: the long-term care part and Schedule A. - The plan business, S corporation wage and long-term care premium inputs say that long-term care premiums a partnership or S corporation pays or reimburses go in long_term_health_insurance_premiums, on the insured person. - The reform case with S corporation profits but no box 5 wages now says so in its name. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_ald.yaml | 88 +++++++++++++++++++ ..._health_insurance_earned_income_limit.yaml | 2 +- ...lf_employed_health_insurance_properties.py | 60 ++++++++++--- .../long_term_health_insurance_premiums.py | 5 +- ...employed_health_insurance_plan_business.py | 5 +- .../s_corp_shareholder_medicare_wages.py | 7 +- 6 files changed, 152 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index 8cc9658a4be..d8fdab012ab 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -218,3 +218,91 @@ output: # Form 7206 line 13: 10,000 - 0.25 x 2,825.91. self_employed_health_insurance_ald_person: 9_293.5225 + +- name: Case 13, an S corporation shareholder's wages cover the medical premiums first, then eligible long-term care premiums. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 55 + is_self_employed: true + # The corporation pays the 8,500 of premiums and reports them in W-2 + # box 1 (Form 7206 instructions), so box 1 wages are 15,500. The case + # sets box 5 Medicare wages to 7,000. + employment_income: 15_500 + s_corp_shareholder_medicare_wages: 7_000 + health_insurance_premiums: 6_000 + long_term_health_insurance_premiums: 2_500 + output: + # Line 1 is 6,000; line 2 is min(2,500, 1,860) = 1,860 (ages 51 to 60); + # line 3 is 7,860; line 13 is line 11, 7,000. + self_employed_health_insurance_ald_person: 7_000 + # 7,000 - 6,000 of medical premiums. + self_employed_health_insurance_ald_long_term_care_part: 1_000 + # Schedule A keeps line 3 less the deduction: 7,860 - 7,000. + itemized_medical_expenses: 860 + # 15,500 - 7,000. + adjusted_gross_income: 8_500 + +- name: Case 14, a farmer whose plan is established under the farm deducts eligible long-term care premiums up to farm earned income. + period: 2024 + absolute_error_margin: 0.01 + input: + age: 75 + is_self_employed: true + farm_operations_income: 20_000 + health_insurance_premiums: 15_000 + long_term_health_insurance_premiums: 7_000 + output: + # Line 2 is min(7,000, 5,880) = 5,880 (over 70); line 3 is 20,880. + # Line 13: 20,000 - 20,000 x 0.9235 x 0.153 / 2 = 18,587.045. + self_employed_health_insurance_ald_person: 18_587.045 + # 18,587.045 - 15,000 of medical premiums. + self_employed_health_insurance_ald_long_term_care_part: 3_587.045 + # 20,880 - 18,587.045. + itemized_medical_expenses: 2_292.955 + # 20,000 - 1,412.955 - 18,587.045. + adjusted_gross_income: 0 + +- name: Case 15, a partner deducts eligible long-term care premiums up to their K-1 box 14 code A earnings less half of self-employment tax. + period: 2025 + absolute_error_margin: 0.01 + input: + age: 62 + is_self_employed: true + partnership_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + health_insurance_premiums: 8_000 + long_term_health_insurance_premiums: 6_000 + output: + # Line 2 is min(6,000, 4,810) = 4,810 (ages 61 to 70); line 3 is 12,810. + # Line 13: 10,000 - 10,000 x 0.9235 x 0.153 / 2 = 9,293.5225. + self_employed_health_insurance_ald_person: 9_293.5225 + # 9,293.5225 - 8,000 of medical premiums. + self_employed_health_insurance_ald_long_term_care_part: 1_293.5225 + # 12,810 - 9,293.5225. + itemized_medical_expenses: 3_516.4775 + # 10,000 - 706.4775 - 9,293.5225. + adjusted_gross_income: 0 + +- name: Case 16, with no plan business named, the larger farm's earned income covers line 3, long-term care premiums included. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 45 + is_self_employed: true + self_employment_income: 2_000 + farm_operations_income: 30_000 + health_insurance_premiums: 20_000 + long_term_health_insurance_premiums: 1_500 + output: + # Line 15: 32,000 x 0.9235 x 0.153 / 2 = 2,260.728. Line 13 for the farm + # is 30,000 - 30/32 x 2,260.728 = 27,880.5675, and for the Schedule C + # business 2,000 - 2/32 x 2,260.728 = 1,858.7045. The model takes the farm. + self_employed_health_insurance_earned_income_limit: 27_880.5675 + # Line 2 is min(1,500, 930) = 930 (ages 41 to 50); line 3 is 20,930. + self_employed_health_insurance_ald_person: 20_930 + self_employed_health_insurance_ald_long_term_care_part: 930 + # 20,930 - 20,930. + itemized_medical_expenses: 0 + # 32,000 - 2,260.728 - 20,930. + adjusted_gross_income: 8_809.272 diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml index 5f37c8f4582..9dd44a6167f 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml @@ -71,7 +71,7 @@ # baseline. self_employed_health_insurance_earned_income_limit: 37_174.09 -- name: Without a Schedule C business the limit stays zero +- name: S corporation profits without box 5 wages leave the limit at zero period: 2027 absolute_error_margin: 0.02 reforms: policyengine_us.reforms.ssa.owners_draw.owners_draw diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 9da77e8c0e8..d23dd7b7604 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -10,9 +10,10 @@ business's net profit (line 4), less the part of Schedule 1 line 15 attributable to that business by its share of all net profits subject to self-employment tax (lines 5 to 8), less the Schedule 1 line 16 plan deduction -attributable to it (lines 9 and 10), or as the S corporation wages (line 11); -the deduction is the smaller of the premiums and that amount (line 14). The -plan business may be a Schedule C business, a Schedule F farm, a partnership +attributable to it (lines 9 and 10), or as the S corporation wages (line 11). +The deduction is the smaller of line 3 and that amount (line 14), where line 3 +adds eligible long-term care premiums (line 2) to the medical premiums (line 1). +The plan business may be a Schedule C business, a Schedule F farm, a partnership (K-1 box 14 code A) or an S corporation. When self_employed_health_insurance_plan_business is not supplied, the model uses the business with the largest earned income. The model treats a person's @@ -29,15 +30,17 @@ computed here from Schedule SE, and each limit equals a closed-form Form 7206 computation for its plan business from that line 15 and the model's line 16. Without a named business, the limit is the largest of the four. - The deduction is the smaller of the premiums and that reference limit. + The deduction is the smaller of line 3 and that reference limit. Line 3 + adds the drawn long-term care premiums, up to the limit for ages 41 to 50 + (line 2), to the medical premiums (line 1). 2. Differential: the limit without a named business equals the largest of the limits the model computes when each business is named. With property 1, which ties each named limit to that business's own Form 7206, one plan never pools the earned income of two businesses. -3. Bounds: 0 <= deduction <= premiums, and the limit is at least the Schedule +3. Bounds: 0 <= deduction <= line 3, and the limit is at least the Schedule C limit (widening the base never lowers the deduction). -4. Monotone in premiums: raising premiums by d raises the deduction by at - least 0 and at most d, and leaves the limit unchanged. +4. Monotone in premiums: raising the medical premiums by d raises the + deduction by at least 0 and at most d, and leaves the limit unchanged. 5. For a filer with no business losses whose income is wages and self-employment income, the deduction never takes adjusted gross income below the Schedule C line 10 amount before its floor at zero. That amount @@ -45,6 +48,9 @@ Schedule C business's net profit less its share of line 15; otherwise adjusted gross income stays at or above zero. Intended: a plan under the farm or partnership does not subtract the Schedule C plan deduction. +6. Line 2 and Schedule A: the long-term care part is the smaller of line 2 + and what the deduction leaves after line 1, and Schedule A keeps line 3 + less the deduction, so each premium counts once. The model computes in float32, whose spacing passes a cent above $131,072, so comparisons allow a relative error of 1e-6 on top of the $0.01 tolerance. @@ -72,6 +78,9 @@ MEDICARE_RATE = 0.029 # Contribution and benefit bases, https://www.ssa.gov/oact/cola/cbb.html. WAGE_BASE = {2022: 147_000, 2024: 168_600, 2026: 184_500} +# 26 U.S.C. 213(d)(10): eligible long-term care premiums at ages 41 to 50 +# (Rev. Proc. 2021-45, 2023-34 and 2025-32), as test data. +LONG_TERM_CARE_LIMIT_AGE_45 = {2022: 850, 2024: 880, 2026: 930} BUSINESSES = ["SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP", "S_CORPORATION"] # (name prefix, named plan business, premium raise) for each copy of a filer. COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ @@ -107,6 +116,9 @@ def build_situation(filers): filer["plan"] ), "health_insurance_premiums": every_year(filer["premiums"] + raise_), + "long_term_health_insurance_premiums": every_year( + filer["long_term_care"] + ), "self_employed_health_insurance_plan_business": every_year( plan_business ), @@ -175,8 +187,19 @@ def copy(array, prefix): s_corp = column(filers, "s_corp") # S corporation wages are Social Security wages too. wages = column(filers, "wages") + s_corp + long_term_care = column(filers, "long_term_care") for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) + # Line 2: the drawn premiums up to the age 41-50 limit, for every copy. + line_2 = np.tile( + np.minimum(long_term_care, LONG_TERM_CARE_LIMIT_AGE_45[year]), + len(COPIES), + ) + line_3 = premiums + line_2 + long_term_care_part = sim.calculate( + "self_employed_health_insurance_ald_long_term_care_part", year + ) + schedule_a = sim.calculate("itemized_medical_expenses", year) limit = sim.calculate( "self_employed_health_insurance_earned_income_limit", year ) @@ -212,7 +235,7 @@ def copy(array, prefix): ) # 1. Reference. - close(deduction, np.minimum(premiums, reference_limit)) + close(deduction, np.minimum(line_3, reference_limit)) close(copy(limit, "u"), largest) for business in BUSINESSES: close(copy(limit, business.lower()), line_13[business]) @@ -223,7 +246,7 @@ def copy(array, prefix): # 3. Bounds. assert np.all(deduction >= 0) - assert at_least(premiums, deduction) + assert at_least(line_3, deduction) assert at_least(copy(limit, "u"), line_13["SCHEDULE_C"]) # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. @@ -243,6 +266,14 @@ def copy(array, prefix): for prefix, _, _ in COPIES: assert at_least(copy(agi, prefix)[no_losses], floor[no_losses]) + # 6. Line 2 takes what the deduction leaves after line 1, and + # Schedule A keeps line 3 less the deduction. + close( + long_term_care_part, + np.minimum(line_2, deduction - np.minimum(premiums, deduction)), + ) + close(schedule_a, line_3 - deduction) + money = st.integers(0, 120_000) filer_strategy = st.fixed_dictionaries( @@ -258,6 +289,7 @@ def copy(array, prefix): "wages": st.one_of(st.just(0), st.integers(0, 250_000)), "plan": st.one_of(st.just(0), money), "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), + "long_term_care": st.one_of(st.just(0), st.integers(1, 2_000)), } ) SETTINGS = dict( @@ -284,6 +316,7 @@ def filer( wages=0, plan=0, premiums=0, + long_term_care=0, ): return dict( schedule_c=schedule_c, @@ -294,11 +327,13 @@ def filer( wages=wages, plan=plan, premiums=premiums, + long_term_care=long_term_care, ) def test_reviewer_example_and_boundaries(): - """Fixed filers: the $2,000 example, the $400 threshold, and the new bases.""" + """Fixed filers: the $2,000 example, the $400 threshold, the new bases and + long-term care premiums.""" filers = [ filer(schedule_c=2_000, premiums=6_000), filer(schedule_c=434, premiums=6_000), @@ -316,6 +351,9 @@ def test_reviewer_example_and_boundaries(): # A plan deduction as large as the Schedule C profit, with the farm as # the plan business (property 5). filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), + # Long-term care premiums under an S corporation plan: line 3 is + # 6,000 + min(2,000, 850) = 6,850, above line 11. + filer(s_corp=6_500, premiums=6_000, long_term_care=2_000), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) @@ -336,3 +374,5 @@ def test_reviewer_example_and_boundaries(): assert abs(deduction[9] - 5_000) < TOLERANCE # Farm line 13: 50,000 - 50,000 / 70,000 x (70,000 x 0.9235 x 0.153 / 2). assert abs(deduction[10] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE + # Line 11, 6,500, exceeds the 6,000 of medical premiums. + assert abs(deduction[11] - 6_500) < TOLERANCE diff --git a/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py b/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py index 442a9bd5ab8..8899876e842 100644 --- a/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/health/long_term_health_insurance_premiums.py @@ -10,7 +10,10 @@ class long_term_health_insurance_premiums(Variable): documentation = ( "Annual long-term care insurance premiums paid for this person's own " "contract, excluding pretax payroll deductions and employer-paid " - "premiums. Unlike the other premium inputs, which sit on the person " + "premiums. Premiums a partnership or S corporation pays or reimburses " + "for a partner or more-than-2% shareholder, and reports as guaranteed " + "payments or W-2 box 1 wages, count as the person's own and belong " + "here. Unlike the other premium inputs, which sit on the person " "who paid them, record these on the insured person, whoever in the " "tax unit paid them: 26 U.S.C. 213(d)(10) limits the premiums that " "count as medical care by the insured person's age. The model treats " diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py index 329892469e7..9a151a6057f 100644 --- a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -35,7 +35,10 @@ class self_employed_health_insurance_plan_business(Variable): "counts as established under the partnership or S corporation only " "when that business pays or reimburses the premiums and reports them " "as guaranteed payments or wages; enter those premiums in " - "health_insurance_premiums. The deduction counts premiums only when " + "health_insurance_premiums. Long-term care premiums the business pays " + "or reimburses and reports the same way go in " + "long_term_health_insurance_premiums, on the insured person. The " + "deduction counts premiums only when " "is_self_employed is true, including for a shareholder whose only " "earned income is S corporation wages. UNSPECIFIED (the default) means the " "business was not supplied, and the limit uses the person's business " diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py index e8d32d14e4e..8ac2b63c45a 100644 --- a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -20,8 +20,11 @@ class s_corp_shareholder_medicare_wages(Variable): "health_insurance_premiums, but not here, because box 5 leaves out " "premiums paid under a plan for employees generally (section " "3121(a)(2)(B)). The model has no wage input that is in box 1 but " - "not box 5, so those premiums also count as payroll tax wages. The " - "deduction counts health_insurance_premiums only when " + "not box 5, so those premiums also count as payroll tax wages. " + "Long-term care premiums the corporation pays or reimburses and " + "reports in box 1 go in employment_income and in " + "long_term_health_insurance_premiums, on the insured person. The " + "deduction counts both premium inputs only when " "is_self_employed is true, so set it for a shareholder even when " "these wages are their only earned income." ) From 031c6bcfda648c14ff5f063c7bebb72f17b455ae Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 15:23:33 -0500 Subject: [PATCH 33/41] Say the model taxes reimbursed long-term care premiums as payroll wages The S corporation wage input's documentation now says the model's payroll tax wages start from employment_income, so they include the long-term care premiums it routes there. Three test comments name the year of the $850 limit, the premiums property 6 counts, and both zero lines in the owners' draw case. Co-Authored-By: Claude Opus 5.5 --- ..._self_employed_health_insurance_earned_income_limit.yaml | 2 +- .../unit/test_self_employed_health_insurance_properties.py | 6 +++--- .../self_employment/s_corp_shareholder_medicare_wages.py | 6 ++++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml index 9dd44a6167f..13df8822512 100644 --- a/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/contrib/ssa/owners_draw_self_employed_health_insurance_earned_income_limit.yaml @@ -85,7 +85,7 @@ self_employed_health_insurance_premiums: 8_000 output: self_employment_tax_ald_person: 3_720 - # Line 4 is zero, so line 13 and the deduction are zero. + # Lines 4 and 11 are zero, so line 13 and the deduction are zero. self_employed_health_insurance_earned_income_limit: 0 self_employed_health_insurance_ald_person: 0 diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index d23dd7b7604..e63832c601a 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -50,7 +50,7 @@ farm or partnership does not subtract the Schedule C plan deduction. 6. Line 2 and Schedule A: the long-term care part is the smaller of line 2 and what the deduction leaves after line 1, and Schedule A keeps line 3 - less the deduction, so each premium counts once. + less the deduction, so each premium in line 3 counts once. The model computes in float32, whose spacing passes a cent above $131,072, so comparisons allow a relative error of 1e-6 on top of the $0.01 tolerance. @@ -351,8 +351,8 @@ def test_reviewer_example_and_boundaries(): # A plan deduction as large as the Schedule C profit, with the farm as # the plan business (property 5). filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), - # Long-term care premiums under an S corporation plan: line 3 is - # 6,000 + min(2,000, 850) = 6,850, above line 11. + # Long-term care premiums under an S corporation plan: in 2022, line 3 + # is 6,000 + min(2,000, 850) = 6,850, above line 11. filer(s_corp=6_500, premiums=6_000, long_term_care=2_000), ] situation, n = build_situation(filers) diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py index 8ac2b63c45a..e1328700ce1 100644 --- a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -23,8 +23,10 @@ class s_corp_shareholder_medicare_wages(Variable): "not box 5, so those premiums also count as payroll tax wages. " "Long-term care premiums the corporation pays or reimburses and " "reports in box 1 go in employment_income and in " - "long_term_health_insurance_premiums, on the insured person. The " - "deduction counts both premium inputs only when " + "long_term_health_insurance_premiums, on the insured person. " + "The model's payroll tax wages start from employment_income, so it " + "taxes these premiums as payroll wages too. The deduction " + "counts both premium inputs only when " "is_self_employed is true, so set it for a shareholder even when " "these wages are their only earned income." ) From eddc35a698de9650691a027011e59c62b885f0d4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 16:25:20 -0500 Subject: [PATCH 34/41] Tell partners where guaranteed payments go and cite Notice 2008-1 The plan business input now says a partner also enters premium guaranteed payments in partnership_income, and in partnership_self_employment_net_earnings when the partnership reports them in K-1 box 14 code A, matching the S corporation input's employment_income sentence. Both inputs cite Notice 2008-1 for when a plan is established by the S corporation and for the box 1 vs box 5 treatment, and the plan business input cites the Form 1065 instructions' line 4c note that guaranteed payments are net earnings from self-employment. Documentation and references only. Co-Authored-By: Claude Opus 5.5 --- .../self_employed_health_insurance_plan_business.py | 11 ++++++++++- .../s_corp_shareholder_medicare_wages.py | 3 +++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py index 9a151a6057f..ce86f4cde10 100644 --- a/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py +++ b/policyengine_us/variables/household/expense/health/self_employed_health_insurance_plan_business.py @@ -35,7 +35,12 @@ class self_employed_health_insurance_plan_business(Variable): "counts as established under the partnership or S corporation only " "when that business pays or reimburses the premiums and reports them " "as guaranteed payments or wages; enter those premiums in " - "health_insurance_premiums. Long-term care premiums the business pays " + "health_insurance_premiums. A partner also includes those guaranteed " + "payments in partnership_income, and in " + "partnership_self_employment_net_earnings when the partnership " + "reports them in K-1 box 14 code A; a shareholder includes the wages " + "in employment_income (see s_corp_shareholder_medicare_wages). " + "Long-term care premiums the business pays " "or reimburses and reports the same way go in " "long_term_health_insurance_premiums, on the insured person. The " "deduction counts premiums only when " @@ -51,4 +56,8 @@ class self_employed_health_insurance_plan_business(Variable): "https://www.law.cornell.edu/uscode/text/26/162#l_2_A", # PDF pages 1-2 "https://www.irs.gov/pub/irs-prior/i7206--2023.pdf#page=1", + # Notice 2008-1: when a plan is established by the S corporation + "https://www.irs.gov/pub/irs-drop/n-08-01.pdf#page=3", + # Guaranteed payments to partners are net earnings from self-employment + "https://www.irs.gov/pub/irs-prior/i1065--2023.pdf#page=43", ) diff --git a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py index e1328700ce1..e8ded018863 100644 --- a/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py +++ b/policyengine_us/variables/household/income/person/self_employment/s_corp_shareholder_medicare_wages.py @@ -36,5 +36,8 @@ class s_corp_shareholder_medicare_wages(Variable): "https://www.law.cornell.edu/uscode/text/26/1372", "https://www.law.cornell.edu/uscode/text/26/3121#a_2", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", + # Notice 2008-1: premiums are box 1 wages but not Social Security + # or Medicare wages when section 3121(a)(2)(B) applies + "https://www.irs.gov/pub/irs-drop/n-08-01.pdf#page=2", ) uprating = "calibration.gov.irs.soi.employment_income" From 3aae3a3c7ce15d4dfcf1ec99bb15e2a21c75949b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 20:28:19 -0500 Subject: [PATCH 35/41] Attribute the least plan deduction to the health plan's business With retirement plans under several businesses, the person sets each plan's contributions, so Form 7206 line 9 for the health plan's business now takes the least part of the plan deduction that its plan must carry while the other plans carry as much as their own Pub. 560 limits and the shared dollar and elective deferral limits allow. A closed form replaces the proportional split, and the property tests check it against an exact linear program. The owners' draw test stub names the Schedule C business as the retirement plan business, as its oracles assume. Co-Authored-By: Claude Opus 5.5 --- ...se-plan-business-farm-partnership.fixed.md | 2 +- policyengine_us/reforms/ssa/owners_draw.py | 7 +- .../self_employed_health_insurance_ald.yaml | 22 + ..._health_insurance_earned_income_limit.yaml | 73 ++- .../tests/reforms/test_owners_draw.py | 35 +- ...lf_employed_health_insurance_properties.py | 459 +++++++++++++++--- ...ed_health_insurance_earned_income_limit.py | 92 ++-- ...elf_employed_pension_contribution_limit.py | 184 +++++-- .../self_employed_pension_plan_business.py | 22 +- 9 files changed, 740 insertions(+), 156 deletions(-) diff --git a/changelog.d/se-plan-business-farm-partnership.fixed.md b/changelog.d/se-plan-business-farm-partnership.fixed.md index e6f67e34374..25e13c3025a 100644 --- a/changelog.d/se-plan-business-farm-partnership.fixed.md +++ b/changelog.d/se-plan-business-farm-partnership.fixed.md @@ -1 +1 @@ -Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business, with the self-employed health insurance limit subtracting only the plan deduction attributable to the health plan's business. +Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business under one dollar limit. With retirement plans under several businesses, the self-employed health insurance limit (Form 7206 line 9) subtracts only the least part of the plan deduction that the health plan's business must carry, the split that leaves the most earned income for that deduction. diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index ec4fe97ea73..72d1b9af3d2 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -256,7 +256,10 @@ def formula(person, period, parameters): # chosen among the baseline's Schedule C, farm, partnership and # S corporation businesses. return form_7206_line_13( - person, period, other_line_5_profits=draw_profits_in_line_7 + person, + period, + parameters, + other_line_5_profits=draw_profits_in_line_7, ) class self_employed_pension_earned_income(baseline_pension_earned_income): @@ -265,7 +268,7 @@ def formula(person, period, parameters): return baseline_pension_earned_income.formula( person, period, parameters ) - # Pub. 560 step 2 takes the Schedule C business's share of + # Pub. 560 step 2 takes the plan businesses' share of # Schedule 1 line 15, split by net profit as on Form 7206 lines 5 # to 8. As in the health insurance limit above, the draw profits # whose SECA is in line 15 join the profits it is split over. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index 342c4ccbbaf..b81e8ebb271 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -321,3 +321,25 @@ self_employed_health_insurance_ald_person: 15_587.045 # 20,000 - 1,412.955 - 3,000 - 15,587.045. adjusted_gross_income: 0 + +- name: Case 18, with retirement plans under two businesses, either plan can carry the whole plan deduction, so neither business's limit falls. + period: 2024 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 10_000 + # The partnership's earnings enter gross income through + # partnership_income and self-employment tax through + # partnership_self_employment_net_earnings. + partnership_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 3_000 + health_insurance_premiums: 20_000 + output: + # Line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. Line 8: 9,293.5225 + # for each business, and either business's plan can carry all 3,000, so + # line 9 is 0 for the other. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_ald_person: 9_293.5225 + # 20,000 - 1,412.955 - 3,000 - 9,293.5225. + adjusted_gross_income: 6_293.5225 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index eb9485b5266..c61e4e3433f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -266,7 +266,7 @@ # 50,000 - 50,000 x 0.9235 x 0.153 / 2 = 46,467.6125. self_employed_health_insurance_earned_income_limit: 10_000 -- name: Case 22, retirement plans under both businesses split the plan deduction by earned income. +- name: Case 22, with retirement plans under both businesses, the partnership's plan carries what it can of the plan deduction. period: 2023 absolute_error_margin: 0.01 input: @@ -282,9 +282,14 @@ # Within the 2023 limit of min(66,000, 5,576.11 + 22,500, # (27,880.57 + 22,500) / 2) = 25,190.28. self_employed_pension_contribution_ald_person: 15_000 - # Line 9: Schedule C 2 / 3 x 15,000 = 10,000; partnership 5,000. - # Line 10: Schedule C 8,587.045; partnership 4,293.5225. - self_employed_health_insurance_earned_income_limit: 8_587.045 + # Line 9 for the Schedule C business: the partnership's plan can carry at + # most its earned income, 9,293.5225, all as elective deferrals, so the + # Schedule C plan carries 15,000 - 9,293.5225 = 5,706.4775, within its + # room of (18,587.045 + 22,500 - 9,293.5225) / 2 = 15,896.76. Line 10: + # 18,587.045 - 5,706.4775 = 12,880.5675. For the partnership, line 9 is + # 0, since the Schedule C plan can carry all 15,000, and line 10 is + # 9,293.5225. The larger is the limit. + self_employed_health_insurance_earned_income_limit: 12_880.5675 - name: Case 23, a retirement plan under the partnership leaves the Schedule C business's earned income whole. period: 2023 @@ -311,7 +316,7 @@ self_employed_pension_contribution_ald_person: 3_000 self_employed_health_insurance_earned_income_limit: 15_587.045 -- name: Case 25, a health plan under the farm subtracts only the farm's share of the retirement plan deduction. +- name: Case 25, a retirement plan under the Schedule C business can carry the whole plan deduction, so a health plan under the farm subtracts none of it. period: 2024 absolute_error_margin: 0.01 input: @@ -322,6 +327,58 @@ output: # Line 15: 2,825.91. Line 8: Schedule C 27,880.5675 and farm 9,293.5225. self_employed_pension_contribution_ald_person: 4_000 - # Line 9 for the farm: 9,293.5225 / 37,174.09 x 4,000 = 1,000. - # Line 10: 9,293.5225 - 1,000. - self_employed_health_insurance_earned_income_limit: 8_293.5225 + # Line 9 for the farm: 0, since a plan under the Schedule C business + # alone could take min(69,000, 0.2 x 27,880.5675 + 23,000, + # (27,880.5675 + 23,000) / 2) = 25,440.28. Line 10: 9,293.5225. + self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 26, the partnership's plan frees elective deferral room for the Schedule C plan at 50 cents on the dollar. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 50_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 40_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + # Line 15: 60,000 x 0.9235 x 0.153 / 2 = 4,238.865. Line 8: Schedule C + # 50,000 - 5 / 6 x 4,238.865 = 46,467.6125; partnership 9,293.5225. + self_employment_tax_ald_person: 4_238.865 + # The 2026 limit: min(72,000, 0.2 x 55,761.135 + 24,500, + # (55,761.135 + 24,500) / 2) = 35,652.227. + self_employed_pension_contribution_ald_person: 35_652.227 + # Line 9 for the Schedule C business. The partnership's plan can carry its + # whole earned income, 9,293.5225, as elective deferrals, which leaves the + # Schedule C plan 24,500 - 9,293.5225 = 15,206.4775 of deferral room and + # 0.2 x 46,467.6125 = 9,293.5225 of employer contributions: 24,500. The + # remaining 35,652.227 - 9,293.5225 = 26,358.7045 exceeds that by + # 1,858.7045. Each dollar of deferrals the partnership's plan gives up + # lets it add only 50 cents of employer contributions (Pub. 560 step + # 12), so the Schedule C plan carries 24,500 + 2 x 1,858.7045 = + # 28,217.409. Line 10: 46,467.6125 - 28,217.409 = 18,250.2035. + self_employed_health_insurance_earned_income_limit: 18_250.2035 + +- name: Case 27, with retirement plans under two equal businesses, one plan takes most of the elective deferrals. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 43_000 + partnership_self_employment_net_earnings: 43_000 + self_employed_pension_contributions_desired: 60_000 + output: + # Line 15: 86,000 x 0.9235 x 0.153 / 2 = 6,075.7065. Line 8: 43,000 - + # 6,075.7065 / 2 = 39,962.14675 for each business. + self_employment_tax_ald_person: 6_075.7065 + # The 2026 limit: min(72,000, 0.2 x 79,924.2935 + 24,500, + # (79,924.2935 + 24,500) / 2) = 40,484.8587. + self_employed_pension_contribution_ald_person: 40_484.8587 + # Line 9 for either business. The other business's plan can carry at + # most (39,962.14675 + 24,500) / 2 = 32,231.0734 with all 24,500 of the + # deferral room (Pub. 560 step 12), which leaves this plan + # 0.2 x 39,962.14675 = 7,992.4294 of employer contributions. The + # remaining 40,484.8587 - 32,231.0734 = 8,253.7853 exceeds that by + # 261.3559, and each dollar of deferrals the other plan gives up lets it + # add only 50 cents of employer contributions, so this plan carries + # 7,992.4294 + 2 x 261.3559 = 8,515.1412. Line 10: 39,962.14675 - + # 8,515.1412 = 31,447.0055. + self_employed_health_insurance_earned_income_limit: 31_447.0055 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index ddb8bec0244..bcaf2d1afcb 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -45,6 +45,9 @@ from policyengine_us.variables.household.expense.retirement.self_employed_pension_earned_income import ( self_employed_pension_earned_income, ) +from policyengine_us.variables.household.expense.retirement.self_employed_pension_plan_business import ( + SelfEmployedPensionPlanBusiness, +) from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( taxable_earnings_for_social_security, ) @@ -138,6 +141,16 @@ def _parameters( percent_deductible=deductible_share ), ), + # Form 7206 line 9 reads the retirement plan limits, which + # matter only with plans under every business (2026 values). + gross_income=SimpleNamespace( + retirement_contributions=SimpleNamespace( + limit=SimpleNamespace( + profit_sharing_deduction_rate=0.25, + annual_additions=72_000, + ) + ) + ), ), ) ) @@ -158,6 +171,12 @@ def _calculate(variables, parameters, inputs, outputs=None): "payroll_tax_gross_wages": 0, "self_employed_pension_contribution_ald_person": 0, "s_corp_shareholder_medicare_wages": 0, + "traditional_401k_contributions": 0, + "roth_401k_contributions": 0, + "traditional_403b_contributions": 0, + "roth_403b_contributions": 0, + "elective_deferral_limit": 24_500, + "k401_catch_up_limit": 0, **inputs, } @@ -174,14 +193,20 @@ def _calculate(variables, parameters, inputs, outputs=None): "total_self_employment_income", values["self_employment_income"] + values["sstb_self_employment_income"], ) - # The oracles below take the Schedule C business as the plan business. + # The oracles below take the Schedule C business as the business under + # which both the health insurance plan and the retirement plan are + # established, so Form 7206 line 9 is the whole plan deduction. + shape = np.atleast_1d(values["total_self_employment_income"]).shape values.setdefault( "self_employed_health_insurance_plan_business", SelfEmployedHealthInsurancePlanBusiness.encode( - np.full( - np.atleast_1d(values["total_self_employment_income"]).shape, - SelfEmployedHealthInsurancePlanBusiness.SCHEDULE_C, - ) + np.full(shape, SelfEmployedHealthInsurancePlanBusiness.SCHEDULE_C) + ), + ) + values.setdefault( + "self_employed_pension_plan_business", + SelfEmployedPensionPlanBusiness.encode( + np.full(shape, SelfEmployedPensionPlanBusiness.SCHEDULE_C) ), ) diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index e63832c601a..976b22cd0d6 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -17,50 +17,71 @@ (K-1 box 14 code A) or an S corporation. When self_employed_health_insurance_plan_business is not supplied, the model uses the business with the largest earned income. The model treats a person's -Schedule C income, SSTB and non-SSTB together, as one business, and bases the -line 16 plan deduction on Schedule C income, so only the Schedule C business -subtracts it. - -Hypothesis draws batches of self-employed single filers and runs each batch as -one vectorized baseline simulation. Each drawn filer appears six times: with -no named plan business, again with higher premiums, and once naming each of -the four businesses. For every filer and year: +Schedule C income, SSTB and non-SSTB together, as one business. + +The line 16 plan deduction is attributable to the businesses under which the +retirement plan is established (self_employed_pension_plan_business). With one +named business, all of it is attributable to that business. By default every +profitable business has a plan and the person chooses how much each plan +carries, so line 9 for a business is the least its plan must carry while the +other plans carry as much as they can. Each plan stays within its own +business's Pub. 560 worksheet limits, and the plans share one dollar limit and +one elective deferral limit. The reference finds that least share with an +exact linear program, solved here by enumerating the vertices of its feasible +set, so it shares no code with the model's closed form. + +Hypothesis draws batches of self-employed single filers, each with a +retirement plan business, and runs each batch as one vectorized baseline +simulation. Each drawn filer appears six times: with no named health plan +business, again with higher premiums, and once naming each of the four +businesses. For every filer and year: 1. Reference: Schedule 1 line 15 equals half of the self-employment tax - computed here from Schedule SE, and each limit equals a closed-form Form - 7206 computation for its plan business from that line 15 and the model's - line 16. Without a named business, the limit is the largest of the four. - The deduction is the smaller of line 3 and that reference limit. Line 3 - adds the drawn long-term care premiums, up to the limit for ages 41 to 50 - (line 2), to the medical premiums (line 1). + computed here from Schedule SE, and each limit equals a Form 7206 + computation for its plan business from that line 15, the model's line 16 + and line 9 from the linear program. Without a named business, the limit is + the largest of the four. The deduction is the smaller of line 3 and that + reference limit. Line 3 adds the drawn long-term care premiums, up to the + limit for ages 41 to 50 (line 2), to the medical premiums (line 1). 2. Differential: the limit without a named business equals the largest of the - limits the model computes when each business is named. With property 1, - which ties each named limit to that business's own Form 7206, one plan - never pools the earned income of two businesses. -3. Bounds: 0 <= deduction <= line 3, and the limit is at least the Schedule - C limit (widening the base never lowers the deduction). + limits the model computes when each business is named. +3. Bounds: 0 <= deduction <= line 3; the limit is at least the Schedule C + limit, and each business's limit is at least its line 8 less line 16, since + no business's plan carries more than line 16. 4. Monotone in premiums: raising the medical premiums by d raises the deduction by at least 0 and at most d, and leaves the limit unchanged. 5. For a filer with no business losses whose income is wages and - self-employment income, the deduction never takes adjusted gross income - below the Schedule C line 10 amount before its floor at zero. That amount - is negative only when the Schedule 1 line 16 plan deduction exceeds the - Schedule C business's net profit less its share of line 15; otherwise - adjusted gross income stays at or above zero. Intended: a plan under the - farm or partnership does not subtract the Schedule C plan deduction. + self-employment income, adjusted gross income is never negative: the plans + carry line 16 out of their own businesses' earned income, and the health + plan's business keeps only what its plan doesn't carry. 6. Line 2 and Schedule A: the long-term care part is the smaller of line 2 and what the deduction leaves after line 1, and Schedule A keeps line 3 less the deduction, so each premium in line 3 counts once. +Two tests need no simulation. One checks the model's line 9 function against +the linear program over drawn earned incomes, contributions and limits, +including plan rates other than 25%. The other checks the model's single-plan +limit, which the plan limit's property tests tie to the Pub. 560 worksheet, +against the program's largest total for one plan and for two, so the +program's constraints are the worksheet's. + The model computes in float32, whose spacing passes a cent above $131,072, so -comparisons allow a relative error of 1e-6 on top of the $0.01 tolerance. +comparisons allow a relative error of 1e-6, of the amounts that enter each +result, on top of the $0.01 tolerance. """ +import functools +import itertools + import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation +from policyengine_us.variables.household.expense.retirement.self_employed_pension_contribution_limit import ( + plan_deduction_attributable_to_business, + self_employed_plan_limit, +) TOLERANCE = 0.01 # Model outputs are float32, whose spacing exceeds a cent above $131,072. @@ -81,12 +102,129 @@ # 26 U.S.C. 213(d)(10): eligible long-term care premiums at ages 41 to 50 # (Rev. Proc. 2021-45, 2023-34 and 2025-32), as test data. LONG_TERM_CARE_LIMIT_AGE_45 = {2022: 850, 2024: 880, 2026: 930} +# Plan contribution rate: 26 U.S.C. 404(a)(3)(A)(i)(I) and 404(h)(1)(C). +PLAN_RATE = 0.25 +# Section 415(c) dollar limit and section 402(g) elective deferral limit, from +# IRS Notices 2021-61 (2022), 2023-75 (2024) and 2025-67 (2026). The filers +# are 45, so they have no catch-up room. +PLAN_LIMITS = { + 2022: dict(dollar_limit=61_000, deferral=20_500), + 2024: dict(dollar_limit=69_000, deferral=23_000), + 2026: dict(dollar_limit=72_000, deferral=24_500), +} BUSINESSES = ["SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP", "S_CORPORATION"] -# (name prefix, named plan business, premium raise) for each copy of a filer. +SELF_EMPLOYMENT_BUSINESSES = BUSINESSES[:3] +# Businesses under which the retirement plan may be established; ALL, the +# default, puts a plan under every profitable one. +PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] +# (name prefix, named health plan business, premium raise) for each copy of a +# filer. COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ (business.lower(), business, 0) for business in BUSINESSES ] +# The linear program behind line 9. Two plans: the plans under the other +# businesses together (their limits are linear in earned income, so they act +# as one plan with the sum of their earned income), then the plan under this +# business. x = [employer contributions, regular elective deferrals, catch-up +# contributions] for the other plans, then for this one. +LP_ROWS = np.array( + [ + # Each plan, from Pub. 560's Deduction Worksheet for Self-Employed: + # employer contributions at most the reduced rate times earned income + # (steps 4 and 5), at most half of earned income less deferrals (step + # 12), and all contributions at most earned income (404(a)(8)(C)). + [1, 0, 0, 0, 0, 0], + [2, 1, 0, 0, 0, 0], + [1, 1, 1, 0, 0, 0], + [0, 0, 0, 1, 0, 0], + [0, 0, 0, 2, 1, 0], + [0, 0, 0, 1, 1, 1], + # Shared: the regular and catch-up elective deferral room (402(g) and + # 414(v)) and the dollar limit on employer contributions and regular + # deferrals (415(c)). + [0, 1, 0, 0, 1, 0], + [0, 0, 1, 0, 0, 1], + [1, 1, 0, 1, 1, 0], + # No negative contributions. + *(-np.eye(6)), + ], + dtype=float, +) +# The program's arithmetic is float64, accurate to about 1e-10 dollars. +LP_TOLERANCE = 1e-6 + + +def lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room): + """Right-hand sides of LP_ROWS for each case.""" + reduced_rate = rate / (1 + rate) + zeros = np.zeros_like(this) + return np.stack( + [ + reduced_rate * other, + other, + other, + reduced_rate * this, + this, + this, + regular_room + zeros, + catch_up_room + zeros, + dollar_limit + zeros, + ] + + [zeros] * 6, + axis=-1, + ) + + +@functools.lru_cache(maxsize=None) +def vertex_systems(with_total): + """Inverses of every set of active constraints that fixes one point. + + With with_total, each set adds the constraint that the contributions sum + to the plan deduction. + """ + columns = LP_ROWS.shape[1] + chosen, inverses = [], [] + for rows in itertools.combinations(range(len(LP_ROWS)), columns - with_total): + system = LP_ROWS[list(rows)] + if with_total: + system = np.vstack([system, np.ones(columns)]) + if abs(np.linalg.det(system)) > 1e-9: + chosen.append(rows) + inverses.append(np.linalg.inv(system)) + return np.array(chosen), np.array(inverses) + + +def lp_minimum(objective, bounds, total=None): + """Least objective over LP_ROWS x <= bounds, and sum(x) == total if given. + + The feasible set is a bounded polytope, so the minimum is at one of its + vertices; returns infinity where the set is empty. + """ + chosen, inverses = vertex_systems(total is not None) + rhs = bounds[:, chosen] + if total is not None: + rhs = np.concatenate( + [rhs, np.broadcast_to(total[:, None, None], rhs.shape[:2] + (1,))], + axis=2, + ) + points = np.einsum("sij,csj->csi", inverses, rhs) + slack = bounds[:, None, :] - points @ LP_ROWS.T + feasible = np.all(slack >= -LP_TOLERANCE, axis=2) + return np.where(feasible, points @ objective, np.inf).min(axis=1) + + +def lp_largest_total(this, other, rate, dollar_limit, regular_room, catch_up_room): + """Largest total contribution to the two plans.""" + bounds = lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room) + return -lp_minimum(-np.ones(6), bounds) + + +def lp_least_share(total, this, other, rate, dollar_limit, regular_room, catch_up_room): + """Least that this business's plan carries of a total the plans share.""" + bounds = lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room) + return lp_minimum(np.array([0, 0, 0, 1, 1, 1.0]), bounds, total) + def every_year(value): return {year: value for year in YEARS} @@ -115,6 +253,9 @@ def build_situation(filers): "self_employed_pension_contributions_desired": every_year( filer["plan"] ), + "self_employed_pension_plan_business": every_year( + filer["plan_business"] + ), "health_insurance_premiums": every_year(filer["premiums"] + raise_), "long_term_health_insurance_premiums": every_year( filer["long_term_care"] @@ -140,10 +281,12 @@ def schedule_se_deduction(year, schedule_c, farm, partnership, wages): return tax / 2 -def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): +def form_7206_limits( + year, schedule_c, farm, partnership, s_corp, plan_business, line_15, line_16 +): """Form 7206 line 13 for each plan business, with lines 15 and 16 given. - Also returns the Schedule C line 10 amount before its floor at zero. + Also returns line 8 and line 9 for each self-employment business. """ line_4 = { "SCHEDULE_C": np.maximum(schedule_c, 0), @@ -152,23 +295,44 @@ def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): } line_5 = sum(line_4.values()) safe_line_5 = np.where(line_5 > 0, line_5, 1) - line_10 = {} - for business, profit in line_4.items(): - line_6 = np.where(line_5 > 0, profit / safe_line_5, 0) - line_8 = profit - line_15 * line_6 - line_9 = line_16 if business == "SCHEDULE_C" else 0 - line_10[business] = line_8 - line_9 - line_13 = {business: np.maximum(amount, 0) for business, amount in line_10.items()} + line_8 = { + business: profit - line_15 * np.where(line_5 > 0, profit / safe_line_5, 0) + for business, profit in line_4.items() + } + limits = PLAN_LIMITS[year] + room = (PLAN_RATE, limits["dollar_limit"], limits["deferral"], 0) + all_line_8 = sum(line_8.values()) + line_9 = {} + for business in SELF_EMPLOYMENT_BUSINESSES: + this = line_8[business] + other = all_line_8 - this + # Line 16 comes from the model in float32 and may pass the largest + # total by a rounding error. + total = np.minimum(line_16, lp_largest_total(this, other, *room)) + least = lp_least_share(total, this, other, *room) + line_9[business] = np.select( + [plan_business == "ALL", plan_business == business], + [least, line_16], + 0, + ) + line_13 = { + business: np.maximum(line_8[business] - line_9[business], 0) + for business in SELF_EMPLOYMENT_BUSINESSES + } line_13["S_CORPORATION"] = np.maximum(s_corp, 0) - return line_13, line_10["SCHEDULE_C"] + return line_13, line_8, line_9 -def close(actual, expected): - np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=TOLERANCE) +def close(actual, expected, scale=0): + """Equal within $0.01 plus RTOL of the larger of the result and scale.""" + np.testing.assert_array_less( + np.abs(actual - expected), + TOLERANCE + RTOL * np.maximum(np.abs(expected), scale), + ) -def at_least(actual, bound): - return np.all(actual >= bound - TOLERANCE - RTOL * np.abs(bound)) +def at_least(actual, bound, scale=0): + return np.all(actual >= bound - TOLERANCE - RTOL * np.maximum(np.abs(bound), scale)) def column(filers, key): @@ -188,6 +352,7 @@ def copy(array, prefix): # S corporation wages are Social Security wages too. wages = column(filers, "wages") + s_corp long_term_care = column(filers, "long_term_care") + plan_business = np.array([f["plan_business"] for f in filers]) for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) # Line 2: the drawn premiums up to the age 41-50 limit, for every copy. @@ -214,18 +379,31 @@ def copy(array, prefix): ) for prefix, _, _ in COPIES: close(copy(line_15, prefix), expected_line_15) - # Line 16 does not depend on premiums or the named business. + # Line 16 does not depend on premiums or the named health plan + # business, so the plan limit never reads the health insurance + # deduction. for prefix, _, _ in COPIES[1:]: close(copy(line_16, prefix), copy(line_16, "u")) - line_13, schedule_c_line_10 = form_7206_limits( + plan_deduction = copy(line_16, "u").astype(float) + line_13, line_8, line_9 = form_7206_limits( + year, schedule_c, farm, partnership, s_corp, + plan_business, expected_line_15, - copy(line_16, "u"), + plan_deduction, ) largest = np.max(np.stack(list(line_13.values())), axis=0) + # Every amount that enters a limit, for the float32 tolerance. + scale = ( + np.maximum(schedule_c, 0) + + np.maximum(farm, 0) + + np.maximum(partnership, 0) + + s_corp + + plan_deduction + ) # The reference limit for every copy, in the simulation's order. reference_limit = np.concatenate( [ @@ -235,10 +413,14 @@ def copy(array, prefix): ) # 1. Reference. - close(deduction, np.minimum(line_3, reference_limit)) - close(copy(limit, "u"), largest) + close( + deduction, + np.minimum(line_3, reference_limit), + np.tile(scale, len(COPIES)), + ) + close(copy(limit, "u"), largest, scale) for business in BUSINESSES: - close(copy(limit, business.lower()), line_13[business]) + close(copy(limit, business.lower()), line_13[business], scale) # 2. Differential: the default equals the best named business. named = np.stack([copy(limit, b.lower()) for b in BUSINESSES]) @@ -247,7 +429,13 @@ def copy(array, prefix): # 3. Bounds. assert np.all(deduction >= 0) assert at_least(line_3, deduction) - assert at_least(copy(limit, "u"), line_13["SCHEDULE_C"]) + assert at_least(copy(limit, "u"), copy(limit, "schedule_c")) + for business in SELF_EMPLOYMENT_BUSINESSES: + assert at_least( + copy(limit, business.lower()), + line_8[business] - plan_deduction, + scale, + ) # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. base, raised = copy(deduction, "u"), copy(deduction, "r") @@ -255,16 +443,15 @@ def copy(array, prefix): assert at_least(base + PREMIUM_RAISE, raised) close(copy(limit, "u"), copy(limit, "r")) - # 5. Adjusted gross income floor for filers with no business losses. + # 5. Adjusted gross income for filers with no business losses. no_losses = ( (column(filers, "schedule_c") >= 0) & (column(filers, "sstb") >= 0) & (farm >= 0) & (partnership >= 0) ) - floor = np.minimum(0, schedule_c_line_10) for prefix, _, _ in COPIES: - assert at_least(copy(agi, prefix)[no_losses], floor[no_losses]) + assert at_least(copy(agi, prefix)[no_losses], 0, scale[no_losses]) # 6. Line 2 takes what the deduction leaves after line 1, and # Schedule A keeps line 3 less the deduction. @@ -276,6 +463,8 @@ def copy(array, prefix): money = st.integers(0, 120_000) +# Smaller businesses, whose plans can reach their earned income. +small_business = st.sampled_from([5_000, 10_000, 20_000]) filer_strategy = st.fixed_dictionaries( { "schedule_c": st.one_of( @@ -283,11 +472,15 @@ def copy(array, prefix): st.integers(-20_000, 300_000), ), "sstb": st.one_of(st.just(0), st.integers(-20_000, 100_000)), - "farm": st.one_of(st.just(0), st.integers(-30_000, 100_000)), - "partnership": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "farm": st.one_of(st.just(0), small_business, st.integers(-30_000, 100_000)), + "partnership": st.one_of( + st.just(0), small_business, st.integers(-30_000, 100_000) + ), "s_corp": st.one_of(st.just(0), st.integers(0, 150_000)), "wages": st.one_of(st.just(0), st.integers(0, 250_000)), "plan": st.one_of(st.just(0), money), + # ALL, the default, twice as often as each named business. + "plan_business": st.sampled_from(["ALL"] + PLAN_BUSINESSES), "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), "long_term_care": st.one_of(st.just(0), st.integers(1, 2_000)), } @@ -315,6 +508,7 @@ def filer( s_corp=0, wages=0, plan=0, + plan_business="ALL", premiums=0, long_term_care=0, ): @@ -326,14 +520,15 @@ def filer( s_corp=s_corp, wages=wages, plan=plan, + plan_business=plan_business, premiums=premiums, long_term_care=long_term_care, ) def test_reviewer_example_and_boundaries(): - """Fixed filers: the $2,000 example, the $400 threshold, the new bases and - long-term care premiums.""" + """Fixed filers: the $2,000 example, the $400 threshold, the new bases, + long-term care premiums and line 9 with plans under two businesses.""" filers = [ filer(schedule_c=2_000, premiums=6_000), filer(schedule_c=434, premiums=6_000), @@ -348,12 +543,21 @@ def test_reviewer_example_and_boundaries(): filer(partnership=2_000, premiums=6_000), # S corporation wages. filer(s_corp=5_000, premiums=6_000), - # A plan deduction as large as the Schedule C profit, with the farm as - # the plan business (property 5). - filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), + # A retirement plan under the Schedule C business only, with a plan + # deduction as large as its earned income: the farm keeps its own. + filer( + schedule_c=20_000, + plan=20_000, + plan_business="SCHEDULE_C", + farm=50_000, + premiums=100_000, + ), # Long-term care premiums under an S corporation plan: in 2022, line 3 # is 6,000 + min(2,000, 850) = 6,850, above line 11. filer(s_corp=6_500, premiums=6_000, long_term_care=2_000), + # Plans under two businesses, as in the YAML tests' Cases 26 and 27. + filer(schedule_c=50_000, partnership=10_000, plan=40_000, premiums=100_000), + filer(schedule_c=43_000, partnership=43_000, plan=60_000, premiums=100_000), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) @@ -376,3 +580,140 @@ def test_reviewer_example_and_boundaries(): assert abs(deduction[10] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE # Line 11, 6,500, exceeds the 6,000 of medical premiums. assert abs(deduction[11] - 6_500) < TOLERANCE + # In 2026 the Schedule C plan carries 28,217.409 of 35,652.227 and the + # Schedule C business keeps 18,250.2035; with two equal businesses, each + # plan carries 8,515.1413 of 40,484.8587. + deduction = sim.calculate("self_employed_health_insurance_ald_person", 2026)[:n] + assert abs(deduction[12] - 18_250.2035) < 0.02 + assert abs(deduction[13] - 31_447.0055) < 0.02 + + +# Draws for the line 9 function: earned income of the business and of the +# other plans, the shared limits and room, and the plan deduction as a share +# of the largest total. +earned_income = st.one_of( + st.just(0.0), + st.floats(0, 40_000), + st.floats(0, 400_000), +) +plan_case = st.fixed_dictionaries( + { + "this": earned_income, + "other": earned_income, + "rate": st.sampled_from([0.25, 0.25, 0.15, 0.4, 1.0]), + "dollar_limit": st.one_of( + st.sampled_from([61_000.0, 69_000.0, 72_000.0]), st.floats(10_000, 72_000) + ), + "regular_room": st.one_of( + st.sampled_from([0.0, 20_500.0, 24_500.0]), st.floats(0, 24_500) + ), + "catch_up_room": st.one_of( + st.sampled_from([0.0, 7_500.0, 11_250.0]), st.floats(0, 11_250) + ), + "share": st.one_of(st.just(1.0), st.floats(0, 1), st.floats(0.9, 1)), + } +) + + +def plan_arrays(cases): + return {key: np.array([c[key] for c in cases]) for key in cases[0]} + + +@settings( + max_examples=60, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.lists(plan_case, min_size=1, max_size=40)) +@example( + # Case 26: the partnership's plan, at its earned income, frees deferral + # room at 50 cents on the dollar. + [ + dict( + this=46_467.6125, + other=9_293.5225, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=0.0, + share=1.0, + ), + # Case 27: the other plan, below its earned income, does the same. + dict( + this=39_962.14675, + other=39_962.14675, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=0.0, + share=1.0, + ), + # A catch-up contribution ends at the kink between the two faces. + dict( + this=46_467.6125, + other=9_293.5225, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=8_000.0, + share=1.0, + ), + ] +) +def test_line_9_is_the_least_share_the_plans_allow(cases): + c = plan_arrays(cases) + room = (c["rate"], c["dollar_limit"], c["regular_room"], c["catch_up_room"]) + largest = lp_largest_total(c["this"], c["other"], *room) + total = c["share"] * largest + least = lp_least_share(total, c["this"], c["other"], *room) + model = plan_deduction_attributable_to_business(total, c["this"], c["other"], *room) + np.testing.assert_allclose(model, least, rtol=1e-9, atol=1e-6) + + +@settings( + max_examples=40, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.lists(plan_case, min_size=1, max_size=40)) +def test_plan_limit_is_the_programs_largest_total(cases): + """The linear program's constraints reproduce the worksheet's limit, for + one plan and for plans under two businesses together.""" + c = plan_arrays(cases) + room = (c["rate"], c["dollar_limit"], c["regular_room"], c["catch_up_room"]) + zeros = np.zeros_like(c["this"]) + np.testing.assert_allclose( + lp_largest_total(zeros, c["other"], *room), + self_employed_plan_limit(c["other"], *room), + rtol=1e-9, + atol=1e-6, + ) + np.testing.assert_allclose( + lp_largest_total(c["this"], c["other"], *room), + self_employed_plan_limit(c["this"] + c["other"], *room), + rtol=1e-9, + atol=1e-6, + ) + + +def test_line_9_examples(): + """Form 7206 line 9 in the YAML tests' Cases 22, 26 and 27.""" + room_2023 = (PLAN_RATE, 66_000, 22_500, 0) + room_2026 = (PLAN_RATE, 72_000, 24_500, 0) + cases = [ + # Case 22: the partnership's plan carries its whole earned income. + (15_000, 18_587.045, 9_293.5225, room_2023, 5_706.4775), + # ... and the Schedule C plan can carry everything. + (15_000, 9_293.5225, 18_587.045, room_2023, 0), + # Case 26. + (35_652.227, 46_467.6125, 9_293.5225, room_2026, 28_217.409), + # Case 27. + (40_484.8587, 39_962.14675, 39_962.14675, room_2026, 8_515.1413), + ] + for total, this, other, room, expected in cases: + model = plan_deduction_attributable_to_business( + np.array([total]), np.array([this]), np.array([other]), *room + ) + assert abs(model[0] - expected) < 0.001 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index fef5d0e6b4b..a1c97fa90d2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -1,4 +1,8 @@ from policyengine_us.model_api import * +from policyengine_us.variables.household.expense.retirement.self_employed_pension_contribution_limit import ( + plan_deduction_attributable_to_business, + self_employed_plan_room, +) class self_employed_health_insurance_earned_income_limit(Variable): @@ -18,11 +22,17 @@ class self_employed_health_insurance_earned_income_limit(Variable): "is net profit less the part of the deductible part of " "self-employment tax attributable to that business (lines 4 to 8) and " "less the self-employed SEP, SIMPLE and qualified plan deduction " - "attributable to it (lines 9 and 10). That plan deduction is " - "attributable to the businesses under which the retirement plan is " - "established (self_employed_pension_plan_business), in proportion to " - "each one's earned income, so a business outside the retirement plan " - "subtracts none of it. Each kind of business is one total per person: all " + "attributable to it (lines 9 and 10). With the retirement plan under " + "one named business (self_employed_pension_plan_business), all of " + "that plan deduction is attributable to that business. By default " + "each business with a net profit has a retirement plan, and the person " + "chooses how much to contribute to each, so the plan deduction " + "attributable to a business is the least that its plan must carry " + "while the plans under the other businesses carry as much as their " + "own limits and the shared dollar and elective deferral limits allow. " + "Form 7206 gives no rule for that split, and this one leaves the most " + "earned income for the health insurance deduction. Each kind of " + "business is one total per person: all " "Schedule C income (SSTB and non-SSTB), all Schedule F profit, all " "K-1 box 14 code A earnings and all more-than-2% shareholder wages, " "so a loss in one business of a kind reduces line 4. Schedule 1 line " @@ -53,10 +63,10 @@ class self_employed_health_insurance_earned_income_limit(Variable): ) def formula(person, period, parameters): - return form_7206_line_13(person, period) + return form_7206_line_13(person, period, parameters) -def form_7206_line_13(person, period, other_line_5_profits=0): +def form_7206_line_13(person, period, parameters, other_line_5_profits=0): """Form 7206 line 13 for the plan business. other_line_5_profits adds the profits of businesses that cannot @@ -98,43 +108,59 @@ def attributable_self_employment_tax_deduction(profit): attributable_self_employment_tax_deduction(partnership_profit) ) # Line 9: the SEP, SIMPLE and qualified plan deduction (Schedule 1 line - # 16) attributable to each business. The retirement plan's earned income - # is the sum of its businesses' line 8 amounts (Pub. 560 Deduction - # Worksheet step 3), and each of them takes the deduction in proportion - # to its share of that sum. + # 16) attributable to each business. retirement_plan_deduction = person( "self_employed_pension_contribution_ald_person", period ) - retirement_plan_earned_income = person( - "self_employed_pension_earned_income", period - ) - retirement_plan_deduction_rate = np.divide( - retirement_plan_deduction, - retirement_plan_earned_income, - out=np.zeros_like(retirement_plan_earned_income), - where=retirement_plan_earned_income > 0, - ) retirement_plan_business = person("self_employed_pension_plan_business", period) retirement_plan_businesses = retirement_plan_business.possible_values - all_businesses = retirement_plan_business == retirement_plan_businesses.ALL - schedule_c_in_plan = all_businesses | ( - retirement_plan_business == retirement_plan_businesses.SCHEDULE_C - ) - farm_in_plan = all_businesses | ( - retirement_plan_business == retirement_plan_businesses.SCHEDULE_F - ) - partnership_in_plan = all_businesses | ( - retirement_plan_business == retirement_plan_businesses.PARTNERSHIP - ) + plan_room = self_employed_plan_room(person, period, parameters) + + def line_9(line_8, other_businesses_line_8, business): + # A retirement plan under one named business: all of line 16. + named = where( + retirement_plan_business == business, retirement_plan_deduction, 0 + ) + # Plans under every business with a net profit, whose line 8 amounts + # are their earned income (Pub. 560 Deduction Worksheet step 3): the + # least that this business's plan must carry. + least = plan_deduction_attributable_to_business( + retirement_plan_deduction, + line_8, + other_businesses_line_8, + *plan_room, + ) + return where( + retirement_plan_business == retirement_plan_businesses.ALL, least, named + ) + # Line 10 for each self-employment business. schedule_c = max_( 0, - schedule_c_line_8 * (1 - schedule_c_in_plan * retirement_plan_deduction_rate), + schedule_c_line_8 + - line_9( + schedule_c_line_8, + farm_line_8 + partnership_line_8, + retirement_plan_businesses.SCHEDULE_C, + ), + ) + farm = max_( + 0, + farm_line_8 + - line_9( + farm_line_8, + schedule_c_line_8 + partnership_line_8, + retirement_plan_businesses.SCHEDULE_F, + ), ) - farm = max_(0, farm_line_8 * (1 - farm_in_plan * retirement_plan_deduction_rate)) partnership = max_( 0, - partnership_line_8 * (1 - partnership_in_plan * retirement_plan_deduction_rate), + partnership_line_8 + - line_9( + partnership_line_8, + schedule_c_line_8 + farm_line_8, + retirement_plan_businesses.PARTNERSHIP, + ), ) # Line 11: an S corporation shareholder's wages from the corporation. s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index 2890d78e2e0..4f8eeae858e 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -59,45 +59,151 @@ class self_employed_pension_contribution_limit(Variable): ) def formula(person, period, parameters): - p = parameters(period).gov.irs.gross_income.retirement_contributions.limit # Step 3: earned income before the plan deduction. earned_income = person("self_employed_pension_earned_income", period) - # Steps 4 and 5: the employer contribution rate, reduced because - # compensation is net of the contribution. Steps 6 and 7 cap the - # employer contribution at the plan rate times the section - # 401(a)(17) compensation limit. At current law that never binds the - # result, because 25% of the compensation limit is 1.25 times the - # step 8 dollar limit, so the model leaves it out; a reform that set - # the rate times that limit below the dollar limit would need it. - rate = p.profit_sharing_deduction_rate - employer_limit = earned_income * rate / (1 + rate) - # Step 9: elective deferrals, up to the section 402(g) room left - # after deferrals to other employers' plans, which use the regular - # limit before the catch-up amount. - other_deferrals = add( - person, - period, - [ - "traditional_401k_contributions", - "roth_401k_contributions", - "traditional_403b_contributions", - "roth_403b_contributions", - ], + return self_employed_plan_limit( + earned_income, *self_employed_plan_room(person, period, parameters) ) - deferral_room = max_( - 0, person("elective_deferral_limit", period) - other_deferrals - ) - catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) - regular_deferral_room = deferral_room - catch_up_room - # Steps 10 to 15: employer contributions stay within step 7 and - # within half of what is left of earned income after the regular - # deferrals (step 12), because compensation also excludes the - # employer contribution. With the regular deferrals, they stay - # within the dollar limit (step 8). - annual_additions = min_( - min_(p.annual_additions, employer_limit + regular_deferral_room), - (earned_income + regular_deferral_room) / 2, - ) - # Steps 16 to 19: catch-up contributions, outside the dollar limit. - # The total never exceeds earned income. - return min_(earned_income, annual_additions + catch_up_room) + + +def self_employed_plan_room(person, period, parameters): + """The limits every plan of a self-employed person shares. + + Returns the employer contribution rate (Pub. 560 Deduction Worksheet for + Self-Employed, step 4), the section 415(c) dollar limit (step 8), and the + regular and catch-up elective deferral room (steps 9 and 17). + """ + p = parameters(period).gov.irs.gross_income.retirement_contributions.limit + # Step 9: elective deferrals, up to the section 402(g) room left after + # deferrals to other employers' plans, which use the regular limit + # before the catch-up amount. + other_deferrals = add( + person, + period, + [ + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_403b_contributions", + "roth_403b_contributions", + ], + ) + deferral_room = max_(0, person("elective_deferral_limit", period) - other_deferrals) + catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) + regular_deferral_room = deferral_room - catch_up_room + return ( + p.profit_sharing_deduction_rate, + p.annual_additions, + regular_deferral_room, + catch_up_room, + ) + + +def self_employed_plan_limit( + earned_income, rate, dollar_limit, regular_deferral_room, catch_up_room +): + """Pub. 560 Deduction Worksheet for Self-Employed, steps 4 to 19. + + The largest plan deduction for earned income figured before that + deduction (step 3), with the rate, dollar limit and deferral room from + self_employed_plan_room. + """ + # Steps 4 and 5: the employer contribution rate, reduced because + # compensation is net of the contribution. Steps 6 and 7 cap the + # employer contribution at the plan rate times the section 401(a)(17) + # compensation limit. At current law that never binds the result, + # because 25% of the compensation limit is 1.25 times the step 8 dollar + # limit, so the model leaves it out; a reform that set the rate times + # that limit below the dollar limit would need it. + employer_limit = earned_income * rate / (1 + rate) + # Steps 10 to 15: employer contributions stay within step 7 and within + # half of what is left of earned income after the regular deferrals + # (step 12), because compensation also excludes the employer + # contribution. With the regular deferrals, they stay within the dollar + # limit (step 8). + annual_additions = min_( + min_(dollar_limit, employer_limit + regular_deferral_room), + (earned_income + regular_deferral_room) / 2, + ) + # Steps 16 to 19: catch-up contributions, outside the dollar limit. The + # total never exceeds earned income. + return min_(earned_income, annual_additions + catch_up_room) + + +def plan_deduction_attributable_to_business( + plan_deduction, + earned_income, + other_earned_income, + rate, + dollar_limit, + regular_deferral_room, + catch_up_room, +): + """Least part of the plan deduction that one business's plan must carry. + + The person has a plan under this business, with earned_income, and plans + under other businesses, with other_earned_income in all, and chooses how + much to contribute to each. The plans share one dollar limit and one + elective deferral limit, and each one stays within its own business's + worksheet limits: employer contributions at most the reduced rate times + its earned income (steps 4 and 5) and at most half of its earned income + less its deferrals (step 12), and all contributions at most its earned + income. The result is the share of plan_deduction left to this plan when + the other plans carry as much of it as they can (Form 7206 line 9). + """ + reduced_rate = rate / (1 + rate) + other_limit = self_employed_plan_limit( + other_earned_income, + rate, + dollar_limit, + regular_deferral_room, + catch_up_room, + ) + # What the other plans cannot carry. + excess = plan_deduction - other_limit + # The room the other plans leave when they carry all they can. Below + # their earned income, that takes every dollar of the deferral and + # catch-up room. At their earned income, they contribute it with the + # mix that leaves this plan the most: catch-up contributions up to the + # reduced rate times their earned income, or as many as the other + # limits require, and the fewest elective deferrals that step 12 allows. + at_earned_income = other_limit >= other_earned_income + least_catch_up = max_( + max_(0, (other_earned_income - regular_deferral_room) / 2), + max_( + (1 - reduced_rate) * other_earned_income - regular_deferral_room, + other_earned_income - dollar_limit, + ), + ) + other_catch_up = min_( + catch_up_room, + max_(least_catch_up, min_(catch_up_room, reduced_rate * other_earned_income)), + ) + other_deferrals = where( + other_catch_up < reduced_rate * other_earned_income, + other_earned_income - 2 * other_catch_up, + (1 - reduced_rate) * other_earned_income - other_catch_up, + ) + room_left = self_employed_plan_limit( + earned_income, + rate, + dollar_limit=max_( + 0, + where( + at_earned_income, + dollar_limit - (other_earned_income - other_catch_up), + dollar_limit - (other_limit - catch_up_room), + ), + ), + regular_deferral_room=where( + at_earned_income, max_(0, regular_deferral_room - other_deferrals), 0 + ), + catch_up_room=where( + at_earned_income, max_(0, catch_up_room - other_catch_up), 0 + ), + ) + # Up to room_left, this plan carries just the excess. Beyond it, the + # other plans make room only by trading elective deferrals for employer + # contributions, which step 12 allows at 50 cents on the dollar: each + # further dollar of the excess moves two dollars to this plan and takes + # one from the others. + return min_(plan_deduction, max_(0, max_(excess, 2 * excess - room_left))) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py index d88eb35fdc9..3827aa1d4bb 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py @@ -22,18 +22,22 @@ class self_employed_pension_plan_business(Variable): "has Schedule K-1 box 14 code A net earnings. The plan deduction is " "limited to earned income from the plan's business. ALL (the default) " "means every one of these businesses with a net profit has a plan, " - "which is the largest limit the person's businesses allow; a " - "business with a net loss is left out. A named business limits the " + "which gives the largest limit under one dollar limit; a business " + "with a net loss is left out. The person then chooses how much to " + "contribute to each plan, and Form 7206 line 9 for the business under " + "which the health insurance plan is established takes the least that " + "this business's plan must carry. A named business limits the " "deduction to that business's earned income, as when the person has " - "more than one business but only one has a plan." + "more than one business but only one has a plan. The input cannot " + "name two of the three businesses." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", "https://www.law.cornell.edu/uscode/text/26/401#c_4", - # Compensation: with more than one business but only one plan, only - # that business's earned income counts. Employer: a sole proprietor - # is its own employer, and a partnership is each partner's employer. - "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=7", - # Deduction Worksheet step 1: Schedule C, Schedule F or K-1 box 14 A. - "https://www.irs.gov/pub/irs-pdf/p560.pdf#page=39", + # PDF pages 7, 39: with more than one business but only one plan, + # only that business's earned income counts; a sole proprietor is + # their own employer, and a partnership is each partner's employer; + # Deduction Worksheet step 1 takes Schedule C, Schedule F or K-1 box + # 14 code A. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", ) From f3f756ea1840f27b33d19d046b2183d557322429 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 20:37:58 -0500 Subject: [PATCH 36/41] Give the owners' draw stub's plan enums the inputs' shape Scalar inputs now give scalar enums, so the pension earned income helper's division broadcasts as it does in core. Also describe the new line 9 rule in the Form 7206 limit tests' header. Co-Authored-By: Claude Opus 5.5 --- ..._health_insurance_earned_income_limit.yaml | 7 ++++--- .../tests/reforms/test_owners_draw.py | 19 +++++++++++-------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index c61e4e3433f..8cfdf93e584 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -5,9 +5,10 @@ # which the plan is established (self_employed_health_insurance_plan_business), # or of the person's business with the largest earned income when that input # is not supplied. Line 9 is the part of the Schedule 1 line 16 retirement plan -# deduction attributable to that business: the retirement plan's businesses -# (self_employed_pension_plan_business, by default every profitable one) take -# it in proportion to their line 8 amounts. +# deduction attributable to that business. A retirement plan under one named +# business (self_employed_pension_plan_business) puts all of line 16 there. By +# default every profitable business has a plan, and line 9 is the least that +# this business's plan must carry while the other plans carry all they can. - name: Case 1, one business, the limit is net profit less half of self-employment tax. period: 2022 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index bcaf2d1afcb..53638c510c0 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -195,19 +195,22 @@ def _calculate(variables, parameters, inputs, outputs=None): ) # The oracles below take the Schedule C business as the business under # which both the health insurance plan and the retirement plan are - # established, so Form 7206 line 9 is the whole plan deduction. - shape = np.atleast_1d(values["total_self_employment_income"]).shape + # established, so Form 7206 line 9 is the whole plan deduction. The enums + # take the inputs' own shape, so scalar inputs give scalar results. + shape = np.shape(values["total_self_employment_income"]) + + def schedule_c_plan(business): + return business.encode( + np.full(np.atleast_1d(np.zeros(shape)).shape, business.SCHEDULE_C) + ).reshape(shape) + values.setdefault( "self_employed_health_insurance_plan_business", - SelfEmployedHealthInsurancePlanBusiness.encode( - np.full(shape, SelfEmployedHealthInsurancePlanBusiness.SCHEDULE_C) - ), + schedule_c_plan(SelfEmployedHealthInsurancePlanBusiness), ) values.setdefault( "self_employed_pension_plan_business", - SelfEmployedPensionPlanBusiness.encode( - np.full(shape, SelfEmployedPensionPlanBusiness.SCHEDULE_C) - ), + schedule_c_plan(SelfEmployedPensionPlanBusiness), ) def person(name, instant, options=None): From a755a233f634b9280277812853beb72f355ef56d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 7 Nov 2026 20:55:24 -0500 Subject: [PATCH 37/41] Test line 9 when a job's 401(k) used all regular deferral room The other plans must take only the catch-up contributions they need, so the health plan's business keeps the rest. Giving them all of the catch-up room passed the earlier examples; this one catches it. Co-Authored-By: Claude Opus 5.5 --- ...st_self_employed_health_insurance_properties.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 976b22cd0d6..655e7656710 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -659,6 +659,20 @@ def plan_arrays(cases): catch_up_room=8_000.0, share=1.0, ), + # A job's 401(k) used all the regular deferral room. The partnership's + # plan carries its 2,000 with 1,600 of catch-up contributions and 400 + # of employer contributions, which leaves the Schedule C plan 6,400 + # of catch-up room for its 8,000; giving the partnership's plan all of + # the catch-up room would not. + dict( + this=8_000.0, + other=2_000.0, + rate=0.25, + dollar_limit=72_000.0, + regular_room=0.0, + catch_up_room=8_000.0, + share=1.0, + ), ] ) def test_line_9_is_the_least_share_the_plans_allow(cases): From 364c6b4d10002d3fa48baa9f3dbe543f2f9abb3f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:27:49 -0500 Subject: [PATCH 38/41] Pool one employer's plans for Form 7206 line 9 With plans under every profitable business, line 9 applied Pub. 560's 20% and step 12 limits to each plan separately, and the other plans then freed deferral room for this one at 50 cents on the dollar. A sole proprietor is the employer for all of their own businesses (26 U.S.C. 401(c)(4)), and one employer's plans count as one plan for the 415(c) limits (415(f)(1)(B)) and as one trust for the 404(a)(3) limit (404(a)(3)(A)(iv)). The plan limit already treats every plan as one employer's, under one dollar limit. So the worksheet limits apply to the plans together, the only limit on each plan alone is its own business's earned income (404(a)(8)(C)), and line 9 is the part of line 16 above the other plan businesses' earned income. The pension limit keeps #10121's formula, so this branch changes only its documentation, and Form 7206 line 13 no longer reads the plan parameters. Cases 26 and 27 move to 20,108.908 and 39,439.4348. The property tests' linear program applies the worksheet limits to the plans together, ties its largest total to the model's plan limit, and checks that plans under every business never deduct less in lines 16 and 17 than a plan under the Schedule C business alone. Co-Authored-By: Claude Opus 5.5 --- ...se-plan-business-farm-partnership.fixed.md | 2 +- policyengine_us/reforms/ssa/owners_draw.py | 5 +- .../self_employed_health_insurance_ald.yaml | 4 +- ..._health_insurance_earned_income_limit.yaml | 56 ++- .../tests/reforms/test_owners_draw.py | 16 - ...lf_employed_health_insurance_properties.py | 381 +++++++++--------- ...ed_health_insurance_earned_income_limit.py | 66 ++- ...elf_employed_pension_contribution_limit.py | 217 +++------- .../self_employed_pension_plan_business.py | 9 +- 9 files changed, 328 insertions(+), 428 deletions(-) diff --git a/changelog.d/se-plan-business-farm-partnership.fixed.md b/changelog.d/se-plan-business-farm-partnership.fixed.md index 25e13c3025a..43f6b9db054 100644 --- a/changelog.d/se-plan-business-farm-partnership.fixed.md +++ b/changelog.d/se-plan-business-farm-partnership.fixed.md @@ -1 +1 @@ -Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business under one dollar limit. With retirement plans under several businesses, the self-employed health insurance limit (Form 7206 line 9) subtracts only the least part of the plan deduction that the health plan's business must carry, the split that leaves the most earned income for that deduction. +Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business under one dollar limit. With retirement plans under several businesses, the self-employed health insurance limit (Form 7206 line 9) subtracts only the part of the plan deduction above the other plan businesses' earned income, the split that leaves the most earned income for that deduction. diff --git a/policyengine_us/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index 72d1b9af3d2..81da8aa0a28 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -256,10 +256,7 @@ def formula(person, period, parameters): # chosen among the baseline's Schedule C, farm, partnership and # S corporation businesses. return form_7206_line_13( - person, - period, - parameters, - other_line_5_profits=draw_profits_in_line_7, + person, period, other_line_5_profits=draw_profits_in_line_7 ) class self_employed_pension_earned_income(baseline_pension_earned_income): diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index b81e8ebb271..ab9938aa0b7 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -337,8 +337,8 @@ health_insurance_premiums: 20_000 output: # Line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. Line 8: 9,293.5225 - # for each business, and either business's plan can carry all 3,000, so - # line 9 is 0 for the other. + # for each business. Either business's plan can carry up to its own + # earned income, which covers all 3,000, so line 9 is 0 for the other. self_employed_pension_contribution_ald_person: 3_000 self_employed_health_insurance_ald_person: 9_293.5225 # 20,000 - 1,412.955 - 3,000 - 9,293.5225. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 8cfdf93e584..5b85c766898 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -283,13 +283,14 @@ # Within the 2023 limit of min(66,000, 5,576.11 + 22,500, # (27,880.57 + 22,500) / 2) = 25,190.28. self_employed_pension_contribution_ald_person: 15_000 - # Line 9 for the Schedule C business: the partnership's plan can carry at - # most its earned income, 9,293.5225, all as elective deferrals, so the - # Schedule C plan carries 15,000 - 9,293.5225 = 5,706.4775, within its - # room of (18,587.045 + 22,500 - 9,293.5225) / 2 = 15,896.76. Line 10: - # 18,587.045 - 5,706.4775 = 12,880.5675. For the partnership, line 9 is - # 0, since the Schedule C plan can carry all 15,000, and line 10 is - # 9,293.5225. The larger is the limit. + # Line 9 for the Schedule C business: the person is one employer, so the + # worksheet limits apply to both plans together, and each plan can carry + # up to its own business's earned income (26 U.S.C. 404(a)(8)(C)). The + # partnership's plan carries 9,293.5225, and the Schedule C plan carries + # 15,000 - 9,293.5225 = 5,706.4775. Line 10: 18,587.045 - 5,706.4775 = + # 12,880.5675. For the partnership, line 9 is 0, since the Schedule C + # plan can carry all 15,000, and line 10 is 9,293.5225. The larger is + # the limit. self_employed_health_insurance_earned_income_limit: 12_880.5675 - name: Case 23, a retirement plan under the partnership leaves the Schedule C business's earned income whole. @@ -328,12 +329,12 @@ output: # Line 15: 2,825.91. Line 8: Schedule C 27,880.5675 and farm 9,293.5225. self_employed_pension_contribution_ald_person: 4_000 - # Line 9 for the farm: 0, since a plan under the Schedule C business - # alone could take min(69,000, 0.2 x 27,880.5675 + 23,000, - # (27,880.5675 + 23,000) / 2) = 25,440.28. Line 10: 9,293.5225. + # Line 9 for the farm: 0, since the plan under the Schedule C business + # can carry up to that business's earned income, 27,880.5675, which + # covers all 4,000. Line 10: 9,293.5225. self_employed_health_insurance_earned_income_limit: 9_293.5225 -- name: Case 26, the partnership's plan frees elective deferral room for the Schedule C plan at 50 cents on the dollar. +- name: Case 26, the partnership's plan carries its whole earned income, and the Schedule C plan carries the rest. period: 2026 absolute_error_margin: 0.01 input: @@ -348,18 +349,14 @@ # The 2026 limit: min(72,000, 0.2 x 55,761.135 + 24,500, # (55,761.135 + 24,500) / 2) = 35,652.227. self_employed_pension_contribution_ald_person: 35_652.227 - # Line 9 for the Schedule C business. The partnership's plan can carry its - # whole earned income, 9,293.5225, as elective deferrals, which leaves the - # Schedule C plan 24,500 - 9,293.5225 = 15,206.4775 of deferral room and - # 0.2 x 46,467.6125 = 9,293.5225 of employer contributions: 24,500. The - # remaining 35,652.227 - 9,293.5225 = 26,358.7045 exceeds that by - # 1,858.7045. Each dollar of deferrals the partnership's plan gives up - # lets it add only 50 cents of employer contributions (Pub. 560 step - # 12), so the Schedule C plan carries 24,500 + 2 x 1,858.7045 = - # 28,217.409. Line 10: 46,467.6125 - 28,217.409 = 18,250.2035. - self_employed_health_insurance_earned_income_limit: 18_250.2035 + # Line 9 for the Schedule C business. The worksheet limits apply to both + # plans together, so the partnership's plan can carry its whole earned + # income, 9,293.5225, and the Schedule C plan carries 35,652.227 - + # 9,293.5225 = 26,358.7045, within its own earned income. Line 10: + # 46,467.6125 - 26,358.7045 = 20,108.908. + self_employed_health_insurance_earned_income_limit: 20_108.908 -- name: Case 27, with retirement plans under two equal businesses, one plan takes most of the elective deferrals. +- name: Case 27, with retirement plans under two equal businesses, the other plan carries its whole earned income. period: 2026 absolute_error_margin: 0.01 input: @@ -373,13 +370,8 @@ # The 2026 limit: min(72,000, 0.2 x 79,924.2935 + 24,500, # (79,924.2935 + 24,500) / 2) = 40,484.8587. self_employed_pension_contribution_ald_person: 40_484.8587 - # Line 9 for either business. The other business's plan can carry at - # most (39,962.14675 + 24,500) / 2 = 32,231.0734 with all 24,500 of the - # deferral room (Pub. 560 step 12), which leaves this plan - # 0.2 x 39,962.14675 = 7,992.4294 of employer contributions. The - # remaining 40,484.8587 - 32,231.0734 = 8,253.7853 exceeds that by - # 261.3559, and each dollar of deferrals the other plan gives up lets it - # add only 50 cents of employer contributions, so this plan carries - # 7,992.4294 + 2 x 261.3559 = 8,515.1412. Line 10: 39,962.14675 - - # 8,515.1412 = 31,447.0055. - self_employed_health_insurance_earned_income_limit: 31_447.0055 + # Line 9 for either business. The other business's plan can carry its + # whole earned income, 39,962.14675, so this plan carries 40,484.8587 - + # 39,962.14675 = 522.71195. Line 10: 39,962.14675 - 522.71195 = + # 39,439.4348. + self_employed_health_insurance_earned_income_limit: 39_439.4348 diff --git a/policyengine_us/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index 53638c510c0..ca6d5d8ecb7 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -141,16 +141,6 @@ def _parameters( percent_deductible=deductible_share ), ), - # Form 7206 line 9 reads the retirement plan limits, which - # matter only with plans under every business (2026 values). - gross_income=SimpleNamespace( - retirement_contributions=SimpleNamespace( - limit=SimpleNamespace( - profit_sharing_deduction_rate=0.25, - annual_additions=72_000, - ) - ) - ), ), ) ) @@ -171,12 +161,6 @@ def _calculate(variables, parameters, inputs, outputs=None): "payroll_tax_gross_wages": 0, "self_employed_pension_contribution_ald_person": 0, "s_corp_shareholder_medicare_wages": 0, - "traditional_401k_contributions": 0, - "roth_401k_contributions": 0, - "traditional_403b_contributions": 0, - "roth_403b_contributions": 0, - "elective_deferral_limit": 24_500, - "k401_catch_up_limit": 0, **inputs, } diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 655e7656710..341e7c32d67 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -24,25 +24,30 @@ named business, all of it is attributable to that business. By default every profitable business has a plan and the person chooses how much each plan carries, so line 9 for a business is the least its plan must carry while the -other plans carry as much as they can. Each plan stays within its own -business's Pub. 560 worksheet limits, and the plans share one dollar limit and -one elective deferral limit. The reference finds that least share with an -exact linear program, solved here by enumerating the vertices of its feasible -set, so it shares no code with the model's closed form. +other plans carry as much as they can. The model treats the plans as one +employer's, so the Pub. 560 worksheet limits apply to the plans together +(sections 415(f)(1)(B) and 404(a)(3)(A)(iv)), and each plan's contributions +stay within its own business's earned income (404(a)(8)(C)). The reference +finds that least share with an exact linear program, solved here by +enumerating the vertices of its feasible set, so it shares no code with the +model's closed form. Hypothesis draws batches of self-employed single filers, each with a retirement plan business, and runs each batch as one vectorized baseline -simulation. Each drawn filer appears six times: with no named health plan -business, again with higher premiums, and once naming each of the four -businesses. For every filer and year: +simulation. Each drawn filer appears eleven times: with no named health plan +business, once naming each of the four businesses, and again with higher +premiums; then with no named business and with each named business again, +with the retirement plan under the Schedule C business alone. For every filer +and year: 1. Reference: Schedule 1 line 15 equals half of the self-employment tax - computed here from Schedule SE, and each limit equals a Form 7206 - computation for its plan business from that line 15, the model's line 16 - and line 9 from the linear program. Without a named business, the limit is - the largest of the four. The deduction is the smaller of line 3 and that - reference limit. Line 3 adds the drawn long-term care premiums, up to the - limit for ages 41 to 50 (line 2), to the medical premiums (line 1). + computed here from Schedule SE, the plan limit equals the linear program's + largest total, and each limit equals a Form 7206 computation for its plan + business from that line 15, the model's line 16 and line 9 from the linear + program. Without a named business, the limit is the largest of the four. + The deduction is the smaller of line 3 and that reference limit. Line 3 + adds the drawn long-term care premiums, up to the limit for ages 41 to 50 + (line 2), to the medical premiums (line 1). 2. Differential: the limit without a named business equals the largest of the limits the model computes when each business is named. 3. Bounds: 0 <= deduction <= line 3; the limit is at least the Schedule C @@ -57,13 +62,17 @@ 6. Line 2 and Schedule A: the long-term care part is the smaller of line 2 and what the deduction leaves after line 1, and Schedule A keeps line 3 less the deduction, so each premium in line 3 counts once. - -Two tests need no simulation. One checks the model's line 9 function against -the linear program over drawn earned incomes, contributions and limits, -including plan rates other than 25%. The other checks the model's single-plan -limit, which the plan limit's property tests tie to the Pub. 560 worksheet, -against the program's largest total for one plan and for two, so the -program's constraints are the worksheet's. +7. Plans under every business never deduct less than a plan under the + Schedule C business alone: for a filer whose plan business is the + default, lines 16 and 17 together are at least those of the same filer + with the plan under the Schedule C business, for every health plan + business. + +The plan limit's property tests tie the model's plan limit to the Pub. 560 +worksheet, so check 1 makes the program's constraints the worksheet's. One +test needs no simulation: it checks the model's line 9 function against the +linear program over drawn earned incomes, contributions and limits, including +plan rates other than 25%. The model computes in float32, whose spacing passes a cent above $131,072, so comparisons allow a relative error of 1e-6, of the amounts that enter each @@ -78,9 +87,8 @@ from hypothesis import strategies as st from policyengine_us import Simulation -from policyengine_us.variables.household.expense.retirement.self_employed_pension_contribution_limit import ( +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employed_health_insurance_earned_income_limit import ( plan_deduction_attributable_to_business, - self_employed_plan_limit, ) TOLERANCE = 0.01 @@ -117,32 +125,40 @@ # Businesses under which the retirement plan may be established; ALL, the # default, puts a plan under every profitable one. PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] -# (name prefix, named health plan business, premium raise) for each copy of a -# filer. -COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ - (business.lower(), business, 0) for business in BUSINESSES +# (name prefix, named health plan business) for each health plan business a +# copy of a filer names. +HEALTH_COPIES = [("u", "UNSPECIFIED")] + [ + (business.lower(), business) for business in BUSINESSES ] +# (name prefix, named health plan business, premium raise, retirement plan +# business) for each copy of a filer; None keeps the filer's drawn plan +# business. +COPIES = ( + [(prefix, business, 0, None) for prefix, business in HEALTH_COPIES] + + [("r", "UNSPECIFIED", PREMIUM_RAISE, None)] + + [(prefix + "_c", business, 0, "SCHEDULE_C") for prefix, business in HEALTH_COPIES] +) # The linear program behind line 9. Two plans: the plans under the other -# businesses together (their limits are linear in earned income, so they act -# as one plan with the sum of their earned income), then the plan under this -# business. x = [employer contributions, regular elective deferrals, catch-up -# contributions] for the other plans, then for this one. +# businesses together (each one's only limit of its own is its earned income, +# so together they act as one plan with the sum of their earned income), then +# the plan under this business. x = [employer contributions, regular elective +# deferrals, catch-up contributions] for the other plans, then for this one. LP_ROWS = np.array( [ - # Each plan, from Pub. 560's Deduction Worksheet for Self-Employed: - # employer contributions at most the reduced rate times earned income - # (steps 4 and 5), at most half of earned income less deferrals (step - # 12), and all contributions at most earned income (404(a)(8)(C)). - [1, 0, 0, 0, 0, 0], - [2, 1, 0, 0, 0, 0], + # The plans together, since one employer's plans count as one, from + # Pub. 560's Deduction Worksheet for Self-Employed: employer + # contributions at most the reduced rate times earned income (steps 4 + # and 5) and at most half of earned income less deferrals (step 12). + [1, 0, 0, 1, 0, 0], + [2, 1, 0, 2, 1, 0], + # Each plan alone: all of its contributions at most its own + # business's earned income (404(a)(8)(C)). [1, 1, 1, 0, 0, 0], - [0, 0, 0, 1, 0, 0], - [0, 0, 0, 2, 1, 0], [0, 0, 0, 1, 1, 1], - # Shared: the regular and catch-up elective deferral room (402(g) and - # 414(v)) and the dollar limit on employer contributions and regular - # deferrals (415(c)). + # The person's regular and catch-up elective deferral room (402(g) + # and 414(v)) and the dollar limit on employer contributions and + # regular deferrals (415(c)). [0, 1, 0, 0, 1, 0], [0, 0, 1, 0, 0, 1], [1, 1, 0, 1, 1, 0], @@ -159,13 +175,12 @@ def lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room): """Right-hand sides of LP_ROWS for each case.""" reduced_rate = rate / (1 + rate) zeros = np.zeros_like(this) + both = this + other return np.stack( [ - reduced_rate * other, + reduced_rate * both, + both, other, - other, - reduced_rate * this, - this, this, regular_room + zeros, catch_up_room + zeros, @@ -233,7 +248,7 @@ def every_year(value): def build_situation(filers): """One single-filer household for each copy of each drawn filer.""" people, tax_units, households = {}, {}, {} - for prefix, plan_business, raise_ in COPIES: + for prefix, health_business, raise_, plan in COPIES: for i, filer in enumerate(filers): name = f"{prefix}{i}" people[name] = { @@ -254,14 +269,14 @@ def build_situation(filers): filer["plan"] ), "self_employed_pension_plan_business": every_year( - filer["plan_business"] + filer["plan_business"] if plan is None else plan ), "health_insurance_premiums": every_year(filer["premiums"] + raise_), "long_term_health_insurance_premiums": every_year( filer["long_term_care"] ), "self_employed_health_insurance_plan_business": every_year( - plan_business + health_business ), } tax_units[name] = {"members": [name]} @@ -286,7 +301,8 @@ def form_7206_limits( ): """Form 7206 line 13 for each plan business, with lines 15 and 16 given. - Also returns line 8 and line 9 for each self-employment business. + Also returns line 8 for each self-employment business and the linear + program's largest total for the retirement plans, the plan limit. """ line_4 = { "SCHEDULE_C": np.maximum(schedule_c, 0), @@ -301,26 +317,34 @@ def form_7206_limits( } limits = PLAN_LIMITS[year] room = (PLAN_RATE, limits["dollar_limit"], limits["deferral"], 0) - all_line_8 = sum(line_8.values()) - line_9 = {} - for business in SELF_EMPLOYMENT_BUSINESSES: - this = line_8[business] - other = all_line_8 - this - # Line 16 comes from the model in float32 and may pass the largest - # total by a rounding error. - total = np.minimum(line_16, lp_largest_total(this, other, *room)) - least = lp_least_share(total, this, other, *room) - line_9[business] = np.select( - [plan_business == "ALL", plan_business == business], - [least, line_16], - 0, + every_business = plan_business == "ALL" + # The retirement plans' earned income: every business's line 8 by + # default, or the named business's. + plan_earned_income = { + business: np.where( + every_business | (plan_business == business), line_8[business], 0 ) - line_13 = { - business: np.maximum(line_8[business] - line_9[business], 0) for business in SELF_EMPLOYMENT_BUSINESSES } + all_plan_earned_income = sum(plan_earned_income.values()) + largest_total = lp_largest_total( + plan_earned_income["SCHEDULE_C"], + all_plan_earned_income - plan_earned_income["SCHEDULE_C"], + *room, + ) + # Line 16 comes from the model in float32 and may pass the largest total + # by a rounding error. + total = np.minimum(line_16, largest_total) + line_13 = {} + for business in SELF_EMPLOYMENT_BUSINESSES: + this = plan_earned_income[business] + least = lp_least_share(total, this, all_plan_earned_income - this, *room) + line_9 = np.select( + [every_business, plan_business == business], [least, line_16], 0 + ) + line_13[business] = np.maximum(line_8[business] - line_9, 0) line_13["S_CORPORATION"] = np.maximum(s_corp, 0) - return line_13, line_8, line_9 + return line_13, line_8, largest_total def close(actual, expected, scale=0): @@ -340,8 +364,10 @@ def column(filers, key): def check_properties(sim, filers, n): + prefixes = [prefix for prefix, _, _, _ in COPIES] + def copy(array, prefix): - index = [p for p, _, _ in COPIES].index(prefix) + index = prefixes.index(prefix) return array[index * n : (index + 1) * n] # The model's Schedule C is SSTB and non-SSTB income together. @@ -352,7 +378,13 @@ def copy(array, prefix): # S corporation wages are Social Security wages too. wages = column(filers, "wages") + s_corp long_term_care = column(filers, "long_term_care") - plan_business = np.array([f["plan_business"] for f in filers]) + drawn_plan_business = np.array([f["plan_business"] for f in filers]) + profits = ( + np.maximum(schedule_c, 0) + + np.maximum(farm, 0) + + np.maximum(partnership, 0) + + s_corp + ) for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) # Line 2: the drawn premiums up to the age 41-50 limit, for every copy. @@ -371,71 +403,67 @@ def copy(array, prefix): deduction = sim.calculate("self_employed_health_insurance_ald_person", year) line_15 = sim.calculate("self_employment_tax_ald_person", year) line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) + plan_limit = sim.calculate("self_employed_pension_contribution_limit", year) agi = sim.calculate("adjusted_gross_income", year) - # Line 15 from Schedule SE, and line 16, for every copy. expected_line_15 = schedule_se_deduction( year, schedule_c, farm, partnership, wages ) - for prefix, _, _ in COPIES: + # The reference limit and the float32 scale, every amount that enters + # a limit, for every copy in the simulation's order. + reference_limit, scale = [], [] + for prefix, health_business, _, plan in COPIES: + # Line 15 from Schedule SE. close(copy(line_15, prefix), expected_line_15) - # Line 16 does not depend on premiums or the named health plan - # business, so the plan limit never reads the health insurance - # deduction. - for prefix, _, _ in COPIES[1:]: - close(copy(line_16, prefix), copy(line_16, "u")) - plan_deduction = copy(line_16, "u").astype(float) - line_13, line_8, line_9 = form_7206_limits( - year, - schedule_c, - farm, - partnership, - s_corp, - plan_business, - expected_line_15, - plan_deduction, - ) - largest = np.max(np.stack(list(line_13.values())), axis=0) - # Every amount that enters a limit, for the float32 tolerance. - scale = ( - np.maximum(schedule_c, 0) - + np.maximum(farm, 0) - + np.maximum(partnership, 0) - + s_corp - + plan_deduction - ) - # The reference limit for every copy, in the simulation's order. - reference_limit = np.concatenate( - [ - largest if business == "UNSPECIFIED" else line_13[business] - for _, business, _ in COPIES - ] - ) + plan_business = drawn_plan_business if plan is None else np.full(n, plan) + plan_deduction = copy(line_16, prefix).astype(float) + # Line 16 depends on the retirement plan business but not on + # premiums or the named health plan business, so the plan limit + # never reads the health insurance deduction. + close(plan_deduction, copy(line_16, "u" if plan is None else "u_c")) + line_13, line_8, largest_total = form_7206_limits( + year, + schedule_c, + farm, + partnership, + s_corp, + plan_business, + expected_line_15, + plan_deduction, + ) + copy_scale = profits + plan_deduction + # 1. Reference: the program's largest total is the model's plan + # limit. + close(copy(plan_limit, prefix), largest_total, copy_scale) + reference_limit.append( + np.max(np.stack(list(line_13.values())), axis=0) + if health_business == "UNSPECIFIED" + else line_13[health_business] + ) + scale.append(copy_scale) + # 3. Bounds: no business's plan carries more than line 16. + if health_business in SELF_EMPLOYMENT_BUSINESSES: + assert at_least( + copy(limit, prefix), + line_8[health_business] - plan_deduction, + copy_scale, + ) + reference_limit = np.concatenate(reference_limit) + scale = np.concatenate(scale) # 1. Reference. - close( - deduction, - np.minimum(line_3, reference_limit), - np.tile(scale, len(COPIES)), - ) - close(copy(limit, "u"), largest, scale) - for business in BUSINESSES: - close(copy(limit, business.lower()), line_13[business], scale) + close(limit, reference_limit, scale) + close(deduction, np.minimum(line_3, reference_limit), scale) # 2. Differential: the default equals the best named business. - named = np.stack([copy(limit, b.lower()) for b in BUSINESSES]) - close(copy(limit, "u"), named.max(axis=0)) + for suffix in ["", "_c"]: + named = np.stack([copy(limit, b.lower() + suffix) for b in BUSINESSES]) + close(copy(limit, "u" + suffix), named.max(axis=0)) # 3. Bounds. assert np.all(deduction >= 0) assert at_least(line_3, deduction) assert at_least(copy(limit, "u"), copy(limit, "schedule_c")) - for business in SELF_EMPLOYMENT_BUSINESSES: - assert at_least( - copy(limit, business.lower()), - line_8[business] - plan_deduction, - scale, - ) # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. base, raised = copy(deduction, "u"), copy(deduction, "r") @@ -450,8 +478,10 @@ def copy(array, prefix): & (farm >= 0) & (partnership >= 0) ) - for prefix, _, _ in COPIES: - assert at_least(copy(agi, prefix)[no_losses], 0, scale[no_losses]) + for prefix in prefixes: + assert at_least( + copy(agi, prefix)[no_losses], 0, copy(scale, prefix)[no_losses] + ) # 6. Line 2 takes what the deduction leaves after line 1, and # Schedule A keeps line 3 less the deduction. @@ -461,6 +491,17 @@ def copy(array, prefix): ) close(schedule_a, line_3 - deduction) + # 7. Plans under every business never deduct less than a plan under + # the Schedule C business alone. + every_business = drawn_plan_business == "ALL" + lines_16_and_17 = line_16 + deduction + for prefix, _ in HEALTH_COPIES: + assert at_least( + copy(lines_16_and_17, prefix)[every_business], + copy(lines_16_and_17, prefix + "_c")[every_business], + copy(scale, prefix)[every_business], + ) + money = st.integers(0, 120_000) # Smaller businesses, whose plans can reach their earned income. @@ -580,12 +621,15 @@ def test_reviewer_example_and_boundaries(): assert abs(deduction[10] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE # Line 11, 6,500, exceeds the 6,000 of medical premiums. assert abs(deduction[11] - 6_500) < TOLERANCE - # In 2026 the Schedule C plan carries 28,217.409 of 35,652.227 and the - # Schedule C business keeps 18,250.2035; with two equal businesses, each - # plan carries 8,515.1413 of 40,484.8587. + # In 2026 the partnership's plan carries its whole earned income, + # 9,293.5225, of the 35,652.227 plan deduction, and the Schedule C + # business keeps 46,467.6125 - 26,358.7045 = 20,108.908. With two equal + # businesses, the other business's plan carries 39,962.14675 of + # 40,484.8587, and each business keeps 39,962.14675 - 522.71195 = + # 39,439.4348. deduction = sim.calculate("self_employed_health_insurance_ald_person", 2026)[:n] - assert abs(deduction[12] - 18_250.2035) < 0.02 - assert abs(deduction[13] - 31_447.0055) < 0.02 + assert abs(deduction[12] - 20_108.908) < 0.02 + assert abs(deduction[13] - 39_439.4348) < 0.02 # Draws for the line 9 function: earned income of the business and of the @@ -627,9 +671,8 @@ def plan_arrays(cases): ) @given(st.lists(plan_case, min_size=1, max_size=40)) @example( - # Case 26: the partnership's plan, at its earned income, frees deferral - # room at 50 cents on the dollar. [ + # Case 26: the partnership's plan carries its whole earned income. dict( this=46_467.6125, other=9_293.5225, @@ -639,7 +682,7 @@ def plan_arrays(cases): catch_up_room=0.0, share=1.0, ), - # Case 27: the other plan, below its earned income, does the same. + # Case 27: two equal businesses. dict( this=39_962.14675, other=39_962.14675, @@ -649,27 +692,37 @@ def plan_arrays(cases): catch_up_room=0.0, share=1.0, ), - # A catch-up contribution ends at the kink between the two faces. + # A job's 401(k) used all the regular deferral room, so the plans + # take 2,000 of employer contributions and 8,000 of catch-up + # contributions. The other plan holds its 2,000 of earned income, and + # this one carries 8,000. dict( - this=46_467.6125, - other=9_293.5225, + this=8_000.0, + other=2_000.0, + rate=0.25, + dollar_limit=72_000.0, + regular_room=0.0, + catch_up_room=8_000.0, + share=1.0, + ), + # This business has no earned income, so the other plans carry it + # all. + dict( + this=0.0, + other=50_000.0, rate=0.25, dollar_limit=72_000.0, regular_room=24_500.0, catch_up_room=8_000.0, share=1.0, ), - # A job's 401(k) used all the regular deferral room. The partnership's - # plan carries its 2,000 with 1,600 of catch-up contributions and 400 - # of employer contributions, which leaves the Schedule C plan 6,400 - # of catch-up room for its 8,000; giving the partnership's plan all of - # the catch-up room would not. + # No other business has earned income, so this plan carries it all. dict( - this=8_000.0, - other=2_000.0, + this=50_000.0, + other=0.0, rate=0.25, dollar_limit=72_000.0, - regular_room=0.0, + regular_room=24_500.0, catch_up_room=8_000.0, share=1.0, ), @@ -681,53 +734,5 @@ def test_line_9_is_the_least_share_the_plans_allow(cases): largest = lp_largest_total(c["this"], c["other"], *room) total = c["share"] * largest least = lp_least_share(total, c["this"], c["other"], *room) - model = plan_deduction_attributable_to_business(total, c["this"], c["other"], *room) + model = plan_deduction_attributable_to_business(total, c["other"]) np.testing.assert_allclose(model, least, rtol=1e-9, atol=1e-6) - - -@settings( - max_examples=40, - deadline=None, - derandomize=True, - suppress_health_check=[HealthCheck.too_slow], -) -@given(st.lists(plan_case, min_size=1, max_size=40)) -def test_plan_limit_is_the_programs_largest_total(cases): - """The linear program's constraints reproduce the worksheet's limit, for - one plan and for plans under two businesses together.""" - c = plan_arrays(cases) - room = (c["rate"], c["dollar_limit"], c["regular_room"], c["catch_up_room"]) - zeros = np.zeros_like(c["this"]) - np.testing.assert_allclose( - lp_largest_total(zeros, c["other"], *room), - self_employed_plan_limit(c["other"], *room), - rtol=1e-9, - atol=1e-6, - ) - np.testing.assert_allclose( - lp_largest_total(c["this"], c["other"], *room), - self_employed_plan_limit(c["this"] + c["other"], *room), - rtol=1e-9, - atol=1e-6, - ) - - -def test_line_9_examples(): - """Form 7206 line 9 in the YAML tests' Cases 22, 26 and 27.""" - room_2023 = (PLAN_RATE, 66_000, 22_500, 0) - room_2026 = (PLAN_RATE, 72_000, 24_500, 0) - cases = [ - # Case 22: the partnership's plan carries its whole earned income. - (15_000, 18_587.045, 9_293.5225, room_2023, 5_706.4775), - # ... and the Schedule C plan can carry everything. - (15_000, 9_293.5225, 18_587.045, room_2023, 0), - # Case 26. - (35_652.227, 46_467.6125, 9_293.5225, room_2026, 28_217.409), - # Case 27. - (40_484.8587, 39_962.14675, 39_962.14675, room_2026, 8_515.1413), - ] - for total, this, other, room, expected in cases: - model = plan_deduction_attributable_to_business( - np.array([total]), np.array([this]), np.array([other]), *room - ) - assert abs(model[0] - expected) < 0.001 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index a1c97fa90d2..bad1a9cd8de 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -1,8 +1,4 @@ from policyengine_us.model_api import * -from policyengine_us.variables.household.expense.retirement.self_employed_pension_contribution_limit import ( - plan_deduction_attributable_to_business, - self_employed_plan_room, -) class self_employed_health_insurance_earned_income_limit(Variable): @@ -26,12 +22,17 @@ class self_employed_health_insurance_earned_income_limit(Variable): "one named business (self_employed_pension_plan_business), all of " "that plan deduction is attributable to that business. By default " "each business with a net profit has a retirement plan, and the person " - "chooses how much to contribute to each, so the plan deduction " - "attributable to a business is the least that its plan must carry " - "while the plans under the other businesses carry as much as their " - "own limits and the shared dollar and elective deferral limits allow. " - "Form 7206 gives no rule for that split, and this one leaves the most " - "earned income for the health insurance deduction. Each kind of " + "chooses how much to contribute to each. Form 7206 gives no rule for " + "that split, so the model takes the split that leaves the most earned " + "income for the health insurance deduction: the plans under the other " + "businesses carry as much of the plan deduction as they can. As in the " + "plan limit, the model treats all of the plans as one employer's, so " + "the worksheet limits apply to the plans together and each plan can " + "carry up to its own business's earned income. The plan deduction " + "attributable to a business is then the part of it above the other " + "businesses' earned income. A partnership that the person doesn't " + "control is a separate employer whose plan has limits of its own, and " + "for its partners line 9 can exceed the model's. Each kind of " "business is one total per person: all " "Schedule C income (SSTB and non-SSTB), all Schedule F profit, all " "K-1 box 14 code A earnings and all more-than-2% shareholder wages, " @@ -52,6 +53,17 @@ class self_employed_health_insurance_earned_income_limit(Variable): # Subsection (l)(2)(A), and (l)(5) for S corporation shareholders. "https://www.law.cornell.edu/uscode/text/26/162#l", "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", + # Subsections (a)(3)(A)(iv) and (a)(8)(C): one employer's + # profit-sharing trusts count as one trust, and each plan's + # contributions stay within its own business's earned income. + "https://www.law.cornell.edu/uscode/text/26/404", + # Subsection (f)(1)(B): one employer's defined contribution plans + # count as one plan. + "https://www.law.cornell.edu/uscode/text/26/415#f_1_B", + # PDF pages 7, 10: a sole proprietor is their own employer and a + # partnership is each partner's, and the plans one employer maintains + # count together. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", # Where a plan may be established, and one Form 7206 per plan. # PDF pages 1-2 @@ -63,10 +75,29 @@ class self_employed_health_insurance_earned_income_limit(Variable): ) def formula(person, period, parameters): - return form_7206_line_13(person, period, parameters) + return form_7206_line_13(person, period) -def form_7206_line_13(person, period, parameters, other_line_5_profits=0): +def plan_deduction_attributable_to_business(plan_deduction, other_earned_income): + """Least part of the plan deduction that one business's plan must carry. + + Every business with a net profit has a retirement plan, the person + chooses how much to contribute to each, and the plans under the other + businesses have other_earned_income in all. The model treats the plans + as one employer's, which a sole proprietor is for all of their own + businesses. One employer's defined contribution plans count as one plan + for the section 415(c) limits (415(f)(1)(B)), and its profit-sharing + trusts as one trust for the section 404(a)(3) limit (404(a)(3)(A)(iv)), + so the Pub. 560 worksheet limits apply to the plans together. The only + limit on each plan alone is its own business's earned income before the + plan deduction (404(a)(8)(C)). So the other plans can carry the plan + deduction up to their earned income, and this business's plan carries + the rest (Form 7206 line 9). + """ + return max_(0, plan_deduction - other_earned_income) + + +def form_7206_line_13(person, period, other_line_5_profits=0): """Form 7206 line 13 for the plan business. other_line_5_profits adds the profits of businesses that cannot @@ -114,9 +145,8 @@ def attributable_self_employment_tax_deduction(profit): ) retirement_plan_business = person("self_employed_pension_plan_business", period) retirement_plan_businesses = retirement_plan_business.possible_values - plan_room = self_employed_plan_room(person, period, parameters) - def line_9(line_8, other_businesses_line_8, business): + def line_9(other_businesses_line_8, business): # A retirement plan under one named business: all of line 16. named = where( retirement_plan_business == business, retirement_plan_deduction, 0 @@ -125,10 +155,7 @@ def line_9(line_8, other_businesses_line_8, business): # are their earned income (Pub. 560 Deduction Worksheet step 3): the # least that this business's plan must carry. least = plan_deduction_attributable_to_business( - retirement_plan_deduction, - line_8, - other_businesses_line_8, - *plan_room, + retirement_plan_deduction, other_businesses_line_8 ) return where( retirement_plan_business == retirement_plan_businesses.ALL, least, named @@ -139,7 +166,6 @@ def line_9(line_8, other_businesses_line_8, business): 0, schedule_c_line_8 - line_9( - schedule_c_line_8, farm_line_8 + partnership_line_8, retirement_plan_businesses.SCHEDULE_C, ), @@ -148,7 +174,6 @@ def line_9(line_8, other_businesses_line_8, business): 0, farm_line_8 - line_9( - farm_line_8, schedule_c_line_8 + partnership_line_8, retirement_plan_businesses.SCHEDULE_F, ), @@ -157,7 +182,6 @@ def line_9(line_8, other_businesses_line_8, business): 0, partnership_line_8 - line_9( - partnership_line_8, schedule_c_line_8 + farm_line_8, retirement_plan_businesses.PARTNERSHIP, ), diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index 4f8eeae858e..28b8b11a689 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -31,179 +31,76 @@ class self_employed_pension_contribution_limit(Variable): "403(b)(9) account can also allow more, since section 404(a)(10) " "limits it only by the section 402(g) or 415 limit. Earned income " "comes from every business under which a plan is established " - "(self_employed_pension_earned_income), under one dollar limit and " - "one elective deferral limit. A sole proprietor is the employer for " - "all of their own businesses, so their plans share one dollar limit. " - "A partnership is a separate employer, and unless it is under common " - "control with the person's own business, its plan has a dollar limit " - "of its own. The model applies a single dollar limit to all of them, " - "which understates the limit only when that dollar limit binds. " - "Defined benefit plans are not modeled, so contributions to them " - "above this limit are cut." + "(self_employed_pension_earned_income). A sole proprietor is the " + "employer for all of their own businesses, and one employer's plans " + "count as one plan for these limits, so the worksheet applies to the " + "plans' combined contributions and earned income. A partnership is a " + "separate employer unless the person controls it, and its plan then " + "has limits of its own. The model treats all of the plans as one " + "employer's. For this limit that matters only when the dollar limit " + "binds, since the elective deferral limits cover all of a person's " + "employers and the other limits grow in step with each business's " + "earned income. Defined benefit plans are not modeled, so " + "contributions to them above this limit are cut." ) definition_period = YEAR reference = ( - # Subsections (a)(3)(A)(i)(I), (a)(8)(C) and (D), (a)(10), (h)(1)(C) - # and (n). + # Subsections (a)(3)(A)(i)(I) and (iv), (a)(8)(C) and (D), (a)(10), + # (h)(1)(C) and (n). "https://www.law.cornell.edu/uscode/text/26/404", # Subsections (a)(2), (c)(1), (c)(3)(B) and (D), (f)(1)(B) and (h): # section 415 aggregates the plans of one employer. "https://www.law.cornell.edu/uscode/text/26/415", - # Subsections (c), (v)(2)(A) and (v)(3)(A): employers under common - # control are one employer. + # Subsection (c), under which employers under common control are one + # employer, and (v)(2)(A) and (v)(3)(A), on catch-up contributions. "https://www.law.cornell.edu/uscode/text/26/414", - # PDF pages 7, 15, 39: the employer and compensation of a - # self-employed person, SIMPLE IRA compensation, and the worksheet. + # PDF pages 7, 10, 15, 39: the employer and compensation of a + # self-employed person, the plans one employer maintains counting + # together, SIMPLE IRA compensation, and the worksheet. "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", ) def formula(person, period, parameters): + p = parameters(period).gov.irs.gross_income.retirement_contributions.limit # Step 3: earned income before the plan deduction. earned_income = person("self_employed_pension_earned_income", period) - return self_employed_plan_limit( - earned_income, *self_employed_plan_room(person, period, parameters) + # Steps 4 and 5: the employer contribution rate, reduced because + # compensation is net of the contribution. Steps 6 and 7 cap the + # employer contribution at the plan rate times the section + # 401(a)(17) compensation limit. At current law that never binds the + # result, because 25% of the compensation limit is 1.25 times the + # step 8 dollar limit, so the model leaves it out; a reform that set + # the rate times that limit below the dollar limit would need it. + rate = p.profit_sharing_deduction_rate + employer_limit = earned_income * rate / (1 + rate) + # Step 9: elective deferrals, up to the section 402(g) room left + # after deferrals to other employers' plans, which use the regular + # limit before the catch-up amount. + other_deferrals = add( + person, + period, + [ + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_403b_contributions", + "roth_403b_contributions", + ], ) - - -def self_employed_plan_room(person, period, parameters): - """The limits every plan of a self-employed person shares. - - Returns the employer contribution rate (Pub. 560 Deduction Worksheet for - Self-Employed, step 4), the section 415(c) dollar limit (step 8), and the - regular and catch-up elective deferral room (steps 9 and 17). - """ - p = parameters(period).gov.irs.gross_income.retirement_contributions.limit - # Step 9: elective deferrals, up to the section 402(g) room left after - # deferrals to other employers' plans, which use the regular limit - # before the catch-up amount. - other_deferrals = add( - person, - period, - [ - "traditional_401k_contributions", - "roth_401k_contributions", - "traditional_403b_contributions", - "roth_403b_contributions", - ], - ) - deferral_room = max_(0, person("elective_deferral_limit", period) - other_deferrals) - catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) - regular_deferral_room = deferral_room - catch_up_room - return ( - p.profit_sharing_deduction_rate, - p.annual_additions, - regular_deferral_room, - catch_up_room, - ) - - -def self_employed_plan_limit( - earned_income, rate, dollar_limit, regular_deferral_room, catch_up_room -): - """Pub. 560 Deduction Worksheet for Self-Employed, steps 4 to 19. - - The largest plan deduction for earned income figured before that - deduction (step 3), with the rate, dollar limit and deferral room from - self_employed_plan_room. - """ - # Steps 4 and 5: the employer contribution rate, reduced because - # compensation is net of the contribution. Steps 6 and 7 cap the - # employer contribution at the plan rate times the section 401(a)(17) - # compensation limit. At current law that never binds the result, - # because 25% of the compensation limit is 1.25 times the step 8 dollar - # limit, so the model leaves it out; a reform that set the rate times - # that limit below the dollar limit would need it. - employer_limit = earned_income * rate / (1 + rate) - # Steps 10 to 15: employer contributions stay within step 7 and within - # half of what is left of earned income after the regular deferrals - # (step 12), because compensation also excludes the employer - # contribution. With the regular deferrals, they stay within the dollar - # limit (step 8). - annual_additions = min_( - min_(dollar_limit, employer_limit + regular_deferral_room), - (earned_income + regular_deferral_room) / 2, - ) - # Steps 16 to 19: catch-up contributions, outside the dollar limit. The - # total never exceeds earned income. - return min_(earned_income, annual_additions + catch_up_room) - - -def plan_deduction_attributable_to_business( - plan_deduction, - earned_income, - other_earned_income, - rate, - dollar_limit, - regular_deferral_room, - catch_up_room, -): - """Least part of the plan deduction that one business's plan must carry. - - The person has a plan under this business, with earned_income, and plans - under other businesses, with other_earned_income in all, and chooses how - much to contribute to each. The plans share one dollar limit and one - elective deferral limit, and each one stays within its own business's - worksheet limits: employer contributions at most the reduced rate times - its earned income (steps 4 and 5) and at most half of its earned income - less its deferrals (step 12), and all contributions at most its earned - income. The result is the share of plan_deduction left to this plan when - the other plans carry as much of it as they can (Form 7206 line 9). - """ - reduced_rate = rate / (1 + rate) - other_limit = self_employed_plan_limit( - other_earned_income, - rate, - dollar_limit, - regular_deferral_room, - catch_up_room, - ) - # What the other plans cannot carry. - excess = plan_deduction - other_limit - # The room the other plans leave when they carry all they can. Below - # their earned income, that takes every dollar of the deferral and - # catch-up room. At their earned income, they contribute it with the - # mix that leaves this plan the most: catch-up contributions up to the - # reduced rate times their earned income, or as many as the other - # limits require, and the fewest elective deferrals that step 12 allows. - at_earned_income = other_limit >= other_earned_income - least_catch_up = max_( - max_(0, (other_earned_income - regular_deferral_room) / 2), - max_( - (1 - reduced_rate) * other_earned_income - regular_deferral_room, - other_earned_income - dollar_limit, - ), - ) - other_catch_up = min_( - catch_up_room, - max_(least_catch_up, min_(catch_up_room, reduced_rate * other_earned_income)), - ) - other_deferrals = where( - other_catch_up < reduced_rate * other_earned_income, - other_earned_income - 2 * other_catch_up, - (1 - reduced_rate) * other_earned_income - other_catch_up, - ) - room_left = self_employed_plan_limit( - earned_income, - rate, - dollar_limit=max_( - 0, - where( - at_earned_income, - dollar_limit - (other_earned_income - other_catch_up), - dollar_limit - (other_limit - catch_up_room), - ), - ), - regular_deferral_room=where( - at_earned_income, max_(0, regular_deferral_room - other_deferrals), 0 - ), - catch_up_room=where( - at_earned_income, max_(0, catch_up_room - other_catch_up), 0 - ), - ) - # Up to room_left, this plan carries just the excess. Beyond it, the - # other plans make room only by trading elective deferrals for employer - # contributions, which step 12 allows at 50 cents on the dollar: each - # further dollar of the excess moves two dollars to this plan and takes - # one from the others. - return min_(plan_deduction, max_(0, max_(excess, 2 * excess - room_left))) + deferral_room = max_( + 0, person("elective_deferral_limit", period) - other_deferrals + ) + catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) + regular_deferral_room = deferral_room - catch_up_room + # Steps 10 to 15: employer contributions stay within step 7 and + # within half of what is left of earned income after the regular + # deferrals (step 12), because compensation also excludes the + # employer contribution. With the regular deferrals, they stay + # within the dollar limit (step 8). + annual_additions = min_( + min_(p.annual_additions, employer_limit + regular_deferral_room), + (earned_income + regular_deferral_room) / 2, + ) + # Steps 16 to 19: catch-up contributions, outside the dollar limit. + # The total never exceeds earned income. + return min_(earned_income, annual_additions + catch_up_room) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py index 3827aa1d4bb..4347357bdfb 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py @@ -26,10 +26,11 @@ class self_employed_pension_plan_business(Variable): "with a net loss is left out. The person then chooses how much to " "contribute to each plan, and Form 7206 line 9 for the business under " "which the health insurance plan is established takes the least that " - "this business's plan must carry. A named business limits the " - "deduction to that business's earned income, as when the person has " - "more than one business but only one has a plan. The input cannot " - "name two of the three businesses." + "this business's plan must carry: the part of the plan deduction " + "above the other businesses' earned income. A named business limits " + "the deduction to that business's earned income, as when the person " + "has more than one business but only one has a plan. The input " + "cannot name two of the three businesses." ) reference = ( "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", From bd563532dda7b6547d6e6df46bd966b89ca99e12 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 01:48:22 -0500 Subject: [PATCH 39/41] Cite 404(h)(2) for a SEP's share of the pooled 404(a)(3) limit A SEP's deduction reduces the 404(a)(3)(A) limit for the same participants, so a SEP and a profit-sharing plan of one employer share one limit, as two profit-sharing trusts do under 404(a)(3)(A)(iv). Comments only. Co-Authored-By: Claude Opus 5.5 --- ...lf_employed_health_insurance_earned_income_limit.py | 10 ++++++---- .../self_employed_pension_contribution_limit.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index bad1a9cd8de..89663b90238 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -53,9 +53,10 @@ class self_employed_health_insurance_earned_income_limit(Variable): # Subsection (l)(2)(A), and (l)(5) for S corporation shareholders. "https://www.law.cornell.edu/uscode/text/26/162#l", "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", - # Subsections (a)(3)(A)(iv) and (a)(8)(C): one employer's - # profit-sharing trusts count as one trust, and each plan's - # contributions stay within its own business's earned income. + # Subsections (a)(3)(A)(iv), (h)(2) and (a)(8)(C): one employer's + # profit-sharing trusts count as one trust, a SEP's deduction counts + # against their limit, and each plan's contributions stay within its + # own business's earned income. "https://www.law.cornell.edu/uscode/text/26/404", # Subsection (f)(1)(B): one employer's defined contribution plans # count as one plan. @@ -88,7 +89,8 @@ def plan_deduction_attributable_to_business(plan_deduction, other_earned_income) businesses. One employer's defined contribution plans count as one plan for the section 415(c) limits (415(f)(1)(B)), and its profit-sharing trusts as one trust for the section 404(a)(3) limit (404(a)(3)(A)(iv)), - so the Pub. 560 worksheet limits apply to the plans together. The only + against which a SEP's deduction also counts (404(h)(2)), so the Pub. 560 + worksheet limits apply to the plans together. The only limit on each plan alone is its own business's earned income before the plan deduction (404(a)(8)(C)). So the other plans can carry the plan deduction up to their earned income, and this business's plan carries diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py index 28b8b11a689..68845f7b3f9 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -46,7 +46,7 @@ class self_employed_pension_contribution_limit(Variable): definition_period = YEAR reference = ( # Subsections (a)(3)(A)(i)(I) and (iv), (a)(8)(C) and (D), (a)(10), - # (h)(1)(C) and (n). + # (h)(1)(C) and (2), and (n). "https://www.law.cornell.edu/uscode/text/26/404", # Subsections (a)(2), (c)(1), (c)(3)(B) and (D), (f)(1)(B) and (h): # section 415 aggregates the plans of one employer. From 312ce7c9d016c54c26944bb1952994a1361253c7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 02:01:32 -0500 Subject: [PATCH 40/41] Test line 9 with retirement plans under all three businesses YAML Cases 28 to 30 put the health plan under each business in turn, with plans under a Schedule C business, a farm and a partnership. The other two plans carry their whole earned income, 9,293.5225 each, and the health plan's business carries the rest of the 39,369.636 plan deduction. The property test's fixed filers gain the farm and partnership versions, which its Form 7206 reference checks for every health plan business. Before these, a mutant that left the partnership out of the farm's other businesses passed every test. Co-Authored-By: Claude Opus 5.5 --- ..._health_insurance_earned_income_limit.yaml | 59 +++++++++++++++++++ ...lf_employed_health_insurance_properties.py | 24 +++++++- 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 5b85c766898..42426502c41 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -375,3 +375,62 @@ # 39,962.14675 = 522.71195. Line 10: 39,962.14675 - 522.71195 = # 39,439.4348. self_employed_health_insurance_earned_income_limit: 39_439.4348 + +- name: Case 28, with retirement plans under all three businesses, the plans under the farm and the partnership carry their whole earned income, and the Schedule C plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 60_000 + farm_operations_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + # Line 15: 80,000 x 0.9235 x 0.153 / 2 = 5,651.82. Line 8: Schedule C + # 60,000 - 3 / 4 x 5,651.82 = 55,761.135; farm and partnership 10,000 - + # 5,651.82 / 8 = 9,293.5225 each. + self_employment_tax_ald_person: 5_651.82 + # The 2026 limit: min(72,000, 0.2 x 74,348.18 + 24,500, + # (74,348.18 + 24,500) / 2) = 39,369.636. + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the Schedule C business: the plans under the farm and the + # partnership carry 9,293.5225 each, so this plan carries 39,369.636 - + # 18,587.045 = 20,782.591. Line 10: 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 + +- name: Case 29, with retirement plans under all three businesses, the plans under the Schedule C business and the partnership carry their whole earned income, and the farm's plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 10_000 + farm_operations_income: 60_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # As in Case 28 with the farm in place of the Schedule C business: line + # 8 is 55,761.135 for the farm and 9,293.5225 for each other business. + self_employment_tax_ald_person: 5_651.82 + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the farm: 39,369.636 - 18,587.045 = 20,782.591. Line 10: + # 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 + +- name: Case 30, with retirement plans under all three businesses, the plans under the Schedule C business and the farm carry their whole earned income, and the partnership's plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 10_000 + farm_operations_income: 10_000 + partnership_self_employment_net_earnings: 60_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: PARTNERSHIP + output: + # As in Case 28 with the partnership in place of the Schedule C + # business: line 8 is 55,761.135 for the partnership and 9,293.5225 for + # each other business. + self_employment_tax_ald_person: 5_651.82 + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the partnership: 39,369.636 - 18,587.045 = 20,782.591. Line + # 10: 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index 341e7c32d67..611f39ce5dc 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -569,7 +569,8 @@ def filer( def test_reviewer_example_and_boundaries(): """Fixed filers: the $2,000 example, the $400 threshold, the new bases, - long-term care premiums and line 9 with plans under two businesses.""" + long-term care premiums and line 9 with plans under two or three + businesses.""" filers = [ filer(schedule_c=2_000, premiums=6_000), filer(schedule_c=434, premiums=6_000), @@ -599,6 +600,21 @@ def test_reviewer_example_and_boundaries(): # Plans under two businesses, as in the YAML tests' Cases 26 and 27. filer(schedule_c=50_000, partnership=10_000, plan=40_000, premiums=100_000), filer(schedule_c=43_000, partnership=43_000, plan=60_000, premiums=100_000), + # Plans under all three businesses, as in Cases 29 and 30. + filer( + schedule_c=10_000, + farm=60_000, + partnership=10_000, + plan=60_000, + premiums=100_000, + ), + filer( + schedule_c=10_000, + farm=10_000, + partnership=60_000, + plan=60_000, + premiums=100_000, + ), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) @@ -630,6 +646,12 @@ def test_reviewer_example_and_boundaries(): deduction = sim.calculate("self_employed_health_insurance_ald_person", 2026)[:n] assert abs(deduction[12] - 20_108.908) < 0.02 assert abs(deduction[13] - 39_439.4348) < 0.02 + # With plans under all three businesses, the two 10,000 businesses' + # plans carry 9,293.5225 each of the 39,369.636 plan deduction, and the + # 60,000 business keeps 55,761.135 - 20,782.591 = 34,978.544, the largest + # limit. + for i in (14, 15): + assert abs(deduction[i] - 34_978.544) < 0.02 # Draws for the line 9 function: earned income of the business and of the From 4ced67c38bdf7f22e85943042208691cc3fef558 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 02:27:41 -0500 Subject: [PATCH 41/41] Correct the deductible self-employment tax in five plan limit cases 92,350 x 15.3% / 2 is 7,064.775, not 7,064.7775. The farm-only, partnership-only, default Schedule C and farm, farm-alone and partnership-alone cases now state the exact Pub. 560 worksheet amounts (earned income 92,935.225, 37,174.09 and 46,467.6125; limits 42,087.045, 30,337.045 and 32,793.5225). The old values sat within the 0.01 margin, so no case changes result. Co-Authored-By: Claude Opus 5.5 --- ...f_employed_pension_contribution_limit.yaml | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml index c05cc704c01..66c106f8087 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -397,13 +397,13 @@ farm_operations_income: 100_000 self_employed_pension_contributions_desired: 100_000 output: - # Steps 1 to 3: 100,000 - 92,350 x 15.3% / 2 = 100,000 - 7,064.7775. - self_employment_tax_ald_person: 7_064.7775 - self_employed_pension_earned_income: 92_935.2225 - # Step 7: 18,587.0445. Steps 13 + 15: 18,587.0445 + 23,500. - self_employed_pension_contribution_limit: 42_087.0445 - self_employed_pension_contributions: 42_087.0445 - self_employed_pension_contribution_ald_person: 42_087.0445 + # Steps 1 to 3: 100,000 - 92,350 x 15.3% / 2 = 100,000 - 7,064.775. + self_employment_tax_ald_person: 7_064.775 + self_employed_pension_earned_income: 92_935.225 + # Step 7: 18,587.045. Steps 13 + 15: 18,587.045 + 23,500. + self_employed_pension_contribution_limit: 42_087.045 + self_employed_pension_contributions: 42_087.045 + self_employed_pension_contribution_ald_person: 42_087.045 - name: A partnership with K-1 box 14 code A earnings is the only business. period: 2025 @@ -416,8 +416,8 @@ self_employed_pension_contributions_desired: 10_000 output: # Step 3 and the limit as for the farm alone. - self_employed_pension_earned_income: 92_935.2225 - self_employed_pension_contribution_limit: 42_087.0445 + self_employed_pension_earned_income: 92_935.225 + self_employed_pension_contribution_limit: 42_087.045 self_employed_pension_contributions: 10_000 - name: By default a Schedule C business and a farm both have plans, and their earned income adds up. @@ -431,11 +431,11 @@ farm_operations_income: 40_000 self_employed_pension_contributions_desired: 100_000 output: - # Steps 1 to 3: 100,000 - 7,064.7775 (all of Schedule 1 line 15). - self_employed_pension_earned_income: 92_935.2225 - # Steps 13 + 15: 18,587.0445 + 23,500, against 34,652.23 with the plan + # Steps 1 to 3: 100,000 - 7,064.775 (all of Schedule 1 line 15). + self_employed_pension_earned_income: 92_935.225 + # Steps 13 + 15: 18,587.045 + 23,500, against 34,652.23 with the plan # under the Schedule C business alone. - self_employed_pension_contribution_limit: 42_087.0445 + self_employed_pension_contribution_limit: 42_087.045 - name: A plan under the farm alone uses the farm's earned income. period: 2025 @@ -449,11 +449,11 @@ self_employed_pension_contributions_desired: 100_000 self_employed_pension_plan_business: SCHEDULE_F output: - # Steps 1 to 3: 40,000 - 40% x 7,064.7775 = 37,174.089. - self_employed_pension_earned_income: 37_174.089 - # Step 7: 7,434.82. Step 9: 23,500. Step 12: (37,174.089 - 23,500) / 2 = - # 6,837.0445, the smallest, so step 13 is 6,837.0445. Step 19: 30,337.0445. - self_employed_pension_contribution_limit: 30_337.0445 + # Steps 1 to 3: 40,000 - 40% x 7,064.775 = 37,174.09. + self_employed_pension_earned_income: 37_174.09 + # Step 7: 7,434.818. Step 9: 23,500. Step 12: (37,174.09 - 23,500) / 2 = + # 6,837.045, the smallest, so step 13 is 6,837.045. Step 19: 30,337.045. + self_employed_pension_contribution_limit: 30_337.045 - name: A plan under the partnership alone uses the partnership's earned income. period: 2025 @@ -467,10 +467,10 @@ self_employed_pension_contributions_desired: 100_000 self_employed_pension_plan_business: PARTNERSHIP output: - # Steps 1 to 3: 50,000 - 50% x 7,064.7775 = 46,467.61125. - self_employed_pension_earned_income: 46_467.6113 - # Steps 13 + 15: 9,293.5223 + 23,500. - self_employed_pension_contribution_limit: 32_793.5223 + # Steps 1 to 3: 50,000 - 50% x 7,064.775 = 46,467.6125. + self_employed_pension_earned_income: 46_467.6125 + # Steps 13 + 15: 9,293.5225 + 23,500. + self_employed_pension_contribution_limit: 32_793.5225 - name: A farm with a net loss has no plan by default and does not reduce the Schedule C business's earned income. period: 2024