diff --git a/changelog.d/nd-renters-refund-public-assistance.fixed.md b/changelog.d/nd-renters-refund-public-assistance.fixed.md new file mode 100644 index 00000000000..64ff7d00477 --- /dev/null +++ b/changelog.d/nd-renters-refund-public-assistance.fixed.md @@ -0,0 +1 @@ +North Dakota renter's refund income counts Supplemental Security Income and TANF, which N.D.C.C. 57-02-08.1(5)(c) includes as public assistance benefits. diff --git a/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/income_sources.yaml b/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/income_sources.yaml index 31642f83af3..27f742af193 100644 --- a/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/income_sources.yaml +++ b/policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/income_sources.yaml @@ -4,7 +4,16 @@ values: - market_income - social_security - total_unemployment_compensation + # NDCC 57-02-08.1(5)(c) counts "any county, state, or federal public + # assistance benefits". The IRS leaves general assistance, SSI and TANF out + # of gross income under its general welfare exclusion, which rests on IRS + # rulings, not a statute or regulation, so no federal or state law + # excludes them from income. SNAP (7 U.S.C. 2017(b)) and LIHEAP, including + # nd_liheap (42 U.S.C. 8624(f)(1)), stay out because federal law excludes + # them from income for any purpose under state law. - general_assistance + - ssi + - tanf metadata: unit: list period: year @@ -14,3 +23,5 @@ metadata: href: https://ndlegis.gov/cencode/t57c02.pdf#page=16 - title: North Dakota Office of State Tax Commissioner | Property Tax Credits for North Dakota Homeowners and Renters href: https://www.tax.nd.gov/sites/www/files/documents/guidelines/homestead-veterans-renters/credits-for-nd-homeowners-renters-guideline.pdf#page=2 + - title: North Dakota Office of State Tax Commissioner | SFN 24777 (10-2025), Application for Senior Citizens or Permanently and Totally Disabled Renter's Property Tax Refund, line 7 + href: https://www.tax.nd.gov/sites/www/files/documents/forms/homestead-disabled-renters/renters-refund-application.pdf#page=1 diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py index a6f6721a641..12bb5623f1c 100644 --- a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -370,7 +370,7 @@ def _state_case(**values): def _state_simulation(cases, reference_system, with_se_deduction): - people, tax_units, households, marital_units = {}, {}, {}, {} + people, tax_units, spm_units, households, marital_units = {}, {}, {}, {}, {} expectations = { "nd_renters_refund_income": [], "nm_medical_expense_credit": [], @@ -429,6 +429,10 @@ def _state_simulation(cases, reference_system, with_se_deduction): ), "employment_income": case["income"] if person_index == 0 else 0, "medicare_enrolled": False, + # NDCC 57-02-08.1(5)(c) also counts public assistance. These + # households receive none, so the ND expectation tests the + # medical base alone. + "ssi": 0, premium_variable: premiums[position], "other_medical_expenses": other[position], } @@ -448,6 +452,7 @@ def _state_simulation(cases, reference_system, with_se_deduction): if supplied: tax_unit["itemized_medical_expenses"] = {PERIOD: case["subtotal"]} tax_units[unit_name] = tax_unit + spm_units[unit_name] = {"members": names, "tanf": {PERIOD: 0}} households[unit_name] = {"members": names, "state_code": {PERIOD: state}} accounting["main_base"].append(main_base) accounting["gross"].append(gross) @@ -475,6 +480,7 @@ def _state_simulation(cases, reference_system, with_se_deduction): situation={ "people": people, "tax_units": tax_units, + "spm_units": spm_units, "households": households, "marital_units": marital_units, }, diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund.yaml index c2abaee3396..1b8c5b5bdeb 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund.yaml @@ -134,3 +134,29 @@ state_code: ND output: nd_renters_refund: 400 + +- name: Case 8, SSI counts in income and lowers the refund. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + person1: + age: 70 + social_security_retirement: 6_000 + ssi: 9_000 + rent: 4_800 + tax_units: + tax_unit: + members: [person1] + itemized_medical_expenses: 0 + households: + household: + members: [person1] + state_code: ND + output: + # NDCC 57-02-08.1(5)(c) counts social security and federal public + # assistance benefits: 6,000 + 9,000. + nd_renters_refund_income: 15_000 + # NDCC 57-02-08.1(2)(b): 20% of the rent less 4% of income, + # 960 - 600. + nd_renters_refund: 360 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index 92a0bc58635..55d4a781189 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -40,7 +40,7 @@ output: nd_renters_refund_income: 0 -- name: Case 3, income includes unemployment and excludes nonincome sources. +- name: Case 3, income includes unemployment and public assistance and excludes nonincome sources. absolute_error_margin: 0.01 period: 2025 input: @@ -61,7 +61,10 @@ members: [person1] state_code: ND output: - nd_renters_refund_income: 12_000 + # NDCC 57-02-08.1(5)(c) counts "any county, state, or federal public + # assistance benefits", so general assistance and SSI count with + # unemployment compensation: 8,000 + 4,000 + 6,000. + nd_renters_refund_income: 18_000 - name: Case 4, after-tax premiums reduce renter refund income as medical expenses. absolute_error_margin: 0.01 @@ -346,7 +349,9 @@ people: parent: age: 40 - employment_income: 30_000 + # Wages go in before any labor supply response, where TANF reads + # them, so the family has no TANF. + employment_income_before_lsr: 30_000 child: age: 15 is_tax_unit_dependent: true @@ -411,7 +416,9 @@ people: parent: age: 40 - employment_income: 45_000 + # Wages go in before any labor supply response, where TANF reads + # them, so the family has no TANF. + employment_income_before_lsr: 45_000 child: age: 3 is_tax_unit_dependent: true @@ -480,3 +487,55 @@ output: # 30,000 + 5,000 + 1,000, with no state-law subtraction. nd_renters_refund_income: 36_000 + +- name: North Dakota counts TANF, which no law excludes from income, and leaves out SNAP, which federal law excludes. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 10_000 + child: + age: 8 + tax_units: + tax_unit: + members: [parent, child] + spm_units: + spm_unit: + members: [parent, child] + tanf: 3_600 + snap: 2_400 + households: + household: + members: [parent, child] + state_code: ND + output: + # NDCC 57-02-08.1(5)(c) counts "any county, state, or federal public + # assistance benefits" and excludes "any amount excluded from income by + # federal or state law". 7 U.S.C. 2017(b) keeps SNAP out of income "for + # any purpose under any Federal, State, or local laws": 10,000 + 3,600. + nd_renters_refund_income: 13_600 + +- name: North Dakota counts a dependent's SSI with the applicant's social security. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + grandparent: + age: 70 + social_security_retirement: 18_000 + grandchild: + age: 10 + ssi: 9_000 + tax_units: + tax_unit: + members: [grandparent, grandchild] + households: + household: + members: [grandparent, grandchild] + state_code: ND + output: + # The statute counts "the income of any dependent of the applicant" and + # federal public assistance benefits: 18,000 + 9,000. + nd_renters_refund_income: 27_000 diff --git a/policyengine_us/tests/test_nd_renters_refund_public_assistance.py b/policyengine_us/tests/test_nd_renters_refund_public_assistance.py new file mode 100644 index 00000000000..4b92e625756 --- /dev/null +++ b/policyengine_us/tests/test_nd_renters_refund_public_assistance.py @@ -0,0 +1,321 @@ +"""Invariants for public assistance in North Dakota's renter's refund income. + +N.D.C.C. 57-02-08.1(5)(c) (https://ndlegis.gov/cencode/t57c02.pdf#page=16) +counts income "including any county, state, or federal public assistance +benefits" and excludes "any amount excluded from income by federal or state +law with the exception of income from social security benefits". No federal or +North Dakota law excludes SSI, TANF or general assistance from income. +7 U.S.C. 2017(b) excludes SNAP from income "for any purpose under any Federal, +State, or local laws", and 42 U.S.C. 8624(f)(1) excludes LIHEAP, including +North Dakota's, "for any purpose under any Federal or State law". SSI and +general assistance belong to a person; TANF belongs to an SPM unit, and add() +gives each member of the unit an equal share, which counts in that member's +tax unit. + +Four invariants hold for every input. + +1. A dollar of a tax unit's SSI, general assistance or TANF share raises its + renter's refund income by at most a dollar and never lowers it. It raises + it by exactly a dollar when the income without the benefits exceeds zero, + since only the floor at zero can absorb any of it. +2. SNAP and North Dakota LIHEAP never change renter's refund income or the + refund. +3. Public assistance never raises the refund, since the refund falls as + income rises. +4. TANF counts once in total across the tax units of an SPM unit: the shares + the tax units count sum to the unit's benefit, each in proportion to the + tax unit's members. This per-person split is the model's attribution of an + SPM unit's benefit, which this test pins. + +YAML pins one household per case. These need random amounts, medical costs +that push income to the floor, and SPM units that hold more than one tax unit. +""" + +from collections import Counter + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2026 +# Amounts are float32 in the model. +TOLERANCE = 0.5 + + +@st.composite +def spm_units(draw, positive_income=False): + """A renter aged 65 or more in their own tax unit, sometimes sharing the + SPM unit with a parent and child in a second tax unit.""" + wages = st.integers(min_value=1 if positive_income else 0, max_value=40_000) + benefit = st.integers(min_value=0, max_value=12_000) + unit = { + "renter": { + "age": draw(st.integers(min_value=65, max_value=90)), + "employment_income": draw(wages), + "ssi": draw(benefit), + "general_assistance": draw(benefit), + # Medical costs above income push income to the zero floor. + "other_medical_expenses": ( + 0 if positive_income else draw(st.integers(0, 60_000)) + ), + "rent": draw(st.integers(min_value=0, max_value=15_000)), + }, + "tanf": draw(st.integers(min_value=0, max_value=10_000)), + "snap": draw(st.integers(min_value=0, max_value=8_000)), + "nd_liheap": draw(st.integers(min_value=0, max_value=3_000)), + } + if positive_income or draw(st.booleans()): + unit["family"] = { + "parent_wages": draw(wages), + "child_ssi": draw(benefit), + } + return unit + + +def without_public_assistance(unit): + renter = {**unit["renter"], "ssi": 0, "general_assistance": 0} + family = unit.get("family") + if family is not None: + family = {**family, "child_ssi": 0} + return {**unit, "renter": renter, "family": family, "tanf": 0} + + +def without(unit, *benefits): + return {**unit, **{benefit: 0 for benefit in benefits}} + + +def situation(units): + """One household, family and SPM unit per unit, all in North Dakota. + Every benefit is an input, so no formula adds any other.""" + people, tax_units, marital_units = {}, {}, {} + spm, families, households = {}, {}, {} + for index, unit in enumerate(units): + renter = f"renter_{index}" + people[renter] = { + "age": {YEAR: unit["renter"]["age"]}, + # No computed Medicare premium adds to the medical costs. + "medicare_enrolled": {YEAR: False}, + "is_tax_unit_head": {YEAR: True}, + "is_tax_unit_dependent": {YEAR: False}, + **{ + name: {YEAR: unit["renter"][name]} + for name in ( + "employment_income", + "ssi", + "general_assistance", + "other_medical_expenses", + "rent", + ) + }, + } + members = [renter] + tax_units[f"renter_tax_unit_{index}"] = {"members": [renter]} + marital_units[f"renter_marital_unit_{index}"] = {"members": [renter]} + family = unit.get("family") + if family is not None: + parent, child = f"parent_{index}", f"child_{index}" + people[parent] = { + "age": {YEAR: 30}, + "medicare_enrolled": {YEAR: False}, + "is_tax_unit_head": {YEAR: True}, + "is_tax_unit_dependent": {YEAR: False}, + "employment_income": {YEAR: family["parent_wages"]}, + "ssi": {YEAR: 0}, + "general_assistance": {YEAR: 0}, + } + people[child] = { + "age": {YEAR: 8}, + "medicare_enrolled": {YEAR: False}, + "is_tax_unit_head": {YEAR: False}, + "is_tax_unit_dependent": {YEAR: True}, + "ssi": {YEAR: family["child_ssi"]}, + "general_assistance": {YEAR: 0}, + } + members += [parent, child] + tax_units[f"family_tax_unit_{index}"] = {"members": [parent, child]} + marital_units[f"parent_marital_unit_{index}"] = {"members": [parent]} + marital_units[f"child_marital_unit_{index}"] = {"members": [child]} + spm[f"spm_unit_{index}"] = { + "members": members, + "tanf": {YEAR: unit["tanf"]}, + "snap": {YEAR: unit["snap"]}, + "nd_liheap": {YEAR: unit["nd_liheap"]}, + } + # Without explicit families, the model puts every person in one. + families[f"family_{index}"] = {"members": members} + households[f"household_{index}"] = { + "members": members, + "state_code": {YEAR: "ND"}, + } + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm, + "families": families, + "households": households, + } + + +def tax_unit_rows(units): + """For each tax unit in situation order: its SPM unit's index, its member + count, and the SSI and general assistance its members receive.""" + rows = [] + for index, unit in enumerate(units): + renter = unit["renter"] + rows.append((index, 1, renter["ssi"] + renter["general_assistance"])) + family = unit.get("family") + if family is not None: + rows.append((index, 2, family["child_ssi"])) + return rows + + +def calculate(simulation, *names): + return [simulation.calculate(name, YEAR).astype(float) for name in names] + + +def tanf_shares(units): + """Each tax unit's members' equal shares of their SPM unit's TANF.""" + rows = tax_unit_rows(units) + sizes = Counter() + for index, members, _ in rows: + sizes[index] += members + return np.array( + [units[index]["tanf"] * members / sizes[index] for index, members, _ in rows] + ) + + +def expected_public_assistance(units): + """Each tax unit's SSI and general assistance, plus its TANF share.""" + person_benefits = np.array([benefits for _, _, benefits in tax_unit_rows(units)]) + return person_benefits + tanf_shares(units) + + +@settings(max_examples=12, deadline=None) +@given(units=st.lists(spm_units(), min_size=1, max_size=8)) +@example( + units=[ + # The renter's medical costs exceed wages and benefits together, so + # the floor absorbs all of them. + { + "renter": { + "age": 70, + "employment_income": 5_000, + "ssi": 6_000, + "general_assistance": 1_000, + "other_medical_expenses": 30_000, + "rent": 6_000, + }, + "tanf": 0, + "snap": 2_000, + "nd_liheap": 600, + }, + # The floor absorbs part of the SSI. + { + "renter": { + "age": 70, + "employment_income": 0, + "ssi": 9_000, + "general_assistance": 0, + "other_medical_expenses": 4_000, + "rent": 6_000, + }, + "tanf": 0, + "snap": 0, + "nd_liheap": 0, + }, + ] +) +@example( + units=[ + # Above the floor in a shared SPM unit, the renter's tax unit counts + # 3,000 of SSI, 500 of general assistance and a third of the TANF + # (4,700), and the family's counts the child's 2,400 of SSI and two + # thirds of the TANF (4,800). + { + "renter": { + "age": 72, + "employment_income": 12_000, + "ssi": 3_000, + "general_assistance": 500, + "other_medical_expenses": 0, + "rent": 7_200, + }, + "tanf": 3_600, + "snap": 1_500, + "nd_liheap": 900, + "family": {"parent_wages": 9_000, "child_ssi": 2_400}, + } + ] +) +def test_public_assistance_counts_dollar_for_dollar_above_the_floor(units): + stripped = [without_public_assistance(unit) for unit in units] + no_snap_or_liheap = [without(unit, "snap", "nd_liheap") for unit in units] + simulation = Simulation(situation=situation(units + stripped + no_snap_or_liheap)) + # The SNAP and LIHEAP inputs reach the model, so the guard below tests them. + for name in ("snap", "nd_liheap"): + (amounts,) = calculate(simulation, name) + np.testing.assert_allclose( + amounts[: len(units)], [unit[name] for unit in units], atol=TOLERANCE + ) + income, refund = calculate( + simulation, "nd_renters_refund_income", "nd_renters_refund" + ) + count = len(income) // 3 + with_benefits, without_benefits, without_snap_or_liheap = ( + income[:count], + income[count : 2 * count], + income[2 * count :], + ) + benefits = expected_public_assistance(units) + added = with_benefits - without_benefits + assert (added >= -TOLERANCE).all() + assert (added <= benefits + TOLERANCE).all() + above_floor = without_benefits > 0 + np.testing.assert_allclose( + added[above_floor], benefits[above_floor], atol=TOLERANCE + ) + # SNAP and LIHEAP stay out of income and the refund. + np.testing.assert_allclose(without_snap_or_liheap, with_benefits, atol=TOLERANCE) + np.testing.assert_allclose(refund[2 * count :], refund[:count], atol=TOLERANCE) + # More income never raises the refund. + assert (refund[:count] <= refund[count : 2 * count] + TOLERANCE).all() + + +@settings(max_examples=12, deadline=None) +@given(units=st.lists(spm_units(positive_income=True), min_size=1, max_size=8)) +@example( + units=[ + { + "renter": { + "age": 72, + "employment_income": 12_000, + "ssi": 0, + "general_assistance": 0, + "other_medical_expenses": 0, + "rent": 7_200, + }, + "tanf": 3_600, + "snap": 0, + "nd_liheap": 0, + "family": {"parent_wages": 9_000, "child_ssi": 0}, + } + ] +) +def test_tanf_counts_once_across_the_tax_units_of_an_spm_unit(units): + no_tanf = [without(unit, "tanf") for unit in units] + (income,) = calculate( + Simulation(situation=situation(units + no_tanf)), "nd_renters_refund_income" + ) + count = len(income) // 2 + added = income[:count] - income[count:] + # Every tax unit has wages, so the floor absorbs no share. + np.testing.assert_allclose(added, tanf_shares(units), atol=TOLERANCE) + totals = Counter() + for (index, _, _), share in zip(tax_unit_rows(units), added): + totals[index] += share + for index, total in totals.items(): + assert total == pytest.approx(units[index]["tanf"], abs=TOLERANCE) diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index aa64b2e1811..5586cf5c8c3 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -6831,6 +6831,63 @@ candidates: - https://github.com/PolicyEngine/policyengine-us/pull/10663 state: ruled note: The statute's text settles the case, so it governs over the guideline's general list. +- id: us-nd:renters-refund-tanf + program: renter-credit + years: + from: 2025 + type: tax + question: Does the renter's refund count TANF, when the statute counts public assistance benefits and the Tax + Commissioner's guideline lists TANF among items that are not income? + sources: + - kind: statute + citation: N.D.C.C. 57-02-08.1(5)(c) + url: https://ndlegis.gov/cencode/t57c02.pdf#page=16 + - kind: guidance + citation: Property Tax Credits for North Dakota Homeowners and Renters, item 11 + url: https://www.tax.nd.gov/sites/www/files/documents/guidelines/homestead-veterans-renters/credits-for-nd-homeowners-renters-guideline.pdf#page=2 + - kind: form + citation: SFN 24777 (10-2025), line 7 + url: https://www.tax.nd.gov/sites/www/files/documents/forms/homestead-disabled-renters/renters-refund-application.pdf#page=1 + - kind: form + citation: SFN 24757 (10-2025), 2026 homestead credit application, list of income, p. 2 + url: https://www.tax.nd.gov/sites/www/files/documents/property-tax/2026-homestead-credit-application.pdf#page=2 + - kind: statute + citation: House Bill 1174 (2019), enrolled, sections 1 and 3 + url: https://ndlegis.gov/assembly/66-2019/regular/documents/19-0115-05000.pdf#page=1 + - kind: agency-practice + citation: Office of Tax Commissioner, fiscal note on House Bill 1174 (2019), on section 1 + url: https://ndlegis.gov/files/resource/66-2019/library/hb1174.pdf#page=1 + readings: + statute: Income includes "any county, state, or federal public assistance benefits" and leaves out "any amount + excluded from income by federal or state law with the exception of income from social security benefits". No + federal or North Dakota law excludes TANF from income. The IRS leaves need-based public assistance out of gross + income under its general welfare exclusion, which rests on IRS rulings, not a statute or regulation. Federal law + does exclude SNAP (7 U.S.C. 2017(b)) and LIHEAP (42 U.S.C. 8624(f)(1)) from income for any purpose under state + law, and the clause excepts only social security, so those two stay out. + other: Item 11 lists TANF among items that are not income, in the same list as food stamps and federal fuel + assistance. + example: A renter with $10,000 of wages and $3,600 of TANF has income of $13,600 under the statute and $10,000 + under item 11. + pe_us: + follows: statute + code: + - policyengine_us/parameters/gov/states/nd/tax/property/renters_refund/income_sources.yaml + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10724 + state: ruled + note: The statute names public assistance benefits as income, so it governs over item 11's general list, as in + us-nd:renters-refund-insurance-proceeds. Reading "excluded from income by federal or state law" to take in the + general welfare exclusion would leave the clause that counts public assistance almost nothing to count. N.D.C.C. + 1-02-07 and 1-02-38(2) call for a reading that gives both clauses effect. The inclusion counts public assistance, + and the exclusion removes what a statute or regulation excludes. The exception for social security says which + excluded amounts come back. The Legislative Assembly added it in House Bill 1174 (2019), the Act that created a + state income tax deduction for social security (section 3, now N.D.C.C. 57-38-30.3(2)(s)). In its fiscal note, the + Tax Commissioner's office wrote that section 1 of that Act keeps the full benefit in the homestead income test, so + the exception says nothing about benefits no statute excludes. The Tax Commissioner's own forms count SSI, which + the IRS also leaves out of gross income only under the general welfare exclusion. SFN 24777 line 7 asks for + "S.S.I.", and the 2026 homestead credit application lists "SSI benefits" as income. The May 2013 edition of the + guideline counted TANF as income under item 10, and Engrossed House Bill 1147 (2015) shows the public assistance + words as existing text; the June 2023 and July 2025 editions list TANF under item 11. - id: us-nd:stillborn-subtraction-dependency-condition program: income-tax years: