diff --git a/changelog.d/fix-wi-retirement-exclusion-standard-deduction.fixed.md b/changelog.d/fix-wi-retirement-exclusion-standard-deduction.fixed.md new file mode 100644 index 00000000000..8cd5c459748 --- /dev/null +++ b/changelog.d/fix-wi-retirement-exclusion-standard-deduction.fixed.md @@ -0,0 +1 @@ +Fix Wisconsin Act 15 retirement income exclusion to reduce WI income before the standard-deduction phaseout, so elderly filers with retirement income are no longer over-taxed. 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 ed7f3527215..02452cb7d60 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 @@ -78,11 +78,15 @@ members: [person1] state_code: WI output: - # AGI = $45k → Line 17 ineligible (> $15k), Line 16 = $20k - # Standard tax ≈ $1,377, exclusion tax ≈ $474 - # min($1377, $474) = $474 → exclusion path wins + # AGI = $45k → Line 17 ineligible (> $15k), Line 16 = $20k. + # The $20k Line 16 subtraction lowers WI income to $25k, where the + # standard deduction phaseout is smaller, so the standard deduction is + # $12,906 (vs $10,506 at $45k) — $2,400 larger. Exclusion taxable income + # = ($45k - $10,506 - $950 exemption) - $20k - $2,400 = $11,144; + # exclusion tax = $11,144 * 0.035 = $390. + # Standard tax ≈ $1,344 → min($1,344, $390) = $390 → exclusion path wins. wi_retirement_income_exclusion_amount: 20_000 - wi_income_tax: 474 + wi_income_tax: 390 - name: E2E - Joint both 68, large pension, exclusion zeroes out tax absolute_error_margin: 1 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 5de3ef2d4aa..25e2b9b9d1c 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 @@ -92,3 +92,131 @@ # exclusion_taxinc = max(0, 18000 - 15000 + 0) = 3000 # exclusion_tax = 3000 * 0.035 = 105 wi_retirement_income_exclusion_tax: 105 + +# Regression for issue #8817: the Line 16 subtraction must reduce WI income +# BEFORE the standard-deduction phaseout, so a large exclusion recovers +# standard deduction that the pre-subtraction income had phased out. +- name: E2E - Joint 67+ with retirement income above the standard-deduction phaseout + absolute_error_margin: 1 + period: 2025 + input: + people: + person1: + age: 70 + taxable_pension_income: 90_000 + is_tax_unit_head: true + person2: + age: 70 + taxable_pension_income: 30_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + households: + household: + members: [person1, person2] + state_code: WI + output: + # WI income (line 7) = $120,000; Line 16 exclusion = min($48k, $120k) = $48k. + # Standard deduction at $120k = $6,956; at ($120k - $48k) = $72k it is + # $16,449 — $9,493 larger (the phaseout is 0.19778 * $48k). Exclusion + # taxable income = $72k - $16,449 - $1,900 exemption = $53,651; joint tax + # = 19,580*0.035 + (53,651-19,580)*0.044 = $2,184. Before the fix the + # phaseout stayed keyed to $120k, giving ~$2,602 and over-taxing the couple. + wi_retirement_income_exclusion_amount: 48_000 + wi_retirement_income_exclusion_tax: 2_184 + +# --- Boundary tests for the #8817 standard-deduction-phaseout ordering fix --- + +# Below the single phaseout start ($19,550, 2025) the standard deduction is at +# its max for both full and exclusion-reduced income, so the extra deduction the +# fix unlocks is $0 (no regression vs. a plain taxinc - line16 subtraction). +- name: Exclusion path - below phaseout start, extra standard deduction is zero (no regression) + absolute_error_margin: 0.01 + period: 2025 + input: + age: 68 + taxable_pension_income: 15_000 + is_tax_unit_head: true + filing_status: SINGLE + state_code: WI + wi_taxable_income: 20_000 + wi_retirement_income_subtraction: 0 + output: + # wi_agi = 15,000 < 19,550 -> SD at max ($13,560) for full and reduced; extra_SD = 0. + # line16 = min(24,000, 15,000) = 15,000. exclusion_taxinc = 20,000 - 15,000 = 5,000. + # tax = 5,000 * 0.035 = 175. + wi_retirement_income_exclusion_tax: 175 + +# Fully phased-out top end (age 85, very high income): SD = $0 for both full and +# reduced income, so extra_SD = 0 and the exclusion is a plain subtraction. +- name: Exclusion path - fully phased-out top end, extra standard deduction is zero + absolute_error_margin: 0.01 + period: 2025 + input: + age: 85 + taxable_pension_income: 200_000 + is_tax_unit_head: true + filing_status: SINGLE + state_code: WI + wi_taxable_income: 34_000 + wi_retirement_income_subtraction: 0 + output: + # wi_agi = 200,000 and reduced = 176,000 are both past the single full-phaseout + # point ($132,550) -> SD = 0 for both; extra_SD = 0. line16 = 24,000. + # exclusion_taxinc = 34,000 - 24,000 = 10,000. tax = 10,000 * 0.035 = 350. + wi_retirement_income_exclusion_tax: 350 + +# Partial-unlock boundary: full income ($140k) is past the single full-phaseout +# point (SD_full = 0) while reduced income ($116k) lands INSIDE the phaseout, so +# the fix unlocks a positive partial deduction. +- name: Exclusion path - partial unlock (full income past phaseout end, reduced income inside it) + absolute_error_margin: 1 + period: 2025 + input: + age: 68 + taxable_pension_income: 140_000 + is_tax_unit_head: true + filing_status: SINGLE + state_code: WI + wi_taxable_income: 40_000 + wi_retirement_income_subtraction: 0 + output: + # SD_full(140,000) = 0; line16 = 24,000; reduced = 116,000. + # SD_reduced(116,000) = 13,560 - 0.12*(116,000-19,550) = 1,986; extra_SD = 1,986. + # exclusion_taxinc = 40,000 - 24,000 - 1,986 = 14,014. tax = 14,014 * 0.035 = 490.49. + # (Without the fix extra_SD would be 0 -> 16,000 -> ~571.88.) + wi_retirement_income_exclusion_tax: 490.49 + +# Joint with only ONE spouse age 67+: the exclusion uses the per-person single +# cap ($24,000), NOT the $48,000 both-eligible joint pool. +- name: Exclusion path - joint with one spouse 67+, uses the $24,000 per-person cap + absolute_error_margin: 0.01 + period: 2025 + input: + people: + person1: + age: 68 + taxable_pension_income: 24_000 + is_tax_unit_head: true + person2: + age: 60 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + wi_taxable_income: 30_000 + wi_retirement_income_subtraction: 0 + households: + household: + members: [person1, person2] + state_code: WI + output: + # Only person1 (68) is eligible -> line16 = min(24,000, 24,000) = 24,000 (single + # per-person cap), not the $48k joint pool. wi_agi = 24,000 < 28,210 joint + # phaseout start -> extra_SD = 0. exclusion_taxinc = 30,000 - 24,000 = 6,000. + # tax = 6,000 * 0.035 = 210. + wi_retirement_income_exclusion_amount: 24_000 + wi_retirement_income_exclusion_tax: 210 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 9d51993e9be..ff92acb6368 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 @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.wi.tax.income.wi_standard_deduction import ( + wi_standard_deduction_for_income, +) class wi_retirement_income_exclusion_tax(Variable): @@ -21,9 +24,23 @@ def formula(tax_unit, period, parameters): # Line 16 is claimed, so we simply subtract Line 16 here. line16 = tax_unit("wi_retirement_income_exclusion_amount", period) taxinc = tax_unit("wi_taxable_income", period) - exclusion_taxinc = max_(0, taxinc - line16) + # The Line 16 subtraction reduces WI income (Form 1 line 7), and the + # standard deduction (line 8) is looked up on that post-subtraction + # income. wi_taxable_income embeds the standard deduction computed on + # the higher pre-subtraction income, so add back the extra standard + # deduction the subtraction unlocks. This is algebraically equivalent + # to recomputing taxable income on the reduced WI income, and keeps + # the phaseout from staying keyed to the pre-subtraction income. fstatus = tax_unit("filing_status", period) + 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 + ) + extra_standard_deduction = standard_deduction_reduced - standard_deduction_full + exclusion_taxinc = max_(0, taxinc - line16 - extra_standard_deduction) + statuses = fstatus.possible_values p = parameters(period).gov.states.wi.tax.income return select( diff --git a/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py b/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py index b816f6ab6f1..362bdf7f14b 100644 --- a/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py +++ b/policyengine_us/variables/gov/states/wi/tax/income/wi_standard_deduction.py @@ -1,6 +1,34 @@ from policyengine_us.model_api import * +def wi_standard_deduction_for_income(income, filing_status, parameters, period): + # Wisconsin standard deduction (Form 1 line 8) as a function of WI income. + # Factored out of the variable below so the retirement-income-exclusion + # path can look the deduction up on income reduced by the Schedule SB + # line-16 subtraction (the phaseout otherwise stays keyed to the higher + # pre-subtraction income). + deduction = parameters(period).gov.states.wi.tax.income.deductions + statuses = filing_status.possible_values + max_amount = deduction.standard.max[filing_status] + phase_out_amount = select( + [ + filing_status == statuses.SINGLE, + filing_status == statuses.JOINT, + filing_status == statuses.SURVIVING_SPOUSE, + filing_status == statuses.SEPARATE, + filing_status == statuses.HEAD_OF_HOUSEHOLD, + ], + [ + deduction.standard.phase_out.single.calc(income), + deduction.standard.phase_out.joint.calc(income), + deduction.standard.phase_out.joint.calc(income), + deduction.standard.phase_out.separate.calc(income), + deduction.standard.phase_out.head_of_household.calc(income), + ], + ) + return max_(0, max_amount - phase_out_amount) + + class wi_standard_deduction(Variable): value_type = float entity = TaxUnit @@ -8,33 +36,18 @@ class wi_standard_deduction(Variable): unit = USD definition_period = YEAR reference = ( - "https://www.revenue.wi.gov/TaxForms2021/2021-Form1f.pdf" - "https://www.revenue.wi.gov/TaxForms2021/2021-Form1-Inst.pdf" - "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://www.revenue.wi.gov/TaxForms2021/2021-Form1f.pdf", + "https://www.revenue.wi.gov/TaxForms2021/2021-Form1-Inst.pdf", + "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", + # Standard Deduction Table (keyed to WI income, line 7) and the statute, corroborating the phaseout: + "https://www.revenue.wi.gov/TaxForms2025/2025-Form1-inst.pdf#page=35", + "https://docs.legis.wisconsin.gov/statutes/statutes/71/i/05/22", ) defined_for = StateCode.WI def formula(tax_unit, period, parameters): fstatus = tax_unit("filing_status", period) - deduction = parameters(period).gov.states.wi.tax.income.deductions - max_amount = deduction.standard.max[fstatus] agi = tax_unit("wi_agi", period) - phase_out_amount = select( - [ - fstatus == fstatus.possible_values.SINGLE, - fstatus == fstatus.possible_values.JOINT, - fstatus == fstatus.possible_values.SURVIVING_SPOUSE, - fstatus == fstatus.possible_values.SEPARATE, - fstatus == fstatus.possible_values.HEAD_OF_HOUSEHOLD, - ], - [ - deduction.standard.phase_out.single.calc(agi), - deduction.standard.phase_out.joint.calc(agi), - deduction.standard.phase_out.joint.calc(agi), - deduction.standard.phase_out.separate.calc(agi), - deduction.standard.phase_out.head_of_household.calc(agi), - ], - ) - return max_(0, max_amount - phase_out_amount) + return wi_standard_deduction_for_income(agi, fstatus, parameters, period)