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..07bca016d95 --- /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. 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/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..67942174cac --- /dev/null +++ b/policyengine_us/parameters/gov/states/household/state_property_tax_refunds.yaml @@ -0,0 +1,58 @@ +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 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. + - 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 + 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) + 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 + 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 + 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/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/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..4e8f6dcff88 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/property/state_property_tax_refunds.yaml @@ -0,0 +1,313 @@ +# 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: 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. + 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/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/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/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..b3d554e469a 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,126 @@ 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 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, so it covers every pair in force. + + 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. + """ + 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}" + + +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. + + 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)) + 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): + 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 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/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/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..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): @@ -13,21 +14,33 @@ 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 - # 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. 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 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: