diff --git a/changelog.d/nc-mortgage-property-cap-mfs.fixed.md b/changelog.d/nc-mortgage-property-cap-mfs.fixed.md new file mode 100644 index 00000000000..7ac5d07fdfc --- /dev/null +++ b/changelog.d/nc-mortgage-property-cap-mfs.fixed.md @@ -0,0 +1 @@ +North Carolina's $20,000 cap on mortgage interest and real estate taxes is now shared by spouses filing separately, including a spouse in another state, prorated by the share each paid or, for joint-account payments, by each spouse's income (G.S. 105-153.5(a)(2)b). diff --git a/policyengine_us/tests/core/test_nc_mortgage_and_property_tax_limitation_invariants.py b/policyengine_us/tests/core/test_nc_mortgage_and_property_tax_limitation_invariants.py new file mode 100644 index 00000000000..e8cffe9a9a4 --- /dev/null +++ b/policyengine_us/tests/core/test_nc_mortgage_and_property_tax_limitation_invariants.py @@ -0,0 +1,261 @@ +"""Invariants of North Carolina's $20,000 mortgage and property tax limitation. + +N.C. Gen. Stat. 105-153.5(a)(2)b caps home mortgage interest plus real estate +taxes at $20,000, "claimed by both spouses combined" when spouses file +separately. Above $20,000 the deductions are "prorated based on the percentage +paid by each spouse", or, "for joint obligations paid from joint accounts", +"based on the income reported by each spouse". Form D-400 Schedule A applies +this as line 4 (the limitation) and line 5 (the smaller of line 3 and line 4). + +Generated situations use nonnegative line 3, valid tax-unit heads, explicit +marital units, and NC households. They include separate couples with and +without a joint account, joint returns, single filers, a lone separate filer, +and a separate return with a dependent. YAML can represent mixed vectorized +populations; Python adds randomized generation, shrinking, and dynamic +comparisons after swapping spouse labels or raising payments. Hypothesis +draws six batches of 5 to 25 records, choosing 2024 or 2026 for each; a seeded +population of 120 records runs in both years. Line 3 and AGI are inputs, so +these tests do not cover the federal line-3 calculation. Within this domain: + +1. Differential: lines 4 and 5 equal an independent restatement of the + statute, with the $20,000 taken from the statute rather than the + parameter files. In these fixtures, two separate heads in an explicit + two-person marital unit are spouses. The paid share is each spouse's line 3 + over the couple's total; + the joint-account share is each spouse's federal AGI, not below zero, over + the couple's, or one half when neither spouse has any. +2. Bounds: 0 <= line 5 <= line 3, and a couple's two lines 5 never exceed + $20,000. Without a joint account they add up to the smaller of the + couple's line 3 total and $20,000. +3. Label invariance: listing a couple's spouses in the other order gives each + spouse the same result. +4. Monotone (without a joint account): raising one spouse's payments never + lowers that spouse's deduction or the couple's total, and never raises the + other spouse's deduction. With a joint account the form can lower the + total: just above $20,000 each spouse gets the smaller of their own line 3 + and their income share of $20,000. That is intended under the Schedule A + reading and not checked here. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +CAP = 20_000 # G.S. 105-153.5(a)(2)b +TOLERANCE = 0.01 # dollars +YEARS = ["2024", "2026"] + +amounts = st.one_of( + st.just(0.0), + st.integers(1, 12_000).map(float), + st.integers(12_000, 45_000).map(float), +) +incomes = st.one_of( + st.just(0.0), + st.integers(-30_000, -1).map(float), + st.integers(1, 300_000).map(float), +) +increases = st.integers(0, 20_000).map(float) +KINDS = ["separate", "joint", "single", "lone_separate", "separate_dependent"] + + +@st.composite +def returns(draw): + kind = draw(st.sampled_from(KINDS)) + spouses = 2 if kind in ("separate", "separate_dependent") else 1 + return { + "kind": kind, + "line3": [draw(amounts) for _ in range(spouses)], + "agi": [draw(incomes) for _ in range(spouses)], + "increase": draw(increases), + "joint_account": draw(st.booleans()) and spouses == 2, + } + + +def _seeded_returns(n=120, seed=20261009): + rng = np.random.default_rng(seed) + drawn = [] + for _ in range(n): + kind = KINDS[rng.integers(len(KINDS))] + spouses = 2 if kind in ("separate", "separate_dependent") else 1 + drawn.append( + { + "kind": kind, + "line3": [ + float(rng.choice([0, rng.integers(1, 45_001)])) + for _ in range(spouses) + ], + "agi": [float(rng.integers(-30_000, 300_001)) for _ in range(spouses)], + "increase": float(rng.integers(0, 20_001)), + "joint_account": bool(rng.random() < 0.5) and spouses == 2, + } + ) + return drawn + + +def _situation(drawn, year, *, swap=False, raise_first=False): + """One tax unit per return, or two for spouses filing separately.""" + people, tax_units, marital_units, households = {}, {}, {}, {} + rows = [] # (return index, spouse index) per tax unit, in order + for i, r in enumerate(drawn): + order = [1, 0] if swap and len(r["line3"]) == 2 else [0, 1] + line3 = list(r["line3"]) + if raise_first: + line3[0] += r["increase"] + names = [] + if r["kind"] == "joint": + names = [f"r{i}_head", f"r{i}_spouse"] + people[names[0]] = {"age": {year: 45}} + people[names[1]] = {"age": {year: 43}} + tax_units[f"t{i}"] = { + "members": names, + "filing_status": {year: "JOINT"}, + "nc_mortgage_and_property_tax_before_limitation": {year: line3[0]}, + "adjusted_gross_income": {year: r["agi"][0]}, + } + marital_units[f"m{i}"] = {"members": names} + rows.append((i, 0)) + elif r["kind"] in ("single", "lone_separate"): + names = [f"r{i}_head"] + people[names[0]] = {"age": {year: 45}} + tax_units[f"t{i}"] = { + "members": names, + "filing_status": { + year: "SINGLE" if r["kind"] == "single" else "SEPARATE" + }, + "nc_mortgage_and_property_tax_before_limitation": {year: line3[0]}, + "adjusted_gross_income": {year: r["agi"][0]}, + } + marital_units[f"m{i}"] = {"members": names} + rows.append((i, 0)) + else: + couple = [] + for k in order: + name = f"r{i}_spouse{k}" + couple.append(name) + members = [name] + people[name] = {"age": {year: 45}} + if r["kind"] == "separate_dependent" and k == 0: + child = f"r{i}_child" + people[child] = { + "age": {year: 10}, + "is_tax_unit_dependent": {year: True}, + } + members.append(child) + marital_units[f"m{i}_child"] = {"members": [child]} + names.append(child) + tax_units[f"t{i}_{k}"] = { + "members": members, + "filing_status": {year: "SEPARATE"}, + "nc_mortgage_and_property_tax_before_limitation": {year: line3[k]}, + "adjusted_gross_income": {year: r["agi"][k]}, + } + rows.append((i, k)) + marital_units[f"m{i}"] = { + "members": couple, + "mortgage_and_real_estate_taxes_paid_from_joint_account": { + year: r["joint_account"] + }, + } + names += couple + households[f"h{i}"] = {"members": names, "state_code": {year: "NC"}} + situation = { + "people": people, + "tax_units": tax_units, + "marital_units": marital_units, + "households": households, + } + return Simulation(situation=situation), rows + + +def _expected(drawn, raise_first=False): + """Lines 4 and 5 per (return, spouse), restated from the statute.""" + limitation, deduction = {}, {} + for i, r in enumerate(drawn): + line3 = list(r["line3"]) + if raise_first: + line3[0] += r["increase"] + if len(line3) == 1: + limitation[i, 0] = CAP + deduction[i, 0] = min(line3[0], CAP) + continue + combined = sum(line3) + if combined <= CAP: + shares = None + elif r["joint_account"]: + income = [max(a, 0) for a in r["agi"]] + total = sum(income) + shares = [a / total for a in income] if total > 0 else [0.5, 0.5] + else: + shares = [a / combined for a in line3] + for k in range(2): + limitation[i, k] = CAP if shares is None else CAP * shares[k] + deduction[i, k] = min(line3[k], limitation[i, k]) + return limitation, deduction + + +def _results(drawn, year, **kwargs): + simulation, rows = _situation(drawn, year, **kwargs) + line4 = simulation.calculate("nc_mortgage_and_property_tax_limitation", year) + line5 = simulation.calculate("nc_mortgage_and_property_tax_deduction", year) + return ( + {row: float(v) for row, v in zip(rows, line4)}, + {row: float(v) for row, v in zip(rows, line5)}, + ) + + +def _check(drawn, year): + line3 = {(i, k): a for i, r in enumerate(drawn) for k, a in enumerate(r["line3"])} + expected4, expected5 = _expected(drawn) + line4, line5 = _results(drawn, year) + + # 1. Differential against the restated statute. + for row in expected4: + assert abs(line4[row] - expected4[row]) < TOLERANCE, (row, drawn[row[0]]) + assert abs(line5[row] - expected5[row]) < TOLERANCE, (row, drawn[row[0]]) + + # 2. Bounds, and the couple's total. + for row, value in line5.items(): + assert -TOLERANCE < value < line3[row] + TOLERANCE + for i, r in enumerate(drawn): + if len(r["line3"]) == 2: + total = line5[i, 0] + line5[i, 1] + assert total < CAP + TOLERANCE + if not r["joint_account"]: + assert abs(total - min(sum(r["line3"]), CAP)) < TOLERANCE + + # 3. The order in which the spouses are listed does not matter. + swapped4, swapped5 = _results(drawn, year, swap=True) + for row in line5: + assert abs(swapped4[row] - line4[row]) < TOLERANCE + assert abs(swapped5[row] - line5[row]) < TOLERANCE + + # 4. Paying more never lowers the payer's deduction or the couple's total + # without a joint account. + raised4, raised5 = _results(drawn, year, raise_first=True) + expected_raised4, expected_raised5 = _expected(drawn, raise_first=True) + for row in expected_raised5: + assert abs(raised4[row] - expected_raised4[row]) < TOLERANCE + assert abs(raised5[row] - expected_raised5[row]) < TOLERANCE + for i, r in enumerate(drawn): + if len(r["line3"]) == 2 and not r["joint_account"]: + assert raised5[i, 0] > line5[i, 0] - TOLERANCE + assert raised5[i, 1] < line5[i, 1] + TOLERANCE + assert raised5[i, 0] + raised5[i, 1] > line5[i, 0] + line5[i, 1] - TOLERANCE + + +@settings( + max_examples=6, + deadline=None, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(drawn=st.lists(returns(), min_size=5, max_size=25), year=st.sampled_from(YEARS)) +def test_nc_mortgage_and_property_tax_limitation_invariants(drawn, year): + _check(drawn, year) + + +def test_nc_mortgage_and_property_tax_limitation_seeded_population(): + for year in YEARS: + _check(_seeded_returns(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml index eb8ccc0a96c..2ec2b5c8876 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml @@ -46,6 +46,43 @@ output: nc_itemized_deductions: 14_000 +- name: Spouses filing separately share the $20,000 mortgage and property tax limitation + period: 2024 + input: + people: + spouse1: + age: 45 + employment_income: 90_000 + home_mortgage_interest: 15_000 + spouse2: + age: 45 + employment_income: 90_000 + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + charitable_deduction: 5_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + charitable_deduction: 5_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # G.S. 105-153.5(a)(2)b: $20,000 for both spouses combined, prorated by + # the 50% each paid, so $10,000 each, plus $5,000 of charity. + nc_itemized_deductions: [15_000, 15_000] + # $15,000 exceeds the $12,750 standard deduction for a separate return. + nc_taxable_income: [75_000, 75_000] + # 4.5% in 2024. + nc_income_tax: [3_375, 3_375] + - name: North Carolina follows the federal exclusion for self-employed insurance premiums period: 2025 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.yaml new file mode 100644 index 00000000000..482f949d18b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.yaml @@ -0,0 +1,32 @@ +# Form D-400 Schedule A line 3: home mortgage interest (line 1) plus real +# estate property taxes (line 2), G.S. 105-153.5(a)(2)b. + +- name: Mortgage interest plus real estate taxes within the federal cap + period: 2024 + input: + filing_status: JOINT + mortgage_interest: 9_000 + real_estate_taxes: 4_000 + state_code: NC + output: + nc_mortgage_and_property_tax_before_limitation: 13_000 + +- name: Real estate taxes above the federal cap count only up to the cap + period: 2024 + input: + filing_status: SEPARATE + mortgage_interest: 9_000 + real_estate_taxes: 11_000 + state_code: NC + output: + # Section 164 allows $5,000 of these taxes on a separate return. + nc_mortgage_and_property_tax_before_limitation: 14_000 + +- name: The amount before limitation is not capped at $20,000 + period: 2024 + input: + filing_status: SINGLE + mortgage_interest: 25_000 + state_code: NC + output: + nc_mortgage_and_property_tax_before_limitation: 25_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.yaml new file mode 100644 index 00000000000..b22505ec460 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.yaml @@ -0,0 +1,334 @@ +# Form D-400 Schedule A line 5: "Compare Line 3 with Line 4 and enter the +# smaller amount." Line 3 is home mortgage interest plus real estate property +# taxes; line 4 is the $20,000 limitation, shared by spouses filing separately +# (G.S. 105-153.5(a)(2)b). Spouses filing separately are two tax units linked +# by an explicit marital unit. + +- name: A single filer's deduction is capped at $20,000 + period: 2024 + input: + filing_status: SINGLE + nc_mortgage_and_property_tax_before_limitation: 25_000 + state_code: NC + output: + nc_mortgage_and_property_tax_deduction: 20_000 + +- name: Spouses filing separately who each paid $15,000 of mortgage interest deduct $10,000 each + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 15_000 + spouse2: + age: 45 + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $30,000 combined; each paid half of the $20,000 the spouses may claim. + nc_mortgage_and_property_tax_before_limitation: [15_000, 15_000] + nc_mortgage_and_property_tax_deduction: [10_000, 10_000] + +- name: Spouses filing separately with unequal payments deduct their prorated shares of $20,000 + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 18_000 + spouse2: + age: 45 + home_mortgage_interest: 2_000 + real_estate_taxes: 4_000 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $18,000 and $6,000 paid, $24,000 combined: 75% and 25% of $20,000. + nc_mortgage_and_property_tax_before_limitation: [18_000, 6_000] + nc_mortgage_and_property_tax_deduction: [15_000, 5_000] + +- name: Spouses filing separately below $20,000 combined each deduct what they paid + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 8_000 + spouse2: + age: 45 + home_mortgage_interest: 7_000 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + nc_mortgage_and_property_tax_deduction: [8_000, 7_000] + +- name: Joint obligations paid from a joint account are prorated by income + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 15_000 + spouse2: + age: 45 + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + adjusted_gross_income: 120_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + adjusted_gross_income: 40_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Line 4 is 75% and 25% of $20,000: $15,000 and $5,000. Line 5 is the + # smaller of line 3 ($15,000 each) and line 4. + nc_mortgage_and_property_tax_limitation: [15_000, 5_000] + nc_mortgage_and_property_tax_deduction: [15_000, 5_000] + +- name: A joint-account income share above a spouse's own amount leaves line 5 at line 3 + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 15_000 + spouse2: + age: 45 + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + adjusted_gross_income: 90_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + adjusted_gross_income: 10_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Line 4 is 90% and 10% of $20,000: $18,000 and $2,000. Line 5 takes the + # smaller of each spouse's line 3 ($15,000) and line 4. + nc_mortgage_and_property_tax_limitation: [18_000, 2_000] + nc_mortgage_and_property_tax_deduction: [15_000, 2_000] + +- name: Spouses in NC and VA paying $15,000 and $15,000 from a joint account share the cap + period: 2024 + input: + people: + nc_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + va_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [nc_spouse, va_spouse] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + nc_tax_unit: + members: [nc_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + va_tax_unit: + members: [va_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + households: + nc_household: + members: [nc_spouse] + state_code: NC + va_household: + members: [va_spouse] + state_code: VA + output: + # $15,000 + $15,000 = $30,000 exceeds $20,000. Equal incomes give + # $20,000 x $50,000 / $100,000 = $10,000; line 5 is + # min($15,000, $10,000) = $10,000 for the NC spouse. + # The VA spouse's line 3 counts in the allocation; NC lines 4 and 5 + # remain zero for their own return. + nc_mortgage_and_property_tax_before_limitation: [15_000, 15_000] + nc_mortgage_and_property_tax_limitation: [10_000, 0] + nc_mortgage_and_property_tax_deduction: [10_000, 0] + +- name: Spouses in NC and VA paying $25,000 and $15,000 from a joint account share the cap + period: 2024 + input: + people: + nc_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 25_000 + va_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [nc_spouse, va_spouse] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + nc_tax_unit: + members: [nc_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + va_tax_unit: + members: [va_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + households: + nc_household: + members: [nc_spouse] + state_code: NC + va_household: + members: [va_spouse] + state_code: VA + output: + # $25,000 + $15,000 = $40,000 exceeds $20,000. Equal incomes give + # $20,000 x $50,000 / $100,000 = $10,000; line 5 is + # min($25,000, $10,000) = $10,000 for the NC spouse. + # The VA spouse's line 3 counts in the allocation; NC lines 4 and 5 + # remain zero for their own return. + nc_mortgage_and_property_tax_before_limitation: [25_000, 15_000] + nc_mortgage_and_property_tax_limitation: [10_000, 0] + nc_mortgage_and_property_tax_deduction: [10_000, 0] + +- name: Spouses in NC and VA paying $15,000 and $15,000 from separate accounts share the cap + period: 2024 + input: + people: + nc_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + va_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [nc_spouse, va_spouse] + mortgage_and_real_estate_taxes_paid_from_joint_account: false + tax_units: + nc_tax_unit: + members: [nc_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + va_tax_unit: + members: [va_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + households: + nc_household: + members: [nc_spouse] + state_code: NC + va_household: + members: [va_spouse] + state_code: VA + output: + # $15,000 + $15,000 = $30,000 exceeds $20,000. The NC paid share is + # $15,000 / $30,000, so $20,000 x 15/30 = $10,000; line 5 is + # min($15,000, $10,000) = $10,000 for the NC spouse. + # The VA spouse's line 3 counts in the allocation; NC lines 4 and 5 + # remain zero for their own return. + nc_mortgage_and_property_tax_before_limitation: [15_000, 15_000] + nc_mortgage_and_property_tax_limitation: [10_000, 0] + nc_mortgage_and_property_tax_deduction: [10_000, 0] + +- name: Spouses in NC and VA paying $25,000 and $15,000 from separate accounts share the cap + period: 2024 + input: + people: + nc_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 25_000 + va_spouse: + age: 45 + is_tax_unit_head: true + home_mortgage_interest: 15_000 + marital_units: + couple: + members: [nc_spouse, va_spouse] + mortgage_and_real_estate_taxes_paid_from_joint_account: false + tax_units: + nc_tax_unit: + members: [nc_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + va_tax_unit: + members: [va_spouse] + filing_status: SEPARATE + adjusted_gross_income: 50_000 + households: + nc_household: + members: [nc_spouse] + state_code: NC + va_household: + members: [va_spouse] + state_code: VA + output: + # $25,000 + $15,000 = $40,000 exceeds $20,000. The NC paid share is + # $25,000 / $40,000, so $20,000 x 25/40 = $12,500; line 5 is + # min($25,000, $12,500) = $12,500 for the NC spouse. + # The VA spouse's line 3 counts in the allocation; NC lines 4 and 5 + # remain zero for their own return. + nc_mortgage_and_property_tax_before_limitation: [25_000, 15_000] + nc_mortgage_and_property_tax_limitation: [12_500, 0] + nc_mortgage_and_property_tax_deduction: [12_500, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.yaml new file mode 100644 index 00000000000..6d98d0761c4 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.yaml @@ -0,0 +1,524 @@ +# N.C. Gen. Stat. 105-153.5(a)(2)b and the D-401 instructions for Form D-400 +# Schedule A line 4: "For spouses filing as married filing separately or +# married filing jointly, the total mortgage interest and real estate taxes +# claimed by both spouses combined may not exceed $20,000." Above $20,000 the +# spouses' amounts "must be prorated based on the percentage paid by each +# spouse", and "for joint obligations paid from joint accounts, the proration +# is based on the income reported by each spouse". +# Spouses filing separately are two tax units linked by an explicit marital +# unit. Unless a case says otherwise, the amounts before limitation (line 3) +# are inputs. + +- name: A single filer's limitation is $20,000 + period: 2024 + input: + filing_status: SINGLE + nc_mortgage_and_property_tax_before_limitation: 25_000 + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: 20_000 + +- name: A joint return has one $20,000 limitation for both spouses + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + tax_units: + tax_unit: + members: [spouse1, spouse2] + filing_status: JOINT + nc_mortgage_and_property_tax_before_limitation: 30_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: 20_000 + +- name: Spouses filing separately who each paid $15,000 share the limitation equally + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $30,000 combined exceeds $20,000: each paid 50%, so $20,000 x 50%. + nc_mortgage_and_property_tax_limitation: [10_000, 10_000] + +- name: Spouses filing separately with unequal payments prorate the limitation by the share each paid + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 18_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 6_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $24,000 combined: $20,000 x 18/24 = $15,000 and $20,000 x 6/24 = $5,000. + nc_mortgage_and_property_tax_limitation: [15_000, 5_000] + +- name: Spouses filing separately keep the full limitation when their combined amounts are below $20,000 + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 8_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 7_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $15,000 combined does not exceed $20,000, so nothing is prorated. + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + +- name: Spouses filing separately whose combined amounts equal $20,000 are not prorated + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 12_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 8_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Proration applies only when the combined amount "exceeds" $20,000. + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + +- name: Joint obligations paid from a joint account prorate the limitation by each spouse's income + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + adjusted_gross_income: 120_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + adjusted_gross_income: 40_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Income shares 120/160 = 75% and 40/160 = 25% of $20,000. + nc_mortgage_and_property_tax_limitation: [15_000, 5_000] + +- name: Joint-account proration gives no share to a spouse with negative income + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + adjusted_gross_income: -10_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + adjusted_gross_income: 60_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Income below zero counts as zero: shares 0/60,000 and 60,000/60,000. + nc_mortgage_and_property_tax_limitation: [0, 20_000] + +- name: Joint-account proration is equal when neither spouse has income + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 18_000 + adjusted_gross_income: 0 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 6_000 + adjusted_gross_income: 0 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: [10_000, 10_000] + +- name: A joint account changes nothing when the combined amounts are below $20,000 + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + mortgage_and_real_estate_taxes_paid_from_joint_account: true + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 8_000 + adjusted_gross_income: 150_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 7_000 + adjusted_gross_income: 10_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + +- name: The proration uses each spouse's allowed property taxes, not taxes above the federal cap + period: 2024 + input: + people: + spouse1: + age: 45 + home_mortgage_interest: 10_000 + # Only $5,000 is allowed under section 164 on a separate return. + real_estate_taxes: 9_000 + spouse2: + age: 45 + home_mortgage_interest: 10_000 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Line 3 is $10,000 + $5,000 = $15,000 and $10,000; $25,000 combined. + # $20,000 x 15/25 = $12,000 and $20,000 x 10/25 = $8,000. + nc_mortgage_and_property_tax_before_limitation: [15_000, 10_000] + nc_mortgage_and_property_tax_limitation: [12_000, 8_000] + +- name: A spouse with a dependent on their separate return still shares the limitation + period: 2024 + input: + people: + spouse1: + age: 45 + spouse2: + age: 43 + child: + age: 10 + is_tax_unit_dependent: true + marital_units: + couple: + members: [spouse1, spouse2] + child_unit: + members: [child] + tax_units: + tax_unit1: + members: [spouse1, child] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [spouse1, spouse2, child] + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: [10_000, 10_000] + +- name: A separate filer whose spouse is not in the simulation keeps the $20,000 limitation + period: 2024 + input: + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 25_000 + state_code: NC + output: + # The spouse's amount is unknown, so none is counted against the cap. + nc_mortgage_and_property_tax_limitation: 20_000 + +- name: Two separate filers alone in a simulation without marital units are read as spouses + period: 2024 + input: + people: + person1: + age: 45 + person2: + age: 45 + tax_units: + tax_unit1: + members: [person1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [person2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [person1, person2] + state_code: NC + output: + # Both separate heads share a default two-person marital unit, + # which the model uses to identify the two as spouses. + nc_mortgage_and_property_tax_limitation: [10_000, 10_000] + +- name: A separate filer does not share the limitation with an unrelated single filer + period: 2024 + input: + people: + person1: + age: 45 + person2: + age: 45 + tax_units: + tax_unit1: + members: [person1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [person2] + filing_status: SINGLE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household1: + members: [person1] + state_code: NC + household2: + members: [person2] + state_code: NC + output: + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + +- name: Separate filers in a default marital unit of three people do not share the limitation + period: 2024 + input: + people: + person1: + age: 45 + person2: + age: 45 + person3: + age: 45 + tax_units: + tax_unit1: + members: [person1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [person2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit3: + members: [person3] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [person1, person2, person3] + state_code: NC + output: + # A default unit of three cannot identify a spouse. + nc_mortgage_and_property_tax_limitation: [20_000, 20_000, 20_000] + +- name: Spouses filing separately share the limitation in 2026 + period: 2026 + input: + people: + spouse1: + age: 45 + spouse2: + age: 45 + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 30_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 10_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # $40,000 combined: $20,000 x 30/40 and $20,000 x 10/40. + nc_mortgage_and_property_tax_limitation: [15_000, 5_000] + +- name: A considered-unmarried head-of-household spouse does not share the cap with a separate filer + period: 2024 + input: + people: + spouse1: + age: 45 + is_tax_unit_head: true + spouse2: + age: 45 + is_tax_unit_head: true + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: HEAD_OF_HOUSEHOLD + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # The considered-unmarried HOH filer is outside the spouses' shared + # cap: each has $20,000, and min($15,000, $20,000) is $15,000. + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + nc_mortgage_and_property_tax_deduction: [15_000, 15_000] + +- name: Separate returns with no tax-unit heads fall back to the per-return cap + period: 2024 + input: + people: + spouse1: + age: 45 + is_tax_unit_head: false + spouse2: + age: 45 + is_tax_unit_head: false + marital_units: + couple: + members: [spouse1, spouse2] + tax_units: + tax_unit1: + members: [spouse1] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + tax_unit2: + members: [spouse2] + filing_status: SEPARATE + nc_mortgage_and_property_tax_before_limitation: 15_000 + households: + household: + members: [spouse1, spouse2] + state_code: NC + output: + # Without two separate heads the spouse guard is false: each return + # keeps $20,000, and min($15,000, $20,000) is $15,000. These invalid filer + # roles are outside the property tests' valid-head shared-cap domain. + nc_mortgage_and_property_tax_limitation: [20_000, 20_000] + nc_mortgage_and_property_tax_deduction: [15_000, 15_000] diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py index 037c46fd128..84d7b84b197 100644 --- a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_itemized_deductions.py @@ -11,18 +11,10 @@ class nc_itemized_deductions(Variable): defined_for = StateCode.NC def formula(tax_unit, period, parameters): - # Qualified Mortgage Interest and Real Estate Property Taxes. - filing_status = tax_unit("filing_status", period) - - mortgage_interest = add(tax_unit, period, ["mortgage_interest"]) - pirs = parameters(period).gov.irs.deductions.itemized.salt_and_real_estate - property_taxes = min_( - add(tax_unit, period, ["real_estate_taxes"]), - pirs.cap[filing_status], - ) - pco = parameters(period).gov.states.nc.tax.income.deductions.itemized.cap - capped_mortage_and_property_taxes = min_( - mortgage_interest + property_taxes, pco.mortgage_and_property_tax + # Qualified Mortgage Interest and Real Estate Property Taxes, after the + # $20,000 limitation shared by spouses (Schedule A line 5). + capped_mortage_and_property_taxes = tax_unit( + "nc_mortgage_and_property_tax_deduction", period ) # North Carolina specifies a state and local tax deduction cap which is currently not modeled in PolicyEngine diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.py new file mode 100644 index 00000000000..24da4bdf9a9 --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_before_limitation.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class nc_mortgage_and_property_tax_before_limitation(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina mortgage interest and real estate property taxes before limitation" + unit = USD + definition_period = YEAR + documentation = "Form D-400 Schedule A line 3: home mortgage interest (line 1) plus real estate property taxes (line 2), before the $20,000 limitation. Calculated for all states so a separately filing spouse outside North Carolina contributes to the shared cap." + reference = ( + # N.C. Gen. Stat. 105-153.5(a)(2)b + "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html", + # 2025 Form D-401 instructions, Form D-400 Schedule A lines 1 to 3 + "https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20", + ) + # Both spouses' amounts enter the shared cap even if one lives outside NC. + # Only the final limitation and deduction are restricted to NC returns. + + def formula(tax_unit, period, parameters): + filing_status = tax_unit("filing_status", period) + + mortgage_interest = add(tax_unit, period, ["mortgage_interest"]) + pirs = parameters(period).gov.irs.deductions.itemized.salt_and_real_estate + property_taxes = min_( + add(tax_unit, period, ["real_estate_taxes"]), + pirs.cap[filing_status], + ) + return mortgage_interest + property_taxes diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.py new file mode 100644 index 00000000000..424b6c0d3d7 --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_deduction.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class nc_mortgage_and_property_tax_deduction(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina mortgage interest and real estate property tax deduction" + unit = USD + definition_period = YEAR + documentation = "Form D-400 Schedule A line 5: the smaller of the amount before limitation (line 3) and the limitation (line 4)." + reference = ( + # N.C. Gen. Stat. 105-153.5(a)(2)b + "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html", + # 2025 Form D-401 instructions, Form D-400 Schedule A line 5 + "https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20", + ) + defined_for = StateCode.NC + + def formula(tax_unit, period, parameters): + before_limitation = tax_unit( + "nc_mortgage_and_property_tax_before_limitation", period + ) + limitation = tax_unit("nc_mortgage_and_property_tax_limitation", period) + return min_(before_limitation, limitation) diff --git a/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.py b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.py new file mode 100644 index 00000000000..f13d4610775 --- /dev/null +++ b/policyengine_us/variables/gov/states/nc/tax/income/deductions/nc_mortgage_and_property_tax_limitation.py @@ -0,0 +1,79 @@ +from policyengine_us.model_api import * + + +class nc_mortgage_and_property_tax_limitation(Variable): + value_type = float + entity = TaxUnit + label = "North Carolina mortgage interest and real estate property tax limitation" + unit = USD + definition_period = YEAR + documentation = "Form D-400 Schedule A line 4: $20,000 per return, shared by spouses filing separate returns. When the spouses' combined amounts before limitation exceed $20,000, each spouse's limitation is their prorated share of $20,000." + reference = ( + # N.C. Gen. Stat. 105-153.5(a)(2)b + "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html", + # 2025 Form D-401 instructions, Form D-400 Schedule A line 4 + "https://www.ncdor.gov/2025-d-401-individual-income-tax-instructions/open#page=20", + ) + defined_for = StateCode.NC + + def formula(tax_unit, period, parameters): + cap = parameters( + period + ).gov.states.nc.tax.income.deductions.itemized.cap.mortgage_and_property_tax + person = tax_unit.members + # "For spouses filing as married filing separately or married filing + # jointly, the total mortgage interest and real estate taxes claimed by + # both spouses combined may not exceed $20,000." A joint return holds + # both spouses, so the cap applies to it once. A spouse filing + # separately is in another tax unit. Reach them through the marital + # unit only when it holds exactly two people who each head a separate + # return: without explicit marital units, everyone shares one default + # unit. + filing_status = tax_unit("filing_status", period) + separate = filing_status == filing_status.possible_values.SEPARATE + head = person("is_tax_unit_head", period) + separate_head = head & tax_unit.project(separate) + couple = (person.marital_unit.nb_persons() == 2) & ( + person.marital_unit.sum(separate_head) == 2 + ) + # Each spouse's amount before limitation (Schedule A line 3), on the + # head of their own return. Line 3 also computes federal amounts for + # an other-state spouse, whose payments count toward the shared cap. + before_limitation = tax_unit( + "nc_mortgage_and_property_tax_before_limitation", period + ) + own = head * tax_unit.project(before_limitation) + combined = person.marital_unit.sum(own) + # "If the amount of the mortgage interest and real estate taxes paid by + # both spouses exceeds $20,000, these deductions must be prorated based + # on the percentage paid by each spouse." + paid_share = np.divide( + own, combined, out=np.zeros_like(own), where=combined > 0 + ) + # "For joint obligations paid from joint accounts, the proration is + # based on the income reported by each spouse for that taxable year." + # Income is federal adjusted gross income (Form D-400 line 6), not + # below zero; the shares are equal when neither spouse has any. + agi = max_(tax_unit("adjusted_gross_income", period), 0) + own_income = head * tax_unit.project(agi) + combined_income = person.marital_unit.sum(own_income) + income_share = where( + combined_income > 0, + np.divide( + own_income, + combined_income, + out=np.zeros_like(own_income), + where=combined_income > 0, + ), + 0.5, + ) + joint_account = person.marital_unit( + "mortgage_and_real_estate_taxes_paid_from_joint_account", period + ) + share = where(joint_account, income_share, paid_share) + prorated = head & couple & (combined > cap) + return where( + tax_unit.any(prorated), + cap * tax_unit.sum(where(prorated, share, 0)), + cap, + ) diff --git a/policyengine_us/variables/household/expense/housing/mortgage_and_real_estate_taxes_paid_from_joint_account.py b/policyengine_us/variables/household/expense/housing/mortgage_and_real_estate_taxes_paid_from_joint_account.py new file mode 100644 index 00000000000..9699b5ca1ad --- /dev/null +++ b/policyengine_us/variables/household/expense/housing/mortgage_and_real_estate_taxes_paid_from_joint_account.py @@ -0,0 +1,11 @@ +from policyengine_us.model_api import * + + +class mortgage_and_real_estate_taxes_paid_from_joint_account(Variable): + value_type = bool + entity = MaritalUnit + label = "Mortgage interest and real estate taxes paid from a joint account" + documentation = "Whether a married couple paid the home mortgage interest and real estate taxes they owe jointly from a joint account. North Carolina then shares its mortgage and property tax cap between spouses filing separately by income rather than by the amount each paid." + definition_period = YEAR + default_value = False + reference = "https://www.ncleg.gov/EnactedLegislation/Statutes/HTML/BySection/Chapter_105/GS_105-153.5.html"