From 6a5735ea1212ad7bee8ed8e71b3d77ae098133cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 16:16:17 -0400 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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