From 48319f5442b3a18ed828709d7423cb80d49c4c06 Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 16 Jun 2026 21:36:26 -0400 Subject: [PATCH 1/3] Fix OH refundable EITC reform zero-refund and lost-credit bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the UT fix in #8645 for Ohio's analogous bundled reform. - oh_refundable_eitc: pay the uncapped potential credit (oh_eitc_potential) instead of the tax-liability-capped oh_eitc so the reform delivers a refund to zero-liability filers (the case refundability is meant to help). - oh_non_refundable_credits: replace the formula that returned only oh_non_refundable_eitc (= 0) — which silently discarded Ohio's other six non-refundable credits — with ordered_capped_state_non_refundable_credits on the same ordered list, filtering out oh_eitc since it is paid as refundable here. Updates the contrib tests to drive from federal eitc and pin oh_income_tax_before_non_refundable_credits, mirroring the UT pattern, and adds a new regression test verifying that other OH non-refundable credits (e.g. oh_joint_filing_credit) still apply under the reform. Fixes #8656 Co-Authored-By: Claude Opus 4.7 --- ...efundable-eitc-reform-zero-refund.fixed.md | 1 + .../oh/eitc/oh_refundable_eitc_reform.py | 31 ++++++++++--- .../eitc/oh_refundable_eitc.yaml | 35 ++++++++------- .../policy/reform/oh_refundable_eitc.yaml | 45 +++++++++++++++++++ 4 files changed, 90 insertions(+), 22 deletions(-) create mode 100644 changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md create mode 100644 policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml diff --git a/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md b/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md new file mode 100644 index 00000000000..96f51ab2184 --- /dev/null +++ b/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md @@ -0,0 +1 @@ +Fixed the Ohio refundable EITC contrib reform, which paid no refundable credit to filers with no Ohio tax liability and discarded Ohio's other six non-refundable credits. diff --git a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py b/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py index 7de8c69ff3c..ddb68ba0e19 100644 --- a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/oh/eitc/oh_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_oh_refundable_eitc() -> Reform: @@ -19,7 +22,11 @@ class oh_refundable_eitc(Variable): defined_for = StateCode.OH def formula(tax_unit, period, parameters): - return tax_unit("oh_eitc", period) + # Use the potential (uncapped) OH EITC so the full credit is paid + # as a refund; `oh_eitc` is capped at remaining tax liability via + # the ordered nonrefundable cap and would zero out the credit for + # the low-liability filers refundability is meant to help. + return tax_unit("oh_eitc_potential", period) class oh_non_refundable_eitc(Variable): value_type = float @@ -34,8 +41,6 @@ def formula(tax_unit, period, parameters): return 0 class oh_non_refundable_credits(Variable): - # NOTE: When reform is active, OH EITC moves from nonrefundable to refundable. - # This formula returns the nonrefundable EITC amount (0 under reform). value_type = float entity = TaxUnit label = "Ohio non-refundable credits" @@ -48,8 +53,24 @@ class oh_non_refundable_credits(Variable): defined_for = StateCode.OH def formula(tax_unit, period, parameters): - # When reform is active, EITC is refundable, so nonrefundable EITC is 0 - return tax_unit("oh_non_refundable_eitc", period) + # Mirror the baseline's ordered-cap logic but drop oh_eitc from + # the non-refundable bucket — it's paid as refundable under this + # reform. The previous formula returned only oh_non_refundable_eitc + # (= 0 under the reform), which silently zeroed out Ohio's other + # six non-refundable credits (CDCC, senior, retirement, non-public + # school, exemption, joint filing). + ordered_credits = parameters( + period + ).gov.states.oh.tax.income.credits.non_refundable + filtered_credits = [ + credit for credit in list(ordered_credits) if credit != "oh_eitc" + ] + return ordered_capped_state_non_refundable_credits( + tax_unit, + period, + filtered_credits, + "oh_income_tax_before_non_refundable_credits", + ) class oh_refundable_credits(Variable): value_type = float diff --git a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml index 6b6040e397f..dc50863e671 100644 --- a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml @@ -1,11 +1,19 @@ -- name: Case 1 - OH EITC is refundable when reform active +# The reform makes the Ohio EITC fully refundable. It pays the uncapped +# potential credit (oh_eitc_potential = rate * federal_eitc), so these tests +# drive the credit from the federal EITC rather than injecting the capped +# oh_eitc. The 2024 OH EITC rate is 30% of the federal EITC. + +- name: Case 1 - OH EITC is fully refundable (uncapped) when reform active period: 2024 reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc input: state_code: OH - oh_eitc: 500 + eitc: 2_000 + oh_income_tax_before_non_refundable_credits: 0 output: - oh_refundable_eitc: 500 + oh_eitc_potential: 600 # 2,000 * 0.3 + oh_eitc: 0 # capped at zero tax liability + oh_refundable_eitc: 600 # full potential paid as a refund oh_non_refundable_eitc: 0 - name: Case 2 - OH refundable credits include EITC when reform active @@ -13,16 +21,18 @@ reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc input: state_code: OH - oh_eitc: 1_000 + eitc: 3_000 + oh_income_tax_before_non_refundable_credits: 0 output: - oh_refundable_credits: 1_000 + oh_refundable_eitc: 900 # 3,000 * 0.3 + oh_refundable_credits: 900 -- name: Case 3 - OH EITC is 0 when EITC is 0 +- name: Case 3 - OH refundable EITC is 0 when the federal EITC is 0 period: 2024 reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc input: state_code: OH - oh_eitc: 0 + eitc: 0 output: oh_refundable_eitc: 0 oh_non_refundable_eitc: 0 @@ -32,16 +42,7 @@ reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc input: state_code: CA - oh_eitc: 500 + eitc: 2_000 output: oh_refundable_eitc: 0 oh_non_refundable_eitc: 0 - -- name: Case 5 - OH non-refundable credits exclude EITC when reform active - period: 2024 - reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc - input: - state_code: OH - oh_eitc: 500 - output: - oh_non_refundable_credits: 0 diff --git a/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml new file mode 100644 index 00000000000..a84bb0f7307 --- /dev/null +++ b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml @@ -0,0 +1,45 @@ +# Tests for the Ohio refundable EITC contrib reform. +# Regression coverage for https://github.com/PolicyEngine/policyengine-us/issues/8656 +# (the reform previously paid no refundable credit to zero-liability filers and +# discarded Ohio's other six non-refundable credits). + +- name: Reform pays the full potential OH EITC as a refundable credit at zero liability + period: 2026 + reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc_reform.oh_refundable_eitc + input: + state_code: OH + eitc: 5_000 + # Pin OH tax liability to zero so the nonrefundable cap binds at $0 — + # the test is whether the reform pays the credit despite the cap. + oh_income_tax_before_non_refundable_credits: 0 + output: + # 2026 OH EITC rate is 30% of the federal EITC: 5,000 * 0.3 = 1,500. + oh_eitc_potential: 1_500 + # 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. + oh_eitc: 0 + # EITC is moved out of the nonrefundable bucket... + oh_non_refundable_eitc: 0 + # ...and paid in full as refundable, even with no Ohio tax liability. + oh_refundable_eitc: 1_500 + oh_refundable_credits: 1_500 + +- name: Reform preserves other Ohio non-refundable credits (does not discard them) + period: 2026 + reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc_reform.oh_refundable_eitc + input: + state_code: OH + eitc: 5_000 + oh_income_tax_before_non_refundable_credits: 800 + # Force a non-EITC OH non-refundable credit to a positive value to prove + # the reform's oh_non_refundable_credits formula does not zero it out. + # The bug returned `oh_non_refundable_eitc` (= 0), discarding every other + # credit in the ordered list. + oh_joint_filing_credit: 500 + output: + # Joint-filing credit still applies (capped at liability via the ordered + # walk, which has $800 remaining when joint-filing's turn comes). + oh_non_refundable_credits: 500 + # EITC is paid as refundable in full. + oh_refundable_eitc: 1_500 + oh_refundable_credits: 1_500 From 81fd3ef24b4385d2eb0837f0183e8e21f6e5952a Mon Sep 17 00:00:00 2001 From: PavelMakarchuk Date: Tue, 16 Jun 2026 22:16:52 -0400 Subject: [PATCH 2/3] Address self-review: expand refs, add ordered-cap tests, clarify intent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 2023/2024/2025 OH Schedule of Credits references on oh_refundable_credits and oh_non_refundable_credits to match the baseline non_refundable.yaml parameter. - Note in the docstring that this reform departs from ORC § 5747.71 (which makes the credit nonrefundable) — it is a contrib/what-if module, not a baseline change. - Add two reform tests for previously-untested edges of the ordered cap walk: partial-absorption (refundable EITC paid in full while other credits consume the smaller remaining liability) and a multi-credit binding case (CDCC + exemption + joint-filing competing for liability, total capped at remaining liability). - Tighten test comment ("OH EITC rate is 30%, constant since 2020" instead of "2024 OH EITC rate") and changelog wording ("zeroed out" instead of "discarded"). Co-Authored-By: Claude Opus 4.7 --- ...efundable-eitc-reform-zero-refund.fixed.md | 2 +- .../oh/eitc/oh_refundable_eitc_reform.py | 12 +++++- .../eitc/oh_refundable_eitc.yaml | 2 +- .../policy/reform/oh_refundable_eitc.yaml | 42 ++++++++++++++++++- 4 files changed, 52 insertions(+), 6 deletions(-) diff --git a/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md b/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md index 96f51ab2184..56e3089fce7 100644 --- a/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md +++ b/changelog.d/fix-oh-refundable-eitc-reform-zero-refund.fixed.md @@ -1 +1 @@ -Fixed the Ohio refundable EITC contrib reform, which paid no refundable credit to filers with no Ohio tax liability and discarded Ohio's other six non-refundable credits. +Fixed the Ohio refundable EITC contrib reform, which paid no refundable credit to filers with no Ohio tax liability and zeroed out Ohio's other six non-refundable credits. diff --git a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py b/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py index ddb68ba0e19..cae4da69ce4 100644 --- a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py @@ -9,8 +9,10 @@ def create_oh_refundable_eitc() -> Reform: """ Ohio Refundable EITC Reform - Converts the Ohio Earned Income Credit from a nonrefundable credit - to a refundable credit. By default, OH EITC is nonrefundable. + Hypothetical reform that pays the Ohio EITC as a fully refundable + credit. ORC § 5747.71 currently makes the credit nonrefundable; this + contrib module is used for what-if analysis only and does not reflect + enacted Ohio law. """ class oh_refundable_eitc(Variable): @@ -49,6 +51,9 @@ class oh_non_refundable_credits(Variable): reference = ( "https://tax.ohio.gov/static/forms/ohio_individual/individual/2021/sch-cre.pdf", "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/itschedule-credits.pdf", + "https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2023/1040-bundle-original.pdf#page=7", + "https://tax.ohio.gov/static/webview/view1/UIExtension/1/pdf-view.html?filename=forms/ohio_individual/individual/2024/1040-bundle-original-fi.pdf", + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=28", ) defined_for = StateCode.OH @@ -81,6 +86,9 @@ class oh_refundable_credits(Variable): reference = ( "https://tax.ohio.gov/static/forms/ohio_individual/individual/2021/sch-cre.pdf", "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/itschedule-credits.pdf", + "https://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2023/1040-bundle-original.pdf#page=7", + "https://tax.ohio.gov/static/webview/view1/UIExtension/1/pdf-view.html?filename=forms/ohio_individual/individual/2024/1040-bundle-original-fi.pdf", + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=28", ) defined_for = StateCode.OH diff --git a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml index dc50863e671..bf38ff7d038 100644 --- a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml @@ -1,7 +1,7 @@ # The reform makes the Ohio EITC fully refundable. It pays the uncapped # potential credit (oh_eitc_potential = rate * federal_eitc), so these tests # drive the credit from the federal EITC rather than injecting the capped -# oh_eitc. The 2024 OH EITC rate is 30% of the federal EITC. +# oh_eitc. The OH EITC rate is 30% of the federal EITC (constant since 2020). - name: Case 1 - OH EITC is fully refundable (uncapped) when reform active period: 2024 diff --git a/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml index a84bb0f7307..7f539dfb9e6 100644 --- a/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml @@ -33,8 +33,8 @@ oh_income_tax_before_non_refundable_credits: 800 # Force a non-EITC OH non-refundable credit to a positive value to prove # the reform's oh_non_refundable_credits formula does not zero it out. - # The bug returned `oh_non_refundable_eitc` (= 0), discarding every other - # credit in the ordered list. + # The bug returned `oh_non_refundable_eitc` (= 0), zeroing out every + # other credit in the ordered list. oh_joint_filing_credit: 500 output: # Joint-filing credit still applies (capped at liability via the ordered @@ -43,3 +43,41 @@ # EITC is paid as refundable in full. oh_refundable_eitc: 1_500 oh_refundable_credits: 1_500 + +- name: Reform does not double count when liability partially absorbs other credits + period: 2026 + reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc_reform.oh_refundable_eitc + input: + state_code: OH + eitc: 2_000 + # Liability lower than the potential EITC. After EITC is moved to the + # refundable bucket, the remaining $300 of liability is consumed by + # oh_cdcc (first surviving entry in the filtered ordered list). + oh_income_tax_before_non_refundable_credits: 300 + oh_cdcc: 200 + output: + # 0.3 * 2,000 federal EITC = 600 — paid as refundable regardless of + # liability, not capped at the 300 of OH tax remaining. + oh_refundable_eitc: 600 + oh_refundable_credits: 600 + # CDCC consumes 200 of the 300 remaining liability; no double-counting + # against the refundable EITC. + oh_non_refundable_credits: 200 + +- name: Filtered ordered walk caps multiple non-EITC credits at remaining liability + period: 2026 + reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc_reform.oh_refundable_eitc + input: + state_code: OH + eitc: 0 + # Multiple OH non-refundable credits compete for the cap; total = 1,200 + # but only $600 of liability is available, so the ordered walk must + # cap the bucket at exactly $600. + oh_income_tax_before_non_refundable_credits: 600 + oh_cdcc: 400 + oh_exemption_credit: 400 + oh_joint_filing_credit: 400 + output: + # CDCC fills $400, exemption fills the remaining $200, joint-filing + # gets $0 (later in the order). Total caps at the liability of $600. + oh_non_refundable_credits: 600 From 3ba79b606427d29d5d771199747c93c70b43044f Mon Sep 17 00:00:00 2001 From: Ziming Date: Wed, 17 Jun 2026 00:23:16 -0400 Subject: [PATCH 3/3] Polish OH refundable EITC reform: docstring, end-to-end test, wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add an oh_income_tax end-to-end refund assertion to the zero-liability reform test, confirming the refund actually pays out (not just lands in the refundable bucket). - Document the HB 62 (eff. 2019-07-03) cap repeal in the reform docstring, explaining why reading oh_eitc_potential is the full refundable amount for the modeled era (ORC 5747.71). - Correct the "six other credits" wording in the code comment and test header to "every other entry in the ordered list" — the 2021-2022 list also carries oh_adoption_credit (repealed 2023). The formula was already correct since it filters the live period-specific list. - Swap the non-OH negative test from CA to TX. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../states/oh/eitc/oh_refundable_eitc_reform.py | 15 ++++++++++++--- .../eitc/oh_refundable_eitc.yaml | 2 +- .../tests/policy/reform/oh_refundable_eitc.yaml | 6 +++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py b/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py index cae4da69ce4..8cc0b8e3fe9 100644 --- a/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py +++ b/policyengine_us/reforms/states/oh/eitc/oh_refundable_eitc_reform.py @@ -13,6 +13,14 @@ def create_oh_refundable_eitc() -> Reform: credit. ORC § 5747.71 currently makes the credit nonrefundable; this contrib module is used for what-if analysis only and does not reflect enacted Ohio law. + + Reading ``oh_eitc_potential`` (uncapped 30% of the federal EITC) yields + the full refundable amount for the modeled era (2020+): Ohio's pre-2019 + "50% of tax when OH taxable income exceeds $20,000" limitation was + repealed by HB 62 (eff. 2019-07-03), so the only remaining limit on the + nonrefundable ``oh_eitc`` is the ordinary tax-liability cap — exactly + what refundability lifts. + https://codes.ohio.gov/ohio-revised-code/section-5747.71 """ class oh_refundable_eitc(Variable): @@ -61,9 +69,10 @@ def formula(tax_unit, period, parameters): # Mirror the baseline's ordered-cap logic but drop oh_eitc from # the non-refundable bucket — it's paid as refundable under this # reform. The previous formula returned only oh_non_refundable_eitc - # (= 0 under the reform), which silently zeroed out Ohio's other - # six non-refundable credits (CDCC, senior, retirement, non-public - # school, exemption, joint filing). + # (= 0 under the reform), which silently zeroed out every other + # entry in Ohio's ordered non-refundable list (CDCC, senior, + # retirement, non-public school, exemption, joint filing — plus the + # adoption credit for pre-2023 years). ordered_credits = parameters( period ).gov.states.oh.tax.income.credits.non_refundable diff --git a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml index bf38ff7d038..7e1377b2e81 100644 --- a/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/contrib/states/oh/child_poverty_impact_dashboard/eitc/oh_refundable_eitc.yaml @@ -41,7 +41,7 @@ period: 2024 reforms: policyengine_us.reforms.states.oh.eitc.oh_refundable_eitc input: - state_code: CA + state_code: TX eitc: 2_000 output: oh_refundable_eitc: 0 diff --git a/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml index 7f539dfb9e6..f843272538e 100644 --- a/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml +++ b/policyengine_us/tests/policy/reform/oh_refundable_eitc.yaml @@ -1,7 +1,8 @@ # Tests for the Ohio refundable EITC contrib reform. # Regression coverage for https://github.com/PolicyEngine/policyengine-us/issues/8656 # (the reform previously paid no refundable credit to zero-liability filers and -# discarded Ohio's other six non-refundable credits). +# discarded all of Ohio's other non-refundable credits — six in 2023+, seven in +# 2021-2022 with the adoption credit). - name: Reform pays the full potential OH EITC as a refundable credit at zero liability period: 2026 @@ -23,6 +24,9 @@ # ...and paid in full as refundable, even with no Ohio tax liability. oh_refundable_eitc: 1_500 oh_refundable_credits: 1_500 + # End-to-end: the refund flows through to a negative oh_income_tax + # (liability 0 − refundable 1,500 = −1,500), i.e. an actual payout. + oh_income_tax: -1_500 - name: Reform preserves other Ohio non-refundable credits (does not discard them) period: 2026