From 9b76c8b690331eeda4966f7345ea36aded74fdb7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 10:53:20 -0400 Subject: [PATCH 1/8] Add mortgage points and mortgage insurance premium inputs (federal) Adds two Person-level leaf inputs, home_mortgage_points and mortgage_insurance_premiums, so datasets no longer need to route them through investment_interest_expense (d1054). - Points join the home mortgage interest pool and take the same qualified-loan-limit fraction (Pub. 936, Table 1, line 13). - Premiums are deducted under 26 U.S.C. 163(h)(3)(E) with the AGI phase-out, for 2007-2021 and again from 2026 (P.L. 119-21, sec. 70108), and flow into interest_deduction. Empty inputs leave every output unchanged. Co-Authored-By: Claude Opus 5.5 --- ...mortgage-insurance-premium-inputs.added.md | 1 + .../in_effect.yaml | 27 ++++++++++++++ .../phase_out/increment.yaml | 22 ++++++++++++ .../phase_out/rate.yaml | 12 +++++++ .../phase_out/start.yaml | 22 ++++++++++++ .../tests/core/test_uprating_series.py | 2 ++ .../deductible_mortgage_insurance_premiums.py | 36 +++++++++++++++++++ .../itemizing/interest_deduction.py | 7 ++-- .../person/deductible_mortgage_interest.py | 8 ++--- .../person/home_mortgage_interest_share.py | 10 +++--- .../expense/person/home_mortgage_points.py | 23 ++++++++++++ .../person/mortgage_insurance_premiums.py | 22 ++++++++++++ .../expense/person/mortgage_interest.py | 4 +++ .../non_deductible_mortgage_interest.py | 4 +-- .../tax_unit/mortgage_interest_structure.py | 23 ++++++++---- 15 files changed, 204 insertions(+), 19 deletions(-) create mode 100644 changelog.d/points-mortgage-insurance-premium-inputs.added.md create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/in_effect.yaml create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/increment.yaml create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/rate.yaml create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/interest/mortgage_insurance_premiums/phase_out/start.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.py create mode 100644 policyengine_us/variables/household/expense/person/home_mortgage_points.py create mode 100644 policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py 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/tests/core/test_uprating_series.py b/policyengine_us/tests/core/test_uprating_series.py index 01b5a7e01b7..cf6f734f4a6 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -94,6 +94,8 @@ "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, "student_loan_interest": CPI_U, "investment_interest_expense": CPI_U, # Still on the default. 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..b5ce933e1bb --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.py @@ -0,0 +1,36 @@ +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"]) + 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/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_share.py b/policyengine_us/variables/household/expense/person/home_mortgage_interest_share.py index 7cb833e0b3f..a8d09e92a0b 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,16 @@ 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 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"] + ) 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..f821a68d4b5 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/home_mortgage_points.py @@ -0,0 +1,23 @@ +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 a qualified home 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." + ) + 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..3b7678afd36 --- /dev/null +++ b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py @@ -0,0 +1,22 @@ +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 a qualified home, 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." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", + "https://www.law.cornell.edu/uscode/text/26/163#h_4_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/tax_unit/mortgage_interest_structure.py b/policyengine_us/variables/household/expense/tax_unit/mortgage_interest_structure.py index fed7ca75751..35428367430 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 @@ -134,10 +134,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 +153,7 @@ 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"]) filing_status = tax_unit("filing_status", period) p = parameters(period).gov.irs.deductions.itemized.interest.mortgage @@ -179,7 +185,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 +197,12 @@ 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"]) 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) From 869c0bf6745bd80833772a3c96693659e0a88be1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 12:58:00 -0400 Subject: [PATCH 2/8] Test federal mortgage points and premium deductions Cover mortgage loan caps, person allocation, premium availability and AGI phase-out boundaries, and itemized taxable-income integration with hand-calculated IRS worksheet amounts. Co-Authored-By: GPT-6.1 Sol (subfleet lane) --- .../deductible_mortgage_interest.yaml | 136 ++++++++ ...eductible_mortgage_insurance_premiums.yaml | 310 ++++++++++++++++++ 2 files changed, 446 insertions(+) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/deductible_mortgage_insurance_premiums.yaml 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 From fe25c5276ad80b84528897cf9c8e1cdf69838f7b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 13:06:44 -0400 Subject: [PATCH 3/8] Test vectorized mortgage input invariants Use one simulation for 360 tax units in 2021, 2025, and 2026 to check conservation, bounds, interest-points transfers, an independent integer-cents premiums worksheet, AGI monotonicity, and zero-input compatibility. Reuse read-only calculation arrays instead of constructing models per property. Co-Authored-By: GPT-6.1 Sol (subfleet lane) --- ...mortgage_points_and_premiums_properties.py | 381 ++++++++++++++++++ 1 file changed, 381 insertions(+) create mode 100644 policyengine_us/tests/core/test_mortgage_points_and_premiums_properties.py 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) + ) From a322b233ed32218434aa8087f7f7a5637b2f7c07 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 15:47:17 -0400 Subject: [PATCH 4/8] Limit Kentucky qualified residence interest to the principal residence from 2026 KRS 141.019(2)(j), added by 2026 Ky. Acts ch. 161 (HB 757) sec. 7 and amended by ch. 198 (HB 869) sec. 48, limits the section 163(h)(3) deduction from TY2026 to interest on the principal residence and bars claiming it for more than one qualified residence. Kentucky itemized deductions inherit the federal interest deduction, which pools a second residence. Add three Person leaf inputs, disjoint from the principal-residence inputs: second_residence_mortgage_interest, second_residence_mortgage_points and second_residence_mortgage_insurance_premiums. They join the federal pool (interest and points under the acquisition-debt share, premiums under the AGI phase-out), so federal results do not depend on the split. second_residence_interest_deduction attributes the federal deduction to the second residence, and Kentucky subtracts it from 2026. Empty inputs change nothing. Co-Authored-By: Claude Opus 5.5 --- ...cipal-residence-mortgage-interest.added.md | 1 + .../principal_residence_interest_only.yaml | 17 + ...st_second_residence_mortgage_properties.py | 329 ++++++++++++++++++ .../tests/core/test_uprating_series.py | 3 + .../second_residence_interest_deduction.yaml | 126 +++++++ ..._disallowed_second_residence_interest.yaml | 26 ++ .../ky_itemized_deductions_unit.yaml | 76 ++++ .../deductible_mortgage_insurance_premiums.py | 9 +- .../second_residence_interest_deduction.py | 55 +++ ...ky_disallowed_second_residence_interest.py | 26 ++ .../deductions/ky_itemized_deductions_unit.py | 6 +- .../expense/person/home_mortgage_interest.py | 8 +- .../person/home_mortgage_interest_share.py | 13 +- .../expense/person/home_mortgage_points.py | 16 +- .../person/mortgage_insurance_premiums.py | 6 +- ...d_residence_mortgage_insurance_premiums.py | 21 ++ .../second_residence_mortgage_interest.py | 22 ++ .../second_residence_mortgage_points.py | 23 ++ .../tax_unit/mortgage_interest_structure.py | 28 +- 19 files changed, 789 insertions(+), 22 deletions(-) create mode 100644 changelog.d/ky-principal-residence-mortgage-interest.added.md create mode 100644 policyengine_us/parameters/gov/states/ky/tax/income/deductions/itemized/principal_residence_interest_only.yaml create mode 100644 policyengine_us/tests/core/test_second_residence_mortgage_properties.py create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py create mode 100644 policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.py create mode 100644 policyengine_us/variables/household/expense/person/second_residence_mortgage_insurance_premiums.py create mode 100644 policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py create mode 100644 policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py 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..44d83332d6b --- /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 limit Kentucky's qualified residence interest deduction to the principal residence from 2026 (KRS 141.019(2)(j), 2026 Ky. Acts ch. 161 sec. 7 as amended by ch. 198 sec. 48). 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_second_residence_mortgage_properties.py b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py new file mode 100644 index 00000000000..7f443eb737b --- /dev/null +++ b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py @@ -0,0 +1,329 @@ +"""Vectorized invariants for second-residence mortgage inputs and Kentucky. + +One module fixture builds one Simulation of Kentucky tax units in 2025 and +2026, in four paired scenarios with 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. + +Federal law pools both qualified residences (26 U.S.C. 163(h)(4)(A)), so the +federal deduction cannot depend on the residence split. KRS 141.019(2)(j) +limits Kentucky's deduction to the principal residence from 2026, so from +then on Kentucky itemized deductions cannot depend on second-residence +amounts at all. +""" + +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 = 48 +YEARS = (2025, 2026) +STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD") +SCENARIOS = ("base", "pooled", "principal_only", "omitted") +# 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", + "second_residence_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", +) +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) + first[::5] = 0 + second_balance[::3] = 0 + # 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), + } + + +def _person_inputs(draws, scenario, i, j): + 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 + + +@pytest.fixture(scope="module") +def results(): + draws = _draw() + people, tax_units, households = {}, {}, {} + units, persons, person_to_unit = {}, {}, [] + 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_to_unit.append(unit_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]) + ), + "first_home_mortgage_balance": annual( + float(draws["first_home_mortgage_balance"][i]) + ), + "second_home_mortgage_balance": annual( + float(draws["second_home_mortgage_balance"][i]) + ), + "first_home_mortgage_origination_year": annual( + int(draws["first_home_mortgage_origination_year"][i]) + ), + "second_home_mortgage_origination_year": annual( + int(draws["second_home_mortgage_origination_year"][i]) + ), + # 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 + # Read the acquisition-debt caps once, from this model's parameters. + mortgage = simulation.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.irs.deductions.itemized.interest.mortgage + statuses = draws["filing_status"] + values[year]["caps"] = ( + mortgage.pre_tcja_cap[statuses], + mortgage.cap[statuses], + mortgage.pre_tcja_origination_year, + ) + # 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()}, + np.array(person_to_unit), + ) + + +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_attribution_is_nonnegative_and_bounded(results): + _, values, _, _, _ = results + for year_values in values.values(): + attribution = year_values["second_residence_interest_deduction"] + assert np.all(attribution >= 0) + assert np.all(attribution <= year_values["interest_deduction"] + ATOL) + + +def test_attribution_equals_the_federal_deduction_it_adds(results): + # Differential check: given balances and AGI the federal deduction is + # linear in the amounts, so the attribution must equal the deduction + # with second-residence amounts minus the deduction without them. + _, values, units, _, _ = results + for year_values in values.values(): + deduction = year_values["interest_deduction"] + _close( + deduction[units["base"]] - deduction[units["principal_only"]], + year_values["second_residence_interest_deduction"][units["base"]], + ) + + +def test_attribution_matches_an_independent_computation(results): + draws, values, units, _, _ = results + statuses = draws["filing_status"] + for year, year_values in values.items(): + caps = [ + _mortgage_balance_cap( + draws[f"{position}_home_mortgage_origination_year"], + *year_values["caps"], + ) + 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 + ), + ) + # Premium worksheet: 10% per $1,000 ($500 separate), or fraction, + # above $100,000 ($50,000 separate); no premiums in 2022-2025. + separate = statuses == "SEPARATE" + start = np.where(separate, 50_000, 100_000) + increment = np.where(separate, 500, 1_000) + excess = np.maximum(0, draws["adjusted_gross_income"] - start) + kept = 1 - np.minimum(1, np.ceil(excess / increment) * 0.1) + kept = kept * (year != 2025) + interest_and_points = draws["second_residence_mortgage_interest"].sum( + axis=1 + ) + draws["second_residence_mortgage_points"].sum(axis=1) + premiums = draws["second_residence_mortgage_insurance_premiums"].sum(axis=1) + expected = interest_and_points * share + premiums * kept + _close( + values[year]["second_residence_interest_deduction"][units["base"]], + expected, + ) + + +def test_kentucky_limits_qualified_residence_interest_from_2026(results): + _, values, units, _, _ = results + before, after = values[2025], values[2026] + np.testing.assert_array_equal(before["ky_disallowed_second_residence_interest"], 0) + # 2025: second-residence amounts raise Kentucky itemized deductions + # one-for-one with the federal deduction they add. + _close( + before["ky_itemized_deductions_unit"][units["base"]] + - before["ky_itemized_deductions_unit"][units["principal_only"]], + before["second_residence_interest_deduction"][units["base"]], + ) + # 2026: Kentucky removes exactly the second-residence part... + _close( + after["ky_disallowed_second_residence_interest"], + after["second_residence_interest_deduction"], + ) + # ...so its itemized deductions no longer depend on second-residence + # amounts, while the federal deduction still does... + _close( + after["ky_itemized_deductions_unit"][units["base"]], + after["ky_itemized_deductions_unit"][units["principal_only"]], + ) + # ...and moving those amounts onto the principal residence restores them. + pooled_gain = ( + after["ky_itemized_deductions_unit"][units["pooled"]] + - after["ky_itemized_deductions_unit"][units["base"]] + ) + _close(pooled_gain, after["second_residence_interest_deduction"][units["base"]]) + assert np.any(after["second_residence_interest_deduction"][units["base"]] > 0) + + +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["second_residence_interest_deduction"][units["omitted"]], 0 + ) + 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 cf6f734f4a6..ee3dd6caa74 100644 --- a/policyengine_us/tests/core/test_uprating_series.py +++ b/policyengine_us/tests/core/test_uprating_series.py @@ -96,6 +96,9 @@ "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/itemizing/second_residence_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml new file mode 100644 index 00000000000..92abf867f43 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml @@ -0,0 +1,126 @@ +- 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: + # Interest on both residences: 10,000 + 4,000. + home_mortgage_interest_tax_unit: 14_000 + # No balances are given, so the pool is fully deductible: 14,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 + # Second residence: 4,000 + 500 + 600. + second_residence_interest_deduction: 5_100 + +- name: Second-residence interest takes the acquisition-debt share and premiums take 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. + # Pool: 30,000 + 2,000 + 12,000 = 44,000; 44,000 * 0.625 = 27,500. + deductible_mortgage_interest_tax_unit: 27_500 + # AGI exceeds $100,000 by $5,000: 5 increments * 10% = 50% reduction; 1,000 * 0.5. + deductible_mortgage_insurance_premiums: 500 + interest_deduction: 28_000 + # Second residence: 12,000 * 0.625 + 500 = 8,000. + second_residence_interest_deduction: 8_000 + +- name: Second-residence premiums share the phase-out with principal-residence premiums + 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 $1,000 increment, or fraction thereof: 10% reduction. + # (900 + 300) * 0.9 = 1,080. + deductible_mortgage_insurance_premiums: 1_080 + # 300 * 0.9 = 270. + second_residence_interest_deduction: 270 + +- 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 + second_residence_interest_deduction: 4_000 + +- name: No second-residence amounts attribute nothing to the second residence + period: 2026 + absolute_error_margin: 0 + input: + filing_status: SINGLE + adjusted_gross_income: 50_000 + home_mortgage_interest: 10_000 + home_mortgage_points: 500 + mortgage_insurance_premiums: 1_000 + output: + interest_deduction: 11_500 + second_residence_interest_deduction: 0 + +- name: Second-residence points join the legacy structured interest pool + period: 2026 + absolute_error_margin: 0.01 + input: + filing_status: SINGLE + adjusted_gross_income: 50_000 + first_home_mortgage_interest: 8_000 + second_residence_mortgage_points: 400 + output: + # No person-level interest, so the structured interest is used. + home_mortgage_interest_tax_unit: 8_000 + deductible_mortgage_interest_tax_unit: 8_400 + second_residence_interest_deduction: 400 + +- 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: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + adjusted_gross_income: 80_000 + households: + household: + members: [head, spouse] + output: + # 6,000 / (6,000 + 2,000) and 2,000 / (6,000 + 2,000). + home_mortgage_interest_share: [0.75, 0.25] + deductible_mortgage_interest: [6_000, 2_000] + second_residence_interest_deduction: 2_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..cc8347b6931 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.yaml @@ -0,0 +1,26 @@ +- name: Kentucky allows second-residence interest before 2026 + period: 2025 + absolute_error_margin: 0 + input: + state_code: KY + second_residence_interest_deduction: 5_100 + output: + ky_disallowed_second_residence_interest: 0 + +- name: Kentucky disallows second-residence interest from 2026 + period: 2026 + absolute_error_margin: 0 + input: + state_code: KY + second_residence_interest_deduction: 5_100 + output: + ky_disallowed_second_residence_interest: 5_100 + +- name: The Kentucky disallowance applies only to Kentucky + period: 2026 + absolute_error_margin: 0 + input: + state_code: OH + second_residence_interest_deduction: 5_100 + output: + ky_disallowed_second_residence_interest: 0 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..9be37cd0264 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,79 @@ 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 + # Second residence: 4,000 + 500 + 600. + ky_disallowed_second_residence_interest: 5_100 + # Principal residence: 10,000 + 1,000. + ky_itemized_deductions_unit: 11_000 + +- name: From 2026 Kentucky applies the federal acquisition-debt share to principal-residence interest + 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 + # Second residence: 12,000 * 0.625 + 500 = 8,000. + ky_disallowed_second_residence_interest: 8_000 + # Principal residence: (30,000 + 2,000) * 0.625 = 20,000. + ky_itemized_deductions_unit: 20_000 + +- 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 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 index b5ce933e1bb..291008442da 100644 --- 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 @@ -25,7 +25,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"]) + 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 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py new file mode 100644 index 00000000000..4080e4e8c09 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py @@ -0,0 +1,55 @@ +from policyengine_us.model_api import * + + +class second_residence_interest_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Interest deduction attributable to a second residence" + unit = USD + definition_period = YEAR + documentation = ( + "The part of the federal interest deduction that comes from home " + "mortgage interest, points and qualified mortgage insurance premiums " + "on the second qualified residence. Interest and points take the " + "deductible share of the pooled mortgage interest and points under " + "the acquisition-debt limit; premiums take the adjusted-gross-income " + "phase-out that applies to all premiums." + ) + reference = [ + "https://www.law.cornell.edu/uscode/text/26/163#h_3", + "https://www.law.cornell.edu/uscode/text/26/163#h_4_A", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=16", + ] + + def formula(tax_unit, period, parameters): + second_residence_pool = add( + tax_unit, + period, + ["second_residence_mortgage_interest", "second_residence_mortgage_points"], + ) + pool = tax_unit("home_mortgage_interest_tax_unit", period) + add( + tax_unit, + period, + ["home_mortgage_points", "second_residence_mortgage_points"], + ) + deductible_pool = tax_unit("deductible_mortgage_interest_tax_unit", period) + pool_share = np.divide( + second_residence_pool, + pool, + out=np.zeros_like(pool), + where=pool > 0, + ) + 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) + premium_share = np.divide( + second_residence_premiums, + premiums, + out=np.zeros_like(premiums), + where=premiums > 0, + ) + return deductible_pool * pool_share + deductible_premiums * premium_share 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..83a0657ba2b --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/tax/income/deductions/ky_disallowed_second_residence_interest.py @@ -0,0 +1,26 @@ +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 on a second residence, " + "including points and mortgage insurance premiums, that Kentucky " + "removes from itemized deductions once it limits the deduction to " + "the principal 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 + second_residence = tax_unit("second_residence_interest_deduction", period) + return second_residence * 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/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 a8d09e92a0b..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 @@ -8,14 +8,21 @@ class home_mortgage_interest_share(Variable): definition_period = YEAR documentation = ( "Allocates tax-unit mortgage interest and points across filers using " - "reported person-level home mortgage interest and points when " - "available, otherwise evenly across head and spouse." + "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 * add( - person, period, ["home_mortgage_interest", "home_mortgage_points"] + 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) diff --git a/policyengine_us/variables/household/expense/person/home_mortgage_points.py b/policyengine_us/variables/household/expense/person/home_mortgage_points.py index f821a68d4b5..46398f3bc36 100644 --- a/policyengine_us/variables/household/expense/person/home_mortgage_points.py +++ b/policyengine_us/variables/household/expense/person/home_mortgage_points.py @@ -9,13 +9,15 @@ class home_mortgage_points(Variable): definition_period = YEAR uprating = "gov.bls.cpi.cpi_u" documentation = ( - "Points on debt secured by a qualified home 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." + "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", diff --git a/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py index 3b7678afd36..2b2e4e8bf06 100644 --- a/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py @@ -12,9 +12,11 @@ class mortgage_insurance_premiums(Variable): "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 a qualified home, under a contract issued " + "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." + "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", 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..55e98842f28 --- /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_4_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..e968abc3a94 --- /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)(4)(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_4_A", + "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=3", + ] 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..ca04dc0bfd1 --- /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=6", + ] 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 35428367430..1b28c18c678 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,14 +111,20 @@ 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 are used only " + "when no person-level 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"]) + reported_interest = add( + tax_unit, + period, + ["home_mortgage_interest", "second_residence_mortgage_interest"], + ) structured_interest = add( tax_unit, period, @@ -153,7 +159,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"]) + 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 @@ -203,6 +213,10 @@ class non_deductible_mortgage_interest_tax_unit(Variable): def formula(tax_unit, period, parameters): total_interest = tax_unit("home_mortgage_interest_tax_unit", period) - points = add(tax_unit, period, ["home_mortgage_points"]) + 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 + points - deductible_interest) From ef0377336b26b96892d77f49061c76e618c43519 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 15:55:24 -0400 Subject: [PATCH 5/8] Cite the qualified residence definitions at 163(h)(5) P.L. 119-21 inserted the passenger-vehicle loan interest rule as section 163(h)(4), so the qualified residence and qualified mortgage insurance definitions are now in 163(h)(5)(A) and (E). Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_second_residence_mortgage_properties.py | 2 +- .../itemizing/second_residence_interest_deduction.py | 2 +- .../household/expense/person/mortgage_insurance_premiums.py | 2 +- .../person/second_residence_mortgage_insurance_premiums.py | 2 +- .../expense/person/second_residence_mortgage_interest.py | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/policyengine_us/tests/core/test_second_residence_mortgage_properties.py b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py index 7f443eb737b..1a1515a6e25 100644 --- a/policyengine_us/tests/core/test_second_residence_mortgage_properties.py +++ b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py @@ -8,7 +8,7 @@ - principal_only: second-residence inputs set to zero; - omitted: second-residence inputs left out entirely. -Federal law pools both qualified residences (26 U.S.C. 163(h)(4)(A)), so the +Federal law pools both qualified residences (26 U.S.C. 163(h)(5)(A)), so the federal deduction cannot depend on the residence split. KRS 141.019(2)(j) limits Kentucky's deduction to the principal residence from 2026, so from then on Kentucky itemized deductions cannot depend on second-residence diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py index 4080e4e8c09..87338913e60 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py @@ -17,7 +17,7 @@ class second_residence_interest_deduction(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/163#h_3", - "https://www.law.cornell.edu/uscode/text/26/163#h_4_A", + "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=16", ] diff --git a/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py index 2b2e4e8bf06..f38f418b345 100644 --- a/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py +++ b/policyengine_us/variables/household/expense/person/mortgage_insurance_premiums.py @@ -20,5 +20,5 @@ class mortgage_insurance_premiums(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", - "https://www.law.cornell.edu/uscode/text/26/163#h_4_E", + "https://www.law.cornell.edu/uscode/text/26/163#h_5_E", ] 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 index 55e98842f28..6ea02978d5b 100644 --- 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 @@ -17,5 +17,5 @@ class second_residence_mortgage_insurance_premiums(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/163#h_3_E", - "https://www.law.cornell.edu/uscode/text/26/163#h_4_A", + "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 index e968abc3a94..fbdd56ac31a 100644 --- a/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py @@ -12,11 +12,11 @@ class second_residence_mortgage_interest(Variable): "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)(4)(A)(i)(II). Do not include it in " + "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_4_A", + "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=3", ] From cf2d1f2338a916e988e325ae0a7ecdbc14e0394d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 16:39:10 -0400 Subject: [PATCH 6/8] Cap Kentucky qualified residence interest at principal-residence interest Review found that a pro-rata second-residence share understates Kentucky's deduction whenever combined debt exceeds the federal limit. KRS 141.019(2)(j) limits the federal deduction "to the amount of interest paid or accrued ... on the ... indebtedness of the principal residence", which is a ceiling. Kentucky now allows the lesser of the federal deductible interest and points and the principal residence's interest and points. It still drops second-residence premiums after the federal phase-out. With equal loan rates this matches a recomputation on the principal residence alone, and a property test checks that against the statutory limit. The deprecated structured interest inputs now stand in for principal-residence interest only, and second-residence interest is always added. Previously, reporting any second-residence interest dropped the structured amount. Drop the pro-rata second_residence_interest_deduction. Move the federal pooling cases to interest_deduction, home_mortgage_interest_tax_unit and home_mortgage_interest_share. Add Kentucky cases for a joint return, spouses filing separately, the structured fallback, and debt above the limit. Fix the Pub 936 page anchors. Co-Authored-By: Claude Opus 5.5 --- ...cipal-residence-mortgage-interest.added.md | 2 +- ...st_second_residence_mortgage_properties.py | 244 +++++++++--------- .../itemizing/interest_deduction.yaml | 66 +++++ .../second_residence_interest_deduction.yaml | 126 --------- ..._disallowed_second_residence_interest.yaml | 70 ++++- .../ky_itemized_deductions_indiv.yaml | 28 ++ .../ky_itemized_deductions_unit.yaml | 60 ++++- .../person/home_mortgage_interest_share.yaml | 24 ++ .../home_mortgage_interest_tax_unit.yaml | 28 ++ .../second_residence_interest_deduction.py | 55 ---- ...ky_disallowed_second_residence_interest.py | 57 +++- .../second_residence_mortgage_interest.py | 2 +- .../second_residence_mortgage_points.py | 2 +- .../tax_unit/mortgage_interest_structure.py | 29 ++- 14 files changed, 462 insertions(+), 331 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/interest_deduction.yaml delete mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/expense/person/home_mortgage_interest_share.yaml create mode 100644 policyengine_us/tests/policy/baseline/household/expense/tax_unit/home_mortgage_interest_tax_unit.yaml delete mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py diff --git a/changelog.d/ky-principal-residence-mortgage-interest.added.md b/changelog.d/ky-principal-residence-mortgage-interest.added.md index 44d83332d6b..2b8e94d7211 100644 --- a/changelog.d/ky-principal-residence-mortgage-interest.added.md +++ b/changelog.d/ky-principal-residence-mortgage-interest.added.md @@ -1 +1 @@ -Add `second_residence_mortgage_interest`, `second_residence_mortgage_points` and `second_residence_mortgage_insurance_premiums` inputs, which join the federal mortgage deduction, and limit Kentucky's qualified residence interest deduction to the principal residence from 2026 (KRS 141.019(2)(j), 2026 Ky. Acts ch. 161 sec. 7 as amended by ch. 198 sec. 48). +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/policyengine_us/tests/core/test_second_residence_mortgage_properties.py b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py index 1a1515a6e25..53a0e3b6e37 100644 --- a/policyengine_us/tests/core/test_second_residence_mortgage_properties.py +++ b/policyengine_us/tests/core/test_second_residence_mortgage_properties.py @@ -1,33 +1,32 @@ """Vectorized invariants for second-residence mortgage inputs and Kentucky. One module fixture builds one Simulation of Kentucky tax units in 2025 and -2026, in four paired scenarios with identical draws: +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. KRS 141.019(2)(j) -limits Kentucky's deduction to the principal residence from 2026, so from -then on Kentucky itemized deductions cannot depend on second-residence -amounts at all. +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 -from policyengine_us.variables.household.expense.tax_unit.mortgage_interest_structure import ( - _limited_mortgage_balance, - _mortgage_balance_cap, -) N = 48 YEARS = (2025, 2026) STATUSES = ("SINGLE", "JOINT", "SEPARATE", "HEAD_OF_HOUSEHOLD") -SCENARIOS = ("base", "pooled", "principal_only", "omitted") +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), @@ -44,7 +43,6 @@ "non_deductible_mortgage_interest_tax_unit", "deductible_mortgage_insurance_premiums", "interest_deduction", - "second_residence_interest_deduction", "itemized_deductions_less_salt", "ky_disallowed_second_residence_interest", "ky_itemized_deductions_unit", @@ -61,6 +59,10 @@ "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 @@ -88,8 +90,14 @@ def _draw(): amounts[second][::11] = 0 first = rng.integers(1, 1_500_001, N).astype(float) second_balance = rng.integers(1, 750_001, N).astype(float) - first[::5] = 0 - second_balance[::3] = 0 + # 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 { @@ -100,10 +108,24 @@ def _draw(): "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]) @@ -119,11 +141,30 @@ def _person_inputs(draws, scenario, i, j): 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, person_to_unit = {}, {}, [] + units, persons = {}, {} periods = tuple(map(str, YEARS)) def annual(value): @@ -139,7 +180,6 @@ def annual(value): for j in range(count): person_index = len(people) persons[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)} @@ -152,18 +192,10 @@ def annual(value): "adjusted_gross_income": annual( float(draws["adjusted_gross_income"][i]) ), - "first_home_mortgage_balance": annual( - float(draws["first_home_mortgage_balance"][i]) - ), - "second_home_mortgage_balance": annual( - float(draws["second_home_mortgage_balance"][i]) - ), - "first_home_mortgage_origination_year": annual( - int(draws["first_home_mortgage_origination_year"][i]) - ), - "second_home_mortgage_origination_year": annual( - int(draws["second_home_mortgage_origination_year"][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), @@ -183,28 +215,17 @@ def annual(value): array = simulation.calculate(name, str(year)).copy() array.flags.writeable = False values[year][name] = array - # Read the acquisition-debt caps once, from this model's parameters. - mortgage = simulation.tax_benefit_system.parameters( - f"{year}-01-01" - ).gov.irs.deductions.itemized.interest.mortgage - statuses = draws["filing_status"] - values[year]["caps"] = ( - mortgage.pre_tcja_cap[statuses], - mortgage.cap[statuses], - mortgage.pre_tcja_origination_year, - ) # 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()}, - np.array(person_to_unit), ) def test_residence_split_does_not_change_federal_amounts(results): - _, values, units, persons, _ = 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"]]) @@ -216,99 +237,85 @@ def test_residence_split_does_not_change_federal_amounts(results): ) -def test_attribution_is_nonnegative_and_bounded(results): - _, values, _, _, _ = results - for year_values in values.values(): - attribution = year_values["second_residence_interest_deduction"] - assert np.all(attribution >= 0) - assert np.all(attribution <= year_values["interest_deduction"] + ATOL) +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_attribution_equals_the_federal_deduction_it_adds(results): - # Differential check: given balances and AGI the federal deduction is - # linear in the amounts, so the attribution must equal the deduction - # with second-residence amounts minus the deduction without them. - _, values, units, _, _ = results - for year_values in values.values(): - deduction = year_values["interest_deduction"] - _close( - deduction[units["base"]] - deduction[units["principal_only"]], - year_values["second_residence_interest_deduction"][units["base"]], - ) +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_attribution_matches_an_independent_computation(results): - draws, values, units, _, _ = results - statuses = draws["filing_status"] - for year, year_values in values.items(): - caps = [ - _mortgage_balance_cap( - draws[f"{position}_home_mortgage_origination_year"], - *year_values["caps"], - ) - 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 - ), - ) - # Premium worksheet: 10% per $1,000 ($500 separate), or fraction, - # above $100,000 ($50,000 separate); no premiums in 2022-2025. - separate = statuses == "SEPARATE" - start = np.where(separate, 50_000, 100_000) - increment = np.where(separate, 500, 1_000) - excess = np.maximum(0, draws["adjusted_gross_income"] - start) - kept = 1 - np.minimum(1, np.ceil(excess / increment) * 0.1) - kept = kept * (year != 2025) - interest_and_points = draws["second_residence_mortgage_interest"].sum( - axis=1 - ) + draws["second_residence_mortgage_points"].sum(axis=1) - premiums = draws["second_residence_mortgage_insurance_premiums"].sum(axis=1) - expected = interest_and_points * share + premiums * kept - _close( - values[year]["second_residence_interest_deduction"][units["base"]], - expected, - ) +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_limits_qualified_residence_interest_from_2026(results): - _, values, units, _, _ = results - before, after = values[2025], values[2026] - np.testing.assert_array_equal(before["ky_disallowed_second_residence_interest"], 0) - # 2025: second-residence amounts raise Kentucky itemized deductions - # one-for-one with the federal deduction they add. - _close( - before["ky_itemized_deductions_unit"][units["base"]] - - before["ky_itemized_deductions_unit"][units["principal_only"]], - before["second_residence_interest_deduction"][units["base"]], +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"] + ] ) - # 2026: Kentucky removes exactly the second-residence part... + rate = draws["equal_rate"] + principal, second = draws["equal_principal"], draws["equal_second"] + index = units["equal_rate"] + year_values = values[2026] _close( - after["ky_disallowed_second_residence_interest"], - after["second_residence_interest_deduction"], + year_values["interest_deduction"][index], + rate * np.minimum(limit, principal + second), ) - # ...so its itemized deductions no longer depend on second-residence - # amounts, while the federal deduction still does... _close( - after["ky_itemized_deductions_unit"][units["base"]], - after["ky_itemized_deductions_unit"][units["principal_only"]], + year_values["interest_deduction"][index] + - year_values["ky_disallowed_second_residence_interest"][index], + rate * np.minimum(limit, principal), ) - # ...and moving those amounts onto the principal residence restores them. - pooled_gain = ( - after["ky_itemized_deductions_unit"][units["pooled"]] - - after["ky_itemized_deductions_unit"][units["base"]] + # 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"]], ) - _close(pooled_gain, after["second_residence_interest_deduction"][units["base"]]) - assert np.any(after["second_residence_interest_deduction"][units["base"]] > 0) def test_omitted_and_zero_second_residence_inputs_match(results): - _, values, units, persons, _ = results + _, values, units, persons = results for year_values in values.values(): for name in UNIT_OUTPUTS: np.testing.assert_array_equal( @@ -320,9 +327,6 @@ def test_omitted_and_zero_second_residence_inputs_match(results): year_values[name][persons["principal_only"]], year_values[name][persons["omitted"]], ) - np.testing.assert_array_equal( - year_values["second_residence_interest_deduction"][units["omitted"]], 0 - ) np.testing.assert_array_equal( year_values["ky_disallowed_second_residence_interest"][units["omitted"]], 0, 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/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml deleted file mode 100644 index 92abf867f43..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.yaml +++ /dev/null @@ -1,126 +0,0 @@ -- 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: - # Interest on both residences: 10,000 + 4,000. - home_mortgage_interest_tax_unit: 14_000 - # No balances are given, so the pool is fully deductible: 14,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 - # Second residence: 4,000 + 500 + 600. - second_residence_interest_deduction: 5_100 - -- name: Second-residence interest takes the acquisition-debt share and premiums take 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. - # Pool: 30,000 + 2,000 + 12,000 = 44,000; 44,000 * 0.625 = 27,500. - deductible_mortgage_interest_tax_unit: 27_500 - # AGI exceeds $100,000 by $5,000: 5 increments * 10% = 50% reduction; 1,000 * 0.5. - deductible_mortgage_insurance_premiums: 500 - interest_deduction: 28_000 - # Second residence: 12,000 * 0.625 + 500 = 8,000. - second_residence_interest_deduction: 8_000 - -- name: Second-residence premiums share the phase-out with principal-residence premiums - 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 $1,000 increment, or fraction thereof: 10% reduction. - # (900 + 300) * 0.9 = 1,080. - deductible_mortgage_insurance_premiums: 1_080 - # 300 * 0.9 = 270. - second_residence_interest_deduction: 270 - -- 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 - second_residence_interest_deduction: 4_000 - -- name: No second-residence amounts attribute nothing to the second residence - period: 2026 - absolute_error_margin: 0 - input: - filing_status: SINGLE - adjusted_gross_income: 50_000 - home_mortgage_interest: 10_000 - home_mortgage_points: 500 - mortgage_insurance_premiums: 1_000 - output: - interest_deduction: 11_500 - second_residence_interest_deduction: 0 - -- name: Second-residence points join the legacy structured interest pool - period: 2026 - absolute_error_margin: 0.01 - input: - filing_status: SINGLE - adjusted_gross_income: 50_000 - first_home_mortgage_interest: 8_000 - second_residence_mortgage_points: 400 - output: - # No person-level interest, so the structured interest is used. - home_mortgage_interest_tax_unit: 8_000 - deductible_mortgage_interest_tax_unit: 8_400 - second_residence_interest_deduction: 400 - -- 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: 2_000 - tax_units: - tax_unit: - members: [head, spouse] - filing_status: JOINT - adjusted_gross_income: 80_000 - households: - household: - members: [head, spouse] - output: - # 6,000 / (6,000 + 2,000) and 2,000 / (6,000 + 2,000). - home_mortgage_interest_share: [0.75, 0.25] - deductible_mortgage_interest: [6_000, 2_000] - second_residence_interest_deduction: 2_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 index cc8347b6931..afa3aaafb20 100644 --- 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 @@ -3,24 +3,84 @@ absolute_error_margin: 0 input: state_code: KY - second_residence_interest_deduction: 5_100 + 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 + absolute_error_margin: 0.01 input: state_code: KY - second_residence_interest_deduction: 5_100 + 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: 5_100 + # 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 - second_residence_interest_deduction: 5_100 + 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 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 9be37cd0264..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 @@ -51,12 +51,35 @@ output: # Federal: 10,000 + 4,000 + 500 + 1,000 + 600. itemized_deductions_less_salt: 16_100 - # Second residence: 4,000 + 500 + 600. + # 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 applies the federal acquisition-debt share to principal-residence interest +- 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: @@ -74,10 +97,10 @@ output: # Federal: (30,000 + 2,000 + 12,000) * 0.625 + 1,000 * 0.5 = 28,000. itemized_deductions_less_salt: 28_000 - # Second residence: 12,000 * 0.625 + 500 = 8,000. - ky_disallowed_second_residence_interest: 8_000 - # Principal residence: (30,000 + 2,000) * 0.625 = 20,000. - ky_itemized_deductions_unit: 20_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 @@ -93,3 +116,28 @@ 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/second_residence_interest_deduction.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py deleted file mode 100644 index 87338913e60..00000000000 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/second_residence_interest_deduction.py +++ /dev/null @@ -1,55 +0,0 @@ -from policyengine_us.model_api import * - - -class second_residence_interest_deduction(Variable): - value_type = float - entity = TaxUnit - label = "Interest deduction attributable to a second residence" - unit = USD - definition_period = YEAR - documentation = ( - "The part of the federal interest deduction that comes from home " - "mortgage interest, points and qualified mortgage insurance premiums " - "on the second qualified residence. Interest and points take the " - "deductible share of the pooled mortgage interest and points under " - "the acquisition-debt limit; premiums take the adjusted-gross-income " - "phase-out that applies to all premiums." - ) - reference = [ - "https://www.law.cornell.edu/uscode/text/26/163#h_3", - "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", - "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=16", - ] - - def formula(tax_unit, period, parameters): - second_residence_pool = add( - tax_unit, - period, - ["second_residence_mortgage_interest", "second_residence_mortgage_points"], - ) - pool = tax_unit("home_mortgage_interest_tax_unit", period) + add( - tax_unit, - period, - ["home_mortgage_points", "second_residence_mortgage_points"], - ) - deductible_pool = tax_unit("deductible_mortgage_interest_tax_unit", period) - pool_share = np.divide( - second_residence_pool, - pool, - out=np.zeros_like(pool), - where=pool > 0, - ) - 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) - premium_share = np.divide( - second_residence_premiums, - premiums, - out=np.zeros_like(premiums), - where=premiums > 0, - ) - return deductible_pool * pool_share + deductible_premiums * premium_share 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 index 83a0657ba2b..abbffb85c9e 100644 --- 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 @@ -8,10 +8,18 @@ class ky_disallowed_second_residence_interest(Variable): unit = USD definition_period = YEAR documentation = ( - "Federal qualified residence interest on a second residence, " - "including points and mortgage insurance premiums, that Kentucky " - "removes from itemized deductions once it limits the deduction to " - "the principal residence." + "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. When the " + "combined debt is within the federal limit, or both loans carry the " + "same rate, the cap equals a recomputation on the principal " + "residence alone. 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) @@ -22,5 +30,42 @@ class ky_disallowed_second_residence_interest(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states.ky.tax.income.deductions.itemized - second_residence = tax_unit("second_residence_interest_deduction", period) - return second_residence * p.principal_residence_interest_only + # 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/household/expense/person/second_residence_mortgage_interest.py b/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py index fbdd56ac31a..1bc46d2db1a 100644 --- a/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_interest.py @@ -18,5 +18,5 @@ class second_residence_mortgage_interest(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/163#h_5_A", - "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=3", + "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 index ca04dc0bfd1..33b66cd6542 100644 --- a/policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py +++ b/policyengine_us/variables/household/expense/person/second_residence_mortgage_points.py @@ -19,5 +19,5 @@ class second_residence_mortgage_points(Variable): ) reference = [ "https://www.law.cornell.edu/uscode/text/26/461#g", - "https://www.irs.gov/pub/irs-pdf/p936.pdf#page=6", + "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 1b28c18c678..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 @@ -113,24 +113,33 @@ class home_mortgage_interest_tax_unit(Variable): documentation = ( "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 are used only " - "when no person-level interest is reported (existing datasets still " - "supply them — see issue #9275)." + "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", "second_residence_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): From c42e0e7bf8771895b15837a1a0dcf0eb708dd81c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 17:09:24 -0400 Subject: [PATCH 7/8] Qualify the Kentucky cap's equality claim and restore a fallback case The cap equals a principal-residence-only recomputation when 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. Equal rates alone are not enough. Restore the YAML case with legacy structured interest and second-residence points but no second-residence interest, which was lost when the federal cases moved. Co-Authored-By: Claude Opus 5.5 --- .../ky_disallowed_second_residence_interest.yaml | 16 ++++++++++++++++ .../ky_disallowed_second_residence_interest.py | 13 +++++++------ 2 files changed, 23 insertions(+), 6 deletions(-) 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 index afa3aaafb20..dca4266b73c 100644 --- 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 @@ -84,3 +84,19 @@ # (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 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 index abbffb85c9e..c990c2616b1 100644 --- 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 @@ -14,12 +14,13 @@ class ky_disallowed_second_residence_interest(Variable): "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. When the " - "combined debt is within the federal limit, or both loans carry the " - "same rate, the cap equals a recomputation on the principal " - "residence alone. Otherwise it can differ in either direction, " - "because the model does not record which loan secures which " - "residence." + "second residence are removed after the federal phase-out. The cap " + "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) From f734479be8cb754353256c5dccb48f86c9fa231a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 17:17:57 -0400 Subject: [PATCH 8/8] Say the capped deduction, not the cap, matches the recomputation Also add a Kentucky case with debt over the federal limit that shows legacy structured interest counting toward the principal-residence cap: the federal 6,000 stays within the structured 8,000, so nothing is disallowed. Co-Authored-By: Claude Opus 5.5 --- ..._disallowed_second_residence_interest.yaml | 22 +++++++++++++++++++ ...ky_disallowed_second_residence_interest.py | 14 ++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) 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 index dca4266b73c..9c31e1d9b03 100644 --- 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 @@ -100,3 +100,25 @@ 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/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 index c990c2616b1..676178333db 100644 --- 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 @@ -14,13 +14,13 @@ class ky_disallowed_second_residence_interest(Variable): "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 cap " - "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." + "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)