diff --git a/changelog.d/auto-loan-interest-per-tax-unit.fixed.md b/changelog.d/auto-loan-interest-per-tax-unit.fixed.md new file mode 100644 index 00000000000..0f3ba64d1ce --- /dev/null +++ b/changelog.d/auto-loan-interest-per-tax-unit.fixed.md @@ -0,0 +1 @@ +Count qualified passenger vehicle loan interest on the return of the taxpayer who incurred the loan (26 U.S.C. 163(h)(4)(B)(i)): add a person-level input for each filer's own loans and give a household-level amount to the household head's tax unit instead of splitting it across the household's tax units by head count. diff --git a/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py new file mode 100644 index 00000000000..50bc96fbdca --- /dev/null +++ b/policyengine_us/tests/core/test_auto_loan_interest_attribution_invariants.py @@ -0,0 +1,335 @@ +"""Invariants for attributing car loan interest to tax units. + +26 U.S.C. 163(h)(4)(B)(i) counts interest "on indebtedness incurred by the +taxpayer", and 26 CFR 1.163-16(h)(1) limits it to $10,000 "per Federal tax +return regardless of filing status". The model takes each person's own loans +(qualified_passenger_vehicle_loan_interest_person, for the tax unit head and +spouse) and a household-level amount that goes in full to the tax unit of the +household head (the oldest member when no head is designated, the older flagged +head when several are). + +Invariants, for every household of one to four tax units, written from the +statute and the regulation rather than read from the model's parameters: + +1. Conservation: the household's tax units together hold the household amount + plus every head's and spouse's own interest. Nothing is counted twice and + only a dependent's own interest is dropped. +2. One holder: the household amount lands on exactly one tax unit, the one + holding the designated (or fallback) household head. +3. Locality: a tax unit's deduction depends only on its own filers' loans and + on the household amount when it holds the head. Zeroing other tax units' + loans leaves it unchanged. +4. Differential against the statute: the deduction is + max(min(interest, 10,000) - 200 x ceil(max(MAGI - threshold, 0) / 1,000), 0), + with a $200,000 threshold for a joint return and $100,000 otherwise. +5. Bounds: 0 <= deduction <= min(tax unit interest, 10,000). +""" + +import math + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +CAP = 10_000 # 26 U.S.C. 163(h)(4)(C)(i) +THRESHOLD = {"JOINT": 200_000} # 163(h)(4)(C)(ii)(I); 100,000 otherwise +OTHER_THRESHOLD = 100_000 +STEP, INCREMENT = 200, 1_000 +KINDS = ["single", "separate", "joint", "hoh", "joint_with_child", "adult_dependent"] +# Each kind's dependent, if any, and its age range. An adult dependent (a +# student, an elderly parent) can hold their own loan and be the household's +# oldest member. +DEPENDENT_AGES = { + "hoh": (1, 17), + "joint_with_child": (1, 17), + "adult_dependent": (18, 90), +} + + +def money(high): + return st.one_of(st.just(0.0), st.integers(1, high).map(float)) + + +@st.composite +def tax_unit_specs(draw): + kind = draw(st.sampled_from(KINDS)) + adults = 2 if kind.startswith("joint") else 1 + dependents = 1 if kind in DEPENDENT_AGES else 0 + people = [] + for i in range(adults + dependents): + is_dependent = i >= adults + ages = DEPENDENT_AGES[kind] if is_dependent else (18, 90) + people.append( + { + "age": draw(st.integers(*ages)), + "dependent": is_dependent, + "wages": 0.0 if is_dependent else draw(money(260_000)), + "interest": draw(money(12_000)), + "head_flag": draw(st.sampled_from([False, False, False, True])), + } + ) + return {"kind": kind, "people": people} + + +@st.composite +def household_specs(draw): + return { + "tax_units": draw(st.lists(tax_unit_specs(), min_size=1, max_size=4)), + "amount": draw(money(16_000)), + } + + +def _seeded_households(n=80, seed=20261023, ties=True): + rng = np.random.default_rng(seed) + + def some(high, p=0.6): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + households = [] + for h in range(n): + units = [] + for _ in range(int(rng.integers(1, 5))): + kind = str(rng.choice(KINDS)) + adults = 2 if kind.startswith("joint") else 1 + dependents = 1 if kind in DEPENDENT_AGES else 0 + low, high = DEPENDENT_AGES.get(kind, (18, 90)) + units.append( + { + "kind": kind, + "people": [ + { + "age": int(rng.integers(low, high + 1)) + if i >= adults + else int(rng.integers(18, 91)), + "dependent": i >= adults, + "wages": 0.0 if i >= adults else some(260_000, 0.9), + "interest": some(12_000, 0.5), + # A third of households flag no head, so the + # oldest-member fallback runs beside flagged ones. + "head_flag": bool(h % 3) and rng.random() < 0.3, + } + for i in range(adults + dependents) + ], + } + ) + households.append({"tax_units": units, "amount": some(16_000, 0.7)}) + if not ties: + # Distinct ages within each household, so the holder is unambiguous + # whatever order the members are listed in. + for household in households: + seen = set() + for unit in household["tax_units"]: + for p in unit["people"]: + while p["age"] in seen: + p["age"] += 1 + seen.add(p["age"]) + return households + # Ties: two 40-year-old roommates, no head flagged; and two flagged + # heads of the same age. The first listed takes the household amount. + tie = {"age": 40, "dependent": False, "wages": 50_000.0, "interest": 0.0} + households.append( + { + "tax_units": [ + {"kind": "single", "people": [dict(tie, head_flag=False)]}, + {"kind": "single", "people": [dict(tie, head_flag=False)]}, + ], + "amount": 5_000.0, + } + ) + households.append( + { + "tax_units": [ + {"kind": "single", "people": [dict(tie, head_flag=True)]}, + {"kind": "single", "people": [dict(tie, head_flag=True)]}, + ], + "amount": 5_000.0, + } + ) + return households + + +def _situation(households, year, shuffle_seed=None): + people, tax_units, marital_units, spm_units, hh = {}, {}, {}, {}, {} + for h, household in enumerate(households): + members = [] + for t, unit in enumerate(household["tax_units"]): + unit_members = [] + for i, p in enumerate(unit["people"]): + name = f"p_{h}_{t}_{i}" + people[name] = { + "age": {year: p["age"]}, + "employment_income": {year: p["wages"]}, + "qualified_passenger_vehicle_loan_interest_person": { + year: p["interest"] + }, + "is_tax_unit_dependent": {year: p["dependent"]}, + "is_household_head": {year: p["head_flag"]}, + "is_separated": {year: unit["kind"] == "separate"}, + } + unit_members.append(name) + tax_units[f"t_{h}_{t}"] = {"members": unit_members} + adults = [ + m for m, p in zip(unit_members, unit["people"]) if not p["dependent"] + ] + marital_units[f"m_{h}_{t}"] = {"members": adults} + for m, p in zip(unit_members, unit["people"]): + if p["dependent"]: + marital_units[f"m_{m}"] = {"members": [m]} + spm_units[f"s_{h}_{t}"] = {"members": unit_members} + members += unit_members + hh[f"h_{h}"] = { + "members": members, + "state_code": {year: "TX"}, + "qualified_passenger_vehicle_loan_interest": {year: household["amount"]}, + } + if shuffle_seed is not None: + names = list(people) + order = np.random.default_rng(shuffle_seed).permutation(len(names)) + people = {names[i]: people[names[i]] for i in order} + return { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "spm_units": spm_units, + "households": hh, + } + + +def _holder(household): + """(tax unit index) holding the household amount, from the rule's text.""" + flat = [ + (t, p) for t, unit in enumerate(household["tax_units"]) for p in unit["people"] + ] + flagged = [(t, p) for t, p in flat if p["head_flag"]] + pool = flagged or flat + # Oldest first; ties go to the first listed (max returns the first). + return max(pool, key=lambda tp: tp[1]["age"])[0] + + +def _expected_interest(households): + expected = [] + for household in households: + holder = _holder(household) + for t, unit in enumerate(household["tax_units"]): + own = sum(p["interest"] for p in unit["people"] if not p["dependent"]) + expected.append(own + (household["amount"] if t == holder else 0.0)) + return np.array(expected) + + +def _statute_deduction(interest, magi, filing_status): + out = [] + for i, m, fs in zip(interest, magi, filing_status): + threshold = THRESHOLD.get(fs, OTHER_THRESHOLD) + steps = math.ceil(max(m - threshold, 0) / INCREMENT) + out.append(max(min(i, CAP) - STEP * steps, 0)) + return np.array(out) + + +def _calculate(households, year, shuffle_seed=None): + sim = Simulation(situation=_situation(households, year, shuffle_seed)) + return { + "interest": sim.calculate( + "tax_unit_qualified_passenger_vehicle_loan_interest", year + ), + "deduction": sim.calculate("auto_loan_interest_deduction", year), + "magi": sim.calculate("agi_plus_section_911_931_933_exclusions", year), + "filing_status": sim.calculate("filing_status", year).decode_to_str(), + } + + +def _check(households, year, shuffle_seed=None): + result = _calculate(households, year, shuffle_seed) + interest, deduction = result["interest"], result["deduction"] + expected = _expected_interest(households) + + # 2. One holder, and the tax unit totals are exactly the rule's. + np.testing.assert_allclose(interest, expected, atol=TOLERANCE) + + # 1. Conservation per household. + start = 0 + for household in households: + n = len(household["tax_units"]) + filers_own = sum( + p["interest"] + for unit in household["tax_units"] + for p in unit["people"] + if not p["dependent"] + ) + assert ( + abs(interest[start : start + n].sum() - (household["amount"] + filers_own)) + < TOLERANCE + ) + start += n + + # 4. Differential against the statute. + np.testing.assert_allclose( + deduction, + _statute_deduction(expected, result["magi"], result["filing_status"]), + atol=TOLERANCE, + ) + + # 5. Bounds. + assert (deduction >= -TOLERANCE).all() + assert (deduction <= np.minimum(interest, CAP) + TOLERANCE).all() + return result + + +def _zero_odd_units(households): + """Copy with every odd-numbered tax unit's own loans set to zero.""" + out = [] + for household in households: + units = [] + for t, unit in enumerate(household["tax_units"]): + people = [ + dict(p, interest=0.0) if t % 2 else dict(p) for p in unit["people"] + ] + units.append(dict(unit, people=people)) + out.append(dict(household, tax_units=units)) + return out + + +def _check_locality(households, year, before): + after = _calculate(_zero_odd_units(households), year)["deduction"] + unit_index = np.array( + [t for household in households for t in range(len(household["tax_units"]))] + ) + # 3. Even-numbered tax units kept their own loans and their deduction. + even = unit_index % 2 == 0 + np.testing.assert_allclose(after[even], before[even], atol=TOLERANCE) + + +def test_seeded_mixed_batch_2025(): + households = _seeded_households() + result = _check(households, "2025") + # The two tie households at the end: the first listed tax unit holds it. + assert result["interest"][-4:].tolist() == [5_000, 0, 5_000, 0] + assert "SEPARATE" in result["filing_status"] + + +def test_seeded_mixed_batch_2026_locality(): + households = _seeded_households(seed=20261024) + result = _check(households, "2026") + _check_locality(households, "2026", result["deduction"]) + + +def test_people_listed_out_of_household_order(): + # Microdata need not list a household's members next to each other. + households = _seeded_households(n=60, seed=20261025, ties=False) + _check(households, "2025", shuffle_seed=7) + + +@settings( + max_examples=25, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given( + st.lists(household_specs(), min_size=1, max_size=6), + st.sampled_from(["2025", "2026", "2028"]), +) +def test_attribution_invariants(households, year): + result = _check(households, year) + _check_locality(households, year, result["deduction"]) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml new file mode 100644 index 00000000000..a1edfc1ac7f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/tax_unit_qualified_passenger_vehicle_loan_interest.yaml @@ -0,0 +1,565 @@ +# Qualified passenger vehicle loan interest counts on the return of the +# taxpayer who incurred the loan. 26 U.S.C. 163(h)(4)(B)(i): "interest which +# is paid or accrued during the taxable year on indebtedness incurred by the +# taxpayer". 26 CFR 1.163-16(d)(1) (T.D. 10054, 91 FR 57237) repeats it, and +# 1.163-16(h)(1) limits the amount to $10,000 "per Federal tax return +# regardless of filing status". +# +# qualified_passenger_vehicle_loan_interest_person is a person's own loans +# and counts for the tax unit head and spouse. The household-level +# qualified_passenger_vehicle_loan_interest does not say who incurred the +# loan, so it goes in full to the tax unit holding the household head (the +# oldest member when no head is designated). +# +# Each "Before" figure is the previous formula, which split the household +# amount across tax units by member count. Unless noted: 2025, Texas, each +# adult has $50,000 of wages (under the $100,000 phase-out start). + +- name: The household amount goes to the household head's tax unit + period: 2025 + absolute_error_margin: 0.01 + input: + people: + older_roommate: + age: 50 + employment_income: 50_000 + head: + age: 25 + employment_income: 50_000 + is_household_head: true + tax_units: + roommate_unit: + members: [older_roommate] + head_unit: + members: [head] + marital_units: + roommate_marital_unit: + members: [older_roommate] + head_marital_unit: + members: [head] + spm_units: + roommate_spm_unit: + members: [older_roommate] + head_spm_unit: + members: [head] + households: + household: + members: [older_roommate, head] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # Before: [2_500, 2_500]. + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000] + auto_loan_interest_deduction: [0, 5_000] + +- name: With no household head designated, the oldest member's tax unit takes it + period: 2025 + absolute_error_margin: 0.01 + input: + people: + younger_roommate: + age: 25 + employment_income: 50_000 + older_roommate: + age: 50 + employment_income: 50_000 + tax_units: + younger_unit: + members: [younger_roommate] + older_unit: + members: [older_roommate] + marital_units: + younger_marital_unit: + members: [younger_roommate] + older_marital_unit: + members: [older_roommate] + spm_units: + younger_spm_unit: + members: [younger_roommate] + older_spm_unit: + members: [older_roommate] + households: + household: + members: [younger_roommate, older_roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # Before: [2_500, 2_500]. + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000] + auto_loan_interest_deduction: [0, 5_000] + +- name: A parent's three-person unit and a roommate no longer split by head count + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + employment_income: 50_000 + is_household_head: true + child_1: + age: 10 + is_tax_unit_dependent: true + child_2: + age: 8 + is_tax_unit_dependent: true + roommate: + age: 30 + employment_income: 50_000 + tax_units: + family_unit: + members: [parent, child_1, child_2] + roommate_unit: + members: [roommate] + marital_units: + parent_marital_unit: + members: [parent] + child_1_marital_unit: + members: [child_1] + child_2_marital_unit: + members: [child_2] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [parent, child_1, child_2] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [parent, child_1, child_2, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 8_000 + output: + # Before: [6_000, 2_000], 3/4 and 1/4 of the household amount. + tax_unit_qualified_passenger_vehicle_loan_interest: [8_000, 0] + auto_loan_interest_deduction: [8_000, 0] + +- name: Each filer's own loan counts on that filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 52 + employment_income: 60_000 + is_household_head: true + qualified_passenger_vehicle_loan_interest_person: 4_000 + adult_child: + age: 24 + employment_income: 30_000 + qualified_passenger_vehicle_loan_interest_person: 3_000 + tax_units: + parent_unit: + members: [parent] + adult_child_unit: + members: [adult_child] + marital_units: + parent_marital_unit: + members: [parent] + adult_child_marital_unit: + members: [adult_child] + spm_units: + spm_unit: + members: [parent, adult_child] + households: + household: + members: [parent, adult_child] + state_code: TX + output: + tax_unit_qualified_passenger_vehicle_loan_interest: [4_000, 3_000] + auto_loan_interest_deduction: [4_000, 3_000] + +- name: A dependent's own loan does not reach the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 45 + employment_income: 50_000 + qualified_passenger_vehicle_loan_interest_person: 2_000 + child: + age: 17 + is_tax_unit_dependent: true + qualified_passenger_vehicle_loan_interest_person: 3_000 + tax_units: + tax_unit: + members: [parent, child] + marital_units: + parent_marital_unit: + members: [parent] + child_marital_unit: + members: [child] + spm_units: + spm_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The child's interest is on indebtedness the child incurred, not the + # parent; only the parent's $2,000 is on the parent's return. + tax_unit_qualified_passenger_vehicle_loan_interest: 2_000 + auto_loan_interest_deduction: 2_000 + +- name: Adult dependents' own loans do not reach the filer's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 80_000 + qualified_passenger_vehicle_loan_interest_person: 1_000 + student: + age: 20 + is_tax_unit_dependent: true + is_full_time_student: true + qualified_passenger_vehicle_loan_interest_person: 2_500 + grandparent: + age: 82 + is_tax_unit_dependent: true + qualified_passenger_vehicle_loan_interest_person: 1_500 + tax_units: + tax_unit: + members: [parent, student, grandparent] + marital_units: + parent_marital_unit: + members: [parent] + student_marital_unit: + members: [student] + grandparent_marital_unit: + members: [grandparent] + spm_units: + spm_unit: + members: [parent, student, grandparent] + households: + household: + members: [parent, student, grandparent] + state_code: TX + output: + # The student's and grandparent's loans are their own indebtedness + # (26 CFR 1.163-16(d)(1)); only the parent's $1,000 is on this return. + tax_unit_qualified_passenger_vehicle_loan_interest: 1_000 + auto_loan_interest_deduction: 1_000 + +- name: The oldest-member fallback counts dependents, so the amount follows the oldest member + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 40 + employment_income: 50_000 + grandparent: + age: 85 + is_tax_unit_dependent: true + roommate: + age: 60 + employment_income: 50_000 + tax_units: + family_unit: + members: [parent, grandparent] + roommate_unit: + members: [roommate] + marital_units: + parent_marital_unit: + members: [parent] + grandparent_marital_unit: + members: [grandparent] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [parent, grandparent] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [parent, grandparent, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # No head is designated. The oldest member is the 85-year-old dependent, + # so the amount goes to the return that claims them, not to the 60-year-old + # roommate, who is the oldest filer. Before: [3_333.33, 1_666.67]. + tax_unit_qualified_passenger_vehicle_loan_interest: [5_000, 0] + auto_loan_interest_deduction: [5_000, 0] + +- name: Person and household amounts add on the head's return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + is_household_head: true + qualified_passenger_vehicle_loan_interest_person: 1_000 + roommate: + age: 60 + employment_income: 50_000 + qualified_passenger_vehicle_loan_interest_person: 500 + tax_units: + head_unit: + members: [head] + roommate_unit: + members: [roommate] + marital_units: + head_marital_unit: + members: [head] + roommate_marital_unit: + members: [roommate] + spm_units: + head_spm_unit: + members: [head] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [head, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 2_000 + output: + # Head: 1,000 own + 2,000 household. Roommate: 500 own. + tax_unit_qualified_passenger_vehicle_loan_interest: [3_000, 500] + auto_loan_interest_deduction: [3_000, 500] + +- name: The $10,000 limit applies to the one return that holds the household amount + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 50_000 + is_household_head: true + roommate: + age: 25 + employment_income: 50_000 + tax_units: + head_unit: + members: [head] + roommate_unit: + members: [roommate] + marital_units: + head_marital_unit: + members: [head] + roommate_marital_unit: + members: [roommate] + spm_units: + head_spm_unit: + members: [head] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [head, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 15_000 + output: + # Before: 7,500 on each return, 15,000 in all, each under the limit. + tax_unit_qualified_passenger_vehicle_loan_interest: [15_000, 0] + auto_loan_interest_deduction: [10_000, 0] + +- name: Several people flagged as household head still count the amount once + period: 2025 + absolute_error_margin: 0.01 + input: + people: + younger_head: + age: 30 + employment_income: 50_000 + is_household_head: true + older_head: + age: 55 + employment_income: 50_000 + is_household_head: true + oldest_member: + age: 80 + employment_income: 50_000 + tax_units: + younger_unit: + members: [younger_head] + older_unit: + members: [older_head] + oldest_unit: + members: [oldest_member] + marital_units: + younger_marital_unit: + members: [younger_head] + older_marital_unit: + members: [older_head] + oldest_marital_unit: + members: [oldest_member] + spm_units: + younger_spm_unit: + members: [younger_head] + older_spm_unit: + members: [older_head] + oldest_spm_unit: + members: [oldest_member] + households: + household: + members: [younger_head, older_head, oldest_member] + state_code: TX + qualified_passenger_vehicle_loan_interest: 5_000 + output: + # The older of the two flagged heads takes it; the unflagged oldest + # member does not. + tax_unit_qualified_passenger_vehicle_loan_interest: [0, 5_000, 0] + +- name: A household head claimed as a dependent puts the amount on the claiming return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + son: + age: 50 + employment_income: 50_000 + grandmother: + age: 80 + is_household_head: true + is_tax_unit_dependent: true + roommate: + age: 35 + employment_income: 50_000 + tax_units: + son_unit: + members: [son, grandmother] + roommate_unit: + members: [roommate] + marital_units: + son_marital_unit: + members: [son] + grandmother_marital_unit: + members: [grandmother] + roommate_marital_unit: + members: [roommate] + spm_units: + family_spm_unit: + members: [son, grandmother] + roommate_spm_unit: + members: [roommate] + households: + household: + members: [son, grandmother, roommate] + state_code: TX + qualified_passenger_vehicle_loan_interest: 3_000 + output: + # Before: [2_000, 1_000]. + tax_unit_qualified_passenger_vehicle_loan_interest: [3_000, 0] + +- name: One tax unit in the household takes the whole household amount, as before + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 38 + child: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + couple: + members: [head, spouse] + child_marital_unit: + members: [child] + spm_units: + spm_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + qualified_passenger_vehicle_loan_interest: 4_000 + output: + tax_unit_qualified_passenger_vehicle_loan_interest: 4_000 + auto_loan_interest_deduction: 4_000 + +- name: Married people filing separately each have their own $10,000 limit + # 26 CFR 1.163-16(h)(1): the limit applies "per Federal tax return regardless + # of filing status". A joint return would share one $10,000. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + first_spouse: + age: 40 + employment_income: 50_000 + is_separated: true + qualified_passenger_vehicle_loan_interest_person: 12_000 + second_spouse: + age: 38 + employment_income: 50_000 + is_separated: true + qualified_passenger_vehicle_loan_interest_person: 11_000 + tax_units: + first_return: + members: [first_spouse] + second_return: + members: [second_spouse] + marital_units: + marital_unit: + members: [first_spouse, second_spouse] + spm_units: + spm_unit: + members: [first_spouse, second_spouse] + households: + household: + members: [first_spouse, second_spouse] + state_code: TX + output: + filing_status: [SEPARATE, SEPARATE] + tax_unit_qualified_passenger_vehicle_loan_interest: [12_000, 11_000] + auto_loan_interest_deduction: [10_000, 10_000] + +- name: Spouses' separate loans share one $10,000 limit on a joint return + period: 2025 + absolute_error_margin: 0.01 + input: + people: + a: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 6_000 + b: + age: 40 + qualified_passenger_vehicle_loan_interest_person: 5_000 + tax_units: + tax_unit: + members: [a, b] + adjusted_gross_income: 150_000 + marital_units: + couple: + members: [a, b] + households: + household: + members: [a, b] + state_code: TX + output: + # 26 CFR 1.163-16(h)(3), Example 1 (91 FR 57241): A pays $6,000 and B + # $5,000 on their own loans; they "can deduct no more than $10,000" on + # their joint return. + tax_unit_qualified_passenger_vehicle_loan_interest: 11_000 + auto_loan_interest_deduction: 10_000 + +- name: Single filer phase-out from the regulation's example + period: 2025 + absolute_error_margin: 0.01 + input: + adjusted_gross_income: 124_200 + filing_status: SINGLE + qualified_passenger_vehicle_loan_interest_person: 7_000 + output: + # 26 CFR 1.163-16(h)(3), Example 2 (91 FR 57241): modified AGI $124,200 + # exceeds $100,000 by $24,200, rounded up to 25 steps of $200, so the + # $7,000 is reduced by $5,000 to $2,000. + auto_loan_interest_deduction: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml index d564dd764ce..d840f9f0062 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/in/tax/income/deductions/obbba_conformity/in_auto_loan_interest_deduction.yaml @@ -56,3 +56,39 @@ output: in_auto_loan_interest_deduction: 0 in_deductions: 0 + +- name: Indiana follows the federal deduction to the household head's return + period: 2026 + absolute_error_margin: 0.01 + input: + people: + roommate: + age: 50 + employment_income: 50_000 + head: + age: 30 + employment_income: 50_000 + is_household_head: true + tax_units: + roommate_unit: + members: [roommate] + head_unit: + members: [head] + marital_units: + roommate_marital_unit: + members: [roommate] + head_marital_unit: + members: [head] + spm_units: + roommate_spm_unit: + members: [roommate] + head_spm_unit: + members: [head] + households: + household: + members: [roommate, head] + state_code: IN + qualified_passenger_vehicle_loan_interest: 2_000 + output: + # Before: [1_000, 1_000]. + in_auto_loan_interest_deduction: [0, 2_000] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py index 85d306125ac..564c22d7ecc 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/auto_loan_interest_deduction.py @@ -16,10 +16,8 @@ class auto_loan_interest_deduction(Variable): ) def formula(tax_unit, period, parameters): - auto_loan_interest = add( - tax_unit, - period, - ["qualified_passenger_vehicle_loan_interest"], + auto_loan_interest = tax_unit( + "tax_unit_qualified_passenger_vehicle_loan_interest", period ) p = parameters(period).gov.irs.deductions.auto_loan_interest capped_interest = min_(auto_loan_interest, p.cap) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py new file mode 100644 index 00000000000..0b5d04b8677 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/auto_loan_interest/tax_unit_qualified_passenger_vehicle_loan_interest.py @@ -0,0 +1,45 @@ +from policyengine_us.model_api import * + + +class tax_unit_qualified_passenger_vehicle_loan_interest(Variable): + value_type = float + entity = TaxUnit + label = "Tax unit qualified passenger vehicle loan interest" + unit = USD + documentation = ( + "Qualified passenger vehicle loan interest on the tax unit's return, " + "before the dollar limit and the modified adjusted gross income " + "phase-out." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", + # PDF pages 24, 28 + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", + ) + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # 26 U.S.C. 163(h)(4)(B)(i) counts interest on indebtedness incurred + # by the taxpayer: both spouses' loans on a joint return (26 CFR + # 1.163-16(h)(3), Example 1), and not a dependent's loans. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + own_interest = tax_unit.sum( + head_or_spouse + * person("qualified_passenger_vehicle_loan_interest_person", period) + ) + # A household-level amount does not say who incurred the loan. It + # goes in full to the tax unit of the household head (the oldest + # member when no head is designated), so it counts on one return. + is_household_head = person("is_household_head", period) + has_household_head = person.household.any(is_household_head) + age = person("age", period) + holds_household_amount = where( + has_household_head, + person.get_rank(person.household, -age, condition=is_household_head) == 0, + person.get_rank(person.household, -age) == 0, + ) + household_interest = person.household( + "qualified_passenger_vehicle_loan_interest", period + ) + return own_interest + tax_unit.sum(holds_household_amount * household_interest) diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py index 2b58143257c..f0c39c12aa7 100644 --- a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest.py @@ -9,7 +9,13 @@ class qualified_passenger_vehicle_loan_interest(Variable): documentation = ( "Interest on loans that meet the federal qualified passenger " "vehicle loan interest requirements, including qualifying vehicle " - "and loan requirements." + "and loan requirements, entered for the household as a whole. The " + "whole amount counts on the return of the tax unit that includes the " + "household head: the oldest flagged head when several are flagged, " + "and the oldest member when none is. A head claimed as a dependent " + "puts it on the claiming filer's return. To count a loan on the " + "return of the person who incurred it, enter it in " + "qualified_passenger_vehicle_loan_interest_person instead." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py new file mode 100644 index 00000000000..650a267f7c9 --- /dev/null +++ b/policyengine_us/variables/household/expense/vehicle/qualified_passenger_vehicle_loan_interest_person.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class qualified_passenger_vehicle_loan_interest_person(Variable): + value_type = float + entity = Person + label = "qualified passenger vehicle loan interest on this person's loans" + unit = USD + documentation = ( + "Interest paid by this person on loans this person incurred that meet " + "the federal qualified passenger vehicle loan interest requirements, " + "including qualifying vehicle and loan requirements. It counts on the " + "return of the tax unit this person heads or is the spouse in; a tax " + "unit dependent's loans do not reach the filer's return." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/163#h_4_B_i", + # PDF pages 24, 30 + "https://www.govinfo.gov/content/pkg/FR-2026-09-08/pdf/2026-18219.pdf#page=24", + ) + uprating = "gov.bls.cpi.cpi_u"