From 6a5735ea1212ad7bee8ed8e71b3d77ae098133cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:16:17 -0400 Subject: [PATCH 01/13] 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 02/13] 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 3fd9f1ff2fe6d16dd4c41d93275350f3b4575f6d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:30:07 -0400 Subject: [PATCH 03/13] 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 16a81b9c2d72975646b2a0807a02f45f73431a56 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:39:52 -0400 Subject: [PATCH 04/13] 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 05/13] 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 b4a9c15f5cc8702a3535452b56e76e95b457244f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 23:51:14 +0000 Subject: [PATCH 06/13] 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 07/13] 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 418e5353eb4b5d41e5469c99fd2d5f24be5e8618 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 00:28:01 +0000 Subject: [PATCH 08/13] 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 09/13] 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 10/13] 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 0b8a50d42d94645dac810ae31d9c65b61b728aba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 20:38:20 +0000 Subject: [PATCH 11/13] 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 12/13] 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 13/13] 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,