diff --git a/changelog.d/co-sales-tax-refund-2026-no-surplus.fixed.md b/changelog.d/co-sales-tax-refund-2026-no-surplus.fixed.md new file mode 100644 index 00000000000..ed6004ec48e --- /dev/null +++ b/changelog.d/co-sales-tax-refund-2026-no-surplus.fixed.md @@ -0,0 +1 @@ +Pay no Colorado state sales tax refund for tax year 2026: C.R.S. 39-22-2003(2) allows it only if the fiscal year ending in the tax year had excess state revenues, and FY 2025-26 fell short of the Referendum C cap. diff --git a/policyengine_us/parameters/gov/states/co/tax/income/credits/sales_tax_refund/in_effect.yaml b/policyengine_us/parameters/gov/states/co/tax/income/credits/sales_tax_refund/in_effect.yaml new file mode 100644 index 00000000000..7c6a9c6ebc4 --- /dev/null +++ b/policyengine_us/parameters/gov/states/co/tax/income/credits/sales_tax_refund/in_effect.yaml @@ -0,0 +1,25 @@ +description: Colorado pays the state sales tax refund for a tax year if this is true, which requires excess state revenues for the state fiscal year ending in that tax year. +values: + 2021-01-01: true + # FY 2025-26 revenue fell $175.9 million short of the Referendum C cap, as + # the State Controller certified on 2026-09-08, so no tax year 2026 refund. + 2026-01-01: false + # The LCS June 2026 forecast expects the six-tier refund to apply for tax + # years 2027 and 2028. Until CDOR publishes the tax year 2027 table, the + # tax year 2025 tier amounts stand in as placeholders; they are not + # published 2027 amounts (the LCS September 2026 forecast projects $43 for + # the lowest single tier, against the $19 carried forward). + 2027-01-01: true +metadata: + unit: bool + period: year + label: Colorado sales tax refund in effect + reference: + - title: C.R.S. 39-22-2003(2) + href: https://colorado.public.law/statutes/crs_39-22-2003 + - title: Colorado Legislative Council Staff, Economic and Revenue Forecast, June 2026, Table 9 (tax year 2026 refunds $0 in every tier) + href: https://content.leg.colorado.gov/sites/default/files/june-2026-forecast-for-posting-accessible_0.pdf#page=31 + - title: Colorado Legislative Council Staff, Economic and Revenue Forecast, June 2026 (six-tier refund expected for tax years 2027 and 2028) + href: https://content.leg.colorado.gov/sites/default/files/june-2026-forecast-for-posting-accessible_0.pdf#page=30 + - title: Colorado Legislative Council Staff, Economic and Revenue Forecast, September 2026 (State Controller certification of FY 2025-26) + href: https://content.leg.colorado.gov/sites/default/files/2026-09/sept2026-forecast-with-cover-for-remediation-and-posting-accessible.pdf#page=9 diff --git a/policyengine_us/tests/core/test_co_sales_tax_refund_invariants.py b/policyengine_us/tests/core/test_co_sales_tax_refund_invariants.py new file mode 100644 index 00000000000..6f6436a4fb7 --- /dev/null +++ b/policyengine_us/tests/core/test_co_sales_tax_refund_invariants.py @@ -0,0 +1,181 @@ +"""Invariants for Colorado's state sales tax refund, C.R.S. 39-22-2003. + +Subsection (2) allows the refund for a tax year only "if there were excess +state revenues for the fiscal year ending in that tax year". FY 2025-26 ended +below the Referendum C cap (State Controller certification of 2026-09-08), so +no tax year 2026 refund is paid. Tax year 2025 refunds come from the FY 2024-25 +surplus, at the per-person tier amounts in the Legislative Council Staff March +2026 forecast, Table 8. + +Hypothesis draws batches of tax units (single, joint, or head of household +with a dependent), each with a modified AGI, filer ages and wages; a seeded +population of 100 units adds breadth. Each batch runs as one vectorized +simulation covering all three years. For every tax unit: + +1. Gate: in 2026 the refund is zero, whatever the income, filing status or + eligibility. +2. Differential: in 2025 the refund equals the number of eligible filers + times the published tier amount for the unit's modified AGI, with the + tiers taken from the forecast table rather than from the parameters. +3. Bounds: 0 <= refund <= 2 x the top tier amount, and the refund is + positive exactly when a filer is eligible (2025). +The tax year 2027 amounts are placeholders (the 2025 tier amounts stand in +until CDOR publishes the 2027 table), so no invariant pins them. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# LCS March 2026 forecast, Table 8, "Tax Year 2025 Refunds from FY 2024-25 +# TABOR Refund Obligation", single filers: "up to $52,000" $19, "$52,001 to +# $105,000" $25, "$105,001 to $168,000" $29, "$168,001 to $233,000" $35, +# "$233,001 to $299,000" $37, "$299,001 and up" $59. +PUBLISHED_2025_STARTS = np.array([52_001, 105_001, 168_001, 233_001, 299_001]) +PUBLISHED_2025_AMOUNTS = np.array([19, 25, 29, 35, 37, 59]) + +agis = st.one_of( + st.integers(-50_000, 0).map(float), + st.integers(0, 400_000).map(float), + st.sampled_from([52_000.0, 52_001.0, 105_000.0, 299_000.0, 299_001.0]), +) +ages = st.one_of(st.integers(14, 17), st.integers(18, 90)) +wages = st.one_of(st.just(0.0), st.integers(1, 100_000).map(float)) + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "hoh"])) + return { + "kind": kind, + "agi": draw(agis), + "head": {"age": draw(ages), "employment_income": draw(wages)}, + "spouse": ( + {"age": draw(ages), "employment_income": draw(wages)} + if kind == "joint" + else None + ), + } + + +SEED = 20261006 + + +def _seeded_units(n=100): + rng = np.random.default_rng(SEED) + + def person(): + return { + "age": int(rng.integers(14, 91)), + "employment_income": float(rng.integers(0, 100_001)) + if rng.random() < 0.5 + else 0.0, + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "hoh"]) + units.append( + { + "kind": kind, + "agi": float(rng.integers(-50_000, 400_001)), + "head": person(), + "spouse": person() if kind == "joint" else None, + } + ) + return units + + +YEARS = (2025, 2026, 2027) + + +def _situation(units): + # One simulation holds every year: each input is keyed by all of YEARS. + def every_year(value): + return {year: value for year in YEARS} + + people, groups = {}, {"tax_units": {}, "households": {}} + for i, unit in enumerate(units): + head = f"head_{i}" + people[head] = {**unit["head"], "is_tax_unit_head": True} + members = [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = {**unit["spouse"], "is_tax_unit_spouse": True} + members.append(spouse) + if unit["kind"] == "hoh": + child = f"child_{i}" + people[child] = {"age": 8, "is_tax_unit_dependent": True} + members.append(child) + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "co_modified_agi": every_year(unit["agi"]), + "co_income_tax_before_non_refundable_credits": every_year(0), + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": every_year("CO"), + } + people = { + name: {key: every_year(value) for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units): + sim = Simulation(situation=_situation(units)) + unit = sim.populations["tax_unit"].members_entity_id + by_year = {} + for year in YEARS: + eligible = np.asarray( + sim.calculate("co_sales_tax_refund_person_eligible", year), dtype=float + ) + by_year[year] = { + "refund": np.asarray( + sim.calculate("co_sales_tax_refund", year), dtype=float + ), + "agi": np.asarray(sim.calculate("co_modified_agi", year), dtype=float), + "count": np.bincount(unit, weights=eligible, minlength=len(units)), + } + return by_year + + +def _check(units): + by_year = _run(units) + published = PUBLISHED_2025_AMOUNTS[ + np.searchsorted(PUBLISHED_2025_STARTS, by_year[2025]["agi"], side="right") + ] + + # 1. Gate: no tax year 2026 refund. + np.testing.assert_array_equal(by_year[2026]["refund"], 0) + + # 2. Differential against the published tax year 2025 table. + base = by_year[2025] + np.testing.assert_allclose( + base["refund"], base["count"] * published, atol=TOLERANCE + ) + + # 3. Bounds. + for run in by_year.values(): + assert (run["refund"] >= 0).all() + assert (run["refund"] <= 2 * PUBLISHED_2025_AMOUNTS.max()).all() + np.testing.assert_array_equal(base["refund"] > 0, base["count"] > 0) + + +@settings( + max_examples=4, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.lists(tax_units(), min_size=1, max_size=12)) +def test_co_sales_tax_refund_invariants(units): + _check(units) + + +def test_co_sales_tax_refund_invariants_seeded_population(): + _check(_seeded_units()) diff --git a/policyengine_us/tests/policy/baseline/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.yaml b/policyengine_us/tests/policy/baseline/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.yaml index 15896add05b..350411f45e5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.yaml @@ -461,3 +461,203 @@ state_code: CO output: co_sales_tax_refund: 153 + +# C.R.S. 39-22-2003(2) allows the refund for a tax year only "if there were +# excess state revenues for the fiscal year ending in that tax year". FY 2025-26 +# ended on 2026-06-30 below the Referendum C cap: the LCS June 2026 forecast +# (Table 9) showed $0 in every tax year 2026 tier, and on 2026-09-08 the State +# Controller certified a $175.9 million shortfall (LCS September 2026 forecast, +# p. 9). PolicyBench reference audit 2026-10-05, scenario_043. +- name: 2026 Single - no refund without FY 2025-26 excess state revenues + period: 2026 + input: + state_code: CO + age: 66 + co_modified_agi: 4_625 + co_sales_tax_refund_eligible: true + filing_status: SINGLE + output: + co_sales_tax_refund: 0 + +- name: 2026 Joint - no refund for either eligible spouse + period: 2026 + input: + people: + head: + age: 40 + is_tax_unit_head: true + spouse: + age: 40 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + co_modified_agi: 400_000 + co_income_tax_before_non_refundable_credits: 0 + filing_status: JOINT + households: + household: + members: [head, spouse] + state_code: CO + output: + co_sales_tax_refund_eligible: true + co_sales_tax_refund: 0 + +# scenario_043: a childless single filer aged 66 with $4,088 of wages and $553 +# of taxable pension. The federal EITC requires a childless filer to be under +# 65 (26 U.S.C. 32(c)(1)(A)(ii)(II)), and no other Colorado refundable credit +# applies, so with no tax year 2026 sales tax refund the total is $0. +- name: 2026 scenario_043 - no Colorado refundable credits for a childless 66-year-old + period: 2026 + input: + people: + person1: + age: 66 + employment_income: 4_088 + taxable_private_pension_income: 553 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: CO + output: + co_sales_tax_refund_eligible: true + co_eitc: 0 + co_sales_tax_refund: 0 + co_refundable_credits: 0 + +# Every 2025 tier boundary in tax year 2026: each single filer is eligible +# (age 18 or older) and gets $0. In 2025 these units would get $19, $19, $25, +# $29, $35, $37 and $59 (LCS March 2026 forecast, Table 8). +- name: 2026 - no refund at any tier for eligible single filers + period: 2026 + input: + people: + person1: + age: 30 + person2: + age: 30 + person3: + age: 30 + person4: + age: 30 + person5: + age: 30 + person6: + age: 30 + person7: + age: 30 + tax_units: + tax_unit1: + members: [person1] + co_modified_agi: -10000 + co_income_tax_before_non_refundable_credits: 0 + tax_unit2: + members: [person2] + co_modified_agi: 52000 + co_income_tax_before_non_refundable_credits: 0 + tax_unit3: + members: [person3] + co_modified_agi: 52001 + co_income_tax_before_non_refundable_credits: 0 + tax_unit4: + members: [person4] + co_modified_agi: 105001 + co_income_tax_before_non_refundable_credits: 0 + tax_unit5: + members: [person5] + co_modified_agi: 168001 + co_income_tax_before_non_refundable_credits: 0 + tax_unit6: + members: [person6] + co_modified_agi: 233001 + co_income_tax_before_non_refundable_credits: 0 + tax_unit7: + members: [person7] + co_modified_agi: 299001 + co_income_tax_before_non_refundable_credits: 0 + households: + household1: + members: [person1] + state_code: CO + household2: + members: [person2] + state_code: CO + household3: + members: [person3] + state_code: CO + household4: + members: [person4] + state_code: CO + household5: + members: [person5] + state_code: CO + household6: + members: [person6] + state_code: CO + household7: + members: [person7] + state_code: CO + output: + co_sales_tax_refund_eligible: [true, true, true, true, true, true, true] + co_sales_tax_refund: [0, 0, 0, 0, 0, 0, 0] + +# Positive controls for 2025 to 2027, with zero, one and two eligible +# filers. 2027: the gate is on again and the tax year 2025 amounts stand in +# until CDOR publishes the tax year 2027 table (an intended placeholder). +- name: 2025 to 2027 - refunds for zero, one and two eligible filers + period: 2027 + input: + people: + person1: + age: {2025: 40, 2026: 40, 2027: 40} + person2: + age: {2025: 40, 2026: 40, 2027: 40} + person3: + age: {2025: 40, 2026: 40, 2027: 40} + person4: + age: {2025: 17, 2026: 17, 2027: 17} + person5: + age: {2025: 16, 2026: 16, 2027: 16} + tax_units: + tax_unit1: + members: [person1, person2] + co_modified_agi: {2025: 60_000, 2026: 60_000, 2027: 60_000} + co_income_tax_before_non_refundable_credits: {2025: 0, 2026: 0, 2027: 0} + filing_status: {2025: JOINT, 2026: JOINT, 2027: JOINT} + tax_unit2: + members: [person3] + co_modified_agi: {2025: 4_625, 2026: 4_625, 2027: 4_625} + co_income_tax_before_non_refundable_credits: {2025: 0, 2026: 0, 2027: 0} + tax_unit3: + members: [person4] + co_modified_agi: {2025: 0, 2026: 0, 2027: 0} + co_income_tax_before_non_refundable_credits: {2025: 0, 2026: 0, 2027: 0} + tax_unit4: + members: [person5] + co_modified_agi: {2025: 0, 2026: 0, 2027: 0} + co_income_tax_before_non_refundable_credits: {2025: 0, 2026: 0, 2027: 0} + households: + household1: + members: [person1, person2] + state_code: {2025: CO, 2026: CO, 2027: CO} + household2: + members: [person3] + state_code: {2025: CO, 2026: CO, 2027: CO} + household3: + members: [person4] + state_code: {2025: CO, 2026: CO, 2027: CO} + household4: + members: [person5] + state_code: {2025: CO, 2026: CO, 2027: CO} + output: + co_sales_tax_refund_person_eligible: + 2027: [true, true, true, false, false] + # Joint, both eligible, second tier: 2 x $25. Single, first tier: $19. + # Under-18 filers with no wages or tax are not qualified individuals. + co_sales_tax_refund: + 2025: [50, 19, 0, 0] + 2026: [0, 0, 0, 0] + 2027: [50, 19, 0, 0] diff --git a/policyengine_us/variables/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.py b/policyengine_us/variables/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.py index 9b8f70a1a6d..6aee75e662a 100644 --- a/policyengine_us/variables/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.py +++ b/policyengine_us/variables/gov/states/co/tax/income/credits/sales_tax_refund/co_sales_tax_refund.py @@ -8,22 +8,24 @@ class co_sales_tax_refund(Variable): unit = USD definition_period = YEAR reference = ( - # C.R.S. 39-22-2003(1)(a) defines a "qualified individual"; (3) sets the - # per-person refund amount and its doubling for a joint return. + # C.R.S. 39-22-2003(1)(a) defines a "qualified individual"; (2) allows + # the refund only for a tax year whose fiscal year had excess state + # revenues; (3) sets the per-person refund amount and its doubling for + # a joint return. "https://leg.colorado.gov/sites/default/files/images/olls/crs2023-title-39.pdf", "https://tax.colorado.gov/sites/tax/files/documents/DR_104_Book_2022.pdf#page=23", ) defined_for = "co_sales_tax_refund_eligible" def formula(tax_unit, period, parameters): - p = parameters(period).gov.states.co.tax.income.credits.sales_tax_refund.amount - if p.flat_amount_enabled: - amount = p.amount + p = parameters(period).gov.states.co.tax.income.credits.sales_tax_refund + if p.amount.flat_amount_enabled: + amount = p.amount.amount else: agi = tax_unit("co_modified_agi", period) - amount = p.scale.calc(agi) + amount = p.amount.scale.calc(agi) # The refund is claimed per qualifying individual (head and/or spouse), # so multiply the per-person amount by the number of eligible filers # rather than doubling for every joint return. eligible_count = add(tax_unit, period, ["co_sales_tax_refund_person_eligible"]) - return eligible_count * amount + return eligible_count * amount * p.in_effect