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..444b1edcbbc --- /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 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. Catch-up contributions now count on top of the section 415(c) dollar limit, which raises the limit for people 50 and older with high profit. A SIMPLE IRA can allow slightly more than this limit in a narrow band, which the model doesn't yet capture. diff --git a/changelog.d/se-plan-business-farm-partnership.fixed.md b/changelog.d/se-plan-business-farm-partnership.fixed.md new file mode 100644 index 00000000000..43f6b9db054 --- /dev/null +++ b/changelog.d/se-plan-business-farm-partnership.fixed.md @@ -0,0 +1 @@ +Let self-employed people whose SEP, SIMPLE or qualified plan is established under a farm (Schedule F) or partnership (K-1 box 14 code A) business deduct their contributions, by default from the combined earned income of every profitable self-employment business under one dollar limit. With retirement plans under several businesses, the self-employed health insurance limit (Form 7206 line 9) subtracts only the part of the plan deduction above the other plan businesses' earned income, the split that leaves the most earned income for that deduction. diff --git a/docs/contributed_reforms/owners_draw.md b/docs/contributed_reforms/owners_draw.md index 93007403b14..dc169a4700f 100644 --- a/docs/contributed_reforms/owners_draw.md +++ b/docs/contributed_reforms/owners_draw.md @@ -123,6 +123,11 @@ partnership or the S corporation named by `self_employed_health_insurance_plan_business`, or the one with the largest line 13 when none is named. +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/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..ebb046eb324 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/gross_income/retirement_contributions/limit/profit_sharing_deduction_rate.yaml @@ -0,0 +1,16 @@ +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) 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: 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-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/reforms/ssa/owners_draw.py b/policyengine_us/reforms/ssa/owners_draw.py index c80c676dec4..81da8aa0a28 100644 --- a/policyengine_us/reforms/ssa/owners_draw.py +++ b/policyengine_us/reforms/ssa/owners_draw.py @@ -23,6 +23,10 @@ self_employed_health_insurance_earned_income_limit as baseline_sehi_limit, form_7206_line_13, ) +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, @@ -255,6 +259,25 @@ def formula(person, period, parameters): person, period, other_line_5_profits=draw_profits_in_line_7 ) + 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 plan businesses' share of + # Schedule 1 line 15, split by net profit as on Form 7206 lines 5 + # to 8. As in the health insurance limit above, the draw profits + # whose SECA is in line 15 join the profits it is split over. + 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) @@ -288,6 +311,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/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 15d986812cd..715d4dbcc83 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/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml index d8fdab012ab..ab9938aa0b7 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_ald.yaml @@ -306,3 +306,40 @@ itemized_medical_expenses: 0 # 32,000 - 2,260.728 - 20,930. adjusted_gross_income: 8_809.272 + +- name: Case 17, a farmer with health and retirement plans under the farm ends with AGI of zero. + period: 2023 + absolute_error_margin: 0.01 + input: + is_self_employed: true + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + health_insurance_premiums: 20_000 + output: + # Form 7206 line 13: 20,000 - 1,412.955 - 3,000 = 15,587.045. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_ald_person: 15_587.045 + # 20,000 - 1,412.955 - 3,000 - 15,587.045. + adjusted_gross_income: 0 + +- name: Case 18, with retirement plans under two businesses, either plan can carry the whole plan deduction, so neither business's limit falls. + period: 2024 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 10_000 + # The partnership's earnings enter gross income through + # partnership_income and self-employment tax through + # partnership_self_employment_net_earnings. + partnership_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 3_000 + health_insurance_premiums: 20_000 + output: + # Line 15: 20,000 x 0.9235 x 0.153 / 2 = 1,412.955. Line 8: 9,293.5225 + # for each business. Either business's plan can carry up to its own + # earned income, which covers all 3,000, so line 9 is 0 for the other. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_ald_person: 9_293.5225 + # 20,000 - 1,412.955 - 3,000 - 9,293.5225. + adjusted_gross_income: 6_293.5225 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml index 0594a350c08..42426502c41 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.yaml @@ -4,7 +4,11 @@ # One Form 7206 per plan: the limit is the earned income of the business under # which the plan is established (self_employed_health_insurance_plan_business), # or of the person's business with the largest earned income when that input -# is not supplied. +# is not supplied. Line 9 is the part of the Schedule 1 line 16 retirement plan +# deduction attributable to that business. A retirement plan under one named +# business (self_employed_pension_plan_business) puts all of line 16 there. By +# default every profitable business has a plan, and line 9 is the least that +# this business's plan must carry while the other plans carry all they can. - name: Case 1, one business, the limit is net profit less half of self-employment tax. period: 2022 @@ -26,7 +30,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 @@ -99,7 +103,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: @@ -108,9 +112,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. @@ -194,12 +199,13 @@ # Lines 5 to 8: 10,000 / 40,000 = 0.25; 10,000 - 0.25 x 2,825.91. self_employed_health_insurance_earned_income_limit: 9_293.5225 -- name: Case 17, the plan deduction is attributable to Schedule C, so the partnership earns more. +- name: Case 17, a retirement plan under the Schedule C business leaves the partnership earning more. period: 2023 absolute_error_margin: 0.01 input: self_employment_income: 20_000 self_employed_pension_contributions_desired: 15_000 + self_employed_pension_plan_business: SCHEDULE_C partnership_self_employment_net_earnings: 10_000 output: # Schedule 1 line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. @@ -260,3 +266,171 @@ # Line 11: 10,000. The Schedule C business would give # 50,000 - 50,000 x 0.9235 x 0.153 / 2 = 46,467.6125. self_employed_health_insurance_earned_income_limit: 10_000 + +- name: Case 22, with retirement plans under both businesses, the partnership's plan carries what it can of the plan deduction. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 15_000 + partnership_self_employment_net_earnings: 10_000 + output: + # Line 15: 30,000 x 0.9235 x 0.153 / 2 = 2,119.4325. + self_employment_tax_ald_person: 2_119.4325 + # Line 8: Schedule C 18,587.045 and partnership 9,293.5225, together the + # retirement plan's earned income (Pub. 560 step 3), 27,880.5675. + self_employed_pension_earned_income: 27_880.5675 + # Within the 2023 limit of min(66,000, 5,576.11 + 22,500, + # (27,880.57 + 22,500) / 2) = 25,190.28. + self_employed_pension_contribution_ald_person: 15_000 + # Line 9 for the Schedule C business: the person is one employer, so the + # worksheet limits apply to both plans together, and each plan can carry + # up to its own business's earned income (26 U.S.C. 404(a)(8)(C)). The + # partnership's plan carries 9,293.5225, and the Schedule C plan carries + # 15,000 - 9,293.5225 = 5,706.4775. Line 10: 18,587.045 - 5,706.4775 = + # 12,880.5675. For the partnership, line 9 is 0, since the Schedule C + # plan can carry all 15,000, and line 10 is 9,293.5225. The larger is + # the limit. + self_employed_health_insurance_earned_income_limit: 12_880.5675 + +- name: Case 23, a retirement plan under the partnership leaves the Schedule C business's earned income whole. + period: 2023 + absolute_error_margin: 0.01 + input: + self_employment_income: 20_000 + self_employed_pension_contributions_desired: 15_000 + self_employed_pension_plan_business: PARTNERSHIP + partnership_self_employment_net_earnings: 10_000 + output: + # The partnership's earned income, 9,293.5225, caps the plan deduction. + self_employed_pension_contribution_ald_person: 9_293.5225 + # Line 10: Schedule C 18,587.045 (line 9 is 0); partnership 0. + self_employed_health_insurance_earned_income_limit: 18_587.045 + +- name: Case 24, a farm's own retirement plan reduces the farm's limit. + period: 2023 + absolute_error_margin: 0.01 + input: + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 3_000 + output: + # Line 8: 20,000 - 1,412.955. Line 9: the farm's plan deduction, 3,000. + self_employed_pension_contribution_ald_person: 3_000 + self_employed_health_insurance_earned_income_limit: 15_587.045 + +- name: Case 25, a retirement plan under the Schedule C business can carry the whole plan deduction, so a health plan under the farm subtracts none of it. + period: 2024 + absolute_error_margin: 0.01 + input: + self_employment_income: 30_000 + farm_operations_income: 10_000 + self_employed_pension_contributions_desired: 4_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # Line 15: 2,825.91. Line 8: Schedule C 27,880.5675 and farm 9,293.5225. + self_employed_pension_contribution_ald_person: 4_000 + # Line 9 for the farm: 0, since the plan under the Schedule C business + # can carry up to that business's earned income, 27,880.5675, which + # covers all 4,000. Line 10: 9,293.5225. + self_employed_health_insurance_earned_income_limit: 9_293.5225 + +- name: Case 26, the partnership's plan carries its whole earned income, and the Schedule C plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 50_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 40_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + # Line 15: 60,000 x 0.9235 x 0.153 / 2 = 4,238.865. Line 8: Schedule C + # 50,000 - 5 / 6 x 4,238.865 = 46,467.6125; partnership 9,293.5225. + self_employment_tax_ald_person: 4_238.865 + # The 2026 limit: min(72,000, 0.2 x 55,761.135 + 24,500, + # (55,761.135 + 24,500) / 2) = 35,652.227. + self_employed_pension_contribution_ald_person: 35_652.227 + # Line 9 for the Schedule C business. The worksheet limits apply to both + # plans together, so the partnership's plan can carry its whole earned + # income, 9,293.5225, and the Schedule C plan carries 35,652.227 - + # 9,293.5225 = 26,358.7045, within its own earned income. Line 10: + # 46,467.6125 - 26,358.7045 = 20,108.908. + self_employed_health_insurance_earned_income_limit: 20_108.908 + +- name: Case 27, with retirement plans under two equal businesses, the other plan carries its whole earned income. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 43_000 + partnership_self_employment_net_earnings: 43_000 + self_employed_pension_contributions_desired: 60_000 + output: + # Line 15: 86,000 x 0.9235 x 0.153 / 2 = 6,075.7065. Line 8: 43,000 - + # 6,075.7065 / 2 = 39,962.14675 for each business. + self_employment_tax_ald_person: 6_075.7065 + # The 2026 limit: min(72,000, 0.2 x 79,924.2935 + 24,500, + # (79,924.2935 + 24,500) / 2) = 40,484.8587. + self_employed_pension_contribution_ald_person: 40_484.8587 + # Line 9 for either business. The other business's plan can carry its + # whole earned income, 39,962.14675, so this plan carries 40,484.8587 - + # 39,962.14675 = 522.71195. Line 10: 39,962.14675 - 522.71195 = + # 39,439.4348. + self_employed_health_insurance_earned_income_limit: 39_439.4348 + +- name: Case 28, with retirement plans under all three businesses, the plans under the farm and the partnership carry their whole earned income, and the Schedule C plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 60_000 + farm_operations_income: 10_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: SCHEDULE_C + output: + # Line 15: 80,000 x 0.9235 x 0.153 / 2 = 5,651.82. Line 8: Schedule C + # 60,000 - 3 / 4 x 5,651.82 = 55,761.135; farm and partnership 10,000 - + # 5,651.82 / 8 = 9,293.5225 each. + self_employment_tax_ald_person: 5_651.82 + # The 2026 limit: min(72,000, 0.2 x 74,348.18 + 24,500, + # (74,348.18 + 24,500) / 2) = 39,369.636. + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the Schedule C business: the plans under the farm and the + # partnership carry 9,293.5225 each, so this plan carries 39,369.636 - + # 18,587.045 = 20,782.591. Line 10: 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 + +- name: Case 29, with retirement plans under all three businesses, the plans under the Schedule C business and the partnership carry their whole earned income, and the farm's plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 10_000 + farm_operations_income: 60_000 + partnership_self_employment_net_earnings: 10_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: SCHEDULE_F + output: + # As in Case 28 with the farm in place of the Schedule C business: line + # 8 is 55,761.135 for the farm and 9,293.5225 for each other business. + self_employment_tax_ald_person: 5_651.82 + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the farm: 39,369.636 - 18,587.045 = 20,782.591. Line 10: + # 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 + +- name: Case 30, with retirement plans under all three businesses, the plans under the Schedule C business and the farm carry their whole earned income, and the partnership's plan carries the rest. + period: 2026 + absolute_error_margin: 0.01 + input: + self_employment_income: 10_000 + farm_operations_income: 10_000 + partnership_self_employment_net_earnings: 60_000 + self_employed_pension_contributions_desired: 60_000 + self_employed_health_insurance_plan_business: PARTNERSHIP + output: + # As in Case 28 with the partnership in place of the Schedule C + # business: line 8 is 55,761.135 for the partnership and 9,293.5225 for + # each other business. + self_employment_tax_ald_person: 5_651.82 + self_employed_pension_contribution_ald_person: 39_369.636 + # Line 9 for the partnership: 39,369.636 - 18,587.045 = 20,782.591. Line + # 10: 55,761.135 - 20,782.591 = 34,978.544. + self_employed_health_insurance_earned_income_limit: 34_978.544 diff --git a/policyengine_us/tests/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..2d88fd0b8f5 --- /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,87 @@ +- 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.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] + 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 (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 + +- name: Case 4, a farm's own plan is deductible and cannot make AGI negative. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + farm_operations_income: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + # Pub. 560 step 1 is Schedule F line 34: 20,000. Step 3: + # 20,000 - 1,412.955 = 18,587.045, all of which deferrals can cover. + self_employed_pension_contribution_ald_person: [18_587.045] + adjusted_gross_income: 0 + +- name: Case 5, a partner's plan is deductible from K-1 box 14 code A earnings. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + # The partnership's earnings enter gross income through + # partnership_income and self-employment tax through + # partnership_self_employment_net_earnings. + partnership_income: 20_000 + partnership_self_employment_net_earnings: 20_000 + self_employed_pension_contributions_desired: 20_000 + output: + self_employed_pension_contribution_ald_person: [18_587.045] + adjusted_gross_income: 0 diff --git a/policyengine_us/tests/policy/baseline/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 4110518fd83..0e714dc6cdd 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 @@ -18,11 +18,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/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 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..66c106f8087 100644 --- a/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml +++ b/policyengine_us/tests/policy/baseline/household/expense/retirement/self_employed_pension_contribution_limit.yaml @@ -1,29 +1,51 @@ +# Expected values follow the Pub. 560 Deduction Worksheet for Self-Employed +# (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 the net profit of the +# plan's businesses less their 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, as on Form 7206 lines 5 to 8. Unless the case names one +# (self_employed_pension_plan_business), every business with a net profit has +# a plan, and step 2 is all of Schedule 1 line 15, as the worksheet prints it. + - 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 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 - 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 +57,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 +77,452 @@ 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: 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 add to the limit (steps 16 to 18). + 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: 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 + 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: 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: 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: 1,040.87. 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, from a plan under the Schedule C business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: SCHEDULE_C + output: + # Self-employment tax deduction: 92,350 x 15.3% / 2 = 7,064.78, of which + # 60% (4,238.87) is attributable to the Schedule C business. + 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: A farm loss adds no profit to the split, so Schedule C takes all of line 15. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: -20_000 + self_employed_pension_contributions_desired: 70_000 + output: + # Schedule SE line 4: (60,000 - 20,000) x 0.9235 = 36,940. Line 15: + # 36,940 x 15.3% / 2 = 2,825.91. Step 3: 60,000 - 2,825.91 = 57,174.09. + # Step 7: 20% x 57,174.09 = 11,434.82. Steps 13 + 15: 11,434.82 + 23,500 + # of deferral room = 34,934.82. + self_employed_pension_earned_income: 57_174.09 + self_employed_pension_contribution_limit: 34_934.82 + +- 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 + self_employment_income: 1_000 + partnership_self_employment_net_earnings: 50_000 + self_employed_pension_contributions_desired: 5_000 + self_employed_pension_plan_business: SCHEDULE_C + output: + # 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 + 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 + +- name: A farm (Schedule F) is the only business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + farm_operations_income: 100_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 1 to 3: 100,000 - 92,350 x 15.3% / 2 = 100,000 - 7,064.775. + self_employment_tax_ald_person: 7_064.775 + self_employed_pension_earned_income: 92_935.225 + # Step 7: 18,587.045. Steps 13 + 15: 18,587.045 + 23,500. + self_employed_pension_contribution_limit: 42_087.045 + self_employed_pension_contributions: 42_087.045 + self_employed_pension_contribution_ald_person: 42_087.045 + +- name: A partnership with K-1 box 14 code A earnings is the only business. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + partnership_self_employment_net_earnings: 100_000 + self_employed_pension_contributions_desired: 10_000 + output: + # Step 3 and the limit as for the farm alone. + self_employed_pension_earned_income: 92_935.225 + self_employed_pension_contribution_limit: 42_087.045 + self_employed_pension_contributions: 10_000 + +- name: By default a Schedule C business and a farm both have plans, and their earned income adds up. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + output: + # Steps 1 to 3: 100,000 - 7,064.775 (all of Schedule 1 line 15). + self_employed_pension_earned_income: 92_935.225 + # Steps 13 + 15: 18,587.045 + 23,500, against 34,652.23 with the plan + # under the Schedule C business alone. + self_employed_pension_contribution_limit: 42_087.045 + +- name: A plan under the farm alone uses the farm's earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 60_000 + farm_operations_income: 40_000 + self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: SCHEDULE_F + output: + # Steps 1 to 3: 40,000 - 40% x 7,064.775 = 37,174.09. + self_employed_pension_earned_income: 37_174.09 + # Step 7: 7,434.818. Step 9: 23,500. Step 12: (37,174.09 - 23,500) / 2 = + # 6,837.045, the smallest, so step 13 is 6,837.045. Step 19: 30,337.045. + self_employed_pension_contribution_limit: 30_337.045 + +- name: A plan under the partnership alone uses the partnership's earned income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 50_000 + partnership_self_employment_net_earnings: 50_000 + self_employed_pension_contributions_desired: 100_000 + self_employed_pension_plan_business: PARTNERSHIP + output: + # Steps 1 to 3: 50,000 - 50% x 7,064.775 = 46,467.6125. + self_employed_pension_earned_income: 46_467.6125 + # Steps 13 + 15: 9,293.5225 + 23,500. + self_employed_pension_contribution_limit: 32_793.5225 + +- name: A farm with a net loss has no plan by default and does not reduce the Schedule C business's earned income. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 30_000 + farm_operations_income: -10_000 + self_employed_pension_contributions_desired: 30_000 + output: + # Line 15: (30,000 - 10,000) x 0.9235 x 15.3% / 2 = 1,412.955, all + # attributable to the profitable Schedule C business. + self_employment_tax_ald_person: 1_412.955 + self_employed_pension_earned_income: 28_587.045 + # Step 12: (28,587.045 - 23,000) / 2 = 2,793.5225, the smallest, so + # steps 13 + 15 are 2,793.5225 + 23,000. + self_employed_pension_contribution_limit: 25_793.5225 + self_employed_pension_contributions: 25_793.5225 + +- name: A plan under a farm with a net loss allows no contribution. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 30_000 + farm_operations_income: -10_000 + self_employed_pension_contributions_desired: 30_000 + self_employed_pension_plan_business: SCHEDULE_F + output: + self_employed_pension_earned_income: 0 + self_employed_pension_contribution_limit: 0 + self_employed_pension_contributions: 0 + +- name: Plans under a sole proprietor's Schedule C business and farm share one dollar limit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + self_employment_income: 300_000 + farm_operations_income: 300_000 + self_employed_pension_contributions_desired: 150_000 + output: + # The person is the employer for both businesses (26 U.S.C. 401(c)(4)), + # and one employer's defined contribution plans count as one plan for the + # dollar limit (415(f)(1)(B)). Line 15: (184,500 x 12.4% + 554,100 x + # 2.9%) / 2 = 19,473.45. Step 3: 600,000 - 19,473.45 = 580,526.55. Step 7 + # (116,105.31) exceeds the $72,000 dollar limit, which binds once. + self_employment_tax_ald_person: 19_473.45 self_employed_pension_contribution_limit: 72_000 - self_employed_pension_contributions: 72_000 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/tests/reforms/test_owners_draw.py b/policyengine_us/tests/reforms/test_owners_draw.py index eb94cd18480..ca6d5d8ecb7 100644 --- a/policyengine_us/tests/reforms/test_owners_draw.py +++ b/policyengine_us/tests/reforms/test_owners_draw.py @@ -42,6 +42,12 @@ 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.household.expense.retirement.self_employed_pension_plan_business import ( + SelfEmployedPensionPlanBusiness, +) from policyengine_us.variables.gov.irs.tax.payroll.social_security.taxable_earnings_for_social_security import ( taxable_earnings_for_social_security, ) @@ -73,6 +79,7 @@ self_employment_tax, self_employment_tax_ald_person, self_employed_health_insurance_earned_income_limit, + self_employed_pension_earned_income, ) } PERIOD = period("2027") @@ -170,15 +177,24 @@ def _calculate(variables, parameters, inputs, outputs=None): "total_self_employment_income", values["self_employment_income"] + values["sstb_self_employment_income"], ) - # The oracles below take the Schedule C business as the plan business. + # The oracles below take the Schedule C business as the business under + # which both the health insurance plan and the retirement plan are + # established, so Form 7206 line 9 is the whole plan deduction. The enums + # take the inputs' own shape, so scalar inputs give scalar results. + shape = np.shape(values["total_self_employment_income"]) + + def schedule_c_plan(business): + return business.encode( + np.full(np.atleast_1d(np.zeros(shape)).shape, business.SCHEDULE_C) + ).reshape(shape) + values.setdefault( "self_employed_health_insurance_plan_business", - SelfEmployedHealthInsurancePlanBusiness.encode( - np.full( - np.atleast_1d(values["total_self_employment_income"]).shape, - SelfEmployedHealthInsurancePlanBusiness.SCHEDULE_C, - ) - ), + schedule_c_plan(SelfEmployedHealthInsurancePlanBusiness), + ) + values.setdefault( + "self_employed_pension_plan_business", + schedule_c_plan(SelfEmployedPensionPlanBusiness), ) def person(name, instant, options=None): diff --git a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py index e63832c601a..611f39ce5dc 100644 --- a/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py +++ b/policyengine_us/tests/unit/test_self_employed_health_insurance_properties.py @@ -17,50 +17,79 @@ (K-1 box 14 code A) or an S corporation. When self_employed_health_insurance_plan_business is not supplied, the model uses the business with the largest earned income. The model treats a person's -Schedule C income, SSTB and non-SSTB together, as one business, and bases the -line 16 plan deduction on Schedule C income, so only the Schedule C business -subtracts it. - -Hypothesis draws batches of self-employed single filers and runs each batch as -one vectorized baseline simulation. Each drawn filer appears six times: with -no named plan business, again with higher premiums, and once naming each of -the four businesses. For every filer and year: +Schedule C income, SSTB and non-SSTB together, as one business. + +The line 16 plan deduction is attributable to the businesses under which the +retirement plan is established (self_employed_pension_plan_business). With one +named business, all of it is attributable to that business. By default every +profitable business has a plan and the person chooses how much each plan +carries, so line 9 for a business is the least its plan must carry while the +other plans carry as much as they can. The model treats the plans as one +employer's, so the Pub. 560 worksheet limits apply to the plans together +(sections 415(f)(1)(B) and 404(a)(3)(A)(iv)), and each plan's contributions +stay within its own business's earned income (404(a)(8)(C)). The reference +finds that least share with an exact linear program, solved here by +enumerating the vertices of its feasible set, so it shares no code with the +model's closed form. + +Hypothesis draws batches of self-employed single filers, each with a +retirement plan business, and runs each batch as one vectorized baseline +simulation. Each drawn filer appears eleven times: with no named health plan +business, once naming each of the four businesses, and again with higher +premiums; then with no named business and with each named business again, +with the retirement plan under the Schedule C business alone. For every filer +and year: 1. Reference: Schedule 1 line 15 equals half of the self-employment tax - computed here from Schedule SE, and each limit equals a closed-form Form - 7206 computation for its plan business from that line 15 and the model's - line 16. Without a named business, the limit is the largest of the four. + computed here from Schedule SE, the plan limit equals the linear program's + largest total, and each limit equals a Form 7206 computation for its plan + business from that line 15, the model's line 16 and line 9 from the linear + program. Without a named business, the limit is the largest of the four. The deduction is the smaller of line 3 and that reference limit. Line 3 adds the drawn long-term care premiums, up to the limit for ages 41 to 50 (line 2), to the medical premiums (line 1). 2. Differential: the limit without a named business equals the largest of the - limits the model computes when each business is named. With property 1, - which ties each named limit to that business's own Form 7206, one plan - never pools the earned income of two businesses. -3. Bounds: 0 <= deduction <= line 3, and the limit is at least the Schedule - C limit (widening the base never lowers the deduction). + limits the model computes when each business is named. +3. Bounds: 0 <= deduction <= line 3; the limit is at least the Schedule C + limit, and each business's limit is at least its line 8 less line 16, since + no business's plan carries more than line 16. 4. Monotone in premiums: raising the medical premiums by d raises the deduction by at least 0 and at most d, and leaves the limit unchanged. 5. For a filer with no business losses whose income is wages and - self-employment income, the deduction never takes adjusted gross income - below the Schedule C line 10 amount before its floor at zero. That amount - is negative only when the Schedule 1 line 16 plan deduction exceeds the - Schedule C business's net profit less its share of line 15; otherwise - adjusted gross income stays at or above zero. Intended: a plan under the - farm or partnership does not subtract the Schedule C plan deduction. + self-employment income, adjusted gross income is never negative: the plans + carry line 16 out of their own businesses' earned income, and the health + plan's business keeps only what its plan doesn't carry. 6. Line 2 and Schedule A: the long-term care part is the smaller of line 2 and what the deduction leaves after line 1, and Schedule A keeps line 3 less the deduction, so each premium in line 3 counts once. +7. Plans under every business never deduct less than a plan under the + Schedule C business alone: for a filer whose plan business is the + default, lines 16 and 17 together are at least those of the same filer + with the plan under the Schedule C business, for every health plan + business. + +The plan limit's property tests tie the model's plan limit to the Pub. 560 +worksheet, so check 1 makes the program's constraints the worksheet's. One +test needs no simulation: it checks the model's line 9 function against the +linear program over drawn earned incomes, contributions and limits, including +plan rates other than 25%. The model computes in float32, whose spacing passes a cent above $131,072, so -comparisons allow a relative error of 1e-6 on top of the $0.01 tolerance. +comparisons allow a relative error of 1e-6, of the amounts that enter each +result, on top of the $0.01 tolerance. """ +import functools +import itertools + import numpy as np -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, example, given, settings from hypothesis import strategies as st from policyengine_us import Simulation +from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.self_employed_health_insurance_earned_income_limit import ( + plan_deduction_attributable_to_business, +) TOLERANCE = 0.01 # Model outputs are float32, whose spacing exceeds a cent above $131,072. @@ -81,11 +110,135 @@ # 26 U.S.C. 213(d)(10): eligible long-term care premiums at ages 41 to 50 # (Rev. Proc. 2021-45, 2023-34 and 2025-32), as test data. LONG_TERM_CARE_LIMIT_AGE_45 = {2022: 850, 2024: 880, 2026: 930} +# Plan contribution rate: 26 U.S.C. 404(a)(3)(A)(i)(I) and 404(h)(1)(C). +PLAN_RATE = 0.25 +# Section 415(c) dollar limit and section 402(g) elective deferral limit, from +# IRS Notices 2021-61 (2022), 2023-75 (2024) and 2025-67 (2026). The filers +# are 45, so they have no catch-up room. +PLAN_LIMITS = { + 2022: dict(dollar_limit=61_000, deferral=20_500), + 2024: dict(dollar_limit=69_000, deferral=23_000), + 2026: dict(dollar_limit=72_000, deferral=24_500), +} BUSINESSES = ["SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP", "S_CORPORATION"] -# (name prefix, named plan business, premium raise) for each copy of a filer. -COPIES = [("u", "UNSPECIFIED", 0), ("r", "UNSPECIFIED", PREMIUM_RAISE)] + [ - (business.lower(), business, 0) for business in BUSINESSES +SELF_EMPLOYMENT_BUSINESSES = BUSINESSES[:3] +# Businesses under which the retirement plan may be established; ALL, the +# default, puts a plan under every profitable one. +PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] +# (name prefix, named health plan business) for each health plan business a +# copy of a filer names. +HEALTH_COPIES = [("u", "UNSPECIFIED")] + [ + (business.lower(), business) for business in BUSINESSES ] +# (name prefix, named health plan business, premium raise, retirement plan +# business) for each copy of a filer; None keeps the filer's drawn plan +# business. +COPIES = ( + [(prefix, business, 0, None) for prefix, business in HEALTH_COPIES] + + [("r", "UNSPECIFIED", PREMIUM_RAISE, None)] + + [(prefix + "_c", business, 0, "SCHEDULE_C") for prefix, business in HEALTH_COPIES] +) + +# The linear program behind line 9. Two plans: the plans under the other +# businesses together (each one's only limit of its own is its earned income, +# so together they act as one plan with the sum of their earned income), then +# the plan under this business. x = [employer contributions, regular elective +# deferrals, catch-up contributions] for the other plans, then for this one. +LP_ROWS = np.array( + [ + # The plans together, since one employer's plans count as one, from + # Pub. 560's Deduction Worksheet for Self-Employed: employer + # contributions at most the reduced rate times earned income (steps 4 + # and 5) and at most half of earned income less deferrals (step 12). + [1, 0, 0, 1, 0, 0], + [2, 1, 0, 2, 1, 0], + # Each plan alone: all of its contributions at most its own + # business's earned income (404(a)(8)(C)). + [1, 1, 1, 0, 0, 0], + [0, 0, 0, 1, 1, 1], + # The person's regular and catch-up elective deferral room (402(g) + # and 414(v)) and the dollar limit on employer contributions and + # regular deferrals (415(c)). + [0, 1, 0, 0, 1, 0], + [0, 0, 1, 0, 0, 1], + [1, 1, 0, 1, 1, 0], + # No negative contributions. + *(-np.eye(6)), + ], + dtype=float, +) +# The program's arithmetic is float64, accurate to about 1e-10 dollars. +LP_TOLERANCE = 1e-6 + + +def lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room): + """Right-hand sides of LP_ROWS for each case.""" + reduced_rate = rate / (1 + rate) + zeros = np.zeros_like(this) + both = this + other + return np.stack( + [ + reduced_rate * both, + both, + other, + this, + regular_room + zeros, + catch_up_room + zeros, + dollar_limit + zeros, + ] + + [zeros] * 6, + axis=-1, + ) + + +@functools.lru_cache(maxsize=None) +def vertex_systems(with_total): + """Inverses of every set of active constraints that fixes one point. + + With with_total, each set adds the constraint that the contributions sum + to the plan deduction. + """ + columns = LP_ROWS.shape[1] + chosen, inverses = [], [] + for rows in itertools.combinations(range(len(LP_ROWS)), columns - with_total): + system = LP_ROWS[list(rows)] + if with_total: + system = np.vstack([system, np.ones(columns)]) + if abs(np.linalg.det(system)) > 1e-9: + chosen.append(rows) + inverses.append(np.linalg.inv(system)) + return np.array(chosen), np.array(inverses) + + +def lp_minimum(objective, bounds, total=None): + """Least objective over LP_ROWS x <= bounds, and sum(x) == total if given. + + The feasible set is a bounded polytope, so the minimum is at one of its + vertices; returns infinity where the set is empty. + """ + chosen, inverses = vertex_systems(total is not None) + rhs = bounds[:, chosen] + if total is not None: + rhs = np.concatenate( + [rhs, np.broadcast_to(total[:, None, None], rhs.shape[:2] + (1,))], + axis=2, + ) + points = np.einsum("sij,csj->csi", inverses, rhs) + slack = bounds[:, None, :] - points @ LP_ROWS.T + feasible = np.all(slack >= -LP_TOLERANCE, axis=2) + return np.where(feasible, points @ objective, np.inf).min(axis=1) + + +def lp_largest_total(this, other, rate, dollar_limit, regular_room, catch_up_room): + """Largest total contribution to the two plans.""" + bounds = lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room) + return -lp_minimum(-np.ones(6), bounds) + + +def lp_least_share(total, this, other, rate, dollar_limit, regular_room, catch_up_room): + """Least that this business's plan carries of a total the plans share.""" + bounds = lp_bounds(this, other, rate, dollar_limit, regular_room, catch_up_room) + return lp_minimum(np.array([0, 0, 0, 1, 1, 1.0]), bounds, total) def every_year(value): @@ -95,7 +248,7 @@ def every_year(value): def build_situation(filers): """One single-filer household for each copy of each drawn filer.""" people, tax_units, households = {}, {}, {} - for prefix, plan_business, raise_ in COPIES: + for prefix, health_business, raise_, plan in COPIES: for i, filer in enumerate(filers): name = f"{prefix}{i}" people[name] = { @@ -115,12 +268,15 @@ def build_situation(filers): "self_employed_pension_contributions_desired": every_year( filer["plan"] ), + "self_employed_pension_plan_business": every_year( + filer["plan_business"] if plan is None else plan + ), "health_insurance_premiums": every_year(filer["premiums"] + raise_), "long_term_health_insurance_premiums": every_year( filer["long_term_care"] ), "self_employed_health_insurance_plan_business": every_year( - plan_business + health_business ), } tax_units[name] = {"members": [name]} @@ -140,10 +296,13 @@ def schedule_se_deduction(year, schedule_c, farm, partnership, wages): return tax / 2 -def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): +def form_7206_limits( + year, schedule_c, farm, partnership, s_corp, plan_business, line_15, line_16 +): """Form 7206 line 13 for each plan business, with lines 15 and 16 given. - Also returns the Schedule C line 10 amount before its floor at zero. + Also returns line 8 for each self-employment business and the linear + program's largest total for the retirement plans, the plan limit. """ line_4 = { "SCHEDULE_C": np.maximum(schedule_c, 0), @@ -152,23 +311,52 @@ def form_7206_limits(schedule_c, farm, partnership, s_corp, line_15, line_16): } line_5 = sum(line_4.values()) safe_line_5 = np.where(line_5 > 0, line_5, 1) - line_10 = {} - for business, profit in line_4.items(): - line_6 = np.where(line_5 > 0, profit / safe_line_5, 0) - line_8 = profit - line_15 * line_6 - line_9 = line_16 if business == "SCHEDULE_C" else 0 - line_10[business] = line_8 - line_9 - line_13 = {business: np.maximum(amount, 0) for business, amount in line_10.items()} + line_8 = { + business: profit - line_15 * np.where(line_5 > 0, profit / safe_line_5, 0) + for business, profit in line_4.items() + } + limits = PLAN_LIMITS[year] + room = (PLAN_RATE, limits["dollar_limit"], limits["deferral"], 0) + every_business = plan_business == "ALL" + # The retirement plans' earned income: every business's line 8 by + # default, or the named business's. + plan_earned_income = { + business: np.where( + every_business | (plan_business == business), line_8[business], 0 + ) + for business in SELF_EMPLOYMENT_BUSINESSES + } + all_plan_earned_income = sum(plan_earned_income.values()) + largest_total = lp_largest_total( + plan_earned_income["SCHEDULE_C"], + all_plan_earned_income - plan_earned_income["SCHEDULE_C"], + *room, + ) + # Line 16 comes from the model in float32 and may pass the largest total + # by a rounding error. + total = np.minimum(line_16, largest_total) + line_13 = {} + for business in SELF_EMPLOYMENT_BUSINESSES: + this = plan_earned_income[business] + least = lp_least_share(total, this, all_plan_earned_income - this, *room) + line_9 = np.select( + [every_business, plan_business == business], [least, line_16], 0 + ) + line_13[business] = np.maximum(line_8[business] - line_9, 0) line_13["S_CORPORATION"] = np.maximum(s_corp, 0) - return line_13, line_10["SCHEDULE_C"] + return line_13, line_8, largest_total -def close(actual, expected): - np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=TOLERANCE) +def close(actual, expected, scale=0): + """Equal within $0.01 plus RTOL of the larger of the result and scale.""" + np.testing.assert_array_less( + np.abs(actual - expected), + TOLERANCE + RTOL * np.maximum(np.abs(expected), scale), + ) -def at_least(actual, bound): - return np.all(actual >= bound - TOLERANCE - RTOL * np.abs(bound)) +def at_least(actual, bound, scale=0): + return np.all(actual >= bound - TOLERANCE - RTOL * np.maximum(np.abs(bound), scale)) def column(filers, key): @@ -176,8 +364,10 @@ def column(filers, key): def check_properties(sim, filers, n): + prefixes = [prefix for prefix, _, _, _ in COPIES] + def copy(array, prefix): - index = [p for p, _, _ in COPIES].index(prefix) + index = prefixes.index(prefix) return array[index * n : (index + 1) * n] # The model's Schedule C is SSTB and non-SSTB income together. @@ -188,6 +378,13 @@ def copy(array, prefix): # S corporation wages are Social Security wages too. wages = column(filers, "wages") + s_corp long_term_care = column(filers, "long_term_care") + drawn_plan_business = np.array([f["plan_business"] for f in filers]) + profits = ( + np.maximum(schedule_c, 0) + + np.maximum(farm, 0) + + np.maximum(partnership, 0) + + s_corp + ) for year in YEARS: premiums = sim.calculate("self_employed_health_insurance_premiums", year) # Line 2: the drawn premiums up to the age 41-50 limit, for every copy. @@ -206,48 +403,67 @@ def copy(array, prefix): deduction = sim.calculate("self_employed_health_insurance_ald_person", year) line_15 = sim.calculate("self_employment_tax_ald_person", year) line_16 = sim.calculate("self_employed_pension_contribution_ald_person", year) + plan_limit = sim.calculate("self_employed_pension_contribution_limit", year) agi = sim.calculate("adjusted_gross_income", year) - # Line 15 from Schedule SE, and line 16, for every copy. expected_line_15 = schedule_se_deduction( year, schedule_c, farm, partnership, wages ) - for prefix, _, _ in COPIES: + # The reference limit and the float32 scale, every amount that enters + # a limit, for every copy in the simulation's order. + reference_limit, scale = [], [] + for prefix, health_business, _, plan in COPIES: + # Line 15 from Schedule SE. close(copy(line_15, prefix), expected_line_15) - # Line 16 does not depend on premiums or the named business. - for prefix, _, _ in COPIES[1:]: - close(copy(line_16, prefix), copy(line_16, "u")) - line_13, schedule_c_line_10 = form_7206_limits( - schedule_c, - farm, - partnership, - s_corp, - expected_line_15, - copy(line_16, "u"), - ) - largest = np.max(np.stack(list(line_13.values())), axis=0) - # The reference limit for every copy, in the simulation's order. - reference_limit = np.concatenate( - [ - largest if business == "UNSPECIFIED" else line_13[business] - for _, business, _ in COPIES - ] - ) + plan_business = drawn_plan_business if plan is None else np.full(n, plan) + plan_deduction = copy(line_16, prefix).astype(float) + # Line 16 depends on the retirement plan business but not on + # premiums or the named health plan business, so the plan limit + # never reads the health insurance deduction. + close(plan_deduction, copy(line_16, "u" if plan is None else "u_c")) + line_13, line_8, largest_total = form_7206_limits( + year, + schedule_c, + farm, + partnership, + s_corp, + plan_business, + expected_line_15, + plan_deduction, + ) + copy_scale = profits + plan_deduction + # 1. Reference: the program's largest total is the model's plan + # limit. + close(copy(plan_limit, prefix), largest_total, copy_scale) + reference_limit.append( + np.max(np.stack(list(line_13.values())), axis=0) + if health_business == "UNSPECIFIED" + else line_13[health_business] + ) + scale.append(copy_scale) + # 3. Bounds: no business's plan carries more than line 16. + if health_business in SELF_EMPLOYMENT_BUSINESSES: + assert at_least( + copy(limit, prefix), + line_8[health_business] - plan_deduction, + copy_scale, + ) + reference_limit = np.concatenate(reference_limit) + scale = np.concatenate(scale) # 1. Reference. - close(deduction, np.minimum(line_3, reference_limit)) - close(copy(limit, "u"), largest) - for business in BUSINESSES: - close(copy(limit, business.lower()), line_13[business]) + close(limit, reference_limit, scale) + close(deduction, np.minimum(line_3, reference_limit), scale) # 2. Differential: the default equals the best named business. - named = np.stack([copy(limit, b.lower()) for b in BUSINESSES]) - close(copy(limit, "u"), named.max(axis=0)) + for suffix in ["", "_c"]: + named = np.stack([copy(limit, b.lower() + suffix) for b in BUSINESSES]) + close(copy(limit, "u" + suffix), named.max(axis=0)) # 3. Bounds. assert np.all(deduction >= 0) assert at_least(line_3, deduction) - assert at_least(copy(limit, "u"), line_13["SCHEDULE_C"]) + assert at_least(copy(limit, "u"), copy(limit, "schedule_c")) # 4. Monotone and 1-Lipschitz in premiums; the limit ignores premiums. base, raised = copy(deduction, "u"), copy(deduction, "r") @@ -255,16 +471,17 @@ def copy(array, prefix): assert at_least(base + PREMIUM_RAISE, raised) close(copy(limit, "u"), copy(limit, "r")) - # 5. Adjusted gross income floor for filers with no business losses. + # 5. Adjusted gross income for filers with no business losses. no_losses = ( (column(filers, "schedule_c") >= 0) & (column(filers, "sstb") >= 0) & (farm >= 0) & (partnership >= 0) ) - floor = np.minimum(0, schedule_c_line_10) - for prefix, _, _ in COPIES: - assert at_least(copy(agi, prefix)[no_losses], floor[no_losses]) + for prefix in prefixes: + assert at_least( + copy(agi, prefix)[no_losses], 0, copy(scale, prefix)[no_losses] + ) # 6. Line 2 takes what the deduction leaves after line 1, and # Schedule A keeps line 3 less the deduction. @@ -274,8 +491,21 @@ def copy(array, prefix): ) close(schedule_a, line_3 - deduction) + # 7. Plans under every business never deduct less than a plan under + # the Schedule C business alone. + every_business = drawn_plan_business == "ALL" + lines_16_and_17 = line_16 + deduction + for prefix, _ in HEALTH_COPIES: + assert at_least( + copy(lines_16_and_17, prefix)[every_business], + copy(lines_16_and_17, prefix + "_c")[every_business], + copy(scale, prefix)[every_business], + ) + money = st.integers(0, 120_000) +# Smaller businesses, whose plans can reach their earned income. +small_business = st.sampled_from([5_000, 10_000, 20_000]) filer_strategy = st.fixed_dictionaries( { "schedule_c": st.one_of( @@ -283,11 +513,15 @@ def copy(array, prefix): st.integers(-20_000, 300_000), ), "sstb": st.one_of(st.just(0), st.integers(-20_000, 100_000)), - "farm": st.one_of(st.just(0), st.integers(-30_000, 100_000)), - "partnership": st.one_of(st.just(0), st.integers(-30_000, 100_000)), + "farm": st.one_of(st.just(0), small_business, st.integers(-30_000, 100_000)), + "partnership": st.one_of( + st.just(0), small_business, st.integers(-30_000, 100_000) + ), "s_corp": st.one_of(st.just(0), st.integers(0, 150_000)), "wages": st.one_of(st.just(0), st.integers(0, 250_000)), "plan": st.one_of(st.just(0), money), + # ALL, the default, twice as often as each named business. + "plan_business": st.sampled_from(["ALL"] + PLAN_BUSINESSES), "premiums": st.one_of(st.sampled_from([0, 1_000, 6_000]), money), "long_term_care": st.one_of(st.just(0), st.integers(1, 2_000)), } @@ -315,6 +549,7 @@ def filer( s_corp=0, wages=0, plan=0, + plan_business="ALL", premiums=0, long_term_care=0, ): @@ -326,14 +561,16 @@ def filer( s_corp=s_corp, wages=wages, plan=plan, + plan_business=plan_business, premiums=premiums, long_term_care=long_term_care, ) def test_reviewer_example_and_boundaries(): - """Fixed filers: the $2,000 example, the $400 threshold, the new bases and - long-term care premiums.""" + """Fixed filers: the $2,000 example, the $400 threshold, the new bases, + long-term care premiums and line 9 with plans under two or three + businesses.""" filers = [ filer(schedule_c=2_000, premiums=6_000), filer(schedule_c=434, premiums=6_000), @@ -348,12 +585,36 @@ def test_reviewer_example_and_boundaries(): filer(partnership=2_000, premiums=6_000), # S corporation wages. filer(s_corp=5_000, premiums=6_000), - # A plan deduction as large as the Schedule C profit, with the farm as - # the plan business (property 5). - filer(schedule_c=20_000, plan=20_000, farm=50_000, premiums=100_000), + # A retirement plan under the Schedule C business only, with a plan + # deduction as large as its earned income: the farm keeps its own. + filer( + schedule_c=20_000, + plan=20_000, + plan_business="SCHEDULE_C", + farm=50_000, + premiums=100_000, + ), # Long-term care premiums under an S corporation plan: in 2022, line 3 # is 6,000 + min(2,000, 850) = 6,850, above line 11. filer(s_corp=6_500, premiums=6_000, long_term_care=2_000), + # Plans under two businesses, as in the YAML tests' Cases 26 and 27. + filer(schedule_c=50_000, partnership=10_000, plan=40_000, premiums=100_000), + filer(schedule_c=43_000, partnership=43_000, plan=60_000, premiums=100_000), + # Plans under all three businesses, as in Cases 29 and 30. + filer( + schedule_c=10_000, + farm=60_000, + partnership=10_000, + plan=60_000, + premiums=100_000, + ), + filer( + schedule_c=10_000, + farm=10_000, + partnership=60_000, + plan=60_000, + premiums=100_000, + ), ] situation, n = build_situation(filers) sim = Simulation(situation=situation) @@ -376,3 +637,124 @@ def test_reviewer_example_and_boundaries(): assert abs(deduction[10] - (50_000 - 50_000 * 0.9235 * 0.153 / 2)) < TOLERANCE # Line 11, 6,500, exceeds the 6,000 of medical premiums. assert abs(deduction[11] - 6_500) < TOLERANCE + # In 2026 the partnership's plan carries its whole earned income, + # 9,293.5225, of the 35,652.227 plan deduction, and the Schedule C + # business keeps 46,467.6125 - 26,358.7045 = 20,108.908. With two equal + # businesses, the other business's plan carries 39,962.14675 of + # 40,484.8587, and each business keeps 39,962.14675 - 522.71195 = + # 39,439.4348. + deduction = sim.calculate("self_employed_health_insurance_ald_person", 2026)[:n] + assert abs(deduction[12] - 20_108.908) < 0.02 + assert abs(deduction[13] - 39_439.4348) < 0.02 + # With plans under all three businesses, the two 10,000 businesses' + # plans carry 9,293.5225 each of the 39,369.636 plan deduction, and the + # 60,000 business keeps 55,761.135 - 20,782.591 = 34,978.544, the largest + # limit. + for i in (14, 15): + assert abs(deduction[i] - 34_978.544) < 0.02 + + +# Draws for the line 9 function: earned income of the business and of the +# other plans, the shared limits and room, and the plan deduction as a share +# of the largest total. +earned_income = st.one_of( + st.just(0.0), + st.floats(0, 40_000), + st.floats(0, 400_000), +) +plan_case = st.fixed_dictionaries( + { + "this": earned_income, + "other": earned_income, + "rate": st.sampled_from([0.25, 0.25, 0.15, 0.4, 1.0]), + "dollar_limit": st.one_of( + st.sampled_from([61_000.0, 69_000.0, 72_000.0]), st.floats(10_000, 72_000) + ), + "regular_room": st.one_of( + st.sampled_from([0.0, 20_500.0, 24_500.0]), st.floats(0, 24_500) + ), + "catch_up_room": st.one_of( + st.sampled_from([0.0, 7_500.0, 11_250.0]), st.floats(0, 11_250) + ), + "share": st.one_of(st.just(1.0), st.floats(0, 1), st.floats(0.9, 1)), + } +) + + +def plan_arrays(cases): + return {key: np.array([c[key] for c in cases]) for key in cases[0]} + + +@settings( + max_examples=60, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow], +) +@given(st.lists(plan_case, min_size=1, max_size=40)) +@example( + [ + # Case 26: the partnership's plan carries its whole earned income. + dict( + this=46_467.6125, + other=9_293.5225, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=0.0, + share=1.0, + ), + # Case 27: two equal businesses. + dict( + this=39_962.14675, + other=39_962.14675, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=0.0, + share=1.0, + ), + # A job's 401(k) used all the regular deferral room, so the plans + # take 2,000 of employer contributions and 8,000 of catch-up + # contributions. The other plan holds its 2,000 of earned income, and + # this one carries 8,000. + dict( + this=8_000.0, + other=2_000.0, + rate=0.25, + dollar_limit=72_000.0, + regular_room=0.0, + catch_up_room=8_000.0, + share=1.0, + ), + # This business has no earned income, so the other plans carry it + # all. + dict( + this=0.0, + other=50_000.0, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=8_000.0, + share=1.0, + ), + # No other business has earned income, so this plan carries it all. + dict( + this=50_000.0, + other=0.0, + rate=0.25, + dollar_limit=72_000.0, + regular_room=24_500.0, + catch_up_room=8_000.0, + share=1.0, + ), + ] +) +def test_line_9_is_the_least_share_the_plans_allow(cases): + c = plan_arrays(cases) + room = (c["rate"], c["dollar_limit"], c["regular_room"], c["catch_up_room"]) + largest = lp_largest_total(c["this"], c["other"], *room) + total = c["share"] * largest + least = lp_least_share(total, c["this"], c["other"], *room) + model = plan_deduction_attributable_to_business(total, c["other"]) + np.testing.assert_allclose(model, least, rtol=1e-9, atol=1e-6) 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..c67f0b42d9b --- /dev/null +++ b/policyengine_us/tests/unit/test_self_employed_pension_limit_properties.py @@ -0,0 +1,462 @@ +"""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 plan may be established under the Schedule C business, the Schedule F +farm or a partnership (K-1 box 14 code A), and step 1 is the net profit of the +plan's business (`self_employed_pension_plan_business`). By default every +profitable business has a plan, under one dollar limit and one elective +deferral limit, and their earned income adds up. + +The reference below runs 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). Schedule 1 line 15 is computed here from Schedule SE with the +published wage bases, and step 2 is the plan businesses' 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 +in which every filer appears once for each plan business. 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 the plan businesses' 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 the plan businesses' net profit, + and is at 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 self-employment profit, AGI is never + negative when no business has a loss. +4. Across plan businesses: the default's earned income is the sum of the + named businesses' earned income; its limit is at least each named + business's limit, so naming no business never lowers the deduction from + a plan under the Schedule C business alone, and at most their sum. + +A deterministic grid checks, at its points, that the limit rises with Schedule +C profit (it dips just past $433 of profit, where self-employment tax starts) +and with farm profit, and falls as deferrals to an employer's plan use up the +room. + +The model computes in float32, so comparisons allow a relative error of 1e-6 +on top of the $0.01 tolerance. +""" + +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 +# Model outputs are float32, whose spacing exceeds a cent above $131,072. +RTOL = 1e-6 +# Businesses under which the plan may be established; ALL is the default. +PLAN_BUSINESSES = ["ALL", "SCHEDULE_C", "SCHEDULE_F", "PARTNERSHIP"] +# Published amounts: IRS Notices 2021-61 (2022), 2024-80 (2025) and 2025-67 +# (2026); section 414(v)(2)(E) for ages 60 to 63 from 2025. +PUBLISHED = { + 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) + +# 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 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()} + # The partnership's earnings also enter gross income. + people[name]["partnership_income"] = every_year( + inputs.get("partnership_self_employment_net_earnings", 0) + ) + tax_units[f"t{i}"] = {"members": [name]} + households[f"h{i}"] = {"members": [name], "state_code": every_year("TX")} + return {"people": people, "tax_units": tax_units, "households": households} + + +def column(people_inputs, variable): + return np.array([p.get(variable, 0) for p in people_inputs], dtype=float) + + +def plan_business_profit(people_inputs): + """Step 1: the net profit of the businesses under which the plan is established.""" + profits = { + "SCHEDULE_C": np.maximum( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income"), + 0, + ), + "SCHEDULE_F": np.maximum(column(people_inputs, "farm_operations_income"), 0), + "PARTNERSHIP": np.maximum( + column(people_inputs, "partnership_self_employment_net_earnings"), 0 + ), + } + all_profits = sum(profits.values()) + plan_business = np.array([p["plan_business"] for p in people_inputs]) + plan_profit = np.select( + [plan_business == business for business in profits], + list(profits.values()), + all_profits, + ) + return plan_profit, all_profits + + +def reference(sim, people_inputs, year): + amounts = PUBLISHED[year] + age = column(people_inputs, "age") + plan_profit, all_profits = plan_business_profit(people_inputs) + share = np.divide( + plan_profit, + all_profits, + out=np.zeros_like(plan_profit), + where=all_profits > 0, + ) + # Schedule 1 line 15 from Schedule SE; step 2 is the plan businesses' + # share of it. + 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(plan_profit - step_2, 0) + # Section 402(g) room left by deferrals to an employer's plan, which use + # the regular limit first. + catch_up = catch_up_amount(year, age) + 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 plan_profit, line_15, step_3, limit, catch_up + + +def close(actual, expected, message): + np.testing.assert_allclose( + actual, expected, rtol=RTOL, atol=TOLERANCE, err_msg=message + ) + + +def at_most(actual, bound): + return np.all(actual <= bound + TOLERANCE + RTOL * np.abs(bound)) + + +def check(sim, people_inputs, years=YEARS): + desired = column(people_inputs, "self_employed_pension_contributions_desired") + self_employment_only = np.array([p["self_employment_only"] for p in people_inputs]) + no_losses = ( + ( + column(people_inputs, "self_employment_income") + + column(people_inputs, "sstb_self_employment_income") + >= 0 + ) + & (column(people_inputs, "farm_operations_income") >= 0) + & (column(people_inputs, "partnership_self_employment_net_earnings") >= 0) + ) + for year in years: + plan_profit, 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. 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. + close(deduction, np.minimum(desired, limit), message) + assert np.all(deduction >= 0), message + assert at_most(deduction, earned_income), message + assert at_most(earned_income, plan_profit), message + cap = PUBLISHED[year]["annual_additions"] + catch_up + assert at_most(limit, cap), message + # 3. AGI from self-employment profit alone. + agi = sim.calculate("adjusted_gross_income", year) + rows = self_employment_only & no_losses + assert np.all(agi[rows] >= -TOLERANCE), (message, agi[rows]) + + +def check_across_plan_businesses(sim, people_inputs): + """Property 4, for filers laid out by every_plan_business.""" + width = len(PLAN_BUSINESSES) + for year in YEARS: + message = f"year {year}" + earned, limits, deductions = ( + sim.calculate(variable, year).reshape(-1, width) + for variable in ( + "self_employed_pension_earned_income", + "self_employed_pension_contribution_limit", + "self_employed_pension_contribution_ald_person", + ) + ) + # Column 0 is the default (every profitable business), column 1 the + # Schedule C business alone. + close(earned[:, 0], earned[:, 1:].sum(axis=1), message) + assert at_most(limits[:, 1:].max(axis=1), limits[:, 0]), message + assert at_most(deductions[:, 1], deductions[:, 0]), message + assert at_most(limits[:, 0], limits[:, 1:].sum(axis=1)), message + + +profits = st.one_of( + st.sampled_from([0, 400, 433, 434, 2_000, 20_000, 30_000, 200_000, 500_000]), + st.integers(-20_000, 600_000), +) +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( + { + "self_employment_only": st.booleans(), + "age": st.sampled_from([30, 49, 50, 55, 59, 60, 63, 64, 70]), + "self_employment_income": profits, + "sstb_self_employment_income": st.one_of(st.just(0), profits), + "farm_operations_income": smaller, + "partnership_self_employment_net_earnings": st.one_of(st.just(0), smaller), + "employment_income": st.one_of(st.just(0), st.integers(0, 250_000)), + "traditional_401k_contributions_desired": st.one_of( + st.just(0), st.integers(0, 40_000) + ), + "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["self_employment_only"]: + for variable in ( + "employment_income", + "traditional_401k_contributions_desired", + "roth_401k_contributions_desired", + ): + person[variable] = 0 + return person + + +def every_plan_business(people_inputs): + """Each filer once for each plan business, in the order of PLAN_BUSINESSES.""" + return [ + dict(person, plan_business=business) + for person in people_inputs + for business in PLAN_BUSINESSES + ] + + +def simulation_inputs(people_inputs): + return [ + { + **{ + k: float(v) + for k, v in p.items() + if k not in ("self_employment_only", "plan_business") + }, + "self_employed_pension_plan_business": p["plan_business"], + } + 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=2, max_size=8)) +def test_limit_matches_the_pub_560_worksheet(people_inputs): + people_inputs = every_plan_business(people_inputs) + sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) + check(sim, people_inputs) + check_across_plan_businesses(sim, people_inputs) + + +GRID_PROFITS = [ + -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 = [ + { + "self_employment_only": False, + "plan_business": "ALL", + "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, [year]) + 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, + ) + + +GRID_FARM_PROFITS = [0, 1_000, 5_000, 20_000, 60_000, 200_000, 600_000] + + +@pytest.mark.parametrize("year", YEARS) +def test_limit_rises_with_farm_profit(year): + """A profitable farm adds to the default plan's earned income.""" + people_inputs = [ + { + "self_employment_only": True, + "plan_business": "ALL", + "age": 45, + "self_employment_income": 20_000, + "farm_operations_income": farm, + "self_employed_pension_contributions_desired": 1_000_000, + } + for farm in GRID_FARM_PROFITS + ] + sim = Simulation(situation=build_situation(simulation_inputs(people_inputs))) + check(sim, people_inputs, [year]) + earned_income = sim.calculate("self_employed_pension_earned_income", year) + limit = sim.calculate("self_employed_pension_contribution_limit", year) + assert np.all(np.diff(earned_income) > 0) + assert np.all(np.diff(limit) >= -TOLERANCE) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py index 4fc27185a47..89663b90238 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/self_employed_health_insurance_earned_income_limit.py @@ -18,9 +18,22 @@ class self_employed_health_insurance_earned_income_limit(Variable): "is net profit less the part of the deductible part of " "self-employment tax attributable to that business (lines 4 to 8) and " "less the self-employed SEP, SIMPLE and qualified plan deduction " - "attributable to it (lines 9 and 10); the model bases that plan " - "deduction on Schedule C income, so only the Schedule C business " - "subtracts it. Each kind of business is one total per person: all " + "attributable to it (lines 9 and 10). With the retirement plan under " + "one named business (self_employed_pension_plan_business), all of " + "that plan deduction is attributable to that business. By default " + "each business with a net profit has a retirement plan, and the person " + "chooses how much to contribute to each. Form 7206 gives no rule for " + "that split, so the model takes the split that leaves the most earned " + "income for the health insurance deduction: the plans under the other " + "businesses carry as much of the plan deduction as they can. As in the " + "plan limit, the model treats all of the plans as one employer's, so " + "the worksheet limits apply to the plans together and each plan can " + "carry up to its own business's earned income. The plan deduction " + "attributable to a business is then the part of it above the other " + "businesses' earned income. A partnership that the person doesn't " + "control is a separate employer whose plan has limits of its own, and " + "for its partners line 9 can exceed the model's. Each kind of " + "business is one total per person: all " "Schedule C income (SSTB and non-SSTB), all Schedule F profit, all " "K-1 box 14 code A earnings and all more-than-2% shareholder wages, " "so a loss in one business of a kind reduces line 4. Schedule 1 line " @@ -40,6 +53,18 @@ class self_employed_health_insurance_earned_income_limit(Variable): # Subsection (l)(2)(A), and (l)(5) for S corporation shareholders. "https://www.law.cornell.edu/uscode/text/26/162#l", "https://www.law.cornell.edu/uscode/text/26/401#c_2_A", + # Subsections (a)(3)(A)(iv), (h)(2) and (a)(8)(C): one employer's + # profit-sharing trusts count as one trust, a SEP's deduction counts + # against their limit, and each plan's contributions stay within its + # own business's earned income. + "https://www.law.cornell.edu/uscode/text/26/404", + # Subsection (f)(1)(B): one employer's defined contribution plans + # count as one plan. + "https://www.law.cornell.edu/uscode/text/26/415#f_1_B", + # PDF pages 7, 10: a sole proprietor is their own employer and a + # partnership is each partner's, and the plans one employer maintains + # count together. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", "https://www.irs.gov/pub/irs-prior/f7206--2023.pdf#page=1", # Where a plan may be established, and one Form 7206 per plan. # PDF pages 1-2 @@ -54,6 +79,26 @@ def formula(person, period, parameters): return form_7206_line_13(person, period) +def plan_deduction_attributable_to_business(plan_deduction, other_earned_income): + """Least part of the plan deduction that one business's plan must carry. + + Every business with a net profit has a retirement plan, the person + chooses how much to contribute to each, and the plans under the other + businesses have other_earned_income in all. The model treats the plans + as one employer's, which a sole proprietor is for all of their own + businesses. One employer's defined contribution plans count as one plan + for the section 415(c) limits (415(f)(1)(B)), and its profit-sharing + trusts as one trust for the section 404(a)(3) limit (404(a)(3)(A)(iv)), + against which a SEP's deduction also counts (404(h)(2)), so the Pub. 560 + worksheet limits apply to the plans together. The only + limit on each plan alone is its own business's earned income before the + plan deduction (404(a)(8)(C)). So the other plans can carry the plan + deduction up to their earned income, and this business's plan carries + the rest (Form 7206 line 9). + """ + return max_(0, plan_deduction - other_earned_income) + + def form_7206_line_13(person, period, other_line_5_profits=0): """Form 7206 line 13 for the plan business. @@ -87,25 +132,61 @@ def attributable_self_employment_tax_deduction(profit): ) return self_employment_tax_deduction * share - # Lines 9 and 10: the SEP, SIMPLE and qualified plan deduction - # (Schedule 1 line 16) is attributable to the Schedule C business. + # Line 8 for each self-employment business. + schedule_c_line_8 = schedule_c_profit - attributable_self_employment_tax_deduction( + schedule_c_profit + ) + farm_line_8 = farm_profit - attributable_self_employment_tax_deduction(farm_profit) + partnership_line_8 = partnership_profit - ( + attributable_self_employment_tax_deduction(partnership_profit) + ) + # Line 9: the SEP, SIMPLE and qualified plan deduction (Schedule 1 line + # 16) attributable to each business. retirement_plan_deduction = person( "self_employed_pension_contribution_ald_person", period ) + retirement_plan_business = person("self_employed_pension_plan_business", period) + retirement_plan_businesses = retirement_plan_business.possible_values + + def line_9(other_businesses_line_8, business): + # A retirement plan under one named business: all of line 16. + named = where( + retirement_plan_business == business, retirement_plan_deduction, 0 + ) + # Plans under every business with a net profit, whose line 8 amounts + # are their earned income (Pub. 560 Deduction Worksheet step 3): the + # least that this business's plan must carry. + least = plan_deduction_attributable_to_business( + retirement_plan_deduction, other_businesses_line_8 + ) + return where( + retirement_plan_business == retirement_plan_businesses.ALL, least, named + ) + # Line 10 for each self-employment business. schedule_c = max_( 0, - schedule_c_profit - - attributable_self_employment_tax_deduction(schedule_c_profit) - - retirement_plan_deduction, + schedule_c_line_8 + - line_9( + farm_line_8 + partnership_line_8, + retirement_plan_businesses.SCHEDULE_C, + ), ) farm = max_( - 0, farm_profit - attributable_self_employment_tax_deduction(farm_profit) + 0, + farm_line_8 + - line_9( + schedule_c_line_8 + partnership_line_8, + retirement_plan_businesses.SCHEDULE_F, + ), ) partnership = max_( 0, - partnership_profit - - attributable_self_employment_tax_deduction(partnership_profit), + partnership_line_8 + - line_9( + schedule_c_line_8 + farm_line_8, + retirement_plan_businesses.PARTNERSHIP, + ), ) # Line 11: an S corporation shareholder's wages from the corporation. s_corporation = max_(0, person("s_corp_shareholder_medicare_wages", period)) diff --git a/policyengine_us/variables/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..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 @@ -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-prior/p560--2025.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..68845f7b3f9 100644 --- a/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_contribution_limit.py @@ -4,18 +4,103 @@ 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 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 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. Earned income " + "comes from every business under which a plan is established " + "(self_employed_pension_earned_income). A sole proprietor is the " + "employer for all of their own businesses, and one employer's plans " + "count as one plan for these limits, so the worksheet applies to the " + "plans' combined contributions and earned income. A partnership is a " + "separate employer unless the person controls it, and its plan then " + "has limits of its own. The model treats all of the plans as one " + "employer's. For this limit that matters only when the dollar limit " + "binds, since the elective deferral limits cover all of a person's " + "employers and the other limits grow in step with each business's " + "earned income. Defined benefit plans are not modeled, so " + "contributions to them above this limit are cut." ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/415#c" + reference = ( + # Subsections (a)(3)(A)(i)(I) and (iv), (a)(8)(C) and (D), (a)(10), + # (h)(1)(C) and (2), and (n). + "https://www.law.cornell.edu/uscode/text/26/404", + # Subsections (a)(2), (c)(1), (c)(3)(B) and (D), (f)(1)(B) and (h): + # section 415 aggregates the plans of one employer. + "https://www.law.cornell.edu/uscode/text/26/415", + # Subsection (c), under which employers under common control are one + # employer, and (v)(2)(A) and (v)(3)(A), on catch-up contributions. + "https://www.law.cornell.edu/uscode/text/26/414", + # PDF pages 7, 10, 15, 39: the employer and compensation of a + # self-employed person, the plans one employer maintains counting + # together, SIMPLE IRA compensation, and the worksheet. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", + "https://www.irs.gov/pub/irs-prior/p560--2022.pdf#page=34", + ) def formula(person, period, parameters): - p = parameters(period).gov.irs.gross_income.retirement_contributions - 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 + # employer contribution at the plan rate times the section + # 401(a)(17) compensation limit. At current law that never binds the + # result, because 25% of the compensation limit is 1.25 times the + # step 8 dollar limit, so the model leaves it out; a reform that set + # the rate times that limit below the dollar limit would need it. + rate = p.profit_sharing_deduction_rate + employer_limit = earned_income * rate / (1 + rate) + # Step 9: elective deferrals, up to the section 402(g) room left + # after deferrals to other employers' plans, which use the regular + # limit before the catch-up amount. + other_deferrals = add( + person, + period, + [ + "traditional_401k_contributions", + "roth_401k_contributions", + "traditional_403b_contributions", + "roth_403b_contributions", + ], + ) + deferral_room = max_( + 0, person("elective_deferral_limit", period) - other_deferrals + ) + catch_up_room = min_(deferral_room, person("k401_catch_up_limit", period)) + regular_deferral_room = deferral_room - catch_up_room + # Steps 10 to 15: employer contributions stay within step 7 and + # within half of what is left of earned income after the regular + # deferrals (step 12), because compensation also excludes the + # employer contribution. With the regular deferrals, they stay + # within the dollar limit (step 8). + annual_additions = min_( + min_(p.annual_additions, employer_limit + regular_deferral_room), + (earned_income + regular_deferral_room) / 2, + ) + # Steps 16 to 19: catch-up contributions, outside the dollar limit. + # The total never exceeds earned income. + return min_(earned_income, annual_additions + catch_up_room) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_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..cc90aea1f6e --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_earned_income.py @@ -0,0 +1,84 @@ +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 or businesses under which the self-employed SEP, SIMPLE or " + "qualified plan is established, determined without regard to the plan " + "deduction itself: net profit less the deductible part of " + "self-employment tax (Pub. 560 Deduction Worksheet for Self-Employed, " + "steps 1 to 3). The plan may be established under the Schedule C " + "business, the Schedule F farm or a partnership that reports net " + "earnings on Schedule K-1 box 14 code A " + "(self_employed_pension_plan_business). By default every one of these " + "businesses with a net profit has a plan, and their earned income is " + "added up. The worksheet's step 2 enters all of Schedule 1 line 15; " + "the model splits it by each business's share of the net profits, as " + "on Form 7206 lines 5 to 8, so with every profitable business in the " + "plan step 2 is all of 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", + # 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", + ) + + def formula(person, period, parameters): + 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 businesses under which the plan is established. + + 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 businesses take only their share. + """ + # Step 1: net profit of each business that can establish a plan, + # leaving out net losses. + schedule_c_profit = max_(0, person("total_self_employment_income", period)) + farm_profit = max_(0, person("farm_operations_income", period)) + partnership_profit = max_( + 0, person("partnership_self_employment_net_earnings", period) + ) + plan_business = person("self_employed_pension_plan_business", period) + businesses = plan_business.possible_values + # ALL, the default, puts a plan under every one of them. + plan_business_profit = select( + [ + plan_business == businesses.SCHEDULE_C, + plan_business == businesses.SCHEDULE_F, + plan_business == businesses.PARTNERSHIP, + ], + [schedule_c_profit, farm_profit, partnership_profit], + default=schedule_c_profit + farm_profit + partnership_profit, + ) + # Net profits of every business whose earnings are subject to + # self-employment tax. + all_business_profits = ( + schedule_c_profit + farm_profit + partnership_profit + 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 businesses. + 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) diff --git a/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py new file mode 100644 index 00000000000..4347357bdfb --- /dev/null +++ b/policyengine_us/variables/household/expense/retirement/self_employed_pension_plan_business.py @@ -0,0 +1,44 @@ +from policyengine_us.model_api import * + + +class SelfEmployedPensionPlanBusiness(Enum): + SCHEDULE_C = "Schedule C business" + SCHEDULE_F = "Schedule F farm" + PARTNERSHIP = "Partnership" + ALL = "All self-employment businesses" + + +class self_employed_pension_plan_business(Variable): + value_type = Enum + entity = Person + possible_values = SelfEmployedPensionPlanBusiness + default_value = SelfEmployedPensionPlanBusiness.ALL + definition_period = YEAR + label = "Business under which the self-employed retirement plan is established" + documentation = ( + "The trade or business under which the person's SEP, SIMPLE or " + "qualified plan (Schedule 1 line 16) is established: the Schedule C " + "business, the Schedule F farm, or a partnership in which the person " + "has Schedule K-1 box 14 code A net earnings. The plan deduction is " + "limited to earned income from the plan's business. ALL (the default) " + "means every one of these businesses with a net profit has a plan, " + "which gives the largest limit under one dollar limit; a business " + "with a net loss is left out. The person then chooses how much to " + "contribute to each plan, and Form 7206 line 9 for the business under " + "which the health insurance plan is established takes the least that " + "this business's plan must carry: the part of the plan deduction " + "above the other businesses' earned income. A named business limits " + "the deduction to that business's earned income, as when the person " + "has more than one business but only one has a plan. The input " + "cannot name two of the three businesses." + ) + reference = ( + "https://www.law.cornell.edu/uscode/text/26/404#a_8_C", + "https://www.law.cornell.edu/uscode/text/26/401#c_4", + # PDF pages 7, 39: with more than one business but only one plan, + # only that business's earned income counts; a sole proprietor is + # their own employer, and a partnership is each partner's employer; + # Deduction Worksheet step 1 takes Schedule C, Schedule F or K-1 box + # 14 code A. + "https://www.irs.gov/pub/irs-prior/p560--2025.pdf#page=7", + ) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index fbd780acf24..6e64e640d8d 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -12114,6 +12114,48 @@ candidates: Credit Limit Worksheet B (ctc_credit_limit_worksheet_b_applies.py). The legacy aggregates still count as an exclusion. The EITC keeps the broader section 32(c)(1)(C) test in elects_section_911_exclusion. Register the entry and add the marker once #10297 is on main. +- 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-prior/p560--2025.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: @@ -12688,6 +12730,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-prior/p560--2025.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: