diff --git a/changelog.d/fix-mo-refundable-eitc-reform-crash.fixed.md b/changelog.d/fix-mo-refundable-eitc-reform-crash.fixed.md new file mode 100644 index 00000000000..5a8f821d577 --- /dev/null +++ b/changelog.d/fix-mo-refundable-eitc-reform-crash.fixed.md @@ -0,0 +1 @@ +Fixed the Missouri refundable EITC contrib reform, which crashed at calculation time and paid no refundable credit to filers with no Missouri tax liability. diff --git a/policyengine_us/reforms/states/mo/eitc/mo_refundable_eitc_reform.py b/policyengine_us/reforms/states/mo/eitc/mo_refundable_eitc_reform.py index eb7d30309eb..b87586aba11 100644 --- a/policyengine_us/reforms/states/mo/eitc/mo_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/mo/eitc/mo_refundable_eitc_reform.py @@ -20,7 +20,11 @@ class mo_refundable_wftc(Variable): defined_for = StateCode.MO def formula(tax_unit, period, parameters): - return tax_unit("mo_wftc", period) + # Use the potential (uncapped) WFTC so the full credit is paid as + # a refund; `mo_wftc` is capped at tax liability and would zero out + # the credit for the low-liability filers refundability is meant + # to help. + return tax_unit("mo_wftc_potential", period) class mo_non_refundable_wftc(Variable): value_type = float @@ -43,7 +47,12 @@ class mo_non_refundable_credits(Variable): defined_for = StateCode.MO def formula(tax_unit, period, parameters): - # Include the nonrefundable WFTC (0 when reform is in effect) + # Today `gov.states.mo.tax.income.credits.non_refundable` is + # just `[mo_wftc]`, so returning the (zeroed) reform replacement + # is equivalent to the baseline. If Missouri later adds a second + # nonrefundable credit, this formula would silently drop it — + # at that point switch to summing the full nonrefundable list + # with `mo_wftc` filtered out, mirroring the UT/OH fix pattern. return tax_unit("mo_non_refundable_wftc", period) class mo_refundable_credits(Variable): @@ -53,14 +62,19 @@ class mo_refundable_credits(Variable): unit = USD definition_period = YEAR defined_for = StateCode.MO + # The baseline variable computes via `adds`. We replace it with a + # formula, so clear the inherited computation modes to avoid mixing + # `formula` with `adds`/`subtracts` (rejected by the core engine). + adds = None + subtracts = None def formula(tax_unit, period, parameters): - # Standard refundable credits - other_refundable = add( - tax_unit, - period, - "gov.states.mo.tax.income.credits.refundable", - ) + # Standard refundable credits, resolved to the list of variable + # names before passing to `add` (which iterates variable names). + refundable_credits = parameters( + period + ).gov.states.mo.tax.income.credits.refundable + other_refundable = add(tax_unit, period, refundable_credits) # Add refundable WFTC (positive when reform is in effect) refundable_wftc = tax_unit("mo_refundable_wftc", period) return other_refundable + refundable_wftc diff --git a/policyengine_us/tests/code_health/test_non_refundable_credit_downstream_consumers.py b/policyengine_us/tests/code_health/test_non_refundable_credit_downstream_consumers.py index 9ea675c3d41..b91400e3842 100644 --- a/policyengine_us/tests/code_health/test_non_refundable_credit_downstream_consumers.py +++ b/policyengine_us/tests/code_health/test_non_refundable_credit_downstream_consumers.py @@ -21,9 +21,6 @@ "md_non_refundable_eitc": { "variables/gov/states/md/tax/income/credits/eitc/md_eitc.py", }, - "mo_wftc": { - "reforms/states/mo/eitc/mo_refundable_eitc_reform.py", - }, "ny_household_credit": { "reforms/states/ny/wftc/ny_working_families_tax_credit.py", "variables/gov/states/ny/tax/income/credits/ny_eitc.py", diff --git a/policyengine_us/tests/policy/contrib/states/mo/child_poverty_impact_dashboard/eitc/mo_refundable_eitc.yaml b/policyengine_us/tests/policy/contrib/states/mo/child_poverty_impact_dashboard/eitc/mo_refundable_eitc.yaml index 0b970f6670d..9145dbcb701 100644 --- a/policyengine_us/tests/policy/contrib/states/mo/child_poverty_impact_dashboard/eitc/mo_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/mo/child_poverty_impact_dashboard/eitc/mo_refundable_eitc.yaml @@ -1,19 +1,26 @@ -- name: Case 1 - MO WFTC is refundable when reform active +# The reform makes the Missouri WFTC fully refundable. It pays the uncapped +# potential credit (mo_wftc_potential), so these tests drive the credit from +# the federal EITC rather than injecting the capped mo_wftc. The 2024 WFTC +# match is 20% of the federal EITC. + +- name: Case 1 - MO WFTC is fully refundable (uncapped) when reform active period: 2024 reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc input: state_code: MO - mo_wftc: 500 + eitc: 2_500 output: - mo_refundable_wftc: 500 + mo_wftc_potential: 500 # 2,500 * 0.2 + mo_wftc: 0 # capped at zero tax liability + mo_refundable_wftc: 500 # full potential paid as a refund mo_non_refundable_wftc: 0 -- name: Case 2 - MO WFTC is 0 when WFTC is 0 +- name: Case 2 - MO refundable WFTC is 0 when the federal EITC is 0 period: 2024 reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc input: state_code: MO - mo_wftc: 0 + eitc: 0 output: mo_refundable_wftc: 0 mo_non_refundable_wftc: 0 @@ -23,7 +30,7 @@ reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc input: state_code: CA - mo_wftc: 500 + eitc: 2_500 output: mo_refundable_wftc: 0 mo_non_refundable_wftc: 0 @@ -33,6 +40,6 @@ reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc input: state_code: MO - mo_wftc: 500 + eitc: 2_500 output: mo_non_refundable_credits: 0 diff --git a/policyengine_us/tests/policy/reform/mo_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/mo_refundable_eitc.yaml new file mode 100644 index 00000000000..7aeee17986b --- /dev/null +++ b/policyengine_us/tests/policy/reform/mo_refundable_eitc.yaml @@ -0,0 +1,93 @@ +# Tests for the Missouri refundable EITC (WFTC) contrib reform. +# Regression coverage for https://github.com/PolicyEngine/policyengine-us/issues/8640 +# (the reform previously crashed at calculation time and, once running, paid no +# refundable credit to zero-liability filers). + +- name: Reform pays the full potential WFTC as a refundable credit at zero liability + absolute_error_margin: 0.01 + period: 2026 + reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc_reform.mo_refundable_eitc + input: + state_code: MO + eitc: 5_000 + output: + # 2026 WFTC match is 20% of the federal EITC: 5,000 * 0.2 = 1,000. + mo_wftc_potential: 1_000 + # The capped credit is 0 at zero liability; the fix pays the uncapped + # potential instead, so this contrast is the whole point of the reform. + mo_wftc: 0 + # WFTC is moved out of the nonrefundable bucket... + mo_non_refundable_wftc: 0 + mo_non_refundable_credits: 0 + # ...and paid in full as refundable, even with no Missouri tax liability. + mo_refundable_wftc: 1_000 + mo_refundable_credits: 1_000 + +- name: Reform does not double count when liability already absorbs the WFTC + absolute_error_margin: 0.01 + period: 2026 + reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc_reform.mo_refundable_eitc + input: + state_code: MO + eitc: 5_000 + mo_income_tax_before_credits: 1_000 + output: + # Full potential paid as refundable; nonrefundable portion stays zero. + mo_refundable_wftc: 1_000 + mo_non_refundable_wftc: 0 + mo_refundable_credits: 1_000 + # The $1,000 liability is offset exactly once by the refundable credit: + # 1,000 before refundable credits - 1,000 refundable = 0. If the credit + # were double counted this would go negative. + mo_income_tax_before_refundable_credits: 1_000 + mo_income_tax: 0 + +- name: Reform preserves other Missouri refundable credits (does not drop them) + absolute_error_margin: 0.01 + period: 2026 + reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc_reform.mo_refundable_eitc + input: + state_code: MO + eitc: 5_000 + # The other Missouri refundable credit in + # `gov.states.mo.tax.income.credits.refundable` is mo_property_tax_credit. + # Pin it to a positive value: the reform's mo_refundable_credits formula + # must sum it alongside mo_refundable_wftc, not replace it. + mo_property_tax_credit: 300 + output: + mo_refundable_wftc: 1_000 + # 1,000 (refundable WFTC) + 300 (property tax credit) = 1,300. + mo_refundable_credits: 1_300 + +- name: Reform pays zero when the federal EITC is zero (zero-EITC boundary) + absolute_error_margin: 0.01 + period: 2026 + reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc_reform.mo_refundable_eitc + input: + state_code: MO + eitc: 0 + output: + mo_wftc_potential: 0 + mo_refundable_wftc: 0 + mo_refundable_credits: 0 + +- name: Reform pays in full when liability only partially absorbs the WFTC + absolute_error_margin: 0.01 + period: 2026 + reforms: policyengine_us.reforms.states.mo.eitc.mo_refundable_eitc_reform.mo_refundable_eitc + input: + state_code: MO + eitc: 5_000 + # Potential WFTC = 1,000. Liability of 400 would absorb only part of the + # baseline (nonrefundable-capped) WFTC; the reform should still pay the + # full uncapped potential as refundable. + mo_income_tax_before_credits: 400 + output: + mo_wftc_potential: 1_000 + mo_refundable_wftc: 1_000 + mo_refundable_credits: 1_000 + # The full $1,000 refundable credit flows through against $400 of + # liability, producing a $600 refund. mo_income_tax goes negative + # (refund) by exactly the surplus — proves no double-counting (would + # be -$1,000 if the credit were applied twice). + mo_income_tax: -600