From 2d42e6d7f7b3b1e67ed367a97753ac8b6aff4aa0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 15:33:35 -0500 Subject: [PATCH 1/5] Count property tax and rent refunds paid outside the income tax in household and SPM benefits North Dakota's renter's refund sat only in state_property_tax_credits, which feeds the TAXSIM comparison and nothing else, so it reached no household net income, SPM resources or poverty measure. Five other members of that list had the same gap: Minnesota's homestead credit refund, Montana's property tax rebate, Pennsylvania's property tax or rent rebate, Vermont's renter credit and the renter part of Utah's homeowner's and renter's relief. Each state pays these on its own claim, outside the income tax return. A new year-gated list, state_property_tax_refunds, holds the six, and a tax-unit total of the same name adds them. household_state_benefits and spm_unit_benefits (with its four reform copies) count the total, so both net incomes move by the same dollar. Utah's relief splits into ut_renters_credit, which the Tax Commission pays, and ut_homeowners_credit, which counties apply against the property tax bill (Utah Code 59-2a-205(5) and 59-2a-302(2)(b)). The other bill reductions in the list (North Dakota's primary residence credit, DC's senior and disabled relief and Indiana's three credits) stay out of both totals, as no net income total subtracts property tax. The 18 remaining members already reach both totals through state income tax credit lists. The parity test gains invariant 7: every member of state_property_tax_credits has exactly one documented route, and a differential simulation over all of them checks that both net incomes move together, by the full amount for a refund and not at all for a bill reduction. Co-Authored-By: Claude Opus 5.5 --- .../nd-renters-refund-net-income.fixed.md | 1 + .../household/household_state_benefits.yaml | 12 + .../household/state_property_tax_refunds.yaml | 55 ++++ .../ut/tax/income/credits/refundable.yaml | 9 +- .../boost/boost_middle_class_tax_credit.py | 1 + .../edaa_end_child_poverty_act.py | 1 + .../congress/tlaib/end_child_poverty_act.py | 1 + .../reforms/states/tx/rebate/tx_rebate.py | 1 + .../property/state_property_tax_refunds.yaml | 287 ++++++++++++++++++ .../income/credits/ut_refundable_credits.yaml | 4 +- .../ut_homeowner_renter_relief.yaml | 6 + .../tests/test_aggregate_list_integrity.py | 2 + .../test_household_spm_resource_parity.py | 184 ++++++++++- .../credits/rebate/mt_property_tax_rebate.py | 6 +- .../property/state_property_tax_refunds.py | 15 + .../ut_homeowner_renter_relief.py | 18 +- .../ut_homeowners_credit.py | 27 ++ .../ut_renters_credit.py | 28 ++ .../income/spm_unit/spm_unit_benefits.py | 1 + 19 files changed, 633 insertions(+), 26 deletions(-) create mode 100644 changelog.d/nd-renters-refund-net-income.fixed.md create mode 100644 policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml create mode 100644 policyengine_us/variables/gov/states/tax/property/state_property_tax_refunds.py create mode 100644 policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowners_credit.py create mode 100644 policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_renters_credit.py diff --git a/changelog.d/nd-renters-refund-net-income.fixed.md b/changelog.d/nd-renters-refund-net-income.fixed.md new file mode 100644 index 00000000000..acb5ffb2e48 --- /dev/null +++ b/changelog.d/nd-renters-refund-net-income.fixed.md @@ -0,0 +1 @@ +Count the property tax and rent refunds that North Dakota, Minnesota, Montana, Pennsylvania, Utah and Vermont pay outside the income tax in household and SPM benefits, and split Utah's homeowner and renter relief into the renter's credit the state pays and the homeowner's credit counties apply to the bill. diff --git a/policyengine_us/parameters/gov/household/household_state_benefits.yaml b/policyengine_us/parameters/gov/household/household_state_benefits.yaml index bcc51449cd4..592a481ae4c 100644 --- a/policyengine_us/parameters/gov/household/household_state_benefits.yaml +++ b/policyengine_us/parameters/gov/household/household_state_benefits.yaml @@ -102,6 +102,9 @@ values: - wa_rca # New Jersey benefits - nj_property_tax_relief + # Property tax and rent refunds that states pay in cash, outside the + # income tax return + - state_property_tax_refunds # Louisiana benefits - la_oss # New York benefits @@ -208,6 +211,9 @@ values: - wa_rca # New Jersey benefits - nj_property_tax_relief + # Property tax and rent refunds that states pay in cash, outside the + # income tax return + - state_property_tax_refunds # Louisiana benefits - la_oss # New York benefits @@ -314,6 +320,9 @@ values: - wa_rca # New Jersey benefits - nj_property_tax_relief + # Property tax and rent refunds that states pay in cash, outside the + # income tax return + - state_property_tax_refunds # Louisiana benefits - la_oss # New York benefits @@ -422,6 +431,9 @@ values: - la_oss # New Jersey benefits - nj_property_tax_relief + # Property tax and rent refunds that states pay in cash, outside the + # income tax return + - state_property_tax_refunds # New York benefits - ny_ssp diff --git a/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml new file mode 100644 index 00000000000..e3ce735657c --- /dev/null +++ b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml @@ -0,0 +1,55 @@ +description: States pay these property tax and rent refunds in cash, outside the income tax return. +values: + # Each refund appears from the year it enters state_property_tax_credits, + # the first year its parameters are defined in PE-US. + # The other members of state_property_tax_credits either sit in a state's + # income tax credit lists, which reach net income through state income tax, + # or reduce the property tax bill, which no net income total subtracts. + 2021-01-01: + # Vermont pays the renter credit on Form RCC-146, which Form IN-112 leaves + # out of the refundable credits it carries to Form IN-111. + - vt_renter_credit + 2022-01-01: + # Montana's Department of Revenue pays the rebate on its own claim. + - mt_property_tax_rebate + - vt_renter_credit + 2024-01-01: + # Minnesota pays the homestead credit refund on Form M1PR, its own return. + - mn_homestead_credit_refund + - mt_property_tax_rebate + # North Dakota's Tax Commissioner pays the renter's refund on SFN 24777. + - nd_renters_refund + - vt_renter_credit + 2025-01-01: + - mn_homestead_credit_refund + - mt_property_tax_rebate + - nd_renters_refund + # Pennsylvania's Department of Revenue pays the rebate on Form PA-1000. + - pa_property_tax_or_rent_rebate + # Utah's Tax Commission pays the renter's credit, the cash part of + # ut_homeowner_renter_relief; counties apply the homeowner's credit to + # the property tax bill. + - ut_renters_credit + - vt_renter_credit + +metadata: + unit: list + period: year + label: State property tax and rent refunds paid outside the income tax + reference: + - title: N.D.C.C. 57-02-08.1(2)(b) and (d), renter's refund paid by the tax commissioner + href: https://ndlegis.gov/cencode/t57c02.pdf#page=16 + - title: Minn. Stat. 290A.07, subd. 1, claims paid by the commissioner from the general fund + href: https://www.revisor.mn.gov/statutes/cite/290A.07 + - title: 2025 Form M1PR instructions, refund check or direct deposit + href: https://www.revenue.state.mn.us/sites/default/files/2026-06/m1pr-inst-25.pdf#page=5 + - title: "Montana Department of Revenue, income tax and property tax rebate report, May 2024 #page 13-15" + href: https://archive.legmt.gov/content/Committees/Interim/2023-2024/Revenue/Meetings/May-2024/5.1-DOR-rebate-report.pdf#page=13 + - title: "2025 Montana Session Laws, Chapter 767 (SB 542), sec. 3(5) and (9) #page 3-4" + href: https://archive.legmt.gov/content/Sessions/69th/Contractor_index/CH0767.pdf#page=3 + - title: 2025 Form PA-1000 booklet, Line 20, rebate deposited or mailed by the Department of Revenue + href: https://www.pa.gov/content/dam/copapwp-pagov/en/revenue/documents/formsandpublications/formsforindividuals/ptrr/documents/2025_pa-1000_inst.pdf#page=11 + - title: "Utah Code 59-2a-203 and 59-2a-205(5), renter's credit paid by the commission from the General Fund #page 11-12" + href: https://le.utah.gov/xcode/Title59/Chapter2A/C59-2a_2026010120250507.pdf#page=11 + - title: "2024 Vermont income tax booklet, Schedule IN-112 Part II and Form RCC-146 instructions #page 24,35" + href: https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=24 diff --git a/policyengine_us/parameters/gov/states/ut/tax/income/credits/refundable.yaml b/policyengine_us/parameters/gov/states/ut/tax/income/credits/refundable.yaml index edfb0740a36..655ce01dd20 100644 --- a/policyengine_us/parameters/gov/states/ut/tax/income/credits/refundable.yaml +++ b/policyengine_us/parameters/gov/states/ut/tax/income/credits/refundable.yaml @@ -1,10 +1,11 @@ description: Utah provides these refundable income tax credits on Form TC-40. values: # Utah does not currently administer any refundable credits on the TC-40 - # income tax return. The Homeowner's/Renter's Relief (Utah Code - # § 59-2A-205 / § 59-2A-305) is administered on a separate refund - # application (Form TC-90CB) and flows through the household-level - # state_property_tax_credits aggregate instead. + # income tax return. The Tax Commission pays the renter's credit + # (Utah Code § 59-2A-205) on a separate application (Form TC-90CB), and + # household and SPM benefits count it through state_property_tax_refunds. + # Counties apply the homeowner's credit (Utah Code § 59-2A-305) against + # the property tax bill. 2025-01-01: [] metadata: diff --git a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py index d4a2face50a..2c201a5d984 100644 --- a/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py +++ b/policyengine_us/reforms/congress/tlaib/boost/boost_middle_class_tax_credit.py @@ -141,6 +141,7 @@ def formula(spm_unit, period, parameters): "ma_eaedc", # Massachusetts. "ak_energy_relief", "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. + "state_property_tax_refunds", # Paid outside the income tax. "ny_drive_clean_rebate", ] if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: diff --git a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py index 4b6552e52e3..73c3048bdc9 100644 --- a/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/economic_dignity_for_all_agenda/edaa_end_child_poverty_act.py @@ -149,6 +149,7 @@ def formula(spm_unit, period, parameters): "co_denver_property_tax_relief", # Denver. "ma_eaedc", # Massachusetts. "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. + "state_property_tax_refunds", # Paid outside the income tax. "ny_drive_clean_rebate", ] diff --git a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py index 7dea7b0defc..7c4cd0f5f98 100644 --- a/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py +++ b/policyengine_us/reforms/congress/tlaib/end_child_poverty_act.py @@ -169,6 +169,7 @@ def formula(spm_unit, period, parameters): "ma_eaedc", # Massachusetts. "ak_energy_relief", "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. + "state_property_tax_refunds", # Paid outside the income tax. "ny_drive_clean_rebate", "ecpa_child_benefit", ] diff --git a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py index 3ae570a9999..5779d0789a1 100644 --- a/policyengine_us/reforms/states/tx/rebate/tx_rebate.py +++ b/policyengine_us/reforms/states/tx/rebate/tx_rebate.py @@ -124,6 +124,7 @@ def formula(spm_unit, period, parameters): "co_denver_property_tax_relief", # Denver. "ma_eaedc", # Massachusetts. "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. + "state_property_tax_refunds", # Paid outside the income tax. "ny_drive_clean_rebate", "tx_rebate", ] diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml new file mode 100644 index 00000000000..2a5d1ae222f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml @@ -0,0 +1,287 @@ +# Each payment case gives a high earner one refund as an input, so no +# means-tested benefit moves and both benefit totals equal the refund. + +- name: North Dakota's renter's refund counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + nd_renters_refund: 600 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: ND + output: + state_property_tax_refunds: 600 + household_state_benefits: 600 + household_benefits: 600 + spm_unit_benefits: 600 + +- name: The renter's refund from North Dakota's worked example reaches household state benefits. + # Same household as Case 1 of nd_renters_refund.yaml. North Dakota pays no + # other benefit in household_state_benefits. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 65 + rent: 9_600 + tax_units: + tax_unit: + members: [person1] + nd_renters_refund_income: 35_000 + households: + household: + members: [person1] + state_code: ND + output: + nd_renters_refund: 520 + state_property_tax_refunds: 520 + household_state_benefits: 520 + +- name: Minnesota's homestead credit refund counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + mn_homestead_credit_refund: 1_000 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: MN + output: + state_property_tax_refunds: 1_000 + household_benefits: 1_000 + spm_unit_benefits: 1_000 + +- name: Montana's property tax rebate counts in both benefit totals. + period: 2023 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + mt_property_tax_rebate: 675 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: MT + output: + state_property_tax_refunds: 675 + household_benefits: 675 + spm_unit_benefits: 675 + +- name: Pennsylvania's property tax or rent rebate counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + pa_property_tax_or_rent_rebate: 650 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: PA + output: + state_property_tax_refunds: 650 + household_benefits: 650 + spm_unit_benefits: 650 + +- name: Pennsylvania's rebate enters the totals with its 2025 parameters. + # Each refund starts in the year it enters state_property_tax_credits. + period: 2024 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + pa_property_tax_or_rent_rebate: 650 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: PA + output: + state_property_tax_refunds: 0 + household_benefits: 0 + spm_unit_benefits: 0 + +- name: Utah's renter's credit counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + ut_renters_credit: 850 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: UT + output: + state_property_tax_refunds: 850 + household_benefits: 850 + spm_unit_benefits: 850 + +- name: Vermont's renter credit counts in both benefit totals. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + vt_renter_credit: 1_729 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: VT + output: + state_property_tax_refunds: 1_729 + household_benefits: 1_729 + spm_unit_benefits: 1_729 + +- name: A Utah owner who also rents counts only the renter's credit. + # Same household as Case 7 of ut_homeowner_renter_relief.yaml. Counties + # apply the $600 homeowner's credit to the property tax bill. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 66 + employment_income: + 2024: 10_000 + 2025: 10_000 + pre_subsidy_rent: 10_000 + real_estate_taxes: 600 + is_tax_unit_head: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: + 2024: UT + 2025: UT + output: + ut_homeowner_renter_relief: 1_550 + ut_renters_credit: 950 + ut_homeowners_credit: 600 + state_property_tax_refunds: 950 + +- name: North Dakota's primary residence credit lowers the bill and counts in neither total. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + real_estate_taxes: 3_000 + tax_units: + tax_unit: + members: [adult] + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: ND + output: + nd_primary_residence_credit: 1_600 + state_property_tax_refunds: 0 + household_benefits: 0 + spm_unit_benefits: 0 + +- name: Indiana's supplemental homestead credit lowers the bill and counts in neither total. + period: 2026 + input: + people: + adult: + age: 40 + employment_income: 500_000 + real_estate_taxes: 2_000 + tax_units: + tax_unit: + members: [adult] + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: IN + output: + in_supplemental_homestead_credit: 200 + state_property_tax_refunds: 0 + household_benefits: 0 + spm_unit_benefits: 0 + +- name: A refund from another state counts nowhere. + period: 2026 + input: + people: + adult: + age: 66 + employment_income: 10_000 + rent: 12_000 + tax_units: + tax_unit: + members: [adult] + households: + household: + members: [adult] + state_code: NH + output: + nd_renters_refund: 0 + ut_renters_credit: 0 + vt_renter_credit: 0 + state_property_tax_refunds: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_refundable_credits.yaml index d348fbe125b..7a7106e13cd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/income/credits/ut_refundable_credits.yaml @@ -19,8 +19,8 @@ state_name: UT output: # vt_renter_credit-style structural pattern: the credit is non-zero - # but it does not appear on the income tax return — it flows through - # the household-level state_property_tax_credits aggregate instead. + # but it does not appear on the income tax return. The renter's credit + # reaches household and SPM benefits through state_property_tax_refunds. ut_homeowner_renter_relief: 429 ut_refundable_credits: 0 ut_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml index 4471bfc9683..4ddafb42488 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.yaml @@ -8,6 +8,8 @@ state_code: UT output: ut_homeowner_renter_relief: 1_140 + ut_renters_credit: 1_140 + ut_homeowners_credit: 0 - name: Case 2, renter credit capped by maximum amount. period: 2025 @@ -54,6 +56,8 @@ 2025: UT output: ut_homeowner_renter_relief: 429 + ut_renters_credit: 0 + ut_homeowners_credit: 429 - name: Case 5, homeowner credit capped by property tax paid. period: 2025 @@ -115,6 +119,8 @@ 2025: UT output: ut_homeowner_renter_relief: 1_550 + ut_renters_credit: 950 + ut_homeowners_credit: 600 - name: Case 8, ineligible claimant receives no relief. period: 2025 diff --git a/policyengine_us/tests/test_aggregate_list_integrity.py b/policyengine_us/tests/test_aggregate_list_integrity.py index e76e164a2f0..2599fa64000 100644 --- a/policyengine_us/tests/test_aggregate_list_integrity.py +++ b/policyengine_us/tests/test_aggregate_list_integrity.py @@ -37,6 +37,7 @@ "gov/states/household/state_cdccs.yaml", "gov/hhs/ccdf/child_care_subsidy_programs.yaml", "gov/household/household_state_benefits.yaml", + "gov/states/household/state_property_tax_refunds.yaml", ] # Gates that restrict a variable geographically without a StateCode @@ -289,6 +290,7 @@ def test_reform_aggregates_keep_baseline_contrib_entries( "ma_eaedc", "nj_property_tax_relief", "ny_drive_clean_rebate", + "state_property_tax_refunds", }, "household_benefits": { "ak_energy_relief", diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index 165d3b89f10..a72a35608de 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -4,7 +4,7 @@ cash. Each total keeps its own list of what it adds, in two different files, and the lists drift: a cash program reaches one total and not the other. -Six invariants hold for every input. +Seven invariants hold for every input. 1. The list difference is exactly the documented one. Every program that one total counts and the other does not appears below with its reason. @@ -20,6 +20,10 @@ means-tested benefits to lose. 6. The head and spouse together hold a tax unit's whole Form 4797 amount, with its sign, and gross income holds its positive part. +7. Every member of state_property_tax_credits moves both net incomes by the + same amount. A state income tax credit list or state_property_tax_refunds + carries each payment into both totals, and the bill reductions documented + below reach neither, since neither total subtracts property tax. Child care stays out of this file: household_state_benefits and the SPM treat subsidies differently on purpose, and #9405 and #10569 cover that difference. @@ -38,9 +42,11 @@ from hypothesis import strategies as st from policyengine_us import Simulation +from policyengine_us.system import system PACKAGE = Path(__file__).parent.parent HOUSEHOLD_LISTS = PACKAGE / "parameters/gov/household" +STATE_LISTS = PACKAGE / "parameters/gov/states/household" # household_state_benefits starts in 2023. YEARS = range(2023, 2027) TOLERANCE = 0.02 @@ -145,8 +151,77 @@ ("ma_eaedc", "spm_unit", "MA", 2026), ("nj_property_tax_relief", "tax_unit", "NJ", 2026), ("ny_drive_clean_rebate", "household", "NY", 2026), + # Property tax and rent refunds paid outside the income tax. + ("mn_homestead_credit_refund", "tax_unit", "MN", 2026), + ("mt_property_tax_rebate", "tax_unit", "MT", 2024), + ("nd_renters_refund", "tax_unit", "ND", 2026), + ("pa_property_tax_or_rent_rebate", "tax_unit", "PA", 2026), + ("ut_renters_credit", "tax_unit", "UT", 2026), + ("vt_renter_credit", "tax_unit", "VT", 2026), ] +# Each income tax credit in state_property_tax_credits, and the list that +# carries it into its state's income tax. Both net income totals subtract +# state income tax, so both count these credits. +PROPERTY_TAX_CREDITS_IN_INCOME_TAX = { + "az_property_tax_credit": "parameters/gov/states/az/tax/income/credits/" + "refundable.yaml", + "ct_property_tax_credit": "parameters/gov/states/ct/tax/income/credits/" + "non_refundable.yaml", + "dc_ptc": "parameters/gov/states/dc/tax/income/credits/refundable.yaml", + "il_property_tax_credit": "parameters/gov/states/il/tax/income/credits/" + "non_refundable.yaml", + "ma_senior_circuit_breaker": "parameters/gov/states/ma/tax/income/credits/" + "refundable.yaml", + "me_property_tax_fairness_credit": "parameters/gov/states/me/tax/income/" + "credits/refundable.yaml", + "mi_homestead_property_tax_credit": "parameters/gov/states/mi/tax/income/" + "credits/refundable.yaml", + "mn_renters_credit": "parameters/gov/states/mn/tax/income/credits/refundable.yaml", + "mo_property_tax_credit": "parameters/gov/states/mo/tax/income/credits/" + "refundable.yaml", + "mt_elderly_homeowner_or_renter_credit": "variables/gov/states/mt/tax/income/" + "tax_calculation/mt_refundable_credits.py", + "nj_property_tax_credit": "variables/gov/states/nj/tax/income/" + "nj_refundable_credits.py", + "nm_property_tax_rebate": "parameters/gov/states/nm/tax/income/credits/" + "refundable.yaml", + "ny_real_property_tax_credit": "parameters/gov/states/ny/tax/income/credits/" + "refundable.yaml", + "ok_ptc": "parameters/gov/states/ok/tax/income/credits/refundable.yaml", + "ri_property_tax_credit": "parameters/gov/states/ri/tax/income/credits/" + "refundable.yaml", + "wi_homestead_credit": "parameters/gov/states/wi/tax/income/credits/" + "refundable.yaml", + "wi_property_tax_credit": "parameters/gov/states/wi/tax/income/credits/" + "non_refundable.yaml", + "wv_homestead_excess_property_tax_credit": "parameters/gov/states/wv/tax/" + "income/credits/refundable.yaml", +} + +# Members of state_property_tax_credits that lower the property tax bill +# instead of paying cash. Neither net income total subtracts property tax, so +# neither counts them. +PROPERTY_TAX_BILL_REDUCTIONS = { + "dc_senior_disabled_property_tax_relief": "D.C. Code 47-863(b)(1): a " + "deduction in computing real property tax liability", + "in_blind_disabled_property_tax_credit": "IC 6-1.1-51.3-0.5: applied to " + "property tax liability", + "in_over_65_property_tax_credit": "IC 6-1.1-51.3-0.5: applied to property " + "tax liability", + "in_supplemental_homestead_credit": "IC 6-1.1-20.6-7.7(e): the county " + "auditor applies it to property tax liability", + "nd_primary_residence_credit": "N.D.C.C. 57-02-08.10(1): the county auditor " + "applies it on the property tax statement", + "ut_homeowners_credit": "Utah Code 59-2a-302(2)(b): the county applies it " + "against property taxes accrued", +} + +# Members of state_property_tax_credits that add a cash part and a bill part. +PROPERTY_TAX_CREDIT_PARTS = { + "ut_homeowner_renter_relief": ("ut_renters_credit", "ut_homeowners_credit"), +} + def block_for(list_name, year): raw = yaml.load( @@ -403,3 +478,110 @@ def value(name): - value("spm_unit_energy_subsidy") ) assert np.isclose(household_side, spm_side, atol=TOLERANCE) + + +def list_in_force(path, year): + """The members of a year-keyed list parameter that apply in a year.""" + raw = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + blocks = sorted( + (date, members) + for date, members in raw["values"].items() + if isinstance(members, list) + ) + started = [members for date, members in blocks if date <= f"{year}-01-01"] + return set(started[-1] if started else blocks[0][1]) + + +def property_tax_credit_items(year): + """state_property_tax_credits in a year, with each split credit's parts.""" + items = set() + for name in list_in_force(STATE_LISTS / "state_property_tax_credits.yaml", year): + items.update(PROPERTY_TAX_CREDIT_PARTS.get(name, (name,))) + return items + + +def property_tax_refunds(year): + return list_in_force(STATE_LISTS / "state_property_tax_refunds.yaml", year) + + +@pytest.mark.parametrize("year", YEARS) +def test_every_property_tax_credit_has_one_documented_route(year): + refunds = property_tax_refunds(year) + for name in sorted(property_tax_credit_items(year)): + routes = { + "state_property_tax_refunds": name in refunds, + "state income tax": name in PROPERTY_TAX_CREDITS_IN_INCOME_TAX, + "property tax bill": name in PROPERTY_TAX_BILL_REDUCTIONS, + } + assert sum(routes.values()) == 1, f"{year}: {name} routes {routes}" + if name in PROPERTY_TAX_CREDITS_IN_INCOME_TAX: + path = PACKAGE / PROPERTY_TAX_CREDITS_IN_INCOME_TAX[name] + if path.suffix == ".yaml": + assert name in list_in_force(path, year), f"{year}: {path}" + else: + assert f'"{name}"' in path.read_text(), path + outside = refunds - property_tax_credit_items(year) + assert outside == set(), f"{year}: refunds not in the credits list: {outside}" + + +def test_property_tax_credit_routes_have_no_stale_entries(): + listed = set().union(*(property_tax_credit_items(year) for year in YEARS)) + documented = set(PROPERTY_TAX_CREDITS_IN_INCOME_TAX) | set( + PROPERTY_TAX_BILL_REDUCTIONS + ) + assert documented - listed == set() + for total, parts in PROPERTY_TAX_CREDIT_PARTS.items(): + assert system.variables[total].adds == list(parts), total + + +def one_household_per_credit(credits, amount, year): + """Two high earners per credit, in its state: one gets 0, the other amount.""" + situations = [ + one_person( + name[:2].upper(), + 500_000, + year, + **{name: (system.variables[name].entity.key, value)}, + ) + for name in credits + for value in (0, amount) + ] + combined = {group: {} for group in situations[0]} + for index, situation in enumerate(situations): + for group, entities in situation.items(): + for key, entity in entities.items(): + entity = dict(entity) + if "members" in entity: + entity["members"] = [f"{m}_{index}" for m in entity["members"]] + combined[group][f"{key}_{index}"] = entity + return combined + + +@settings(max_examples=2, deadline=None) +@given(amount=st.integers(min_value=100, max_value=5_000)) +@example(amount=500) +def test_property_tax_credits_move_both_net_incomes_together(amount): + """A differential test over every credit in state_property_tax_credits. + + A payment through state income tax can lose part of its value to federal + tax, through the SALT deduction, but it loses the same part in both totals. + """ + year = 2026 + credits = sorted(property_tax_credit_items(year)) + refunds = property_tax_refunds(year) + simulation = Simulation(situation=one_household_per_credit(credits, amount, year)) + + def change(name): + values = simulation.calculate(name, year).reshape(-1, 2) + return values[:, 1] - values[:, 0] + + household = change("household_net_income") + spm = change("spm_unit_net_income") + for name, household_change, spm_change in zip(credits, household, spm): + assert household_change == pytest.approx(spm_change, abs=TOLERANCE), name + if name in PROPERTY_TAX_BILL_REDUCTIONS: + assert household_change == pytest.approx(0, abs=TOLERANCE), name + elif name in refunds: + assert household_change == pytest.approx(amount, abs=TOLERANCE), name + else: + assert TOLERANCE < household_change <= amount + TOLERANCE, name diff --git a/policyengine_us/variables/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.py b/policyengine_us/variables/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.py index 7b6bbd0dee3..578afb66971 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/credits/rebate/mt_property_tax_rebate.py @@ -14,8 +14,10 @@ def formula(tax_unit, period, parameters): # Modeling limitation: this does not encode the statutory eligibility # conditions for the rebate (principal residence; at least 7 months of # ownership and occupancy during the year; property taxes billed and - # paid; a timely rebate claim). Revisit if the rebate is ever wired - # into a benefit-side channel where those conditions would bind. + # paid; a timely rebate claim). household_state_benefits and + # spm_unit_benefits count the rebate through state_property_tax_refunds, + # so for tax years 2022 through 2024 they credit it to every Montana + # tax unit with real estate taxes, claimants or not. p = parameters(period).gov.states.mt.tax.income.credits.rebate.property person = tax_unit.members mt_property_tax = person("real_estate_taxes", period) diff --git a/policyengine_us/variables/gov/states/tax/property/state_property_tax_refunds.py b/policyengine_us/variables/gov/states/tax/property/state_property_tax_refunds.py new file mode 100644 index 00000000000..5efa802fb02 --- /dev/null +++ b/policyengine_us/variables/gov/states/tax/property/state_property_tax_refunds.py @@ -0,0 +1,15 @@ +from policyengine_us.model_api import * + + +class state_property_tax_refunds(Variable): + value_type = float + entity = TaxUnit + label = "State property tax and rent refunds paid outside the income tax" + documentation = ( + "Property tax and rent refunds that states pay in cash on their own " + "claims, outside the income tax return. household_state_benefits and " + "spm_unit_benefits both count them." + ) + unit = USD + definition_period = YEAR + adds = "gov.states.household.state_property_tax_refunds" diff --git a/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.py b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.py index 6d5688ee06b..94488bc6db9 100644 --- a/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.py +++ b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowner_renter_relief.py @@ -14,20 +14,4 @@ class ut_homeowner_renter_relief(Variable): "https://le.utah.gov/xcode/Title59/Chapter2A/C59-2a_2026010120250507.pdf#page=14", ) defined_for = "ut_homeowner_renter_relief_eligible" - - def formula(tax_unit, period, parameters): - p = parameters(period).gov.states.ut.tax.property.homeowner_renter_relief - renter_income = tax_unit.household( - "ut_homeowner_renter_relief_household_income", period - ) - homeowner_income = tax_unit.household( - "ut_homeowner_renter_relief_household_income", period.last_year - ) - renter_maximum = p.maximum.calc(renter_income) - homeowner_maximum = p.maximum.calc(homeowner_income) - rent = add(tax_unit, period, ["rent"]) - renter_credit = min_(rent * p.renter.rate.calc(renter_income), renter_maximum) - homeowner_credit = min_( - add(tax_unit, period, ["real_estate_taxes"]), homeowner_maximum - ) - return renter_credit + homeowner_credit + adds = ["ut_renters_credit", "ut_homeowners_credit"] diff --git a/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowners_credit.py b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowners_credit.py new file mode 100644 index 00000000000..8dfca3331f1 --- /dev/null +++ b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_homeowners_credit.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class ut_homeowners_credit(Variable): + value_type = float + entity = TaxUnit + label = "Utah homeowner's credit" + unit = USD + definition_period = YEAR + documentation = ( + "Utah counties apply the homeowner's credit against the claimant's " + "property taxes accrued, so it lowers the property tax bill instead " + "of arriving as a payment." + ) + reference = ( + "https://tax.utah.gov/relief/homeowner-renter-relief/", + "https://le.utah.gov/xcode/Title59/Chapter2A/C59-2a_2026010120250507.pdf#page=13", + ) + defined_for = "ut_homeowner_renter_relief_eligible" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.ut.tax.property.homeowner_renter_relief + income = tax_unit.household( + "ut_homeowner_renter_relief_household_income", period.last_year + ) + real_estate_taxes = add(tax_unit, period, ["real_estate_taxes"]) + return min_(real_estate_taxes, p.maximum.calc(income)) diff --git a/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_renters_credit.py b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_renters_credit.py new file mode 100644 index 00000000000..18f946bb292 --- /dev/null +++ b/policyengine_us/variables/gov/states/ut/tax/property/homeowner_renter_relief/ut_renters_credit.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class ut_renters_credit(Variable): + value_type = float + entity = TaxUnit + label = "Utah renter's credit" + unit = USD + definition_period = YEAR + documentation = ( + "The Utah State Tax Commission pays the renter's credit from the " + "General Fund on the claimant's own application, outside the income " + "tax return." + ) + reference = ( + "https://tax.utah.gov/relief/renter-refund/", + "https://files.tax.utah.gov/tax/forms/current/tc-90cb.pdf#page=4", + "https://le.utah.gov/xcode/Title59/Chapter2A/C59-2a_2026010120250507.pdf#page=11", + ) + defined_for = "ut_homeowner_renter_relief_eligible" + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.ut.tax.property.homeowner_renter_relief + income = tax_unit.household( + "ut_homeowner_renter_relief_household_income", period + ) + rent = add(tax_unit, period, ["rent"]) + return min_(rent * p.renter.rate.calc(income), p.maximum.calc(income)) diff --git a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py index 0295c517ff8..1c4aba748a1 100644 --- a/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py +++ b/policyengine_us/variables/household/income/spm_unit/spm_unit_benefits.py @@ -105,6 +105,7 @@ def formula(spm_unit, period, parameters): "co_denver_property_tax_relief", # Denver. "ma_eaedc", # Massachusetts. "nj_property_tax_relief", # ANCHOR, Senior Freeze and Stay NJ. + "state_property_tax_refunds", # Paid outside the income tax. "ny_drive_clean_rebate", ] if parameters(period).gov.contrib.ubi_center.flat_tax.deduct_ptc: From 11f05841d8d04e931054c479e19668da5b6eb8c2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 8 Nov 2026 16:09:26 -0500 Subject: [PATCH 2/5] Check the refund list stays inside the credits list in every year The new test walks every year either list changes, from the refund list's first block, and fails when a refund enters state_property_tax_refunds before it enters state_property_tax_credits. A copy of the list with Pennsylvania's rebate in the 2024 block fails it at 2024. The list's comment no longer says each refund enters in the first year PE-US defines its parameters: North Dakota's cap and income limit start in 2009, and Montana's rebate amount in 2021 at zero. It now says that years before 2021 take the 2021 block, as household_state_benefits' first block covers the years before it, and that policyengine-core reads an empty block as no list, which breaks adds. Vermont's comment names Schedule IN-112. Co-Authored-By: Claude Opus 5.5 --- .../household/state_property_tax_refunds.yaml | 8 +++++--- .../test_household_spm_resource_parity.py | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml index e3ce735657c..2b11b6c65cc 100644 --- a/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml +++ b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml @@ -1,13 +1,15 @@ description: States pay these property tax and rent refunds in cash, outside the income tax return. values: # Each refund appears from the year it enters state_property_tax_credits, - # the first year its parameters are defined in PE-US. + # so from 2021 on this list stays inside that one. Years before 2021 take + # the 2021 block, as household_state_benefits' first block covers the years + # before it; policyengine-core reads an empty block as no list at all. # The other members of state_property_tax_credits either sit in a state's # income tax credit lists, which reach net income through state income tax, # or reduce the property tax bill, which no net income total subtracts. 2021-01-01: - # Vermont pays the renter credit on Form RCC-146, which Form IN-112 leaves - # out of the refundable credits it carries to Form IN-111. + # Vermont pays the renter credit on its own claim, Form RCC-146. Schedule + # IN-112 carries only other refundable credits to Form IN-111. - vt_renter_credit 2022-01-01: # Montana's Department of Revenue pays the rebate on its own claim. diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index a72a35608de..cefbcb88ab5 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -524,6 +524,26 @@ def test_every_property_tax_credit_has_one_documented_route(year): assert outside == set(), f"{year}: refunds not in the credits list: {outside}" +def list_change_years(path): + raw = yaml.load(path.read_text(), Loader=yaml.BaseLoader) + return {int(date[:4]) for date in raw["values"]} + + +def test_refunds_stay_inside_the_credits_list_in_every_year(): + """Checks each year either list changes, from the refund list's first block. + + Years before that block take it, because policyengine-core reads an empty + block as no list at all. + """ + refund_years = list_change_years(STATE_LISTS / "state_property_tax_refunds.yaml") + years = refund_years | list_change_years( + STATE_LISTS / "state_property_tax_credits.yaml" + ) + for year in sorted(year for year in years if year >= min(refund_years)): + outside = property_tax_refunds(year) - property_tax_credit_items(year) + assert outside == set(), f"{year}: refunds not in the credits list: {outside}" + + def test_property_tax_credit_routes_have_no_stale_entries(): listed = set().union(*(property_tax_credit_items(year) for year in YEARS)) documented = set(PROPERTY_TAX_CREDITS_IN_INCOME_TAX) | set( From 8bc99ba750b5a544028b2a4b1248dd68bb006675 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 04:03:16 -0500 Subject: [PATCH 3/5] Keep Vermont's credit out of the refund list before 2021 and require the whole dollar cc9a076 dropped the empty first block from state_property_tax_refunds and said policyengine-core reads an empty block as no list. That was wrong. An empty list block works with adds. The crash came from my own test loop, which started at 1976, before any block and before PE-US copies parameters back to 2015, so the parameter returned None. Without the block, PE-US copied the 2021 block back to 2015 and counted the Vermont renter credit that the model computes for 2015 to 2020 from its 2021 parameters. The list now opens with an empty block on 1977-01-01, the first date of state_property_tax_credits, and a YAML case keeps the 2020 credit out of both benefit totals. test_property_tax_credits_move_both_net_incomes_together let an income tax credit lose part of its value to federal tax through the SALT deduction. Each of the 18 income tax credits moved both totals by the full amount, at $500 and $4,321, so the test now requires the full amount. Co-Authored-By: Claude Opus 5.5 --- .../household/state_property_tax_refunds.yaml | 15 +++++---- .../property/state_property_tax_refunds.yaml | 26 +++++++++++++++ .../test_household_spm_resource_parity.py | 32 ++++++++----------- 3 files changed, 48 insertions(+), 25 deletions(-) diff --git a/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml index 2b11b6c65cc..67942174cac 100644 --- a/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml +++ b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml @@ -1,12 +1,13 @@ description: States pay these property tax and rent refunds in cash, outside the income tax return. values: # Each refund appears from the year it enters state_property_tax_credits, - # so from 2021 on this list stays inside that one. Years before 2021 take - # the 2021 block, as household_state_benefits' first block covers the years - # before it; policyengine-core reads an empty block as no list at all. + # so this list stays inside that one in every year. The empty first block + # shares that list's first date; without it, PE-US would copy the 2021 + # block back to 2015, its first modeled year. # The other members of state_property_tax_credits either sit in a state's # income tax credit lists, which reach net income through state income tax, # or reduce the property tax bill, which no net income total subtracts. + 1977-01-01: [] 2021-01-01: # Vermont pays the renter credit on its own claim, Form RCC-146. Schedule # IN-112 carries only other refundable credits to Form IN-111. @@ -45,13 +46,13 @@ metadata: href: https://www.revisor.mn.gov/statutes/cite/290A.07 - title: 2025 Form M1PR instructions, refund check or direct deposit href: https://www.revenue.state.mn.us/sites/default/files/2026-06/m1pr-inst-25.pdf#page=5 - - title: "Montana Department of Revenue, income tax and property tax rebate report, May 2024 #page 13-15" + - title: Montana Department of Revenue, income tax and property tax rebate report, May 2024 href: https://archive.legmt.gov/content/Committees/Interim/2023-2024/Revenue/Meetings/May-2024/5.1-DOR-rebate-report.pdf#page=13 - - title: "2025 Montana Session Laws, Chapter 767 (SB 542), sec. 3(5) and (9) #page 3-4" + - title: 2025 Montana Session Laws, Chapter 767 (SB 542), sec. 3(5) and (9) href: https://archive.legmt.gov/content/Sessions/69th/Contractor_index/CH0767.pdf#page=3 - title: 2025 Form PA-1000 booklet, Line 20, rebate deposited or mailed by the Department of Revenue href: https://www.pa.gov/content/dam/copapwp-pagov/en/revenue/documents/formsandpublications/formsforindividuals/ptrr/documents/2025_pa-1000_inst.pdf#page=11 - - title: "Utah Code 59-2a-203 and 59-2a-205(5), renter's credit paid by the commission from the General Fund #page 11-12" + - title: Utah Code 59-2a-203 and 59-2a-205(5), renter's credit paid by the commission from the General Fund href: https://le.utah.gov/xcode/Title59/Chapter2A/C59-2a_2026010120250507.pdf#page=11 - - title: "2024 Vermont income tax booklet, Schedule IN-112 Part II and Form RCC-146 instructions #page 24,35" + - title: 2024 Vermont income tax booklet, Schedule IN-112 Part II and Form RCC-146 instructions href: https://tax.vermont.gov/sites/tax/files/documents/Income%20Booklet-2024.pdf#page=24 diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml index 2a5d1ae222f..4e8f6dcff88 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml @@ -187,6 +187,32 @@ household_benefits: 1_729 spm_unit_benefits: 1_729 +- name: Vermont's renter credit stays out of both totals before it enters the credits list in 2021. + # The input stands in for the credit PE-US computes for 2020 from its 2021 + # parameters. + period: 2020 + input: + people: + adult: + age: 40 + employment_income: 500_000 + tax_units: + tax_unit: + members: [adult] + vt_renter_credit: 1_729 + spm_units: + spm_unit: + members: [adult] + households: + household: + members: [adult] + state_code: VT + output: + state_property_tax_refunds: 0 + household_state_benefits: 0 + household_benefits: 0 + spm_unit_benefits: 0 + - name: A Utah owner who also rents counts only the renter's credit. # Same household as Case 7 of ut_homeowner_renter_relief.yaml. Counties # apply the $600 homeowner's credit to the property tax bill. diff --git a/policyengine_us/tests/test_household_spm_resource_parity.py b/policyengine_us/tests/test_household_spm_resource_parity.py index cefbcb88ab5..b3d554e469a 100644 --- a/policyengine_us/tests/test_household_spm_resource_parity.py +++ b/policyengine_us/tests/test_household_spm_resource_parity.py @@ -530,16 +530,16 @@ def list_change_years(path): def test_refunds_stay_inside_the_credits_list_in_every_year(): - """Checks each year either list changes, from the refund list's first block. + """Checks each year either list changes, so it covers every pair in force. - Years before that block take it, because policyengine-core reads an empty - block as no list at all. + PE-US copies each list's first block back to 2015, its first modeled + year, so the refund list starts with an empty block on the credits list's + first date. """ - refund_years = list_change_years(STATE_LISTS / "state_property_tax_refunds.yaml") - years = refund_years | list_change_years( - STATE_LISTS / "state_property_tax_credits.yaml" - ) - for year in sorted(year for year in years if year >= min(refund_years)): + years = list_change_years( + STATE_LISTS / "state_property_tax_refunds.yaml" + ) | list_change_years(STATE_LISTS / "state_property_tax_credits.yaml") + for year in sorted(years): outside = property_tax_refunds(year) - property_tax_credit_items(year) assert outside == set(), f"{year}: refunds not in the credits list: {outside}" @@ -583,12 +583,12 @@ def one_household_per_credit(credits, amount, year): def test_property_tax_credits_move_both_net_incomes_together(amount): """A differential test over every credit in state_property_tax_credits. - A payment through state income tax can lose part of its value to federal - tax, through the SALT deduction, but it loses the same part in both totals. + At $500,000 of wages each earner owes enough state income tax to use a + non-refundable credit in full, so every credit but a bill reduction moves + both totals by the whole amount. """ year = 2026 credits = sorted(property_tax_credit_items(year)) - refunds = property_tax_refunds(year) simulation = Simulation(situation=one_household_per_credit(credits, amount, year)) def change(name): @@ -598,10 +598,6 @@ def change(name): household = change("household_net_income") spm = change("spm_unit_net_income") for name, household_change, spm_change in zip(credits, household, spm): - assert household_change == pytest.approx(spm_change, abs=TOLERANCE), name - if name in PROPERTY_TAX_BILL_REDUCTIONS: - assert household_change == pytest.approx(0, abs=TOLERANCE), name - elif name in refunds: - assert household_change == pytest.approx(amount, abs=TOLERANCE), name - else: - assert TOLERANCE < household_change <= amount + TOLERANCE, name + expected = 0 if name in PROPERTY_TAX_BILL_REDUCTIONS else amount + assert household_change == pytest.approx(expected, abs=TOLERANCE), name + assert spm_change == pytest.approx(expected, abs=TOLERANCE), name From 458d45172a18c45afd9c8f074c8c9ac6de2b0301 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 04:03:16 -0500 Subject: [PATCH 4/5] Limit Vermont's renter credit to tax units that paid rent vt_renter_credit had no renter test, so it gave every low-income Vermont tax unit a credit based on fair market rent, homeowners included. Only the TAXSIM comparison read the amount until this branch counted the credit in benefits. The AABD integration case then showed the result: $1,324 more in household_state_benefits for a person who pays no rent. 32 V.S.A. 6066(b)(1) gives the credit to "an eligible claimant who rented the homestead", and 6066(c)(3) requires at least six calendar months of renting. vt_renter_credit_eligible applies the rent test. PE-US has no months-rented input, so rent paid stands in for the six-month test. PE-US #10044 adds the 6066(c)(2) dependent bar with its joint-return reading, so this commit leaves that bar to it. The YAML cases and department-table tests set rent to 0 while checking the credit amount. They now set rent, and new cases and properties cover a tax unit that pays no rent and a tenant whose subsidy covers all the rent. Co-Authored-By: Claude Opus 5.5 --- .../nd-renters-refund-net-income.fixed.md | 2 +- .../rent/test_vt_renter_credit_amounts.py | 43 ++++++++- .../income/credits/rent/vt_renter_credit.yaml | 56 ++++++------ .../rent/vt_renter_credit_eligible.yaml | 87 +++++++++++++++++++ .../income/credits/renter/vt_renter_credit.py | 2 +- .../renter/vt_renter_credit_eligible.py | 21 +++++ 6 files changed, 179 insertions(+), 32 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_eligible.yaml create mode 100644 policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit_eligible.py diff --git a/changelog.d/nd-renters-refund-net-income.fixed.md b/changelog.d/nd-renters-refund-net-income.fixed.md index acb5ffb2e48..0498a429c4e 100644 --- a/changelog.d/nd-renters-refund-net-income.fixed.md +++ b/changelog.d/nd-renters-refund-net-income.fixed.md @@ -1 +1 @@ -Count the property tax and rent refunds that North Dakota, Minnesota, Montana, Pennsylvania, Utah and Vermont pay outside the income tax in household and SPM benefits, and split Utah's homeowner and renter relief into the renter's credit the state pays and the homeowner's credit counties apply to the bill. +Count the property tax and rent refunds that North Dakota, Minnesota, Montana, Pennsylvania, Utah and Vermont pay outside the income tax in household and SPM benefits, and split Utah's homeowner and renter relief into the renter's credit the state pays and the homeowner's credit counties apply to the bill. Limit Vermont's renter credit to tax units that paid rent, as 32 V.S.A. 6066 requires. diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/test_vt_renter_credit_amounts.py b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/test_vt_renter_credit_amounts.py index dd1b27f4aa4..616a6bc2913 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/test_vt_renter_credit_amounts.py +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/test_vt_renter_credit_amounts.py @@ -143,12 +143,12 @@ def _department_credit(amount, income, full, partial): return Fraction(amount) * Fraction(partial - income, partial - full) -def _calculate(year, households): +def _calculate(year, households, rent=12_000): """vt_renter_credit for single-person households given as (county, tax unit size, renter credit income).""" people, tax_units, spm_units, households_out = {}, {}, {}, {} for i, (county, size, income) in enumerate(households): - people[f"person_{i}"] = {"age": {year: 40}, "rent": {year: 0}} + people[f"person_{i}"] = {"age": {year: 40}, "rent": {year: rent}} tax_units[f"tax_unit_{i}"] = { "members": [f"person_{i}"], "tax_unit_size": {year: size}, @@ -261,3 +261,42 @@ def test_random_incomes_follow_department_formula(year, draws): households.append((county, size, income)) expected.append(int(credit + Fraction(1, 2))) assert list(_calculate(year, households)) == expected + + +UNITS = st.lists( + st.tuples( + st.sampled_from(COUNTIES), + st.integers(min_value=1, max_value=8), + st.floats(min_value=0, max_value=1.2), + ), + min_size=1, + max_size=40, +) + + +def _households(year, draws): + return [ + (county, size, int(share * _limits(year, county, size)[1])) + for county, size, share in draws + ] + + +@hypothesis.settings(max_examples=10, deadline=None) +@hypothesis.given(year=st.sampled_from(list(YEARS)), draws=UNITS) +def test_a_household_that_pays_no_rent_gets_no_credit(year, draws): + """32 V.S.A. 6066(b)(1) gives the credit to a claimant who rented.""" + assert not _calculate(year, _households(year, draws), rent=0).any() + + +@hypothesis.settings(max_examples=10, deadline=None) +@hypothesis.given( + year=st.sampled_from(list(YEARS)), + draws=UNITS, + rent=st.integers(min_value=1, max_value=100_000), +) +def test_unsubsidized_credit_does_not_depend_on_rent_paid(year, draws, rent): + """The unsubsidized credit is a share of fair market rent, not rent paid.""" + households = _households(year, draws) + assert list(_calculate(year, households, rent=rent)) == list( + _calculate(year, households) + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml index 6a063373d9a..652efa72b92 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit.yaml @@ -45,7 +45,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: vt_renter_credit: 0 @@ -58,7 +58,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: vt_renter_credit: 1_340 @@ -98,7 +98,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: vt_renter_credit: 1_692 @@ -111,7 +111,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: vt_renter_credit: 2_115 @@ -141,7 +141,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: vt_renter_credit: 1_692 @@ -158,7 +158,7 @@ tax_unit_size: 4 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,477.91 = 1,773.49; the whole-dollar 1,478 gave 1,774. @@ -172,7 +172,7 @@ tax_unit_size: 3 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,457.83 = 1,749.40; the whole-dollar 1,458 gave 1,750. @@ -186,7 +186,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,057.75 = 1,269.30; the whole-dollar 1,058 gave 1,270. @@ -200,7 +200,7 @@ tax_unit_size: 3 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,687.17 = 2,024.60; the whole-dollar 1,688 gave 2,026. @@ -214,7 +214,7 @@ tax_unit_size: 6 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,828.75 = 2,194.50 exactly, which the department rounds up. @@ -228,7 +228,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (49,340 - 31,090) / (49,340 - 22,790) x 1,269 = 872.29. @@ -242,7 +242,7 @@ tax_unit_size: 3 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (63,410 - 39,980) / (63,410 - 29,280) x 2,025 = 1,390.15. @@ -256,7 +256,7 @@ tax_unit_size: 3 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (42,350 - 30,740) / (42,350 - 25,640) x 1,749 = 1,215.19. @@ -270,7 +270,7 @@ tax_unit_size: 5 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (47,990 - 37,560) / (47,990 - 32,660) x 1,773 = 1,206.29. @@ -284,7 +284,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 10% x 12 x 1,103 = 1,323.60, which the department rounds to 1,324 before @@ -303,7 +303,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (49,340 - 36,065) / (49,340 - 22,790) x 1,269 = 634.50. @@ -317,7 +317,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # (47,190 - 34,495) / (47,190 - 21,800) x 1,169 = 584.50. @@ -331,7 +331,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 1,103 x 12 x the temporary 12.5% rate = 1,654.50. @@ -347,7 +347,7 @@ tax_unit_size: 1 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # 924 x (30,000 - 23,500) / (30,000 - 18,000) = 500.50. @@ -366,7 +366,7 @@ tax_unit_size: 7 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 88,990 = 1.3 x HUD FY2025 very low-income limit 68,450, @@ -383,7 +383,7 @@ tax_unit_size: 7 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Above the booklet's 88,880 but below 88,990. @@ -399,7 +399,7 @@ tax_unit_size: 8 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 102,050 = 1.3 x HUD FY2024 limit 78,500 (PE had 102,850). @@ -413,7 +413,7 @@ tax_unit_size: 3 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 61,690 = 1.3 x HUD FY2024 limit 47,450 (PE had 61,960). @@ -427,7 +427,7 @@ tax_unit_size: 4 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Full limit 31,200 = HUD FY2024 extremely low-income limit (PE had 30,000). @@ -442,7 +442,7 @@ tax_unit_size: 7 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 58,320 (PE had Lamoille's 56,250). Full limit 45,420. @@ -458,7 +458,7 @@ tax_unit_size: 7 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 56,250 (PE had Franklin's 58,320). Full limit 45,420. @@ -474,7 +474,7 @@ tax_unit_size: 2 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Partial limit 31,550 = HUD FY2021 limit (PE had 31,350). Full limit 18,950. @@ -490,7 +490,7 @@ tax_unit_size: 5 rent_is_shared_with_another_tax_unit: false housing_assistance: 0 - rent: 0 + rent: 12_000 state_code: VT output: # Full limit 31,050 = HUD FY2021 extremely low-income limit (PE had 31,040). diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_eligible.yaml new file mode 100644 index 00000000000..eca0e737e58 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_eligible.yaml @@ -0,0 +1,87 @@ +# Same household as "test 5" of vt_renter_credit.yaml, which gets $1,340. + +- name: A low-income Vermont renter is eligible and gets the credit. + period: 2022 + input: + vt_renter_credit_income: 5_000 + county: ADDISON_COUNTY_VT + tax_unit_size: 2 + rent_is_shared_with_another_tax_unit: false + housing_assistance: 0 + rent: 12_000 + state_code: VT + output: + vt_renter_credit_eligible: true + vt_renter_credit: 1_340 + +- name: 32 V.S.A. 6066(b)(1) gives a homeowner who pays no rent no renter credit. + period: 2022 + input: + vt_renter_credit_income: 5_000 + county: ADDISON_COUNTY_VT + tax_unit_size: 2 + rent_is_shared_with_another_tax_unit: false + housing_assistance: 0 + rent: 0 + real_estate_taxes: 3_000 + state_code: VT + output: + vt_renter_credit_eligible: false + vt_renter_credit: 0 + +- name: A tenant whose subsidy covers all the rent paid no gross rent and gets no credit. + # 32 V.S.A. 6066(b)(1)(D) pays a share of gross rent paid, and 6061(7)(B) + # defines gross rent as the rent the claimant actually paid. + period: 2022 + input: + vt_renter_credit_income: 5_000 + county: ADDISON_COUNTY_VT + tax_unit_size: 2 + rent_is_shared_with_another_tax_unit: false + housing_assistance: 6_000 + pre_subsidy_rent: 6_000 + state_code: VT + output: + rent: 0 + vt_renter_credit_eligible: false + vt_renter_credit: 0 + +- name: A married couple counts rent paid by either spouse. + period: 2022 + input: + people: + head: + age: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + spouse: + age: 40 + rent: 12_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + vt_renter_credit_income: 5_000 + rent_is_shared_with_another_tax_unit: false + spm_units: + spm_unit: + members: [head, spouse] + housing_assistance: 0 + households: + household: + members: [head, spouse] + county: ADDISON_COUNTY_VT + state_code: VT + output: + vt_renter_credit_eligible: true + vt_renter_credit: 1_340 + +- name: A renter outside Vermont is not eligible. + period: 2022 + input: + rent: 12_000 + state_code: NH + output: + vt_renter_credit_eligible: false + vt_renter_credit: 0 diff --git a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py index 6284d0a2187..036bba4e648 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py @@ -13,7 +13,7 @@ class vt_renter_credit(Variable): # the formula used in this file is based on the excel sheet provided on the official Vermont government website "https://tax.vermont.gov/individuals/renter-credit/calculator-and-credit-amounts", # link for the excel sheet ) - defined_for = StateCode.VT + defined_for = "vt_renter_credit_eligible" def formula(tax_unit, period, parameters): p = parameters(period).gov.states.vt.tax.income.credits.renter diff --git a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit_eligible.py b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit_eligible.py new file mode 100644 index 00000000000..d5d78e74b9c --- /dev/null +++ b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit_eligible.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class vt_renter_credit_eligible(Variable): + value_type = bool + entity = TaxUnit + label = "Eligible for the Vermont renter credit" + definition_period = YEAR + reference = ( + "https://legislature.vermont.gov/statutes/section/32/154/06066", # (b)(1), (c)(3) + "https://tax.vermont.gov/sites/tax/files/documents/RCC-146-2024.pdf#page=1", # line 4 + ) + defined_for = StateCode.VT + + def formula(tax_unit, period, parameters): + # 32 V.S.A. 6066(b)(1) gives the credit to a claimant "who rented the + # homestead", and (c)(3) requires at least six calendar months of + # renting. PE-US has no months-rented input, so rent paid stands in for + # both. A tenant whose subsidy covers all the rent also gets nothing + # under (b)(1)(D), which pays a share of the gross rent the tenant paid. + return add(tax_unit, period, ["rent"]) > 0 From dc5083e7dab0e6c26fc8fbbba93130f51b171cf3 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Mon, 9 Nov 2026 04:37:40 -0500 Subject: [PATCH 5/5] Let Vermont's renter credit run for every household Counting the credit in net income puts its formula on every household's path. Core runs a formula over the whole population whenever any row passes defined_for, and the formula indexes tables by tax unit size and county. A 2026 microsimulation raised ParameterNotFoundError on a tax unit of nine, and a county outside Vermont's tables raises the same way. This takes the Vermont guard from #10116 word for word: tax units above eight people use the eight-person values, and a county outside Vermont's tables falls back to Addison. The department's 2025 calculator looks up IF(Household_Size>8,8,Household_Size) and accepts family sizes 1 to 10. The fallback changes nothing for a household in a Vermont county. #10116's four Vermont YAML cases move to vt_renter_credit_lookups.yaml with rent set, since this branch gives no credit to a tax unit that paid no rent. Co-Authored-By: Claude Opus 5.5 --- .../nd-renters-refund-net-income.fixed.md | 2 +- .../credits/renter/max_household_size.yaml | 25 ++ .../rent/vt_renter_credit_lookups.yaml | 226 ++++++++++++++++++ .../tools/geography/county_helpers.py | 20 ++ .../income/credits/renter/vt_renter_credit.py | 29 ++- 5 files changed, 293 insertions(+), 9 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/max_household_size.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_lookups.yaml diff --git a/changelog.d/nd-renters-refund-net-income.fixed.md b/changelog.d/nd-renters-refund-net-income.fixed.md index 0498a429c4e..07bca016d95 100644 --- a/changelog.d/nd-renters-refund-net-income.fixed.md +++ b/changelog.d/nd-renters-refund-net-income.fixed.md @@ -1 +1 @@ -Count the property tax and rent refunds that North Dakota, Minnesota, Montana, Pennsylvania, Utah and Vermont pay outside the income tax in household and SPM benefits, and split Utah's homeowner and renter relief into the renter's credit the state pays and the homeowner's credit counties apply to the bill. Limit Vermont's renter credit to tax units that paid rent, as 32 V.S.A. 6066 requires. +Count the property tax and rent refunds that North Dakota, Minnesota, Montana, Pennsylvania, Utah and Vermont pay outside the income tax in household and SPM benefits, and split Utah's homeowner and renter relief into the renter's credit the state pays and the homeowner's credit counties apply to the bill. Limit Vermont's renter credit to tax units that paid rent, as 32 V.S.A. 6066 requires, and let its table lookups run for every household, using the eight-person values for larger tax units as the Department of Taxes' calculator does. diff --git a/policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/max_household_size.yaml b/policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/max_household_size.yaml new file mode 100644 index 00000000000..160b2756f93 --- /dev/null +++ b/policyengine_us/parameters/gov/states/vt/tax/income/credits/renter/max_household_size.yaml @@ -0,0 +1,25 @@ +description: Vermont applies the renter credit income limits and fair market rents for this household size to larger households. +# The income limit and fair market rent tables run through family size 8. The +# Department of Taxes' renter credit calculators use Calculator sheet cells +# G10-G12 in 2021-2022 and G11-G13 in 2023-2025. The income limit lookups use +# IF(Household_Size>8,8,Household_Size), and the calculators accept family sizes +# 1 through 10. Their credit amount lookup caps the size at 4, which the fair +# market rent table already reflects by repeating the 4-person value through +# size 8. +values: + 2021-01-01: 8 +metadata: + unit: person + period: year + label: Vermont renter credit maximum household size + reference: + - title: 2025 Renter Credit Website Calculator (Calculator sheet, cells G11-G13) + href: https://tax.vermont.gov/sites/tax/files/documents/2025%20Renter%20Credit%20Website%20Calculator.xlsx + - title: 2024 Renter Credit Website Calculator (Calculator sheet, cells G11-G13) + href: https://tax.vermont.gov/sites/tax/files/documents/2024%20Renter%20Credit%20Website%20Calculator.xlsx + - title: 2023 Renter Credit Website Calculator (Calculator sheet, cells G11-G13) + href: https://web.archive.org/web/20250526044955/https://tax.vermont.gov/sites/tax/files/documents/2023%20Renter%20Credit%20Website%20Calculator_1.xlsx + - title: 2022 Renter Credit Website Calculator (Calculator sheet, cells G10-G12) + href: https://tax.vermont.gov/sites/tax/files/documents/2022%20Renter%20Credit%20Website%20Calculator_1.xlsx + - title: 2021 Renter Credit Website Calculator (Calculator sheet, cells G10-G12) + href: https://tax.vermont.gov/sites/tax/files/documents/2021%20Renter%20Credit%20Website%20Calculator_8.xlsx diff --git a/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_lookups.yaml b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_lookups.yaml new file mode 100644 index 00000000000..7bd9de810d6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/vt/tax/income/credits/rent/vt_renter_credit_lookups.yaml @@ -0,0 +1,226 @@ +# The formula runs over every household in a simulation, so its table lookups +# must hold for any county and tax unit size. Each renter pays $12,000 in rent +# so the rent test does not decide the result. + +- name: Out-of-state and unknown-county households run in one simulation with a Chittenden County household. + period: 2025 + input: + people: + person1: + age: 40 + rent: 12_000 + person2: + age: 40 + rent: 12_000 + person3: + age: 40 + rent: 12_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit_size: 2 + vt_renter_credit_income: 49_370 + rent_is_shared_with_another_tax_unit: false + tax_unit2: + members: [person2] + tax_unit_size: 2 + vt_renter_credit_income: 49_370 + rent_is_shared_with_another_tax_unit: false + tax_unit3: + members: [person3] + tax_unit_size: 2 + vt_renter_credit_income: 44_145 + rent_is_shared_with_another_tax_unit: false + spm_units: + spm_unit1: + members: [person1] + housing_assistance: 0 + spm_unit2: + members: [person2] + housing_assistance: 0 + spm_unit3: + members: [person3] + housing_assistance: 0 + households: + household1: + members: [person1] + state_code: VT + county: CHITTENDEN_COUNTY_VT + household2: + members: [person2] + state_code: NH + county: GRAFTON_COUNTY_NH + household3: + members: [person3] + state_code: VT + county: UNKNOWN + output: + # tax_unit1: Chittenden 2025, size 2: full credit limit 31,200, partial + # credit limit 67,540, fair market rent 1,936. + # (67,540 - 49,370) / (67,540 - 31,200) = 0.5; + # Base: ROUND(1,936 * 12 * 0.1, 0) = 2,323; + # 0.5 * 2,323 = 1,161.5 -> 1,162 (half up). + # tax_unit2: New Hampshire; defined_for zeroes the result. + # tax_unit3: UNKNOWN county falls back to Addison 2025, size 2: limits + # 27,900 and 60,390, fair market rent 1,410. + # (60,390 - 44,145) / (60,390 - 27,900) = 0.5; + # Base: ROUND(1,410 * 12 * 0.1, 0) = 1,692; + # 0.5 * 1,692 = 846. (Chittenden would give 1,496.) + # The Department of Taxes 2025 Renter Credit Website Calculator gives + # 1,162 (Chittenden) and 846 (Addison) for these inputs. + vt_renter_credit: [1_162, 0, 846] + +- name: Tax units of nine or more use the eight-person income limits and fair market rent. + # The Department of Taxes renter credit calculator looks up + # IF(Household_Size>8,8,Household_Size). + period: 2025 + input: + people: + person1: + age: 40 + rent: 12_000 + person2: + age: 40 + rent: 12_000 + person3: + age: 40 + rent: 12_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit_size: 8 + vt_renter_credit_income: 82_780 + rent_is_shared_with_another_tax_unit: false + tax_unit2: + members: [person2] + tax_unit_size: 9 + vt_renter_credit_income: 82_780 + rent_is_shared_with_another_tax_unit: false + tax_unit3: + members: [person3] + tax_unit_size: 12 + vt_renter_credit_income: 82_780 + rent_is_shared_with_another_tax_unit: false + spm_units: + spm_unit1: + members: [person1] + housing_assistance: 0 + spm_unit2: + members: [person2] + housing_assistance: 0 + spm_unit3: + members: [person3] + housing_assistance: 0 + households: + household1: + members: [person1] + state_code: VT + county: CHITTENDEN_COUNTY_VT + household2: + members: [person2] + state_code: VT + county: CHITTENDEN_COUNTY_VT + household3: + members: [person3] + state_code: VT + county: CHITTENDEN_COUNTY_VT + output: + # Chittenden 2025, size 8: full credit limit 54,150, partial credit limit + # 111,410, fair market rent 2,083. + # (111,410 - 82,780) / (111,410 - 54,150) = 0.5; + # Base: ROUND(2,083 * 12 * 0.1, 0) = 2,500; + # 0.5 * 2,500 = 1,250 for all three tax units. + # (The 7-person limits, 48,650 and 104,650, would give 976.) + # The Department of Taxes 2025 Renter Credit Website Calculator gives + # 1,250 for family sizes 8, 9 and 10 (its maximum input) and 976 for 7. + vt_renter_credit: [1_250, 1_250, 1_250] + +- name: A Vermont household with a New Hampshire county uses Addison in a mixed simulation. + period: 2025 + input: + people: + person1: + age: 40 + rent: 12_000 + person2: + age: 40 + rent: 12_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit_size: 2 + vt_renter_credit_income: 49_370 + rent_is_shared_with_another_tax_unit: false + tax_unit2: + members: [person2] + tax_unit_size: 2 + vt_renter_credit_income: 44_145 + rent_is_shared_with_another_tax_unit: false + spm_units: + spm_unit1: + members: [person1] + housing_assistance: 0 + spm_unit2: + members: [person2] + housing_assistance: 0 + households: + household1: + members: [person1] + state_code: VT + county: CHITTENDEN_COUNTY_VT + household2: + members: [person2] + state_code: VT + county: GRAFTON_COUNTY_NH + output: + # tax_unit1: Chittenden, size 2: ROUND(1,936 * 12 * 0.1, 0) = 2,323; + # (67,540 - 49,370) * 2,323 / (67,540 - 31,200) = 1,161.5 -> 1,162. + # tax_unit2: the NH county falls back to Addison, size 2: + # ROUND(1,410 * 12 * 0.1, 0) = 1,692; + # (60,390 - 44,145) * 1,692 / (60,390 - 27,900) = 846. + vt_renter_credit: [1_162, 846] + +- name: UTF-8 byte counties keep the Vermont credit in a mixed-state simulation. + # !!binary supplies actual bytes through Core's YAML loader. The second + # county is DOÑA_ANA_COUNTY_NM encoded as UTF-8; an ASCII cast raises before + # defined_for can mask the New Mexico household. + period: 2025 + input: + people: + person1: + age: 40 + rent: 12_000 + person2: + age: 40 + rent: 12_000 + tax_units: + tax_unit1: + members: [person1] + tax_unit_size: 2 + vt_renter_credit_income: 49_370 + rent_is_shared_with_another_tax_unit: false + tax_unit2: + members: [person2] + tax_unit_size: 2 + vt_renter_credit_income: 49_370 + rent_is_shared_with_another_tax_unit: false + spm_units: + spm_unit1: + members: [person1] + housing_assistance: 0 + spm_unit2: + members: [person2] + housing_assistance: 0 + households: + household1: + members: [person1] + state_code: VT + county_str: !!binary Q0hJVFRFTkRFTl9DT1VOVFlfVlQ= # CHITTENDEN_COUNTY_VT + household2: + members: [person2] + state_code: NM + county_str: !!binary RE/DkUFfQU5BX0NPVU5UWV9OTQ== # DOÑA_ANA_COUNTY_NM + output: + # Chittenden, size 2: midpoint of the income limits gives half the + # rounded $2,323 base credit, rounded half up to $1,162. + vt_renter_credit: [1_162, 0] diff --git a/policyengine_us/tools/geography/county_helpers.py b/policyengine_us/tools/geography/county_helpers.py index 7eda43b43a6..17151a480c4 100644 --- a/policyengine_us/tools/geography/county_helpers.py +++ b/policyengine_us/tools/geography/county_helpers.py @@ -15,6 +15,26 @@ COUNTY_FIPS_PACKAGE = "policyengine_us.data" +def county_has_state_suffix(county: np.ndarray, state_code: str) -> np.ndarray: + """Match county suffixes without ASCII-decoding UTF-8 byte inputs. + + Core can store bytes inside object arrays, so match each value with a + suffix of the same type and leave the county lookup keys unchanged. + """ + suffix = f"_{state_code}" + byte_suffix = suffix.encode("utf-8") + return np.fromiter( + ( + value.endswith(byte_suffix) + if isinstance(value, bytes) + else str(value).endswith(suffix) + for value in county + ), + dtype=bool, + count=len(county), + ) + + def _read_county_fips_dataset(dataset_file) -> pd.DataFrame: return pd.read_csv( dataset_file, diff --git a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py index 036bba4e648..c5866c1da44 100644 --- a/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py +++ b/policyengine_us/variables/gov/states/vt/tax/income/credits/renter/vt_renter_credit.py @@ -1,4 +1,5 @@ from policyengine_us.model_api import * +from policyengine_us.tools.geography.county_helpers import county_has_state_suffix class vt_renter_credit(Variable): @@ -17,17 +18,29 @@ class vt_renter_credit(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.vt.tax.income.credits.renter - # get tax unit size and county. + # The tables run through family size 8; larger households use the + # 8-person values, as in the Department of Taxes' renter credit + # calculator. tax_unit_size = tax_unit("tax_unit_size", period) - county = tax_unit.household("county", period) + size = min_(tax_unit_size, p.max_household_size).astype(int) + # defined_for masks the output but does not stop the formula from + # running over every household, so non-Vermont households and Vermont + # households with unknown or another state's counties use Addison + # before indexing the county-keyed tables, which would otherwise raise + # ParameterNotFoundError. + county = tax_unit.household("county_str", period) + in_vt = tax_unit.household("state_code_str", period) == "VT" + safe_county = where( + in_vt & county_has_state_suffix(county, "VT"), + county, + "ADDISON_COUNTY_VT", + ) # locate the values by family size and county - full_credit_income_limit = p.income_limit_ami.thirty_percent[tax_unit_size][ - county - ] - partial_credit_income_limit = p.income_limit_ami.fifty_percent[tax_unit_size][ - county + full_credit_income_limit = p.income_limit_ami.thirty_percent[size][safe_county] + partial_credit_income_limit = p.income_limit_ami.fifty_percent[size][ + safe_county ] - fmr = p.fair_market_rent[tax_unit_size][county] + fmr = p.fair_market_rent[size][safe_county] # The department rounds the credit amount to whole dollars, half up, # before applying the income phase-out: its 2021 calculator computes # ROUND(FMR * 12 * 10%, 0), and its published tables are whole dollars.