diff --git a/changelog.d/sc-fully-refundable-eitc-reform.added.md b/changelog.d/sc-fully-refundable-eitc-reform.added.md new file mode 100644 index 00000000000..561c081e696 --- /dev/null +++ b/changelog.d/sc-fully-refundable-eitc-reform.added.md @@ -0,0 +1 @@ +Added a South Carolina fully refundable EITC contrib reform for the Child Poverty Impact Dashboard. diff --git a/policyengine_us/parameters/gov/contrib/states/sc/child_poverty_impact_dashboard/eitc/in_effect.yaml b/policyengine_us/parameters/gov/contrib/states/sc/child_poverty_impact_dashboard/eitc/in_effect.yaml new file mode 100644 index 00000000000..01bcc6d68bb --- /dev/null +++ b/policyengine_us/parameters/gov/contrib/states/sc/child_poverty_impact_dashboard/eitc/in_effect.yaml @@ -0,0 +1,12 @@ +description: South Carolina applies a fully refundable EITC when this parameter is in effect. By default, the SC EITC is nonrefundable. This reform makes it refundable for ALL filers. + +values: + 0000-01-01: false + +metadata: + unit: bool + period: year + label: South Carolina fully refundable EITC in effect + reference: + - title: SC H.4216 Section 7 - Section 12-6-3632 + href: https://www.scstatehouse.gov/sess126_2025-2026/bills/4216.htm diff --git a/policyengine_us/reforms/reforms.py b/policyengine_us/reforms/reforms.py index 1cb63204dce..1c68affe08b 100644 --- a/policyengine_us/reforms/reforms.py +++ b/policyengine_us/reforms/reforms.py @@ -263,6 +263,9 @@ from .states.ut.child_poverty_eitc import ( create_ut_fully_refundable_eitc_reform, ) +from .states.sc.child_poverty_eitc import ( + create_sc_fully_refundable_eitc_reform, +) from policyengine_core.reforms import Reform import warnings @@ -455,6 +458,9 @@ def create_structural_reforms_from_parameters(parameters, period): ut_fully_refundable_eitc = create_ut_fully_refundable_eitc_reform( parameters, period ) + sc_fully_refundable_eitc = create_sc_fully_refundable_eitc_reform( + parameters, period + ) nj_stay_nj = create_nj_stay_nj_reform(parameters, period) nj_anchor = create_nj_anchor_reform(parameters, period) working_parents_tax_relief_act = create_working_parents_tax_relief_act_reform( @@ -570,6 +576,7 @@ def create_structural_reforms_from_parameters(parameters, period): mo_refundable_eitc, oh_refundable_eitc, ut_fully_refundable_eitc, + sc_fully_refundable_eitc, nj_stay_nj, nj_anchor, working_parents_tax_relief_act, diff --git a/policyengine_us/reforms/states/sc/child_poverty_eitc/__init__.py b/policyengine_us/reforms/states/sc/child_poverty_eitc/__init__.py new file mode 100644 index 00000000000..a390f12c33f --- /dev/null +++ b/policyengine_us/reforms/states/sc/child_poverty_eitc/__init__.py @@ -0,0 +1,5 @@ +from .sc_fully_refundable_eitc_reform import ( + create_sc_fully_refundable_eitc, + create_sc_fully_refundable_eitc_reform, + sc_fully_refundable_eitc, +) diff --git a/policyengine_us/reforms/states/sc/child_poverty_eitc/sc_fully_refundable_eitc_reform.py b/policyengine_us/reforms/states/sc/child_poverty_eitc/sc_fully_refundable_eitc_reform.py new file mode 100644 index 00000000000..d4522581b00 --- /dev/null +++ b/policyengine_us/reforms/states/sc/child_poverty_eitc/sc_fully_refundable_eitc_reform.py @@ -0,0 +1,127 @@ +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_sc_fully_refundable_eitc() -> Reform: + """ + South Carolina Fully Refundable EITC Reform + + Converts the South Carolina EITC from nonrefundable to fully refundable + for ALL filers. SC's EITC is 125% of the federal EITC (subject to a + cap of $200 per return / per tax unit from 2026, including married filing + jointly) and is nonrefundable by default, i.e. + applied only up to remaining state income tax liability. This reform pays + the potential (uncapped-at-liability) credit as a refundable credit, so + zero-liability filers receive it. + + Mirrors the corrected Utah/Missouri/Ohio refundability reforms + (PolicyEngine/policyengine-us#8645, #8642, #8657): pay the ``*_potential`` + amount, rebuild the non-refundable bucket with the EITC filtered out of + the ordered cap walk, and clear the inherited ``adds`` on the refundable + aggregate before giving it a formula. + + Activated by + ``gov.contrib.states.sc.child_poverty_impact_dashboard.eitc.in_effect``. + + Reference: SC Code Section 12-6-3632. + """ + + class sc_fully_refundable_eitc(Variable): + value_type = float + entity = TaxUnit + label = "South Carolina fully refundable EITC" + unit = USD + definition_period = YEAR + defined_for = StateCode.SC + + def formula(tax_unit, period, parameters): + # Pay the potential (uncapped-at-liability) SC EITC so the whole + # credit is refundable; ``sc_eitc`` is capped at tax liability and + # would zero out for the low-liability filers refundability is + # meant to help. ``sc_eitc_potential`` still applies the statutory + # cap per return / per tax unit + # (gov.states.sc.tax.income.credits.eitc.max). + return tax_unit("sc_eitc_potential", period) + + class sc_non_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "South Carolina non-refundable credits" + unit = USD + definition_period = YEAR + defined_for = StateCode.SC + + def formula(tax_unit, period, parameters): + # Mirror the baseline ordered-cap walk but drop sc_eitc from the + # non-refundable bucket — it is paid as a refundable credit under + # this reform. A raw ``sum - sc_eitc`` would overstate the total + # whenever the bucket binds at liability, so re-run the ordered + # walk over the filtered list instead. + ordered_credits = parameters( + period + ).gov.states.sc.tax.income.credits.non_refundable + filtered_credits = [ + credit for credit in list(ordered_credits) if credit != "sc_eitc" + ] + return ordered_capped_state_non_refundable_credits( + tax_unit, + period, + filtered_credits, + "sc_income_tax_before_non_refundable_credits", + ) + + class sc_refundable_credits(Variable): + value_type = float + entity = TaxUnit + label = "South Carolina refundable credits" + unit = USD + definition_period = YEAR + defined_for = StateCode.SC + # The baseline 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): + p = parameters(period).gov.states.sc.tax.income.credits + standard_credits = add(tax_unit, period, p.refundable) + refundable_eitc = tax_unit("sc_fully_refundable_eitc", period) + return standard_credits + refundable_eitc + + class reform(Reform): + def apply(self): + self.update_variable(sc_fully_refundable_eitc) + self.update_variable(sc_non_refundable_credits) + self.update_variable(sc_refundable_credits) + + return reform + + +def create_sc_fully_refundable_eitc_reform(parameters, period, bypass: bool = False): + if bypass: + return create_sc_fully_refundable_eitc() + + p = parameters.gov.contrib.states.sc.child_poverty_impact_dashboard.eitc + + reform_active = False + current_period = period_(period) + + for _ in range(5): + if p(current_period).in_effect: + reform_active = True + break + current_period = current_period.offset(1, "year") + + if reform_active: + return create_sc_fully_refundable_eitc() + else: + return None + + +sc_fully_refundable_eitc = create_sc_fully_refundable_eitc_reform( + None, None, bypass=True +) 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 b91400e3842..21b38057eca 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 @@ -31,6 +31,9 @@ "sc_cdcc": { "reforms/states/sc/h3492/sc_h3492_eitc_refundable.py", }, + "sc_eitc": { + "reforms/states/sc/child_poverty_eitc/sc_fully_refundable_eitc_reform.py", + }, "sc_two_wage_earner_credit": { "reforms/states/sc/h3492/sc_h3492_eitc_refundable.py", }, diff --git a/policyengine_us/tests/policy/reform/sc_fully_refundable_eitc.yaml b/policyengine_us/tests/policy/reform/sc_fully_refundable_eitc.yaml new file mode 100644 index 00000000000..6a0d129b20c --- /dev/null +++ b/policyengine_us/tests/policy/reform/sc_fully_refundable_eitc.yaml @@ -0,0 +1,150 @@ +# Tests for the South Carolina fully refundable EITC contrib reform. +# +# The reform converts SC's nonrefundable EITC (125% of the federal EITC, capped +# per return / per tax unit at $200 from 2026) to fully refundable for all +# filers. It pays the potential (uncapped-at-liability) credit, so +# zero-liability filers receive it. +# These tests drive the credit from the federal EITC and pin SC tax liability +# directly. Mirrors the corrected UT/MO/OH reforms (issues #8644/#8640/#8656). + +- name: Pays the full potential SC EITC as a refundable credit at zero liability + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: SC + eitc: 100 + # Pin SC tax liability to zero so the nonrefundable cap binds at $0 — the + # test is whether the reform pays the credit despite no liability. + sc_income_tax_before_non_refundable_credits: 0 + output: + # 2026 SC EITC is 125% of the federal EITC: 100 * 1.25 = 125, below the + # $200 per return / per tax unit cap. + sc_eitc_potential: 125 + # The capped credit is 0 at zero liability; the reform pays the uncapped + # potential instead — the whole point of the conversion. + sc_eitc: 0 + # Paid in full as refundable, even with no SC tax liability. + sc_fully_refundable_eitc: 125 + sc_refundable_credits: 125 + +- name: Does not double count when liability could absorb the EITC + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: SC + eitc: 100 + sc_income_tax_before_non_refundable_credits: 1_000 + output: + sc_fully_refundable_eitc: 125 + # EITC is removed from the non-refundable bucket (no other SC credits here). + sc_non_refundable_credits: 0 + sc_refundable_credits: 125 + +- name: Respects the statutory per return / per tax unit cap ($200 in 2026) + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: SC + eitc: 1_000 + sc_income_tax_before_non_refundable_credits: 0 + output: + # 1,000 * 1.25 = 1,250, capped at the $200 maximum. + sc_eitc_potential: 200 + sc_fully_refundable_eitc: 200 + sc_refundable_credits: 200 + +- name: No effect outside South Carolina + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: CA + eitc: 100 + output: + sc_fully_refundable_eitc: 0 + +# End-to-end: at zero liability the refundable EITC turns SC income tax negative +# (a net refund flowing through the tax), not just the intermediate credit. +- name: Refundable EITC produces a net SC income tax refund at zero liability + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: SC + eitc: 100 + sc_income_tax_before_non_refundable_credits: 0 + output: + # 100 * 1.25 = 125, below the $200 cap; paid in full as refundable. + sc_fully_refundable_eitc: 125 + sc_refundable_credits: 125 + # No liability, so tax before refundable credits is 0. + sc_income_tax_before_refundable_credits: 0 + # sc_income_tax = before_refundable_credits - refundable_credits + # = 0 - 125 = -125 (a net refund flowing through the tax). + sc_income_tax: -125 + +# Partial liability: the $200-capped refundable EITC offsets part of a larger +# liability, leaving positive tax owed — the split (capped credit vs. residual +# liability) is exercised, not a full wash to a refund. +- name: Capped refundable EITC partially offsets a larger SC liability + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + state_code: SC + eitc: 1_000 + sc_income_tax_before_non_refundable_credits: 300 + output: + # 1,000 * 1.25 = 1,250, capped at the $200 maximum (cap binds). + sc_eitc_potential: 200 + sc_fully_refundable_eitc: 200 + # sc_eitc is removed from the non-refundable bucket; no other SC credits. + sc_non_refundable_credits: 0 + # before_refundable = 300 - 0 = 300. + sc_income_tax_before_refundable_credits: 300 + # sc_income_tax = 300 - 200 = 100: the capped credit offsets part of the + # $300 liability, leaving $100 owed. + sc_income_tax: 100 + +# MFJ: the refundable portion is capped at $200 PER TAX UNIT, not per filer — +# a married-filing-jointly couple does NOT get $400. +- name: Married filing jointly is capped at $200 per tax unit, not $400 + period: 2026 + reforms: policyengine_us.reforms.states.sc.child_poverty_eitc.sc_fully_refundable_eitc_reform.sc_fully_refundable_eitc + input: + people: + person1: + age: 40 + is_tax_unit_head: true + person2: + age: 40 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + eitc: 1_000 + sc_income_tax_before_non_refundable_credits: 0 + households: + household: + members: [person1, person2] + state_code: SC + output: + # 1,000 * 1.25 = 1,250, capped at $200 for the whole tax unit (not $400). + sc_eitc_potential: 200 + sc_fully_refundable_eitc: 200 + sc_refundable_credits: 200 + +# No-op gating: with NO reform applied (in_effect defaults to false), the +# always-on bypass is not exercised and baseline nonrefundable SC EITC behavior +# holds — at zero liability the credit is fully absorbed to $0, not refunded. +- name: Default (no reform, in_effect false) keeps baseline nonrefundable EITC + period: 2026 + input: + state_code: SC + eitc: 100 + sc_income_tax_before_non_refundable_credits: 0 + output: + # Potential still 125, but baseline sc_eitc caps at remaining liability (0). + sc_eitc_potential: 125 + sc_eitc: 0 + # No fully refundable conversion: refundable bucket is empty (no tuition). + sc_refundable_credits: 0 + # No refund flows through: tax stays at 0, not -125. + sc_income_tax: 0