diff --git a/changelog.d/ky-principal-residence-mortgage-interest.added.md b/changelog.d/ky-principal-residence-mortgage-interest.added.md new file mode 100644 index 00000000000..2b8e94d7211 --- /dev/null +++ b/changelog.d/ky-principal-residence-mortgage-interest.added.md @@ -0,0 +1 @@ +Add `second_residence_mortgage_interest`, `second_residence_mortgage_points` and `second_residence_mortgage_insurance_premiums` inputs, which join the federal mortgage deduction, and from 2026 cap Kentucky's qualified residence interest deduction at the interest paid on the principal residence (KRS 141.019(2)(j); 2026 Ky. Acts ch. 161 sec. 7, as amended by ch. 198 sec. 48). diff --git a/changelog.d/points-mortgage-insurance-premium-inputs.added.md b/changelog.d/points-mortgage-insurance-premium-inputs.added.md new file mode 100644 index 00000000000..601bba53334 --- /dev/null +++ b/changelog.d/points-mortgage-insurance-premium-inputs.added.md @@ -0,0 +1 @@ +Add `home_mortgage_points` and `mortgage_insurance_premiums` inputs. Points are deducted as home mortgage interest under the acquisition-debt caps. Qualified mortgage insurance premiums are deducted under 26 U.S.C. 163(h)(3)(E) with its AGI phase-out, in 2007–2021 and again from 2026 (P.L. 119-21, sec. 70108). diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/in_effect.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/in_effect.yaml new file mode 100644 index 00000000000..4a49e0a836b --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/in_effect.yaml @@ -0,0 +1,27 @@ +description: The IRS treats qualified mortgage insurance premiums as deductible qualified residence interest if this is true. +values: + 1900-01-01: false + # Premiums paid or accrued after December 31, 2006, under contracts issued + # after that date (P.L. 109-432, sec. 419). + 2007-01-01: true + # 163(h)(3)(E)(iv): no deduction for amounts paid or accrued after + # December 31, 2021. + 2022-01-01: false + # P.L. 119-21, sec. 70108: 163(h)(3)(F)(i)(III) turns off clause (iv) for + # taxable years beginning after December 31, 2025. + 2026-01-01: true +metadata: + unit: bool + period: year + label: IRS mortgage insurance premiums deduction in effect + reference: + - title: 26 U.S. Code § 163(h)(3)(E) - Mortgage insurance premiums treated as interest + href: https://www.law.cornell.edu/uscode/text/26/163#h_3_E + - title: 26 U.S. Code § 163(h)(3)(F)(i)(III) + href: https://www.law.cornell.edu/uscode/text/26/163#h_3_F_i_III + - title: Public Law 119-21, sec. 70108 + href: https://www.congress.gov/119/plaws/publ21/PLAW-119publ21.pdf#page=93 + - title: 2021 Instructions for Schedule A (Form 1040), line 8d + href: https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=9 + - title: 2025 Publication 936, Reminders + href: https://www.irs.gov/pub/irs-pdf/p936.pdf#page=1 diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/increment.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/increment.yaml new file mode 100644 index 00000000000..d3ac8435652 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/increment.yaml @@ -0,0 +1,22 @@ +description: The IRS reduces the qualified mortgage insurance premiums deduction for each of these amounts, or fraction thereof, by which adjusted gross income exceeds the phase-out start, based on filing status. +SINGLE: + 2007-01-01: 1_000 +JOINT: + 2007-01-01: 1_000 +SEPARATE: + 2007-01-01: 500 +HEAD_OF_HOUSEHOLD: + 2007-01-01: 1_000 +SURVIVING_SPOUSE: + 2007-01-01: 1_000 +metadata: + propagate_metadata_to_children: true + breakdown: filing_status + unit: currency-USD + period: year + label: IRS mortgage insurance premiums deduction phase-out increment + reference: + - title: 26 U.S. Code § 163(h)(3)(E)(ii) - Phaseout + href: https://www.law.cornell.edu/uscode/text/26/163#h_3_E_ii + - title: 2021 Instructions for Schedule A (Form 1040), Mortgage Insurance Premiums Deduction Worksheet line 4 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=10 diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/rate.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/rate.yaml new file mode 100644 index 00000000000..a3e7a96271a --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/rate.yaml @@ -0,0 +1,12 @@ +description: The IRS reduces the qualified mortgage insurance premiums deduction by this share of the premiums for each phase-out increment by which adjusted gross income exceeds the phase-out start. +values: + 2007-01-01: 0.1 +metadata: + unit: /1 + period: year + label: IRS mortgage insurance premiums deduction phase-out rate + reference: + - title: 26 U.S. Code § 163(h)(3)(E)(ii) - Phaseout + href: https://www.law.cornell.edu/uscode/text/26/163#h_3_E_ii + - title: 2021 Instructions for Schedule A (Form 1040), Mortgage Insurance Premiums Deduction Worksheet line 5 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=10 diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/start.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/start.yaml new file mode 100644 index 00000000000..357feceef89 --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/start.yaml @@ -0,0 +1,22 @@ +description: The IRS reduces the qualified mortgage insurance premiums deduction when adjusted gross income exceeds this amount, based on filing status. +SINGLE: + 2007-01-01: 100_000 +JOINT: + 2007-01-01: 100_000 +SEPARATE: + 2007-01-01: 50_000 +HEAD_OF_HOUSEHOLD: + 2007-01-01: 100_000 +SURVIVING_SPOUSE: + 2007-01-01: 100_000 +metadata: + propagate_metadata_to_children: true + breakdown: filing_status + unit: currency-USD + period: year + label: IRS mortgage insurance premiums deduction phase-out start + reference: + - title: 26 U.S. Code § 163(h)(3)(E)(ii) - Phaseout + href: https://www.law.cornell.edu/uscode/text/26/163#h_3_E_ii + - title: 2021 Instructions for Schedule A (Form 1040), Mortgage Insurance Premiums Deduction Worksheet line 3 + href: https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=10 diff --git a/policyengine_us/parameters/gov/states/ky/tax/income/deductions/itemized/principal_residence_interest_only.yaml b/policyengine_us/parameters/gov/states/ky/tax/income/deductions/itemized/principal_residence_interest_only.yaml new file mode 100644 index 00000000000..c4bd5c9d061 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ky/tax/income/deductions/itemized/principal_residence_interest_only.yaml @@ -0,0 +1,17 @@ +description: Kentucky limits the qualified residence interest deduction to the principal residence if this is true. +values: + # Before HB 757, KRS 141.019(2) ended at paragraph (i) and had no qualified residence interest limit. + 2018-01-01: false + # HB 757 (2026 Ky. Acts ch. 161) sec. 7, as amended by HB 869 (ch. 198) sec. 48, adds KRS 141.019(2)(j) for taxable years beginning on or after January 1, 2026. + 2026-01-01: true +metadata: + unit: bool + period: year + label: Kentucky qualified residence interest limited to the principal residence + reference: + - title: KRS 141.019(2)(j), net income of taxpayers other than corporations + href: https://apps.legislature.ky.gov/law/statutes/statute.aspx?id=57914#page=4 + - title: 2026 Ky. Acts ch. 161 (HB 757), sec. 7, KRS 141.019(2)(j) + href: https://apps.legislature.ky.gov/law/acts/26RS/documents/0161.pdf#page=13 + - title: 2026 Ky. Acts ch. 198 (HB 869), sec. 48, amending HB 757 sec. 7 + href: https://apps.legislature.ky.gov/law/acts/26RS/documents/0198.pdf#page=74 diff --git a/policyengine_us/tests/core/test_mortgage_points_and_premiums_properties.py b/policyengine_us/tests/core/test_mortgage_points_and_premiums_properties.py new file mode 100644 index 00000000000..dd3b5bad0e6 --- /dev/null +++ b/policyengine_us/tests/core/test_mortgage_points_and_premiums_properties.py @@ -0,0 +1,381 @@ +"""Vectorized mortgage invariants that household YAML examples cannot express. + +One module fixture builds one Simulation for 360 tax units in 2021, 2025 and +2026. Seeded draws and paired tax units exercise conservation, bounds, +interest/points transfers, an independent integer-cents Schedule A worksheet, +AGI monotonicity and compatibility with zero or omitted new inputs. Paired +units avoid changing inputs or sharing mutable simulations between tests; +the fixture exposes only read-only arrays after calculation. + +Reference: 2021 Instructions for Schedule A, Mortgage Insurance Premiums +Deduction Worksheet, lines 1-7 (page A-10), and Publication 936, Table 1, +line 13 instructions. YAML covers the individual thresholds and 2022-2024. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.variables.household.expense.tax_unit.mortgage_interest_structure import ( + _limited_mortgage_balance, + _mortgage_balance_cap, +) + +N = 60 +YEARS = (2021, 2025, 2026) +STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD", "SURVIVING_SPOUSE") +SCENARIOS = ("base", "moved", "richer", "zero", "omitted", "legacy") +PERSON_INPUTS = ( + "home_mortgage_interest", + "home_mortgage_points", + "mortgage_insurance_premiums", + "investment_interest_expense", +) +PERSON_OUTPUTS = ( + "home_mortgage_interest_share", + "deductible_mortgage_interest", + "non_deductible_mortgage_interest", + "mortgage_interest", + "deductible_interest_expense", +) +UNIT_OUTPUTS = ( + "home_mortgage_interest_tax_unit", + "deductible_mortgage_interest_tax_unit", + "non_deductible_mortgage_interest_tax_unit", + "deductible_mortgage_insurance_premiums", + "interest_deduction", +) +# Mortgage outputs are float32. Allow accumulated rounding in person sums +# and transfers, scaling with the operands, not with a small difference. +RTOL = 1e-6 +ATOL = 0.01 + + +def _close(actual, expected): + np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=ATOL) + + +def _draw(): + rng = np.random.default_rng(936_2021) + statuses = rng.choice(STATUSES, N) + statuses[: 3 * len(STATUSES)] = np.tile(STATUSES, 3) + spouse = statuses == "JOINT" + start_cents = np.where(statuses == "SEPARATE", 5_000_000, 10_000_000) + increment_cents = np.where(statuses == "SEPARATE", 50_000, 100_000) + agi_cents = rng.integers(0, 15_000_001, N) + # Include exact multiples and one cent on either side of increments. + boundary = np.arange(N) % 3 != 2 + steps = rng.integers(0, 12, N) + offsets = rng.choice((-1, 0, 1), N) + agi_cents[boundary] = (start_cents + steps * increment_cents + offsets)[boundary] + # Guarantee an exact increment and nearby cents in every filing status. + for block, (step, offset) in enumerate(((1, 0), (1, 1), (9, -1))): + rows = slice(block * len(STATUSES), (block + 1) * len(STATUSES)) + agi_cents[rows] = start_cents[rows] + step * increment_cents[rows] + offset + inputs = {} + for name, upper in zip(PERSON_INPUTS, (40_000, 10_000, 12_000, 4_000)): + # Integer premiums make the independent worksheet entirely integer + # arithmetic through line 7; other expenses include cents. + if name == "mortgage_insurance_premiums": + amount = rng.integers(0, upper + 1, (N, 2)).astype(float) + else: + amount = rng.integers(0, upper * 100 + 1, (N, 2)) / 100 + amount[rng.random((N, 2)) < 0.2] = 0 + amount[~spouse, 1] = 0 + inputs[name] = amount + # Exercise zero-interest, points-only and all-zero mortgage pools. + inputs["home_mortgage_interest"][::5] = 0 + inputs["home_mortgage_points"][::10] = 0 + first = rng.integers(1, 1_500_001, N).astype(float) + second = rng.integers(1, 750_001, N).astype(float) + first[::6] = 0 + second[::3] = 0 + transfer_fraction = rng.uniform(0, 1, (N, 2)) + # Include complete transfers, so interest can vanish and invoke fallback. + transfer_fraction[::3] = 0 + transfer_fraction[1::3] = 1 + return { + **inputs, + "filing_status": statuses, + "adjusted_gross_income": agi_cents / 100, + "agi_cents": agi_cents, + "raised_agi_cents": agi_cents + rng.integers(1, 2_000_001, N), + "first_home_mortgage_balance": first, + "second_home_mortgage_balance": second, + "first_home_mortgage_origination_year": rng.choice((2010, 2016, 2020), N), + "second_home_mortgage_origination_year": rng.choice((2010, 2016, 2020), N), + "transfer_fraction": transfer_fraction, + } + + +@pytest.fixture(scope="module") +def mortgage_results(): + draws = _draw() + people, tax_units, households = {}, {}, {} + unit_indices, person_indices, person_to_unit = {}, {}, [] + periods = tuple(map(str, YEARS)) + + def annual(value): + return dict.fromkeys(periods, value) + + for scenario in SCENARIOS: + unit_indices[scenario], person_indices[scenario] = [], [] + for i in range(N): + unit_index = len(tax_units) + unit_indices[scenario].append(unit_index) + members = [] + count = 2 if draws["filing_status"][i] == "JOINT" else 1 + for j in range(count): + person_index = len(people) + person_indices[scenario].append(person_index) + person_to_unit.append(unit_index) + person_id = f"p{person_index}" + members.append(person_id) + person = {"age": annual(50 - j)} + total = sum(draws[name][i, j] for name in PERSON_INPUTS[:2]) + for name in PERSON_INPUTS: + value = float(draws[name][i, j]) + if scenario == "moved": + fraction = draws["transfer_fraction"][i, j] + if name == "home_mortgage_interest": + value = total * fraction + elif name == "home_mortgage_points": + value = total * (1 - fraction) + if scenario in ("zero", "omitted", "legacy") and name in ( + "home_mortgage_points", + "mortgage_insurance_premiums", + ): + if scenario == "omitted": + continue + value = 0.0 + if scenario == "legacy" and name == "home_mortgage_interest": + value = 0.0 + person[name] = annual(value) + people[person_id] = person + unit = { + "members": members, + "filing_status": annual(str(draws["filing_status"][i])), + "adjusted_gross_income": annual( + float( + draws[ + "raised_agi_cents" if scenario == "richer" else "agi_cents" + ][i] + / 100 + ) + ), + **{ + name: annual(float(draws[name][i])) + for name in ( + "first_home_mortgage_balance", + "second_home_mortgage_balance", + ) + }, + **{ + name: annual(int(draws[name][i])) + for name in ( + "first_home_mortgage_origination_year", + "second_home_mortgage_origination_year", + ) + }, + # The transfer property must not trigger the legacy fallback. + "first_home_mortgage_interest": annual(0.0), + "second_home_mortgage_interest": annual(0.0), + } + if scenario == "legacy": + interest = draws["home_mortgage_interest"][i].sum() + unit["first_home_mortgage_interest"] = annual(float(interest * 0.6)) + unit["second_home_mortgage_interest"] = annual(float(interest * 0.4)) + tax_units[f"t{unit_index}"] = unit + households[f"h{unit_index}"] = {"members": members} + + simulation = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + results = {} + for year in YEARS: + results[year] = {} + for name in UNIT_OUTPUTS + PERSON_OUTPUTS + PERSON_INPUTS: + values = simulation.calculate(name, str(year)).copy() + values.flags.writeable = False + results[year][name] = values + # Reuse only the unchanged pre-input balance helpers to reconstruct + # the previous mortgage computation, with no points or premium term. + p = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.irs.deductions.itemized.interest.mortgage + pre_cap = p.pre_tcja_cap[draws["filing_status"]] + post_cap = p.cap[draws["filing_status"]] + caps = [ + _mortgage_balance_cap( + draws[f"{position}_home_mortgage_origination_year"], + pre_cap, + post_cap, + p.pre_tcja_origination_year, + ) + for position in ("first", "second") + ] + first = draws["first_home_mortgage_balance"] + second = draws["second_home_mortgage_balance"] + limited = _limited_mortgage_balance(first, second, *caps) + share = np.minimum( + 1, + np.divide( + limited, first + second, out=np.ones(N), where=first + second > 0 + ), + ) + share.flags.writeable = False + results[year]["legacy_deductible_share"] = share + # Do not retain the Simulation, its mutable caches or its policy tree. + return draws, results, unit_indices, person_indices, np.array(person_to_unit) + + +def _sum_people(values, person_to_unit): + return np.bincount(person_to_unit, weights=values, minlength=N * len(SCENARIOS)) + + +def test_mortgage_pool_and_person_allocations_are_conserved(mortgage_results): + _, results, _, _, membership = mortgage_results + for values in results.values(): + pool = values["home_mortgage_interest_tax_unit"] + _sum_people( + values["home_mortgage_points"], membership + ) + deductible = values["deductible_mortgage_interest_tax_unit"] + nondeductible = values["non_deductible_mortgage_interest_tax_unit"] + _close(deductible + nondeductible, pool) + _close( + _sum_people(values["deductible_mortgage_interest"], membership), deductible + ) + _close( + _sum_people(values["non_deductible_mortgage_interest"], membership), + nondeductible, + ) + _close(_sum_people(values["mortgage_interest"], membership), pool) + + +def test_outputs_are_nonnegative_and_deductions_are_bounded(mortgage_results): + _, results, _, _, membership = mortgage_results + for values in results.values(): + for name in UNIT_OUTPUTS + PERSON_OUTPUTS: + assert np.all(values[name] >= 0), name + pool = values["home_mortgage_interest_tax_unit"] + _sum_people( + values["home_mortgage_points"], membership + ) + assert np.all(values["deductible_mortgage_interest_tax_unit"] <= pool + ATOL) + premiums = _sum_people(values["mortgage_insurance_premiums"], membership) + assert np.all(values["deductible_mortgage_insurance_premiums"] <= premiums) + + +def test_points_and_interest_are_interchangeable(mortgage_results): + _, results, units, persons, _ = mortgage_results + for values in results.values(): + for name in ( + "deductible_mortgage_interest_tax_unit", + "non_deductible_mortgage_interest_tax_unit", + ): + _close(values[name][units["moved"]], values[name][units["base"]]) + # Moving amounts within a person also preserves their allocation. + for name in ("deductible_mortgage_interest", "mortgage_interest"): + _close(values[name][persons["moved"]], values[name][persons["base"]]) + + +def _premium_worksheet_cents(premiums_cents, agi_cents, statuses): + """2021 Schedule A lines 1-7, independently using integer cents. + + Line 4 rounds excess AGI UP to a $1,000/$500 multiple; line 5 divides + that rounded dollar excess by $10,000/$5,000, capped at 1. Lines 6-7 + multiply premiums by the fraction and subtract the reduction. + """ + deductions = [] + for line_1, line_2, status in zip(premiums_cents, agi_cents, statuses): + separate = status == "SEPARATE" + line_3 = 5_000_000 if separate else 10_000_000 + if line_2 <= line_3: + deductions.append(int(line_1)) + continue + multiple = 50_000 if separate else 100_000 + line_4 = ((int(line_2) - line_3 + multiple - 1) // multiple) * multiple + denominator = 500_000 if separate else 1_000_000 + numerator = min(line_4, denominator) + line_6 = int(line_1) * numerator // denominator + deductions.append(int(line_1) - line_6) + return np.array(deductions) + + +def test_premiums_match_the_independent_integer_worksheet(mortgage_results): + draws, results, units, _, membership = mortgage_results + for year, values in results.items(): + premiums_cents = np.rint( + _sum_people(values["mortgage_insurance_premiums"], membership) * 100 + ).astype(np.int64) + for scenario in SCENARIOS: + indices = units[scenario] + agi = draws["raised_agi_cents" if scenario == "richer" else "agi_cents"] + expected = ( + _premium_worksheet_cents( + premiums_cents[indices], agi, draws["filing_status"] + ) + / 100 + ) + if year == 2025: + expected = np.zeros(N) + _close(values["deductible_mortgage_insurance_premiums"][indices], expected) + + +def test_premium_deduction_never_increases_with_agi(mortgage_results): + _, results, units, _, _ = mortgage_results + for year, values in results.items(): + premiums = values["deductible_mortgage_insurance_premiums"] + assert np.all(premiums[units["richer"]] <= premiums[units["base"]]) + if year != 2025: + assert np.any(premiums[units["richer"]] < premiums[units["base"]]) + + +def test_zero_new_inputs_preserve_the_previous_computation(mortgage_results): + _, results, units, persons, membership = mortgage_results + for values in results.values(): + for name in UNIT_OUTPUTS: + np.testing.assert_array_equal( + values[name][units["zero"]], values[name][units["omitted"]] + ) + for name in PERSON_OUTPUTS: + np.testing.assert_array_equal( + values[name][persons["zero"]], values[name][persons["omitted"]] + ) + for scenario in ("zero", "omitted", "legacy"): + index = units[scenario] + person_index = persons[scenario] + interest = values["home_mortgage_interest_tax_unit"][index] + person_interest = values["home_mortgage_interest"][person_index] + totals = _sum_people(values["home_mortgage_interest"], membership)[index] + local_membership = membership[person_index] - index[0] + filer_count = np.bincount(local_membership, minlength=N) + allocation = np.divide( + person_interest, + totals[local_membership], + out=1.0 / filer_count[local_membership], + where=totals[local_membership] > 0, + ) + _close(values["home_mortgage_interest_share"][person_index], allocation) + deductible = values["deductible_mortgage_interest_tax_unit"][index] + nondeductible = values["non_deductible_mortgage_interest_tax_unit"][index] + _close(deductible, interest * values["legacy_deductible_share"]) + _close(deductible + nondeductible, interest) + _close( + values["deductible_mortgage_interest"][person_index], + deductible[local_membership] * allocation, + ) + _close( + values["non_deductible_mortgage_interest"][person_index], + nondeductible[local_membership] * allocation, + ) + _close( + values["mortgage_interest"][person_index], + interest[local_membership] * allocation, + ) + previous_interest = _sum_people( + values["deductible_interest_expense"], membership + )[index] + _close(values["interest_deduction"][index], previous_interest) + np.testing.assert_array_equal( + values["deductible_mortgage_insurance_premiums"][index], np.zeros(N) + ) diff --git a/policyengine_us/tests/core/test_second_residence_mortgage_properties.py b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py new file mode 100644 index 00000000000..53a0e3b6e37 --- /dev/null +++ b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py @@ -0,0 +1,333 @@ +"""Vectorized invariants for second-residence mortgage inputs and Kentucky. + +One module fixture builds one Simulation of Kentucky tax units in 2025 and +2026. Four paired scenarios share identical draws: + +- base: separate principal- and second-residence interest, points and premiums; +- pooled: every second-residence amount moved onto the principal residence; +- principal_only: second-residence inputs set to zero; +- omitted: second-residence inputs left out entirely. + +A fifth scenario, equal_rate, gives each tax unit a principal and a second +mortgage at one common rate, so the result can be checked against an +independent recomputation on the principal residence alone. + +Federal law pools both qualified residences (26 U.S.C. 163(h)(5)(A)), so the +federal deduction cannot depend on the residence split. From 2026, KRS +141.019(2)(j) caps Kentucky's deduction at the interest paid on the +principal residence. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation + +N = 48 +YEARS = (2025, 2026) +STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD") +SCENARIOS = ("base", "pooled", "principal_only", "omitted", "equal_rate") +# Principal-residence input, its second-residence counterpart, upper draw. +PAIRS = ( + ("home_mortgage_interest", "second_residence_mortgage_interest", 40_000), + ("home_mortgage_points", "second_residence_mortgage_points", 6_000), + ( + "mortgage_insurance_premiums", + "second_residence_mortgage_insurance_premiums", + 5_000, + ), +) +UNIT_OUTPUTS = ( + "home_mortgage_interest_tax_unit", + "deductible_mortgage_interest_tax_unit", + "non_deductible_mortgage_interest_tax_unit", + "deductible_mortgage_insurance_premiums", + "interest_deduction", + "itemized_deductions_less_salt", + "ky_disallowed_second_residence_interest", + "ky_itemized_deductions_unit", +) +PERSON_OUTPUTS = ( + "home_mortgage_interest_share", + "deductible_mortgage_interest", + "mortgage_interest", +) +FEDERAL_UNIT_OUTPUTS = ( + "deductible_mortgage_interest_tax_unit", + "non_deductible_mortgage_interest_tax_unit", + "deductible_mortgage_insurance_premiums", + "interest_deduction", + "itemized_deductions_less_salt", +) +# 26 U.S.C. 163(h)(3)(F)(i)(II): $750,000 ($375,000 married filing +# separately) for debt incurred after December 15, 2017. +POST_2017_LIMIT = {"SEPARATE": 375_000} +DEFAULT_POST_2017_LIMIT = 750_000 +RTOL = 1e-6 +ATOL = 0.01 + + +def _close(actual, expected): + np.testing.assert_allclose(actual, expected, rtol=RTOL, atol=ATOL) + + +def _draw(): + rng = np.random.default_rng(141_019) + statuses = rng.choice(STATUSES, N) + statuses[: 2 * len(STATUSES)] = np.tile(STATUSES, 2) + spouse = statuses == "JOINT" + amounts = {} + for principal, second, upper in PAIRS: + for name in (principal, second): + amount = rng.integers(0, upper * 100 + 1, (N, 2)) / 100 + amount[rng.random((N, 2)) < 0.25] = 0 + amount[~spouse, 1] = 0 + amounts[name] = amount + # Units with only second-residence interest, and with no mortgage at all. + amounts["home_mortgage_interest"][::7] = 0 + for principal, second, _ in PAIRS: + amounts[principal][::11] = 0 + amounts[second][::11] = 0 + first = rng.integers(1, 1_500_001, N).astype(float) + second_balance = rng.integers(1, 750_001, N).astype(float) + # Every fourth unit reports no balances, so the pool is fully deductible. + first[::4] = 0 + second_balance[::4] = 0 + # Equal-rate units: principal debt on the first loan, second-residence + # debt on the second, both incurred after 2017 at one rate. + equal_principal = rng.integers(1, 1_200_001, N).astype(float) + equal_second = rng.integers(0, 800_001, N).astype(float) + equal_rate = rng.uniform(0.02, 0.09, N) + # AGI on both sides of the premium phase-out range. + agi = rng.integers(0, 15_000_001, N) / 100 + return { + **amounts, + "filing_status": statuses, + "adjusted_gross_income": agi, + "first_home_mortgage_balance": first, + "second_home_mortgage_balance": second_balance, + "first_home_mortgage_origination_year": rng.choice((2010, 2016, 2020), N), + "second_home_mortgage_origination_year": rng.choice((2010, 2016, 2020), N), + "equal_principal": equal_principal, + "equal_second": equal_second, + "equal_rate": equal_rate, + } + + +def _person_inputs(draws, scenario, i, j): + if scenario == "equal_rate": + if j > 0: + return {} + return { + "home_mortgage_interest": float( + draws["equal_rate"][i] * draws["equal_principal"][i] + ), + "second_residence_mortgage_interest": float( + draws["equal_rate"][i] * draws["equal_second"][i] + ), + } + inputs = {} + for principal, second, _ in PAIRS: + principal_value = float(draws[principal][i, j]) + second_value = float(draws[second][i, j]) + if scenario == "pooled": + principal_value += second_value + second_value = 0.0 + elif scenario in ("principal_only", "omitted"): + second_value = 0.0 + inputs[principal] = principal_value + if scenario != "omitted": + inputs[second] = second_value + return inputs + + +def _balances(draws, scenario, i): + if scenario == "equal_rate": + return { + "first_home_mortgage_balance": float(draws["equal_principal"][i]), + "second_home_mortgage_balance": float(draws["equal_second"][i]), + "first_home_mortgage_origination_year": 2020, + "second_home_mortgage_origination_year": 2020, + } + return { + name: (float if "balance" in name else int)(draws[name][i]) + for name in ( + "first_home_mortgage_balance", + "second_home_mortgage_balance", + "first_home_mortgage_origination_year", + "second_home_mortgage_origination_year", + ) + } + + +@pytest.fixture(scope="module") +def results(): + draws = _draw() + people, tax_units, households = {}, {}, {} + units, persons = {}, {} + periods = tuple(map(str, YEARS)) + + def annual(value): + return dict.fromkeys(periods, value) + + for scenario in SCENARIOS: + units[scenario], persons[scenario] = [], [] + for i in range(N): + unit_index = len(tax_units) + units[scenario].append(unit_index) + members = [] + count = 2 if draws["filing_status"][i] == "JOINT" else 1 + for j in range(count): + person_index = len(people) + persons[scenario].append(person_index) + person_id = f"p{person_index}" + members.append(person_id) + person = {"age": annual(50 - j)} + for name, value in _person_inputs(draws, scenario, i, j).items(): + person[name] = annual(value) + people[person_id] = person + tax_units[f"t{unit_index}"] = { + "members": members, + "filing_status": annual(str(draws["filing_status"][i])), + "adjusted_gross_income": annual( + float(draws["adjusted_gross_income"][i]) + ), + **{ + name: annual(value) + for name, value in _balances(draws, scenario, i).items() + }, + # Keep the deprecated structured interest out of every pool. + "first_home_mortgage_interest": annual(0.0), + "second_home_mortgage_interest": annual(0.0), + } + households[f"h{unit_index}"] = { + "members": members, + "state_code": annual("KY"), + } + + simulation = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + values = {} + for year in YEARS: + values[year] = {} + for name in UNIT_OUTPUTS + PERSON_OUTPUTS: + array = simulation.calculate(name, str(year)).copy() + array.flags.writeable = False + values[year][name] = array + # Do not retain the Simulation, its mutable caches or its policy tree. + return ( + draws, + values, + {k: np.array(v) for k, v in units.items()}, + {k: np.array(v) for k, v in persons.items()}, + ) + + +def test_residence_split_does_not_change_federal_amounts(results): + _, values, units, persons = results + for year_values in values.values(): + for name in FEDERAL_UNIT_OUTPUTS: + _close(year_values[name][units["base"]], year_values[name][units["pooled"]]) + # Moving amounts within a person keeps that person's allocation. + for name in PERSON_OUTPUTS: + _close( + year_values[name][persons["base"]], + year_values[name][persons["pooled"]], + ) + + +def test_kentucky_disallowance_is_bounded(results): + _, values, _, _ = results + np.testing.assert_array_equal( + values[2025]["ky_disallowed_second_residence_interest"], 0 + ) + disallowed = values[2026]["ky_disallowed_second_residence_interest"] + assert np.all(disallowed >= 0) + assert np.all(disallowed <= values[2026]["interest_deduction"] + ATOL) + assert np.any(disallowed > 0) + + +def test_kentucky_lies_between_principal_only_and_pooled(results): + # Adding second-residence amounts cannot lower Kentucky's deduction, and + # moving them onto the principal residence cannot lower it either. + _, values, units, _ = results + kentucky = values[2026]["ky_itemized_deductions_unit"] + assert np.all(kentucky[units["principal_only"]] <= kentucky[units["base"]] + ATOL) + assert np.all(kentucky[units["base"]] <= kentucky[units["pooled"]] + ATOL) + + +def test_kentucky_ignores_second_residence_when_the_pool_is_fully_deductible( + results, +): + draws, values, units, _ = results + no_balance = (draws["first_home_mortgage_balance"] == 0) & ( + draws["second_home_mortgage_balance"] == 0 + ) + assert no_balance.sum() >= N // 4 + kentucky = values[2026]["ky_itemized_deductions_unit"] + _close( + kentucky[units["base"]][no_balance], + kentucky[units["principal_only"]][no_balance], + ) + + +def test_kentucky_matches_a_principal_residence_recomputation_at_equal_rates( + results, +): + # Independent statutory computation: with one rate r, the federal + # deduction is r * min(limit, P + S), and the deduction on the principal + # residence alone is r * min(limit, P). + draws, values, units, _ = results + limit = np.array( + [ + POST_2017_LIMIT.get(status, DEFAULT_POST_2017_LIMIT) + for status in draws["filing_status"] + ] + ) + rate = draws["equal_rate"] + principal, second = draws["equal_principal"], draws["equal_second"] + index = units["equal_rate"] + year_values = values[2026] + _close( + year_values["interest_deduction"][index], + rate * np.minimum(limit, principal + second), + ) + _close( + year_values["interest_deduction"][index] + - year_values["ky_disallowed_second_residence_interest"][index], + rate * np.minimum(limit, principal), + ) + # The draws exercise both sides of the limit. + assert np.any(principal > limit) and np.any(principal + second > limit) + assert np.any((principal < limit) & (principal + second > limit)) + + +def test_kentucky_follows_the_federal_deduction_before_2026(results): + _, values, units, _ = results + before = values[2025] + _close( + before["ky_itemized_deductions_unit"][units["base"]] + - before["ky_itemized_deductions_unit"][units["principal_only"]], + before["interest_deduction"][units["base"]] + - before["interest_deduction"][units["principal_only"]], + ) + + +def test_omitted_and_zero_second_residence_inputs_match(results): + _, values, units, persons = results + for year_values in values.values(): + for name in UNIT_OUTPUTS: + np.testing.assert_array_equal( + year_values[name][units["principal_only"]], + year_values[name][units["omitted"]], + ) + for name in PERSON_OUTPUTS: + np.testing.assert_array_equal( + year_values[name][persons["principal_only"]], + year_values[name][persons["omitted"]], + ) + np.testing.assert_array_equal( + year_values["ky_disallowed_second_residence_interest"][units["omitted"]], + 0, + ) diff --git a/policyengine_us/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index 01b5a7e01b7..ee3dd6caa74 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -94,6 +94,11 @@ "homeowners_association_fees": CPI_U, "pre_subsidy_transportation_expense": CPI_U, "home_mortgage_interest": CPI_U, + "home_mortgage_points": CPI_U, + "mortgage_insurance_premiums": CPI_U, + "second_residence_mortgage_interest": CPI_U, + "second_residence_mortgage_points": CPI_U, + "second_residence_mortgage_insurance_premiums": CPI_U, "student_loan_interest": CPI_U, "investment_interest_expense": CPI_U, # Still on the default. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/deductible_mortgage_interest.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/deductible_mortgage_interest.yaml index c437e3030f3..c2af6be26f9 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/deductible_mortgage_interest.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/deductible_mortgage_interest.yaml @@ -144,3 +144,139 @@ output: # Post-TCJA cap: 45,000 x 750,000 / 900,000 = 37,500 deductible_mortgage_interest_tax_unit: 37_500 + +- name: Points take the same deductible share as post-TCJA mortgage interest + period: 2026 + input: + filing_status: SINGLE + home_mortgage_interest: 40_000 + home_mortgage_points: 4_000 + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + output: + # Pub. 936 (2025), Table 1: lines 11/12 give 750,000/1,000,000 + # = 0.750 on line 14. Line 15 interest = 40,000 x 0.750 = 30,000; + # line 13 instructions give points = 4,000 x 0.750 = 3,000. + # Combined deduction = 33,000; remainder = 44,000 - 33,000 = 11,000. + # Mortgage interest before the cap = 40,000 + 4,000 = 44,000. + deductible_mortgage_interest_tax_unit: 33_000 + non_deductible_mortgage_interest_tax_unit: 11_000 + deductible_mortgage_interest: 33_000 + non_deductible_mortgage_interest: 11_000 + mortgage_interest: 44_000 + +- name: Points are fully deductible when no mortgage balance is supplied + period: 2026 + input: + filing_status: SINGLE + home_mortgage_interest: 12_000 + home_mortgage_points: 2_000 + output: + # No balance is reported, so assume debt within the Pub. 936 limits: + # Table 1's line 12 stop rule makes the deductible fraction 1. + # Interest = 12,000 x 1; points = 2,000 x 1; total = 14,000; + # non-deductible amount = 14,000 - 14,000 = 0. + deductible_mortgage_interest_tax_unit: 14_000 + non_deductible_mortgage_interest_tax_unit: 0 + deductible_mortgage_interest: 14_000 + mortgage_interest: 14_000 + +- name: Points on acquisition debt grandfathered under the pre-TCJA cap are fully deductible + period: 2026 + input: + filing_status: SINGLE + home_mortgage_interest: 36_000 + home_mortgage_points: 4_000 + first_home_mortgage_balance: 900_000 + first_home_mortgage_origination_year: 2016 + output: + # Pub. 936 Table 1: line 2 = 900,000; line 3 = 1,000,000; + # line 6 = min(1,000,000, 900,000) = 900,000. With no post-2017 + # debt, line 11 = 900,000 = line 12, so the deductible fraction is 1. + # Interest = 36,000; points = 4,000 x 1 = 4,000; total = 40,000. + # Non-deductible amount = 40,000 - 40,000 = 0. + deductible_mortgage_interest_tax_unit: 40_000 + non_deductible_mortgage_interest_tax_unit: 0 + deductible_mortgage_interest: 40_000 + mortgage_interest: 40_000 + +- name: Joint return allocates spouse-reported points with head-reported interest + period: 2026 + input: + people: + head: + age: 45 + home_mortgage_interest: 30_000 + spouse: + age: 44 + home_mortgage_points: 10_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + output: + # Pub. 936 Table 1 line 14 = 750,000/1,000,000 = 0.750. + # Interest = 30,000 x 0.750 = 22,500; deductible points = + # 10,000 x 0.750 = 7,500. Deduction = 30,000; remainder = 10,000. + # Reported interest + points = 40,000, allocated 30,000/40,000 = + # 0.75 to head and 10,000/40,000 = 0.25 to spouse. Deductible + # amounts 22,500 + 7,500 = 30,000; remainders 7,500 + 2,500 = 10,000. + # Gross mortgage amounts = 22,500 + 7,500 = 30,000 for head and + # 7,500 + 2,500 = 10,000 for spouse, summing to 40,000. + people: + head: + home_mortgage_interest_share: 0.75 + deductible_mortgage_interest: 22_500 + non_deductible_mortgage_interest: 7_500 + mortgage_interest: 30_000 + spouse: + home_mortgage_interest_share: 0.25 + deductible_mortgage_interest: 7_500 + non_deductible_mortgage_interest: 2_500 + mortgage_interest: 10_000 + tax_units: + tax_unit: + deductible_mortgage_interest_tax_unit: 30_000 + non_deductible_mortgage_interest_tax_unit: 10_000 + +- name: Points-only joint return allocates to the reporting spouse when interest is zero + period: 2026 + input: + people: + head: + age: 45 + spouse: + age: 44 + home_mortgage_points: 8_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + output: + # Pub. 936 Table 1 line 14 = 750,000/1,000,000 = 0.750. + # Interest = 0; points = 8,000 x 0.750 = 6,000 deductible; + # non-deductible amount = 8,000 - 6,000 = 2,000. + # The spouse reported all 8,000, so shares are 0/8,000 = 0 for + # head and 8,000/8,000 = 1 for spouse, even with no reported interest. + # Head receives 0; spouse receives deduction 6,000, remainder 2,000, + # and gross mortgage amount 6,000 + 2,000 = 8,000. + people: + head: + home_mortgage_interest_share: 0 + deductible_mortgage_interest: 0 + non_deductible_mortgage_interest: 0 + mortgage_interest: 0 + spouse: + home_mortgage_interest_share: 1 + deductible_mortgage_interest: 6_000 + non_deductible_mortgage_interest: 2_000 + mortgage_interest: 8_000 + tax_units: + tax_unit: + home_mortgage_interest_tax_unit: 0 + deductible_mortgage_interest_tax_unit: 6_000 + non_deductible_mortgage_interest_tax_unit: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.yaml new file mode 100644 index 00000000000..f394efb9e15 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.yaml @@ -0,0 +1,310 @@ +# 2021 Schedule A instructions, Mortgage Insurance Premiums Deduction +# Worksheet, lines 1-7: round positive excess AGI up to the next $1,000 +# ($500 if married filing separately), divide by $10,000 ($5,000), cap the +# reduction at 100%, and subtract that share of qualified premiums. +# https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=10 +# Pub. 936 (2021), Table 1, line 13 instructions apply this AGI worksheet +# to premiums, without multiplying them by the qualified-loan-limit share. +# https://www.irs.gov/pub/irs-prior/p936--2021.pdf#page=14 + +- name: Qualified premiums are deductible in 2021 + period: 2021 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + mortgage_insurance_premiums: 1_000 + output: + # Worksheet line 2 ($90,000) <= line 3 ($100,000): deduct line 1 in full. + deductible_mortgage_insurance_premiums: 1_000 + +- name: Qualified premiums are not deductible in 2022 + period: 2022 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + mortgage_insurance_premiums: 1_000 + output: + # 26 USC 163(h)(3)(E)(iv) disallows premiums paid after 2021: $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Qualified premiums are not deductible in 2023 + period: 2023 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + mortgage_insurance_premiums: 1_000 + output: + # 26 USC 163(h)(3)(E)(iv) disallows premiums paid after 2021: $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Qualified premiums are not deductible in 2024 + period: 2024 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + mortgage_insurance_premiums: 1_000 + output: + # 26 USC 163(h)(3)(E)(iv) disallows premiums paid after 2021: $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Qualified premiums are excluded from the interest deduction in 2025 + period: 2025 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + home_mortgage_interest: 12_000 + mortgage_insurance_premiums: 1_000 + output: + # Premium deduction is suspended; $12,000 uncapped interest + $0 premiums. + deductible_mortgage_insurance_premiums: 0 + interest_deduction: 12_000 + +- name: Qualified premiums return and enter the interest deduction in 2026 + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + home_mortgage_interest: 12_000 + mortgage_insurance_premiums: 1_000 + output: + # P.L. 119-21 sec. 70108 restores the deduction. AGI is below $100,000, + # so $1,000 premiums are deductible; $12,000 interest + $1,000 = $13,000. + deductible_mortgage_insurance_premiums: 1_000 + interest_deduction: 13_000 + +- name: Single filer at the premium phase-out threshold + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 100_000 + mortgage_insurance_premiums: 1_000 + output: + # Worksheet line 2 equals line 3: line 1 ($1,000) is fully deductible. + deductible_mortgage_insurance_premiums: 1_000 + +- name: Single filer one cent above the premium phase-out threshold + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 100_000.01 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $0.01 up to $1,000; line 5 = $1,000 / $10,000 = 0.1. + # Lines 6-7: $1,000 - $1,000 * 0.1 = $900. + deductible_mortgage_insurance_premiums: 900 + +- name: Single filer one dollar above the premium phase-out threshold + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 100_001 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $1 up to $1,000; $1,000 - $1,000 * 0.1 = $900. + deductible_mortgage_insurance_premiums: 900 + +- name: Single filer at the first complete premium phase-out increment + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 101_000 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 is exactly $1,000; $1,000 - $1,000 * ($1,000 / $10,000) = $900. + deductible_mortgage_insurance_premiums: 900 + +- name: Single filer one dollar into the second premium phase-out increment + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 101_001 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $1,001 to $2,000; $1,000 - $1,000 * 0.2 = $800. + deductible_mortgage_insurance_premiums: 800 + +- name: Single filer at the last partly deductible premium boundary + period: 2026 + # Float32 subtraction of a 90% reduction leaves a small rounding residual. + absolute_error_margin: 0.001 + input: + filing_status: SINGLE + adjusted_gross_income: 109_000 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 = $9,000; line 5 = 0.9; $1,000 - $1,000 * 0.9 = $100. + deductible_mortgage_insurance_premiums: 100 + +- name: Single filer one cent beyond the last deductible premium boundary + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 109_000.01 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $9,000.01 to $10,000; line 5 = 1; $1,000 - $1,000 = $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Single filer well beyond the premium phase-out range + period: 2026 + input: + filing_status: SINGLE + adjusted_gross_income: 150_000 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 = $50,000; line 5 is capped at 1; $1,000 - $1,000 = $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Separate filer at the premium phase-out threshold + period: 2026 + input: + filing_status: SEPARATE + adjusted_gross_income: 50_000 + mortgage_insurance_premiums: 1_000 + output: + # Worksheet line 2 equals the MFS line 3 ($50,000): deduct $1,000 in full. + deductible_mortgage_insurance_premiums: 1_000 + +- name: Separate filer one dollar above the premium phase-out threshold + period: 2026 + input: + filing_status: SEPARATE + adjusted_gross_income: 50_001 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $1 to $500; line 5 = $500 / $5,000 = 0.1. + # Lines 6-7: $1,000 - $1,000 * 0.1 = $900. + deductible_mortgage_insurance_premiums: 900 + +- name: Separate filer at the last partly deductible premium boundary + period: 2026 + # Float32 subtraction of a 90% reduction leaves a small rounding residual. + absolute_error_margin: 0.001 + input: + filing_status: SEPARATE + adjusted_gross_income: 54_500 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 = $4,500; line 5 = $4,500 / $5,000 = 0.9; deduction = $100. + deductible_mortgage_insurance_premiums: 100 + +- name: Separate filer one dollar beyond the last deductible premium boundary + period: 2026 + input: + filing_status: SEPARATE + adjusted_gross_income: 54_501 + mortgage_insurance_premiums: 1_000 + output: + # Line 4 rounds $4,501 to $5,000; line 5 = 1; $1,000 - $1,000 = $0. + deductible_mortgage_insurance_premiums: 0 + +- name: Joint filers use the single premium phase-out schedule + period: 2026 + input: + filing_status: JOINT + adjusted_gross_income: 101_001 + mortgage_insurance_premiums: 1_000 + output: + # Start $100,000, increment $1,000: round $1,001 to $2,000. + # $1,000 - $1,000 * ($2,000 / $10,000) = $800. + deductible_mortgage_insurance_premiums: 800 + +- name: Head of household uses the single premium phase-out schedule + period: 2026 + input: + filing_status: HEAD_OF_HOUSEHOLD + adjusted_gross_income: 101_001 + mortgage_insurance_premiums: 1_000 + output: + # Start $100,000, increment $1,000: round $1,001 to $2,000. + # $1,000 - $1,000 * ($2,000 / $10,000) = $800. + deductible_mortgage_insurance_premiums: 800 + +- name: Surviving spouse uses the single premium phase-out schedule + period: 2026 + input: + filing_status: SURVIVING_SPOUSE + adjusted_gross_income: 101_001 + mortgage_insurance_premiums: 1_000 + output: + # Start $100,000, increment $1,000: round $1,001 to $2,000. + # $1,000 - $1,000 * ($2,000 / $10,000) = $800. + deductible_mortgage_insurance_premiums: 800 + +- name: Acquisition-debt cap does not prorate qualified premiums + period: 2021 + input: + filing_status: SINGLE + adjusted_gross_income: 90_000 + home_mortgage_interest: 40_000 + mortgage_insurance_premiums: 2_000 + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + output: + # Pub. 936 Table 1: line 14 = $750,000 / $1,000,000 = 0.75; + # line 15 interest = $40,000 * 0.75 = $30,000. + # The separate premium instructions use only AGI: $2,000 in full. + # Total interest deduction = $30,000 + $2,000 = $32,000. + deductible_mortgage_interest_tax_unit: 30_000 + deductible_mortgage_insurance_premiums: 2_000 + interest_deduction: 32_000 + +- name: Both spouses premiums enter the joint deduction before the phase-out + period: 2026 + input: + people: + head: + mortgage_insurance_premiums: 1_000 + is_tax_unit_head: true + spouse: + mortgage_insurance_premiums: 1_500 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 101_001 + output: + # Worksheet line 1 = $1,000 + $1,500 = $2,500; line 4 = $2,000. + # Lines 5-7: $2,500 - $2,500 * 0.2 = $2,000. + deductible_mortgage_insurance_premiums: 2_000 + interest_deduction: 2_000 + +- name: Mortgage points and premiums flow into itemized deductions and taxable income in 2026 + period: 2026 + input: + people: + head: + age: 40 + employment_income: 90_000 + home_mortgage_interest: 40_000 + home_mortgage_points: 4_000 + mortgage_insurance_premiums: 2_000 + tax_units: + tax_unit: + members: [head] + filing_status: SINGLE + tax_unit_itemizes: true + state_sales_tax: 0 + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + households: + household: + members: [head] + state_code: TX + output: + # Pub. 936 Table 1 line 14 = $750,000 / $1,000,000 = 0.75. + # Interest is $40,000 * 0.75 = $30,000; deductible points are + # $4,000 * 0.75 = $3,000, for a combined mortgage deduction of $33,000. + # AGI $90,000 is below the $100,000 premium phase-out start, so add all + # $2,000 premiums: interest and total itemized deductions are $35,000. + # No other deductions, exemptions or itemized reduction apply; + # taxable income = $90,000 - $35,000 = $55,000. + adjusted_gross_income: 90_000 + mortgage_interest: 44_000 + deductible_mortgage_interest_tax_unit: 33_000 + deductible_mortgage_insurance_premiums: 2_000 + interest_deduction: 35_000 + itemized_taxable_income_deductions: 35_000 + taxable_income_deductions: 35_000 + taxable_income: 55_000 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.yaml new file mode 100644 index 00000000000..669cf649c4a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.yaml @@ -0,0 +1,66 @@ +- name: Second-residence interest, points and premiums join the federal deduction in 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + second_residence_mortgage_points: 500 + mortgage_insurance_premiums: 1_000 + second_residence_mortgage_insurance_premiums: 600 + output: + # No balances are given, so the pool is fully deductible: + # 10,000 + 4,000 + 500 points. + deductible_mortgage_interest_tax_unit: 14_500 + # AGI is below the $100,000 phase-out start: 1,000 + 600. + deductible_mortgage_insurance_premiums: 1_600 + interest_deduction: 16_100 + +- name: Second-residence interest takes the acquisition-debt share and its premiums the phase-out + period: 2026 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 105_000 + first_home_mortgage_balance: 900_000 + first_home_mortgage_origination_year: 2020 + second_home_mortgage_balance: 300_000 + second_home_mortgage_origination_year: 2020 + home_mortgage_interest: 30_000 + home_mortgage_points: 2_000 + second_residence_mortgage_interest: 12_000 + second_residence_mortgage_insurance_premiums: 1_000 + output: + # Deductible share: $750,000 / ($900,000 + $300,000) = 0.625. + # (30,000 + 2,000 + 12,000) * 0.625 = 27,500. + deductible_mortgage_interest_tax_unit: 27_500 + # $5,000 above the start is 5 increments of 10%: 1,000 * 0.5. + deductible_mortgage_insurance_premiums: 500 + interest_deduction: 28_000 + +- name: Second-residence and principal-residence premiums share the phase-out + period: 2026 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 100_500 + mortgage_insurance_premiums: 900 + second_residence_mortgage_insurance_premiums: 300 + output: + # $500 above the start is one increment or fraction: (900 + 300) * 0.9. + deductible_mortgage_insurance_premiums: 1_080 + interest_deduction: 1_080 + +- name: Second-residence premiums are not deductible in 2025 + period: 2025 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + second_residence_mortgage_insurance_premiums: 600 + output: + deductible_mortgage_insurance_premiums: 0 + interest_deduction: 14_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.yaml new file mode 100644 index 00000000000..9c31e1d9b03 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.yaml @@ -0,0 +1,124 @@ +- name: Kentucky allows second-residence interest before 2026 + period: 2025 + absolute_error_margin: 0 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + output: + ky_disallowed_second_residence_interest: 0 + +- name: Kentucky disallows second-residence interest from 2026 + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + output: + # Pool 14,000 fully deductible; principal residence 10,000. + ky_disallowed_second_residence_interest: 4_000 + +- name: The Kentucky disallowance applies only to Kentucky + period: 2026 + absolute_error_margin: 0 + input: + state_code: OH + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + output: + ky_disallowed_second_residence_interest: 0 + +- name: Kentucky keeps the federal deduction when principal-residence debt alone exceeds the limit + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + second_home_mortgage_balance: 500_000 + second_home_mortgage_origination_year: 2020 + home_mortgage_interest: 50_000 + second_residence_mortgage_interest: 25_000 + output: + # Both loans at 5%. Federal: 75,000 * 750,000 / 1,500,000 = 37,500, + # below the principal residence's 50,000, so nothing is disallowed. + # A recomputation on the principal residence alone gives the same: + # 50,000 * 750,000 / 1,000,000 = 37,500. + ky_disallowed_second_residence_interest: 0 + +- name: Kentucky treats legacy structured interest as principal-residence interest + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_interest: 8_000 + second_residence_mortgage_interest: 4_000 + output: + # No person-level principal-residence interest, so the structured 8,000 + # stands in for it: 8,000 + 4,000. + home_mortgage_interest_tax_unit: 12_000 + ky_disallowed_second_residence_interest: 4_000 + +- name: Kentucky disallows second-residence premiums after the federal phase-out + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 100_500 + mortgage_insurance_premiums: 900 + second_residence_mortgage_insurance_premiums: 300 + output: + # One $1,000 increment or fraction above $100,000: 10% reduction. + # (900 + 300) * 0.9 = 1,080, of which the second residence's 300 * 0.9. + deductible_mortgage_insurance_premiums: 1_080 + ky_disallowed_second_residence_interest: 270 + +- name: Kentucky disallows second-residence points beside legacy structured interest + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_interest: 8_000 + second_residence_mortgage_points: 400 + output: + # The structured 8,000 stands in for principal-residence interest, and + # the second residence's 400 of points join the fully deductible pool. + home_mortgage_interest_tax_unit: 8_000 + deductible_mortgage_interest_tax_unit: 8_400 + ky_disallowed_second_residence_interest: 400 + +- name: Kentucky counts legacy structured interest toward the principal-residence cap + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_balance: 1_000_000 + first_home_mortgage_origination_year: 2020 + second_home_mortgage_balance: 500_000 + second_home_mortgage_origination_year: 2020 + first_home_mortgage_interest: 8_000 + second_residence_mortgage_interest: 4_000 + output: + # Federal: (8,000 + 4,000) * 750,000 / 1,500,000 = 6,000. The structured + # 8,000 counts as principal-residence interest, so the 6,000 is within + # the cap and nothing is disallowed. (Were it not counted, Kentucky would + # disallow min(4,000, 6,000) = 4,000.) + home_mortgage_interest_tax_unit: 12_000 + deductible_mortgage_interest_tax_unit: 6_000 + ky_disallowed_second_residence_interest: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_indiv.yaml index a8865e77a6a..719e329c905 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_indiv.yaml @@ -87,3 +87,31 @@ state_code: KY output: ky_itemized_deductions_indiv: [0, 0] + +- name: From 2026 spouses filing separately share Kentucky itemized deductions without second-residence interest + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 50 + ky_agi: 60_000 + home_mortgage_interest: 10_000 + person2: + age: 50 + ky_agi: 40_000 + second_residence_mortgage_interest: 5_000 + tax_units: + tax_unit: + members: [person1, person2] + filing_status: JOINT + adjusted_gross_income: 100_000 + households: + household: + members: [person1, person2] + state_code: KY + output: + # Federal 15,000 less the 5,000 second-residence excess leaves 10,000, + # split by Kentucky AGI: 60% and 40%. + ky_itemized_deductions_unit: 10_000 + ky_itemized_deductions_indiv: [6_000, 4_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.yaml index c43befc6998..2c95b9fe2fc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.yaml @@ -17,3 +17,127 @@ medical_expense_deduction: 2 output: ky_itemized_deductions_unit: 8 + +- name: In 2025 Kentucky keeps federal second-residence mortgage interest + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + second_residence_mortgage_points: 500 + mortgage_insurance_premiums: 1_000 + second_residence_mortgage_insurance_premiums: 600 + output: + # Federal premiums are not deductible in 2025: 10,000 + 4,000 + 500. + itemized_deductions_less_salt: 14_500 + ky_disallowed_second_residence_interest: 0 + ky_itemized_deductions_unit: 14_500 + +- name: From 2026 Kentucky keeps only principal-residence interest, points and premiums + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + second_residence_mortgage_points: 500 + mortgage_insurance_premiums: 1_000 + second_residence_mortgage_insurance_premiums: 600 + output: + # Federal: 10,000 + 4,000 + 500 + 1,000 + 600. + itemized_deductions_less_salt: 16_100 + # No balances are given, so the federal pool of 14,500 is fully + # deductible and exceeds the principal residence's 10,000 by 4,500; + # the 600 of second-residence premiums is disallowed too. + ky_disallowed_second_residence_interest: 5_100 + # Principal residence: 10,000 + 1,000. + ky_itemized_deductions_unit: 11_000 + +- name: From 2026 Kentucky allows principal-residence interest that combined debt pushed over the federal limit + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_balance: 600_000 + first_home_mortgage_origination_year: 2020 + second_home_mortgage_balance: 300_000 + second_home_mortgage_origination_year: 2020 + home_mortgage_interest: 36_000 + second_residence_mortgage_interest: 18_000 + output: + # Federal: (36,000 + 18,000) * 750,000 / 900,000 = 45,000. + itemized_deductions_less_salt: 45_000 + # Kentucky caps the federal deduction at the principal residence's + # 36,000, which its $600,000 debt alone would keep in full. + ky_disallowed_second_residence_interest: 9_000 + ky_itemized_deductions_unit: 36_000 + +- name: From 2026 Kentucky allows the federal deduction up to principal-residence interest and points + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 105_000 + first_home_mortgage_balance: 900_000 + first_home_mortgage_origination_year: 2020 + second_home_mortgage_balance: 300_000 + second_home_mortgage_origination_year: 2020 + home_mortgage_interest: 30_000 + home_mortgage_points: 2_000 + second_residence_mortgage_interest: 12_000 + second_residence_mortgage_insurance_premiums: 1_000 + output: + # Federal: (30,000 + 2,000 + 12,000) * 0.625 + 1,000 * 0.5 = 28,000. + itemized_deductions_less_salt: 28_000 + # The 27,500 of deductible interest and points is below the principal + # residence's 32,000, so only the 500 of second-residence premiums goes. + ky_disallowed_second_residence_interest: 500 + ky_itemized_deductions_unit: 27_500 + +- name: From 2026 Kentucky itemized deductions are unchanged without second-residence amounts + period: 2026 + absolute_error_margin: 0.01 + input: + state_code: KY + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + home_mortgage_points: 500 + mortgage_insurance_premiums: 1_000 + output: + itemized_deductions_less_salt: 11_500 + ky_disallowed_second_residence_interest: 0 + ky_itemized_deductions_unit: 11_500 + +- name: From 2026 Kentucky disallows second-residence interest on a joint return + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + home_mortgage_interest: 9_000 + spouse: + age: 50 + second_residence_mortgage_interest: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 90_000 + households: + household: + members: [head, spouse] + state_code: KY + output: + itemized_deductions_less_salt: 12_000 + ky_disallowed_second_residence_interest: 3_000 + ky_itemized_deductions_unit: 9_000 diff --git a/policyengine_us/tests/policy/baseline/household/expense/person/home_mortgage_interest_share.yaml b/policyengine_us/tests/policy/baseline/household/expense/person/home_mortgage_interest_share.yaml new file mode 100644 index 00000000000..b03b21757e5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/person/home_mortgage_interest_share.yaml @@ -0,0 +1,24 @@ +- name: Spouses' shares of the mortgage deduction count second-residence amounts + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + home_mortgage_interest: 6_000 + spouse: + age: 50 + second_residence_mortgage_interest: 1_500 + second_residence_mortgage_points: 500 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + households: + household: + members: [head, spouse] + output: + # 6,000 / 8,000 and (1,500 + 500) / 8,000. + home_mortgage_interest_share: [0.75, 0.25] + deductible_mortgage_interest: [6_000, 2_000] + mortgage_interest: [6_000, 2_000] diff --git a/policyengine_us/tests/policy/baseline/household/expense/tax_unit/home_mortgage_interest_tax_unit.yaml b/policyengine_us/tests/policy/baseline/household/expense/tax_unit/home_mortgage_interest_tax_unit.yaml new file mode 100644 index 00000000000..a4398939df5 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/household/expense/tax_unit/home_mortgage_interest_tax_unit.yaml @@ -0,0 +1,28 @@ +- name: Interest on both residences is pooled + period: 2026 + absolute_error_margin: 0 + input: + home_mortgage_interest: 10_000 + second_residence_mortgage_interest: 4_000 + output: + home_mortgage_interest_tax_unit: 14_000 + +- name: Legacy structured interest stands in for principal-residence interest only + period: 2026 + absolute_error_margin: 0 + input: + first_home_mortgage_interest: 8_000 + second_residence_mortgage_interest: 4_000 + output: + # No person-level principal-residence interest: 8,000 + 4,000. + home_mortgage_interest_tax_unit: 12_000 + +- name: Person-level principal-residence interest replaces legacy structured interest + period: 2026 + absolute_error_margin: 0 + input: + home_mortgage_interest: 5_000 + first_home_mortgage_interest: 8_000 + second_residence_mortgage_interest: 4_000 + output: + home_mortgage_interest_tax_unit: 9_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.py new file mode 100644 index 00000000000..291008442da --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.py @@ -0,0 +1,43 @@ +from policyengine_us.model_api import * + + +class deductible_mortgage_insurance_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Deductible mortgage insurance premiums" + unit = USD + definition_period = YEAR + documentation = ( + "Qualified mortgage insurance premiums treated as qualified residence " + "interest under 26 U.S.C. 163(h)(3)(E), after the adjusted gross " + "income phase-out (Schedule A line 8d). Publication 936 applies only " + "the phase-out to premiums, not the qualified-loan-limit fraction that " + "applies to interest and points." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", + "https://www.law.cornell.edu/uscode/text/26/163#h_3_F_i_III", + "https://www.irs.gov/pub/irs-prior/i1040sca--2021.pdf#page=10", + "https://www.irs.gov/pub/irs-prior/p936--2021.pdf#page=14", + ] + + def formula(tax_unit, period, parameters): + p = parameters( + period + ).gov.irs.deductions.itemized.interest.mortgage_insurance_premiums + premiums = add( + tax_unit, + period, + [ + "mortgage_insurance_premiums", + "second_residence_mortgage_insurance_premiums", + ], + ) + agi = tax_unit("adjusted_gross_income", period) + filing_status = tax_unit("filing_status", period) + # Worksheet lines 3-5: the deduction falls by the rate for each + # increment, or fraction of one, of AGI above the start. + excess = max_(0, agi - p.phase_out.start[filing_status]) + increments = np.ceil(excess / p.phase_out.increment[filing_status]) + reduction = min_(1, increments * p.phase_out.rate) + return p.in_effect * premiums * (1 - reduction) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.py index effde0252ef..a0f48ec0de2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.py @@ -6,8 +6,11 @@ class interest_deduction(Variable): entity = TaxUnit label = "Interest deduction" unit = USD - documentation = "Interest expenses deducted from taxable income." + documentation = ( + "Interest expenses deducted from taxable income, including deductible " + "mortgage insurance premiums (Schedule A line 8d)." + ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/163" - adds = ["deductible_interest_expense"] + adds = ["deductible_interest_expense", "deductible_mortgage_insurance_premiums"] diff --git a/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.py b/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.py new file mode 100644 index 00000000000..676178333db --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.py @@ -0,0 +1,72 @@ +from policyengine_us.model_api import * + + +class ky_disallowed_second_residence_interest(Variable): + value_type = float + entity = TaxUnit + label = "Kentucky disallowed second-residence interest deduction" + unit = USD + definition_period = YEAR + documentation = ( + "Federal qualified residence interest that Kentucky removes from " + "itemized deductions once it limits the deduction to the principal " + "residence. The federal deduction for mortgage interest and points " + "is capped at the interest and points paid on the principal " + "residence. The deprecated structured interest inputs count as " + "principal-residence interest. Mortgage insurance premiums on the " + "second residence are removed after the federal phase-out. The " + "capped deduction equals a recomputation on the principal residence " + "alone when the combined debt is within the federal limit, or when " + "both loans carry the same rate, fall under the same debt limit and " + "carry points in proportion to their debt (or none). Otherwise it " + "can differ in either direction, because the model does not record " + "which loan secures which residence." + ) + reference = ( + "https://apps.legislature.ky.gov/law/statutes/statute.aspx?id=57914#page=4", # (2)(j) + "https://apps.legislature.ky.gov/law/acts/26RS/documents/0161.pdf#page=13", + "https://apps.legislature.ky.gov/law/acts/26RS/documents/0198.pdf#page=74", + ) + defined_for = StateCode.KY + + def formula(tax_unit, period, parameters): + p = parameters(period).gov.states.ky.tax.income.deductions.itemized + # Federal deductible interest and points on both qualified + # residences, after the acquisition-debt limit. + deductible_interest = tax_unit("deductible_mortgage_interest_tax_unit", period) + # Interest and points paid on the principal residence. + principal_residence_interest = ( + tax_unit("home_mortgage_interest_tax_unit", period) + - add(tax_unit, period, ["second_residence_mortgage_interest"]) + + add(tax_unit, period, ["home_mortgage_points"]) + ) + second_residence_interest = add( + tax_unit, + period, + ["second_residence_mortgage_interest", "second_residence_mortgage_points"], + ) + # The excess can never exceed the second residence's own interest and + # points, since the deductible pool is at most the sum of both. + excess_interest = min_( + second_residence_interest, + max_(0, deductible_interest - principal_residence_interest), + ) + # Premiums: the federal phase-out applies to all premiums alike, so + # the second residence's part of the deduction is its share of them. + second_residence_premiums = add( + tax_unit, period, ["second_residence_mortgage_insurance_premiums"] + ) + premiums = second_residence_premiums + add( + tax_unit, period, ["mortgage_insurance_premiums"] + ) + deductible_premiums = tax_unit("deductible_mortgage_insurance_premiums", period) + second_residence_premium_share = np.divide( + second_residence_premiums, + premiums, + out=np.zeros_like(premiums), + where=premiums > 0, + ) + disallowed = ( + excess_interest + deductible_premiums * second_residence_premium_share + ) + return disallowed * p.principal_residence_interest_only diff --git a/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.py b/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.py index 7adcc1154a8..94e39ee398d 100644 --- a/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.py +++ b/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_itemized_deductions_unit.py @@ -10,8 +10,12 @@ class ky_itemized_deductions_unit(Variable): reference = ( "https://revenue.ky.gov/Forms/Form%20740%20Schedule%20A%202022.pdf", "https://law.justia.com/codes/kentucky/2022/chapter-141/section-141-019/", # (2)(i) + "https://apps.legislature.ky.gov/law/statutes/statute.aspx?id=57914#page=4", # (2)(j) ) defined_for = StateCode.KY adds = ["itemized_deductions_less_salt"] - subtracts = ["medical_expense_deduction"] + subtracts = [ + "medical_expense_deduction", + "ky_disallowed_second_residence_interest", + ] diff --git a/policyengine_us/variables/household/expense/person/deductible_mortgage_interest.py b/policyengine_us/variables/household/expense/person/deductible_mortgage_interest.py index 917801673d0..fe92a98e205 100644 --- a/policyengine_us/variables/household/expense/person/deductible_mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/deductible_mortgage_interest.py @@ -6,10 +6,10 @@ class deductible_mortgage_interest(Variable): entity = Person label = "Deductible mortgage interest" documentation = ( - "Federal deductible mortgage interest. When structural mortgage inputs " - "are provided at the tax-unit level, PolicyEngine applies the " - "acquisition-debt caps and allocates the resulting deduction across " - "filers." + "Federal deductible home mortgage interest and points. When " + "structural mortgage inputs are provided at the tax-unit level, " + "PolicyEngine applies the acquisition-debt caps and allocates the " + "resulting deduction across filers." ) unit = USD definition_period = YEAR diff --git a/policyengine_us/variables/household/expense/person/home_mortgage_interest.py b/policyengine_us/variables/household/expense/person/home_mortgage_interest.py index f25312cafea..0a5f5700a14 100644 --- a/policyengine_us/variables/household/expense/person/home_mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/home_mortgage_interest.py @@ -8,4 +8,10 @@ class home_mortgage_interest(Variable): unit = USD definition_period = YEAR uprating = "gov.bls.cpi.cpi_u" - documentation = "Home mortgage interest, including both reported and not reported on federal Form 1098." + documentation = ( + "Home mortgage interest on debt secured by the principal residence, " + "including interest reported and not reported on federal Form 1098. " + "Report interest on a second qualified residence, such as a vacation " + "home, in second_residence_mortgage_interest instead. Interest whose " + "residence is unknown is treated as principal-residence interest." + ) diff --git a/policyengine_us/variables/household/expense/person/home_mortgage_interest_share.py b/policyengine_us/variables/household/expense/person/home_mortgage_interest_share.py index 7cb833e0b3f..694a65fff0f 100644 --- a/policyengine_us/variables/household/expense/person/home_mortgage_interest_share.py +++ b/policyengine_us/variables/household/expense/person/home_mortgage_interest_share.py @@ -7,14 +7,23 @@ class home_mortgage_interest_share(Variable): label = "Share of tax-unit home mortgage interest" definition_period = YEAR documentation = ( - "Allocates tax-unit mortgage interest across filers using reported " - "person-level home mortgage interest when available, otherwise evenly " - "across head and spouse." + "Allocates tax-unit mortgage interest and points across filers using " + "reported person-level home mortgage interest and points on both " + "residences when available, otherwise evenly across head and spouse." ) def formula(person, period, parameters): head_or_spouse = person("is_tax_unit_head_or_spouse", period) - reported_interest = head_or_spouse * person("home_mortgage_interest", period) + reported_interest = head_or_spouse * add( + person, + period, + [ + "home_mortgage_interest", + "home_mortgage_points", + "second_residence_mortgage_interest", + "second_residence_mortgage_points", + ], + ) total_reported_interest = person.tax_unit.sum(reported_interest) reported_share = np.zeros_like(total_reported_interest) diff --git a/policyengine_us/variables/household/expense/person/home_mortgage_points.py b/policyengine_us/variables/household/expense/person/home_mortgage_points.py new file mode 100644 index 00000000000..46398f3bc36 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/home_mortgage_points.py @@ -0,0 +1,25 @@ +from policyengine_us.model_api import * + + +class home_mortgage_points(Variable): + value_type = float + entity = Person + label = "Deductible points paid on a home mortgage" + unit = USD + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + documentation = ( + "Points on debt secured by the principal residence that are " + "deductible this year: points deductible in the year paid under 26 " + "U.S.C. 461(g)(2), plus this year's ratable share of points deducted " + "over the life of a loan. Exclude points already included in " + "home_mortgage_interest. Points reported on Form 1098 usually are, " + "because Schedule A line 8a combines them with interest, so this is " + "typically Schedule A line 8c, points not reported on Form 1098. " + "Report points on a second qualified residence in " + "second_residence_mortgage_points." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/461#g_2", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=6", + ] diff --git a/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py new file mode 100644 index 00000000000..f38f418b345 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class mortgage_insurance_premiums(Variable): + value_type = float + entity = Person + label = "Qualified mortgage insurance premiums" + unit = USD + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + documentation = ( + "Premiums paid for qualified mortgage insurance (from the Department " + "of Veterans Affairs, the Federal Housing Administration, the Rural " + "Housing Service, or private mortgage insurance) in connection with " + "home acquisition debt on the principal residence, under a contract issued " + "after 2006, and allocable to this year (Form 1098 box 5). Enter the " + "premiums before any adjusted-gross-income phase-out. Report premiums " + "on a second qualified residence in " + "second_residence_mortgage_insurance_premiums." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", + "https://www.law.cornell.edu/uscode/text/26/163#h_5_E", + ] diff --git a/policyengine_us/variables/household/expense/person/mortgage_interest.py b/policyengine_us/variables/household/expense/person/mortgage_interest.py index f1591180b52..ff8d88ccd81 100644 --- a/policyengine_us/variables/household/expense/person/mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/mortgage_interest.py @@ -7,5 +7,9 @@ class mortgage_interest(Variable): label = "Mortgage interest" unit = USD definition_period = YEAR + documentation = ( + "Home mortgage interest and deductible points, before the federal " + "acquisition-debt caps." + ) adds = ["non_deductible_mortgage_interest", "deductible_mortgage_interest"] diff --git a/policyengine_us/variables/household/expense/person/non_deductible_mortgage_interest.py b/policyengine_us/variables/household/expense/person/non_deductible_mortgage_interest.py index 0f38d351d09..f72266c9c30 100644 --- a/policyengine_us/variables/household/expense/person/non_deductible_mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/non_deductible_mortgage_interest.py @@ -8,8 +8,8 @@ class non_deductible_mortgage_interest(Variable): unit = USD definition_period = YEAR documentation = ( - "Home mortgage interest that is not deductible federally after " - "applying the acquisition-debt caps." + "Home mortgage interest and points that are not deductible federally " + "after applying the acquisition-debt caps." ) def formula(person, period, parameters): diff --git a/policyengine_us/variables/household/expense/person/second_residence_mortgage_insurance_premiums.py b/policyengine_us/variables/household/expense/person/second_residence_mortgage_insurance_premiums.py new file mode 100644 index 00000000000..6ea02978d5b --- /dev/null +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_insurance_premiums.py @@ -0,0 +1,21 @@ +from policyengine_us.model_api import * + + +class second_residence_mortgage_insurance_premiums(Variable): + value_type = float + entity = Person + label = "Qualified mortgage insurance premiums on a second residence" + unit = USD + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + documentation = ( + "Qualified mortgage insurance premiums paid in connection with " + "acquisition debt on the taxpayer's second qualified residence (a " + "residence other than the principal residence), allocable to this " + "year, before any adjusted-gross-income phase-out. Do not include " + "them in mortgage_insurance_premiums." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", + "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", + ] diff --git a/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py b/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py new file mode 100644 index 00000000000..1bc46d2db1a --- /dev/null +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class second_residence_mortgage_interest(Variable): + value_type = float + entity = Person + label = "Interest paid on a second-residence mortgage" + unit = USD + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + documentation = ( + "Home mortgage interest on debt secured by the taxpayer's second " + "qualified residence: a residence other than the principal residence, " + "such as a vacation home, that the taxpayer treats as a qualified " + "residence under 26 U.S.C. 163(h)(5)(A)(i)(II). Do not include it in " + "home_mortgage_interest. Federal law pools it with principal-residence " + "interest; some states allow only principal-residence interest." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=4", + ] diff --git a/policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py b/policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py new file mode 100644 index 00000000000..33b66cd6542 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class second_residence_mortgage_points(Variable): + value_type = float + entity = Person + label = "Deductible points paid on a second-residence mortgage" + unit = USD + definition_period = YEAR + uprating = "gov.bls.cpi.cpi_u" + documentation = ( + "This year's deductible points on debt secured by the taxpayer's " + "second qualified residence (a residence other than the principal " + "residence). Points on a second home are deducted ratably over the " + "life of the loan, because 26 U.S.C. 461(g)(2) allows a deduction in " + "the year paid only for the principal residence. Exclude points " + "already included in second_residence_mortgage_interest, and do not " + "include them in home_mortgage_points." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/461#g", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=8", + ] diff --git a/policyengine_us/variables/household/expense/tax_unit/mortgage_interest_structure.py b/policyengine_us/variables/household/expense/tax_unit/mortgage_interest_structure.py index fed7ca75751..b30c3504300 100644 --- a/policyengine_us/variables/household/expense/tax_unit/mortgage_interest_structure.py +++ b/policyengine_us/variables/household/expense/tax_unit/mortgage_interest_structure.py @@ -111,20 +111,35 @@ class home_mortgage_interest_tax_unit(Variable): unit = USD definition_period = YEAR documentation = ( - "Total home mortgage interest. The person-level home_mortgage_interest " - "input is canonical; the deprecated structured first/second interest " - "inputs are used only when no person-level interest is reported " - "(existing datasets still supply them — see issue #9275)." + "Total home mortgage interest on the principal and second residences. " + "The person-level home_mortgage_interest and " + "second_residence_mortgage_interest inputs are canonical. The " + "deprecated structured first/second interest inputs stand in for " + "principal-residence interest when no person-level " + "home_mortgage_interest is reported (existing datasets still supply " + "them — see issue #9275)." ) def formula(tax_unit, period, parameters): - reported_interest = add(tax_unit, period, ["home_mortgage_interest"]) + principal_residence_interest = add(tax_unit, period, ["home_mortgage_interest"]) structured_interest = add( tax_unit, period, ["first_home_mortgage_interest", "second_home_mortgage_interest"], ) - return where(reported_interest > 0, reported_interest, structured_interest) + second_residence_interest = add( + tax_unit, period, ["second_residence_mortgage_interest"] + ) + # The structured inputs record loans, not residences, so they stand in + # for principal-residence interest only. + return ( + where( + principal_residence_interest > 0, + principal_residence_interest, + structured_interest, + ) + + second_residence_interest + ) class deductible_mortgage_interest_tax_unit(Variable): @@ -134,10 +149,15 @@ class deductible_mortgage_interest_tax_unit(Variable): unit = USD definition_period = YEAR documentation = ( - "Federal deductible mortgage interest after applying the statutory " - "acquisition-debt caps to up to two mortgages." + "Federal deductible home mortgage interest and points (Schedule A " + "lines 8a through 8c) after applying the statutory acquisition-debt " + "caps to up to two mortgages. Points take the same deductible share " + "as interest (Publication 936, Table 1, line 13 instructions)." ) - reference = "https://www.law.cornell.edu/uscode/text/26/163" + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=16", + ] def formula(tax_unit, period, parameters): first_balance = tax_unit("first_home_mortgage_balance", period) @@ -148,6 +168,11 @@ def formula(tax_unit, period, parameters): # Falls back to reported person-level interest when the structured # first/second inputs are absent. total_interest = tax_unit("home_mortgage_interest_tax_unit", period) + points = add( + tax_unit, + period, + ["home_mortgage_points", "second_residence_mortgage_points"], + ) filing_status = tax_unit("filing_status", period) p = parameters(period).gov.irs.deductions.itemized.interest.mortgage @@ -179,7 +204,9 @@ def formula(tax_unit, period, parameters): deductible_share[mask] = np.minimum( 1, limited_balance[mask] / total_balance[mask] ) - return total_interest * deductible_share + # Publication 936, Table 1, line 13: deductible points are multiplied + # by the same line 14 fraction as the interest on line 13. + return (total_interest + points) * deductible_share class non_deductible_mortgage_interest_tax_unit(Variable): @@ -189,11 +216,16 @@ class non_deductible_mortgage_interest_tax_unit(Variable): unit = USD definition_period = YEAR documentation = ( - "Home mortgage interest that is not deductible federally because it " - "exceeds the acquisition-debt caps." + "Home mortgage interest and points that are not deductible federally " + "because they exceed the acquisition-debt caps." ) def formula(tax_unit, period, parameters): total_interest = tax_unit("home_mortgage_interest_tax_unit", period) + points = add( + tax_unit, + period, + ["home_mortgage_points", "second_residence_mortgage_points"], + ) deductible_interest = tax_unit("deductible_mortgage_interest_tax_unit", period) - return max_(0, total_interest - deductible_interest) + return max_(0, total_interest + points - deductible_interest)