From ea8dbe55c35dc644728071fc53877468b70727e6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:47:30 -0400 Subject: [PATCH 1/5] Fix Wisconsin retirement election reporting and capital gain distributions --- changelog.d/hub-pb-wi-1009.fixed.md | 1 + ...etirement_income_exclusion_refundable.yaml | 20 ++ .../wi_capital_gain_loss_subtration.yaml | 38 +++ .../states/wi/tax/income/wi_income_tax.yaml | 140 ++++++++++ ..._income_tax_before_refundable_credits.yaml | 22 +- .../wi_retirement_income_exclusion.yaml | 6 +- ...i_retirement_income_exclusion_elected.yaml | 249 +++++++++++++++++ .../wi_retirement_income_exclusion_tax.yaml | 11 +- .../tests/unit/test_wi_retirement_election.py | 256 ++++++++++++++++++ .../income/credits/wi_refundable_credits.py | 10 +- .../wi_capital_gain_loss_subtraction.py | 9 +- .../wi_retirement_income_exclusion_amount.py | 6 +- .../wi_retirement_income_exclusion_elected.py | 40 +++ ...tirement_income_exclusion_line17_offset.py | 54 ++++ .../gov/states/wi/tax/income/wi_income_tax.py | 9 +- ...wi_income_tax_before_refundable_credits.py | 5 +- .../wi_retirement_income_exclusion_tax.py | 16 +- 17 files changed, 855 insertions(+), 37 deletions(-) create mode 100644 changelog.d/hub-pb-wi-1009.fixed.md create mode 100644 policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml create mode 100644 policyengine_us/tests/unit/test_wi_retirement_election.py create mode 100644 policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py create mode 100644 policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py diff --git a/changelog.d/hub-pb-wi-1009.fixed.md b/changelog.d/hub-pb-wi-1009.fixed.md new file mode 100644 index 00000000000..55a423eabc7 --- /dev/null +++ b/changelog.d/hub-pb-wi-1009.fixed.md @@ -0,0 +1 @@ +Correct Wisconsin retirement exclusion election reporting, prevent overlapping retirement subtractions, and include directly reported capital gain distributions in the capital gain subtraction. diff --git a/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml b/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml new file mode 100644 index 00000000000..baeccb64c31 --- /dev/null +++ b/policyengine_us/parameters/gov/states/wi/tax/income/credits/retirement_income_exclusion_refundable.yaml @@ -0,0 +1,20 @@ +description: Wisconsin allows these refundable credits when the retirement income exclusion is elected. +metadata: + unit: list + period: year + label: Wisconsin refundable credits allowed with the retirement income exclusion + reference: + - title: 2025 Wisconsin Schedule SB instructions + href: https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7 + - title: 2025 Wisconsin Act 174, section 7 + href: https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2 + - title: Wis. Stat. 71.05(6)(b)54m.d + href: https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d + - title: Wis. Stat. 71.54 + href: https://docs.legis.wisconsin.gov/statutes/statutes/71/viii/54 +values: + 2021-01-01: [] + # Act 174, effective April 4, 2026, limits the prohibition to section 71.07 + # credits. Homestead is under section 71.54; earned income is under 71.07. + 2026-01-01: + - wi_homestead_credit diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml index 11635a6391e..13e882d98cc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_capital_gain_loss_subtration.yaml @@ -27,3 +27,41 @@ state_code: WI output: wi_capital_gain_loss_subtraction: 0 + +- name: Directly reported capital gain distributions receive the long term subtraction + absolute_error_margin: 0.01 + period: 2025 + input: + non_sch_d_capital_gains: 1000 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 300 + +- name: Capital gain distribution exclusion is limited by net gain after short term losses + absolute_error_margin: 0.01 + period: 2026 + input: + non_sch_d_capital_gains: 1000 + short_term_capital_gains: -800 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 60 + +- name: Capital gain distributions do not create a subtraction for a net capital loss + absolute_error_margin: 0.01 + period: 2026 + input: + non_sch_d_capital_gains: 1000 + short_term_capital_gains: -1200 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 0 + +- name: Direct capital gain distribution treatment also applies before Act 15 + absolute_error_margin: 0.01 + period: 2024 + input: + non_sch_d_capital_gains: 1000 + state_code: WI + output: + wi_capital_gain_loss_subtraction: 300 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml index 1a64fe83ddc..f74c52844b1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml @@ -26,3 +26,143 @@ # Both variables must agree (#7805). wi_income_tax: 6_636.85 state_income_tax: 6_636.85 + +# Household inputs match the PolicyBench scenario conversion, including +# takeup defaults. Filing status is inferred from the supplied person facts. +- name: PolicyBench scenario 042 elects the retirement exclusion + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 79 + employment_income: 0 + bank_account_assets: 152100 + farm_income: -1127.2940673828125 + farm_operations_income: -1127.2940673828125 + is_surviving_spouse: true + is_tax_unit_head: true + is_tax_unit_spouse: false + non_qualified_dividend_income: 5920 + non_sch_d_capital_gains: 3753 + other_medical_expenses: 250 + over_the_counter_health_expenses: 100 + qualified_bdc_income: 70.93914031982422 + qualified_dividend_income: 928 + real_estate_taxes: 5010.88232421875 + social_security_dependents: 29580 + stock_assets: 670000 + tax_exempt_ira_distributions: 4700 + taxable_interest_income: 175 + taxable_ira_distributions: 19200 + taxable_private_pension_income: 4886 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit: + members: [head] + tax_units: + tax_unit: + members: [head] + first_home_mortgage_balance: 1000 + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + state_code: WI + household_vehicles_value: 8370 + output: + state_income_tax_before_refundable_credits: 0 + wi_capital_gain_loss_subtraction: 1125.90 + state_refundable_credits: 0 + state_income_tax: 0 + +- name: PolicyBench scenario 091 subtracts directly reported capital gain distributions + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 22 + employment_income: 27000 + attends_eligible_educational_institution_for_american_opportunity_credit: true + bank_account_assets: 558700 + employer_sponsored_insurance_premiums: 6589.27587890625 + fsla_overtime_premium: 627.906982421875 + has_american_opportunity_credit_1098_t_or_exception: true + has_american_opportunity_credit_institution_ein: true + has_esi: true + health_insurance_premiums_without_medicare_part_b: 1800 + hourly_wage: 13 + hours_worked_last_week: 42 + weekly_hours_worked_before_lsr: 42 + is_enrolled_at_least_half_time_for_american_opportunity_credit: true + is_paid_hourly: true + is_pursuing_credential_for_american_opportunity_credit: true + is_tax_unit_head: true + is_tax_unit_spouse: false + non_qualified_dividend_income: 208 + non_sch_d_capital_gains: 1170 + other_health_insurance_premiums: 1800 + over_the_counter_health_expenses: 25 + qualified_dividend_income: 3968 + roth_401k_contributions_desired: 163.44000244140625 + roth_ira_contributions_desired: 67.12319946289062 + stock_assets: 718100 + taxable_interest_income: 4806 + traditional_401k_contributions_desired: 926.1599731445312 + traditional_ira_contributions_desired: 43.276798248291016 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + marital_units: + marital_unit: + members: [head] + tax_units: + tax_unit: + members: [head] + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: [head] + takes_up_snap_if_eligible: true + families: + family: + members: [head] + households: + household: + members: [head] + state_code: WI + output: + state_income_tax_before_refundable_credits: 878.52 + wi_capital_gain_loss_subtraction: 351 + state_refundable_credits: 0 + state_income_tax: 878.52 + +- name: Minimal retirement election is reflected before refundable credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 24000 + state_income_tax_before_refundable_credits: 0 + state_refundable_credits: 0 + state_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml index 2de67b8e595..6d654f20358 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.yaml @@ -19,29 +19,34 @@ wi_income_tax_before_refundable_credits: 0 - name: WI income tax before refundable credits with retirement exclusion - # This intermediate remains the standard pre-refundable-credit tax, - # even when the exclusion path wins in wi_income_tax. - # standard_before_refundable = max(0, 5000 - 500) = 4500 + # The elected path must be reported before refundable credits as well. absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 5_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 3_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: - wi_income_tax_before_refundable_credits: 4_500 + wi_income_tax_before_refundable_credits: 3_000 + wi_refundable_credits: 0 - name: WI income tax before refundable credits - standard path lower # standard_before_refundable = max(0, 2000 - 500) = 1500 absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 2_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 5_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: wi_income_tax_before_refundable_credits: 1_500 @@ -50,10 +55,13 @@ absolute_error_margin: 0.01 period: 2025 input: + age: 68 + taxable_private_pension_income: 30_000 + is_tax_unit_head: true wi_income_tax_before_credits: 5_000 wi_non_refundable_credits: 500 wi_retirement_income_exclusion_tax: 3_000 - wi_refundable_credits: 200 + wi_earned_income_credit: 200 state_code: WI output: wi_income_tax: 3_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml index 02452cb7d60..a182704c0ac 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion.yaml @@ -14,7 +14,7 @@ wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 wi_retirement_income_exclusion_tax: 70 - wi_refundable_credits: 0 + wi_earned_income_credit: 0 wi_retirement_income_subtraction: 0 output: wi_income_tax: 70 @@ -35,7 +35,7 @@ wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 wi_retirement_income_exclusion_tax: 280 - wi_refundable_credits: 300 + wi_earned_income_credit: 300 wi_retirement_income_subtraction: 0 output: wi_income_tax: 50 @@ -52,7 +52,7 @@ wi_taxable_income: 10_000 wi_income_tax_before_credits: 350 wi_non_refundable_credits: 0 - wi_refundable_credits: 0 + wi_earned_income_credit: 0 wi_retirement_income_subtraction: 0 output: wi_income_tax: 350 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml new file mode 100644 index 00000000000..a846c22d7d2 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_elected.yaml @@ -0,0 +1,249 @@ +- name: Retirement election retains the standard path on a tax tie + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + wi_income_tax_before_credits: 100 + wi_non_refundable_credits: 0 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 0 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: Refundable credits can make the standard retirement path preferable + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + wi_income_tax_before_credits: 100 + wi_non_refundable_credits: 0 + wi_retirement_income_exclusion_tax: 0 + wi_earned_income_credit: 200 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 200 + state_income_tax: -100 + +- name: Electing retirement exclusion forfeits otherwise available section 71.07 credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 200 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 300 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: Retirement election is unavailable before the 2025 effective date + absolute_error_margin: 0.01 + period: 2024 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 200 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 300 + wi_homestead_credit: 0 + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + state_income_tax_before_refundable_credits: 800 + state_refundable_credits: 300 + state_income_tax: 500 + +- name: The 2025 retirement election forfeits homestead and earned income credits + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 100 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 25 + wi_homestead_credit: 200 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 0 + state_income_tax: 100 + +- name: The 2026 retirement election retains homestead but forfeits earned income credit + absolute_error_margin: 0.01 + period: 2026 + input: + age: 68 + taxable_private_pension_income: 30000 + is_tax_unit_head: true + wi_income_tax_before_credits: 1000 + wi_non_refundable_credits: 100 + wi_retirement_income_exclusion_tax: 100 + wi_earned_income_credit: 25 + wi_homestead_credit: 200 + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + state_income_tax_before_refundable_credits: 100 + state_refundable_credits: 200 + state_income_tax: -100 + +- name: Single retirement exclusion cannot subtract the same pension on lines 16 and 17 + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Pooled joint exclusion preserves the maximum remaining line 17 subtraction + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 45000 + farm_operations_income: -30000 + is_tax_unit_head: true + spouse: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + adjusted_gross_income: 23000 + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 48000 + # $5,000 remains after line 16; all of it can be retained on line 17. + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Unequal spouse pensions preserve both line 17 amounts under joint pooling + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 53000 + farm_operations_income: -35000 + is_tax_unit_head: true + spouse: + age: 68 + taxable_private_pension_income: 5000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + adjusted_gross_income: 23000 + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 48000 + # Allocate line 16 to the head's income above the two line 17 amounts. + wi_retirement_income_exclusion_line17_offset: 0 + +- name: Younger spouse income does not enter the retirement exclusion pool + absolute_error_margin: 0.01 + period: 2025 + input: + people: + head: + age: 68 + taxable_private_pension_income: 8000 + is_tax_unit_head: true + spouse: + age: 66 + taxable_private_pension_income: 8000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: WI + output: + wi_retirement_income_subtraction: 10000 + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: IRA distributions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_ira_distributions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: 401k distributions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_401k_distributions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 + +- name: Roth conversions cannot be subtracted on both retirement lines + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_roth_conversions: 8000 + is_tax_unit_head: true + state_code: WI + output: + wi_retirement_income_exclusion_amount: 8000 + wi_retirement_income_subtraction: 5000 + wi_retirement_income_exclusion_line17_offset: 5000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml index 25e2b9b9d1c..2ec7fa9cf4d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.yaml @@ -57,12 +57,11 @@ wi_taxable_income: 10_000 wi_retirement_income_subtraction: 3_000 output: - # Line 17 ($3k) is already subtracted in wi_taxable_income. - # Per the Line 17 worksheet step 4, Line 17 self-adjusts when - # Line 16 is claimed. We simply subtract Line 16: - # exclusion_taxinc = max(0, 10000 - 8000) = 2000 - # exclusion_tax = 2000 * 0.035 = 70 - wi_retirement_income_exclusion_tax: 70 + # Line 17 ($3k) is already subtracted in wi_taxable_income, but all of + # this pension is subtracted on Line 16. Add back the overlapping $3k: + # exclusion_taxinc = max(0, 10000 - 8000 + 3000) = 5000. + wi_retirement_income_exclusion_line17_offset: 3_000 + wi_retirement_income_exclusion_tax: 175 - name: Joint filing - both spouses eligible absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/unit/test_wi_retirement_election.py b/policyengine_us/tests/unit/test_wi_retirement_election.py new file mode 100644 index 00000000000..35a21de7de9 --- /dev/null +++ b/policyengine_us/tests/unit/test_wi_retirement_election.py @@ -0,0 +1,256 @@ +"""Check accounting and reporting invariants beyond individual YAML amounts. + +One shared vectorized simulation and two scalar controls verify independence +between returns. YAML cases cannot compare vectorized and separate executions +or express equality between alternative reporting routes across input rows. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + + +def _situation(cases, year): + situation = { + entity: {} + for entity in ( + "people", + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ) + } + for index, case in enumerate(cases): + members = [] + for person_index, person in enumerate(case["people"]): + name = f"person_{index}_{person_index}" + members.append(name) + situation["people"][name] = { + variable: {str(year): value} + for variable, value in { + **person, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + }.items() + } + unit_name = f"unit_{index}" + for entity in ("spm_units", "families", "marital_units"): + situation[entity][unit_name] = {"members": members} + situation["tax_units"][unit_name] = { + "members": members, + "filing_status": {str(year): "JOINT" if len(members) == 2 else "SINGLE"}, + **{ + variable: {str(year): value} + for variable, value in case.get("tax", {}).items() + }, + } + situation["households"][unit_name] = { + "members": members, + "state_code": {str(year): "WI"}, + } + return situation + + +RETIREMENT_CASES = [ + {"people": [{"age": 68, "taxable_private_pension_income": 8_000}]}, + {"people": [{"age": 68, "taxable_private_pension_income": 30_000}]}, + {"people": [{"age": 66, "taxable_private_pension_income": 30_000}]}, + { + "people": [ + { + "age": 68, + "taxable_private_pension_income": 45_000, + "farm_operations_income": -30_000, + }, + {"age": 68, "taxable_private_pension_income": 8_000}, + ] + }, + { + "people": [ + { + "age": 68, + "taxable_private_pension_income": 53_000, + "farm_operations_income": -35_000, + }, + {"age": 68, "taxable_private_pension_income": 5_000}, + ] + }, + { + "people": [ + {"age": 68, "taxable_private_pension_income": 8_000}, + {"age": 66, "taxable_private_pension_income": 8_000}, + ] + }, + { + "people": [{"age": 68, "taxable_private_pension_income": 30_000}], + "tax": { + "wi_income_tax_before_credits": 1_000, + "wi_non_refundable_credits": 100, + "wi_retirement_income_exclusion_tax": 100, + "wi_earned_income_credit": 25, + "wi_homestead_credit": 200, + }, + }, + {"people": [{"age": 68, "taxable_ira_distributions": 8_000}]}, + {"people": [{"age": 68, "taxable_401k_distributions": 8_000}]}, + {"people": [{"age": 68, "taxable_roth_conversions": 8_000}]}, +] + + +CAPITAL_AMOUNTS = [ + (0, 1_000, 0), + (0, 1_000, -800), + (0, 1_000, -1_200), + (2_000, 1_000, -800), + (-1_000, 1_500, -800), + (-2_000, 1_000, 3_000), +] + + +def _capital_gain_cases(): + direct_cases = [] + schedule_d_cases = [] + for long_term, distribution, short_term in CAPITAL_AMOUNTS: + common = { + "age": 40, + "employment_income": 40_000, + "short_term_capital_gains": short_term, + } + direct_cases.append( + { + "people": [ + { + **common, + "long_term_capital_gains": long_term, + "non_sch_d_capital_gains": distribution, + } + ] + } + ) + schedule_d_cases.append( + { + "people": [ + { + **common, + "long_term_capital_gains": long_term + distribution, + } + ] + } + ) + return direct_cases, schedule_d_cases + + +@pytest.fixture(scope="module") +def wi_simulation(): + direct_cases, schedule_d_cases = _capital_gain_cases() + return Simulation( + situation=_situation(RETIREMENT_CASES + direct_cases + schedule_d_cases, 2026) + ) + + +def test_retirement_election_vectorized_matches_separate_returns(wi_simulation): + simulation = wi_simulation + year = 2026 + retirement_slice = slice(0, len(RETIREMENT_CASES)) + variables = ( + "wi_retirement_income_exclusion_amount", + "wi_retirement_income_exclusion_line17_offset", + "wi_retirement_income_exclusion_tax", + "wi_retirement_income_exclusion_elected", + "state_income_tax_before_refundable_credits", + "state_refundable_credits", + "state_income_tax", + ) + vectorized = { + variable: simulation.calculate(variable, year)[retirement_slice] + for variable in variables + } + # Compare an unequal-pension joint return and a return retaining homestead. + # The remaining rows exercise their vectorized accounting identities below. + for index in (4, 6): + case = RETIREMENT_CASES[index] + separate = Simulation(situation=_situation([case], year)) + for variable in variables: + assert vectorized[variable][index] == pytest.approx( + separate.calculate(variable, year)[0], abs=0.01 + ) + + before = vectorized["state_income_tax_before_refundable_credits"] + refundable = vectorized["state_refundable_credits"] + net = vectorized["state_income_tax"] + elected = vectorized["wi_retirement_income_exclusion_elected"] + standard_before = np.maximum( + 0, + simulation.calculate("wi_income_tax_before_credits", year) + - simulation.calculate("wi_non_refundable_credits", year), + )[retirement_slice] + earned_income_credit = simulation.calculate("wi_earned_income_credit", year)[ + retirement_slice + ] + homestead_credit = simulation.calculate("wi_homestead_credit", year)[ + retirement_slice + ] + standard_net = standard_before - earned_income_credit - homestead_credit + + np.testing.assert_allclose(net, before - refundable, atol=0.01) + assert np.all(net <= standard_net + 0.01) + assert np.any(elected) + assert np.any(~elected) + np.testing.assert_allclose( + refundable[elected], homestead_credit[elected], atol=0.01 + ) + np.testing.assert_allclose( + before[elected], + vectorized["wi_retirement_income_exclusion_tax"][elected], + atol=0.01, + ) + np.testing.assert_allclose(before[~elected], standard_before[~elected], atol=0.01) + + offset = vectorized["wi_retirement_income_exclusion_line17_offset"] + line17 = simulation.calculate("wi_retirement_income_subtraction", year)[ + retirement_slice + ] + assert np.all(offset >= 0) + assert np.all(offset <= line17) + assert np.all(offset <= vectorized["wi_retirement_income_exclusion_amount"]) + + +def test_capital_gain_distribution_reporting_route_and_loss_bounds(wi_simulation): + count = len(CAPITAL_AMOUNTS) + start = len(RETIREMENT_CASES) + direct_slice = slice(start, start + count) + schedule_d_slice = slice(start + count, start + 2 * count) + subtraction = wi_simulation.calculate("wi_capital_gain_loss_subtraction", 2026) + np.testing.assert_allclose( + subtraction[direct_slice], subtraction[schedule_d_slice], atol=0.01 + ) + + # Compare downstream tax when the original Schedule D has no net loss; + # federal and Wisconsin loss-addition conventions are outside this fix. + nonloss_rows = [0, 3, 5] + for variable in ( + "wi_agi", + "state_income_tax_before_refundable_credits", + "state_income_tax", + ): + values = wi_simulation.calculate(variable, 2026) + np.testing.assert_allclose( + values[direct_slice][nonloss_rows], + values[schedule_d_slice][nonloss_rows], + atol=0.01, + ) + + subtraction = subtraction[direct_slice] + long_term = np.array( + [gain + distribution for gain, distribution, _ in CAPITAL_AMOUNTS] + ) + net_gain = long_term + np.array( + [short_term for _, _, short_term in CAPITAL_AMOUNTS] + ) + assert np.all(subtraction >= 0) + assert np.all(subtraction <= 0.3 * np.maximum(long_term, 0) + 0.01) + assert np.all(subtraction <= 0.3 * np.maximum(net_gain, 0) + 0.01) + np.testing.assert_array_equal(subtraction[net_gain <= 0], 0) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py index daf50dfc0bd..9a37127adbf 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py @@ -13,6 +13,14 @@ class wi_refundable_credits(Variable): "https://www.revenue.wi.gov/TaxForms2022/2022-Form1f.pdf", "https://www.revenue.wi.gov/TaxForms2022/2022-Form1-Inst.pdf", "https://docs.legis.wisconsin.gov/misc/lfb/informational_papers/january_2023/0002_individual_income_tax_informational_paper_2.pdf", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d", + "https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2", ) defined_for = StateCode.WI - adds = "gov.states.wi.tax.income.credits.refundable" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income.credits + standard = add(tax_unit, period, p.refundable) + permitted = add(tax_unit, period, p.retirement_income_exclusion_refundable) + elected = tax_unit("wi_retirement_income_exclusion_elected", period) + return where(elected, permitted, standard) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py index 3c001340416..6544108a00f 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_capital_gain_loss_subtraction.py @@ -10,6 +10,9 @@ class wi_capital_gain_loss_subtraction(Variable): reference = ( "https://www.revenue.wi.gov/TaxForms2021/2021-ScheduleWDf.pdf#page=2", "https://www.revenue.wi.gov/TaxForms2022/2022-ScheduleWDf.pdf#page=2", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/9", + "https://www.law.cornell.edu/uscode/text/26/852#b_3_B", + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=2", ) defined_for = StateCode.WI @@ -17,7 +20,11 @@ def formula(tax_unit, period, parameters): # calculate Schedule WD, Line 8 stcg_net = add(tax_unit, period, ["short_term_capital_gains"]) # calculate Schedule WD, Line 17 - ltcg_net = add(tax_unit, period, ["long_term_capital_gains"]) + # Capital gain distributions retain their long-term character when + # reported directly on Form 1040, without a federal Schedule D. + ltcg_net = add( + tax_unit, period, ["long_term_capital_gains", "non_sch_d_capital_gains"] + ) # calculate Schedule WD, Line 18 totcg = max_(0, stcg_net + ltcg_net) # calculate Schedule WD, Line 20, the capital gain reduction diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py index 8515ab0c805..80043667b6e 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_amount.py @@ -15,10 +15,8 @@ class wi_retirement_income_exclusion_amount(Variable): def formula(tax_unit, period, parameters): # Schedule SB Line 16: qualifying retirement income, capped. - # This is NOT subtracted from income directly — instead it - # feeds into wi_retirement_income_exclusion_tax_reduction, - # which compares tax computed two ways (with vs without the - # exclusion) because claiming Line 16 forfeits all credits. + # Compute both paths before electing the subtraction: claiming Line + # 16 restricts credits, so the exclusion can increase net tax. psri = parameters( period ).gov.states.wi.tax.income.subtractions.retirement_income diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py new file mode 100644 index 00000000000..78265bf3caa --- /dev/null +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py @@ -0,0 +1,40 @@ +from policyengine_us.model_api import * + + +class wi_retirement_income_exclusion_elected(Variable): + value_type = bool + entity = TaxUnit + label = "Wisconsin retirement income exclusion is elected" + definition_period = YEAR + reference = ( + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/d", + "https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2", + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", + ) + defined_for = StateCode.WI + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income + if not p.subtractions.retirement_income.exclusion.in_effect: + return tax_unit.filled_array(False) + + # Compare net tax on each return before publishing the elected path. + # Reading the credit components avoids a cycle through the reported + # before-refundable tax and refundable credits. + standard_before_refundable = max_( + 0, + tax_unit("wi_income_tax_before_credits", period) + - tax_unit("wi_non_refundable_credits", period), + ) + standard_refundable = add(tax_unit, period, p.credits.refundable) + exclusion_refundable = add( + tax_unit, period, p.credits.retirement_income_exclusion_refundable + ) + exclusion_net = ( + tax_unit("wi_retirement_income_exclusion_tax", period) + - exclusion_refundable + ) + standard_net = standard_before_refundable - standard_refundable + line16 = tax_unit("wi_retirement_income_exclusion_amount", period) + # Keep the standard path on a tie and require qualifying income. + return (line16 > 0) & (exclusion_net < standard_net) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py new file mode 100644 index 00000000000..61bbf919b5e --- /dev/null +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_line17_offset.py @@ -0,0 +1,54 @@ +from policyengine_us.model_api import * + + +class wi_retirement_income_exclusion_line17_offset(Variable): + value_type = float + entity = TaxUnit + label = "Wisconsin retirement subtraction lost when the larger exclusion is claimed" + unit = USD + definition_period = YEAR + reference = ( + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/c", + # PDF pages 7-8 + "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", + ) + defined_for = "wi_retirement_income_exclusion_eligible" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.wi.tax.income.subtractions.retirement_income + person = tax_unit.members + age = person("age", period) + retirement = add(person, period, p.sources) + eligible16 = (age >= p.exclusion.min_age) & person( + "is_tax_unit_head_or_spouse", period + ) + retirement16 = retirement * eligible16 + line16_person = min_(p.exclusion.max_amount.single, retirement16) + + # Use the same qualifying income sources as both existing retirement + # subtractions, removing amounts claimed on line 16 (worksheet step 4). + eligible17 = (age >= p.min_age) & ~person("is_tax_unit_dependent", period) + line17_person = min_(p.max_amount, retirement * eligible17) * tax_unit.project( + tax_unit("wi_retirement_income_subtraction_agi_eligible", period) + ) + remaining = max_(0, retirement - line16_person) + nonpooled_offset = tax_unit.sum(line17_person - min_(line17_person, remaining)) + + filing_status = tax_unit("filing_status", period) + pooled = (filing_status == filing_status.possible_values.JOINT) & ( + tax_unit.sum(eligible16) >= 2 + ) + # The joint limit is pooled regardless of each spouse's income. Assign + # line 16 to income above the existing line 17 entitlements first; no + # proportional allocation is required by the statute or worksheet. + pooled_line17 = tax_unit.sum(line17_person * eligible16) + pooled_remaining = max_( + 0, + tax_unit.sum(retirement16) + - tax_unit("wi_retirement_income_exclusion_amount", period), + ) + pooled_offset = max_(0, pooled_line17 - pooled_remaining) + offset = where(pooled, pooled_offset, nonpooled_offset) + # Only add back subtraction actually included in the standard return. + return min_(offset, tax_unit("wi_retirement_income_subtraction", period)) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py index ac660aef9e6..5580f9b5ca3 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax.py @@ -18,11 +18,4 @@ class wi_income_tax(Variable): def formula(tax_unit, period, parameters): before_refundable = tax_unit("wi_income_tax_before_refundable_credits", period) refundable = tax_unit("wi_refundable_credits", period) - standard_tax = before_refundable - refundable - p = parameters( - period - ).gov.states.wi.tax.income.subtractions.retirement_income.exclusion - if p.in_effect: - exclusion_tax = tax_unit("wi_retirement_income_exclusion_tax", period) - return min_(standard_tax, exclusion_tax) - return standard_tax + return before_refundable - refundable diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py index e0ffc34505f..73320130abc 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_income_tax_before_refundable_credits.py @@ -20,4 +20,7 @@ class wi_income_tax_before_refundable_credits(Variable): def formula(tax_unit, period, parameters): income_tax_before = tax_unit("wi_income_tax_before_credits", period) nonrefundable_credits = tax_unit("wi_non_refundable_credits", period) - return max_(0, income_tax_before - nonrefundable_credits) + standard = max_(0, income_tax_before - nonrefundable_credits) + elected = tax_unit("wi_retirement_income_exclusion_elected", period) + exclusion_tax = tax_unit("wi_retirement_income_exclusion_tax", period) + return where(elected, exclusion_tax, standard) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py index ff92acb6368..031448a3262 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_retirement_income_exclusion_tax.py @@ -12,17 +12,19 @@ class wi_retirement_income_exclusion_tax(Variable): definition_period = YEAR reference = ( "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/6/b/54m/a", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/22", + # PDF pages 7-8 "https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): # Schedule SB Line 16 path: tax on income reduced by the - # exclusion amount, with zero credits applied. - # wi_taxable_income already includes Line 17; per the Line 17 - # worksheet (step 4), Line 17 self-adjusts downward when - # Line 16 is claimed, so we simply subtract Line 16 here. + # exclusion amount, before any permitted refundable credits. + # The standard path's Line 17 must be reduced by its overlap with + # Line 16, as required by step 4 of the Line 17 worksheet. line16 = tax_unit("wi_retirement_income_exclusion_amount", period) + line17_offset = tax_unit("wi_retirement_income_exclusion_line17_offset", period) taxinc = tax_unit("wi_taxable_income", period) # The Line 16 subtraction reduces WI income (Form 1 line 7), and the @@ -36,10 +38,12 @@ def formula(tax_unit, period, parameters): wi_agi = tax_unit("wi_agi", period) standard_deduction_full = tax_unit("wi_standard_deduction", period) standard_deduction_reduced = wi_standard_deduction_for_income( - max_(0, wi_agi - line16), fstatus, parameters, period + max_(0, wi_agi - line16 + line17_offset), fstatus, parameters, period ) extra_standard_deduction = standard_deduction_reduced - standard_deduction_full - exclusion_taxinc = max_(0, taxinc - line16 - extra_standard_deduction) + exclusion_taxinc = max_( + 0, taxinc - line16 + line17_offset - extra_standard_deduction + ) statuses = fstatus.possible_values p = parameters(period).gov.states.wi.tax.income From 1a1a9d116f3b148f52c6fc9b8de5a0b10e0a2bc8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 09:35:59 -0400 Subject: [PATCH 2/5] Handle retirement elections with no permitted refundable credits --- .../baseline/gov/states/wi/tax/income/wi_income_tax.yaml | 3 +++ .../states/wi/tax/income/credits/wi_refundable_credits.py | 3 ++- .../subtractions/wi_retirement_income_exclusion_elected.py | 5 +++-- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml index f74c52844b1..5701c4fc15f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wi/tax/income/wi_income_tax.yaml @@ -85,6 +85,9 @@ output: state_income_tax_before_refundable_credits: 0 wi_capital_gain_loss_subtraction: 1125.90 + # The standard path isolates the capital gain correction: 708.70 - 300. + wi_income_tax_before_credits: 708.70 + wi_non_refundable_credits: 300 state_refundable_credits: 0 state_income_tax: 0 diff --git a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py index 9a37127adbf..342cba945a8 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/credits/wi_refundable_credits.py @@ -21,6 +21,7 @@ class wi_refundable_credits(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.wi.tax.income.credits standard = add(tax_unit, period, p.refundable) - permitted = add(tax_unit, period, p.retirement_income_exclusion_refundable) + permitted_credits = p.retirement_income_exclusion_refundable + permitted = add(tax_unit, period, permitted_credits) if permitted_credits else 0 elected = tax_unit("wi_retirement_income_exclusion_elected", period) return where(elected, permitted, standard) diff --git a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py index 78265bf3caa..b26f8b1a465 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/subtractions/wi_retirement_income_exclusion_elected.py @@ -27,8 +27,9 @@ def formula(tax_unit, period, parameters): - tax_unit("wi_non_refundable_credits", period), ) standard_refundable = add(tax_unit, period, p.credits.refundable) - exclusion_refundable = add( - tax_unit, period, p.credits.retirement_income_exclusion_refundable + permitted_credits = p.credits.retirement_income_exclusion_refundable + exclusion_refundable = ( + add(tax_unit, period, permitted_credits) if permitted_credits else 0 ) exclusion_net = ( tax_unit("wi_retirement_income_exclusion_tax", period) From cdfbea4229105c94b3b9d4498aedc478534e413c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 10:07:35 -0400 Subject: [PATCH 3/5] Exercise Wisconsin invariants with leaf income inputs --- .../tests/unit/test_wi_retirement_election.py | 27 +++++++++---------- 1 file changed, 13 insertions(+), 14 deletions(-) diff --git a/policyengine_us/tests/unit/test_wi_retirement_election.py b/policyengine_us/tests/unit/test_wi_retirement_election.py index 35a21de7de9..1e7f6c40a0b 100644 --- a/policyengine_us/tests/unit/test_wi_retirement_election.py +++ b/policyengine_us/tests/unit/test_wi_retirement_election.py @@ -42,10 +42,6 @@ def _situation(cases, year): situation["tax_units"][unit_name] = { "members": members, "filing_status": {str(year): "JOINT" if len(members) == 2 else "SINGLE"}, - **{ - variable: {str(year): value} - for variable, value in case.get("tax", {}).items() - }, } situation["households"][unit_name] = { "members": members, @@ -85,14 +81,13 @@ def _situation(cases, year): ] }, { - "people": [{"age": 68, "taxable_private_pension_income": 30_000}], - "tax": { - "wi_income_tax_before_credits": 1_000, - "wi_non_refundable_credits": 100, - "wi_retirement_income_exclusion_tax": 100, - "wi_earned_income_credit": 25, - "wi_homestead_credit": 200, - }, + "people": [ + { + "age": 68, + "taxable_private_pension_income": 24_500, + "real_estate_taxes": 2_000, + } + ] }, {"people": [{"age": 68, "taxable_ira_distributions": 8_000}]}, {"people": [{"age": 68, "taxable_401k_distributions": 8_000}]}, @@ -168,9 +163,9 @@ def test_retirement_election_vectorized_matches_separate_returns(wi_simulation): variable: simulation.calculate(variable, year)[retirement_slice] for variable in variables } - # Compare an unequal-pension joint return and a return retaining homestead. + # Compare a taxable standard return and a return retaining homestead. # The remaining rows exercise their vectorized accounting identities below. - for index in (4, 6): + for index in (1, 6): case = RETIREMENT_CASES[index] separate = Simulation(situation=_situation([case], year)) for variable in variables: @@ -195,6 +190,10 @@ def test_retirement_election_vectorized_matches_separate_returns(wi_simulation): ] standard_net = standard_before - earned_income_credit - homestead_credit + assert standard_before[1] > 0 + assert standard_before[6] > 0 + assert homestead_credit[6] > 0 + assert elected[6] np.testing.assert_allclose(net, before - refundable, atol=0.01) assert np.all(net <= standard_net + 0.01) assert np.any(elected) From 5584af4d4504fa98463550238b045ea96b6187d6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 21:37:40 -0400 Subject: [PATCH 4/5] Make household state tax totals equal each state's own income tax Household net income subtracts household_state_tax_before_refundable_credits and adds household_refundable_state_tax_credits, which sum the cross-state lists of state tax before refundable credits and refundable credits. Five state taxes applied rules outside those components, so household net income disagreed with the state's own income tax: - Wisconsin: the retirement income exclusion election (fixed in #10026's components); drop the WI override in state_income_tax and household_state_income_tax, which now follow the decomposition. - New Jersey: the override was redundant (the filing-threshold zero-out is already in nj_income_tax_before_refundable_credits); drop it. - Vermont: add vt_child_care_contributions (Form IN-111 line 21). - Colorado: add co_amt (DR 0104 line 14). - Mississippi: add ms_income_tax_before_refundable_credits, which applies nonrefundable credits (Form 80-105 lines 18-19), and build ms_income_tax from it less ms_refundable_credits. - Michigan (contributed surtax): add mi_surtax. Add table-sourced YAML cases for each state and an every-state invariant test: structural list membership, state_income_tax == xx_income_tax, and household before-refundable less refundable == state_income_tax + use tax. Co-Authored-By: Claude Opus 5.5 --- ...nt-exclusion-household-accounting.fixed.md | 1 + ...usehold_tax_before_refundable_credits.yaml | 5 +- ..._income_tax_before_refundable_credits.yaml | 5 +- ..._state_income_tax_accounting_invariants.py | 481 ++++++++++++++++++ .../states/ms/tax/income/ms_income_tax.yaml | 20 + ..._income_tax_before_refundable_credits.yaml | 17 + .../states/tax/income/state_income_tax.yaml | 66 +++ .../household/household_net_income.yaml | 29 ++ ...ousehold_refundable_state_tax_credits.yaml | 74 +++ ...d_state_tax_before_refundable_credits.yaml | 243 +++++++++ .../policy/contrib/states/mi/mi_surtax.yaml | 17 + .../gov/states/ms/tax/income/ms_income_tax.py | 7 +- ...ms_income_tax_before_refundable_credits.py | 18 + .../gov/states/tax/income/state_income_tax.py | 26 +- .../household/household_state_income_tax.py | 21 +- 15 files changed, 983 insertions(+), 47 deletions(-) create mode 100644 changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md create mode 100644 policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml create mode 100644 policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py diff --git a/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md b/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md new file mode 100644 index 00000000000..65b2ac46d2a --- /dev/null +++ b/changelog.d/fix-wi-retirement-exclusion-household-accounting.fixed.md @@ -0,0 +1 @@ +Make household net income and state income tax use each state's own final income tax: drop the Wisconsin and New Jersey overrides in `state_income_tax`, add the Vermont child care contribution, Colorado alternative minimum tax and contributed Michigan surtax to state tax before refundable credits, and apply Mississippi's nonrefundable credits there. diff --git a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml index 656d4b40bf3..db48e7b173c 100644 --- a/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/household/household_tax_before_refundable_credits.yaml @@ -6,6 +6,7 @@ values: - az_income_tax_before_refundable_credits - ca_income_tax_before_refundable_credits - co_income_tax_before_refundable_credits + - co_amt - ct_income_tax_before_refundable_credits - dc_income_tax_before_refundable_credits - de_income_tax_before_refundable_credits @@ -25,9 +26,10 @@ values: - ma_income_tax_before_refundable_credits - md_income_tax_before_refundable_credits - mi_income_tax_before_refundable_credits + - mi_surtax - mn_income_tax_before_refundable_credits - mo_income_tax_before_refundable_credits - - ms_income_tax_before_credits_unit + - ms_income_tax_before_refundable_credits - mt_income_tax_before_refundable_credits_unit - nc_income_tax # NC has no refundable credits. - nd_income_tax_before_refundable_credits @@ -48,6 +50,7 @@ values: - ut_income_tax_before_refundable_credits - va_income_tax_before_refundable_credits - vt_income_tax_before_refundable_credits + - vt_child_care_contributions - wi_income_tax_before_refundable_credits - wv_income_tax_before_refundable_credits diff --git a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml index c3877dbb5d1..9c16160e1a5 100644 --- a/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/parameters/gov/states/household/state_income_tax_before_refundable_credits.yaml @@ -6,6 +6,7 @@ values: - az_income_tax_before_refundable_credits - ca_income_tax_before_refundable_credits - co_income_tax_before_refundable_credits + - co_amt # DR 0104 line 14 alternative minimum tax, added before refundable credits - ct_income_tax_before_refundable_credits - dc_income_tax_before_refundable_credits - de_income_tax_before_refundable_credits @@ -23,9 +24,10 @@ values: - ma_income_tax_before_refundable_credits - md_income_tax_before_refundable_credits - mi_income_tax_before_refundable_credits + - mi_surtax # contributed surtax, zero unless gov.contrib.states.mi.surtax.in_effect - mn_income_tax_before_refundable_credits - mo_income_tax_before_refundable_credits - - ms_income_tax_before_credits_unit + - ms_income_tax_before_refundable_credits - mt_income_tax_before_refundable_credits_unit - nc_income_tax - nd_income_tax_before_refundable_credits @@ -44,6 +46,7 @@ values: - ut_income_tax_before_refundable_credits - va_income_tax_before_refundable_credits - vt_income_tax_before_refundable_credits + - vt_child_care_contributions # Form IN-111 line 21 child care contribution, added before refundable credits - wi_income_tax_before_refundable_credits - wv_income_tax_before_refundable_credits diff --git a/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py new file mode 100644 index 00000000000..a9ad866c6d5 --- /dev/null +++ b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py @@ -0,0 +1,481 @@ +"""State income tax accounting invariants, for every state. + +Household net income does not read each state's own final income tax. It +subtracts ``household_state_tax_before_refundable_credits`` and adds +``household_refundable_state_tax_credits``, which sum every state's tax before +refundable credits and every state's refundable credits from two cross-state +lists. A state whose final tax applies a rule that those two components do not +carry is then right in its own ``xx_income_tax`` and wrong in household net +income. Five cases shipped: the Wisconsin retirement income exclusion +election, the Vermont child care contribution, the Colorado alternative +minimum tax, Mississippi's nonrefundable credits, and the contributed Michigan +surtax. + +Three properties guard against a sixth: + +1. Structure, with no simulation. Where a state's ``xx_income_tax`` is a sum, + it adds exactly that state's members of the before-refundable list and + subtracts exactly its members of the refundable list. +2. ``state_income_tax`` equals the state's own ``xx_income_tax`` wherever the + model defines one, and is zero in the states with no modelled income tax. +3. A household's state tax before refundable credits, less its refundable + state credits, equals the ``state_income_tax`` of all its tax units plus + their ``state_use_tax``. Use tax is the one intended difference: the + household pays it to the state, but it is not an income tax + (``state_income_tax_excludes_use_tax.yaml``). + +Properties 2 and 3 must hold for every tax unit and household, so the tests +draw a seeded population in each of the 50 states and DC and run it as one +vectorized simulation. Where a state's ``xx_income_tax`` is a sum, property 1 +already implies property 2, so Hypothesis draws further households only in the +states that compute it with a formula. YAML cases cannot express these +properties: each compares two computed totals on every row instead of one +total with a stated amount. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +YEAR = 2025 +SEED = 20261009 +N_RANDOM_PER_STATE = 6 +TOLERANCE = 0.05 # dollars; float32 sums of five- and six-figure amounts + +STATES = ( + "AL AK AZ AR CA CO CT DE DC FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS " + "MO MT NE NV NH NJ NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY" +).split() + +# The model has no income tax for these states. The set is asserted against +# the variable registry, so a state cannot drop out of the properties unnoticed. +STATES_WITHOUT_INCOME_TAX = {"AK", "FL", "NV", "SD", "TN", "TX", "WY"} + +# These states compute ``xx_income_tax`` with a formula, so property 1 cannot +# read its parts. Property 2 covers them. +STATES_WITH_FORMULA_INCOME_TAX = { + # Lower of the joint return and separate returns on one form, each after + # refundable credits. + "MT", + # No refundable credits: the before-refundable list holds nc_income_tax. + "NC", + # No tax at or below the filing threshold. + "NJ", + # Tax before refundable credits less refundable credits, both following + # the retirement income exclusion election. + "WI", +} + + +def _list_members(parameter): + return set(parameter(f"{YEAR}-01-01")) + + +def _state_members(members, state): + return {member for member in members if member.startswith(f"{state.lower()}_")} + + +@pytest.fixture(scope="module") +def before_refundable_list(): + return _list_members( + system.parameters.gov.states.household.state_income_tax_before_refundable_credits + ) + + +@pytest.fixture(scope="module") +def refundable_list(): + return _list_members( + system.parameters.gov.states.household.state_refundable_credits + ) + + +def test_states_without_an_income_tax_variable_are_the_expected_ones(): + without = { + state + for state in STATES + if f"{state.lower()}_income_tax" not in system.variables + } + assert without == STATES_WITHOUT_INCOME_TAX + + +def test_states_with_a_formula_income_tax_are_the_expected_ones(): + with_formula = { + state + for state in STATES + if state not in STATES_WITHOUT_INCOME_TAX + and system.variables[f"{state.lower()}_income_tax"].adds is None + } + assert with_formula == STATES_WITH_FORMULA_INCOME_TAX + + +def test_list_members_are_defined_variables(before_refundable_list, refundable_list): + undefined = (before_refundable_list | refundable_list) - set(system.variables) + assert not undefined + + +def test_household_refundable_list_matches_the_state_refundable_list(refundable_list): + household_list = _list_members( + system.parameters.gov.household.household_refundable_state_credits + ) + assert household_list == refundable_list + + +@pytest.mark.parametrize( + "state", + sorted(set(STATES) - STATES_WITHOUT_INCOME_TAX - STATES_WITH_FORMULA_INCOME_TAX), +) +def test_state_income_tax_parts_are_the_states_list_members( + state, before_refundable_list, refundable_list +): + own = system.variables[f"{state.lower()}_income_tax"] + assert set(own.adds) == _state_members(before_refundable_list, state) + assert set(own.subtracts or []) == _state_members(refundable_list, state) + + +# Each archetype is a list of tax units in one household. All inputs are leaf +# facts. A child is an age, or a dict of person inputs. +ARCHETYPES = [ + # Single worker. + [{"adults": [{"age": 30, "employment_income": 30_000}]}], + # Low-income single parent who rents: refundable state credits. + [ + { + "adults": [{"age": 32, "employment_income": 18_000, "rent": 9_000}], + "children": [3, 7], + } + ], + # Single parent who pays for child care: nonrefundable state credits. + [ + { + "adults": [{"age": 30, "employment_income": 30_000}], + "children": [3, 5], + "spm_unit": {"spm_unit_pre_subsidy_childcare_expenses": 6_000}, + } + ], + # Married couple with two children. + [ + { + "adults": [ + {"age": 40, "employment_income": 60_000}, + {"age": 38, "employment_income": 40_000}, + ], + "children": [5, 9], + } + ], + # Single retiree with a pension and Social Security. + [ + { + "adults": [ + { + "age": 70, + "taxable_private_pension_income": 40_000, + "social_security_retirement": 24_000, + } + ] + } + ], + # Retired couple with pensions, Social Security and investment income. + [ + { + "adults": [ + { + "age": 68, + "taxable_private_pension_income": 30_000, + "social_security_retirement": 20_000, + "long_term_capital_gains": 10_000, + "taxable_interest_income": 3_000, + }, + { + "age": 72, + "taxable_private_pension_income": 30_000, + "social_security_retirement": 20_000, + }, + ] + } + ], + # High earner with capital gains and dividends. + [ + { + "adults": [ + { + "age": 50, + "employment_income": 400_000, + "long_term_capital_gains": 100_000, + "qualified_dividend_income": 20_000, + } + ] + } + ], + # Self-employed parent who rents. + [ + { + "adults": [{"age": 45, "self_employment_income": 50_000, "rent": 12_000}], + "children": [10], + } + ], + # Very low earner, below state filing thresholds. + [{"adults": [{"age": 25, "employment_income": 5_000}]}], + # Elderly renter living on Social Security. + [{"adults": [{"age": 75, "social_security_retirement": 12_000, "rent": 7_000}]}], + # Low-income retiree who rents: property tax and homestead credits. + [ + { + "adults": [ + {"age": 67, "taxable_private_pension_income": 16_313, "rent": 12_000} + ] + } + ], + # Two tax units in one household: parents and an adult child who works. + [ + { + "adults": [ + {"age": 58, "employment_income": 70_000}, + {"age": 57, "taxable_private_pension_income": 25_000}, + ] + }, + {"adults": [{"age": 24, "employment_income": 28_000}]}, + ], +] + + +def _random_tax_unit(rng): + adults = [] + for _ in range(int(rng.integers(1, 3))): + age = int(rng.integers(19, 90)) + adults.append( + { + "age": age, + "employment_income": float( + round(rng.choice([0, 0, rng.uniform(0, 150_000)])) + ), + "self_employment_income": float( + round(rng.choice([0, 0, 0, rng.uniform(-20_000, 80_000)])) + ), + "taxable_private_pension_income": float( + round(rng.choice([0, rng.uniform(0, 90_000)]) if age >= 55 else 0) + ), + "social_security_retirement": float( + round( + rng.choice([0, rng.uniform(5_000, 40_000)]) if age >= 62 else 0 + ) + ), + "long_term_capital_gains": float( + round(rng.choice([0, 0, rng.uniform(-5_000, 60_000)])) + ), + "taxable_interest_income": float( + round(rng.choice([0, rng.uniform(0, 8_000)])) + ), + "rent": float(round(rng.choice([0, rng.uniform(3_000, 24_000)]))), + "real_estate_taxes": float( + round(rng.choice([0, 0, rng.uniform(500, 9_000)])) + ), + } + ) + children = [ + int(rng.integers(0, 17)) for _ in range(int(rng.choice([0, 0, 1, 2, 3]))) + ] + return {"adults": adults, "children": children} + + +def _situation(households, year): + """Build a situation from (state, tax units) pairs, one per household.""" + people, tax_units, spm_units, household_entities = {}, {}, {}, {} + + def at_year(inputs): + return {variable: {year: value} for variable, value in inputs.items()} + + for household_index, (state, household_tax_units) in enumerate(households): + household_members = [] + spm_unit_inputs = {} + for unit_index, unit in enumerate(household_tax_units): + key = f"{household_index}_{unit_index}" + members = [] + for adult_index, adult in enumerate(unit["adults"]): + name = f"adult_{adult_index}_{key}" + members.append(name) + people[name] = at_year(adult) + for child_index, child in enumerate(unit.get("children", [])): + name = f"child_{child_index}_{key}" + members.append(name) + people[name] = at_year( + child if isinstance(child, dict) else {"age": child} + ) + tax_units[f"tax_unit_{key}"] = {"members": members} + spm_unit_inputs.update(unit.get("spm_unit", {})) + household_members += members + spm_units[f"spm_unit_{household_index}"] = { + "members": household_members, + **at_year(spm_unit_inputs), + } + household_entities[f"household_{household_index}"] = { + "members": household_members, + "state_code": {year: state}, + } + return { + "people": people, + "tax_units": tax_units, + "spm_units": spm_units, + "households": household_entities, + } + + +def _seeded_households(): + rng = np.random.default_rng(SEED) + households = [] + for state in STATES: + households += [(state, archetype) for archetype in ARCHETYPES] + households += [ + (state, [_random_tax_unit(rng)]) for _ in range(N_RANDOM_PER_STATE) + ] + return households + + +def _tax_unit_state(simulation, year): + tax_unit = simulation.populations["tax_unit"] + return np.asarray(tax_unit.household("state_code", year).decode_to_str()) + + +def _tax_unit(simulation, variable, year=YEAR): + return np.asarray(simulation.calculate(variable, year), dtype=float) + + +def _household(simulation, variable, year=YEAR): + return np.asarray( + simulation.calculate(variable, year, map_to="household"), dtype=float + ) + + +def _check_state_income_tax_equals_own_income_tax(simulation, state, year=YEAR): + in_state = _tax_unit_state(simulation, year) == state + state_income_tax = _tax_unit(simulation, "state_income_tax", year)[in_state] + if state in STATES_WITHOUT_INCOME_TAX: + np.testing.assert_array_equal(state_income_tax, 0) + return + own_income_tax = _tax_unit(simulation, f"{state.lower()}_income_tax", year) + np.testing.assert_allclose( + state_income_tax, own_income_tax[in_state], atol=TOLERANCE, rtol=0 + ) + + +def _check_household_totals(simulation, year=YEAR): + before_refundable = _household( + simulation, "household_state_tax_before_refundable_credits", year + ) + refundable = _household(simulation, "household_refundable_state_tax_credits", year) + state_income_tax = _household(simulation, "state_income_tax", year) + use_tax = _household(simulation, "state_use_tax", year) + np.testing.assert_allclose( + before_refundable - refundable, + state_income_tax + use_tax, + atol=TOLERANCE, + rtol=0, + ) + + +@pytest.fixture(scope="module") +def simulation(): + return Simulation(situation=_situation(_seeded_households(), YEAR)) + + +@pytest.fixture(scope="module") +def tax_unit_state(simulation): + return _tax_unit_state(simulation, YEAR) + + +def test_population_exercises_the_properties(simulation, tax_unit_state): + state_income_tax = _tax_unit(simulation, "state_income_tax") + for state in STATES: + assert (tax_unit_state == state).sum() >= len(ARCHETYPES) + N_RANDOM_PER_STATE + # Net refunds from refundable credits, in many states. + assert len(set(tax_unit_state[state_income_tax < 0])) >= 10 + # Use tax, the labelled exception in property 3. + assert (_household(simulation, "state_use_tax") > 0).sum() >= 10 + # Households with more than one tax unit. + households = simulation.populations["household"].count + assert simulation.populations["tax_unit"].count > households + # Rules that were once missing from household net income. Leaf inputs + # cannot produce a Colorado alternative minimum tax or the contributed + # Michigan surtax; property 1 covers those. + elected = np.asarray( + simulation.calculate("wi_retirement_income_exclusion_elected", YEAR) + ) + assert elected.sum() >= 2 + assert (_tax_unit(simulation, "vt_child_care_contributions") > 0).sum() >= 2 + assert (_tax_unit(simulation, "ms_non_refundable_credits") > 0).sum() >= 1 + + +@pytest.mark.parametrize("state", STATES) +def test_state_income_tax_equals_the_states_own_income_tax(simulation, state): + _check_state_income_tax_equals_own_income_tax(simulation, state) + + +def test_household_state_tax_totals_equal_state_income_tax_plus_use_tax(simulation): + _check_household_totals(simulation) + + +# Whole-dollar amounts keep float32 sums exact. The fixed values sit on and +# beside Wisconsin's $24,000 exclusion cap and New Jersey's filing thresholds. +amounts = st.one_of( + st.just(0.0), + st.sampled_from([9_999.0, 10_000.0, 10_001.0, 20_000.0, 24_000.0, 24_001.0]), + st.integers(1, 150_000).map(float), +) + + +@st.composite +def formula_state_households(draw): + adults = [] + for _ in range(draw(st.integers(1, 2))): + age = draw(st.one_of(st.sampled_from([64, 65, 66, 67]), st.integers(19, 90))) + adults.append( + { + "age": age, + "employment_income": draw(amounts), + "self_employment_income": draw( + st.one_of(st.just(0.0), st.integers(-30_000, 80_000).map(float)) + ), + "taxable_private_pension_income": draw(amounts) if age >= 55 else 0.0, + "social_security_retirement": ( + draw(st.integers(0, 40_000).map(float)) if age >= 62 else 0.0 + ), + "long_term_capital_gains": draw( + st.one_of(st.just(0.0), st.integers(-8_000, 80_000).map(float)) + ), + "rent": draw( + st.one_of(st.just(0.0), st.integers(1, 24_000).map(float)) + ), + } + ) + children = draw(st.lists(st.integers(0, 16), max_size=3)) + unit = {"adults": adults, "children": children} + if children: + unit["spm_unit"] = { + "spm_unit_pre_subsidy_childcare_expenses": draw( + st.one_of(st.just(0.0), st.integers(1, 12_000).map(float)) + ) + } + state = draw(st.sampled_from(sorted(STATES_WITH_FORMULA_INCOME_TAX))) + return state, [unit] + + +@settings( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(formula_state_households(), min_size=10, max_size=40), + # Montana let spouses file separately on one form through 2023, and the + # Wisconsin retirement income exclusion starts in 2025. + st.sampled_from([2023, 2025]), +) +def test_formula_states_keep_the_properties(households, year): + simulation = Simulation(situation=_situation(households, year)) + for state in sorted(STATES_WITH_FORMULA_INCOME_TAX): + _check_state_income_tax_equals_own_income_tax(simulation, state, year) + _check_household_totals(simulation, year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml index 40e7b93ff58..20e9ea37f6f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax.yaml @@ -48,3 +48,23 @@ ms_income_tax_before_credits_unit: 500 output: ms_income_tax: 200 + +- name: Refundable credits reduce Mississippi income tax + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 500 + ms_non_refundable_credits: 300 + ms_refundable_credits: 50 + output: + ms_income_tax: 150 + +- name: Refundable credits can make Mississippi income tax negative + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 200 + ms_non_refundable_credits: 300 + ms_refundable_credits: 50 + output: + ms_income_tax: -50 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml new file mode 100644 index 00000000000..7d038a74991 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.yaml @@ -0,0 +1,17 @@ +- name: Nonrefundable credits reduce Mississippi income tax + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 500 + ms_non_refundable_credits: 300 + output: + ms_income_tax_before_refundable_credits: 200 + +- name: Nonrefundable credits cannot reduce Mississippi income tax below zero + period: 2025 + input: + state_code: MS + ms_income_tax_before_credits_unit: 200 + ms_non_refundable_credits: 300 + output: + ms_income_tax_before_refundable_credits: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml index 97eecf3e204..eb23bcccb64 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_income_tax.yaml @@ -35,3 +35,69 @@ output: nj_income_tax: 0 state_income_tax: 0 + +- name: Vermont state income tax includes the child care contribution on self-employment income. + # 2024 Form IN-111: line 20 (income tax after credits) + line 21 (child + # care contribution) + line 22 (use tax) = line 23 (total Vermont taxes); + # refundable credits follow on line 26c. + # Child Care Contribution Worksheet: Schedule SE line 6 x 0.5 x 0.0011. + # https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=12 + # Schedule SE line 6 = 100,000 x 0.9235 = 92,350; contribution = + # 92,350 x 0.5 x 0.0011 = 50.79; total = 1,000 + 50.79 - 200 = 850.79. + period: 2024 + absolute_error_margin: 0.01 + input: + state_code: VT + self_employment_income: 100_000 + vt_income_tax_before_refundable_credits: 1_000 + vt_refundable_credits: 200 + output: + vt_child_care_contributions: 50.79 + vt_income_tax: 850.79 + state_income_tax: 850.79 + +- name: Mississippi state income tax subtracts nonrefundable credits. + # 2025 Mississippi resident instructions: head of family exemption $8,000, + # $1,500 per dependent, standard deduction $3,400; tax is 4.4% of taxable + # income over $10,000 (line 17). Credits on lines 18 and 19 reduce it. + # https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8 + # Taxable income = 30,000 - 3,400 - 8,000 - 3,000 = 15,600; + # tax = 0.044 x 5,600 = 246.40; less a credit of 159 = 87.40. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + child1: + age: 3 + child2: + age: 5 + tax_units: + tax_unit: + members: [parent, child1, child2] + ms_cdcc: 159 + households: + household: + members: [parent, child1, child2] + state_code: MS + output: + ms_income_tax_before_credits_unit: 246.40 + ms_income_tax_before_refundable_credits: 87.40 + ms_income_tax: 87.40 + state_income_tax: 87.40 + +- name: Colorado state income tax includes the alternative minimum tax. + # 2025 Form DR 0104: line 13 (tax) + line 14 (alternative minimum tax) + + # line 15 = line 16, before the refundable credits on line 31. + # https://tax.colorado.gov/sites/tax/files/documents/Book104_2025.pdf#page=11 + period: 2025 + input: + state_code: CO + co_income_tax_before_refundable_credits: 4_000 + co_amt: 500 + co_refundable_credits: 100 + output: + co_income_tax: 4_400 + state_income_tax: 4_400 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml index 8ca8a254933..7017abac77c 100644 --- a/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_net_income.yaml @@ -131,3 +131,32 @@ household_health_costs: 0 output: household_net_income: 29_000 + +- name: Household net income of a Wisconsin retiree reflects the retirement income exclusion. + # 2025 Wisconsin Form 1 instructions, Standard Deduction Table (page 35) + # and Tax Table (page 38), with exemptions of $700 + $250: + # with credits, line 7 = $41,250; standard deduction $10,956; taxable + # income $29,344; tax $1,159. + # with the Schedule SB Line 16 exclusion of $24,000, line 7 = $17,250; + # standard deduction $13,560; taxable income $2,740; tax $96. + # https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf#page=35 + # Federal income tax is set to zero to isolate the state tax: + # $41,250 - $96 = $41,154. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 41_250 + tax_units: + tax_unit: + members: [person1] + income_tax_before_refundable_credits: 0 + income_tax_refundable_credits: 0 + households: + household: + members: [person1] + state_code: WI + output: + household_net_income: 41_154 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml new file mode 100644 index 00000000000..e1f251326e3 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_refundable_state_tax_credits.yaml @@ -0,0 +1,74 @@ +- name: Wisconsin retiree keeps the homestead credit when it beats the retirement income exclusion + # 2025 Form 1 instructions (https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf): + # line 7 = $16,313; standard deduction $13,560 (page 35); exemptions + # $950; taxable income $1,803; tax $65 (page 38). The renter's school + # property tax credit (line 16a) exceeds $65, so net tax is $0. + # 2025 Schedule H instructions (https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleH-inst.pdf): + # household income $16,313 -> Table A (page 25) gives $722; rent + # constituting property taxes is capped at $1,460; $1,460 - $722 = $738 + # -> Table B (page 26) gives a credit of $588. + # Claiming the Schedule SB Line 16 subtraction for 2025 would bar the + # homestead credit (Schedule H instructions, page 4, Step 2 question h) + # and leave a tax of $0, so the filer keeps the credits: $0 - $588 = -$588. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 16_313 + rent: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_homestead_credit: 588 + wi_retirement_income_exclusion_elected: false + wi_income_tax: -588 + state_income_tax: -588 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 588 + +- name: Wisconsin filer who elects the retirement income exclusion for 2025 has no refundable state credits + # 2025 Schedule SB instructions, page 7, Line 16: no credit on Form 1 lines + # 13-20 and 30-35. Tax with credits = max(0, 5,000 - 500) - (200 + 300) = + # 4,000, above the exclusion tax of 3,000. + period: 2025 + input: + state_code: WI + wi_income_tax_before_credits: 5_000 + wi_non_refundable_credits: 500 + wi_earned_income_credit: 200 + wi_homestead_credit: 300 + wi_retirement_income_exclusion_amount: 24_000 + wi_retirement_income_exclusion_tax: 3_000 + state_use_tax: 0 + output: + wi_retirement_income_exclusion_elected: true + household_state_tax_before_refundable_credits: 3_000 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin filer who elects the retirement income exclusion for 2026 keeps the homestead credit + # 2025 Wisconsin Act 174, section 7, bars only credits under section 71.07; + # the homestead credit is under section 71.54 and the earned income credit + # is under 71.07. Tax with credits = max(0, 5,000 - 500) - (200 + 300) = + # 4,000; tax with the exclusion = 3,000 - 300 = 2,700. + # https://docs.legis.wisconsin.gov/2025/related/acts/174.pdf#page=2 + period: 2026 + input: + state_code: WI + wi_income_tax_before_credits: 5_000 + wi_non_refundable_credits: 500 + wi_earned_income_credit: 200 + wi_homestead_credit: 300 + wi_retirement_income_exclusion_amount: 24_000 + wi_retirement_income_exclusion_tax: 3_000 + state_use_tax: 0 + output: + wi_retirement_income_exclusion_elected: true + household_state_tax_before_refundable_credits: 3_000 + household_refundable_state_tax_credits: 300 diff --git a/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml new file mode 100644 index 00000000000..03a80e94c46 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/income/household/household_state_tax_before_refundable_credits.yaml @@ -0,0 +1,243 @@ +# Wisconsin retirees who claim the Schedule SB Line 16 retirement income +# subtraction owe the tax on the reduced income. For 2025 they may not claim +# any credit on Form 1 lines 13-20 and 30-35. The household totals must +# carry the tax and credits of the return the filer elects. +# +# Expected values come from the 2025 Wisconsin Form 1 instructions: +# https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf +# Standard Deduction Table, pages 35-37 (keyed to line 7, Wisconsin income). +# Tax Table, pages 38-43 (keyed to line 11, taxable income). +# Line 10 exemptions, page 15: $700 per filer plus $250 if 65 or older. +# and the 2025 Schedule SB instructions, Line 16 (up to $24,000 per filer +# aged 67 or older, $48,000 on a joint return when both are): +# https://www.revenue.wi.gov/TaxForms2025/2025-ScheduleSB-Inst.pdf#page=7 +# +# Social Security is not taxed by Wisconsin, and 30% of a net long-term +# capital gain is excluded, so line 7 below is pension + 70% of the gain. + +- name: Wisconsin retiree, $20,250 pension, claims the exclusion and owes no tax + # With credits: line 7 = $20,250 + $3,500 = $23,750; standard deduction + # $13,056; taxable income $23,750 - $13,056 - $950 = $9,744; tax $341. + # With the exclusion: line 16 = $20,250; line 7 = $3,500; tax $0. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 20_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_income_tax_before_credits: 341 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 0 + state_income_tax: 0 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree, $40,250 pension, claims the exclusion and owes $184 + # With credits: line 7 = $43,750; standard deduction $10,656; taxable + # income $43,750 - $10,656 - $950 = $32,144; tax $1,282. + # With the exclusion: line 16 = $24,000; line 7 = $19,750; standard + # deduction $13,536; taxable income $5,264; tax $184. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 40_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_income_tax_before_credits: 1_282 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 184 + state_income_tax: 184 + household_state_tax_before_refundable_credits: 184 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree, $59,250 pension, claims the exclusion and owes $1,036 + # With credits: line 7 = $62,750; standard deduction $8,376; taxable + # income $62,750 - $8,376 - $950 = $53,424; tax $2,246. + # With the exclusion: line 16 = $24,000; line 7 = $38,750; standard + # deduction $11,256; taxable income $26,544; tax $1,036. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 59_250 + social_security_retirement: 24_000 + long_term_capital_gains: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + wi_income_tax: 1_036 + state_income_tax: 1_036 + household_state_tax_before_refundable_credits: 1_036 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin couple both aged 68 claim the $48,000 exclusion and owe no tax + # With credits: line 7 = $70,250; standard deduction $16,795; exemptions + # $1,900; taxable income $51,555; tax $2,092. + # With the exclusion: line 16 = $48,000; line 7 = $22,250; standard + # deduction $25,110; taxable income $0; tax $0. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 40_250 + person2: + age: 68 + taxable_private_pension_income: 30_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: WI + output: + wi_income_tax_before_credits: 2_092 + wi_retirement_income_exclusion_elected: true + wi_income_tax: 0 + state_income_tax: 0 + household_state_tax_before_refundable_credits: 0 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin retiree who rents claims the exclusion and forgoes the school property tax credit + # With credits: line 7 = $41,250; standard deduction $10,956; taxable + # income $29,344; tax $1,159, less a renter's school property tax credit + # of at most $300 (line 16a), so at least $859. + # With the exclusion: line 7 = $17,250; standard deduction $13,560; + # taxable income $2,740; tax $96, with no credit. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 68 + taxable_private_pension_income: 41_250 + rent: 12_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: true + wi_income_tax: 96 + state_income_tax: 96 + household_state_tax_before_refundable_credits: 96 + household_refundable_state_tax_credits: 0 + +- name: Wisconsin filer aged 66 cannot claim the exclusion + # Line 7 = $41,250; standard deduction $10,956; taxable income $29,344; + # tax $1,159. + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 66 + taxable_private_pension_income: 41_250 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: WI + output: + wi_retirement_income_exclusion_elected: false + wi_income_tax: 1_159 + state_income_tax: 1_159 + household_state_tax_before_refundable_credits: 1_159 + household_refundable_state_tax_credits: 0 + +- name: Vermont household state tax includes the child care contribution on self-employment income + # 2024 Form IN-111 line 21, Child Care Contribution Worksheet: Schedule SE + # line 6 x 0.5 x 0.0011 = 100,000 x 0.9235 x 0.5 x 0.0011 = 50.79, added + # to income tax after credits (line 20) before refundable credits (26c). + # https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=12 + period: 2024 + absolute_error_margin: 0.01 + input: + state_code: VT + self_employment_income: 100_000 + vt_income_tax_before_refundable_credits: 1_000 + vt_refundable_credits: 200 + output: + household_state_tax_before_refundable_credits: 1_050.79 + household_refundable_state_tax_credits: 200 + +- name: Mississippi household state tax subtracts nonrefundable credits + # 2025 Mississippi resident instructions: head of family exemption $8,000, + # $1,500 per dependent, standard deduction $3,400; tax is 4.4% of taxable + # income over $10,000 (line 17). Credits on lines 18 and 19 reduce it. + # https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8 + # Taxable income = 30,000 - 3,400 - 8,000 - 3,000 = 15,600; + # tax = 0.044 x 5,600 = 246.40; less a credit of 159 = 87.40. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 30 + employment_income: 30_000 + child1: + age: 3 + child2: + age: 5 + tax_units: + tax_unit: + members: [parent, child1, child2] + ms_cdcc: 159 + households: + household: + members: [parent, child1, child2] + state_code: MS + output: + household_state_tax_before_refundable_credits: 87.40 + household_refundable_state_tax_credits: 0 + +- name: Colorado household state tax includes the alternative minimum tax + # 2025 Form DR 0104: line 13 (tax) + line 14 (alternative minimum tax) + + # line 15 = line 16, before the refundable credits on line 31. + # https://tax.colorado.gov/sites/tax/files/documents/Book104_2025.pdf#page=11 + period: 2025 + input: + state_code: CO + co_income_tax_before_refundable_credits: 4_000 + co_amt: 500 + co_refundable_credits: 100 + output: + household_state_tax_before_refundable_credits: 4_500 + household_refundable_state_tax_credits: 100 diff --git a/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml b/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml index 36cc117ce9e..025b1eebd67 100644 --- a/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml +++ b/policyengine_us/tests/policy/contrib/states/mi/mi_surtax.yaml @@ -100,3 +100,20 @@ state_code: MI output: mi_surtax: 0 + +- name: Surtax reaches Michigan income tax, state income tax and the household total + period: 2027 + reforms: policyengine_us.reforms.states.mi.surtax.mi_surtax + input: + gov.contrib.states.mi.surtax.in_effect: True + mi_taxable_income: 600_000 + mi_income_tax_before_refundable_credits: 25_000 + mi_refundable_credits: 0 + filing_status: SINGLE + state_code: MI + output: + # (600_000 - 500_000) * 0.05 = 5_000; 25_000 + 5_000 = 30_000 + mi_surtax: 5_000 + mi_income_tax: 30_000 + state_income_tax: 30_000 + household_state_tax_before_refundable_credits: 30_000 diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py index 34faef8c568..fa7c302c106 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax.py @@ -8,8 +8,5 @@ class ms_income_tax(Variable): unit = USD definition_period = YEAR defined_for = StateCode.MS - - def formula(tax_unit, period, parameters): - tax_before_credits = tax_unit("ms_income_tax_before_credits_unit", period) - non_refundable_credits = tax_unit("ms_non_refundable_credits", period) - return max_(tax_before_credits - non_refundable_credits, 0) + adds = ["ms_income_tax_before_refundable_credits"] + subtracts = ["ms_refundable_credits"] diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py new file mode 100644 index 00000000000..30ac7cbed53 --- /dev/null +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_income_tax_before_refundable_credits.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class ms_income_tax_before_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "Mississippi income tax before refundable credits" + unit = USD + definition_period = YEAR + # Form 80-105 line 17 (total income tax due) less the credits on lines + # 18 and 19, which may not exceed it. + reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=8" + defined_for = StateCode.MS + + def formula(tax_unit, period, parameters): + tax_before_credits = tax_unit("ms_income_tax_before_credits_unit", period) + non_refundable_credits = tax_unit("ms_non_refundable_credits", period) + return max_(tax_before_credits - non_refundable_credits, 0) diff --git a/policyengine_us/variables/gov/states/tax/income/state_income_tax.py b/policyengine_us/variables/gov/states/tax/income/state_income_tax.py index b4d2e47076b..0a4e46fd7ac 100644 --- a/policyengine_us/variables/gov/states/tax/income/state_income_tax.py +++ b/policyengine_us/variables/gov/states/tax/income/state_income_tax.py @@ -7,24 +7,8 @@ class state_income_tax(Variable): label = "state income tax" unit = USD definition_period = YEAR - - def formula(tax_unit, period, parameters): - income_tax_before_refundable_credits = add( - tax_unit, - period, - ["state_income_tax_before_refundable_credits"], - ) - refundable_credits = add(tax_unit, period, ["state_refundable_credits"]) - default_tax = income_tax_before_refundable_credits - refundable_credits - state_code = tax_unit.household("state_code", period) - return select( - [ - state_code == StateCode.WI, - state_code == StateCode.NJ, - ], - [ - tax_unit("wi_income_tax", period), - tax_unit("nj_income_tax", period), - ], - default=default_tax, - ) + # Household net income is built from these two totals, so a state's own + # final tax must equal them. State-specific elections belong in that + # state's components, not here. + adds = ["state_income_tax_before_refundable_credits"] + subtracts = ["state_refundable_credits"] diff --git a/policyengine_us/variables/household/income/household/household_state_income_tax.py b/policyengine_us/variables/household/income/household/household_state_income_tax.py index dc8fcb6e284..d319116c4e7 100644 --- a/policyengine_us/variables/household/income/household/household_state_income_tax.py +++ b/policyengine_us/variables/household/income/household/household_state_income_tax.py @@ -8,22 +8,5 @@ class household_state_income_tax(Variable): label = "household State tax" unit = USD definition_period = YEAR - - def formula(tax_unit, period, parameters): - default_tax = add( - tax_unit, - period, - ["state_income_tax_before_refundable_credits"], - ) - add(tax_unit, period, ["state_refundable_credits"]) - state_code = tax_unit.household("state_code", period) - return select( - [ - state_code == StateCode.WI, - state_code == StateCode.NJ, - ], - [ - tax_unit("wi_income_tax", period), - tax_unit("nj_income_tax", period), - ], - default=default_tax, - ) + adds = ["state_income_tax_before_refundable_credits"] + subtracts = ["state_refundable_credits"] From d6a300f8385c1dadbb62fb6456894cb3855b3090 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:01:00 -0400 Subject: [PATCH 5/5] Address review r1: test scope, Hypothesis shrink, comments - Say which properties find a new state's gap and what property 3 guards. - Add a seeded 2023 population for the formula states, so Montana's separate filing on one form is always exercised. - Skip Hypothesis shrinking: each example builds a full simulation. - Correct the NC and NJ notes, the household_state_income_tax comment, and note that the Michigan surtax reform must stay unconditional because the baseline list adds mi_surtax. Co-Authored-By: Claude Opus 5.5 --- policyengine_us/reforms/states/mi/surtax.py | 2 + ..._state_income_tax_accounting_invariants.py | 41 ++++++++++++++++--- .../household/household_state_income_tax.py | 2 +- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/policyengine_us/reforms/states/mi/surtax.py b/policyengine_us/reforms/states/mi/surtax.py index a450f704f9b..c60e5f323af 100644 --- a/policyengine_us/reforms/states/mi/surtax.py +++ b/policyengine_us/reforms/states/mi/surtax.py @@ -43,6 +43,8 @@ def apply(self): def create_mi_surtax_reform(parameters, period, bypass: bool = False): # Always return the reform - the in_effect check happens in the formula + # Keep this unconditional: the baseline state_income_tax_before_refundable_credits + # list adds mi_surtax, so the variable must exist in every system. return create_mi_surtax() diff --git a/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py index a9ad866c6d5..10abc0b5d0e 100644 --- a/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py +++ b/policyengine_us/tests/core/test_state_income_tax_accounting_invariants.py @@ -11,7 +11,7 @@ minimum tax, Mississippi's nonrefundable credits, and the contributed Michigan surtax. -Three properties guard against a sixth: +Three properties: 1. Structure, with no simulation. Where a state's ``xx_income_tax`` is a sum, it adds exactly that state's members of the before-refundable list and @@ -24,18 +24,24 @@ household pays it to the state, but it is not an income tax (``state_income_tax_excludes_use_tax.yaml``). +Properties 1 and 2 find a new state's gap. Property 3 holds by construction +while ``state_income_tax`` is the plain sum of the two lists; it fails if an +override inside ``state_income_tax``, like the Wisconsin and New Jersey ones +this test replaced, comes back. + Properties 2 and 3 must hold for every tax unit and household, so the tests draw a seeded population in each of the 50 states and DC and run it as one vectorized simulation. Where a state's ``xx_income_tax`` is a sum, property 1 -already implies property 2, so Hypothesis draws further households only in the -states that compute it with a formula. YAML cases cannot express these +already implies property 2, so the states that compute it with a formula get +more: a seeded 2023 population, for Montana's separate filing on one form, and +Hypothesis draws in 2023 and 2025. YAML cases cannot express these properties: each compares two computed totals on every row instead of one total with a stated amount. """ import numpy as np import pytest -from hypothesis import HealthCheck, given, settings +from hypothesis import HealthCheck, Phase, given, settings from hypothesis import strategies as st from policyengine_us import Simulation @@ -61,9 +67,14 @@ # Lower of the joint return and separate returns on one form, each after # refundable credits. "MT", - # No refundable credits: the before-refundable list holds nc_income_tax. + # No refundable credits in baseline, so the before-refundable list holds + # nc_income_tax itself. The contributed EITC and child care credit reforms + # net their refundable credits inside nc_income_tax, which keeps NC out of + # the refundable list. "NC", - # No tax at or below the filing threshold. + # No tax at or below the filing threshold. The zero-out is in both + # nj_income_tax_before_refundable_credits and nj_income_tax; the second + # copy keeps tests that set the first as an input right. "NJ", # Tax before refundable credits less refundable credits, both following # the retirement income exclusion election. @@ -462,10 +473,28 @@ def formula_state_households(draw): return state, [unit] +def test_formula_states_keep_the_properties_in_2023(): + # Montana lets spouses file separately on one form through 2023. + households = [ + (state, units) + for state, units in _seeded_households() + if state in STATES_WITH_FORMULA_INCOME_TAX + ] + simulation = Simulation(situation=_situation(households, 2023)) + separately = np.asarray(simulation.calculate("mt_files_separately", 2023)) + assert separately.sum() >= 1 + for state in sorted(STATES_WITH_FORMULA_INCOME_TAX): + _check_state_income_tax_equals_own_income_tax(simulation, state, 2023) + _check_household_totals(simulation, 2023) + + @settings( max_examples=4, deadline=None, derandomize=True, + # Each example builds a full simulation, so shrinking a failure could run + # for hours in CI. A failing example is reported unshrunk. + phases=[Phase.explicit, Phase.generate], suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], ) @given( diff --git a/policyengine_us/variables/household/income/household/household_state_income_tax.py b/policyengine_us/variables/household/income/household/household_state_income_tax.py index d319116c4e7..ce0b7da2ba6 100644 --- a/policyengine_us/variables/household/income/household/household_state_income_tax.py +++ b/policyengine_us/variables/household/income/household/household_state_income_tax.py @@ -2,7 +2,7 @@ class household_state_income_tax(Variable): - # This definition contains all modelled states, and exists to solve circular dependencies in state_income_tax. + # The same sum as state_income_tax, kept for callers that read this name. value_type = float entity = TaxUnit label = "household State tax"