From 373c5b883c70b7bf1387b6f72c350e452f1633d5 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 15 Jun 2026 22:07:57 -0400 Subject: [PATCH 1/2] Fix UT fully refundable EITC reform crash and zero-refund bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the MO fix in #8642 for Utah's analogous bundled reform. - ut_refundable_credits: clear inherited adds/subtracts so the reform's formula doesn't trip the core engine's strict computation-mode check. - ut_non_refundable_credits: resolve the non_refundable parameter to a list of variable names before passing it to add() — the previous literal string was iterated character-by-character at calc time. - ut_fully_refundable_eitc: pay the uncapped potential credit (ut_eitc_potential) instead of the tax-liability-capped ut_eitc so the reform delivers a refund to zero-liability filers. Fixes #8644 Co-Authored-By: Claude Opus 4.7 --- ...ully-refundable-eitc-reform-crash.fixed.md | 1 + .../ut_fully_refundable_eitc_reform.py | 24 +++++++--- .../eitc/ut_fully_refundable_eitc.yaml | 30 ++++++++---- .../reform/ut_fully_refundable_eitc.yaml | 46 +++++++++++++++++++ 4 files changed, 87 insertions(+), 14 deletions(-) create mode 100644 changelog.d/fix-ut-fully-refundable-eitc-reform-crash.fixed.md create mode 100644 policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml diff --git a/changelog.d/fix-ut-fully-refundable-eitc-reform-crash.fixed.md b/changelog.d/fix-ut-fully-refundable-eitc-reform-crash.fixed.md new file mode 100644 index 00000000000..6008dd8d828 --- /dev/null +++ b/changelog.d/fix-ut-fully-refundable-eitc-reform-crash.fixed.md @@ -0,0 +1 @@ +Fixed the Utah fully refundable EITC contrib reform, which crashed at calculation time and paid no refundable credit to filers with no Utah tax liability. diff --git a/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py b/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py index b5878cccf97..1fc179c25f8 100644 --- a/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py @@ -21,7 +21,12 @@ class ut_fully_refundable_eitc(Variable): defined_for = StateCode.UT def formula(tax_unit, period, parameters): - return tax_unit("ut_eitc", period) + # Use the potential (uncapped) UT EITC so the full credit is paid + # as a refund; `ut_eitc` is capped at tax liability and would zero + # out the credit for the low-liability filers refundability is + # meant to help. `ut_eitc_potential` still applies the W-2 wages + # cap mandated by Utah Code § 59-10-1044. + return tax_unit("ut_eitc_potential", period) class ut_non_refundable_eitc(Variable): value_type = float @@ -44,12 +49,14 @@ class ut_non_refundable_credits(Variable): defined_for = StateCode.UT def formula(tax_unit, period, parameters): - # Use parameter-driven approach: get baseline non-refundable credits - # then subtract ut_eitc (now refundable) and add back ut_non_refundable_eitc (0) + # Baseline non-refundable credits, resolved to the list of + # variable names before passing to `add` (which iterates variable + # names, not parameter paths). + non_refundable_list = parameters( + period + ).gov.states.ut.tax.income.credits.non_refundable baseline_non_refundable = add( - tax_unit, - period, - "gov.states.ut.tax.income.credits.non_refundable", + tax_unit, period, non_refundable_list ) # Remove ut_eitc from non-refundable (it's now handled separately) ut_eitc = tax_unit("ut_eitc", period) @@ -64,6 +71,11 @@ class ut_refundable_credits(Variable): unit = USD definition_period = YEAR defined_for = StateCode.UT + # 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): # Add the fully refundable EITC (positive when reform is in effect) diff --git a/policyengine_us/tests/policy/contrib/states/ut/child_poverty_impact_dashboard/eitc/ut_fully_refundable_eitc.yaml b/policyengine_us/tests/policy/contrib/states/ut/child_poverty_impact_dashboard/eitc/ut_fully_refundable_eitc.yaml index 1dbfbaecfe5..83e7741d426 100644 --- a/policyengine_us/tests/policy/contrib/states/ut/child_poverty_impact_dashboard/eitc/ut_fully_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/ut/child_poverty_impact_dashboard/eitc/ut_fully_refundable_eitc.yaml @@ -1,11 +1,22 @@ -- name: Case 1 - UT EITC is fully refundable when reform active +# The reform makes the Utah EITC fully refundable. It pays the uncapped +# potential credit (ut_eitc_potential), so these tests drive the credit from +# the federal EITC rather than injecting the capped ut_eitc. The 2024 UT EITC +# rate is 20% of the federal EITC; the credit is also capped at W-2 wages +# (Utah Code § 59-10-1044), so the head's employment income is set high +# enough not to bind. + +- name: Case 1 - UT EITC is fully refundable (uncapped) when reform active period: 2024 reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc input: state_code: UT - ut_eitc: 500 + eitc: 2_500 + ut_income_tax_before_non_refundable_credits: 0 + employment_income: 30_000 output: - ut_fully_refundable_eitc: 500 + ut_eitc_potential: 500 # 2,500 * 0.2 + ut_eitc: 0 # capped at zero tax liability + ut_fully_refundable_eitc: 500 # full potential paid as a refund ut_non_refundable_eitc: 0 - name: Case 2 - UT refundable credits include EITC when reform active @@ -13,7 +24,8 @@ reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc input: state_code: UT - ut_eitc: 1_000 + eitc: 5_000 + employment_income: 30_000 output: ut_fully_refundable_eitc: 1_000 ut_refundable_credits: 1_000 @@ -23,17 +35,18 @@ reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc input: state_code: UT - ut_eitc: 300 + eitc: 1_500 + employment_income: 30_000 output: ut_fully_refundable_eitc: 300 ut_refundable_credits: 300 -- name: Case 4 - UT EITC is 0 when EITC is 0 +- name: Case 4 - UT refundable EITC is 0 when the federal EITC is 0 period: 2024 reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc input: state_code: UT - ut_eitc: 0 + eitc: 0 output: ut_fully_refundable_eitc: 0 ut_refundable_credits: 0 @@ -43,6 +56,7 @@ reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc input: state_code: CA - ut_eitc: 500 + eitc: 2_500 + employment_income: 30_000 output: ut_fully_refundable_eitc: 0 diff --git a/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml new file mode 100644 index 00000000000..953d627cebb --- /dev/null +++ b/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml @@ -0,0 +1,46 @@ +# Tests for the Utah fully refundable EITC contrib reform. +# Regression coverage for https://github.com/PolicyEngine/policyengine-us/issues/8644 +# (the reform previously crashed at calculation time and, once running, paid no +# refundable credit to zero-liability filers). + +- name: Reform pays the full potential UT EITC as a refundable credit at zero liability + period: 2026 + reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc_reform.ut_fully_refundable_eitc + input: + state_code: UT + eitc: 5_000 + # Pin UT tax liability to zero so the nonrefundable cap binds at $0 — + # the test is whether the reform pays the credit despite the cap. + ut_income_tax_before_non_refundable_credits: 0 + # Set wages comfortably above the UT EITC potential so the W-2 wages cap + # (Utah Code § 59-10-1044) does not bind. + employment_income: 30_000 + output: + # 2026 UT EITC rate is 20% of the federal EITC: 5,000 * 0.2 = 1,000. + ut_eitc_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. + ut_eitc: 0 + # EITC is moved out of the nonrefundable bucket... + ut_non_refundable_eitc: 0 + # ...and paid in full as refundable, even with no Utah tax liability. + ut_fully_refundable_eitc: 1_000 + ut_refundable_credits: 1_000 + +- name: Reform does not double count when liability already absorbs the EITC + period: 2026 + reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc_reform.ut_fully_refundable_eitc + input: + state_code: UT + eitc: 5_000 + employment_income: 30_000 + ut_income_tax_before_non_refundable_credits: 1_000 + output: + # Full potential paid as refundable; nonrefundable EITC portion stays zero. + ut_fully_refundable_eitc: 1_000 + ut_non_refundable_eitc: 0 + ut_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. + ut_income_tax: 0 From a7b596198462db27d0066ad35f4b0c7ab3f17f7c Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Mon, 15 Jun 2026 22:19:49 -0400 Subject: [PATCH 2/2] Address PR review: fix ordered nonrefundable cap and broaden test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ut_non_refundable_credits: switch from `add() then subtract ut_eitc` to `ordered_capped_state_non_refundable_credits` with ut_eitc filtered out of the ordered list. The previous form overstated the non-refundable total whenever the bucket binds at liability — the ordered cap must walk the credit list to free each later credit's slot correctly. This matches the baseline ut_non_refundable_credits computation exactly, except EITC is removed because it is paid as refundable here. - Add three regression tests: W-2 wages cap (Utah Code § 59-10-1044) binding under the reform, a self-employment-only filer receiving no refundable credit (no W-2 wages → zero potential), and confirmation that the reformed credit is paid without double-counting at partial liability. Co-Authored-By: Claude Opus 4.7 --- .../ut_fully_refundable_eitc_reform.py | 29 ++++++++++------- .../reform/ut_fully_refundable_eitc.yaml | 32 +++++++++++++++++++ 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py b/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py index 1fc179c25f8..b2c23b3bdcf 100644 --- a/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/ut/child_poverty_eitc/ut_fully_refundable_eitc_reform.py @@ -1,5 +1,8 @@ from policyengine_us.model_api import * from policyengine_core.periods import period as period_ +from policyengine_us.variables.gov.states.tax.income.non_refundable_credit_cap import ( + ordered_capped_state_non_refundable_credits, +) def create_ut_fully_refundable_eitc() -> Reform: @@ -49,20 +52,24 @@ class ut_non_refundable_credits(Variable): defined_for = StateCode.UT def formula(tax_unit, period, parameters): - # Baseline non-refundable credits, resolved to the list of - # variable names before passing to `add` (which iterates variable - # names, not parameter paths). - non_refundable_list = parameters( + # Mirror the baseline's ordered-cap logic but drop ut_eitc from + # the non-refundable bucket — it's paid as refundable under this + # reform. A raw `sum - ut_eitc` instead of the ordered walk would + # overstate the non-refundable total whenever the bucket binds at + # liability (later credits in the ordered list would no longer + # see the EITC's slot freed correctly). + ordered_credits = parameters( period ).gov.states.ut.tax.income.credits.non_refundable - baseline_non_refundable = add( - tax_unit, period, non_refundable_list + filtered_credits = [ + credit for credit in list(ordered_credits) if credit != "ut_eitc" + ] + return ordered_capped_state_non_refundable_credits( + tax_unit, + period, + filtered_credits, + "ut_income_tax_before_non_refundable_credits", ) - # Remove ut_eitc from non-refundable (it's now handled separately) - ut_eitc = tax_unit("ut_eitc", period) - # Add back nonrefundable EITC (0 when reform is in effect) - nonrefundable_eitc = tax_unit("ut_non_refundable_eitc", period) - return baseline_non_refundable - ut_eitc + nonrefundable_eitc class ut_refundable_credits(Variable): value_type = float diff --git a/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml index 953d627cebb..3bcbf3b85e7 100644 --- a/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/reform/ut_fully_refundable_eitc.yaml @@ -44,3 +44,35 @@ # 1,000 before refundable credits - 1,000 refundable = 0. If the credit # were double counted this would go negative. ut_income_tax: 0 + +- name: W-2 wages cap (Utah Code § 59-10-1044) still binds under the reform + period: 2026 + reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc_reform.ut_fully_refundable_eitc + input: + state_code: UT + eitc: 5_000 + # Wages below the 20%-of-federal-EITC potential, so the wages cap binds. + employment_income: 400 + ut_income_tax_before_non_refundable_credits: 0 + output: + # Potential = min(0.2 * 5,000, 400) = 400. Reform pays the wages-capped + # potential as refundable, NOT the full 1,000 — § 59-10-1044 still applies. + ut_eitc_potential: 400 + ut_fully_refundable_eitc: 400 + ut_refundable_credits: 400 + +- name: Self-employment-only filer receives no refundable EITC (no W-2 wages) + period: 2026 + reforms: policyengine_us.reforms.states.ut.child_poverty_eitc.ut_fully_refundable_eitc_reform.ut_fully_refundable_eitc + input: + state_code: UT + eitc: 5_000 + employment_income: 0 + self_employment_income: 30_000 + ut_income_tax_before_non_refundable_credits: 0 + output: + # § 59-10-1044 explicitly excludes self-employment earnings from the cap; + # zero W-2 wages → zero potential credit → zero refundable credit. + ut_eitc_potential: 0 + ut_fully_refundable_eitc: 0 + ut_refundable_credits: 0