diff --git a/changelog.d/ny-medical-expense-floor.fixed.md b/changelog.d/ny-medical-expense-floor.fixed.md new file mode 100644 index 00000000000..8d20abc37a9 --- /dev/null +++ b/changelog.d/ny-medical-expense-floor.fixed.md @@ -0,0 +1 @@ +New York itemized deductions now allow medical and dental expenses only above 10% of federal AGI, the pre-TCJA 26 U.S.C. 213(a) floor that NY Tax Law § 615(a) keeps and Form IT-196 line 3 applies, through a new ny_medical_expense_deduction used by ny_itemized_deductions_max and the Line 40 limitation. They previously used the federal 7.5% floor, which overstated the deduction by up to 2.5% of federal AGI. diff --git a/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml new file mode 100644 index 00000000000..d17693a2977 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ny/tax/income/deductions/itemized/medical/floor.yaml @@ -0,0 +1,31 @@ +description: New York allows an itemized deduction for medical and dental expenses exceeding this share of federal adjusted gross income. +values: + # Through 2017, Form IT-201-D line 1 took the medical deduction from federal + # Schedule A line 4, after the federal floor: 10% from 2013 (7.5% for filers + # 65 and over through 2016, not modeled), then 7.5% for 2017 under Public + # Law 115-97 § 11027. + 2013-01-01: 0.1 + 2017-01-01: 0.075 + # From 2018, NY Tax Law § 615(a) computes NY itemized deductions as federal + # deductions existed immediately prior to Public Law 115-97, so NY keeps the + # pre-TCJA 26 U.S.C. 213(a) 10% floor (Form IT-196 line 3). + 2018-01-01: 0.1 +metadata: + unit: /1 + period: year + label: New York itemized medical expense deduction floor + reference: + - title: NY Tax Law § 615(a) + href: https://www.nysenate.gov/legislation/laws/TAX/615 + - title: 26 U.S. Code § 213(a) (2016 edition, before Public Law 115-97) + href: https://www.govinfo.gov/content/pkg/USCODE-2016-title26/html/USCODE-2016-title26-subtitleA-chap1-subchapB-partVII-sec213.htm + - title: 2016 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2016/inc/it201d_2016_fill_in.pdf#page=1 + - title: 2017 NY Form IT-201-D, line 1 (federal Schedule A, line 4) + href: https://www.tax.ny.gov/pdf/2017/inc/it201d_2017_fill_in.pdf#page=1 + - title: 2017 federal Schedule A, line 3 (multiply line 2 by 7.5%) + href: https://www.irs.gov/pub/irs-prior/f1040sa--2017.pdf#page=1 + - title: 2018 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2018/inc/it196_2018_fill_in.pdf#page=1 + - title: 2025 NY Form IT-196, line 3 (multiply line 2 by 10%) + href: https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1 diff --git a/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py new file mode 100644 index 00000000000..01c54bad117 --- /dev/null +++ b/policyengine_us/tests/core/test_ny_medical_expense_deduction_invariants.py @@ -0,0 +1,255 @@ +"""Invariants for the New York itemized medical expense deduction. + +NY Tax Law 615(a) computes New York itemized deductions as federal deductions +existed immediately before Public Law 115-97, so Form IT-196 lines 1-4 keep +the 10% floor of pre-TCJA 26 USC 213(a): line 4 is medical and dental +expenses less 10% of federal AGI. The federal floor has been 7.5% since 2017. +Through 2017, Form IT-201-D line 1 took federal Schedule A line 4, so New +York followed the federal 7.5% floor in 2017. + +Hypothesis draws batches of tax units (single or joint, in New York or +another state, with AGI from negative to several million, medical expenses, +real estate taxes and charitable gifts), and a seeded population of 200 units +adds breadth. Each batch runs as one vectorized simulation, and again with +every head's medical expenses raised by a drawn non-negative amount. For +every tax unit: + +1. Bound: 0 <= ny_medical_expense_deduction <= itemized_medical_expenses, + and it is zero outside New York. +2. Differential: in New York, ny_medical_expense_deduction equals an + independent numpy max(0, expenses - floor * max(AGI, 0)), with the floor + read from the forms (Form IT-196 line 3 from 2018; for 2017, the federal + Schedule A line 3 that IT-201-D line 1 used) rather than from the model's + parameters. +3. Order: it never exceeds the federal medical_expense_deduction. +4. Monotone: raising expenses by d raises it by at least 0 and at most d. +5. Composition: ny_itemized_deductions_max less ny_medical_expense_deduction + does not change when medical expenses change. +6. Line 40: ny_itemized_deductions_phase_out does not change when medical + expenses change, because the medical deduction enters worksheet line 1 + (deductions before the limitation) and line 2 (deductions not subject to + it) alike. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +# The model computes in float32, about 7 significant digits. A difference of +# float32 amounts can be off by a few ulps of its largest operand (half an +# ulp is about 0.03 near 600,000) even when the result is near zero, so each +# comparison also allows RTOL times the largest amount that went into it. +RTOL = 1e-6 + +# Form IT-196 line 3, "Multiply line 2 by 10% (0.10)", on the 2018, 2021, +# 2024 and 2025 forms (tax.ny.gov/pdf//inc/it196__fill_in.pdf). +# For 2017, IT-201-D line 1 took federal Schedule A line 4, and the 2017 +# Schedule A line 3 reads "Multiply line 2 by 7.5% (0.075)". +PUBLISHED_FLOOR = {2017: 0.075, 2018: 0.1, 2021: 0.1, 2024: 0.1, 2025: 0.1} +YEARS = sorted(PUBLISHED_FLOOR) +STATES = ["NY", "NY", "NY", "CA", "TX"] + +agis = st.one_of( + st.integers(-200_000, 0).map(float), + st.integers(0, 500_000).map(float), + st.integers(500_000, 6_000_000).map(float), +) +amounts = st.one_of( + st.just(0.0), + st.integers(1, 60_000).map(float), + st.integers(60_000, 800_000).map(float), +) +increases = st.one_of(st.just(0.0), st.integers(1, 100_000).map(float)) + + +@st.composite +def tax_units(draw): + return { + "joint": draw(st.booleans()), + "state": draw(st.sampled_from(STATES)), + "agi": draw(agis), + "medical": draw(amounts), + "real_estate_taxes": draw(amounts), + "charity": draw(amounts), + "increase": draw(increases), + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def amount(): + return float( + [0, rng.integers(1, 60_001), rng.integers(60_000, 800_001)][ + rng.choice([0, 1, 2]) + ] + ) + + return [ + { + "joint": bool(rng.random() < 0.5), + "state": str(rng.choice(STATES)), + "agi": float( + [ + rng.integers(-200_000, 1), + rng.integers(0, 500_001), + rng.integers(500_000, 6_000_001), + ][rng.choice([0, 1, 2])] + ), + "medical": amount(), + "real_estate_taxes": amount(), + "charity": amount(), + "increase": float(rng.integers(0, 100_001)) if rng.random() < 0.7 else 0.0, + } + for _ in range(n) + ] + + +def _situation(units, year, *, raised): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, unit in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 45, + "is_tax_unit_head": True, + "other_medical_expenses": unit["medical"] + raised * unit["increase"], + "real_estate_taxes": unit["real_estate_taxes"], + "charitable_cash_donations": unit["charity"], + } + members = [head] + if unit["joint"]: + spouse = f"spouse_{i}" + people[spouse] = {"age": 43, "is_tax_unit_spouse": True} + members.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "adjusted_gross_income": {year: unit["agi"]}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: unit["state"]}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, *, raised=False): + sim = Simulation(situation=_situation(units, year, raised=raised)) + return { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + "itemized_medical_expenses", + "adjusted_gross_income", + "medical_expense_deduction", + "ny_medical_expense_deduction", + "ny_itemized_deductions_max", + "ny_itemized_deductions_phase_out", + ] + } + + +def _slack(*operands): + return TOLERANCE + RTOL * np.max(np.abs(np.stack(operands)), axis=0) + + +def _assert_close(actual, desired, slack, what): + off = np.abs(actual - desired) > slack + assert not off.any(), f"{what}: {actual[off]} != {desired[off]}" + + +def _check(units, year): + in_ny = np.array([unit["state"] == "NY" for unit in units]) + base = _run(units, year) + expenses = base["itemized_medical_expenses"] + deduction = base["ny_medical_expense_deduction"] + + agi = np.maximum(base["adjusted_gross_income"], 0) + slack = _slack(expenses, agi) + + # 1. Bounds, and zero outside New York. + assert (deduction >= 0).all() + assert (deduction <= expenses + slack).all() + assert (deduction[~in_ny] == 0).all() + + # 2. Differential against numpy and the published floor. + expected = np.maximum(expenses - PUBLISHED_FLOOR[year] * agi, 0) + _assert_close(deduction[in_ny], expected[in_ny], slack[in_ny], "differential") + + # 3. Never more than the federal deduction, whose floor is no higher. + assert (deduction <= base["medical_expense_deduction"] + slack).all() + + # 4. Monotone, and at most dollar for dollar, in expenses. + raised = _run(units, year, raised=True) + raised_slack = _slack(raised["itemized_medical_expenses"], agi) + increase = raised["itemized_medical_expenses"] - expenses + gain = raised["ny_medical_expense_deduction"] - deduction + assert (gain >= -raised_slack).all() + assert (gain <= increase + raised_slack).all() + + # 5. Medical expenses reach the NY itemized total only through the NY + # medical deduction. + total_slack = _slack( + raised["ny_itemized_deductions_max"], base["ny_itemized_deductions_max"] + ) + _assert_close( + raised["ny_itemized_deductions_max"] - raised["ny_medical_expense_deduction"], + base["ny_itemized_deductions_max"] - deduction, + total_slack, + "composition", + ) + + # 6. The Line 40 limitation is unaffected by medical expenses. + _assert_close( + raised["ny_itemized_deductions_phase_out"], + base["ny_itemized_deductions_phase_out"], + total_slack, + "Line 40", + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=8, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +def _near_floor(agi, medical): + return { + "joint": False, + "state": "NY", + "agi": float(agi), + "medical": float(medical), + "real_estate_taxes": 0.0, + "charity": 0.0, + "increase": 0.0, + } + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +# Expenses within float32 rounding of a large floor, where a tolerance scaled +# by the result rather than the operands fails (review of #9931). +@example(2025, [_near_floor(5_999_999, 600_000)] * 5) +@example(2025, [_near_floor(5_999_997, 600_000), _near_floor(5_242_881, 524_290)]) +def test_ny_medical_expense_deduction_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + _check(_seeded_units(), year) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml index 07de472c2ad..25f730d5927 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_phase_out.yaml @@ -10,7 +10,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 340_000 @@ -26,7 +26,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 350_000 @@ -45,7 +45,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 408_000 @@ -61,7 +61,7 @@ input: ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 @@ -80,7 +80,7 @@ input: ny_itemized_deductions_max: 20_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 5_000_000 @@ -102,7 +102,7 @@ # remaining 60,000 is subject to the limitation. ny_itemized_deductions_max: 100_000 ny_college_tuition_deduction: 0 - medical_expense_deduction: 25_000 + ny_medical_expense_deduction: 25_000 investment_interest_expense: 10_000 ny_casualty_loss_deduction: 5_000 adjusted_gross_income: 5_000_000 @@ -123,7 +123,7 @@ # the federal Line 40 worksheet; the Pease base should ignore it. ny_itemized_deductions_max: 130_000 ny_college_tuition_deduction: 30_000 - medical_expense_deduction: 0 + ny_medical_expense_deduction: 0 investment_interest_expense: 0 ny_casualty_loss_deduction: 0 adjusted_gross_income: 500_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml new file mode 100644 index 00000000000..1d13a25a085 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_medical_expense_deduction.yaml @@ -0,0 +1,158 @@ +# NY Form IT-196 lines 1-4: medical and dental expenses (line 1) less 10% of +# federal AGI (line 2, Form IT-201 or IT-203 line 19, or line 19a, recomputed +# federal AGI, in 2020-2022; line 3 multiplies by 10% on every IT-196 from +# 2018 through 2025). NY Tax Law § 615(a) computes NY itemized deductions as +# federal deductions existed immediately before Public Law 115-97, so NY +# keeps pre-TCJA 26 U.S.C. 213(a)'s 10% floor, not the federal 7.5% floor. +# Through 2017, Form IT-201-D line 1 took the federal Schedule A line 4 +# amount, so NY followed the federal floor. + +- name: Expenses above 10% of AGI are deductible; the federal floor is lower. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + # Line 1 = 12,000; line 3 = 100,000 * 0.10 = 10,000; line 4 = 2,000. + ny_medical_expense_deduction: 2_000 + # Federal Schedule A: 12,000 - 100,000 * 0.075 = 4,500. + medical_expense_deduction: 4_500 + +- name: Expenses between 7.5% and 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + other_medical_expenses: 7_000 + output: + # Line 3 = 80,000 * 0.10 = 8,000 is more than line 1 (7,000), so line 4 + # is blank. + ny_medical_expense_deduction: 0 + # Federal: 7,000 - 80,000 * 0.075 = 1,000. + medical_expense_deduction: 1_000 + +- name: Expenses exactly at 10% of AGI give no NY deduction. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_000 + output: + # Line 4 = 10,000 - 10,000 = 0. + ny_medical_expense_deduction: 0 + +- name: One dollar above the 10% floor is deductible. + period: 2025 + input: + state_code: NY + employment_income: 100_000 + other_medical_expenses: 10_001 + output: + ny_medical_expense_deduction: 1 + +- name: Negative AGI gives a zero floor, so all expenses are deductible. + period: 2025 + input: + state_code: NY + adjusted_gross_income: -20_000 + other_medical_expenses: 3_000 + output: + # positive_agi = max(-20,000, 0) = 0, so line 3 = 0 and line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + +- name: First IT-196 year (2018) uses the 10% floor; federal 2018 used 7.5%. + period: 2018 + input: + state_code: NY + adjusted_gross_income: 50_000 + other_medical_expenses: 8_000 + output: + # 2018 Form IT-196 line 3: 50,000 * 0.10 = 5,000; line 4 = 3,000. + ny_medical_expense_deduction: 3_000 + # TCJA section 11027 floor for 2018: 8,000 - 50,000 * 0.075 = 4,250. + medical_expense_deduction: 4_250 + +- name: 2017 follows the federal 7.5% floor (IT-201-D line 1 used federal Schedule A). + period: 2017 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 9_000 + output: + # 2017 IT-201-D line 1 = federal Schedule A line 4; 2017 Schedule A + # line 3 multiplies AGI by 7.5%: 9,000 - 7,500 = 1,500. + ny_medical_expense_deduction: 1_500 + medical_expense_deduction: 1_500 + +- name: 2016 follows the federal 10% floor. + period: 2016 + input: + state_code: NY + adjusted_gross_income: 100_000 + other_medical_expenses: 12_000 + output: + # 2016 Schedule A line 3 multiplies AGI by 10% for filers under 65: + # 12,000 - 10,000 = 2,000. + ny_medical_expense_deduction: 2_000 + medical_expense_deduction: 2_000 + +- name: 2026 keeps the 10% floor. + period: 2026 + input: + state_code: NY + adjusted_gross_income: 200_000 + other_medical_expenses: 25_000 + output: + # 25,000 - 200,000 * 0.10 = 5,000 (federal: 25,000 - 15,000 = 10,000). + ny_medical_expense_deduction: 5_000 + medical_expense_deduction: 10_000 + +- name: Filers outside New York get no NY medical expense deduction. + period: 2025 + input: + state_code: CA + employment_income: 100_000 + other_medical_expenses: 12_000 + output: + ny_medical_expense_deduction: 0 + +- name: NY itemized deductions use the 10% medical floor. + period: 2025 + input: + state_code: NY + employment_income: 80_000 + real_estate_taxes: 12_000 + other_medical_expenses: 9_000 + output: + # NY medical: 9,000 - 80,000 * 0.10 = 1,000 (federal floor would give + # 9,000 - 6,000 = 3,000). + ny_medical_expense_deduction: 1_000 + # Real estate taxes 12,000 + NY medical 1,000. Federal AGI is below the + # Line 40 applicable amount and NY AGI is not over 100,000, so neither + # reduction applies. + ny_itemized_deductions_max: 13_000 + ny_itemized_deductions_phase_out: 0 + ny_itemized_deductions_reduction: 0 + ny_itemized_deductions: 13_000 + +- name: Line 40 worksheet excludes the same NY medical deduction it includes. + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: NY + filing_status: SINGLE + adjusted_gross_income: 5_000_000 + real_estate_taxes: 150_000 + other_medical_expenses: 600_000 + output: + # NY medical: 600,000 - 5,000,000 * 0.10 = 100,000. + ny_medical_expense_deduction: 100_000 + # Worksheet line 1 = 150,000 + 100,000 = 250,000. + ny_itemized_deductions_max: 250_000 + # Line 2 = NY medical 100,000; line 3 = 150,000; line 4 = 0.80 * 150,000 + # = 120,000. Line 8 = 0.03 * (5,000,000 - 340,700) = 139,779. + # Reduction = min(120,000, 139,779) = 120,000. Excluding the federal + # medical deduction (225,000) instead would leave line 3 = 25,000 and a + # 20,000 reduction. + ny_itemized_deductions_phase_out: 120_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml index 502aab68f83..c37afdd3437 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_pre_tcja_deductions.yaml @@ -78,9 +78,10 @@ casualty_loss: 15_000 output: # Charitable: 5,000 - # Medical: 10,000 - (100,000 * 0.075) = 2,500 + # NY Medical (Form IT-196 lines 1-4): 10,000 - (100,000 * 0.10) = 0 + # (the federal 7.5% floor would give 2,500) # NY SALT: 12,000 real estate only (sales tax 3,000 added back per s.615(c)(1)) # NY Misc: 4,000 - (100,000 * 0.02) = 2,000 # NY Casualty: 15,000 - (100,000 * 0.10) = 5,000 - # Total: 5,000 + 2,500 + 12,000 + 2,000 + 5,000 = 26,500 - ny_itemized_deductions_max: 26_500 + # Total: 5,000 + 0 + 12,000 + 2,000 + 5,000 = 24,000 + ny_itemized_deductions_max: 24_000 diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py index 69366a1932e..c6b5d9dc9d8 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/ny_itemized_deductions_max.py @@ -18,6 +18,7 @@ class ny_itemized_deductions_max(Variable): Public Law 115-97 (TCJA). This means: - No SALT cap ($10,000 limit does not apply) - State/local income taxes are NOT deductible (only sales + property) + - Medical expenses deductible above 10% of AGI (not the 7.5% floor) - Miscellaneous deductions still allowed with 2% AGI floor - Casualty losses not limited to federally declared disasters - NY-specific college tuition deduction addition per § 615(d) @@ -26,8 +27,8 @@ class ny_itemized_deductions_max(Variable): # Federal deductions that don't differ from pre-TCJA "charitable_deduction", "interest_deduction", - "medical_expense_deduction", # NY-specific pre-TCJA versions + "ny_medical_expense_deduction", "ny_salt_deduction", "ny_misc_deduction", "ny_casualty_loss_deduction", diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py index 27565cc41af..9e160b62047 100644 --- a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/phase_out/ny_itemized_deductions_phase_out.py @@ -35,7 +35,7 @@ class ny_itemized_deductions_phase_out(Variable): Worksheet line 2 (L2) = form lines 4, 14, 16a, 20, 29, 30, 37, i.e. the deductions NOT subject to the limitation (26 U.S.C. 68(c)): - - line 4 medical/dental -> medical_expense_deduction + - line 4 medical/dental -> ny_medical_expense_deduction - line 14 investment interest -> investment_interest_expense - line 20 casualty/theft loss -> ny_casualty_loss_deduction - lines 16a/29/30/37 (qualified contributions, gambling losses, @@ -62,7 +62,7 @@ def formula(tax_unit, period, parameters): # Worksheet line 2 (L2): deductions not subject to the limitation # (26 U.S.C. 68(c)) - medical, investment interest, casualty/theft. - medical = tax_unit("medical_expense_deduction", period) + medical = tax_unit("ny_medical_expense_deduction", period) investment_interest = add(tax_unit, period, ["investment_interest_expense"]) casualty = tax_unit("ny_casualty_loss_deduction", period) l2 = medical + investment_interest + casualty diff --git a/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py new file mode 100644 index 00000000000..eb74491e9f4 --- /dev/null +++ b/policyengine_us/variables/gov/states/ny/tax/income/taxable_income/deductions/itemized/pre_tcja/ny_medical_expense_deduction.py @@ -0,0 +1,33 @@ +from policyengine_us.model_api import * + + +class ny_medical_expense_deduction(Variable): + value_type = float + entity = TaxUnit + label = "NY medical expense deduction" + unit = USD + definition_period = YEAR + reference = ( + "https://www.nysenate.gov/legislation/laws/TAX/615", + "https://www.tax.ny.gov/pdf/2025/inc/it196_2025_fill_in.pdf#page=1", + ) + defined_for = StateCode.NY + documentation = """ + NY Tax Law § 615 requires itemized deductions to be computed using + pre-TCJA federal rules from 2018. Pre-TCJA 26 U.S.C. 213(a) allowed + medical and dental expenses only above 10% of federal AGI; NY does not + follow the federal 7.5% floor in place since 2017 (Public Law 115-97 + § 11027 for 2017-2018, later law after that). Form IT-196 lines 1-4 apply + this floor to federal AGI (Form IT-201 or IT-203 line 19; line 19a, + recomputed federal AGI, in 2020-2022, which PolicyEngine does not + distinguish from federal AGI). Through 2017, NY took the federal + Schedule A amount, so the floor parameter follows the federal floor then. + """ + + def formula(tax_unit, period, parameters): + # From 2018 NY uses pre-TCJA rules: medical expenses are deductible + # above 10% of AGI, not the federal 7.5% floor in place since 2017 + p = parameters(period).gov.states.ny.tax.income.deductions.itemized.medical + expenses = tax_unit("itemized_medical_expenses", period) + medical_floor = p.floor * tax_unit("positive_agi", period) + return max_(0, expenses - medical_floor)