From 7fb88f0abfdc22d0f4990845ba23553dde0d4d62 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 11:06:28 -0400 Subject: [PATCH 01/45] Floor Montana's joint-return medical and standard deductions on the return's Montana AGI The 2021-2023 Form 2 Itemized Deductions Schedule (line 1b) and Standard Deduction Worksheet (line 1) take Montana AGI from page 1, line 14. Single, head of household and joint returns report that line in one column, so a joint return pools both spouses' income before flooring at zero. Both joint-path variables instead summed each spouse's mt_agi_indiv, which floors each spouse at zero first, so a spouse's loss never offset the other's income. They now use mt_agi_joint. From 2024 mt_agi_indiv is switched off (Montana repealed filing separately on the same form), so the joint medical floor fell to zero and the full medical expense was deducted. From 2024 Form 2 line 2 takes federal itemized deductions (Worksheet A), so the variable now carries the federal medical expense deduction (7.5% of federal AGI). A new parameter, gov.states.mt.tax.income.deductions.itemized.state_specific_deduction_applies, marks the switch. Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-medical-floor-joint-agi.fixed.md | 1 + .../state_specific_deduction_applies.yaml | 16 ++ .../mt_medical_expense_deduction_joint.yaml | 115 ++++++++- .../standard/mt_standard_deduction_joint.yaml | 45 +++- ...test_mt_joint_agi_deductions_properties.py | 230 ++++++++++++++++++ .../mt_medical_expense_deduction_joint.py | 24 +- .../standard/mt_standard_deduction_joint.py | 4 +- 7 files changed, 408 insertions(+), 27 deletions(-) create mode 100644 changelog.d/fix-mt-medical-floor-joint-agi.fixed.md create mode 100644 policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml create mode 100644 policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py diff --git a/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md b/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md new file mode 100644 index 00000000000..abd7a2db8d4 --- /dev/null +++ b/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md @@ -0,0 +1 @@ +Floor Montana's 2021-2023 medical expense deduction and standard deduction on a joint return's own Montana AGI (Form 2, page 1, line 14), so one spouse's loss offsets the other's income, instead of on the sum of each spouse's AGI floored at zero. From 2024, take the federal medical expense deduction (7.5% of federal AGI), because Form 2 line 2 uses federal itemized deductions; the Montana floor had fallen to zero from 2024, so the full medical expense was deducted. diff --git a/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml new file mode 100644 index 00000000000..8b6d63360ab --- /dev/null +++ b/policyengine_us/parameters/gov/states/mt/tax/income/deductions/itemized/state_specific_deduction_applies.yaml @@ -0,0 +1,16 @@ +description: Montana computes itemized deductions on its own Itemized Deductions Schedule, in lieu of adjusted federal itemized deductions, if this is true. +metadata: + label: Montana state specific itemized deductions apply + period: year + unit: bool + reference: + - title: Montana Individual Income Tax Return (Form 2) 2023 | Itemized Deductions Schedule + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7 + - title: 2024 Montana Form 2 Instructions | Discontinued Worksheets and Schedules + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=8 + - title: 2024 Montana Form 2 Instructions | Line 2 and Worksheet A, Adjusted Federal Itemized Deductions + href: https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15 + +values: + 2021-01-01: true + 2024-01-01: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index df9e02675bc..f683598aa05 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -1,4 +1,4 @@ -- name: Amount only distributed to the head +- name: Amount only distributed to the head period: 2021 absolute_error_margin: 1 input: @@ -6,16 +6,19 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 400 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 600 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] state_code: MT output: + # 2,000 - 7.5% x 1,000 = 1,925 mt_medical_expense_deduction_joint: [1_925, 0] - name: No income @@ -26,11 +29,13 @@ person1: is_tax_unit_head: true other_medical_expenses: 1_000 - mt_agi_indiv: 0 person2: is_tax_unit_head: false other_medical_expenses: 1_000 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 0 households: household: members: [person1, person2] @@ -46,14 +51,110 @@ person1: is_tax_unit_head: true other_medical_expenses: 0 - mt_agi_indiv: 1_000 person2: is_tax_unit_head: false other_medical_expenses: 0 - mt_agi_indiv: 0 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 1_000 households: household: members: [person1, person2] state_code: MT output: mt_medical_expense_deduction_joint: [0, 0] + +- name: Spouse's loss reduces the joint return's Montana AGI before the floor + period: 2022 + absolute_error_margin: 1 + input: + people: + head: + age: 40 + employment_income: 100_000 + other_medical_expenses: 10_000 + spouse: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Form 2 page 1, line 14: 100,000 - 20,000 = 80,000, reported in one column. + mt_agi_joint: 80_000 + # Itemized Deductions Schedule line 1: 10,000 - 7.5% x 80,000 = 4,000. + # Flooring each spouse's Montana AGI at zero first would give + # 10,000 - 7.5% x 90,000 = 3,250. + mt_medical_expense_deduction_joint: [4_000, 0] + +- name: Last year of the Montana schedule floors on Montana AGI, not the federal deduction + period: 2023 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 40_000 + medical_expense_deduction: 9_999 + households: + household: + members: [person1] + state_code: MT + output: + # 10,000 - 7.5% x 40,000 = 7,000 + mt_medical_expense_deduction_joint: 7_000 + +- name: From 2024 the federal medical expense deduction carries through + period: 2024 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 50_000 + medical_expense_deduction: 2_500 + households: + household: + members: [person1] + state_code: MT + output: + mt_medical_expense_deduction_joint: 2_500 + +- name: From 2024 the floor uses federal AGI, not Montana AGI + period: 2024 + absolute_error_margin: 1 + input: + people: + person1: + age: 70 + medicare_enrolled: false + employment_income: 100_000 + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + # The $5,500 subtraction for taxpayers 65 and older reduces Montana AGI + # but not federal AGI. + adjusted_gross_income: 100_000 + mt_agi_joint: 94_500 + # Form 2 line 2, Worksheet A, takes federal Schedule A: + # 10,000 - 7.5% x 100,000 = 2,500. + mt_medical_expense_deduction_joint: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml index fc733856faf..68ba1b89870 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.yaml @@ -2,7 +2,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: SINGLE output: mt_standard_deduction_joint: 2_590 @@ -11,7 +11,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 8_000 + mt_agi_joint: 8_000 filing_status: SURVIVING_SPOUSE output: mt_standard_deduction_joint: 2_140 @@ -20,7 +20,7 @@ period: 2021 input: state_code: MT - mt_agi_indiv: 30_000 + mt_agi_joint: 30_000 filing_status: SEPARATE output: mt_standard_deduction_joint: 4_830 @@ -29,7 +29,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 25_180 + mt_agi_joint: 25_180 filing_status: HEAD_OF_HOUSEHOLD output: mt_standard_deduction_joint: 5_036 @@ -38,7 +38,7 @@ period: 2022 input: state_code: MT - mt_agi_indiv: 15_000 + mt_agi_joint: 15_000 filing_status: JOINT output: mt_standard_deduction_joint: 4_520 @@ -50,17 +50,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 5_000 person2: is_tax_unit_head: false - mt_agi_indiv: 100_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 105_000 households: household: members: [person1, person2, person3] @@ -75,17 +73,15 @@ people: person1: is_tax_unit_head: true - mt_agi_indiv: 20_000 person2: is_tax_unit_head: false - mt_agi_indiv: 10_000 person3: is_tax_unit_head: false - mt_agi_indiv: 0 tax_units: tax_unit: members: [person1, person2, person3] filing_status: JOINT + mt_agi_joint: 30_000 households: household: members: [person1, person2, person3] @@ -97,7 +93,32 @@ period: 2024 input: state_code: MT - mt_agi_indiv: 12_950 + mt_agi_joint: 12_950 filing_status: JOINT output: mt_standard_deduction_joint: 29_200 + +- name: Spouse's loss reduces the joint return's Montana AGI + period: 2022 + absolute_error_margin: 1 + input: + people: + head: + age: 40 + employment_income: 50_000 + spouse: + age: 40 + self_employment_income: -10_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Form 2 page 1, line 14: 50,000 - 10,000 = 40,000, reported in one column. + mt_agi_joint: 40_000 + # Standard Deduction Worksheet: 20% x 40,000 = 8,000, between the $4,520 + # minimum and the $10,180 maximum for a joint return. + mt_standard_deduction_joint: [8_000, 0] diff --git a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py new file mode 100644 index 00000000000..035f36895bf --- /dev/null +++ b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py @@ -0,0 +1,230 @@ +"""Properties of Montana's AGI-based deductions on a joint (single-column) return. + +From 2021 to 2023 the Form 2 Itemized Deductions Schedule (line 1b) and the +Standard Deduction Worksheet (line 1) both take Montana AGI from Form 2 page 1, +line 14. Single, head of household and joint returns report that line in one +column, so a joint return pools both spouses' income before Montana AGI is +floored at zero. From 2024 Form 2 line 2 takes federal itemized deductions +(Worksheet A), so the medical floor applies to federal AGI. + +Each test evaluates every point of an input grid in one vectorized simulation, +so the properties hold for all grid inputs rather than for a few examples: + +- 2021-2023: the medical deduction is max(0, expenses - 7.5% of the return's + Montana AGI), and the standard deduction is 20% of that AGI held between the + filing status minimum and maximum; +- moving an income item from one spouse to the other leaves both deductions + unchanged, because they depend on the return's pooled Montana AGI only; +- each deduction is held once, by the head; +- the medical deduction lies between zero and the medical expenses; +- for a single filer the joint and separate variants agree; +- from 2024 the medical deduction is the federal medical expense deduction, + including where Montana AGI differs from federal AGI. +""" + +import itertools + +import numpy as np +import pytest + +from policyengine_us import Simulation + +MEDICAL_FLOOR = 0.075 # Itemized Deductions Schedule line 1c +STANDARD_RATE = 0.2 # Standard Deduction Worksheet line 2 + +HEAD_WAGES = [0, 30_000, 60_000, 100_000] +SPOUSE_SELF_EMPLOYMENT = [-50_000, -20_000, 0, 40_000] +MEDICAL = [0, 2_000, 10_000, 30_000] +# Where the spouse's self-employment income is recorded: on the spouse, or +# moved to the head. The return's income is the same either way. +SPLITS = ["spouse", "head"] +CHILD_MEDICAL = [None, 1_500] # no child, or a dependent child's expenses +COUPLE_GRID = list( + itertools.product( + HEAD_WAGES, SPOUSE_SELF_EMPLOYMENT, MEDICAL, SPLITS, CHILD_MEDICAL + ) +) +SINGLE_GRID = list( + itertools.product([0, 20_000, 100_000], [-10_000, 0], MEDICAL, [40, 70]) +) +STATE_YEARS = [2021, 2022, 2023] +FEDERAL_YEARS = [2024, 2025] +YEARS = STATE_YEARS + FEDERAL_YEARS + + +def every_year(value): + return {year: value for year in YEARS} + + +@pytest.fixture(scope="module") +def grid(): + """One simulation holding every couple and single-filer grid point.""" + people, tax_units, households = {}, {}, {} + unit_of_person, role = [], [] + for i, (wages, se, medical, split, child) in enumerate(COUPLE_GRID): + head, spouse = f"c{i}_head", f"c{i}_spouse" + people[head] = { + "age": every_year(40), + "employment_income": every_year(wages), + "self_employment_income": every_year(se if split == "head" else 0), + "other_medical_expenses": every_year(medical), + } + people[spouse] = { + "age": every_year(40), + "self_employment_income": every_year(se if split == "spouse" else 0), + } + members = [head, spouse] + if child is not None: + name = f"c{i}_child" + people[name] = { + "age": every_year(10), + "is_tax_unit_dependent": every_year(True), + "other_medical_expenses": every_year(child), + } + members.append(name) + unit_of_person += [i] * len(members) + role += ["head", "spouse", "dependent"][: len(members)] + tax_units[f"c{i}"] = {"members": members} + households[f"c{i}"] = {"members": members, "state_code": every_year("MT")} + for j, (wages, se, medical, age) in enumerate(SINGLE_GRID): + name = f"s{j}" + people[name] = { + "age": every_year(age), + "employment_income": every_year(wages), + "self_employment_income": every_year(se), + "other_medical_expenses": every_year(medical), + } + unit_of_person.append(len(COUPLE_GRID) + j) + role.append("head") + tax_units[name] = {"members": [name]} + households[name] = {"members": [name], "state_code": every_year("MT")} + sim = Simulation( + situation={"people": people, "tax_units": tax_units, "households": households} + ) + return sim, np.array(unit_of_person), np.array(role) + + +N_COUPLES = len(COUPLE_GRID) +COUPLES = slice(0, N_COUPLES) +SINGLES = slice(N_COUPLES, None) + + +def per_unit(sim, variable, year, unit): + """Sum a person-level variable to its tax unit, in grid order.""" + return np.bincount(unit, weights=sim.calculate(variable, year)) + + +def montana_agi_reference(sim, year): + # The grid has no Montana additions or subtractions before 2024, so page 1 + # line 14 is the return's federal AGI, floored at zero like PE's. + return np.maximum(sim.calculate("adjusted_gross_income", year), 0) + + +def twin_pairs(): + """Index pairs of couples that differ only in which spouse has the item.""" + index = {point: i for i, point in enumerate(COUPLE_GRID)} + return np.array( + [ + (index[(w, se, m, "spouse", c)], index[(w, se, m, "head", c)]) + for w, se, m, split, c in COUPLE_GRID + if split == "spouse" + ] + ).T + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_premises(grid, year): + sim, unit, _ = grid + filing_status = sim.calculate("filing_status", year).decode_to_str() + assert set(filing_status[COUPLES]) == {"JOINT"} + assert set(filing_status[SINGLES]) == {"SINGLE"} + np.testing.assert_allclose( + sim.calculate("mt_agi_joint", year), montana_agi_reference(sim, year) + ) + # Moving the item between spouses does not change the return's AGI... + spouse_side, head_side = twin_pairs() + agi = sim.calculate("adjusted_gross_income", year) + np.testing.assert_allclose(agi[spouse_side], agi[head_side]) + # ...but it does change the sum of each spouse's AGI floored at zero, so + # the grid reaches the cases where pooling matters. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + assert np.any(summed_individual[COUPLES] > agi[COUPLES] + 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_medical_floor_uses_the_return_montana_agi(grid, year): + sim, unit, role = grid + expenses = sim.calculate("itemized_medical_expenses", year) + expected = np.maximum( + expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0 + ) + deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, expected, atol=0.01) + held = sim.calculate("mt_medical_expense_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + assert np.all(deduction >= 0) + assert np.all(deduction <= expenses + 0.01) + spouse_side, head_side = twin_pairs() + np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) + assert deduction.max() > 0 + # Adding each spouse's Montana AGI after flooring it at zero would change + # the deduction somewhere on the grid. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + assert np.any(np.abs(unpooled - expected) > 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_standard_deduction_uses_the_return_montana_agi(grid, year): + sim, unit, role = grid + p = sim.tax_benefit_system.parameters( + f"{year}-01-01" + ).gov.states.mt.tax.income.deductions.standard + filing_status = sim.calculate("filing_status", year).decode_to_str() + minimum = p.floor[filing_status] + maximum = p.cap[filing_status] + expected = np.clip( + STANDARD_RATE * montana_agi_reference(sim, year), minimum, maximum + ) + deduction = per_unit(sim, "mt_standard_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, expected, atol=0.01) + held = sim.calculate("mt_standard_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + spouse_side, head_side = twin_pairs() + np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) + # Some returns fall between the minimum and maximum. + assert np.any((deduction > minimum + 1) & (deduction < maximum - 1)) + # Adding each spouse's Montana AGI after flooring it at zero would change + # the deduction somewhere on the grid. + summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) + unpooled = np.clip(STANDARD_RATE * summed_individual, minimum, maximum) + assert np.any(np.abs(unpooled - expected) > 1) + + +@pytest.mark.parametrize("year", STATE_YEARS) +def test_single_filer_joint_and_separate_variants_agree(grid, year): + sim, unit, _ = grid + for joint, separate in [ + ("mt_medical_expense_deduction_joint", "mt_medical_expense_deduction_indiv"), + ("mt_standard_deduction_joint", "mt_standard_deduction_indiv"), + ]: + np.testing.assert_allclose( + per_unit(sim, joint, year, unit)[SINGLES], + per_unit(sim, separate, year, unit)[SINGLES], + atol=0.01, + ) + + +@pytest.mark.parametrize("year", FEDERAL_YEARS) +def test_from_2024_the_federal_medical_deduction_carries_through(grid, year): + sim, unit, role = grid + federal = sim.calculate("medical_expense_deduction", year) + deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) + np.testing.assert_allclose(deduction, federal, atol=0.01) + held = sim.calculate("mt_medical_expense_deduction_joint", year) + assert np.all(held[role != "head"] == 0) + # The grid includes returns whose Montana AGI differs from federal AGI + # (the subtraction for taxpayers 65 and older) and that deduct expenses. + montana_agi = sim.calculate("mt_agi_joint", year) + federal_agi = np.maximum(sim.calculate("adjusted_gross_income", year), 0) + assert np.any((montana_agi < federal_agi - 1) & (deduction > 0)) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 28d8b020611..182f474d45d 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -8,17 +8,27 @@ class mt_medical_expense_deduction_joint(Variable): label = "Montana medical expense deduction when married couples are filing jointly" reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15", "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(g)(i) + # MT Code § 15-30-2131 (2022) (1)(a) ) unit = USD defined_for = StateCode.MT def formula(person, period, parameters): - expense = person.tax_unit("itemized_medical_expenses", period) - p = parameters(period).gov.irs.deductions.itemized.medical - # Law does not define Montana AGI as the cap. - # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = p.floor * add(person.tax_unit, period, ["mt_agi_indiv"]) + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + if p.state_specific_deduction_applies: + expense = person.tax_unit("itemized_medical_expenses", period) + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + # Itemized Deductions Schedule line 1b takes Montana AGI from + # page 1, line 14. A joint return reports that line in a single + # column, so the floor applies to the return's pooled Montana AGI. + montana_agi = person.tax_unit("mt_agi_joint", period) + deduction = max_(0, expense - floor * montana_agi) + else: + # From 2024, Form 2 line 2 takes federal itemized deductions + # (Worksheet A), so the federal floor on federal AGI applies. + deduction = person.tax_unit("medical_expense_deduction", period) is_head = person("is_tax_unit_head", period) - return is_head * max_(0, expense - medical_floor) + return is_head * deduction diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py index 9f5279e5caa..c471e6c9ac1 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/standard/mt_standard_deduction_joint.py @@ -17,7 +17,9 @@ def formula(person, period, parameters): if p.state_specific_deduction_applies: # ── Pre-2024 MT-specific calculation ─────────────────────────────── - agi = add(person.tax_unit, period, ["mt_agi_indiv"]) + # Standard Deduction Worksheet line 1 takes Montana AGI from + # page 1, line 14, which pools both spouses on a joint return. + agi = person.tax_unit("mt_agi_joint", period) floor = p.floor[filing_status] cap = p.cap[filing_status] uncapped = p.rate * agi From bb978bfe07473845555e8d05c82f0da27c4cdd0d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:05:18 -0400 Subject: [PATCH 02/45] Deduct Montana 2021-2023 medical and long-term care insurance premiums in full For TY2021-2023 the Form 2 Itemized Deductions Schedule floors only line 1, medical and dental expenses other than premiums, at 7.5% of Montana AGI. Line 2 deducts medical insurance premiums in full, except premiums already deducted in Montana AGI (the self-employed health insurance deduction) and pre-tax premiums; line 3 deducts long-term care insurance premiums in full (former MCA 15-30-2131(1)(a)(iii)-(iv) and (1)(g)). - New mt_eligible_medical_insurance_premiums (person): federal medical expense premiums, including Medicare Part B, less the person's self-employed health insurance deduction. - mt_medical_expense_deduction_joint (2021-2023) and _indiv: line 1 floored on Montana AGI, plus lines 2 and 3 in full. From 2024 the joint variant keeps the federal medical expense deduction. Fixes #9930. Stacked on #9903. Co-Authored-By: Claude Opus 5.5 --- .../fix-mt-medical-premiums-in-full.fixed.md | 1 + ...t_eligible_medical_insurance_premiums.yaml | 83 ++++ .../mt_medical_expense_deduction_indiv.yaml | 76 +++- .../mt_medical_expense_deduction_joint.yaml | 156 ++++++++ ...mt_medical_premium_deduction_invariants.py | 373 ++++++++++++++++++ .../mt_eligible_medical_insurance_premiums.py | 32 ++ .../mt_medical_expense_deduction_indiv.py | 31 +- .../mt_medical_expense_deduction_joint.py | 32 +- 8 files changed, 767 insertions(+), 17 deletions(-) create mode 100644 changelog.d/fix-mt-medical-premiums-in-full.fixed.md create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml create mode 100644 policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py diff --git a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md new file mode 100644 index 00000000000..fd5e0bc2250 --- /dev/null +++ b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md @@ -0,0 +1 @@ +Deduct Montana's 2021–2023 medical insurance premiums (other than those taken as the self-employed health insurance deduction) and long-term care insurance premiums in full on the Itemized Deductions Schedule, applying the 7.5% floor only to other medical and dental expenses. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml new file mode 100644 index 00000000000..22714f491d7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml @@ -0,0 +1,83 @@ +- name: Premiums paid with after-tax dollars qualify in full + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 4_000 + state_code: MT + output: + mt_eligible_medical_insurance_premiums: 4_000 + +- name: Medicare Part B premiums qualify alongside other premiums + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums_without_medicare_part_b: 1_000 + medicare_enrolled: true + medicare_part_b_premium: 2_000 + state_code: MT + output: + # The 2022 Form 2 instructions for line 2 make Medicare Part B premiums + # 100 percent deductible on this line. + medical_expense_health_insurance_premiums: 3_000 + mt_eligible_medical_insurance_premiums: 3_000 + +- name: Pre-tax premiums do not qualify + period: 2022 + absolute_error_margin: 0.01 + input: + employment_income: 50_000 + pre_tax_health_insurance_premiums: 3_000 + state_code: MT + output: + # Premiums paid through a cafeteria plan are excluded from wages and are + # not counted as medical expenses. + medical_expense_health_insurance_premiums: 0 + mt_eligible_medical_insurance_premiums: 0 + +- name: Premiums fully taken as the self-employed health insurance deduction do not qualify + period: 2022 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 40_000 + health_insurance_premiums: 6_000 + state_code: MT + output: + self_employed_health_insurance_ald_person: 6_000 + mt_eligible_medical_insurance_premiums: 0 + +- name: Premiums above the self-employed health insurance deduction qualify + period: 2022 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 2_000 + health_insurance_premiums: 6_000 + state_code: MT + output: + # The self-employed health insurance deduction is limited to 2,000 of + # self-employment earnings, so 6,000 - 2,000 = 4,000 was not deducted in + # Montana AGI. + self_employed_health_insurance_ald_person: 2_000 + mt_eligible_medical_insurance_premiums: 4_000 + +- name: Self-employed person with a loss deducts none of the premiums in AGI + period: 2023 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: -5_000 + health_insurance_premiums: 3_000 + state_code: MT + output: + self_employed_health_insurance_ald_person: 0 + mt_eligible_medical_insurance_premiums: 3_000 + +- name: Outside Montana the amount is zero + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 4_000 + state_code: ID + output: + mt_eligible_medical_insurance_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index 3804de55c08..4e4f08bd584 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -22,8 +22,82 @@ period: 2021 input: other_medical_expenses: 1_000 + health_insurance_premiums: 2_000 + mt_agi_indiv: 10_000 + state_code: MT + is_tax_unit_head_or_spouse: false + output: + mt_medical_expense_deduction_indiv: 0 + +- name: Medical insurance premiums are deducted in full on line 2, not under the floor + period: 2022 + absolute_error_margin: 0.01 + input: + other_medical_expenses: 1_000 + health_insurance_premiums: 2_000 mt_agi_indiv: 20_000 state_code: MT is_tax_unit_head_or_spouse: true output: - mt_medical_expense_deduction_indiv: 0 + # Line 1: max(0, 1,000 - 7.5% x 20,000) = max(0, 1,000 - 1,500) = 0. + # Line 2: 2,000 in full. + # Flooring the premiums with the other expenses would give + # 3,000 - 1,500 = 1,500. + mt_medical_expense_deduction_indiv: 2_000 + +- name: Long-term care insurance premiums are deducted in full on line 3 + period: 2023 + absolute_error_margin: 0.01 + input: + long_term_health_insurance_premiums: 1_500 + mt_agi_indiv: 50_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + mt_medical_expense_deduction_indiv: 1_500 + +- name: Premiums taken as the self-employed health insurance deduction are not deducted again + period: 2021 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 30_000 + health_insurance_premiums: 5_000 + other_medical_expenses: 4_000 + mt_agi_indiv: 25_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + self_employed_health_insurance_ald_person: 5_000 + # Line 1: 4,000 - 7.5% x 25,000 = 2,125. Line 2: 0. + mt_medical_expense_deduction_indiv: 2_125 + +- name: Each spouse filing separately on the same form deducts their own premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 3_000 + other_medical_expenses: 2_000 + mt_agi_indiv: 20_000 + person2: + is_tax_unit_spouse: true + health_insurance_premiums: 1_000 + long_term_health_insurance_premiums: 800 + mt_agi_indiv: 10_000 + person3: + is_tax_unit_dependent: true + health_insurance_premiums: 600 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Head: line 1 max(0, 2,000 - 7.5% x 20,000) = 500, line 2 3,000. + # Spouse: line 2 1,000, line 3 800. + mt_medical_expense_deduction_indiv: [3_500, 1_800, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index f683598aa05..4573625219f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -158,3 +158,159 @@ # Form 2 line 2, Worksheet A, takes federal Schedule A: # 10,000 - 7.5% x 100,000 = 2,500. mt_medical_expense_deduction_joint: 2_500 + +- name: Medical insurance premiums are deducted in full on line 2, not under the floor + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 4_000 + other_medical_expenses: 2_000 + person2: + is_tax_unit_head: false + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 60_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: max(0, 2,000 - 7.5% x 60,000) = max(0, 2,000 - 4,500) = 0. + # Line 2: 4,000 in full. Line 3: 0. + # Flooring the premiums with the other expenses would give + # 6,000 - 4,500 = 1,500. + mt_medical_expense_deduction_joint: [4_000, 0] + +- name: Long-term care insurance premiums are deducted in full on line 3 + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 5_000 + long_term_health_insurance_premiums: 3_000 + person2: + is_tax_unit_head: false + long_term_health_insurance_premiums: 2_500 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 40_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: 5,000 - 7.5% x 40,000 = 2,000. + # Line 3: 3,000 + 2,500 = 5,500. + # Total: 2,000 + 5,500 = 7,500, held by the head. + mt_medical_expense_deduction_joint: [7_500, 0] + +- name: Premiums taken as the self-employed health insurance deduction are not deducted again + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + is_self_employed: true + self_employment_income: 50_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # The self-employed health insurance deduction takes all 6,000 of the + # premiums in federal and so in Montana AGI, so line 2 is 0. + self_employed_health_insurance_ald: 6_000 + # Line 1: 10,000 - 7.5% x 40,000 = 7,000. + mt_medical_expense_deduction_joint: 7_000 + +- name: A dependent's premiums count on line 2 and other medical expenses on line 1 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 3_000 + person2: + is_tax_unit_spouse: true + other_medical_expenses: 4_000 + person3: + is_tax_unit_dependent: true + health_insurance_premiums: 1_200 + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + mt_agi_joint: 50_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Line 1: (4,000 + 1,000) - 7.5% x 50,000 = 5,000 - 3,750 = 1,250. + # Line 2: 3,000 + 1,200 = 4,200. + mt_medical_expense_deduction_joint: [5_450, 0, 0] + +- name: Joint return computed from wages, with premiums and other medical expenses + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 80_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 3_000 + person2: + age: 40 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_agi_joint: 80_000 + # Line 1: max(0, 3,000 - 7.5% x 80,000) = max(0, 3,000 - 6,000) = 0. + # Line 2: 6,000. + # Flooring the premiums with the other expenses would give + # 9,000 - 6,000 = 3,000. + mt_medical_expense_deduction_joint: [6_000, 0] + +- name: From 2024 medical insurance premiums stay under the federal floor + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 60_000 + health_insurance_premiums: 4_000 + other_medical_expenses: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + adjusted_gross_income: 60_000 + # Federal Schedule A: (4,000 + 2,000) - 7.5% x 60,000 = 1,500. + medical_expense_deduction: 1_500 + mt_medical_expense_deduction_joint: 1_500 diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py new file mode 100644 index 00000000000..6aa0107b6d1 --- /dev/null +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -0,0 +1,373 @@ +"""Invariants for Montana's 2021-2023 medical insurance premium deduction. + +The 2021-2023 Form 2 Itemized Deductions Schedule floors only line 1, medical +and dental expenses other than insurance premiums, at 7.5% of Montana AGI. +Line 2 deducts medical insurance premiums in full, except premiums already +deducted in Montana AGI (the self-employed health insurance deduction) and +pre-tax premiums. Line 3 deducts long-term care insurance premiums in full. +Former MCA 15-30-2131(1)(a)(iii)-(iv) and (1)(g). From 2024 Montana takes +federal itemized deductions, where premiums stay under the federal floor. + +Hypothesis draws batches of tax units (single, head of household, joint, and +joint with dependents). Each person draws wages, self-employment income, +other medical expenses, long-term care premiums, pre-tax premiums and medical +insurance premiums, reported directly, decomposed, or with Medicare Part B. +A seeded population adds breadth. Each batch runs as one vectorized +simulation that also holds two twins of every unit: one with drawn increases +to after-tax premiums and long-term care premiums, and one without pre-tax +premiums. For every tax unit: + +1. Accounting (2021-2023), against an independent numpy calculation with the + 7.5% rate from Form 2 line 1c: the joint deduction is + max(0, other expenses - 7.5% x Montana AGI) + eligible premiums + long-term + care premiums, held by the head. The separate variant is the same per head + and spouse on their own Montana AGI; dependents hold nothing. +2. Eligible premiums: each person's premiums, computed from the inputs, less + their self-employed health insurance deduction, never below zero. +3. Bounds (2021-2023): premiums are never floored, so the deduction is at + least the eligible and long-term care premiums and at most those plus + other expenses. +4. Slope (2021-2023): raising after-tax premiums of people who are not self + employed, and long-term care premiums, raises the deduction by exactly the + increase. Montana AGI is unchanged by those increases. +5. Pre-tax premiums never count on line 2. +6. Line 1 base: other expenses equal federal itemized medical expenses less + the premiums, so line 1 tracks the federal definition. +7. Differential (2021-2023): for single filers the joint and separate + variants agree. +8. From 2024 the joint deduction is the federal medical expense deduction and + the separate variant is zero. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c +STATE_YEARS = [2021, 2022, 2023] +FEDERAL_YEARS = [2024, 2025] +YEARS = STATE_YEARS + FEDERAL_YEARS +VARIANTS = ["base", "raised", "no_pre_tax"] + +amount = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) +small_amount = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) + + +@st.composite +def people(draw): + self_employed = draw(st.booleans()) + return { + "employment_income": float(draw(st.integers(0, 150_000))), + "self_employed": self_employed, + "self_employment_income": ( + float(draw(st.integers(-20_000, 60_000))) if self_employed else 0.0 + ), + "premium_source": draw(st.sampled_from(["direct", "decomposed", "medicare"])), + "premium": draw(amount), + "part_b": draw(st.integers(0, 3_000).map(float)), + "other_medical": draw( + st.one_of(st.just(0.0), st.integers(1, 30_000).map(float)) + ), + "long_term_care": draw(small_amount), + "pre_tax": draw(small_amount), + "premium_increase": draw(small_amount), + "long_term_care_increase": draw(small_amount), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + } + + +SEED = 20261006 + + +def _seeded_units(n=150): + rng = np.random.default_rng(SEED) + + def maybe(high): + return float(rng.integers(1, high + 1)) if rng.random() < 0.6 else 0.0 + + def person(): + self_employed = bool(rng.random() < 0.3) + return { + "employment_income": float(rng.integers(0, 150_001)), + "self_employed": self_employed, + "self_employment_income": ( + float(rng.integers(-20_000, 60_001)) if self_employed else 0.0 + ), + "premium_source": str(rng.choice(["direct", "decomposed", "medicare"])), + "premium": maybe(15_000), + "part_b": float(rng.integers(0, 3_001)), + "other_medical": maybe(30_000), + "long_term_care": maybe(5_000), + "pre_tax": maybe(5_000), + "premium_increase": maybe(5_000), + "long_term_care_increase": maybe(5_000), + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + } + ) + return units + + +def _person_inputs(values, role, variant): + raised = variant == "raised" + premium = ( + values["premium"] + + raised * (not values["self_employed"]) * values["premium_increase"] + ) + source = values["premium_source"] + return { + "age": 10 if role == "dependent" else 40, + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["employment_income"], + "is_self_employed": values["self_employed"], + "self_employment_income": values["self_employment_income"], + "other_medical_expenses": values["other_medical"], + "long_term_health_insurance_premiums": values["long_term_care"] + + raised * values["long_term_care_increase"], + "pre_tax_health_insurance_premiums": ( + 0.0 if variant == "no_pre_tax" else values["pre_tax"] + ), + "health_insurance_premiums": premium if source == "direct" else 0.0, + "health_insurance_premiums_without_medicare_part_b": ( + 0.0 if source == "direct" else premium + ), + "medicare_enrolled": source == "medicare", + "medicare_part_b_premium": values["part_b"] if source == "medicare" else 0.0, + } + + +def _expected_premium(values, variant): + """Premiums counted as federal medical expenses, from the inputs alone.""" + raised = variant == "raised" + premium = ( + values["premium"] + + raised * (not values["self_employed"]) * values["premium_increase"] + ) + if values["premium_source"] == "direct" and premium != 0: + return premium + part_b = values["part_b"] if values["premium_source"] == "medicare" else 0.0 + return (0.0 if values["premium_source"] == "direct" else premium) + part_b + + +def _situation(units, year): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + expected_premium, increase, roles = [], [], [] + for variant in VARIANTS: + for i, unit in enumerate(units): + tag = f"{variant}_{i}" + members, couple = [], [] + entries = [("head", unit["head"])] + if unit["spouse"] is not None: + entries.append(("spouse", unit["spouse"])) + entries += [("dependent", d) for d in unit["dependents"]] + unit_increase = 0.0 + for j, (role, values) in enumerate(entries): + name = f"{tag}_{role}_{j}" + people[name] = _person_inputs(values, role, variant) + members.append(name) + expected_premium.append(_expected_premium(values, variant)) + roles.append(role) + if role == "dependent": + groups["marital_units"][f"{tag}_single_{j}"] = {"members": [name]} + else: + couple.append(name) + unit_increase += (not values["self_employed"]) * values[ + "premium_increase" + ] + values["long_term_care_increase"] + increase.append(unit_increase) + groups["marital_units"][f"{tag}_couple"] = {"members": couple} + groups["tax_units"][f"{tag}_tax_unit"] = {"members": members} + groups["households"][f"{tag}_household"] = { + "members": members, + "state_code": "MT", + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return ( + {"people": people, **groups}, + np.array(expected_premium), + np.array(increase), + np.array(roles), + ) + + +PERSON_VARIABLES = [ + "other_medical_expenses", + "long_term_health_insurance_premiums", + "medical_expense_health_insurance_premiums", + "self_employed_health_insurance_ald_person", + "mt_eligible_medical_insurance_premiums", + "mt_agi_indiv", + "mt_medical_expense_deduction_joint", + "mt_medical_expense_deduction_indiv", +] +UNIT_VARIABLES = [ + "mt_agi_joint", + "itemized_medical_expenses", + "medical_expense_deduction", +] + + +def _run(units, year): + situation, expected_premium, increase, roles = _situation(units, year) + sim = Simulation(situation=situation) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON_VARIABLES + UNIT_VARIABLES + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + out["filing_status"] = sim.calculate("filing_status", year).decode_to_str() + out["expected_premium"] = expected_premium + out["increase"] = increase + out["role"] = roles + return out + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values, minlength=len(run["mt_agi_joint"])) + + +def _check(units, year): + run = _run(units, year) + n = len(units) + base, raised, no_pre_tax = (slice(k * n, (k + 1) * n) for k in range(3)) + role = run["role"] + joint = run["mt_medical_expense_deduction_joint"] + indiv = run["mt_medical_expense_deduction_indiv"] + deduction = _unit_sum(run, joint) + assert (joint[role != "head"] == 0).all() + + if year in FEDERAL_YEARS: + # 8. Federal treatment from 2024. + np.testing.assert_allclose( + deduction, run["medical_expense_deduction"], atol=TOLERANCE + ) + assert (indiv == 0).all() + return + + premium = run["medical_expense_health_insurance_premiums"] + se_ald = run["self_employed_health_insurance_ald_person"] + eligible = run["mt_eligible_medical_insurance_premiums"] + other = run["other_medical_expenses"] + long_term_care = run["long_term_health_insurance_premiums"] + + # 2. Eligible premiums. + np.testing.assert_allclose(premium, run["expected_premium"], atol=TOLERANCE) + assert (se_ald <= premium + TOLERANCE).all() + np.testing.assert_allclose( + eligible, np.maximum(run["expected_premium"] - se_ald, 0), atol=TOLERANCE + ) + + # 1. Accounting, joint and separate. + full = _unit_sum(run, eligible + long_term_care) + floored = np.maximum(_unit_sum(run, other) - FORM_FLOOR * run["mt_agi_joint"], 0) + np.testing.assert_allclose(deduction, floored + full, atol=TOLERANCE) + filer = role != "dependent" + expected_indiv = filer * ( + np.maximum(other - FORM_FLOOR * run["mt_agi_indiv"], 0) + + eligible + + long_term_care + ) + np.testing.assert_allclose(indiv, expected_indiv, atol=TOLERANCE) + + # 3. Bounds. + assert (deduction >= full - TOLERANCE).all() + assert (deduction <= full + _unit_sum(run, other) + TOLERANCE).all() + + # 4. Slope of one in after-tax and long-term care premiums. + np.testing.assert_allclose( + run["mt_agi_joint"][raised], run["mt_agi_joint"][base], atol=TOLERANCE + ) + np.testing.assert_allclose( + deduction[raised] - deduction[base], + run["increase"][raised], + atol=TOLERANCE, + ) + + # 5. Pre-tax premiums never reach line 2. + np.testing.assert_allclose( + _unit_sum(run, eligible)[no_pre_tax], + _unit_sum(run, eligible)[base], + atol=TOLERANCE, + ) + + # 6. Line 1 base tracks the federal definition. + np.testing.assert_allclose( + _unit_sum(run, other), + run["itemized_medical_expenses"] - _unit_sum(run, premium), + atol=TOLERANCE, + ) + + # 7. Single filers: joint and separate variants agree. + single = run["filing_status"] == "SINGLE" + np.testing.assert_allclose( + deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE + ) + return run, deduction, floored, full + + +# 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], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=20)) +def test_mt_medical_premium_deduction_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + result = _check(_seeded_units(), year) + if result is None: + return + run, deduction, floored, full = result + # Non-vacuity: some returns deduct premiums while line 1 is below the + # floor, where flooring premiums with other expenses would cut the + # deduction; some deduct line 1 too; some have premiums taken as the + # self-employed health insurance deduction. + agi_floor = FORM_FLOOR * run["mt_agi_joint"] + total = _unit_sum( + run, + run["other_medical_expenses"] + run["mt_eligible_medical_insurance_premiums"], + ) + assert np.any((full > 0) & (total < agi_floor)) + assert np.any((full > 0) & (floored > 0)) + assert np.any(run["self_employed_health_insurance_ald_person"] > 0) + assert np.any(run["filing_status"] == "SINGLE") + assert np.any(run["filing_status"] == "JOINT") diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py new file mode 100644 index 00000000000..c6d8a9f4187 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py @@ -0,0 +1,32 @@ +from policyengine_us.model_api import * + + +class mt_eligible_medical_insurance_premiums(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Montana medical insurance premiums deductible in full" + documentation = ( + "Each person's medical insurance premiums that Montana's Itemized " + "Deductions Schedule line 2 deducts in full, before the 2024 switch " + "to federal itemized deductions: the premiums counted as federal " + "medical expenses, including Medicare Part B, less the premiums " + "deducted as the self-employed health insurance deduction in " + "Montana AGI. Pre-tax premiums are excluded because they are not " + "counted as federal medical expenses." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + # MCA 2021 § 15-30-2131(1)(a)(iii) and (1)(g)(i) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + ) + unit = USD + defined_for = StateCode.MT + + def formula(person, period, parameters): + premiums = person("medical_expense_health_insurance_premiums", period) + # Premiums deducted in determining Montana AGI do not qualify, such as + # the self-employed health insurance deduction (Schedule 1, line 17). + deducted_in_agi = person("self_employed_health_insurance_ald_person", period) + return max_(0, premiums - deducted_in_agi) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index f369afc173e..94e6e256294 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -10,19 +10,34 @@ class mt_medical_expense_deduction_indiv(Variable): ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", - "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(g)(i) + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", + # MCA 2021 § 15-30-2131(1)(a) and (1)(g) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", ) unit = USD + # Separate filing on the same form, and with it the Itemized Deductions + # Schedule, ended after 2023. defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - expense = person("medical_expense_health_insurance_premiums", period) + person( - "other_medical_expenses", period - ) - p = parameters(period).gov.irs.deductions.itemized.medical + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + # Itemized Deductions Schedule line 1 takes medical and dental expenses + # other than the insurance premiums deducted in full on lines 2 and 3. + expense = person("other_medical_expenses", period) # Law does not define Montana AGI as the cap. # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = p.floor * person("mt_agi_indiv", period) + montana_agi = person("mt_agi_indiv", period) + floored_expense = max_(0, expense - floor * montana_agi) + # Line 2: medical insurance premiums not deducted elsewhere. + # Line 3: long-term care insurance premiums, without the federal + # age-based limit. + premiums = add( + person, + period, + [ + "mt_eligible_medical_insurance_premiums", + "long_term_health_insurance_premiums", + ], + ) is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return is_head_or_spouse * max_(0, expense - medical_floor) + return is_head_or_spouse * (floored_expense + premiums) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 182f474d45d..c1a2caead3d 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -10,8 +10,8 @@ class mt_medical_expense_deduction_joint(Variable): "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15", - "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(a) + # MCA 2021 § 15-30-2131(1)(a) and (1)(g) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", ) unit = USD defined_for = StateCode.MT @@ -19,13 +19,29 @@ class mt_medical_expense_deduction_joint(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.mt.tax.income.deductions.itemized if p.state_specific_deduction_applies: - expense = person.tax_unit("itemized_medical_expenses", period) + tax_unit = person.tax_unit floor = parameters(period).gov.irs.deductions.itemized.medical.floor - # Itemized Deductions Schedule line 1b takes Montana AGI from - # page 1, line 14. A joint return reports that line in a single - # column, so the floor applies to the return's pooled Montana AGI. - montana_agi = person.tax_unit("mt_agi_joint", period) - deduction = max_(0, expense - floor * montana_agi) + # Itemized Deductions Schedule line 1 takes medical and dental + # expenses other than the insurance premiums deducted in full on + # lines 2 and 3. + expense = add(tax_unit, period, ["other_medical_expenses"]) + # Line 1b takes Montana AGI from page 1, line 14. A joint return + # reports that line in a single column, so the floor applies to + # the return's pooled Montana AGI. + montana_agi = tax_unit("mt_agi_joint", period) + floored_expense = max_(0, expense - floor * montana_agi) + # Line 2: medical insurance premiums not deducted elsewhere. + # Line 3: long-term care insurance premiums, without the federal + # age-based limit. + premiums = add( + tax_unit, + period, + [ + "mt_eligible_medical_insurance_premiums", + "long_term_health_insurance_premiums", + ], + ) + deduction = floored_expense + premiums else: # From 2024, Form 2 line 2 takes federal itemized deductions # (Worksheet A), so the federal floor on federal AGI applies. From 664331bc7a32ad3eaf070fa3ca12b282035027ea Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:41:01 -0400 Subject: [PATCH 03/45] Update the joint medical floor property for premiums deducted in full The 2021-2023 property now expects max(0, other expenses - 7.5% of Montana AGI) plus line 2 and 3 premiums. The grid's age-70 single filers carry modeled Medicare Part B premiums, which the schedule deducts in full. Co-Authored-By: Claude Opus 5.5 --- ...test_mt_joint_agi_deductions_properties.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py index 035f36895bf..e3d17601979 100644 --- a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py +++ b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py @@ -10,9 +10,10 @@ Each test evaluates every point of an input grid in one vectorized simulation, so the properties hold for all grid inputs rather than for a few examples: -- 2021-2023: the medical deduction is max(0, expenses - 7.5% of the return's - Montana AGI), and the standard deduction is 20% of that AGI held between the - filing status minimum and maximum; +- 2021-2023: the medical deduction is max(0, expenses other than premiums - + 7.5% of the return's Montana AGI) plus the premiums deducted in full on + lines 2 and 3, and the standard deduction is 20% of that AGI held between + the filing status minimum and maximum; - moving an income item from one spouse to the other leaves both deductions unchanged, because they depend on the return's pooled Montana AGI only; - each deduction is held once, by the head; @@ -154,23 +155,32 @@ def test_premises(grid, year): @pytest.mark.parametrize("year", STATE_YEARS) def test_medical_floor_uses_the_return_montana_agi(grid, year): sim, unit, role = grid - expenses = sim.calculate("itemized_medical_expenses", year) - expected = np.maximum( - expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0 + # Line 1 floors expenses other than premiums; lines 2 and 3 deduct the + # premiums in full. The grid's age-70 single filers have modeled + # Medicare Part B premiums. + expenses = per_unit(sim, "other_medical_expenses", year, unit) + premiums = per_unit( + sim, "mt_eligible_medical_insurance_premiums", year, unit + ) + per_unit(sim, "long_term_health_insurance_premiums", year, unit) + expected = ( + np.maximum(expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0) + + premiums ) deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) np.testing.assert_allclose(deduction, expected, atol=0.01) held = sim.calculate("mt_medical_expense_deduction_joint", year) assert np.all(held[role != "head"] == 0) - assert np.all(deduction >= 0) - assert np.all(deduction <= expenses + 0.01) + assert np.all(deduction >= premiums - 0.01) + assert np.all(deduction <= expenses + premiums + 0.01) spouse_side, head_side = twin_pairs() np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) assert deduction.max() > 0 # Adding each spouse's Montana AGI after flooring it at zero would change # the deduction somewhere on the grid. summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) - unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + unpooled = ( + np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + premiums + ) assert np.any(np.abs(unpooled - expected) > 1) From 4a5802c1a835643d7c0c0d7817416d192502a540 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 6 Oct 2026 18:55:15 -0400 Subject: [PATCH 04/45] Allow for float32 rounding in the Montana premium invariants Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/test_mt_medical_premium_deduction_invariants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 6aa0107b6d1..b7bb2bb55c9 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -46,7 +46,8 @@ from policyengine_us import Simulation -TOLERANCE = 0.01 # dollars +# Dollars; the model computes in float32 and unit amounts reach about $100,000. +TOLERANCE = 0.05 FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c STATE_YEARS = [2021, 2022, 2023] FEDERAL_YEARS = [2024, 2025] From 541d6b52cf17652aac0dc4e33386d3fd0cd573b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:05:11 -0400 Subject: [PATCH 05/45] Add Idaho health premium subtraction regressions and invariants --- .../core/test_id_health_insurance_premiums.py | 130 ++++++++++++++++++ .../states/id/tax/income/id_deductions.yaml | 47 +++++++ ..._income_tax_before_refundable_credits.yaml | 106 ++++++++++++++ ...health_insurance_premiums_subtraction.yaml | 111 +++++++++++++++ 4 files changed, 394 insertions(+) create mode 100644 policyengine_us/tests/core/test_id_health_insurance_premiums.py create mode 100644 policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py new file mode 100644 index 00000000000..bd23b20a3d0 --- /dev/null +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -0,0 +1,130 @@ +"""Compare Idaho premium election with independently enumerated legal routes.""" + +from hypothesis import example, given, settings, strategies as st +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system as SYSTEM + + +PERIOD = "2026" +AMOUNT = st.integers(min_value=0, max_value=50_000) +ROW = st.fixed_dictionaries( + { + "premiums": st.tuples(AMOUNT, AMOUNT), + "pre_tax": st.tuples(AMOUNT, AMOUNT), + "above_line": AMOUNT, + "dependent_above_line": AMOUNT, + "medical": AMOUNT, + "standard": AMOUNT, + "itemized": AMOUNT, + "mandatory": st.booleans(), + } +) + + +@settings(max_examples=20, deadline=None) +@given(rows=st.lists(ROW, min_size=2, max_size=5)) +@example( + rows=[ + { + "premiums": (1_000, 500), + "pre_tax": (0, 0), + "above_line": 0, + "dependent_above_line": 0, + "medical": 500, + "standard": 10_000, + "itemized": 10_500, + "mandatory": False, + }, + { + "premiums": (0, 0), + "pre_tax": (0, 0), + "above_line": 0, + "dependent_above_line": 0, + "medical": 0, + "standard": 10_000, + "itemized": 1_000, + "mandatory": True, + }, + ] +) +def test_premium_subtraction_and_election_match_best_legal_route(rows): + people = {} + tax_units = {} + households = {} + expected_itemizes = [] + expected_subtractions = [] + expected_reductions = [] + available_premiums = [] + for index, row in enumerate(rows): + members = [f"person_{index}_{member}" for member in range(2)] + for member, person_id in enumerate(members): + people[person_id] = { + "age": {PERIOD: 35}, + "health_insurance_premiums": {PERIOD: row["premiums"][member]}, + "pre_tax_health_insurance_premiums": {PERIOD: row["pre_tax"][member]}, + } + tax_units[f"tax_unit_{index}"] = { + "members": members, + "self_employed_health_insurance_ald": {PERIOD: row["above_line"]}, + "dependents_self_employed_health_insurance_ald": { + PERIOD: row["dependent_above_line"] + }, + "medical_expense_deduction": {PERIOD: row["medical"]}, + "standard_deduction": {PERIOD: row["standard"]}, + "id_itemized_deductions": {PERIOD: row["itemized"]}, + "separate_filer_itemizes": {PERIOD: row["mandatory"]}, + } + households[f"household_{index}"] = { + "members": members, + "state_code": {PERIOD: "ID"}, + } + + # Enumerate the standard and itemized routes using scalar arithmetic. + # Their combined reduction, rather than deduction alone, determines + # the favorable election; a spouse's itemization overrides that choice. + available = max( + 0, + sum(row["premiums"]) + - sum(row["pre_tax"]) + - row["above_line"] + - row["dependent_above_line"], + ) + unclaimed_if_itemizing = max(0, available - row["medical"]) + standard_route = (row["standard"] + available, False, available) + itemized_route = ( + row["itemized"] + unclaimed_if_itemizing, + True, + unclaimed_if_itemizing, + ) + elected = ( + itemized_route + if row["mandatory"] + else max((standard_route, itemized_route), key=lambda route: route[0]) + ) + expected_reductions.append(elected[0]) + expected_itemizes.append(elected[1]) + expected_subtractions.append(elected[2]) + available_premiums.append(available) + + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + }, + ) + subtraction = simulation.calculate( + "id_health_insurance_premiums_subtraction", PERIOD + ) + deductions = simulation.calculate("id_deductions", PERIOD) + + assert simulation.calculate("id_itemizes", PERIOD).tolist() == expected_itemizes + assert subtraction.tolist() == pytest.approx(expected_subtractions) + assert (deductions + subtraction).tolist() == pytest.approx(expected_reductions) + assert all( + 0 <= actual <= available + for actual, available in zip(subtraction, available_premiums) + ) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml index 8528e9d221c..bb9249af83b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml @@ -7,6 +7,53 @@ output: id_deductions: 1_000 +- name: Standard deduction plus premiums beats a larger itemized deduction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 17_000 + output: + id_deductions: 16_100 + id_health_insurance_premiums_subtraction: 6_000 + +- name: Combined reduction tie retains the standard deduction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 18_100 + output: + id_deductions: 16_100 + id_health_insurance_premiums_subtraction: 6_000 + +- name: A larger total reduction permits itemization + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 18_101 + output: + id_deductions: 18_101 + id_health_insurance_premiums_subtraction: 4_000 + +- name: Separate filers must itemize even without premiums + period: 2026 + input: + state_code: ID + separate_filer_itemizes: true + health_insurance_premiums: 0 + standard_deduction: 1_000 + id_itemized_deductions: 500 + output: + id_deductions: 500 + - name: Does not itemize period: 2023 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml index bb41c382522..75200bf5f29 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml @@ -15,3 +15,109 @@ id_non_refundable_credits: 500 output: id_income_tax_before_refundable_credits: 0 + +# Audited PolicyBench households, using their supplied person-level facts. +- name: PolicyBench scenario_007 Idaho paid health insurance premiums + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 56 + employment_income: 0.0 + bank_account_assets: 71500.0 + employer_sponsored_insurance_premiums: 6309.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 2080.0 + hours_worked_last_week: 50.0 + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 2080.0 + other_medical_expenses: 150.0 + pre_subsidy_rent: 18352.94140625 + social_security_disability: 19285.0 + tax_exempt_private_pension_income: 284.1176452636719 + taxable_private_pension_income: 35280.0 + weekly_hours_worked_before_lsr: 50.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + tax_units: + tax_unit: + members: &id001 + - head + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id001 + takes_up_snap_if_eligible: true + families: + family: + members: *id001 + marital_units: + marital_unit: + members: *id001 + households: + household: + members: *id001 + state_code: ID + household_vehicles_value: 2660.0 + output: + id_income_tax_before_refundable_credits: 655.54 + state_income_tax_before_refundable_credits: 665.54 # Includes the $10 PBF tax on current main. +- name: PolicyBench scenario_053 Idaho paid health insurance premiums + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 25 + employment_income: 66968.6796875 + bank_account_assets: 3660.0 + employer_sponsored_insurance_premiums: 3389.27587890625 + has_esi: true + health_insurance_premiums_without_medicare_part_b: 5000.0 + hourly_wage: 32.0 + hours_worked_last_week: 40.0 + is_paid_hourly: true + is_tax_unit_head: true + is_tax_unit_spouse: false + other_health_insurance_premiums: 5000.0 + other_medical_expenses: 400.0 + over_the_counter_health_expenses: 150.0 + pre_subsidy_rent: 37200.0 + weekly_hours_worked_before_lsr: 40.0 + takes_up_medicaid_if_eligible: true + takes_up_ssi_if_eligible: true + tax_units: + tax_unit: + members: &id002 + - head + takes_up_aca_if_eligible: true + takes_up_dc_ptc: true + takes_up_eitc: true + would_file_if_eligible_for_refundable_credit: true + would_file_taxes_voluntarily: true + spm_units: + spm_unit: + members: *id002 + takes_up_snap_if_eligible: true + families: + family: + members: *id002 + marital_units: + marital_unit: + members: *id002 + households: + household: + members: *id002 + state_code: ID + auto_loan_balance: 19000.0 + auto_loan_interest: 950.0 + household_vehicles_value: 7410.0 + output: + id_income_tax_before_refundable_credits: 2180.28 + state_income_tax_before_refundable_credits: 2190.28 # Includes the $10 PBF tax on current main. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml new file mode 100644 index 00000000000..c95ec279539 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -0,0 +1,111 @@ +- name: Standard deduction retains all post-tax premiums + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + standard_deduction: 16_100 + id_itemized_deductions: 10_000 + medical_expense_deduction: 2_000 + output: + id_health_insurance_premiums_subtraction: 6_000 + +- name: Pretax premiums and federal above-line deductions cannot be subtracted again + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + standard_deduction: 16_100 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 3_000 + +- name: Itemizers allocate medical deductions to health premiums first + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + medical_expense_deduction: 2_000 + standard_deduction: 16_100 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_subtraction: 3_000 + +- name: Medical deduction exceeding available premiums leaves no subtraction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 6_000 + medical_expense_deduction: 10_000 + standard_deduction: 16_100 + id_itemized_deductions: 25_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Excess federal deductions cannot create a negative subtraction + period: 2026 + input: + state_code: ID + health_insurance_premiums: 1_000 + self_employed_health_insurance_ald: 2_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Reported non-Medicare premiums and voluntary Medicare B premiums qualify + period: 2025 + input: + state_code: ID + health_insurance_premiums_without_medicare_part_b: 2_000 + medicare_enrolled: true + medicare_part_b_premium: 1_000 + standard_deduction: 15_750 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 3_000 + +- name: Premium subtraction is included in the existing 2021 subtraction list + period: 2021 + input: + state_code: ID + health_insurance_premiums: 2_000 + standard_deduction: 12_550 + id_itemized_deductions: 0 + output: + id_health_insurance_premiums_subtraction: 2_000 + id_subtractions: 2_000 + +- name: Subtraction is limited to Idaho + period: 2026 + input: + state_code: WA + health_insurance_premiums: 2_000 + output: + id_health_insurance_premiums_subtraction: 0 + +- name: Dependent premiums deducted on their own federal return are excluded + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + self_employment_income: 5_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + standard_deduction: 16_100 + id_itemized_deductions: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + dependents_self_employed_health_insurance_ald: 500 + id_health_insurance_premiums_subtraction: 1_000 From 16fcbfb55cca4d5fa5c729f2b459635254a2f811 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:26:51 -0400 Subject: [PATCH 06/45] Fix Idaho health premium subtraction and deduction election --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 1 + .../tax/income/subtractions/subtractions.yaml | 6 +++- .../id/tax/income/deductions/id_deductions.py | 9 ++--- .../id/tax/income/deductions/id_itemizes.py | 31 ++++++++++++++++ ...d_health_insurance_premiums_subtraction.py | 22 ++++++++++++ .../id_qualified_health_insurance_premiums.py | 35 +++++++++++++++++++ 6 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 changelog.d/hub-pb-id-premiums-1009.fixed.md create mode 100644 policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py create mode 100644 policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py create mode 100644 policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md new file mode 100644 index 00000000000..c4598a23b94 --- /dev/null +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -0,0 +1 @@ +Fix Idaho's subtraction for health insurance premiums, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election. diff --git a/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml b/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml index ee01efbc66d..b2e90d71d34 100644 --- a/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml +++ b/policyengine_us/parameters/gov/states/id/tax/income/subtractions/subtractions.yaml @@ -11,6 +11,7 @@ values: - workers_compensation # (13) - id_retirement_benefits_deduction # (15) - id_529_plan_subtraction # Idaho Code 63-3022(n) + - id_health_insurance_premiums_subtraction # Idaho Code 63-3022P 2025-01-01: - salt_refund_income # (2) - us_govt_interest # (3)(a) @@ -23,6 +24,7 @@ values: - id_retirement_benefits_deduction # (15) - id_military_retirement_deduction # (15) per HB 40 - id_529_plan_subtraction # Idaho Code 63-3022(n) + - id_health_insurance_premiums_subtraction # Idaho Code 63-3022P metadata: @@ -34,7 +36,9 @@ metadata: href: https://law.justia.com/codes/idaho/2022/title-63/chapter-30/section-63-3022d/ - title: Individual Income Tax Forms and Instructions 2021 href: https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_11-15-2021.pdf#page=30 - - title: Idaho Individual Income Tax Instructions Packet 2025 + - title: "Idaho Individual Income Tax Instructions Packet 2025 #page 33,48-49" href: https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=33 + - title: Idaho Code Section 63-3022P - Health insurance costs + href: https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ - title: Section 35.01.01.254 - (5) / (3) (a) href: https://regulations.justia.com/states/idaho/35/35-01-01/section-35-01-01-254/ diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py index d10534624fe..62450596743 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_deductions.py @@ -9,13 +9,14 @@ class id_deductions(Variable): definition_period = YEAR reference = ( "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # (j) - "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-01-2023.pdf#page=8", + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=10", ) defined_for = StateCode.ID def formula(tax_unit, period, parameters): itm_ded = tax_unit("id_itemized_deductions", period) std_ded = tax_unit("standard_deduction", period) - # Idaho qualified business income and federal Schedule 1-A deductions - # are modeled separately. - return max_(itm_ded, std_ded) + itemizes = tax_unit("id_itemizes", period) + # The election accounts for the associated health premium subtraction. + # Qualified business income and Schedule 1-A deductions are separate. + return where(itemizes, itm_ded, std_ded) diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py new file mode 100644 index 00000000000..42ea389c970 --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class id_itemizes(Variable): + value_type = bool + entity = TaxUnit + label = "Tax unit itemizes deductions for Idaho" + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # (j) + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 10, 48-49 (Form 40 election and Form 39R premiums). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=10", + ) + documentation = ( + "Choose the larger combined Idaho deduction and health premium " + "subtraction. Itemization removes health premiums already included " + "in the medical deduction; the standard election preserves them. " + "Ties retain the standard deduction, unless itemization is required." + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + medical = tax_unit("medical_expense_deduction", period) + # Form 39R allocates itemized medical deductions to health premiums first. + overlap = min_(premiums, medical) + itemized = tax_unit("id_itemized_deductions", period) + standard = tax_unit("standard_deduction", period) + mandatory = tax_unit("separate_filer_itemizes", period) + return mandatory | (itemized > standard + overlap) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py new file mode 100644 index 00000000000..ced23ce9c95 --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class id_health_insurance_premiums_subtraction(Variable): + value_type = float + entity = TaxUnit + label = "Idaho health insurance premiums subtraction" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and its worksheet). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + medical = tax_unit("medical_expense_deduction", period) + overlap = min_(premiums, medical) + itemizes = tax_unit("id_itemizes", period) + return premiums - where(itemizes, overlap, 0) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py new file mode 100644 index 00000000000..621d58b89d5 --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class id_qualified_health_insurance_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Idaho health insurance premiums not excluded or deducted elsewhere" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and its worksheet). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + ) + documentation = ( + "Premiums for the taxpayer, spouse, and dependents, before removing " + "the portion used in Idaho itemized deductions. Reported pretax " + "premiums are treated as a subset of total reported premiums. " + "Self-employed premiums already deducted federally are excluded, " + "including deductions on dependents' own returns." + ) + + def formula(tax_unit, period, parameters): + premiums = add(tax_unit, period, ["medical_expense_health_insurance_premiums"]) + pretax = add(tax_unit, period, ["pre_tax_health_insurance_premiums"]) + deducted_elsewhere = add( + tax_unit, + period, + [ + "self_employed_health_insurance_ald", + "dependents_self_employed_health_insurance_ald", + ], + ) + return max_(0, premiums - pretax - deducted_elsewhere) From cea944532110ad4dfd357201c8d3b06cf10e5855 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 09:29:49 -0400 Subject: [PATCH 07/45] Correct Idaho benchmark tax component assertions --- .../income/id_income_tax_before_refundable_credits.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml index 75200bf5f29..d9e70d30606 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_income_tax_before_refundable_credits.yaml @@ -66,8 +66,8 @@ state_code: ID household_vehicles_value: 2660.0 output: - id_income_tax_before_refundable_credits: 655.54 - state_income_tax_before_refundable_credits: 665.54 # Includes the $10 PBF tax on current main. + id_income_tax_before_refundable_credits: 645.54 + state_income_tax_before_refundable_credits: 655.54 # Includes the $10 PBF tax. - name: PolicyBench scenario_053 Idaho paid health insurance premiums absolute_error_margin: 0.01 period: 2026 @@ -119,5 +119,5 @@ auto_loan_interest: 950.0 household_vehicles_value: 7410.0 output: - id_income_tax_before_refundable_credits: 2180.28 - state_income_tax_before_refundable_credits: 2190.28 # Includes the $10 PBF tax on current main. + id_income_tax_before_refundable_credits: 2170.28 + state_income_tax_before_refundable_credits: 2180.28 # Includes the $10 PBF tax. From f44901ee32b0df5ce39eed4ff732a3e5203470f0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 10:15:15 -0400 Subject: [PATCH 08/45] Set self-employment fact in dependent premium regression --- .../subtractions/id_health_insurance_premiums_subtraction.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index c95ec279539..456052c085b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -96,6 +96,7 @@ age: 20 health_insurance_premiums: 500 self_employment_income: 5_000 + is_self_employed: true is_tax_unit_dependent: true tax_units: tax_unit: From fe2e9b93f7af14ded22921f8236d344e8b458152 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 18:15:20 -0400 Subject: [PATCH 09/45] Fix Idaho premium subtraction payer attribution --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../core/test_id_health_insurance_premiums.py | 66 +++++++++++++++---- ...health_insurance_premiums_subtraction.yaml | 60 ++++++++++++++++- .../id_qualified_health_insurance_premiums.py | 27 ++++---- 4 files changed, 127 insertions(+), 28 deletions(-) diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index c4598a23b94..695c40809c5 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election. diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index bd23b20a3d0..c7f354ee0b0 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -11,8 +11,8 @@ AMOUNT = st.integers(min_value=0, max_value=50_000) ROW = st.fixed_dictionaries( { - "premiums": st.tuples(AMOUNT, AMOUNT), - "pre_tax": st.tuples(AMOUNT, AMOUNT), + "premiums": st.tuples(AMOUNT, AMOUNT, AMOUNT), + "pre_tax": st.tuples(AMOUNT, AMOUNT, AMOUNT), "above_line": AMOUNT, "dependent_above_line": AMOUNT, "medical": AMOUNT, @@ -28,18 +28,18 @@ @example( rows=[ { - "premiums": (1_000, 500), - "pre_tax": (0, 0), + "premiums": (1_000, 500, 1_000), + "pre_tax": (0, 0, 0), "above_line": 0, - "dependent_above_line": 0, + "dependent_above_line": 900, "medical": 500, "standard": 10_000, "itemized": 10_500, "mandatory": False, }, { - "premiums": (0, 0), - "pre_tax": (0, 0), + "premiums": (0, 0, 500), + "pre_tax": (0, 0, 0), "above_line": 0, "dependent_above_line": 0, "medical": 0, @@ -49,6 +49,40 @@ }, ] ) +@example( + rows=[ + { + "premiums": (1_000, 0, 500), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 0, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + { + "premiums": (1_000, 0, 500), + "pre_tax": (0, 0, 0), + "above_line": 0, + "dependent_above_line": 500, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + { + "premiums": (1_000, 400, 500), + "pre_tax": (100, 50, 500), + "above_line": 150, + "dependent_above_line": 500, + "medical": 0, + "standard": 16_100, + "itemized": 0, + "mandatory": False, + }, + ] +) def test_premium_subtraction_and_election_match_best_legal_route(rows): people = {} tax_units = {} @@ -58,10 +92,13 @@ def test_premium_subtraction_and_election_match_best_legal_route(rows): expected_reductions = [] available_premiums = [] for index, row in enumerate(rows): - members = [f"person_{index}_{member}" for member in range(2)] + members = [f"person_{index}_{member}" for member in range(3)] for member, person_id in enumerate(members): people[person_id] = { - "age": {PERIOD: 35}, + "age": {PERIOD: (40, 39, 20)[member]}, + "is_tax_unit_head": {PERIOD: member == 0}, + "is_tax_unit_spouse": {PERIOD: member == 1}, + "is_tax_unit_dependent": {PERIOD: member == 2}, "health_insurance_premiums": {PERIOD: row["premiums"][member]}, "pre_tax_health_insurance_premiums": {PERIOD: row["pre_tax"][member]}, } @@ -84,12 +121,12 @@ def test_premium_subtraction_and_election_match_best_legal_route(rows): # Enumerate the standard and itemized routes using scalar arithmetic. # Their combined reduction, rather than deduction alone, determines # the favorable election; a spouse's itemization overrides that choice. + # Premium inputs identify the payer: only the head and spouse's + # payments enter this return. The dependent's payments and deductions + # belong on the dependent's own return. available = max( 0, - sum(row["premiums"]) - - sum(row["pre_tax"]) - - row["above_line"] - - row["dependent_above_line"], + sum(row["premiums"][:2]) - sum(row["pre_tax"][:2]) - row["above_line"], ) unclaimed_if_itemizing = max(0, available - row["medical"]) standard_route = (row["standard"] + available, False, available) @@ -121,6 +158,9 @@ def test_premium_subtraction_and_election_match_best_legal_route(rows): ) deductions = simulation.calculate("id_deductions", PERIOD) + assert simulation.calculate( + "id_qualified_health_insurance_premiums", PERIOD + ).tolist() == pytest.approx(available_premiums) assert simulation.calculate("id_itemizes", PERIOD).tolist() == expected_itemizes assert subtraction.tolist() == pytest.approx(expected_subtractions) assert (deductions + subtraction).tolist() == pytest.approx(expected_reductions) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index 456052c085b..d9e3718ff2e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -84,7 +84,65 @@ output: id_health_insurance_premiums_subtraction: 0 -- name: Dependent premiums deducted on their own federal return are excluded +- name: Premiums paid by a dependent do not qualify on the parent's return + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + standard_deduction: 15_750 + id_itemized_deductions: 0 + medical_expense_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + id_qualified_health_insurance_premiums: 1_000 + id_health_insurance_premiums_subtraction: 1_000 + +- name: Both filers' payments qualify while dependent pretax payments are excluded + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + health_insurance_premiums: 2_000 + pre_tax_health_insurance_premiums: 500 + is_tax_unit_spouse: true + dependent: + age: 20 + health_insurance_premiums: 500 + pre_tax_health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + standard_deduction: 31_500 + id_itemized_deductions: 0 + medical_expense_deduction: 0 + households: + household: + members: [head, spouse, dependent] + state_code: ID + output: + id_qualified_health_insurance_premiums: 2_500 + id_health_insurance_premiums_subtraction: 2_500 + +- name: A dependent's federal deduction cannot reduce the filers' own premiums period: 2026 input: people: diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py index 621d58b89d5..26ed3d3c99d 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py @@ -14,22 +14,23 @@ class id_qualified_health_insurance_premiums(Variable): "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", ) documentation = ( - "Premiums for the taxpayer, spouse, and dependents, before removing " - "the portion used in Idaho itemized deductions. Reported pretax " + "Premiums paid by the taxpayer and spouse for themselves and their " + "dependents, before removing the portion used in Idaho itemized " + "deductions. Reported pretax " "premiums are treated as a subset of total reported premiums. " - "Self-employed premiums already deducted federally are excluded, " - "including deductions on dependents' own returns." + "Self-employed premiums already deducted on the filers' federal " + "return are excluded. Dependents' own payments are excluded " + "regardless of their federal deduction." ) def formula(tax_unit, period, parameters): - premiums = add(tax_unit, period, ["medical_expense_health_insurance_premiums"]) - pretax = add(tax_unit, period, ["pre_tax_health_insurance_premiums"]) - deducted_elsewhere = add( - tax_unit, - period, - [ - "self_employed_health_insurance_ald", - "dependents_self_employed_health_insurance_ald", - ], + premiums = tax_unit_non_dep_add( + tax_unit, period, ["medical_expense_health_insurance_premiums"] ) + pretax = tax_unit_non_dep_add( + tax_unit, period, ["pre_tax_health_insurance_premiums"] + ) + # Dependents' federal deductions concern their excluded payments, + # so they must not reduce the filers' qualifying premiums again. + deducted_elsewhere = tax_unit("self_employed_health_insurance_ald", period) return max_(0, premiums - pretax - deducted_elsewhere) From 3e6a2962189ab66c63a6eefa02cb640ea52327e8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 20:56:31 -0400 Subject: [PATCH 10/45] Fix claimant medical overlap for Idaho health premiums --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../states/id/tax/income/id_deductions.yaml | 33 ++++ ...health_insurance_premiums_subtraction.yaml | 158 ++++++++++++++++++ .../id/tax/income/deductions/id_itemizes.py | 2 +- ...th_insurance_premiums_medical_deduction.py | 37 ++++ ...d_health_insurance_premiums_subtraction.py | 2 +- 6 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index 695c40809c5..da4026be9b2 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election using claimant-paid medical expenses for both calculations. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml index bb9249af83b..e70f41c30d1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_deductions.yaml @@ -62,3 +62,36 @@ state_code: ID output: id_deductions: 1_000 + +- name: Claimant medical overlap determines the combined deduction election + # Form 39R worksheet: claimant medical deduction is $750, not $1,250. + # Itemized route: $16,750 + $2,250 = $19,000. + # Standard route: $15,750 + $3,000 = $18,750. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 16_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemizes: true + id_deductions: 16_750 + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 11_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index d9e3718ff2e..5ffced98873 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -168,3 +168,161 @@ output: dependents_self_employed_health_insurance_ald: 500 id_health_insurance_premiums_subtraction: 1_000 + +- name: Dependent-paid premiums cannot reduce the parent's itemized subtraction + # Idaho Code 63-3022P; 2025 Form 39R worksheet, PDF page 49, + # lines 1-6 and 8: $3,000 claimant costs - $2,250 floor = $750 overlap. + # Leave medical_expense_deduction derived to reproduce review r2's P2. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_750 + +- name: Dependent payments cannot push claimant medical expenses above the floor + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 2_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 250 + id_health_insurance_premiums_medical_deduction: 0 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_000 + +- name: Claimant medical overlap includes spouse expenses and excludes dependent expenses + # Claimants paid $5,000 premiums + $2,000 other costs; the floor is $6,000. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + other_medical_expenses: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + health_insurance_premiums: 2_000 + other_medical_expenses: 1_000 + is_tax_unit_spouse: true + dependent: + age: 20 + health_insurance_premiums: 500 + other_medical_expenses: 4_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, dependent] + adjusted_gross_income: 80_000 + standard_deduction: 31_500 + id_itemized_deductions: 40_000 + households: + household: + members: [head, spouse, dependent] + state_code: ID + output: + medical_expense_deduction: 5_500 + id_health_insurance_premiums_medical_deduction: 1_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 4_000 + +- name: Excluded premiums cannot enter derived claimant medical overlap + # Form 39R excludes pretax and Schedule 1 amounts before the worksheet. + # Eligible medical costs: $6,000 - $2,000 - $1,000 + $500 = $3,500. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 4_250 + id_qualified_health_insurance_premiums: 3_000 + id_health_insurance_premiums_medical_deduction: 1_250 + id_itemizes: true + id_health_insurance_premiums_subtraction: 1_750 + +- name: A smaller actual federal medical deduction limits claimant overlap + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + medical_expense_deduction: 500 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_medical_deduction: 500 + id_health_insurance_premiums_subtraction: 2_500 + +- name: An actual claimant medical deduction can include otherwise unreported expenses + # Supply the Idaho claimant deduction when its underlying expense facts + # are not modeled; an aggregate federal amount alone cannot identify payers. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + medical_expense_deduction: 3_000 + id_health_insurance_premiums_medical_deduction: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + id_health_insurance_premiums_subtraction: 0 diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py index 42ea389c970..0b719d1bf76 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemizes.py @@ -22,7 +22,7 @@ class id_itemizes(Variable): def formula(tax_unit, period, parameters): premiums = tax_unit("id_qualified_health_insurance_premiums", period) - medical = tax_unit("medical_expense_deduction", period) + medical = tax_unit("id_health_insurance_premiums_medical_deduction", period) # Form 39R allocates itemized medical deductions to health premiums first. overlap = min_(premiums, medical) itemized = tax_unit("id_itemized_deductions", period) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py new file mode 100644 index 00000000000..eb9261aaedb --- /dev/null +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class id_health_insurance_premiums_medical_deduction(Variable): + value_type = float + entity = TaxUnit + label = "Claimant medical deduction for Idaho health premium subtraction" + unit = USD + definition_period = YEAR + defined_for = StateCode.ID + reference = ( + "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", + # PDF pages 48-49 (Form 39R, line 18 and worksheet lines 1-8). + "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + ) + documentation = ( + "Medical deduction used to allocate the claimant's health premium " + "overlap. Only costs paid by the head and spouse enter the medical " + "floor calculation, including their payments covering dependents. " + "Premiums excluded or deducted elsewhere are removed first. The " + "actual federal medical deduction limits the result. Supply this " + "Idaho value directly when the claimant's actual aggregate deduction " + "includes expenses not represented in person-level medical inputs." + ) + + def formula(tax_unit, period, parameters): + premiums = tax_unit("id_qualified_health_insurance_premiums", period) + other_medical = tax_unit_non_dep_add( + tax_unit, period, ["other_medical_expenses"] + ) + p = parameters(period).gov.irs.deductions.itemized.medical + medical_floor = p.floor * tax_unit("positive_agi", period) + # Form 39R lines 1-6 use costs claimed on this return, not payments + # independently made by dependents and aggregated by the federal model. + claimant_medical = max_(0, premiums + other_medical - medical_floor) + federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) + return min_(federal_medical, claimant_medical) diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py index ced23ce9c95..fbe4fcebf28 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.py @@ -16,7 +16,7 @@ class id_health_insurance_premiums_subtraction(Variable): def formula(tax_unit, period, parameters): premiums = tax_unit("id_qualified_health_insurance_premiums", period) - medical = tax_unit("medical_expense_deduction", period) + medical = tax_unit("id_health_insurance_premiums_medical_deduction", period) overlap = min_(premiums, medical) itemizes = tax_unit("id_itemizes", period) return premiums - where(itemizes, overlap, 0) From 2e206a3f762c4313734a43c58022be0e3387fc2a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 21:21:26 -0400 Subject: [PATCH 11/45] Correct claimant medical costs in Idaho itemized deductions --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../id/tax/income/id_itemized_deductions.yaml | 70 +++++++++++++++++++ .../deductions/id_itemized_deductions.py | 33 ++++++++- 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index da4026be9b2..854aa319f94 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction with the Idaho deduction election using claimant-paid medical expenses for both calculations. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml index b40cca82519..00ba7c13efc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml @@ -18,6 +18,76 @@ output: id_itemized_deductions: 0 +- name: Derived Idaho itemized deductions exclude independently paid dependent medical costs + # 2025 Form 39R worksheet: claimant medical deduction is $750. + # $20,000 interest + $750 medical + $2,250 premiums = $23,000 reduction. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + interest_deduction: 20_000 + salt_deduction: 0 + id_salt_deduction: 0 + standard_deduction: 15_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + itemized_taxable_income_deductions: 21_250 + id_itemized_deductions: 20_750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_000 + +- name: Corrected Idaho medical costs also reduce the 2026 itemized limitation + # P.L. 119-21 sec. 70111: $70,000 * (1 - 2/37) = $66,216.21622. + # Subtracting $500 from the already-limited federal total would be too low. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 100_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SINGLE + adjusted_gross_income: 800_000 + interest_deduction: 30_000 + salt_deduction: 0 + id_salt_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 40_500 + id_health_insurance_premiums_medical_deduction: 40_000 + itemized_taxable_income_deductions: 66_689.18919 + id_itemized_deductions: 66_216.21622 + id_itemizes: true + id_health_insurance_premiums_subtraction: 60_000 + - name: Idaho does not add back the federal foreign tax credit when it is claimed as a credit period: 2025 input: diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py index 9d51f1abe43..b24821635be 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py @@ -12,6 +12,7 @@ class id_itemized_deductions(Variable): "https://tax.idaho.gov/wp-content/uploads/forms/EFO00089/EFO00089_09-23-2021.pdf", "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022/", # Idaho Code 63-3022 (subtractions/itemized framework) "https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=13", # Idaho Form 39R - Additions and Subtractions instructions (foreign-tax addback rule) + "https://www.govinfo.gov/content/pkg/PLAW-119publ21/html/PLAW-119publ21.htm", # P.L. 119-21, sec. 70111 (IRC 68, from 2026) ) defined_for = StateCode.ID @@ -26,4 +27,34 @@ def formula(tax_unit, period, parameters): # are consistent with no unconditional addback here. id_salt_ded = tax_unit("id_salt_deduction", period) itemized_ded = tax_unit("itemized_taxable_income_deductions", period) - return max_(itemized_ded - id_salt_ded, 0) + federal_medical = tax_unit("medical_expense_deduction", period) + claimant_medical = tax_unit( + "id_health_insurance_premiums_medical_deduction", period + ) + medical_adjustment = federal_medical - claimant_medical + + # Correct the medical component of derived itemized totals as well as + # premium overlap, so dependent-paid costs do not remain deducted here. + limitation_adjustment = 0 + p = parameters(period).gov.irs.deductions.itemized.limitation + if p.applies and p.obbb.applies: + # IRC 68 applies after the medical deduction is limited. Removing + # invalid medical costs can also reduce the 2/37 limitation. + total = tax_unit("total_itemized_taxable_income_deductions", period) + corrected_total = max_(0, total - medical_adjustment) + filing_status = tax_unit("filing_status", period) + threshold = parameters(period).gov.irs.income.bracket.thresholds["6"][ + filing_status + ] + agi = tax_unit("adjusted_gross_income", period) + exemptions = tax_unit("exemptions", period) + excess = max_(0, agi - exemptions - threshold) + limitation_adjustment = p.obbb.rate * ( + min_(total, excess) - min_(corrected_total, excess) + ) + # The legacy Pease ceiling excludes medical deductions, so its + # reduction does not change when this medical component is corrected. + return max_( + 0, + itemized_ded - medical_adjustment + limitation_adjustment - id_salt_ded, + ) From 5be0e8a1e19f571aac226f4a19c7e632308147fe Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 22:34:46 -0400 Subject: [PATCH 12/45] Preserve supplied medical deductions in Idaho premium overlap --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../core/test_id_health_insurance_premiums.py | 47 ++++++++++++++++ ...health_insurance_premiums_subtraction.yaml | 54 +++++++++++++++++-- policyengine_us/tools/general.py | 1 + policyengine_us/tools/period_branch.py | 26 +++++++++ ...th_insurance_premiums_medical_deduction.py | 17 ++++-- 6 files changed, 139 insertions(+), 8 deletions(-) diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index 854aa319f94..0a67cb51cd7 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical deductions. diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index c7f354ee0b0..cd7344c63b2 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -1,6 +1,7 @@ """Compare Idaho premium election with independently enumerated legal routes.""" from hypothesis import example, given, settings, strategies as st +import numpy as np import pytest from policyengine_us import Simulation @@ -168,3 +169,49 @@ def test_premium_subtraction_and_election_match_best_legal_route(rows): 0 <= actual <= available for actual, available in zip(subtraction, available_premiums) ) + + +def test_actual_medical_deduction_is_scoped_to_period_and_branch(): + years = (2025, 2026) + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "head": { + "age": {year: 40 for year in years}, + "health_insurance_premiums": {year: 3_000 for year in years}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["head"], + "adjusted_gross_income": {year: 30_000 for year in years}, + "medical_expense_deduction": {2026: 3_000}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {year: "ID" for year in years}, + } + }, + }, + ) + # Create branches before calculating the helper so they inherit no + # previously calculated Idaho value. A sibling's input must not change + # the parent's derived claim, but a nested branch inherits that input. + actual_claim = simulation.get_branch("actual_claim") + actual_claim.set_input("medical_expense_deduction", 2025, np.array([3_000])) + nested_claim = actual_claim.get_branch("nested_claim") + deleted_claim = simulation.get_branch("deleted_claim") + deleted_claim.set_input("medical_expense_deduction", 2025, np.array([3_000])) + deleted_claim.delete_arrays("medical_expense_deduction", 2025) + + variable = "id_health_insurance_premiums_medical_deduction" + assert simulation.calculate(variable, 2025).tolist() == [750] + assert simulation.calculate(variable, 2026).tolist() == [3_000] + assert actual_claim.calculate(variable, 2025).tolist() == [3_000] + assert nested_claim.calculate(variable, 2025).tolist() == [3_000] + # Core retains source keys after deletion; an absent input array must + # not turn the newly calculated federal deduction into an actual claim. + assert deleted_claim.calculate(variable, 2025).tolist() == [750] diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index 5ffced98873..1daf97b66c5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -299,8 +299,8 @@ id_health_insurance_premiums_subtraction: 2_500 - name: An actual claimant medical deduction can include otherwise unreported expenses - # Supply the Idaho claimant deduction when its underlying expense facts - # are not modeled; an aggregate federal amount alone cannot identify payers. + # An explicit Idaho claimant deduction can override the supplied federal + # amount when the caller has a different state-specific medical claim. period: 2025 input: people: @@ -316,7 +316,7 @@ tax_unit: members: [head, dependent] adjusted_gross_income: 30_000 - medical_expense_deduction: 3_000 + medical_expense_deduction: 4_000 id_health_insurance_premiums_medical_deduction: 3_000 standard_deduction: 15_750 id_itemized_deductions: 20_000 @@ -325,4 +325,52 @@ members: [head, dependent] state_code: ID output: + id_health_insurance_premiums_medical_deduction: 3_000 id_health_insurance_premiums_subtraction: 0 + +- name: A supplied Schedule A medical deduction includes unreported underlying expenses + # Form 39R worksheet lines 6 and 8 use the actual Schedule A deduction. + # The supplied $3,000 includes $2,250 of other bills omitted from inputs; + # reconstructing from premiums alone would incorrectly allow $2,250 twice. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + medical_expense_deduction: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + id_health_insurance_premiums_medical_deduction: 3_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 0 + id_taxable_income: 10_000 + +- name: A supplied medical deduction for another year cannot override claimant scope + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + medical_expense_deduction: + 2026: 3_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_health_insurance_premiums_subtraction: 2_250 diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 3762e1dc1e2..445c53b6f78 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -5,6 +5,7 @@ from policyengine_us.tools.period_branch import ( get_branch_for_period, get_override_branch, + has_input_for_period, ) from pathlib import Path import pandas as pd diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 5e47797cf20..79da32851b6 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -73,6 +73,32 @@ def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: } +def has_input_for_period(simulation: Simulation, variable: str, period: Period) -> bool: + """Whether the visible array for this exact period was set as an input. + + Applies to variables with a finite definition period, including variables + that also have formulas. A stored array in a nearer branch shadows arrays + from its ancestors, whether the nearer value was supplied or calculated. + Sibling inputs and inputs from another period do not apply. + + Call before calculating the queried variable. Core retains input keys + after deleting an array; a subsequently computed cache at that same key + cannot be distinguished from the deleted input by this metadata. + """ + period = to_period(period) + holder = simulation.get_holder(variable) + if holder.variable.definition_period == ETERNITY: + raise ValueError("has_input_for_period does not support eternal variables.") + if holder.variable.is_neutralized: + return False + stored_keys = set(holder.get_known_branch_periods()) + input_keys = _input_keys(simulation) + for branch_name in simulation._get_visible_branch_names(): + if (branch_name, period) in stored_keys: + return (variable, branch_name, period) in input_keys + return False + + def drop_inherited_values(branch: Simulation) -> None: """Delete every array ``branch`` holds except inputs. diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py index eb9261aaedb..9ac0b807bd5 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py @@ -18,12 +18,22 @@ class id_health_insurance_premiums_medical_deduction(Variable): "overlap. Only costs paid by the head and spouse enter the medical " "floor calculation, including their payments covering dependents. " "Premiums excluded or deducted elsewhere are removed first. The " - "actual federal medical deduction limits the result. Supply this " - "Idaho value directly when the claimant's actual aggregate deduction " - "includes expenses not represented in person-level medical inputs." + "actual federal medical deduction limits the derived result. A " + "federal medical deduction supplied for this period represents the " + "claimant's actual Schedule A deduction and is used directly, even " + "when its underlying expenses are not represented in person inputs." ) def formula(tax_unit, period, parameters): + # An actual Schedule A deduction is authoritative. Check its source + # before calculation: a cached formula result is not an input, and + # an input for another period or branch does not describe this claim. + supplied_medical = has_input_for_period( + tax_unit.simulation, "medical_expense_deduction", period + ) + federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) + if supplied_medical: + return federal_medical premiums = tax_unit("id_qualified_health_insurance_premiums", period) other_medical = tax_unit_non_dep_add( tax_unit, period, ["other_medical_expenses"] @@ -33,5 +43,4 @@ def formula(tax_unit, period, parameters): # Form 39R lines 1-6 use costs claimed on this return, not payments # independently made by dependents and aggregated by the federal model. claimant_medical = max_(0, premiums + other_medical - medical_floor) - federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) return min_(federal_medical, claimant_medical) From 93ae8550a22eee100c3b2157cc8bc53484b3e821 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 22:44:49 -0400 Subject: [PATCH 13/45] Preserve authoritative federal itemized totals for Idaho --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../id/tax/income/id_itemized_deductions.yaml | 69 +++++++++++++++++++ .../deductions/id_itemized_deductions.py | 10 +++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index 0a67cb51cd7..c71f865bfcb 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical deductions. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical and itemized deductions. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml index 00ba7c13efc..e931e17dbdd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/id_itemized_deductions.yaml @@ -100,3 +100,72 @@ # the itemized deduction (already inside itemized_taxable_income_deductions), # not when it is claimed as a federal credit. id_itemized_deductions: 0 + +- name: A supplied final federal itemized total already excludes dependent-paid medical costs + # The actual $20,000 Schedule A total includes claimant medical expenses; + # subtracting the derived federal medical overstatement again is incorrect. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + adjusted_gross_income: 30_000 + itemized_taxable_income_deductions: 20_000 + id_salt_deduction: 0 + standard_deduction: 15_750 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 1_250 + id_health_insurance_premiums_medical_deduction: 750 + id_itemized_deductions: 20_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_250 + id_taxable_income: 7_750 + +- name: A supplied raw federal itemized total receives the 2026 limitation once + # The actual $70,000 already uses claimant medical expenses; applying the + # 2/37 limitation gives $66,216.21622 without a second medical adjustment. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 100_000 + is_tax_unit_head: true + dependent: + age: 20 + health_insurance_premiums: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, dependent] + filing_status: SINGLE + adjusted_gross_income: 800_000 + interest_deduction: 30_000 + total_itemized_taxable_income_deductions: 70_000 + salt_deduction: 0 + id_salt_deduction: 0 + households: + household: + members: [head, dependent] + state_code: ID + output: + medical_expense_deduction: 40_500 + id_health_insurance_premiums_medical_deduction: 40_000 + itemized_taxable_income_deductions: 66_216.21622 + id_itemized_deductions: 66_216.21622 + id_itemizes: true + id_health_insurance_premiums_subtraction: 60_000 diff --git a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py index b24821635be..b406cb6a7ed 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py +++ b/policyengine_us/variables/gov/states/id/tax/income/deductions/id_itemized_deductions.py @@ -17,6 +17,14 @@ class id_itemized_deductions(Variable): defined_for = StateCode.ID def formula(tax_unit, period, parameters): + # Supplied Schedule A totals already represent the claimant's + # actual deductions, even if component expenses are incomplete. + # Check their provenance before calculating either aggregate. + supplied_itemized = has_input_for_period( + tax_unit.simulation, "itemized_taxable_income_deductions", period + ) or has_input_for_period( + tax_unit.simulation, "total_itemized_taxable_income_deductions", period + ) # Idaho reduces the federal itemized deductions by the SALT deduction. # When foreign tax is claimed as a federal itemized deduction, it is # already in itemized_taxable_income_deductions; when claimed as a @@ -27,6 +35,8 @@ def formula(tax_unit, period, parameters): # are consistent with no unconditional addback here. id_salt_ded = tax_unit("id_salt_deduction", period) itemized_ded = tax_unit("itemized_taxable_income_deductions", period) + if supplied_itemized: + return max_(0, itemized_ded - id_salt_ded) federal_medical = tax_unit("medical_expense_deduction", period) claimant_medical = tax_unit( "id_health_insurance_premiums_medical_deduction", period From d8d077d006bf8770db6e95fa364142796a3cc6cd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 02:38:48 -0400 Subject: [PATCH 14/45] Honor supplied Schedule A medical expenses for Idaho premiums --- ...health_insurance_premiums_subtraction.yaml | 40 +++++++++++++++++++ ...th_insurance_premiums_medical_deduction.py | 24 +++++++---- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index 1daf97b66c5..ebe306705a2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -374,3 +374,43 @@ medical_expense_deduction: 1_250 id_health_insurance_premiums_medical_deduction: 750 id_health_insurance_premiums_subtraction: 2_250 + +- name: A supplied Schedule A expense subtotal includes unreported claimant bills + # Review r3's P2; Form 39R worksheet, PDF page 49, lines 1-6 and 8. + # $6,000 claimant expenses - $2,250 floor = $3,750 medical deduction, + # exhausting the $3,000 premiums even without person-level other bills. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 3_000 + adjusted_gross_income: 30_000 + itemized_medical_expenses: 6_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 3_750 + id_health_insurance_premiums_medical_deduction: 3_750 + id_itemizes: true + id_health_insurance_premiums_subtraction: 0 + id_taxable_income: 10_000 + +- name: A supplied Schedule A expense subtotal already excludes disallowed premiums + # Schedule A line 1 excludes pretax premiums and the Schedule 1 deduction. + # $6,000 premiums - $2,000 pretax - $1,000 above-line + $500 other bills + # = $3,500 supplied expenses; subtract only the $2,250 medical floor. + period: 2025 + input: + state_code: ID + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald: 1_000 + adjusted_gross_income: 30_000 + itemized_medical_expenses: 3_500 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + output: + medical_expense_deduction: 1_250 + id_qualified_health_insurance_premiums: 3_000 + id_health_insurance_premiums_medical_deduction: 1_250 + id_itemizes: true + id_health_insurance_premiums_subtraction: 1_750 diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py index 9ac0b807bd5..142676ae8d0 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py @@ -12,6 +12,7 @@ class id_health_insurance_premiums_medical_deduction(Variable): "https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/", # PDF pages 48-49 (Form 39R, line 18 and worksheet lines 1-8). "https://tax.idaho.gov/wp-content/uploads/forms/EIN00046/EIN00046_03-02-2026.pdf#page=48", + "https://www.irs.gov/instructions/i1040sca", ) documentation = ( "Medical deduction used to allocate the claimant's health premium " @@ -19,17 +20,24 @@ class id_health_insurance_premiums_medical_deduction(Variable): "floor calculation, including their payments covering dependents. " "Premiums excluded or deducted elsewhere are removed first. The " "actual federal medical deduction limits the derived result. A " - "federal medical deduction supplied for this period represents the " - "claimant's actual Schedule A deduction and is used directly, even " - "when its underlying expenses are not represented in person inputs." + "federal medical deduction or expense subtotal supplied for this " + "period represents the claimant's actual Schedule A amounts and is " + "honored even when its underlying expenses are not represented in " + "person inputs. A supplied Schedule A expense subtotal already " + "excludes pretax premiums and premiums deducted on Schedule 1." ) def formula(tax_unit, period, parameters): - # An actual Schedule A deduction is authoritative. Check its source - # before calculation: a cached formula result is not an input, and - # an input for another period or branch does not describe this claim. - supplied_medical = has_input_for_period( - tax_unit.simulation, "medical_expense_deduction", period + # Actual Schedule A deductions and expense subtotals are authoritative. + # Subtotals already exclude pretax and Schedule 1 premiums, so do not + # remove those amounts again. Check the source before calculation: + # cached results and other periods or branches do not describe this claim. + supplied_medical = any( + has_input_for_period(tax_unit.simulation, variable, period) + for variable in ( + "medical_expense_deduction", + "itemized_medical_expenses", + ) ) federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) if supplied_medical: From 1b71b7fd0350d3e82676eafa5437699fcccea87d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 03:08:16 -0400 Subject: [PATCH 15/45] Clear deleted input provenance before recalculation --- .../core/test_id_health_insurance_premiums.py | 62 ++++++++++++++++++- .../tests/core/test_override_branches.py | 35 +++++++++++ policyengine_us/tools/period_branch.py | 50 ++++++++++++++- 3 files changed, 142 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index cd7344c63b2..9641fc53ece 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -3,6 +3,7 @@ from hypothesis import example, given, settings, strategies as st import numpy as np import pytest +from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_us import Simulation from policyengine_us.system import system as SYSTEM @@ -212,6 +213,63 @@ def test_actual_medical_deduction_is_scoped_to_period_and_branch(): assert simulation.calculate(variable, 2026).tolist() == [3_000] assert actual_claim.calculate(variable, 2025).tolist() == [3_000] assert nested_claim.calculate(variable, 2025).tolist() == [3_000] - # Core retains source keys after deletion; an absent input array must - # not turn the newly calculated federal deduction into an actual claim. + # Deletion removes the input's provenance along with its stored array; + # a newly calculated federal deduction is not an actual claim input. assert deleted_claim.calculate(variable, 2025).tolist() == [750] + + +@pytest.mark.parametrize( + "first", + ["medical_expense_deduction", "id_health_insurance_premiums_medical_deduction"], +) +@pytest.mark.parametrize("simulation_type", [Simulation, CoreSimulation]) +@pytest.mark.parametrize("delete_api", ["simulation", "holder"]) +def test_deleted_medical_input_stays_derived_after_recalculation( + first, simulation_type, delete_api +): + simulation = simulation_type( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "head": { + "age": {2025: 40}, + "health_insurance_premiums": {2025: 3_000}, + }, + "dependent": { + "age": {2025: 20}, + "is_tax_unit_dependent": {2025: True}, + "health_insurance_premiums": {2025: 500}, + }, + }, + "tax_units": { + "tax_unit": { + "members": ["head", "dependent"], + "adjusted_gross_income": {2025: 30_000}, + "standard_deduction": {2025: 15_750}, + "id_itemized_deductions": {2025: 20_000}, + } + }, + "households": { + "household": { + "members": ["head", "dependent"], + "state_code": {2025: "ID"}, + } + }, + }, + ) + simulation.set_input("medical_expense_deduction", 2025, np.array([3_000])) + if delete_api == "holder": + simulation.get_holder("medical_expense_deduction").delete_arrays(2025) + else: + simulation.delete_arrays("medical_expense_deduction", 2025) + simulation.calculate(first, 2025) + # Federal aggregation includes the dependent's own $500 payment. It + # must stay a derived result, never become the claimant's actual input. + assert simulation.calculate("medical_expense_deduction", 2025).tolist() == [1_250] + assert simulation.calculate( + "id_health_insurance_premiums_medical_deduction", 2025 + ).tolist() == [750] + assert simulation.calculate( + "id_health_insurance_premiums_subtraction", 2025 + ).tolist() == [2_250] + assert simulation.calculate("id_taxable_income", 2025).tolist() == [7_750] diff --git a/policyengine_us/tests/core/test_override_branches.py b/policyengine_us/tests/core/test_override_branches.py index 9e46a63644e..eabfc10da10 100644 --- a/policyengine_us/tests/core/test_override_branches.py +++ b/policyengine_us/tests/core/test_override_branches.py @@ -49,6 +49,7 @@ drop_inherited_values, get_branch_for_period, get_override_branch, + has_input_for_period, ) YEAR = 2026 @@ -623,3 +624,37 @@ def test_drop_inherited_values_keeps_inputs_only(households): _fresh(households, "taxable_income", {"tax_unit_itemizes": itemizes}), atol=0.01, ) + + +@pytest.mark.parametrize("delete_period", [2025, None]) +def test_deleted_input_provenance_is_isolated_by_branch_and_period(delete_period): + simulation = Simulation( + situation=_with_tax_unit_inputs( + _situation([REVIEW_HOUSEHOLD], years=YEARS), + {"medical_expense_deduction": (2025, 3_000)}, + ) + ) + variable = "medical_expense_deduction" + simulation.set_input(variable, YEAR, np.array([4_000])) + sibling = simulation.get_branch("sibling") + parent = simulation.get_branch("parent") + parent.set_input(variable, 2025, np.array([5_000])) + existing_child = parent.get_branch("existing_child") + parent.delete_arrays(variable, delete_period) + parent.calculate(variable, 2025) + + # The parent deletes both its own and its inherited input arrays. A + # recalculated cache has no input provenance, including in a new child. + assert not has_input_for_period(parent, variable, 2025) + new_child = parent.get_branch("new_child") + assert not has_input_for_period(new_child, variable, 2025) + drop_inherited_values(new_child) + assert new_child.get_array(variable, 2025) is None + + # Previously cloned holders keep their arrays and their provenance. + for unchanged in (simulation, sibling, existing_child): + assert has_input_for_period(unchanged, variable, 2025) + assert has_input_for_period(unchanged, variable, YEAR) + assert has_input_for_period(parent, variable, YEAR) == (delete_period is not None) + parent.set_input(variable, 2025, np.array([6_000])) + assert has_input_for_period(parent, variable, 2025) diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index 79da32851b6..cb5f1f34c04 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -31,9 +31,11 @@ and deletes the variables it recalculates. """ +from functools import wraps from typing import Dict, Set, Tuple, Union import numpy as np +from policyengine_core.holders import Holder from policyengine_core.periods import ETERNITY, Period from policyengine_core.periods import period as to_period from policyengine_core.simulations import Simulation @@ -81,9 +83,9 @@ def has_input_for_period(simulation: Simulation, variable: str, period: Period) from its ancestors, whether the nearer value was supplied or calculated. Sibling inputs and inputs from another period do not apply. - Call before calculating the queried variable. Core retains input keys - after deleting an array; a subsequently computed cache at that same key - cannot be distinguished from the deleted input by this metadata. + The deletion compatibility wrapper below clears the provenance of + deleted inputs before recalculation, including for Core simulations and + direct holder deletion. """ period = to_period(period) holder = simulation.get_holder(variable) @@ -99,6 +101,48 @@ def has_input_for_period(simulation: Simulation, variable: str, period: Period) return False +def _track_input_deletion(delete_arrays): + """Keep Core's input provenance in sync with its holder deletion. + + Core shares input keys across cloned simulations, but deleting an array + only changes the caller's holder storage. Replace its key set so the + parent's, siblings', and existing children's provenance stays intact. + Delegate storage deletion unchanged and reconcile only the requested + variable and branch. This compatibility hook is needed until Core + removes deleted inputs from ``_user_input_keys`` itself. + """ + + @wraps(delete_arrays) + def delete_with_provenance(holder, period=None, branch_name="default"): + result = delete_arrays(holder, period, branch_name) + simulation = holder.simulation + input_keys = getattr(simulation, "_user_input_keys", set()) + tracked_keys = { + key + for key in input_keys + if key[0] == holder.variable.name and key[1] == branch_name + } + if not tracked_keys: + return result + stored_keys = set(holder.get_known_branch_periods()) + eternal = holder.variable.definition_period == ETERNITY + deleted_keys = { + key + for key in tracked_keys + if (key[1], to_period(ETERNITY) if eternal else key[2]) not in stored_keys + } + if deleted_keys: + simulation._user_input_keys = input_keys - deleted_keys + return result + + delete_with_provenance._tracks_input_deletion = True + return delete_with_provenance + + +if not getattr(Holder.delete_arrays, "_tracks_input_deletion", False): + Holder.delete_arrays = _track_input_deletion(Holder.delete_arrays) + + def drop_inherited_values(branch: Simulation) -> None: """Delete every array ``branch`` holds except inputs. From b9324a04fb53d536e66df257b7a841a84e7a414f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 04:28:15 -0400 Subject: [PATCH 16/45] Remove global input deletion patch from Idaho premiums fix --- .../core/test_id_health_insurance_premiums.py | 63 +---------------- .../tests/core/test_override_branches.py | 35 ---------- policyengine_us/tools/general.py | 27 ++++++- policyengine_us/tools/period_branch.py | 70 ------------------- 4 files changed, 29 insertions(+), 166 deletions(-) diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index 9641fc53ece..4387c433619 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -3,7 +3,6 @@ from hypothesis import example, given, settings, strategies as st import numpy as np import pytest -from policyengine_core.simulations import Simulation as CoreSimulation from policyengine_us import Simulation from policyengine_us.system import system as SYSTEM @@ -213,63 +212,7 @@ def test_actual_medical_deduction_is_scoped_to_period_and_branch(): assert simulation.calculate(variable, 2026).tolist() == [3_000] assert actual_claim.calculate(variable, 2025).tolist() == [3_000] assert nested_claim.calculate(variable, 2025).tolist() == [3_000] - # Deletion removes the input's provenance along with its stored array; - # a newly calculated federal deduction is not an actual claim input. + # Check before federal recalculation, while the deleted input's array is + # absent. Core retains its input key, so recalculating first is a separate + # provenance defect documented in the PR's known limitation. assert deleted_claim.calculate(variable, 2025).tolist() == [750] - - -@pytest.mark.parametrize( - "first", - ["medical_expense_deduction", "id_health_insurance_premiums_medical_deduction"], -) -@pytest.mark.parametrize("simulation_type", [Simulation, CoreSimulation]) -@pytest.mark.parametrize("delete_api", ["simulation", "holder"]) -def test_deleted_medical_input_stays_derived_after_recalculation( - first, simulation_type, delete_api -): - simulation = simulation_type( - tax_benefit_system=SYSTEM, - situation={ - "people": { - "head": { - "age": {2025: 40}, - "health_insurance_premiums": {2025: 3_000}, - }, - "dependent": { - "age": {2025: 20}, - "is_tax_unit_dependent": {2025: True}, - "health_insurance_premiums": {2025: 500}, - }, - }, - "tax_units": { - "tax_unit": { - "members": ["head", "dependent"], - "adjusted_gross_income": {2025: 30_000}, - "standard_deduction": {2025: 15_750}, - "id_itemized_deductions": {2025: 20_000}, - } - }, - "households": { - "household": { - "members": ["head", "dependent"], - "state_code": {2025: "ID"}, - } - }, - }, - ) - simulation.set_input("medical_expense_deduction", 2025, np.array([3_000])) - if delete_api == "holder": - simulation.get_holder("medical_expense_deduction").delete_arrays(2025) - else: - simulation.delete_arrays("medical_expense_deduction", 2025) - simulation.calculate(first, 2025) - # Federal aggregation includes the dependent's own $500 payment. It - # must stay a derived result, never become the claimant's actual input. - assert simulation.calculate("medical_expense_deduction", 2025).tolist() == [1_250] - assert simulation.calculate( - "id_health_insurance_premiums_medical_deduction", 2025 - ).tolist() == [750] - assert simulation.calculate( - "id_health_insurance_premiums_subtraction", 2025 - ).tolist() == [2_250] - assert simulation.calculate("id_taxable_income", 2025).tolist() == [7_750] diff --git a/policyengine_us/tests/core/test_override_branches.py b/policyengine_us/tests/core/test_override_branches.py index eabfc10da10..9e46a63644e 100644 --- a/policyengine_us/tests/core/test_override_branches.py +++ b/policyengine_us/tests/core/test_override_branches.py @@ -49,7 +49,6 @@ drop_inherited_values, get_branch_for_period, get_override_branch, - has_input_for_period, ) YEAR = 2026 @@ -624,37 +623,3 @@ def test_drop_inherited_values_keeps_inputs_only(households): _fresh(households, "taxable_income", {"tax_unit_itemizes": itemizes}), atol=0.01, ) - - -@pytest.mark.parametrize("delete_period", [2025, None]) -def test_deleted_input_provenance_is_isolated_by_branch_and_period(delete_period): - simulation = Simulation( - situation=_with_tax_unit_inputs( - _situation([REVIEW_HOUSEHOLD], years=YEARS), - {"medical_expense_deduction": (2025, 3_000)}, - ) - ) - variable = "medical_expense_deduction" - simulation.set_input(variable, YEAR, np.array([4_000])) - sibling = simulation.get_branch("sibling") - parent = simulation.get_branch("parent") - parent.set_input(variable, 2025, np.array([5_000])) - existing_child = parent.get_branch("existing_child") - parent.delete_arrays(variable, delete_period) - parent.calculate(variable, 2025) - - # The parent deletes both its own and its inherited input arrays. A - # recalculated cache has no input provenance, including in a new child. - assert not has_input_for_period(parent, variable, 2025) - new_child = parent.get_branch("new_child") - assert not has_input_for_period(new_child, variable, 2025) - drop_inherited_values(new_child) - assert new_child.get_array(variable, 2025) is None - - # Previously cloned holders keep their arrays and their provenance. - for unchanged in (simulation, sibling, existing_child): - assert has_input_for_period(unchanged, variable, 2025) - assert has_input_for_period(unchanged, variable, YEAR) - assert has_input_for_period(parent, variable, YEAR) == (delete_period is not None) - parent.set_input(variable, 2025, np.array([6_000])) - assert has_input_for_period(parent, variable, 2025) diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 445c53b6f78..2a290392374 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -1,11 +1,11 @@ from policyengine_core.model_api import * +from policyengine_core.periods import period as to_period from policyengine_core.projectors import Projector from policyengine_us.entities import * from policyengine_us.tools.branched_simulation import BranchedSimulation from policyengine_us.tools.period_branch import ( get_branch_for_period, get_override_branch, - has_input_for_period, ) from pathlib import Path import pandas as pd @@ -14,6 +14,31 @@ USD = "currency-USD" +def has_input_for_period(simulation, variable, period): + """Whether the visible array for this exact period was set as an input. + + A nearer branch's stored value shadows its ancestors. Inputs from sibling + branches or other periods do not apply. Only finite definition periods + are supported. + + Core can retain an input key after deletion. If that value has already + been recalculated, this read-only check cannot distinguish it from a + supplied value. + """ + period = to_period(period) + holder = simulation.get_holder(variable) + if holder.variable.definition_period == ETERNITY: + raise ValueError("has_input_for_period does not support eternal variables.") + if holder.variable.is_neutralized: + return False + stored_keys = set(holder.get_known_branch_periods()) + input_keys = getattr(simulation, "_user_input_keys", set()) + for branch_name in simulation._get_visible_branch_names(): + if (branch_name, period) in stored_keys: + return (variable, branch_name, period) in input_keys + return False + + def tax_unit_non_dep_sum(var, tax_unit, period): return tax_unit.sum( tax_unit.members(var, period) diff --git a/policyengine_us/tools/period_branch.py b/policyengine_us/tools/period_branch.py index cb5f1f34c04..5e47797cf20 100644 --- a/policyengine_us/tools/period_branch.py +++ b/policyengine_us/tools/period_branch.py @@ -31,11 +31,9 @@ and deletes the variables it recalculates. """ -from functools import wraps from typing import Dict, Set, Tuple, Union import numpy as np -from policyengine_core.holders import Holder from policyengine_core.periods import ETERNITY, Period from policyengine_core.periods import period as to_period from policyengine_core.simulations import Simulation @@ -75,74 +73,6 @@ def _input_keys(branch: Simulation) -> Set[Tuple[str, str, Period]]: } -def has_input_for_period(simulation: Simulation, variable: str, period: Period) -> bool: - """Whether the visible array for this exact period was set as an input. - - Applies to variables with a finite definition period, including variables - that also have formulas. A stored array in a nearer branch shadows arrays - from its ancestors, whether the nearer value was supplied or calculated. - Sibling inputs and inputs from another period do not apply. - - The deletion compatibility wrapper below clears the provenance of - deleted inputs before recalculation, including for Core simulations and - direct holder deletion. - """ - period = to_period(period) - holder = simulation.get_holder(variable) - if holder.variable.definition_period == ETERNITY: - raise ValueError("has_input_for_period does not support eternal variables.") - if holder.variable.is_neutralized: - return False - stored_keys = set(holder.get_known_branch_periods()) - input_keys = _input_keys(simulation) - for branch_name in simulation._get_visible_branch_names(): - if (branch_name, period) in stored_keys: - return (variable, branch_name, period) in input_keys - return False - - -def _track_input_deletion(delete_arrays): - """Keep Core's input provenance in sync with its holder deletion. - - Core shares input keys across cloned simulations, but deleting an array - only changes the caller's holder storage. Replace its key set so the - parent's, siblings', and existing children's provenance stays intact. - Delegate storage deletion unchanged and reconcile only the requested - variable and branch. This compatibility hook is needed until Core - removes deleted inputs from ``_user_input_keys`` itself. - """ - - @wraps(delete_arrays) - def delete_with_provenance(holder, period=None, branch_name="default"): - result = delete_arrays(holder, period, branch_name) - simulation = holder.simulation - input_keys = getattr(simulation, "_user_input_keys", set()) - tracked_keys = { - key - for key in input_keys - if key[0] == holder.variable.name and key[1] == branch_name - } - if not tracked_keys: - return result - stored_keys = set(holder.get_known_branch_periods()) - eternal = holder.variable.definition_period == ETERNITY - deleted_keys = { - key - for key in tracked_keys - if (key[1], to_period(ETERNITY) if eternal else key[2]) not in stored_keys - } - if deleted_keys: - simulation._user_input_keys = input_keys - deleted_keys - return result - - delete_with_provenance._tracks_input_deletion = True - return delete_with_provenance - - -if not getattr(Holder.delete_arrays, "_tracks_input_deletion", False): - Holder.delete_arrays = _track_input_deletion(Holder.delete_arrays) - - def drop_inherited_values(branch: Simulation) -> None: """Delete every array ``branch`` holds except inputs. From f89b859cdf204e76238389af1b011792c9136ffa Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 05:23:22 -0400 Subject: [PATCH 17/45] Use disjoint premium inputs for Idaho health insurance subtraction --- .../core/test_id_health_insurance_premiums.py | 9 ++- ...health_insurance_premiums_subtraction.yaml | 71 ++++++++++++++----- ...th_insurance_premiums_medical_deduction.py | 6 +- .../id_qualified_health_insurance_premiums.py | 11 ++- 4 files changed, 70 insertions(+), 27 deletions(-) diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index 4387c433619..6ab549b31eb 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -124,10 +124,15 @@ def test_premium_subtraction_and_election_match_best_legal_route(rows): # the favorable election; a spouse's itemization overrides that choice. # Premium inputs identify the payer: only the head and spouse's # payments enter this return. The dependent's payments and deductions - # belong on the dependent's own return. + # belong on the dependent's own return. Pretax payroll premiums are + # a separate leaf input and never reduce these after-tax payments. + # The explicit pretax example has 1,000 + 400 - 150 = 1,250 available, + # irrespective of its separate 100 + 50 = 150 pretax premiums. + # Its standard route retains 1,250 and reduces income by + # 16,100 + 1,250 = 17,350, exceeding itemized 0 + 1,250 = 1,250. available = max( 0, - sum(row["premiums"][:2]) - sum(row["pre_tax"][:2]) - row["above_line"], + sum(row["premiums"][:2]) - row["above_line"], ) unclaimed_if_itemizing = max(0, available - row["medical"]) standard_route = (row["standard"] + available, False, available) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index ebe306705a2..d3018aea5e2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -9,7 +9,9 @@ output: id_health_insurance_premiums_subtraction: 6_000 -- name: Pretax premiums and federal above-line deductions cannot be subtracted again +- name: Pretax premiums are a separate input and never enter the subtraction + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # The separate $2,000 pretax input has no effect; standard retains $5,000. period: 2026 input: state_code: ID @@ -19,7 +21,7 @@ standard_deduction: 16_100 id_itemized_deductions: 0 output: - id_health_insurance_premiums_subtraction: 3_000 + id_health_insurance_premiums_subtraction: 5_000 - name: Itemizers allocate medical deductions to health premiums first period: 2026 @@ -110,7 +112,10 @@ id_qualified_health_insurance_premiums: 1_000 id_health_insurance_premiums_subtraction: 1_000 -- name: Both filers' payments qualify while dependent pretax payments are excluded +- name: Both filers' premiums qualify independently of separate pretax payments + # Qualified premiums: $1,000 head + $2,000 spouse = $3,000. + # The dependent's $500 payment is excluded; both pretax inputs are separate. + # Standard deduction retains the full $3,000 subtraction. period: 2025 input: people: @@ -139,8 +144,8 @@ members: [head, spouse, dependent] state_code: ID output: - id_qualified_health_insurance_premiums: 2_500 - id_health_insurance_premiums_subtraction: 2_500 + id_qualified_health_insurance_premiums: 3_000 + id_health_insurance_premiums_subtraction: 3_000 - name: A dependent's federal deduction cannot reduce the filers' own premiums period: 2026 @@ -265,9 +270,13 @@ id_itemizes: true id_health_insurance_premiums_subtraction: 4_000 -- name: Excluded premiums cannot enter derived claimant medical overlap - # Form 39R excludes pretax and Schedule 1 amounts before the worksheet. - # Eligible medical costs: $6,000 - $2,000 - $1,000 + $500 = $3,500. +- name: Derived claimant medical overlap uses premiums excluding pretax payroll payments + # Pretax payroll premiums are separate; remove only the Schedule 1 amount. + # Qualified premiums: $6,000 - $1,000 = $5,000; floor: $30,000 * 7.5% = $2,250. + # Federal medical: $6,000 + $500 - $2,250 = $4,250 (unchanged). + # Claimant overlap: min($4,250, $5,000 + $500 - $2,250) = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. period: 2025 input: state_code: ID @@ -280,8 +289,8 @@ id_itemized_deductions: 20_000 output: medical_expense_deduction: 4_250 - id_qualified_health_insurance_premiums: 3_000 - id_health_insurance_premiums_medical_deduction: 1_250 + id_qualified_health_insurance_premiums: 5_000 + id_health_insurance_premiums_medical_deduction: 3_250 id_itemizes: true id_health_insurance_premiums_subtraction: 1_750 @@ -395,9 +404,12 @@ id_taxable_income: 10_000 - name: A supplied Schedule A expense subtotal already excludes disallowed premiums - # Schedule A line 1 excludes pretax premiums and the Schedule 1 deduction. - # $6,000 premiums - $2,000 pretax - $1,000 above-line + $500 other bills - # = $3,500 supplied expenses; subtract only the $2,250 medical floor. + # The $6,000 premium input already excludes separate pretax payroll premiums. + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # Supplied expenses: $5,000 + $500 unreported other bills = $5,500. + # Federal and claimant medical: $5,500 - $30,000 * 7.5% = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. period: 2025 input: state_code: ID @@ -405,12 +417,37 @@ pre_tax_health_insurance_premiums: 2_000 self_employed_health_insurance_ald: 1_000 adjusted_gross_income: 30_000 - itemized_medical_expenses: 3_500 + itemized_medical_expenses: 5_500 standard_deduction: 15_750 id_itemized_deductions: 20_000 output: - medical_expense_deduction: 1_250 - id_qualified_health_insurance_premiums: 3_000 - id_health_insurance_premiums_medical_deduction: 1_250 + medical_expense_deduction: 3_250 + id_qualified_health_insurance_premiums: 5_000 + id_health_insurance_premiums_medical_deduction: 3_250 id_itemizes: true id_health_insurance_premiums_subtraction: 1_750 + +- name: Identical households have identical Idaho outputs with or without pretax premiums + # Each household has $6,000 premiums excluding pretax payroll payments. + # Qualified premiums: $6,000 - $1,000 Schedule 1 deduction = $5,000. + # Claimant overlap: $5,000 + $500 - $30,000 * 7.5% = $3,250. + # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding + # standard route $15,750 + $5,000 = $20,750; subtraction is $1,750. + # Deductions: $20,000; taxable income: $30,000 - $20,000 - $1,750 = $8,250. + period: 2025 + input: + state_code: [ID, ID] + health_insurance_premiums: [6_000, 6_000] + pre_tax_health_insurance_premiums: [0, 2_000] + self_employed_health_insurance_ald: [1_000, 1_000] + other_medical_expenses: [500, 500] + adjusted_gross_income: [30_000, 30_000] + standard_deduction: [15_750, 15_750] + id_itemized_deductions: [20_000, 20_000] + output: + id_qualified_health_insurance_premiums: [5_000, 5_000] + id_health_insurance_premiums_medical_deduction: [3_250, 3_250] + id_itemizes: [true, true] + id_health_insurance_premiums_subtraction: [1_750, 1_750] + id_deductions: [20_000, 20_000] + id_taxable_income: [8_250, 8_250] diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py index 142676ae8d0..04860677ccf 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_medical_deduction.py @@ -18,7 +18,8 @@ class id_health_insurance_premiums_medical_deduction(Variable): "Medical deduction used to allocate the claimant's health premium " "overlap. Only costs paid by the head and spouse enter the medical " "floor calculation, including their payments covering dependents. " - "Premiums excluded or deducted elsewhere are removed first. The " + "Premium inputs already exclude pretax payroll premiums; premiums " + "deducted on Schedule 1 are removed first. The " "actual federal medical deduction limits the derived result. A " "federal medical deduction or expense subtotal supplied for this " "period represents the claimant's actual Schedule A amounts and is " @@ -29,7 +30,8 @@ class id_health_insurance_premiums_medical_deduction(Variable): def formula(tax_unit, period, parameters): # Actual Schedule A deductions and expense subtotals are authoritative. - # Subtotals already exclude pretax and Schedule 1 premiums, so do not + # Pretax payroll premiums are separate from the premium inputs. + # Supplied subtotals already exclude Schedule 1 premiums, so do not # remove those amounts again. Check the source before calculation: # cached results and other periods or branches do not describe this claim. supplied_medical = any( diff --git a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py index 26ed3d3c99d..c523305214a 100644 --- a/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/id/tax/income/subtractions/id_qualified_health_insurance_premiums.py @@ -16,8 +16,10 @@ class id_qualified_health_insurance_premiums(Variable): documentation = ( "Premiums paid by the taxpayer and spouse for themselves and their " "dependents, before removing the portion used in Idaho itemized " - "deductions. Reported pretax " - "premiums are treated as a subset of total reported premiums. " + "deductions. The premium inputs exclude premiums paid through " + "pretax payroll deductions. Those premiums are reported separately " + "in pre_tax_health_insurance_premiums, which never enters this " + "subtraction. " "Self-employed premiums already deducted on the filers' federal " "return are excluded. Dependents' own payments are excluded " "regardless of their federal deduction." @@ -27,10 +29,7 @@ def formula(tax_unit, period, parameters): premiums = tax_unit_non_dep_add( tax_unit, period, ["medical_expense_health_insurance_premiums"] ) - pretax = tax_unit_non_dep_add( - tax_unit, period, ["pre_tax_health_insurance_premiums"] - ) # Dependents' federal deductions concern their excluded payments, # so they must not reduce the filers' qualifying premiums again. deducted_elsewhere = tax_unit("self_employed_health_insurance_ald", period) - return max_(0, premiums - pretax - deducted_elsewhere) + return max_(0, premiums - deducted_elsewhere) From 6657e9331136cc7602684f02ff2f43547270f577 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 05:23:29 -0400 Subject: [PATCH 18/45] Track federal-first medical input deletion limitation with strict xfail --- .../core/test_id_health_insurance_premiums.py | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index 6ab549b31eb..51de941cc14 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -221,3 +221,56 @@ def test_actual_medical_deduction_is_scoped_to_period_and_branch(): # absent. Core retains its input key, so recalculating first is a separate # provenance defect documented in the PR's known limitation. assert deleted_claim.calculate(variable, 2025).tolist() == [750] + + +@pytest.mark.xfail( + strict=True, + reason=( + "Core retains deleted input provenance and misclassifies a federal " + "medical deduction recalculated before the Idaho helper as supplied." + ), +) +def test_deleted_medical_input_recalculated_federal_first(): + year = 2025 + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": { + "head": { + "age": {year: 40}, + "is_tax_unit_head": {year: True}, + "health_insurance_premiums": {year: 3_000}, + }, + "dependent": { + "age": {year: 20}, + "is_tax_unit_dependent": {year: True}, + "health_insurance_premiums": {year: 500}, + }, + }, + "tax_units": { + "tax_unit": { + "members": ["head", "dependent"], + "adjusted_gross_income": {year: 30_000}, + "standard_deduction": {year: 15_750}, + "id_itemized_deductions": {year: 20_000}, + } + }, + "households": { + "household": { + "members": ["head", "dependent"], + "state_code": {year: "ID"}, + } + }, + }, + ) + simulation.set_input("medical_expense_deduction", year, np.array([3_000])) + simulation.delete_arrays("medical_expense_deduction", year) + + # Federal first: 3,000 + 500 - 30,000 * 7.5% = 1,250. + assert simulation.calculate("medical_expense_deduction", year).tolist() == [1_250] + # Idaho excludes the dependent's payment: medical overlap is + # 3,000 - 30,000 * 7.5% = 750, leaving 3,000 - 750 = 2,250. + # The known provenance defect instead uses 1,250 and returns 1,750. + assert simulation.calculate( + "id_health_insurance_premiums_subtraction", year + ).tolist() == [2_250] From 186995cf34a0605d28ac9aacc226398e0ad24173 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 07:32:34 -0400 Subject: [PATCH 19/45] test: cover modeled Medicare premiums in Idaho subtraction --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 2 +- .../gov/states/id/tax/income/integration.yaml | 8 +++- ...health_insurance_premiums_subtraction.yaml | 44 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md index c71f865bfcb..5563b17bd5e 100644 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ b/changelog.d/hub-pb-id-premiums-1009.fixed.md @@ -1 +1 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical and itemized deductions. +Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, including modeled Medicare premiums for enrolled filers, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical and itemized deductions. diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml index a96995acc78..4c16b814021 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/integration.yaml @@ -265,4 +265,10 @@ additional_senior_deduction: 6_000 id_additional_senior_deduction: 6_000 id_qualified_business_income_and_federal_schedule_1a_deductions: 6_000 - id_taxable_income: 134_000 + # Idaho Code 63-3022P and 2025 Form 39R line 18 allow Medicare B premiums: + # https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ + # https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=11 + # Modeled premiums: 2 * $185 * 12 = $4,440, below the $15,000 medical floor. + # Taxable income: $200,000 - $60,000 - $6,000 - $4,440 = $129,560. + id_health_insurance_premiums_subtraction: 4_440 + id_taxable_income: 129_560 diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index d3018aea5e2..b7d9991ce35 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -67,6 +67,50 @@ output: id_health_insurance_premiums_subtraction: 3_000 +- name: Medicare-enrolled joint filers qualify for modeled Part B premiums without premium inputs + # Idaho Code 63-3022P; 2025 Form 39R line 18 and worksheet: + # https://legislature.idaho.gov/statutesrules/idstat/Title63/T63CH30/SECT63-3022P/ + # https://tax.idaho.gov/wp-content/uploads/forms/EFO00088/EFO00088_03-02-2026.pdf#page=11 + # Each filer pays the modeled $185 monthly premium: 2 * $185 * 12 = $4,440. + # The $200,000 * 7.5% = $15,000 floor leaves no Schedule A medical overlap. + # Taxable income: $200,000 - $60,000 itemized - $6,000 senior - $4,440. + period: 2025 + input: + people: + head: + age: 70 + taxable_interest_income: 100_000 + medicare_enrolled: true + is_tax_unit_head: true + is_tax_unit_head_or_spouse: true + ssn_card_type: CITIZEN + spouse: + age: 70 + taxable_interest_income: 100_000 + medicare_enrolled: true + is_tax_unit_spouse: true + is_tax_unit_head_or_spouse: true + ssn_card_type: CITIZEN + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + itemized_taxable_income_deductions: 60_000 + id_salt_deduction: 0 + households: + household: + members: [head, spouse] + state_code: ID + output: + medicare_part_b_premium: [2_220, 2_220] + medical_expense_health_insurance_premiums: [2_220, 2_220] + medical_expense_deduction: 0 + id_qualified_health_insurance_premiums: 4_440 + id_health_insurance_premiums_medical_deduction: 0 + id_itemizes: true + id_health_insurance_premiums_subtraction: 4_440 + id_taxable_income: 129_560 + - name: Premium subtraction is included in the existing 2021 subtraction list period: 2021 input: From d3f56815167726b809be586b9510882852751565 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 07:58:48 -0400 Subject: [PATCH 20/45] test: gate Idaho deleted-input xfail on affected core versions --- .../core/test_id_health_insurance_premiums.py | 15 ++++++++++----- policyengine_us/tools/general.py | 6 +++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/policyengine_us/tests/core/test_id_health_insurance_premiums.py b/policyengine_us/tests/core/test_id_health_insurance_premiums.py index 51de941cc14..b27b2be36ac 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -1,7 +1,10 @@ """Compare Idaho premium election with independently enumerated legal routes.""" +from importlib.metadata import version + from hypothesis import example, given, settings, strategies as st import numpy as np +from packaging.version import Version import pytest from policyengine_us import Simulation @@ -218,16 +221,18 @@ def test_actual_medical_deduction_is_scoped_to_period_and_branch(): assert actual_claim.calculate(variable, 2025).tolist() == [3_000] assert nested_claim.calculate(variable, 2025).tolist() == [3_000] # Check before federal recalculation, while the deleted input's array is - # absent. Core retains its input key, so recalculating first is a separate - # provenance defect documented in the PR's known limitation. + # absent. Core versions before 3.32.27 retain its input key, so recalculating + # first triggers the provenance defect documented in the PR's limitation. assert deleted_claim.calculate(variable, 2025).tolist() == [750] @pytest.mark.xfail( + condition=Version(version("policyengine-core")) < Version("3.32.27"), strict=True, reason=( - "Core retains deleted input provenance and misclassifies a federal " - "medical deduction recalculated before the Idaho helper as supplied." + "policyengine-core<3.32.27 retains deleted input provenance and " + "misclassifies a federal medical deduction recalculated before the " + "Idaho helper as supplied; fixed by policyengine-core#561." ), ) def test_deleted_medical_input_recalculated_federal_first(): @@ -270,7 +275,7 @@ def test_deleted_medical_input_recalculated_federal_first(): assert simulation.calculate("medical_expense_deduction", year).tolist() == [1_250] # Idaho excludes the dependent's payment: medical overlap is # 3,000 - 30,000 * 7.5% = 750, leaving 3,000 - 750 = 2,250. - # The known provenance defect instead uses 1,250 and returns 1,750. + # Core's provenance defect before 3.32.27 uses 1,250 and returns 1,750. assert simulation.calculate( "id_health_insurance_premiums_subtraction", year ).tolist() == [2_250] diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index 2a290392374..5e9b7accd28 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -21,9 +21,9 @@ def has_input_for_period(simulation, variable, period): branches or other periods do not apply. Only finite definition periods are supported. - Core can retain an input key after deletion. If that value has already - been recalculated, this read-only check cannot distinguish it from a - supplied value. + Core versions before 3.32.27 can retain an input key after deletion. If + that value has already been recalculated, this read-only check cannot + distinguish it from a supplied value. Core 3.32.27 fixes this in #561. """ period = to_period(period) holder = simulation.get_holder(variable) From b3c28021510ad48425ca460266eccefe48172341 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 08:46:30 -0400 Subject: [PATCH 21/45] Exclude SE health insurance deductions from Schedule A medical expenses --- .../hub-schedA-se-premiums-1010.fixed.md | 1 + ...st_medical_expense_premium_conservation.py | 199 ++++++++++++++++++ .../itemizing/medical_expense_deduction.yaml | 135 ++++++++++++ .../itemized/al_medical_deduction.yaml | 13 ++ .../ar_medical_expense_deduction_indiv.yaml | 11 + .../ar_medical_expense_deduction_joint.yaml | 11 + .../itemized/az_itemized_deductions.yaml | 15 ++ .../hi_medical_expense_deduction.yaml | 11 + ...o_qualified_health_insurance_premiums.yaml | 17 ++ .../mt_medical_expense_deduction_indiv.yaml | 32 +++ .../mt_medical_expense_deduction_joint.yaml | 31 +++ .../deductions/nc_itemized_deductions.yaml | 15 ++ .../nd_renters_refund_income.yaml | 13 ++ .../nj_medical_expense_deduction.yaml | 64 ++++++ .../credits/nm_medical_expense_credit.yaml | 14 ++ .../nm_medical_expense_exemption.yaml | 13 ++ .../tax/income/state_itemized_deductions.yaml | 16 ++ .../itemizing/itemized_medical_expenses.py | 25 ++- .../mt_medical_expense_deduction_indiv.py | 9 +- .../nd_renters_refund_income.py | 11 +- .../nj_medical_expense_deduction.py | 22 +- .../credits/nm_medical_expense_credit.py | 8 +- .../nm_medical_expense_exemption.py | 8 +- 23 files changed, 682 insertions(+), 12 deletions(-) create mode 100644 changelog.d/hub-schedA-se-premiums-1010.fixed.md create mode 100644 policyengine_us/tests/core/test_medical_expense_premium_conservation.py diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md new file mode 100644 index 00000000000..549a85622b6 --- /dev/null +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -0,0 +1 @@ +Prevent self-employed health insurance premiums deducted above the line from also entering federal and conforming state medical deductions, while preserving independent state medical-expense benefits. diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py new file mode 100644 index 00000000000..d17bec18cd3 --- /dev/null +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -0,0 +1,199 @@ +"""Premium conservation between Schedule A and the section 162(l) deduction. + +IRC 162(l)(3) excludes premiums deducted under section 162(l) from medical +expenses under section 213. Hypothesis varies earnings caps, covered premium +subsets, direct/decomposed premium inputs, spouses, dependents and separate +tax units. +The independent accounting invariant is that Schedule A's premium base plus +the self-employed health insurance deduction never exceeds premiums paid. +Non-premium medical expenses must remain available in full before the floor. + +Each example uses one small vectorized simulation and a shared read-only +reference system. YAML covers the individual policy examples; these properties +check conservation across generated combinations and filing-unit boundaries. +""" + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +PERIOD = "2025" + + +@pytest.fixture(scope="module") +def reference_system(): + return system + + +@st.composite +def person_expenses(draw): + premiums = draw(st.integers(0, 25_000)) + direct = draw(st.booleans()) + self_employed = draw(st.booleans()) + covered = draw(st.integers(0, premiums)) if self_employed else 0 + # A direct premium can also exercise the default derivation of the SE + # premium input. Decomposed premiums need the covered subset explicitly. + if direct and draw(st.booleans()): + covered = None + return { + "premiums": premiums, + "other": draw(st.integers(0, 10_000)), + "earnings": draw(st.integers(-25_000, 50_000)) if self_employed else 0, + "self_employed": self_employed, + "direct": direct, + "covered": covered, + } + + +units = st.lists( + st.lists(person_expenses(), min_size=1, max_size=3), + min_size=2, + max_size=4, +) + + +@st.composite +def units_with_deduction(draw): + members = draw(units) + deductions = [ + # The head's and spouse's deduction covers their premium pool. A + # dependent's own SE deduction belongs on their separate return. + draw(st.integers(0, sum(person["premiums"] for person in unit[:2]))) + for unit in members + ] + return members, deductions + + +def _simulation(members, reference_system, deductions=None): + people, tax_units, households, marital_units = {}, {}, {}, {} + for unit_index, unit in enumerate(members): + names = [] + for person_index, person in enumerate(unit): + name = f"person_{unit_index}_{person_index}" + names.append(name) + premium_variable = ( + "health_insurance_premiums" + if person["direct"] + else "health_insurance_premiums_without_medicare_part_b" + ) + values = { + "age": 12 if person_index == 2 else 40, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + "is_tax_unit_dependent": person_index == 2, + "is_self_employed": person["self_employed"], + "self_employment_income": person["earnings"], + "medicare_enrolled": False, + premium_variable: person["premiums"], + "other_medical_expenses": person["other"], + } + if person["covered"] is not None: + values["self_employed_health_insurance_premiums"] = person["covered"] + people[name] = { + variable: {PERIOD: value} for variable, value in values.items() + } + tax_unit = {"members": names} + if deductions is not None: + tax_unit["self_employed_health_insurance_ald"] = { + PERIOD: deductions[unit_index] + } + tax_units[f"tax_unit_{unit_index}"] = tax_unit + households[f"household_{unit_index}"] = { + "members": names, + "state_code": {PERIOD: "TX"}, + } + marital_units[f"marital_unit_{unit_index}"] = {"members": names[:2]} + for name in names[2:]: + marital_units[f"dependent_marital_unit_{unit_index}"] = {"members": [name]} + return Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + + +def _assert_conservation(members, simulation): + paid = np.array([sum(person["premiums"] for person in unit) for unit in members]) + other = np.array([sum(person["other"] for person in unit) for unit in members]) + dependent_premiums = np.array( + [sum(person["premiums"] for person in unit[2:]) for unit in members] + ) + medical_base = simulation.calculate("itemized_medical_expenses", PERIOD) + se_deduction = simulation.calculate("self_employed_health_insurance_ald", PERIOD) + schedule_a_premium_use = medical_base - other + + # Neither the SE deduction nor a different filing unit can consume the + # non-premium expenses. Check the bound separately from conservation. + assert (schedule_a_premium_use >= 0).all() + assert (schedule_a_premium_use >= dependent_premiums).all() + assert (schedule_a_premium_use + se_deduction <= paid).all() + # All premiums here are otherwise eligible: unused premiums remain in + # Schedule A's base, even when earnings cap the SE deduction. + np.testing.assert_array_equal(schedule_a_premium_use + se_deduction, paid) + + +EXAMPLE_UNITS = [ + [ + dict( + premiums=6_000, + other=500, + earnings=1_000, + self_employed=True, + direct=True, + covered=None, + ), + dict( + premiums=2_000, + other=300, + earnings=5_000, + self_employed=True, + direct=False, + covered=1_000, + ), + dict( + premiums=12_000, + other=200, + earnings=15_000, + self_employed=True, + direct=True, + covered=None, + ), + ], + [ + dict( + premiums=5_000, + other=700, + earnings=-2_000, + self_employed=True, + direct=True, + covered=None, + ) + ], +] + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example(members=EXAMPLE_UNITS) +@given(members=units) +def test_premiums_are_not_duplicated_with_derived_se_deduction( + reference_system, members +): + _assert_conservation(members, _simulation(members, reference_system)) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example(case=(EXAMPLE_UNITS, [8_000, 0])) +@given(case=units_with_deduction()) +def test_premiums_are_not_duplicated_with_tax_unit_se_deduction(reference_system, case): + members, deductions = case + _assert_conservation( + members, _simulation(members, reference_system, deductions=deductions) + ) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml index 0ed831fd439..420a311338d 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml @@ -6,3 +6,138 @@ output: itemized_medical_expenses: 10_000 medical_expense_deduction: 2_500 + +- name: Schedule A excludes premiums deducted on Schedule 1 before applying its floor. + # 2025 Schedule A instructions, medical insurance premiums and line 1. + period: 2025 + input: + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + medical_expense_health_insurance_premiums: 6_000 + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + +- name: An earnings-limited derived SE deduction leaves unused premiums on Schedule A. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 1_000 + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + +- name: Fully deducted premiums leave only non-premium medical expenses. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: 10_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 6_000 + itemized_medical_expenses: 500 + medical_expense_deduction: 0 + +- name: Self-employment losses do not remove premiums from Schedule A. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: -1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 0 + itemized_medical_expenses: 6_500 + medical_expense_deduction: 4_250 + +- name: The SE deduction cannot reduce other medical expenses when premiums are exhausted. + period: 2025 + input: + health_insurance_premiums: 300 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 0 + output: + itemized_medical_expenses: 500 + medical_expense_deduction: 500 + +- name: The exclusion also applies to decomposed medical and Medicare premiums. + period: 2025 + input: + age: 65 + medicare_enrolled: true + health_insurance_premiums_without_medicare_part_b: 1_200 + medicare_part_b_premium: 2_400 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + medical_expense_health_insurance_premiums: 3_600 + itemized_medical_expenses: 3_100 + medical_expense_deduction: 850 + +- name: Each filing unit excludes its own SE deduction independently of pretax premiums. + period: 2025 + input: + people: + person1: + is_self_employed: true + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 0 + self_employment_income: 1_000 + other_medical_expenses: 500 + person2: + is_self_employed: true + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employment_income: 1_000 + other_medical_expenses: 500 + person3: + health_insurance_premiums: 6_000 + self_employment_income: 0 + other_medical_expenses: 500 + tax_units: + first: + members: [person1] + adjusted_gross_income: 30_000 + second: + members: [person2] + adjusted_gross_income: 30_000 + third: + members: [person3] + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: [1_000, 1_000, 0] + itemized_medical_expenses: [5_500, 5_500, 6_500] + medical_expense_deduction: [3_250, 3_250, 4_250] + +- name: A filer SE deduction cannot consume premiums attributed to a dependent. + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + other_medical_expenses: 100 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + other_medical_expenses: 200 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 1_000 + adjusted_gross_income: 0 + output: + itemized_medical_expenses: 800 + medical_expense_deduction: 800 diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml index 5d0a3298c34..88943f6144e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml @@ -17,3 +17,16 @@ output: al_medical_expense_deduction: 3_400 #7000-0.04 * 90,000=3400 + +- name: Alabama excludes premiums already deducted for self-employed health insurance + period: 2025 + input: + state_code: AL + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + al_agi: 30_000 + output: + # Alabama follows federal eligible expenses, with its own 4% floor. + itemized_medical_expenses: 5_500 + al_medical_expense_deduction: 4_300 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml index 0497db85afa..c86f264256e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml @@ -203,3 +203,14 @@ output: # 9_000 - 0.075 * 50_000; Form AR3 Line 3B: "if either you or your spouse were age 65 or over" ar_medical_expense_deduction_indiv: [5_250, 0] + +- name: Arkansas separate medical deduction excludes self-employed premiums before its 10% floor + period: 2025 + input: + state_code: AR + ar_agi_indiv: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + output: + ar_medical_expense_deduction_indiv: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml index 142d394f993..d059cdafa4b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml @@ -203,3 +203,14 @@ output: # 9_000 - 0.075 * 50_000; Form AR3 Line 3B: "if either you or your spouse were age 65 or over" ar_medical_expense_deduction_joint: [5_250, 0] + +- name: Arkansas joint medical deduction excludes self-employed premiums before its 10% floor + period: 2025 + input: + state_code: AR + ar_agi_joint: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + output: + ar_medical_expense_deduction_joint: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml index 620371ff617..3cfeaae9e08 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml @@ -126,3 +126,18 @@ output: # min($8,000, $10,000) = $8,000 az_itemized_deductions: 8_000 + +- name: Arizona excludes self-employed premiums from its medical deduction without an AGI floor + period: 2025 + input: + state_code: AZ + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + salt_deduction: 0 + charitable_deduction: 0 + interest_deduction: 0 + casualty_loss_deduction: 0 + output: + # A.R.S. 43-1042(B) removes the floor, while retaining federal eligible expenses. + az_itemized_deductions: 5_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml index abb0de0bc96..71b2f64df4f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml @@ -73,3 +73,14 @@ state_code: HI output: hi_medical_expense_deduction: 0 + +- name: Hawaii excludes premiums already deducted for self-employed health insurance + period: 2025 + input: + state_code: HI + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + hi_agi: 30_000 + output: + hi_medical_expense_deduction: 3_250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml index 42dbfce011a..39bc69ecae1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml @@ -92,3 +92,20 @@ output: # Half of their expenses were deducted from federal return. mo_qualified_health_insurance_premiums: [50, 150] + +- name: Missouri's medical overlap ratio follows federal Schedule A after the SE premium exclusion + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + taxable_income: 30_000 + tax_unit_itemizes: true + output: + # Form 5695 lines 10-11 use the corrected federal Schedule A amounts. + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + mo_qualified_health_insurance_premiums: 2_454.54545 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index 3804de55c08..3bf6f36d639 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -27,3 +27,35 @@ is_tax_unit_head_or_spouse: true output: mt_medical_expense_deduction_indiv: 0 + +- name: Montana separate medical deductions exclude each spouse's own self-employed premiums + period: 2023 + input: + people: + head: + age: 40 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + other_medical_expenses: 500 + mt_agi_indiv: 30_000 + mt_married_filing_separately_on_same_return_eligible: true + spouse: + age: 40 + health_insurance_premiums: 3_000 + self_employed_health_insurance_ald_person: 2_000 + other_medical_expenses: 500 + mt_agi_indiv: 10_000 + mt_married_filing_separately_on_same_return_eligible: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_medical_expense_deduction_indiv: [3_250, 750] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index df9e02675bc..5a564b9ef89 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -57,3 +57,34 @@ state_code: MT output: mt_medical_expense_deduction_joint: [0, 0] + +- name: Montana joint medical deduction excludes spouses' self-employed premiums + period: 2023 + input: + people: + head: + age: 40 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + other_medical_expenses: 500 + mt_agi_indiv: 30_000 + spouse: + age: 40 + health_insurance_premiums: 3_000 + self_employed_health_insurance_ald_person: 2_000 + other_medical_expenses: 500 + mt_agi_indiv: 10_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # (6,000 + 3,000 - 1,000 - 2,000 + 1,000) - 7.5% of 40,000. + mt_medical_expense_deduction_joint: [4_000, 0] 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 31a7a288278..eb8ccc0a96c 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 @@ -45,3 +45,18 @@ state_code: NC output: nc_itemized_deductions: 14_000 + +- name: North Carolina follows the federal exclusion for self-employed insurance premiums + period: 2025 + input: + state_code: NC + adjusted_gross_income: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + mortgage_interest: 0 + real_estate_taxes: 0 + charitable_deduction: 0 + output: + medical_expense_deduction: 3_250 + nc_itemized_deductions: 3_250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index 189fd53d517..e232c5afdc6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -62,3 +62,16 @@ state_code: ND output: nd_renters_refund_income: 12_000 + +- name: North Dakota renter refund deducts medical expenses actually paid from gross income + period: 2025 + input: + state_code: ND + employment_income: 30_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 500 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 5_500 + # The property tax program separately deducts the full 6,500 paid. + nd_renters_refund_income: 23_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml index ddcd2cb333c..9dde9ede429 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml @@ -20,6 +20,32 @@ output: nj_medical_expense_deduction: 1_000 +- name: New Jersey preserves dependent premiums when the filer's deduction exceeds reported filer premiums + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + other_medical_expenses: 100 + child: + age: 12 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + other_medical_expenses: 200 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 1_000 + nj_agi: 0 + households: + household: + members: [parent, child] + state_code: NJ + output: + itemized_medical_expenses: 800 + nj_medical_expense_deduction: 1_800 + - name: Partial out of pocket expenses deductable period: 2022 absolute_error_margin: 0 @@ -55,3 +81,41 @@ # child's 1,000 of self-employed health insurance is deducted too. nj_agi: 50_000 nj_medical_expense_deduction: 1_000 + +- name: New Jersey counts self-employed premiums once across its medical deductions + period: 2025 + input: + state_code: NJ + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + nj_agi: 30_000 + output: + # Worksheet F adds the SE deduction after applying the 2% floor to the remainder. + nj_medical_expense_deduction: 5_900 + +- name: New Jersey excludes a dependent's separately deducted premiums from its medical base + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 30_000 + child: + age: 17 + self_employment_income: 10_000 + health_insurance_premiums: 1_000 + is_self_employed: true + other_medical_expenses: 500 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: NJ + output: + nj_agi: 40_000 + dependents_self_employed_health_insurance_ald: 1_000 + # Only the 500 of other expenses remains subject to the 800 floor. + nj_medical_expense_deduction: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml index df55277d73a..d0984e6aa73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml @@ -8,6 +8,20 @@ output: nm_medical_expense_credit: 0 +- name: New Mexico senior medical credit uses expenses paid before federal premium deductions + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + health_insurance_premiums: 6_000 + other_medical_expenses: 22_000 + self_employed_health_insurance_ald: 1_000 + head_is_dependent_elsewhere: false + output: + itemized_medical_expenses: 27_000 + nm_medical_expense_credit: 2_800 + - name: Below expense eligibility period: 2023 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml index 65264be9353..2e3f6b60630 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml @@ -8,6 +8,19 @@ output: nm_medical_expense_exemption: 0 +- name: New Mexico senior medical exemption uses expenses paid before federal premium deductions + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + health_insurance_premiums: 6_000 + other_medical_expenses: 22_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_000 + nm_medical_expense_exemption: 3_000 + - name: Below expense eligibility period: 2023 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml index e11a5c61b86..d0a92fc4e10 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml @@ -11,6 +11,22 @@ output: state_itemized_deductions: 0 +- name: Federal itemized deduction states inherit the self-employed premium exclusion + period: 2025 + input: + state_code: [GA, ND, SC, UT] + adjusted_gross_income: [30_000, 30_000, 30_000, 30_000] + health_insurance_premiums: [6_000, 6_000, 6_000, 6_000] + self_employed_health_insurance_ald: [1_000, 1_000, 1_000, 1_000] + other_medical_expenses: [500, 500, 500, 500] + salt_deduction: [0, 0, 0, 0] + charitable_deduction: [0, 0, 0, 0] + interest_deduction: [0, 0, 0, 0] + casualty_loss_deduction: [0, 0, 0, 0] + output: + medical_expense_deduction: [3_250, 3_250, 3_250, 3_250] + state_itemized_deductions: [3_250, 3_250, 3_250, 3_250] + - name: Federal itemized deductions adopted for Georgia period: 2023 input: diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index 6b2d9b21d76..c8efb378dc3 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -8,8 +8,9 @@ class itemized_medical_expenses(Variable): unit = USD definition_period = YEAR reference = [ - "https://www.law.cornell.edu/uscode/text/26/213#a", - "https://www.law.cornell.edu/uscode/text/26/213#d_1", + "https://www.law.cornell.edu/uscode/text/26/213", + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", ] documentation = ( "Medical expenses counted before applying the itemized medical " @@ -19,10 +20,20 @@ class itemized_medical_expenses(Variable): "medical care; qualified long-term care services; and insurance " "premiums covering medical care. Current modeling uses health " "insurance premiums and other medical expenses, excluding general " - "over-the-counter health expenses." + "over-the-counter health expenses. Premiums deducted through the " + "filer's self-employed health insurance deduction are excluded under " + "IRC Section 162(l)(3) before the medical expense floor is applied." ) - adds = [ - "medical_expense_health_insurance_premiums", - "other_medical_expenses", - ] + def formula(tax_unit, period, parameters): + premiums = add(tax_unit, period, ["medical_expense_health_insurance_premiums"]) + # The federal ALD covers the head and spouse; dependents take their + # own SE deductions on their own returns. Bound the exclusion to the + # corresponding filer premiums, leaving other medical costs intact. + filer_premiums = tax_unit_non_dep_sum( + "medical_expense_health_insurance_premiums", tax_unit, period + ) + se_health_insurance_ald = tax_unit("self_employed_health_insurance_ald", period) + excluded_premiums = min_(filer_premiums, max_(0, se_health_insurance_ald)) + other_expenses = add(tax_unit, period, ["other_medical_expenses"]) + return premiums - excluded_premiums + other_expenses diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index f369afc173e..5142f8c3399 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -9,6 +9,7 @@ class mt_medical_expense_deduction_indiv(Variable): "Montana medical expense deduction when married couples are filing separately" ) reference = ( + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=36", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", # MT Code § 15-30-2131 (2022) (1)(g)(i) @@ -17,7 +18,13 @@ class mt_medical_expense_deduction_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - expense = person("medical_expense_health_insurance_premiums", period) + person( + premiums = person("medical_expense_health_insurance_premiums", period) + se_health_insurance_ald = person( + "self_employed_health_insurance_ald_person", period + ) + # Separately computed medical expenses exclude the same person's + # premiums already deducted from income. + expense = max_(0, premiums - se_health_insurance_ald) + person( "other_medical_expenses", period ) p = parameters(period).gov.irs.deductions.itemized.medical diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index cf6e3da8f99..3f1bd174e0b 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -17,6 +17,15 @@ def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund return max_( add(tax_unit, period, p.income_sources) - - tax_unit("itemized_medical_expenses", period), + # The refund uses gross income and medical costs actually paid, + # rather than expenses net of federal above-the-line deductions. + - add( + tax_unit, + period, + [ + "medical_expense_health_insurance_premiums", + "other_medical_expenses", + ], + ), 0, ) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py index 03bbb514baf..c8154557dc3 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py @@ -8,6 +8,10 @@ class nj_medical_expense_deduction(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NJ + reference = ( + "https://pub.njleg.gov/bills/9899/PL99/222_.HTM", + "https://www.nj.gov/treasury/taxation/pdf/current/1040i.pdf#page=25", + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nj.tax.income.deductions.medical_expenses @@ -21,7 +25,23 @@ def formula(tax_unit, period, parameters): "dependents_self_employed_health_insurance_ald", ], ) - medical_expenses = tax_unit("itemized_medical_expenses", period) + # C.54A:3-3 excludes premiums counted by the separate SE deduction + # under C.54A:3-5, including dependents' deductions recognized by NJ. + # The federal base already excludes the filers' deduction. Apply + # dependents' deductions only to their own premium pool. + dependent_premiums = tax_unit.sum( + tax_unit.members("medical_expense_health_insurance_premiums", period) + * tax_unit.members("is_tax_unit_dependent", period) + ) + dependent_deduction = tax_unit( + "dependents_self_employed_health_insurance_ald", period + ) + excluded_dependent_premiums = min_( + dependent_premiums, max_(0, dependent_deduction) + ) + medical_expenses = ( + tax_unit("itemized_medical_expenses", period) - excluded_dependent_premiums + ) agi = tax_unit("nj_agi", period) floor = p.rate * agi applicable_medical_expenses = max_(0, medical_expenses - floor) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index e516884f45e..460888c60c5 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -14,7 +14,13 @@ def formula(tax_unit, period, parameters): pcredits = parameters(period).gov.states.nm.tax.income.credits p = pcredits.unreimbursed_medical_care_expense age = person("age", period) - medical_expense = tax_unit("itemized_medical_expenses", period) + # This independent senior benefit counts unreimbursed costs paid, + # including expenses also deducted on federal Schedule A. + medical_expense = add( + tax_unit, + period, + ["medical_expense_health_insurance_premiums", "other_medical_expenses"], + ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses dependent_elsewhere = tax_unit("head_is_dependent_elsewhere", period) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index 3b5bed34f4a..77f00a7943d 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -15,7 +15,13 @@ def formula(tax_unit, period, parameters): period ).gov.states.nm.tax.income.exemptions.unreimbursed_medical_care_expense age = person("age", period) - medical_expense = tax_unit("itemized_medical_expenses", period) + # This independent senior benefit counts unreimbursed costs paid, + # including expenses also deducted on federal Schedule A. + medical_expense = add( + tax_unit, + period, + ["medical_expense_health_insurance_premiums", "other_medical_expenses"], + ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses eligible = age_eligible & expense_eligible From f9f6daa395e1ffccf99ba2f0f700e79752f7eef6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 10:36:52 -0400 Subject: [PATCH 22/45] Cap New Jersey dependent medical exclusions per payer --- ...st_medical_expense_premium_conservation.py | 115 +++++++++++++++++- .../nj_medical_expense_deduction.yaml | 32 +++++ .../nj_medical_expense_deduction.py | 27 ++-- 3 files changed, 164 insertions(+), 10 deletions(-) diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py index d17bec18cd3..d663371e119 100644 --- a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -4,6 +4,10 @@ expenses under section 213. Hypothesis varies earnings caps, covered premium subsets, direct/decomposed premium inputs, spouses, dependents and separate tax units. +Premium inputs use payer attribution: a person's inputs include all premiums +that person paid, including family coverage regardless of who is covered. +The federal conservation properties generate deductions within the filers' +paid premiums; beneficiary-assigned inputs cannot establish that invariant. The independent accounting invariant is that Schedule A's premium base plus the self-employed health insurance deduction never exceeds premiums paid. Non-premium medical expenses must remain available in full before the floor. @@ -60,7 +64,8 @@ def person_expenses(draw): def units_with_deduction(draw): members = draw(units) deductions = [ - # The head's and spouse's deduction covers their premium pool. A + # Payer attribution puts parent-paid family premiums on that parent. + # The head's and spouse's deduction covers their paid premium pool. A # dependent's own SE deduction belongs on their separate return. draw(st.integers(0, sum(person["premiums"] for person in unit[:2]))) for unit in members @@ -197,3 +202,111 @@ def test_premiums_are_not_duplicated_with_tax_unit_se_deduction(reference_system _assert_conservation( members, _simulation(members, reference_system, deductions=deductions) ) + + +@st.composite +def nj_dependent_expenses(draw): + return { + "premiums": draw(st.integers(0, 25_000)), + "other": draw(st.integers(0, 10_000)), + # Independent person ALDs deliberately include amounts above the + # person's paid medical premiums, as supported by separate SE inputs. + "deduction": draw(st.integers(0, 50_000)), + } + + +nj_units = st.lists( + st.lists(nj_dependent_expenses(), min_size=2, max_size=4), + min_size=2, + max_size=4, +) + + +def _nj_simulation(members, reference_system): + people, tax_units, households, marital_units = {}, {}, {}, {} + for unit_index, unit in enumerate(members): + parent = f"parent_{unit_index}" + names = [parent] + people[parent] = {"age": {PERIOD: 40}} + marital_units[f"parent_marital_unit_{unit_index}"] = {"members": [parent]} + for person_index, person in enumerate(unit): + name = f"dependent_{unit_index}_{person_index}" + names.append(name) + people[name] = { + variable: {PERIOD: value} + for variable, value in { + "age": 17, + "is_tax_unit_dependent": True, + "medicare_enrolled": False, + "health_insurance_premiums": person["premiums"], + "other_medical_expenses": person["other"], + "self_employed_health_insurance_ald_person": person["deduction"], + }.items() + } + marital_units[f"dependent_marital_unit_{unit_index}_{person_index}"] = { + "members": [name] + } + tax_units[f"tax_unit_{unit_index}"] = { + "members": names, + "self_employed_health_insurance_ald": {PERIOD: 0}, + "nj_agi": {PERIOD: 0}, + } + households[f"household_{unit_index}"] = { + "members": names, + "state_code": {PERIOD: "NJ"}, + } + return Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + members=[ + [ + dict(premiums=0, other=0, deduction=1_000), + dict(premiums=1_000, other=0, deduction=0), + ], + [ + dict(premiums=300, other=200, deduction=800), + dict(premiums=500, other=100, deduction=0), + ], + ] +) +@given(members=nj_units) +def test_nj_excludes_each_dependents_deduction_only_from_their_own_premiums( + reference_system, members +): + simulation = _nj_simulation(members, reference_system) + paid = np.array([sum(person["premiums"] for person in unit) for unit in members]) + other = np.array([sum(person["other"] for person in unit) for unit in members]) + deductions = np.array( + [sum(person["deduction"] for person in unit) for unit in members] + ) + person_exclusions = [ + [min(person["deduction"], person["premiums"]) for person in unit] + for unit in members + ] + for unit, exclusions in zip(members, person_exclusions): + assert all( + 0 <= exclusion <= person["premiums"] + for person, exclusion in zip(unit, exclusions) + ) + excluded = np.array([sum(exclusions) for exclusions in person_exclusions]) + nj_deduction = simulation.calculate("nj_medical_expense_deduction", PERIOD) + actual_exclusion = paid + other + deductions - nj_deduction + + # C.54A:3-3(c)(3) excludes only amounts separately deducted. Each person's + # exclusion is bounded before summation, so no ALD can consume a different + # person's premiums. This property also covers ALDs above own paid costs. + # https://pub.njleg.gov/bills/9899/PL99/222_.HTM + assert (actual_exclusion >= 0).all() + assert (actual_exclusion <= excluded).all() + np.testing.assert_array_equal(actual_exclusion, excluded) + np.testing.assert_array_equal(nj_deduction, paid + other + deductions - excluded) diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml index 9dde9ede429..6aa0d6ff82c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml @@ -119,3 +119,35 @@ dependents_self_employed_health_insurance_ald: 1_000 # Only the 500 of other expenses remains subject to the 800 floor. nj_medical_expense_deduction: 1_000 + +- name: New Jersey cannot subtract one dependent's deduction from another dependent's premiums + period: 2025 + input: + people: + parent: + age: 40 + dependent_a: + age: 17 + is_tax_unit_dependent: true + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + dependent_b: + age: 16 + is_tax_unit_dependent: true + health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [parent, dependent_a, dependent_b] + nj_agi: 0 + households: + household: + members: [parent, dependent_a, dependent_b] + state_code: NJ + output: + dependents_self_employed_health_insurance_ald: 1_000 + itemized_medical_expenses: 1_000 + # C.54A:3-3(c)(3) excludes separately deducted amounts, not another + # person's paid premiums: min(1,000, 0) + min(0, 1,000) = 0 excluded. + # Separate SE deduction 1,000 + general medical premiums 1,000 = 2,000. + # https://pub.njleg.gov/bills/9899/PL99/222_.HTM + nj_medical_expense_deduction: 2_000 diff --git a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py index c8154557dc3..b809c4f9222 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py @@ -8,6 +8,16 @@ class nj_medical_expense_deduction(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NJ + documentation = ( + "New Jersey's separate self-employed health insurance deduction plus " + "medical expenses above the state floor, excluding premiums already " + "deducted separately. Premium inputs follow payer attribution: each " + "person reports the premiums they paid, including family coverage " + "regardless of who is covered. Each dependent's exclusion is limited " + "to that dependent's own medical premiums. A dependent aggregate ALD " + "override must identify the corresponding person ALDs for exclusion; " + "the total alone does not establish which person paid the premiums." + ) reference = ( "https://pub.njleg.gov/bills/9899/PL99/222_.HTM", "https://www.nj.gov/treasury/taxation/pdf/current/1040i.pdf#page=25", @@ -28,16 +38,15 @@ def formula(tax_unit, period, parameters): # C.54A:3-3 excludes premiums counted by the separate SE deduction # under C.54A:3-5, including dependents' deductions recognized by NJ. # The federal base already excludes the filers' deduction. Apply - # dependents' deductions only to their own premium pool. - dependent_premiums = tax_unit.sum( - tax_unit.members("medical_expense_health_insurance_premiums", period) - * tax_unit.members("is_tax_unit_dependent", period) - ) - dependent_deduction = tax_unit( - "dependents_self_employed_health_insurance_ald", period + # each dependent's deduction only to that person's own paid premiums. + person = tax_unit.members + dependent_premiums = person("medical_expense_health_insurance_premiums", period) + dependent_deduction = person( + "self_employed_health_insurance_ald_person", period ) - excluded_dependent_premiums = min_( - dependent_premiums, max_(0, dependent_deduction) + excluded_dependent_premiums = tax_unit.sum( + min_(dependent_premiums, max_(0, dependent_deduction)) + * person("is_tax_unit_dependent", period) ) medical_expenses = ( tax_unit("itemized_medical_expenses", period) - excluded_dependent_premiums From 8cd9571dcd4d158b01497e237ca5c9ff5e71ebc7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 10:39:15 -0400 Subject: [PATCH 23/45] Exclude federally deducted Missouri premiums and round overlap ratio --- ...o_qualified_health_insurance_premiums.yaml | 146 +++++++++++++++++- .../mo_qualified_health_insurance_premiums.py | 123 ++++++++++----- 2 files changed, 222 insertions(+), 47 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml index 39bc69ecae1..a5caf4fc9b9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml @@ -17,12 +17,15 @@ input: health_insurance_premiums: 5_000 other_medical_expenses: 0 - medical_expense_deduction: 4_625 #medical expenses over 7.5% of taxable_income, equals to 92.5% of income and insurance_premiums + medical_expense_deduction: 4_625 tax_unit_itemizes: True taxable_income: 5_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 375 + # Form 5695 line 12: 4,625 / 5,000 = 92.5%, rounded to 93%. + # Lines 13-14: 5,000 * (1 - 0.93) = 350. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 350 - name: Itemizes, more health expenses than taxable income, max federal itemized health deduction period: 2021 @@ -35,7 +38,10 @@ taxable_income: 7_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 525 + # Form 5695 lines 12-14: 11,475 / 12,000 = 95.625% -> 96%; + # 12,000 * (1 - 0.96) = 480. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 480 - name: Itemizes, equal amounts of other medical expenses and health insurance premiums period: 2021 @@ -48,7 +54,10 @@ taxable_income: 7_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 262.5 + # Form 5695 lines 12-14: 11,475 / 12,000 = 95.625% -> 96%; + # 6,000 * (1 - 0.96) = 240. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 240 - name: Head and spouse have different amounts, no itemizing. period: 2021 @@ -105,7 +114,132 @@ taxable_income: 30_000 tax_unit_itemizes: true output: - # Form 5695 lines 10-11 use the corrected federal Schedule A amounts. + # MO-1040 instructions page 15 excludes the Schedule 1 deduction: + # qualifying pool = 6,000 - 1,000 = 5,000. + # Schedule A: 5,000 + 500 - 0.075 * 30,000 = 3,250. + # Form 5695 lines 10-14: 3,250 / 5,500 = 59.0909% -> 59%; + # Missouri subtraction = 5,000 * (1 - 0.59) = 2,050. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=15 + # https://dor.mo.gov/forms/5695.pdf itemized_medical_expenses: 5_500 medical_expense_deduction: 3_250 - mo_qualified_health_insurance_premiums: 2_454.54545 + mo_qualified_health_insurance_premiums: 2_050 + +- name: Missouri's qualifying pool is unchanged without an SE deduction + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 0 + other_medical_expenses: 0 + adjusted_gross_income: 40_000 + taxable_income: 40_000 + tax_unit_itemizes: true + output: + # 6,000 - 0.075 * 40,000 = 3,000; ratio = 50% exactly. + # 6,000 * (1 - 0.5) = 3,000, matching the previous formula. + itemized_medical_expenses: 6_000 + medical_expense_deduction: 3_000 + mo_qualified_health_insurance_premiums: 3_000 + +- name: Missouri excludes SE-deducted premiums even without itemization + period: 2025 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + mo_qualified_health_insurance_premiums: 5_000 + +- name: Missouri's full-percent rounding just below a half-percent tie + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 10_000 + other_medical_expenses: 0 + medical_expense_deduction: 5_849 + tax_unit_itemizes: true + taxable_income: 30_000 + output: + # 58.49% rounds to 58%; 10,000 * (1 - 0.58) = 4,200. + mo_qualified_health_insurance_premiums: 4_200 + +- name: Missouri's full-percent rounding at a half-percent tie + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 10_000 + other_medical_expenses: 0 + medical_expense_deduction: 5_850 + tax_unit_itemizes: true + taxable_income: 30_000 + output: + # Form 5695 line 12 requires full-percent rounding. Half-up ties follow + # the MO-1040 instructions' percentage example on page 7 (97.5% -> 98%). + # 58.5% rounds to 59%; 10,000 * (1 - 0.59) = 4,100. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=7 + mo_qualified_health_insurance_premiums: 4_100 + +- name: Missouri caps each payer's SE exclusion before combining premiums + period: 2025 + input: + people: + parent: + health_insurance_premiums: 300 + self_employed_health_insurance_ald_person: 800 + spouse: + health_insurance_premiums: 500 + self_employed_health_insurance_ald_person: 0 + tax_units: + tax_unit: + members: [parent, spouse] + tax_unit_itemizes: false + taxable_income: 30_000 + households: + household: + members: [parent, spouse] + state_code: MO + output: + # Premiums belong to their payer. The parent's unmatched $500 deduction + # cannot exclude the spouse's $500 of premiums. + self_employed_health_insurance_ald: 800 + mo_qualified_health_insurance_premiums: [0, 500] + +- name: Missouri honors a supplied zero federal SE deduction + period: 2025 + input: + state_code: MO + health_insurance_premiums: 1_000 + is_self_employed: true + self_employed_health_insurance_premiums: 1_000 + total_self_employment_income: 10_000 + self_employed_health_insurance_ald: 0 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + # Personal eligibility is $1,000, but the actual federal return claims $0. + # No premiums were deducted federally, so the qualifying pool stays $1,000. + self_employed_health_insurance_ald_person: 1_000 + mo_qualified_health_insurance_premiums: 1_000 + +- name: Missouri honors a supplied partial federal SE deduction + period: 2025 + input: + state_code: MO + health_insurance_premiums: 1_000 + is_self_employed: true + self_employed_health_insurance_premiums: 1_000 + total_self_employment_income: 10_000 + self_employed_health_insurance_ald: 500 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + # Limit the personal $1,000 deduction to the actual $500 claimed federally. + # The remaining qualifying pool is 1,000 - 500 = 500. + self_employed_health_insurance_ald_person: 1_000 + mo_qualified_health_insurance_premiums: 500 diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py index ccd57a3c240..f447826f91d 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py @@ -4,58 +4,99 @@ class mo_qualified_health_insurance_premiums(Variable): value_type = float entity = Person - label = "Missouri qualified healh insurance premiums" + label = "Missouri qualified health insurance premiums" unit = USD definition_period = YEAR reference = ( - "https://dor.mo.gov/forms/5695.pdf", # MO Form 5695 - "https://www.irs.gov/pub/irs-pdf/f1040sa.pdf", # US Form 1040 Sch A - "https://www.irs.gov/pub/irs-pdf/f1040.pdf", # US Form 1040 + "https://dor.mo.gov/forms/5695.pdf", # Lines 8-17 + "https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=15", + "https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=7", + "https://www.irs.gov/pub/irs-pdf/f1040sa.pdf", + ) + documentation = ( + "Health insurance premiums eligible for the Missouri subtraction, " + "excluding premiums already deducted through the self-employed health " + "insurance deduction or federal itemization. Premium inputs follow " + "payer attribution: record a person's premiums on the person who paid " + "them, including all parent-paid family coverage on the parent. Each " + "person's SE deduction exclusion is capped at that person's premiums. " + "Filers' person deductions are limited to the actual tax-unit federal " + "deduction; dependents retain their own deductions. " + "A supplied tax-unit SE deduction without corresponding person " + "deductions is allocated across the filers' remaining premiums. " + "Form 5695's federal medical deduction ratio is rounded to a whole " + "percent; half-percent ties round up, consistent with Missouri's " + "percentage-rounding examples." ) defined_for = StateCode.MO def formula(person, period, parameters): - # Variable involves person- and tax unit-level variables. tax_unit = person.tax_unit - - # total_health_insurance_premiums is also a primary input to - # the MO side of calculation, MO Form 5695, Line 8 - # Federal Schedule A, Line 4 - med_expense_deduction = add(tax_unit, period, ["medical_expense_deduction"]) - - # the ratio of federal medical expense deduction to - # total medical expenses (out of pocket + premiums) - # need division because med_dental_out_of_pocket is - # in federal tax, but no MO tax - # this ratio is then used to scale the health_insurance_premium - # amount that can be claimed - # IRS Schedule A Line 1 - tax_unit_health_expenses = tax_unit("itemized_medical_expenses", period) - med_expense_ratio = np.zeros_like(tax_unit_health_expenses) - mask = tax_unit_health_expenses > 0 - med_expense_ratio[mask] = ( - med_expense_deduction[mask] / tax_unit_health_expenses[mask] + person_premiums = max_(person("health_insurance_premiums", period), 0) + person_se_ald = max_( + person("self_employed_health_insurance_ald_person", period), 0 + ) + filer = not_(person("is_tax_unit_dependent", period)) + tax_unit_se_ald = max_( + tax_unit("self_employed_health_insurance_ald", period), 0 + ) + filers_person_se_ald = tax_unit.sum(person_se_ald * filer) + # A supplied unit deduction can be smaller than the modeled personal + # deductions. Respect the actual amount claimed on the filers' return. + filer_scale = np.divide( + min_(tax_unit_se_ald, filers_person_se_ald), + filers_person_se_ald, + out=np.zeros_like(filers_person_se_ald), + where=filers_person_se_ald > 0, ) + attributed_se_ald = where(filer, person_se_ald * filer_scale, person_se_ald) + # MO-1040 instructions, page 15, exclude federally deducted premiums + # from Form 5695's pool before applying its Schedule A overlap ratio. + person_eligible_premiums = max_(person_premiums - attributed_se_ald, 0) + # Reconcile a directly supplied tax-unit ALD. Compare against actual + # person ALDs, so an unmatched payer's deduction cannot consume another + # person's premiums merely because its own exclusion was capped. + unattributed_ald = max_(tax_unit_se_ald - filers_person_se_ald, 0) + filer_premiums = person_eligible_premiums * filer + tax_unit_filer_premiums = tax_unit.sum(filer_premiums) + unattributed_share = np.divide( + filer_premiums, + tax_unit_filer_premiums, + out=np.zeros_like(filer_premiums), + where=tax_unit_filer_premiums > 0, + ) + person_eligible_premiums -= unattributed_share * min_( + unattributed_ald, tax_unit_filer_premiums + ) + tax_unit_premiums = tax_unit.sum(person_eligible_premiums) - person_premiums = person("health_insurance_premiums", period) - tax_unit_premiums = tax_unit.sum(person_premiums) + # Form 5695 lines 10-12 use Schedule A lines 1 and 4, with the ratio + # rounded to a full percent. Half-up ties follow the percentage example + # in the MO-1040 instructions, page 7 (97.5% becomes 98%). + medical_expenses = tax_unit("itemized_medical_expenses", period) + medical_deduction = tax_unit("medical_expense_deduction", period) + # Divide in float64 before rounding so float32 error cannot turn an + # exact half-percent tie, such as 5,850 / 10,000, into a value below it. + medical_percent = np.divide( + medical_deduction.astype(np.float64) * 100, + medical_expenses, + out=np.zeros_like(medical_expenses, dtype=np.float64), + dtype=np.float64, + where=medical_expenses > 0, + ) + rounded_ratio = np.floor(medical_percent + 0.5) / 100 + remaining_premiums = max_(tax_unit_premiums * (1 - rounded_ratio), 0) - # Line 13 of MO Form 5695, represents the portion of - # medical expenses already deducted via federal tax itemization - deducted_portion = tax_unit_premiums * med_expense_ratio - # subtracts the portion of premiums already deducted from federal tax - itemized_premiums_amount = max_(tax_unit_premiums - deducted_portion, 0) + # Lines 16-17 cap the unit's subtraction at federal taxable income, + # then allocate it by each person's share of the qualifying premiums. + person_share = np.divide( + person_eligible_premiums, + tax_unit_premiums, + out=np.zeros_like(person_eligible_premiums), + where=tax_unit_premiums > 0, + ) itemizes = tax_unit("tax_unit_itemizes", period) - # Cap at federal taxable income. taxable_income = tax_unit("taxable_income", period) - # Allocate proportionally across people in the tax unit based on share - # of premiums. - # Use a mask rather than where to avoid a divide-by-zero warning. Default to zero. - person_share = np.zeros_like(tax_unit_premiums) - mask = tax_unit_premiums > 0 - person_share[mask] = person_premiums[mask] / tax_unit_premiums[mask] - return person_share * where( - itemizes, - min_(itemized_premiums_amount, taxable_income), - min_(tax_unit_premiums, taxable_income), + return person_share * min_( + where(itemizes, remaining_premiums, tax_unit_premiums), taxable_income ) From 1951078ce369e863fdaa37c3d79067e396231623 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 10:40:21 -0400 Subject: [PATCH 24/45] Document payer attribution and cover family premium inputs --- .../hub-schedA-se-premiums-1010.fixed.md | 2 +- .../itemizing/medical_expense_deduction.yaml | 48 +++++++++++++++++++ .../itemizing/itemized_medical_expenses.py | 8 +++- .../mt_medical_expense_deduction_indiv.py | 8 ++++ .../nd_renters_refund_income.py | 10 ++++ .../credits/nm_medical_expense_credit.py | 8 ++++ .../nm_medical_expense_exemption.py | 9 ++++ 7 files changed, 91 insertions(+), 2 deletions(-) diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md index 549a85622b6..8b9a0288172 100644 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -1 +1 @@ -Prevent self-employed health insurance premiums deducted above the line from also entering federal and conforming state medical deductions, while preserving independent state medical-expense benefits. +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml index 420a311338d..1228ba0acf8 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml @@ -141,3 +141,51 @@ output: itemized_medical_expenses: 800 medical_expense_deduction: 800 + +- name: Parent-paid family coverage is attributed entirely to the parent. + period: 2025 + input: + people: + parent: + age: 40 + # The parent paid the full family premium, including dependent coverage. + health_insurance_premiums: 800 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 0 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 800 + adjusted_gross_income: 0 + output: + # Payer attribution: 800 paid - 800 already deducted = 0 on Schedule A. + itemized_medical_expenses: 0 + medical_expense_deduction: 0 + +- name: Beneficiary-attributed family inputs cannot transfer the parent's exclusion to a dependent. + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 800 + adjusted_gross_income: 0 + output: + # The exclusion is min(800 ALD, 300 filer-paid premiums) = 300, leaving + # the dependent's 500. Attributed premium uses are 300 + 500 = 800. + # Supplying an 800 parent ALD with only 300 parent-paid premiums violates + # the payer contract; adding the full supplied ALD to Schedule A gives + # 1,300 and is not a conservation guarantee for these inconsistent inputs. + # A parent who actually paid all 800 must use the preceding input pattern. + itemized_medical_expenses: 500 + medical_expense_deduction: 500 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index c8efb378dc3..88b1b8140b2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -22,7 +22,13 @@ class itemized_medical_expenses(Variable): "insurance premiums and other medical expenses, excluding general " "over-the-counter health expenses. Premiums deducted through the " "filer's self-employed health insurance deduction are excluded under " - "IRC Section 162(l)(3) before the medical expense floor is applied." + "IRC Section 162(l)(3) before the medical expense floor is applied. " + "Premium inputs use payer attribution: each person reports premiums " + "that person paid, including the full cost of family coverage regardless " + "of whom it covers. A parent-paid dependent policy belongs on the parent. " + "The exclusion is bounded to filer-paid premiums; beneficiary-attributed " + "inputs with an ALD exceeding that pool are inconsistent with this contract " + "and cannot establish conservation of the supplied ALD." ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index 5142f8c3399..5faa21eefb9 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -17,6 +17,14 @@ class mt_medical_expense_deduction_indiv(Variable): unit = USD defined_for = "mt_married_filing_separately_on_same_return_eligible" + documentation = ( + "Medical deduction after excluding the same person's self-employed " + "health insurance premiums and applying the medical floor. Premium " + "inputs use payer attribution: report all premiums paid by a person " + "on that person, including family coverage regardless of the covered " + "beneficiary." + ) + def formula(person, period, parameters): premiums = person("medical_expense_health_insurance_premiums", period) se_health_insurance_ald = person( diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index 3f1bd174e0b..2fe761b1e1f 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -13,6 +13,16 @@ class nd_renters_refund_income(Variable): ) defined_for = StateCode.ND + documentation = ( + "Renter refund income after unreimbursed medical costs actually paid. This " + "independent paid-cost base retains premiums deducted through the federal " + "self-employed health insurance ALD; interpreting the guidance's federal- " + "definition cross-reference as importing that exclusion is an alternative " + "methodology. Premium inputs use payer attribution: each person reports " + "premiums that person paid, including family coverage regardless of whom " + "it covers. " + ) + def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund return max_( diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index 460888c60c5..4d7545bfb09 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -9,6 +9,14 @@ class nm_medical_expense_credit(Variable): reference = "https://nmonesource.com/nmos/nmsa/en/item/4340/index.do#!fragment/zoupio-_Toc140503776/BQCwhgziBcwMYgK4DsDWszIQewE4BUBTADwBdoAvbRABwEtsBaAfX2zgEYAWABgFYeAZgDswgGwBKADTJspQhACKiQrgCe0AOSapEQmFwJlqjdt37DIAMp5SAIQ0AlAKIAZZwDUAggDkAws5SpGAARtCk7BISQA" defined_for = StateCode.NM + documentation = ( + "Senior medical credit based on gross unreimbursed costs paid, preserving " + "premiums deducted through the federal self-employed health insurance ALD. " + "This assumes qualifying costs were funded from income included in AGI. " + "Premium inputs use payer attribution: each person reports premiums that " + "person paid, including family coverage regardless of whom it covers. " + ) + def formula(tax_unit, period, parameters): person = tax_unit.members pcredits = parameters(period).gov.states.nm.tax.income.credits diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index 77f00a7943d..f5257c6f976 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -9,6 +9,15 @@ class nm_medical_expense_exemption(Variable): reference = "https://nmonesource.com/nmos/nmsa/en/item/4340/index.do#!fragment/zoupio-_Toc140503680/BQCwhgziBcwMYgK4DsDWszIQewE4BUBTADwBdoAvbRABwEtsBaAfX2zgEYAWABgFYeAZgBsADh4BKADTJspQhACKiQrgCe0AOSapEQmFwJlqjdt37DIAMp5SAIQ0AlAKIAZZwDUAggDkAws5SpGAARtCk7BISQA" defined_for = StateCode.NM + documentation = ( + "Senior medical exemption based on gross unreimbursed costs paid, " + "preserving premiums deducted through the federal self-employed health " + "insurance ALD. This assumes qualifying costs were funded from income " + "included in AGI. Premium inputs use payer attribution: each person " + "reports premiums that person paid, including family coverage regardless " + "of whom it covers. " + ) + def formula(tax_unit, period, parameters): person = tax_unit.members p = parameters( From bba165d129986b623b5ca3e31cda06f869b89711 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 13:46:23 -0400 Subject: [PATCH 25/45] Preserve ND and NM supplied medical expense subtotals --- .../hub-schedA-se-premiums-1010.fixed.md | 2 +- ...st_medical_expense_premium_conservation.py | 194 ++++++++++++++++++ .../nd_renters_refund_income.yaml | 19 ++ .../credits/nm_medical_expense_credit.yaml | 23 +++ .../nm_medical_expense_exemption.yaml | 21 ++ .../itemizing/itemized_medical_expenses.py | 6 +- ...ized_medical_expenses_excluded_premiums.py | 28 +++ ..._health_insurance_ald_excluded_premiums.py | 24 +++ .../nd_renters_refund_income.py | 34 ++- .../nj_medical_expense_deduction.py | 6 +- .../credits/nm_medical_expense_credit.py | 10 +- .../nm_medical_expense_exemption.py | 12 +- 12 files changed, 344 insertions(+), 35 deletions(-) create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md index 8b9a0288172..de9cb248dd0 100644 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -1 +1 @@ -Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and caller-supplied subtotals, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py index d663371e119..c1d11f5eb32 100644 --- a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -310,3 +310,197 @@ def test_nj_excludes_each_dependents_deduction_only_from_their_own_premiums( assert (actual_exclusion <= excluded).all() np.testing.assert_array_equal(actual_exclusion, excluded) np.testing.assert_array_equal(nj_deduction, paid + other + deductions - excluded) + + +# Flat strategies keep generation small while varying independent caller +# subtotals, component inputs, filing units and the senior-benefit gates. +state_expenses = st.fixed_dictionaries( + { + "premiums": st.tuples(*(st.integers(0, 25_000) for _ in range(3))), + "other": st.tuples(*(st.integers(0, 30_000) for _ in range(3))), + "direct": st.tuples(*(st.booleans() for _ in range(3))), + "income": st.integers(0, 100_000), + "age": st.sampled_from([64, 65, 80]), + "filing_status": st.sampled_from(["SINGLE", "JOINT", "SEPARATE"]), + "dependent_elsewhere": st.booleans(), + "subtotal_mode": st.sampled_from(["derived", "supplied", "subtotal_only"]), + "subtotal": st.one_of( + st.integers(0, 100_000), st.sampled_from([27_999, 28_000, 28_001]) + ), + "deduction": st.integers(-10_000, 75_000), + } +) +state_units = st.lists(state_expenses, min_size=2, max_size=3) + + +def _state_case(**values): + return { + "premiums": (0, 0, 0), + "other": (0, 0, 0), + "direct": (True, True, True), + "income": 30_000, + "age": 65, + "filing_status": "SINGLE", + "dependent_elsewhere": False, + "subtotal_mode": "derived", + "subtotal": 0, + "deduction": 1_000, + **values, + } + + +def _state_simulation(cases, reference_system, with_se_deduction): + people, tax_units, households, marital_units = {}, {}, {}, {} + subtotal_overrides = [] + expectations = { + "nd_renters_refund_income": [], + "nm_medical_expense_credit": [], + "nm_medical_expense_exemption": [], + } + for case_index, case in enumerate(cases): + joint = case["filing_status"] == "JOINT" + person_indices = [0, 1, 2] if joint else [0, 2] + subtotal_only = case["subtotal_mode"] == "subtotal_only" + premiums = [0 if subtotal_only else case["premiums"][i] for i in person_indices] + other = [0 if subtotal_only else case["other"][i] for i in person_indices] + deduction = case["deduction"] if with_se_deduction else 0 + filer_premiums = premiums[0] + (premiums[1] if joint else 0) + excluded = min(filer_premiums, max(0, deduction)) + gross = sum(premiums) + sum(other) + subtotal = None + if case["subtotal_mode"] != "derived": + # A supplied net subtotal with SE deduction is made consistent with + # its components, so restoring the exclusion recovers main's gross + # base. Without SE, an arbitrary caller subtotal must take precedence + # over the components, just as it did on main. + subtotal = ( + gross - excluded + if with_se_deduction and not subtotal_only + else case["subtotal"] + ) + main_base = gross if subtotal is None else subtotal + excluded + senior_eligible = case["age"] >= 65 and main_base >= 28_000 + denominator = 2 if case["filing_status"] == "SEPARATE" else 1 + + for state in ("ND", "NM"): + unit_name = f"{state}_{case_index}" + names = [] + for position, person_index in enumerate(person_indices): + name = f"{unit_name}_person_{person_index}" + names.append(name) + premium_variable = ( + "health_insurance_premiums" + if case["direct"][person_index] + else "health_insurance_premiums_without_medicare_part_b" + ) + values = { + "age": case["age"] + if person_index == 0 + else 40 + if person_index == 1 + else 17, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + "is_tax_unit_dependent": person_index == 2, + "employment_income": case["income"] if person_index == 0 else 0, + "medicare_enrolled": False, + premium_variable: premiums[position], + "other_medical_expenses": other[position], + } + people[name] = { + variable: {PERIOD: value} for variable, value in values.items() + } + if person_index == 2: + marital_units[f"{unit_name}_dependent_marital_unit"] = { + "members": [name] + } + marital_units[f"{unit_name}_marital_unit"] = {"members": names[:-1]} + tax_unit = { + "members": names, + "self_employed_health_insurance_ald": {PERIOD: deduction}, + "filing_status": {PERIOD: case["filing_status"]}, + "head_is_dependent_elsewhere": {PERIOD: case["dependent_elsewhere"]}, + } + tax_units[unit_name] = tax_unit + subtotal_overrides.append(subtotal) + households[unit_name] = {"members": names, "state_code": {PERIOD: state}} + # This independent accounting oracle reproduces main's state + # outputs from its paid-cost base rather than reading this head's + # medical subtotal or excluded-premium calculations as expectations. + expectations["nd_renters_refund_income"].append( + max(0, case["income"] - main_base) if state == "ND" else 0 + ) + expectations["nm_medical_expense_credit"].append( + 2_800 / denominator + if state == "NM" and senior_eligible and not case["dependent_elsewhere"] + else 0 + ) + expectations["nm_medical_expense_exemption"].append( + 3_000 / denominator if state == "NM" and senior_eligible else 0 + ) + simulation = Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + if any(value is not None for value in subtotal_overrides): + # Core fills omitted entries in a partially supplied input vector with + # defaults. Preserve the actual derived values first, then splice in + # caller subtotals before any state result is calculated or cached. + medical_base = simulation.calculate("itemized_medical_expenses", PERIOD).copy() + for index, subtotal in enumerate(subtotal_overrides): + if subtotal is not None: + medical_base[index] = subtotal + simulation.set_input("itemized_medical_expenses", PERIOD, medical_base) + return simulation, expectations + + +def _assert_state_outputs(cases, reference_system, with_se_deduction): + simulation, expectations = _state_simulation( + cases, reference_system, with_se_deduction + ) + for variable, expected in expectations.items(): + np.testing.assert_array_equal(simulation.calculate(variable, PERIOD), expected) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + cases=[ + _state_case(subtotal_mode="subtotal_only", subtotal=6_500), + _state_case(subtotal_mode="subtotal_only", subtotal=28_000), + _state_case( + premiums=(6_000, 0, 0), + other=(22_000, 0, 0), + subtotal_mode="supplied", + subtotal=27_999, + ), + _state_case(premiums=(6_000, 0, 0), other=(21_999, 0, 0)), + _state_case(premiums=(6_000, 0, 0), other=(22_001, 0, 0)), + ] +) +@given(cases=state_units) +def test_state_paid_cost_bases_match_main_without_se_deduction(reference_system, cases): + _assert_state_outputs(cases, reference_system, with_se_deduction=False) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + cases=[ + _state_case(premiums=(6_000, 0, 0), other=(500, 0, 0)), + _state_case( + premiums=(6_000, 0, 0), other=(22_000, 0, 0), subtotal_mode="supplied" + ), + _state_case(premiums=(6_000, 0, 0), other=(21_999, 0, 0), deduction=75_000), + _state_case(subtotal_mode="subtotal_only", subtotal=28_000, deduction=75_000), + _state_case(premiums=(0, 0, 28_000), deduction=75_000), + ] +) +@given(cases=state_units) +def test_state_paid_cost_bases_restore_main_gross_costs_with_se_deduction( + reference_system, cases +): + _assert_state_outputs(cases, reference_system, with_se_deduction=True) diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index e232c5afdc6..8bb478653d6 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -75,3 +75,22 @@ itemized_medical_expenses: 5_500 # The property tax program separately deducts the full 6,500 paid. nd_renters_refund_income: 23_500 + +- name: North Dakota renter refund preserves caller-supplied medical expenses + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + output: + nd_renters_refund_income: 23_500 + +- name: North Dakota cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + self_employed_health_insurance_ald: 1_000 + output: + nd_renters_refund_income: 23_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml index d0984e6aa73..d155c78af36 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml @@ -22,6 +22,29 @@ itemized_medical_expenses: 27_000 nm_medical_expense_credit: 2_800 +- name: New Mexico senior credit preserves caller-supplied medical expenses + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 28_000 + head_is_dependent_elsewhere: false + output: + nm_medical_expense_credit: 2_800 + +- name: New Mexico credit cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 27_999 + self_employed_health_insurance_ald: 1_000 + head_is_dependent_elsewhere: false + output: + nm_medical_expense_credit: 0 + - name: Below expense eligibility period: 2023 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml index 2e3f6b60630..d1d5fb1b65c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml @@ -21,6 +21,27 @@ itemized_medical_expenses: 27_000 nm_medical_expense_exemption: 3_000 +- name: New Mexico senior exemption preserves caller-supplied medical expenses + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 28_000 + output: + nm_medical_expense_exemption: 3_000 + +- name: New Mexico exemption cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 27_999 + self_employed_health_insurance_ald: 1_000 + output: + nm_medical_expense_exemption: 0 + - name: Below expense eligibility period: 2023 input: diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index 88b1b8140b2..91538c9b38a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -36,10 +36,8 @@ def formula(tax_unit, period, parameters): # The federal ALD covers the head and spouse; dependents take their # own SE deductions on their own returns. Bound the exclusion to the # corresponding filer premiums, leaving other medical costs intact. - filer_premiums = tax_unit_non_dep_sum( - "medical_expense_health_insurance_premiums", tax_unit, period + excluded_premiums = tax_unit( + "itemized_medical_expenses_excluded_premiums", period ) - se_health_insurance_ald = tax_unit("self_employed_health_insurance_ald", period) - excluded_premiums = min_(filer_premiums, max_(0, se_health_insurance_ald)) other_expenses = add(tax_unit, period, ["other_medical_expenses"]) return premiums - excluded_premiums + other_expenses diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py new file mode 100644 index 00000000000..730dd73b536 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class itemized_medical_expenses_excluded_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Self-employed health insurance premiums excluded from Schedule A" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", + ] + documentation = ( + "The federal self-employed health insurance ALD, limited to premiums " + "paid by the head and spouse. Premium inputs use payer attribution, " + "including family coverage paid by a filer. This preserves supplied " + "tax-unit ALDs without allocating them to individual filers. States " + "that retain gross paid medical costs add this excluded amount back " + "to itemized_medical_expenses, including a caller-supplied subtotal." + ) + + def formula(tax_unit, period, parameters): + filer_premiums = tax_unit_non_dep_sum( + "medical_expense_health_insurance_premiums", tax_unit, period + ) + se_health_insurance_ald = tax_unit("self_employed_health_insurance_ald", period) + return min_(filer_premiums, max_(0, se_health_insurance_ald)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py new file mode 100644 index 00000000000..002b80703b3 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class self_employed_health_insurance_ald_excluded_premiums(Variable): + value_type = float + entity = Person + label = "Medical premiums excluded by a person's self-employed deduction" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://pub.njleg.gov/bills/9899/PL99/222_.HTM", + ] + documentation = ( + "Medical premiums paid by a person and separately deducted through " + "that person's self-employed health insurance ALD. Premium inputs " + "use payer attribution, including family coverage paid by that " + "person. The exclusion cannot consume another person's premiums." + ) + + def formula(person, period, parameters): + premiums = person("medical_expense_health_insurance_premiums", period) + deduction = person("self_employed_health_insurance_ald_person", period) + return min_(premiums, max_(0, deduction)) diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index 2fe761b1e1f..830cf17a14d 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -14,28 +14,26 @@ class nd_renters_refund_income(Variable): defined_for = StateCode.ND documentation = ( - "Renter refund income after unreimbursed medical costs actually paid. This " - "independent paid-cost base retains premiums deducted through the federal " - "self-employed health insurance ALD; interpreting the guidance's federal- " - "definition cross-reference as importing that exclusion is an alternative " - "methodology. Premium inputs use payer attribution: each person reports " - "premiums that person paid, including family coverage regardless of whom " - "it covers. " + "Renter refund income after unreimbursed medical costs actually paid. " + "The paid-cost base adds the excluded self-employed premiums back to " + "the federal medical expense amount, preserving both caller-supplied " + "subtotals and computed gross costs. Premium inputs use payer " + "attribution: each person reports premiums that person paid, including " + "family coverage regardless of whom it covers." ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund + # Restore gross paid costs while retaining supplied federal subtotals. + medical_expenses = add( + tax_unit, + period, + [ + "itemized_medical_expenses", + "itemized_medical_expenses_excluded_premiums", + ], + ) return max_( - add(tax_unit, period, p.income_sources) - # The refund uses gross income and medical costs actually paid, - # rather than expenses net of federal above-the-line deductions. - - add( - tax_unit, - period, - [ - "medical_expense_health_insurance_premiums", - "other_medical_expenses", - ], - ), + add(tax_unit, period, p.income_sources) - medical_expenses, 0, ) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py index b809c4f9222..b5d778fcfef 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py @@ -40,12 +40,8 @@ def formula(tax_unit, period, parameters): # The federal base already excludes the filers' deduction. Apply # each dependent's deduction only to that person's own paid premiums. person = tax_unit.members - dependent_premiums = person("medical_expense_health_insurance_premiums", period) - dependent_deduction = person( - "self_employed_health_insurance_ald_person", period - ) excluded_dependent_premiums = tax_unit.sum( - min_(dependent_premiums, max_(0, dependent_deduction)) + person("self_employed_health_insurance_ald_excluded_premiums", period) * person("is_tax_unit_dependent", period) ) medical_expenses = ( diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index 4d7545bfb09..bd4c468b65b 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -12,6 +12,8 @@ class nm_medical_expense_credit(Variable): documentation = ( "Senior medical credit based on gross unreimbursed costs paid, preserving " "premiums deducted through the federal self-employed health insurance ALD. " + "The federal medical amount plus excluded premiums preserves both " + "caller-supplied subtotals and computed gross costs. " "This assumes qualifying costs were funded from income included in AGI. " "Premium inputs use payer attribution: each person reports premiums that " "person paid, including family coverage regardless of whom it covers. " @@ -22,12 +24,14 @@ def formula(tax_unit, period, parameters): pcredits = parameters(period).gov.states.nm.tax.income.credits p = pcredits.unreimbursed_medical_care_expense age = person("age", period) - # This independent senior benefit counts unreimbursed costs paid, - # including expenses also deducted on federal Schedule A. + # Restore gross paid costs while retaining supplied federal subtotals. medical_expense = add( tax_unit, period, - ["medical_expense_health_insurance_premiums", "other_medical_expenses"], + [ + "itemized_medical_expenses", + "itemized_medical_expenses_excluded_premiums", + ], ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index f5257c6f976..ef2bcebd722 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -12,7 +12,9 @@ class nm_medical_expense_exemption(Variable): documentation = ( "Senior medical exemption based on gross unreimbursed costs paid, " "preserving premiums deducted through the federal self-employed health " - "insurance ALD. This assumes qualifying costs were funded from income " + "insurance ALD. The federal medical amount plus excluded premiums " + "preserves both caller-supplied subtotals and computed gross costs. " + "This assumes qualifying costs were funded from income " "included in AGI. Premium inputs use payer attribution: each person " "reports premiums that person paid, including family coverage regardless " "of whom it covers. " @@ -24,12 +26,14 @@ def formula(tax_unit, period, parameters): period ).gov.states.nm.tax.income.exemptions.unreimbursed_medical_care_expense age = person("age", period) - # This independent senior benefit counts unreimbursed costs paid, - # including expenses also deducted on federal Schedule A. + # Restore gross paid costs while retaining supplied federal subtotals. medical_expense = add( tax_unit, period, - ["medical_expense_health_insurance_premiums", "other_medical_expenses"], + [ + "itemized_medical_expenses", + "itemized_medical_expenses_excluded_premiums", + ], ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses From 7c96cd804a52117a28e4daabc4238ab0f675e541 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 16:28:33 -0400 Subject: [PATCH 26/45] Preserve supplied state medical subtotals using input provenance --- .../hub-schedA-se-premiums-1010.fixed.md | 2 +- ...st_medical_expense_premium_conservation.py | 112 ++++++++++++------ ...health_insurance_premiums_subtraction.yaml | 6 +- .../nd_renters_refund_income.yaml | 42 +++++++ .../credits/nm_medical_expense_credit.yaml | 17 +++ .../nm_medical_expense_exemption.yaml | 17 +++ ...ized_medical_expenses_applied_exclusion.py | 27 +++++ ...ized_medical_expenses_excluded_premiums.py | 5 +- .../nd_renters_refund_income.py | 10 +- .../credits/nm_medical_expense_credit.py | 8 +- .../nm_medical_expense_exemption.py | 9 +- 11 files changed, 202 insertions(+), 53 deletions(-) create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md index de9cb248dd0..4cfb53bff12 100644 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -1 +1 @@ -Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and caller-supplied subtotals, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and every mix of caller-supplied subtotals and components by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py index c1d11f5eb32..6cffa421527 100644 --- a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -12,9 +12,11 @@ the self-employed health insurance deduction never exceeds premiums paid. Non-premium medical expenses must remain available in full before the floor. -Each example uses one small vectorized simulation and a shared read-only -reference system. YAML covers the individual policy examples; these properties -check conservation across generated combinations and filing-unit boundaries. +Examples use small vectorized simulations and a shared read-only reference +system. Supplied and computed subtotal scenarios use separate simulations to +preserve their input provenance. YAML covers the individual policy examples; +these properties check conservation across generated combinations and +filing-unit boundaries. """ import numpy as np @@ -351,12 +353,19 @@ def _state_case(**values): def _state_simulation(cases, reference_system, with_se_deduction): people, tax_units, households, marital_units = {}, {}, {}, {} - subtotal_overrides = [] expectations = { "nd_renters_refund_income": [], "nm_medical_expense_credit": [], "nm_medical_expense_exemption": [], } + accounting = { + "main_base": [], + "gross": [], + "other": [], + "filer_premiums": [], + "excluded": [], + "applied_exclusion": [], + } for case_index, case in enumerate(cases): joint = case["filing_status"] == "JOINT" person_indices = [0, 1, 2] if joint else [0, 2] @@ -367,18 +376,11 @@ def _state_simulation(cases, reference_system, with_se_deduction): filer_premiums = premiums[0] + (premiums[1] if joint else 0) excluded = min(filer_premiums, max(0, deduction)) gross = sum(premiums) + sum(other) - subtotal = None - if case["subtotal_mode"] != "derived": - # A supplied net subtotal with SE deduction is made consistent with - # its components, so restoring the exclusion recovers main's gross - # base. Without SE, an arbitrary caller subtotal must take precedence - # over the components, just as it did on main. - subtotal = ( - gross - excluded - if with_se_deduction and not subtotal_only - else case["subtotal"] - ) - main_base = gross if subtotal is None else subtotal + excluded + supplied = case["subtotal_mode"] != "derived" + # Caller subtotals are independent of their component inputs and SE + # deduction. The oracle preserves the supplied amount exactly as main + # does; only the computed path restores an applied federal exclusion. + main_base = case["subtotal"] if supplied else gross senior_eligible = case["age"] >= 65 and main_base >= 28_000 denominator = 2 if case["filing_status"] == "SEPARATE" else 1 @@ -421,9 +423,16 @@ def _state_simulation(cases, reference_system, with_se_deduction): "filing_status": {PERIOD: case["filing_status"]}, "head_is_dependent_elsewhere": {PERIOD: case["dependent_elsewhere"]}, } + if supplied: + tax_unit["itemized_medical_expenses"] = {PERIOD: case["subtotal"]} tax_units[unit_name] = tax_unit - subtotal_overrides.append(subtotal) households[unit_name] = {"members": names, "state_code": {PERIOD: state}} + accounting["main_base"].append(main_base) + accounting["gross"].append(gross) + accounting["other"].append(sum(other)) + accounting["filer_premiums"].append(filer_premiums) + accounting["excluded"].append(excluded) + accounting["applied_exclusion"].append(0 if supplied else excluded) # This independent accounting oracle reproduces main's state # outputs from its paid-cost base rather than reading this head's # medical subtotal or excluded-premium calculations as expectations. @@ -447,24 +456,48 @@ def _state_simulation(cases, reference_system, with_se_deduction): "marital_units": marital_units, }, ) - if any(value is not None for value in subtotal_overrides): - # Core fills omitted entries in a partially supplied input vector with - # defaults. Preserve the actual derived values first, then splice in - # caller subtotals before any state result is calculated or cached. - medical_base = simulation.calculate("itemized_medical_expenses", PERIOD).copy() - for index, subtotal in enumerate(subtotal_overrides): - if subtotal is not None: - medical_base[index] = subtotal - simulation.set_input("itemized_medical_expenses", PERIOD, medical_base) - return simulation, expectations + return simulation, expectations, accounting def _assert_state_outputs(cases, reference_system, with_se_deduction): - simulation, expectations = _state_simulation( - cases, reference_system, with_se_deduction - ) - for variable, expected in expectations.items(): - np.testing.assert_array_equal(simulation.calculate(variable, PERIOD), expected) + # Core records input provenance for an entire tax-unit vector. Keep supplied + # and computed cases in separate simulations so computed rows are never + # turned into supplied inputs by filling an otherwise partial input vector. + for supplied in (False, True): + same_provenance = [ + case for case in cases if (case["subtotal_mode"] != "derived") == supplied + ] + if not same_provenance: + continue + simulation, expectations, accounting = _state_simulation( + same_provenance, reference_system, with_se_deduction + ) + medical_base = simulation.calculate("itemized_medical_expenses", PERIOD) + applied_exclusion = simulation.calculate( + "itemized_medical_expenses_applied_exclusion", PERIOD + ) + np.testing.assert_array_equal( + applied_exclusion, accounting["applied_exclusion"] + ) + np.testing.assert_array_equal( + medical_base + applied_exclusion, accounting["main_base"] + ) + if not supplied: + excluded = simulation.calculate( + "itemized_medical_expenses_excluded_premiums", PERIOD + ) + # Computed Schedule A costs conserve the full paid amount when the + # capped premium exclusion is restored. These bounds deliberately + # do not constrain an independent caller-supplied subtotal. + np.testing.assert_array_equal(excluded, accounting["excluded"]) + np.testing.assert_array_equal(medical_base + excluded, accounting["gross"]) + assert (excluded >= 0).all() + assert (excluded <= np.array(accounting["filer_premiums"])).all() + assert (medical_base >= np.array(accounting["other"])).all() + for variable, expected in expectations.items(): + np.testing.assert_array_equal( + simulation.calculate(variable, PERIOD), expected + ) @settings(max_examples=12, deadline=None, derandomize=True) @@ -492,15 +525,26 @@ def test_state_paid_cost_bases_match_main_without_se_deduction(reference_system, cases=[ _state_case(premiums=(6_000, 0, 0), other=(500, 0, 0)), _state_case( - premiums=(6_000, 0, 0), other=(22_000, 0, 0), subtotal_mode="supplied" + premiums=(6_000, 0, 0), + other=(500, 0, 0), + subtotal_mode="supplied", + subtotal=6_500, + ), + _state_case( + premiums=(6_000, 0, 0), + other=(22_000, 0, 0), + subtotal_mode="supplied", + subtotal=27_999, ), + _state_case(premiums=(1_000, 0, 0), subtotal_mode="supplied", subtotal=500), + _state_case(premiums=(1_000, 0, 0), subtotal_mode="supplied", subtotal=0), _state_case(premiums=(6_000, 0, 0), other=(21_999, 0, 0), deduction=75_000), _state_case(subtotal_mode="subtotal_only", subtotal=28_000, deduction=75_000), _state_case(premiums=(0, 0, 28_000), deduction=75_000), ] ) @given(cases=state_units) -def test_state_paid_cost_bases_restore_main_gross_costs_with_se_deduction( +def test_state_paid_cost_bases_preserve_main_inputs_with_se_deduction( reference_system, cases ): _assert_state_outputs(cases, reference_system, with_se_deduction=True) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml index b7d9991ce35..e2cd3149159 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/tax/income/subtractions/id_health_insurance_premiums_subtraction.yaml @@ -317,8 +317,8 @@ - name: Derived claimant medical overlap uses premiums excluding pretax payroll payments # Pretax payroll premiums are separate; remove only the Schedule 1 amount. # Qualified premiums: $6,000 - $1,000 = $5,000; floor: $30,000 * 7.5% = $2,250. - # Federal medical: $6,000 + $500 - $2,250 = $4,250 (unchanged). - # Claimant overlap: min($4,250, $5,000 + $500 - $2,250) = $3,250. + # Federal medical: $6,000 - $1,000 + $500 - $2,250 = $3,250. + # Claimant overlap: min($3,250, $5,000 + $500 - $2,250) = $3,250. # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. period: 2025 @@ -332,7 +332,7 @@ standard_deduction: 15_750 id_itemized_deductions: 20_000 output: - medical_expense_deduction: 4_250 + medical_expense_deduction: 3_250 id_qualified_health_insurance_premiums: 5_000 id_health_insurance_premiums_medical_deduction: 3_250 id_itemizes: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index 8bb478653d6..9dcdb1e7932 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -94,3 +94,45 @@ self_employed_health_insurance_ald: 1_000 output: nd_renters_refund_income: 23_500 + +- name: North Dakota supplied subtotal takes precedence over premiums and SE deduction + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + health_insurance_premiums: 6_000 + other_medical_expenses: 500 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 6_500 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nd_renters_refund_income: 23_500 + +- name: North Dakota supplied subtotal below potential exclusion remains unchanged + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 500 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 500 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + # The supplied paid-cost base is $500, not $1,500. + nd_renters_refund_income: 29_500 + +- name: North Dakota supplied zero subtotal does not restore a potential exclusion + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 0 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses_applied_exclusion: 0 + nd_renters_refund_income: 30_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml index d155c78af36..10a46317f43 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml @@ -100,3 +100,20 @@ head_is_dependent_elsewhere: true output: nm_medical_expense_credit: 0 + +- name: New Mexico supplied subtotal and SE premiums do not create senior credit eligibility + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + head_is_dependent_elsewhere: false + itemized_medical_expenses: 27_999 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_999 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nm_medical_expense_credit: 0 + nm_medical_expense_exemption: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml index d1d5fb1b65c..54d7b1c8e0a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml @@ -82,3 +82,20 @@ filing_status: SURVIVING_SPOUSE output: nm_medical_expense_exemption: 3_000 + +- name: New Mexico supplied subtotal and SE premiums do not create senior exemption eligibility + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + head_is_dependent_elsewhere: false + itemized_medical_expenses: 27_999 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_999 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nm_medical_expense_credit: 0 + nm_medical_expense_exemption: 0 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py new file mode 100644 index 00000000000..f48a1f7fa60 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class itemized_medical_expenses_applied_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "Self-employed premium exclusion applied to Schedule A medical expenses" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", + ] + documentation = ( + "Premiums actually excluded by the federal medical expense formula. " + "A caller-supplied subtotal bypasses that formula, so no exclusion " + "was applied. States retaining gross paid medical costs restore this " + "amount only for computed subtotals. Premium inputs use payer " + "attribution, including family coverage paid by a filer." + ) + + def formula(tax_unit, period, parameters): + if has_input_for_period( + tax_unit.simulation, "itemized_medical_expenses", period + ): + return 0 + return tax_unit("itemized_medical_expenses_excluded_premiums", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py index 730dd73b536..c07cab1c17e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py @@ -16,8 +16,9 @@ class itemized_medical_expenses_excluded_premiums(Variable): "paid by the head and spouse. Premium inputs use payer attribution, " "including family coverage paid by a filer. This preserves supplied " "tax-unit ALDs without allocating them to individual filers. States " - "that retain gross paid medical costs add this excluded amount back " - "to itemized_medical_expenses, including a caller-supplied subtotal." + "that retain gross paid medical costs use " + "itemized_medical_expenses_applied_exclusion to restore only premiums " + "actually excluded by the federal formula." ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index 830cf17a14d..7f9aaeaab05 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -15,22 +15,22 @@ class nd_renters_refund_income(Variable): documentation = ( "Renter refund income after unreimbursed medical costs actually paid. " - "The paid-cost base adds the excluded self-employed premiums back to " - "the federal medical expense amount, preserving both caller-supplied " - "subtotals and computed gross costs. Premium inputs use payer " + "The paid-cost base restores self-employed premiums only when the " + "federal formula excluded them. Caller-supplied subtotals remain " + "unchanged, including when components are supplied. Premium inputs use payer " "attribution: each person reports premiums that person paid, including " "family coverage regardless of whom it covers." ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund - # Restore gross paid costs while retaining supplied federal subtotals. + # Restore only premiums excluded from a computed federal subtotal. medical_expenses = add( tax_unit, period, [ "itemized_medical_expenses", - "itemized_medical_expenses_excluded_premiums", + "itemized_medical_expenses_applied_exclusion", ], ) return max_( diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index bd4c468b65b..7d584b8d7df 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -12,8 +12,8 @@ class nm_medical_expense_credit(Variable): documentation = ( "Senior medical credit based on gross unreimbursed costs paid, preserving " "premiums deducted through the federal self-employed health insurance ALD. " - "The federal medical amount plus excluded premiums preserves both " - "caller-supplied subtotals and computed gross costs. " + "Only the exclusion applied to a computed federal medical amount is " + "restored; caller-supplied subtotals remain unchanged even with components. " "This assumes qualifying costs were funded from income included in AGI. " "Premium inputs use payer attribution: each person reports premiums that " "person paid, including family coverage regardless of whom it covers. " @@ -24,13 +24,13 @@ def formula(tax_unit, period, parameters): pcredits = parameters(period).gov.states.nm.tax.income.credits p = pcredits.unreimbursed_medical_care_expense age = person("age", period) - # Restore gross paid costs while retaining supplied federal subtotals. + # Restore only premiums excluded from a computed federal subtotal. medical_expense = add( tax_unit, period, [ "itemized_medical_expenses", - "itemized_medical_expenses_excluded_premiums", + "itemized_medical_expenses_applied_exclusion", ], ) age_eligible = tax_unit.any(age >= p.age_eligibility) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index ef2bcebd722..9ded2f096e7 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -12,8 +12,9 @@ class nm_medical_expense_exemption(Variable): documentation = ( "Senior medical exemption based on gross unreimbursed costs paid, " "preserving premiums deducted through the federal self-employed health " - "insurance ALD. The federal medical amount plus excluded premiums " - "preserves both caller-supplied subtotals and computed gross costs. " + "insurance ALD. Only the exclusion applied to a computed federal medical " + "amount is restored; caller-supplied subtotals remain unchanged even " + "with components. " "This assumes qualifying costs were funded from income " "included in AGI. Premium inputs use payer attribution: each person " "reports premiums that person paid, including family coverage regardless " @@ -26,13 +27,13 @@ def formula(tax_unit, period, parameters): period ).gov.states.nm.tax.income.exemptions.unreimbursed_medical_care_expense age = person("age", period) - # Restore gross paid costs while retaining supplied federal subtotals. + # Restore only premiums excluded from a computed federal subtotal. medical_expense = add( tax_unit, period, [ "itemized_medical_expenses", - "itemized_medical_expenses_excluded_premiums", + "itemized_medical_expenses_applied_exclusion", ], ) age_eligible = tax_unit.any(age >= p.age_eligibility) From 380afa5003602b0b69b510825078b0b798cb8f54 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:01:54 -0400 Subject: [PATCH 27/45] Run individual Round 4 Python checks on the branch --- .github/workflows/docs.yaml | 98 +++++++++++-------------------------- 1 file changed, 29 insertions(+), 69 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index ee3afc0c21b..390c064b3ee 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,36 +1,22 @@ -name: Documentation +name: Round 4 individual Python checks + on: - pull_request: - branches: [main] - paths: - - "docs/**" - - "Makefile" - - "policyengine_us/tools/add_plotly_to_book.py" - - "policyengine_us/tools/render_plotly_outputs.py" - - ".github/workflows/docs.yaml" - push: - branches: [main] - paths: - - "docs/**" - - "Makefile" - - "policyengine_us/tools/add_plotly_to_book.py" - - "policyengine_us/tools/render_plotly_outputs.py" - - ".github/workflows/docs.yaml" workflow_dispatch: permissions: contents: read jobs: - Build: - name: Build documentation + Round4Python: + name: Round 4 individual Python checks runs-on: ubuntu-latest - timeout-minutes: 20 - concurrency: - group: docs-build-${{ github.ref }} - cancel-in-progress: true + timeout-minutes: 90 + env: + PYTHONUNBUFFERED: "1" + PYTEST_ADDOPTS: "--durations=25" steps: - - uses: actions/checkout@v6 + - name: Checkout assigned branch + uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -39,50 +25,24 @@ jobs: uses: astral-sh/setup-uv@v8.1.0 with: version: "0.12.13" - # The book is Jupyter Book 1 (docs/_config.yml, docs/_toc.yml), but - # uv.lock resolves jupyter-book 2, so the docs toolchain is installed - # here rather than with `uv sync --extra dev`. docs/_config.yml loads - # sphinxarg.ext, which needs sphinx-argparse. The book renders stored - # outputs and never imports policyengine_us; plotly (pinned to the - # uv.lock version) is only needed to render the stored charts. - - name: Install the Jupyter Book 1 toolchain - run: | - uv venv --python 3.13 .venv - uv pip install --python .venv/bin/python "jupyter-book>=1.0.4.post1,<2" "furo>=2024.8.6" sphinx-argparse "plotly==5.24.1" - - name: Render stored Plotly outputs - run: .venv/bin/python policyengine_us/tools/render_plotly_outputs.py docs - - name: Build the book + - name: Install locked dependencies + id: dependencies + run: uv sync --locked --extra dev + - name: Record Python and dependency versions run: | - source .venv/bin/activate - make documentation - - uses: actions/upload-artifact@v4 - with: - name: documentation - path: docs/_build/html - if-no-files-found: error + uv run --no-sync python - <<'PY' + import importlib.metadata + import json + import platform - Deploy: - name: Deploy documentation to GitHub Pages - needs: Build - timeout-minutes: 10 - if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'PolicyEngine/policyengine-us' - runs-on: ubuntu-latest - permissions: - contents: write - concurrency: - group: docs-deploy - cancel-in-progress: false - steps: - - uses: actions/checkout@v6 - - uses: actions/download-artifact@v4 - with: - name: documentation - path: site - # GitHub Pages serves the gh-pages branch with Jekyll unless .nojekyll - # is present, and Jekyll drops Sphinx's _static/ and _sources/ folders. - - name: Disable Jekyll - run: touch site/.nojekyll - - uses: JamesIves/github-pages-deploy-action@v4 - with: - branch: gh-pages - folder: site + print(json.dumps({ + "python": platform.python_version(), + "core": importlib.metadata.version("policyengine-core"), + "filelock": importlib.metadata.version("filelock"), + })) + PY + - name: Run medical premium conservation tests + run: uv run --no-sync python -m pytest -q policyengine_us/tests/core/test_medical_expense_premium_conservation.py + - name: Run Idaho health insurance premium tests + if: ${{ !cancelled() && steps.dependencies.outcome == 'success' }} + run: uv run --no-sync python -m pytest -q policyengine_us/tests/core/test_id_health_insurance_premiums.py From e6855037bc12a809fe22b61a616b6990fc22d1f6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 18:10:21 -0400 Subject: [PATCH 28/45] Restore documentation workflow after selective test dispatch --- .github/workflows/docs.yaml | 98 ++++++++++++++++++++++++++----------- 1 file changed, 69 insertions(+), 29 deletions(-) diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 390c064b3ee..ee3afc0c21b 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,22 +1,36 @@ -name: Round 4 individual Python checks - +name: Documentation on: + pull_request: + branches: [main] + paths: + - "docs/**" + - "Makefile" + - "policyengine_us/tools/add_plotly_to_book.py" + - "policyengine_us/tools/render_plotly_outputs.py" + - ".github/workflows/docs.yaml" + push: + branches: [main] + paths: + - "docs/**" + - "Makefile" + - "policyengine_us/tools/add_plotly_to_book.py" + - "policyengine_us/tools/render_plotly_outputs.py" + - ".github/workflows/docs.yaml" workflow_dispatch: permissions: contents: read jobs: - Round4Python: - name: Round 4 individual Python checks + Build: + name: Build documentation runs-on: ubuntu-latest - timeout-minutes: 90 - env: - PYTHONUNBUFFERED: "1" - PYTEST_ADDOPTS: "--durations=25" + timeout-minutes: 20 + concurrency: + group: docs-build-${{ github.ref }} + cancel-in-progress: true steps: - - name: Checkout assigned branch - uses: actions/checkout@v6 + - uses: actions/checkout@v6 - name: Set up Python uses: actions/setup-python@v6 with: @@ -25,24 +39,50 @@ jobs: uses: astral-sh/setup-uv@v8.1.0 with: version: "0.12.13" - - name: Install locked dependencies - id: dependencies - run: uv sync --locked --extra dev - - name: Record Python and dependency versions + # The book is Jupyter Book 1 (docs/_config.yml, docs/_toc.yml), but + # uv.lock resolves jupyter-book 2, so the docs toolchain is installed + # here rather than with `uv sync --extra dev`. docs/_config.yml loads + # sphinxarg.ext, which needs sphinx-argparse. The book renders stored + # outputs and never imports policyengine_us; plotly (pinned to the + # uv.lock version) is only needed to render the stored charts. + - name: Install the Jupyter Book 1 toolchain + run: | + uv venv --python 3.13 .venv + uv pip install --python .venv/bin/python "jupyter-book>=1.0.4.post1,<2" "furo>=2024.8.6" sphinx-argparse "plotly==5.24.1" + - name: Render stored Plotly outputs + run: .venv/bin/python policyengine_us/tools/render_plotly_outputs.py docs + - name: Build the book run: | - uv run --no-sync python - <<'PY' - import importlib.metadata - import json - import platform + source .venv/bin/activate + make documentation + - uses: actions/upload-artifact@v4 + with: + name: documentation + path: docs/_build/html + if-no-files-found: error - print(json.dumps({ - "python": platform.python_version(), - "core": importlib.metadata.version("policyengine-core"), - "filelock": importlib.metadata.version("filelock"), - })) - PY - - name: Run medical premium conservation tests - run: uv run --no-sync python -m pytest -q policyengine_us/tests/core/test_medical_expense_premium_conservation.py - - name: Run Idaho health insurance premium tests - if: ${{ !cancelled() && steps.dependencies.outcome == 'success' }} - run: uv run --no-sync python -m pytest -q policyengine_us/tests/core/test_id_health_insurance_premiums.py + Deploy: + name: Deploy documentation to GitHub Pages + needs: Build + timeout-minutes: 10 + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' && github.repository == 'PolicyEngine/policyengine-us' + runs-on: ubuntu-latest + permissions: + contents: write + concurrency: + group: docs-deploy + cancel-in-progress: false + steps: + - uses: actions/checkout@v6 + - uses: actions/download-artifact@v4 + with: + name: documentation + path: site + # GitHub Pages serves the gh-pages branch with Jekyll unless .nojekyll + # is present, and Jekyll drops Sphinx's _static/ and _sources/ folders. + - name: Disable Jekyll + run: touch site/.nojekyll + - uses: JamesIves/github-pages-deploy-action@v4 + with: + branch: gh-pages + folder: site From c6e06d08be54616847580d08e7bc31e56c07ccf8 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 10 Oct 2026 23:03:54 -0400 Subject: [PATCH 29/45] Document and test the state medical input deletion core gate --- .../hub-schedA-se-premiums-1010.fixed.md | 2 +- ...st_state_medical_expense_input_deletion.py | 102 ++++++++++++++++++ ...ized_medical_expenses_applied_exclusion.py | 6 +- 3 files changed, 108 insertions(+), 2 deletions(-) create mode 100644 policyengine_us/tests/core/test_state_medical_expense_input_deletion.py diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md index 4cfb53bff12..682ab26d913 100644 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -1 +1 @@ -Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and every mix of caller-supplied subtotals and components by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and intact caller-supplied subtotals by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. Correct North Dakota and New Mexico results after deleting and recalculating a supplied medical subtotal require policyengine-core>=3.32.27; state regressions document the known provenance limitation on earlier cores. diff --git a/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py new file mode 100644 index 00000000000..d0f25100f5e --- /dev/null +++ b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py @@ -0,0 +1,102 @@ +"""State paid-cost bases after a supplied medical subtotal is deleted. + +The intact inputs remain supplied. Deleted inputs must instead use the +recomputed subtotal and restore its federal premium exclusion. Core 3.32.27 +fixes the retained input provenance that prevents this on older versions. +""" + +from importlib.metadata import version + +import numpy as np +from packaging.version import Version +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system + + +PERIOD = 2025 +SUBTOTAL_DELETION_CASES = [ + pytest.param(False, id="intact-input"), + pytest.param( + True, + id="deleted-input", + marks=pytest.mark.xfail( + condition=Version(version("policyengine-core")) < Version("3.32.27"), + strict=True, + reason=( + "policyengine-core<3.32.27 retains deleted input provenance and " + "misclassifies a recomputed medical subtotal as supplied, " + "suppressing the ND/NM premium add-back; fixed by " + "policyengine-core#561." + ), + ), + ), +] + + +def _simulation(state, other_expenses): + simulation = Simulation( + tax_benefit_system=system, + situation={ + "people": { + "head": { + "age": {PERIOD: 65}, + "is_tax_unit_head": {PERIOD: True}, + "employment_income": {PERIOD: 30_000}, + "medicare_enrolled": {PERIOD: False}, + "health_insurance_premiums": {PERIOD: 6_000}, + "other_medical_expenses": {PERIOD: other_expenses}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["head"], + "filing_status": {PERIOD: "SINGLE"}, + "head_is_dependent_elsewhere": {PERIOD: False}, + "self_employed_health_insurance_ald": {PERIOD: 1_000}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {PERIOD: state}, + } + }, + }, + ) + simulation.set_input( + "itemized_medical_expenses", PERIOD, np.array([6_000 + other_expenses]) + ) + return simulation + + +@pytest.mark.parametrize("delete_subtotal", SUBTOTAL_DELETION_CASES) +def test_nd_paid_cost_base_after_medical_subtotal_deletion(delete_subtotal): + simulation = _simulation("ND", other_expenses=500) + if delete_subtotal: + simulation.delete_arrays("itemized_medical_expenses", PERIOD) + + # Calculate the state first: it recomputes the deleted subtotal as 5,500 + # before inspecting provenance. Restore its 1,000 exclusion to retain + # 6,500 paid costs. Old core retains the marker and returns 24,500. + assert simulation.calculate("nd_renters_refund_income", PERIOD).tolist() == [23_500] + + +@pytest.mark.parametrize("delete_subtotal", SUBTOTAL_DELETION_CASES) +def test_nm_senior_benefits_after_medical_subtotal_deletion(delete_subtotal): + simulation = _simulation("NM", other_expenses=22_000) + if delete_subtotal: + simulation.delete_arrays("itemized_medical_expenses", PERIOD) + + # The recomputed federal subtotal is 27,000, but restoring its 1,000 + # exclusion meets NM's 28,000 paid-cost threshold. On old core both + # benefits are zero. Calculate both before asserting to preserve evidence. + benefits = tuple( + simulation.calculate(variable, PERIOD).item() + for variable in ( + "nm_medical_expense_credit", + "nm_medical_expense_exemption", + ) + ) + assert benefits == (2_800, 3_000) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py index f48a1f7fa60..92801a10d0f 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -16,7 +16,11 @@ class itemized_medical_expenses_applied_exclusion(Variable): "A caller-supplied subtotal bypasses that formula, so no exclusion " "was applied. States retaining gross paid medical costs restore this " "amount only for computed subtotals. Premium inputs use payer " - "attribution, including family coverage paid by a filer." + "attribution, including family coverage paid by a filer. Correct " + "provenance after deleting and recalculating a supplied subtotal " + "requires policyengine-core>=3.32.27. Earlier cores retain the deleted " + "input marker, so North Dakota's renter refund and New Mexico's senior " + "medical credit and exemption can omit the computed exclusion add-back." ) def formula(tax_unit, period, parameters): From dfa0863bd694f1c70853c5b68df92838151d11bd Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 06:25:12 -0400 Subject: [PATCH 30/45] Require core 3.32.27 for medical input deletion provenance --- changelog.d/hub-pb-id-premiums-1009.fixed.md | 1 - .../hub-schedA-se-premiums-1010.fixed.md | 2 +- ...st_state_medical_expense_input_deletion.py | 28 ++++--------------- ...ized_medical_expenses_applied_exclusion.py | 8 ++---- pyproject.toml | 2 +- uv.lock | 8 +++--- 6 files changed, 15 insertions(+), 34 deletions(-) delete mode 100644 changelog.d/hub-pb-id-premiums-1009.fixed.md diff --git a/changelog.d/hub-pb-id-premiums-1009.fixed.md b/changelog.d/hub-pb-id-premiums-1009.fixed.md deleted file mode 100644 index 5563b17bd5e..00000000000 --- a/changelog.d/hub-pb-id-premiums-1009.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Fix Idaho's subtraction for health insurance premiums paid by the tax unit's head and spouse, including modeled Medicare premiums for enrolled filers, exclude premiums already deducted or excluded from income, and coordinate the subtraction, deduction election, and derived Idaho itemized deductions using claimant-paid medical expenses while preserving supplied actual medical and itemized deductions. diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md index 682ab26d913..2e6af14fa53 100644 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -1 +1 @@ -Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and intact caller-supplied subtotals by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. Correct North Dakota and New Mexico results after deleting and recalculating a supplied medical subtotal require policyengine-core>=3.32.27; state regressions document the known provenance limitation on earlier cores. +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and caller-supplied subtotals by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. Require policyengine-core>=3.32.27 so deleting and recalculating a supplied medical subtotal also preserves correct North Dakota and New Mexico results. diff --git a/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py index d0f25100f5e..8f700ae0cae 100644 --- a/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py +++ b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py @@ -1,14 +1,11 @@ """State paid-cost bases after a supplied medical subtotal is deleted. The intact inputs remain supplied. Deleted inputs must instead use the -recomputed subtotal and restore its federal premium exclusion. Core 3.32.27 -fixes the retained input provenance that prevents this on older versions. +recomputed subtotal and restore its federal premium exclusion. The required +Core 3.32.27 release clears input provenance when the subtotal is deleted. """ -from importlib.metadata import version - import numpy as np -from packaging.version import Version import pytest from policyengine_us import Simulation @@ -18,20 +15,7 @@ PERIOD = 2025 SUBTOTAL_DELETION_CASES = [ pytest.param(False, id="intact-input"), - pytest.param( - True, - id="deleted-input", - marks=pytest.mark.xfail( - condition=Version(version("policyengine-core")) < Version("3.32.27"), - strict=True, - reason=( - "policyengine-core<3.32.27 retains deleted input provenance and " - "misclassifies a recomputed medical subtotal as supplied, " - "suppressing the ND/NM premium add-back; fixed by " - "policyengine-core#561." - ), - ), - ), + pytest.param(True, id="deleted-input"), ] @@ -79,7 +63,7 @@ def test_nd_paid_cost_base_after_medical_subtotal_deletion(delete_subtotal): # Calculate the state first: it recomputes the deleted subtotal as 5,500 # before inspecting provenance. Restore its 1,000 exclusion to retain - # 6,500 paid costs. Old core retains the marker and returns 24,500. + # 6,500 paid costs. assert simulation.calculate("nd_renters_refund_income", PERIOD).tolist() == [23_500] @@ -90,8 +74,8 @@ def test_nm_senior_benefits_after_medical_subtotal_deletion(delete_subtotal): simulation.delete_arrays("itemized_medical_expenses", PERIOD) # The recomputed federal subtotal is 27,000, but restoring its 1,000 - # exclusion meets NM's 28,000 paid-cost threshold. On old core both - # benefits are zero. Calculate both before asserting to preserve evidence. + # exclusion meets NM's 28,000 paid-cost threshold. Calculate both before + # asserting to preserve evidence. benefits = tuple( simulation.calculate(variable, PERIOD).item() for variable in ( diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py index 92801a10d0f..f034c37903a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -16,11 +16,9 @@ class itemized_medical_expenses_applied_exclusion(Variable): "A caller-supplied subtotal bypasses that formula, so no exclusion " "was applied. States retaining gross paid medical costs restore this " "amount only for computed subtotals. Premium inputs use payer " - "attribution, including family coverage paid by a filer. Correct " - "provenance after deleting and recalculating a supplied subtotal " - "requires policyengine-core>=3.32.27. Earlier cores retain the deleted " - "input marker, so North Dakota's renter refund and New Mexico's senior " - "medical credit and exemption can omit the computed exclusion add-back." + "attribution, including family coverage paid by a filer. The required " + "policyengine-core>=3.32.27 clears input provenance on deletion, so " + "recalculating a deleted subtotal also restores its applied exclusion." ) def formula(tax_unit, period, parameters): diff --git a/pyproject.toml b/pyproject.toml index 299025fc55a..a6adb5efda6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ # pandas 3 copy-on-write enables the shallow-copy fast path in dataset # extension and is available on every supported Python version. "pandas>=3.0", - "policyengine-core>=3.32.8", + "policyengine-core>=3.32.27", "spm-calculator>=1.0.0,<=1.0.0.post1", "tables>=3.9", "tqdm>=4.67.1", diff --git a/uv.lock b/uv.lock index 767fe44d0e4..0efc72d3f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -1791,7 +1791,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.32.8" +version = "3.32.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -1811,9 +1811,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/df/2fe428d690f02cb109e91d22b8af31ca6dcb047920d7177a2b28487a8ea1/policyengine_core-3.32.8.tar.gz", hash = "sha256:925a8990985ec22885f4d6c4ed129cd13dcc2eab02cf9a130939ca563394fcc3", size = 426369, upload-time = "2026-09-27T19:10:50.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/24/84c0747c64fba95e89d3acd332604ad01e8a220ce7e80cf8d342b3b213bd/policyengine_core-3.32.27.tar.gz", hash = "sha256:632e8708ec243f7b592ec857e1978df207547c1510039373e707c310173cf4e4", size = 599334, upload-time = "2026-10-09T05:44:09.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/45/bbba25be7ddc52ad9bb6f2b8ea044eb3c670f4168c67044328f631ccb025/policyengine_core-3.32.8-py3-none-any.whl", hash = "sha256:84e470d1f03d531d1d5ee77c173c59444bbbdea50132faf9150a82aa00d54397", size = 246996, upload-time = "2026-09-27T19:10:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b3/fc1a10b03f2a159bc5623f0aa465887d1c1c98ee2f3118fd9ee3e8275921/policyengine_core-3.32.27-py3-none-any.whl", hash = "sha256:d0b0135939252ed628efd496988f76c0ab89bfa3b3437aa90f39000667c37687", size = 278496, upload-time = "2026-10-09T05:44:07.764Z" }, ] [[package]] @@ -1850,7 +1850,7 @@ requires-dist = [ { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, - { name = "policyengine-core", specifier = ">=3.32.8" }, + { name = "policyengine-core", specifier = ">=3.32.27" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "setuptools", marker = "extra == 'dev'", specifier = ">=80.9.0" }, { name = "spm-calculator", specifier = ">=1.0.0,<=1.0.0.post1" }, From 1e8a44539a48060a8706adeddd675870235c88a1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:28:58 -0400 Subject: [PATCH 31/45] Net the self-employed deduction once across Montana lines 2 and 3 - Line 2 reuses #10055's per-person excluded premiums; the new line 3 variable nets the remainder of the self-employed deduction from long-term care premiums (26 U.S.C. 162(l)(2)(C) lets those premiums into that deduction). - The separate-filing variant applies lines 2 and 3 only while the Itemized Deductions Schedule applies; a reform that allows separate filing after 2023 keeps premiums under the floor. - Tests: supply the self-employed deduction where its earnings limit matters, a below-floor dependent case, #10055's Montana cases moved to lines 1-3, and invariants for premium conservation and #10055's applied exclusion. Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/test_mt_joint_agi_deductions_properties.py | 4 +--- .../unit/test_mt_medical_premium_deduction_invariants.py | 3 ++- .../medical/mt_eligible_medical_insurance_premiums.py | 4 +++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py index 20448b1e013..c9ea424b401 100644 --- a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py +++ b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py @@ -187,9 +187,7 @@ def test_medical_floor_uses_the_return_montana_agi(grid, year): # Adding each spouse's Montana AGI after flooring it at zero would change # the deduction somewhere on the grid. summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) - unpooled = ( - np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + premiums - ) + unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + premiums assert np.any(np.abs(unpooled - expected) > 1) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 873b482fa32..55e26b677b6 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -409,7 +409,8 @@ def test_seeded_population(year): agi_floor = FORM_FLOOR * run["mt_agi_joint"] total = _unit_sum( run, - run["other_medical_expenses"] + run["medical_expense_health_insurance_premiums"], + run["other_medical_expenses"] + + run["medical_expense_health_insurance_premiums"], ) assert np.any((full > 0) & (total < agi_floor)) assert np.any((full > 0) & (floored > 0)) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py index 060f8b4e5a4..23e8eb423d2 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py @@ -31,5 +31,7 @@ def formula(person, period, parameters): premiums = person("medical_expense_health_insurance_premiums", period) # Premiums deducted in determining Montana AGI do not qualify: the # self-employed health insurance deduction (Schedule 1, line 17). - deducted = person("self_employed_health_insurance_ald_excluded_premiums", period) + deducted = person( + "self_employed_health_insurance_ald_excluded_premiums", period + ) return premiums - deducted From 6c173268f16ad060b1f5d620b536c0c12cca7dee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:48:15 -0400 Subject: [PATCH 32/45] Drop #9903's changelog fragment, already released on main Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-mt-medical-floor-joint-agi.fixed.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/fix-mt-medical-floor-joint-agi.fixed.md diff --git a/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md b/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md deleted file mode 100644 index abd7a2db8d4..00000000000 --- a/changelog.d/fix-mt-medical-floor-joint-agi.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Floor Montana's 2021-2023 medical expense deduction and standard deduction on a joint return's own Montana AGI (Form 2, page 1, line 14), so one spouse's loss offsets the other's income, instead of on the sum of each spouse's AGI floored at zero. From 2024, take the federal medical expense deduction (7.5% of federal AGI), because Form 2 line 2 uses federal itemized deductions; the Montana floor had fallen to zero from 2024, so the full medical expense was deducted. From 595cd1e541408eab76ad1f9ab57936ed12021bab Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 11 Oct 2026 12:48:22 -0400 Subject: [PATCH 33/45] Describe line 3 netting in the changelog fragment Co-Authored-By: Claude Opus 5.5 --- changelog.d/fix-mt-medical-premiums-in-full.fixed.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md index fd5e0bc2250..1be85896d4b 100644 --- a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md +++ b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md @@ -1 +1 @@ -Deduct Montana's 2021–2023 medical insurance premiums (other than those taken as the self-employed health insurance deduction) and long-term care insurance premiums in full on the Itemized Deductions Schedule, applying the 7.5% floor only to other medical and dental expenses. +Deduct Montana's 2021–2023 medical and long-term care insurance premiums in full on the Itemized Deductions Schedule (lines 2 and 3), net of premiums taken as the self-employed health insurance deduction, and apply the 7.5% floor only to other medical and dental expenses. From 541639564988c52ccd0480a5fb365c1de16e4b4a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:29:47 -0400 Subject: [PATCH 34/45] Drop #10055's changelog fragment, already released on main Co-Authored-By: Claude Opus 5.5 --- changelog.d/hub-schedA-se-premiums-1010.fixed.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 changelog.d/hub-schedA-se-premiums-1010.fixed.md diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md deleted file mode 100644 index 2e6af14fa53..00000000000 --- a/changelog.d/hub-schedA-se-premiums-1010.fixed.md +++ /dev/null @@ -1 +0,0 @@ -Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and caller-supplied subtotals by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. Require policyengine-core>=3.32.27 so deleting and recalculating a supplied medical subtotal also preserves correct North Dakota and New Mexico results. From 39a5e66899a63de8cc64e5dcd1334869f8974a80 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:34:52 -0400 Subject: [PATCH 35/45] Net a return-level self-employed deduction on Montana joint lines 2-3 The r2 review found the joint 2021-2023 path read only person-level self-employed health insurance deductions, so a deduction supplied for the return (as #10055's federal Schedule A exclusion honors) was lost. New mt_premium_deduction_joint nets the return's deduction from the head's and spouse's premiums, medical premiums first (the same exclusion as Schedule A) and the rest from long-term care premiums; dependents net their own deductions. Invariants now check the pooled closed form, draw return-level deductions, and qualify the dependent payer boundary. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 24 +++ .../itemized/mt_premium_deduction_joint.yaml | 135 +++++++++++++ ...mt_medical_premium_deduction_invariants.py | 188 ++++++++++++------ ...gible_long_term_care_insurance_premiums.py | 5 +- .../mt_eligible_medical_insurance_premiums.py | 5 +- .../mt_medical_expense_deduction_joint.py | 9 +- .../medical/mt_premium_deduction_joint.py | 56 ++++++ 7 files changed, 355 insertions(+), 67 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml create mode 100644 policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 5cc3b2cfd07..05322431e40 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -373,3 +373,27 @@ # Line 1: 4,000 - 7.5% x 40,000 = 1,000. # Line 2: 2,000 - 2,000 = 0. Line 3: 1,500 - 1,000 = 500. mt_medical_expense_deduction_joint: 1_500 + +- name: A self-employed deduction supplied only for the return still comes off line 2 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + person2: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + self_employed_health_insurance_ald: 2_000 + mt_agi_joint: 60_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: 0. Line 2: 6,000 - 2,000 = 4,000. Ignoring the supplied + # deduction would give 6,000. + mt_medical_expense_deduction_joint: [4_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml new file mode 100644 index 00000000000..64e1a061c8f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml @@ -0,0 +1,135 @@ +- name: A self-employed deduction supplied only for the return is netted from the filers' premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + person2: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + self_employed_health_insurance_ald: 2_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 2: 6,000 - 2,000 deducted in Montana AGI = 4,000. + mt_premium_deduction_joint: 4_000 + +- name: A return's self-employed deduction covering long-term care premiums removes them from line 3 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + long_term_health_insurance_premiums: 400 + tax_units: + tax_unit: + members: [person1] + self_employed_health_insurance_ald: 400 + households: + household: + members: [person1] + state_code: MT + output: + mt_premium_deduction_joint: 0 + +- name: The return's deduction left after medical premiums reduces either spouse's long-term care premiums + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 2_000 + person2: + is_tax_unit_spouse: true + long_term_health_insurance_premiums: 1_500 + tax_units: + tax_unit: + members: [person1, person2] + self_employed_health_insurance_ald: 3_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 2: 2,000 - 2,000 = 0. Line 3: 1,500 - (3,000 - 2,000) = 500. + itemized_medical_expenses_excluded_premiums: 2_000 + mt_premium_deduction_joint: 500 + +- name: Each spouse's own deduction is netted once across the return + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + person2: + is_tax_unit_spouse: true + health_insurance_premiums: 3_000 + self_employed_health_insurance_ald_person: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + self_employed_health_insurance_ald: 3_000 + # (6,000 + 3,000) - (1,000 + 2,000) = 6,000. + mt_premium_deduction_joint: 6_000 + +- name: A dependent's own deduction is netted from the dependent's premiums only + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 3_000 + person2: + is_tax_unit_dependent: true + health_insurance_premiums: 1_000 + long_term_health_insurance_premiums: 300 + self_employed_health_insurance_ald_person: 1_200 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + # The dependent's deduction is on the dependent's own return, so it is + # not part of the return's deduction and does not reduce the head's + # 3,000. The dependent's line 2 is 1,000 - 1,000 = 0 and line 3 is + # 300 - (1,200 - 1,000) = 100. + self_employed_health_insurance_ald: 0 + mt_premium_deduction_joint: 3_100 + +- name: Outside Montana the amount is zero + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: ID + output: + mt_premium_deduction_joint: 0 diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 55e26b677b6..43a06c5c1c4 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -3,46 +3,57 @@ The 2021-2023 Form 2 Itemized Deductions Schedule floors only line 1, medical and dental expenses other than insurance premiums, at 7.5% of Montana AGI. Line 2 deducts medical insurance premiums in full and line 3 long-term care -insurance premiums in full, except premiums already deducted in Montana AGI -through the self-employed health insurance deduction, and pre-tax premiums. -Former MCA 15-30-2131(1)(a)(iii)-(iv) and (1)(g). From 2024 Montana takes -federal itemized deductions, where premiums stay under the federal floor. +insurance premiums in full, except premiums already deducted through the +self-employed health insurance deduction, and pre-tax premiums. Former MCA +15-30-2131(1)(a)(iii)-(iv) and (1)(g). From 2024 Montana takes federal +itemized deductions, where premiums stay under the federal floor. + +For the head and spouse that deduction is part of Montana AGI. A dependent +takes it on the dependent's own return, where it does not reduce the filer's +Montana AGI; under PolicyEngine's payer attribution those premiums were paid +by the dependent, so they are netted from the dependent's own premiums. The +properties below hold for the premium inputs as attributed and the deduction +as modeled; they do not establish who paid a premium. Why Python rather than YAML: Hypothesis generates mixed populations (single, head of household and joint returns with dependents; premiums reported -directly, decomposed or with Medicare Part B; derived and supplied -self-employed deductions), and every batch also holds twins of each return -whose differences are asserted. YAML cases check fixed examples; these -properties check relations between generated returns. +directly, decomposed or with Medicare Part B; derived deductions, deductions +supplied per person, and deductions supplied only for the return), and every +batch also holds twins of each return whose differences are asserted. YAML +cases check fixed examples; these properties check relations between +generated returns. Each batch runs as one vectorized simulation holding three copies of every tax unit: the drawn return, a twin with higher after-tax and long-term care -premiums for people with no self-employed deduction, and a twin with no -pre-tax premiums. The 7.5% rate is taken from Form 2 line 1c rather than the -model's parameters. Montana AGI and the self-employed deduction are read from -the model; they are inputs to these properties, not derived independently. -For every tax unit: +premiums for people whose premiums no deduction can absorb, and a twin with +no pre-tax premiums. The 7.5% rate is taken from Form 2 line 1c rather than +the model's parameters. Montana AGI and the self-employed deductions are read +from the model; they are inputs to these properties, not derived +independently. For every tax unit: 1. Accounting (2021-2023): the joint deduction is - max(0, other expenses - 7.5% x Montana AGI) + line 2 + line 3, held by the - head. The separate variant is the same per head and spouse on their own - Montana AGI; dependents hold nothing. -2. Premium conservation, per person, against a closed form: line 2 + line 3 - = max(0, premiums + long-term care premiums - self-employed deduction), - with premiums computed from the raw inputs. Line 2 alone is the premiums - less the part of the deduction they absorb. So the deduction is netted - exactly once. + max(0, other expenses - 7.5% x Montana AGI) + lines 2 and 3, held by the + head. The separate variant is max(0, own other expenses - 7.5% x own + Montana AGI) + own lines 2 and 3 for the head and spouse; dependents hold + nothing. +2. Premium conservation against closed forms. Per person (separate returns + and dependents): line 2 + line 3 = max(0, premiums + long-term care + premiums - own deduction), and line 2 = premiums - min(premiums, own + deduction). Joint return: lines 2 and 3 = max(0, filers' premiums + + filers' long-term care premiums - the return's deduction) + each + dependent's per-person amount. Premiums are computed from the raw inputs. + So each deduction is netted exactly once. 3. Bounds (2021-2023): premiums are never floored, so the deduction is at least lines 2 and 3 and at most those plus other expenses. -4. Slope (2021-2023): raising after-tax premiums or long-term care premiums - of people with no self-employed deduction raises the deduction by exactly - the increase. Montana AGI is unchanged by those increases (asserted). +4. Slope (2021-2023): raising after-tax or long-term care premiums that no + deduction can absorb raises the deduction by exactly the increase. + Montana AGI is unchanged by those increases (asserted). 5. Pre-tax premiums never change lines 2 or 3. 6. Line 1 base: other expenses equal federal itemized medical expenses plus the self-employed premiums those exclude, less the premiums. Line 1 therefore tracks the federal definition apart from the premiums. -7. Differential (2021-2023): for single filers the joint and separate - variants agree. +7. Differential (2021-2023): for single filers whose deduction is recorded + per person, the joint and separate variants agree. 8. From 2024 the joint deduction is the federal medical expense deduction and the separate variant is zero. """ @@ -100,6 +111,10 @@ def tax_units(draw): "head": draw(people()), "spouse": draw(people()) if kind.startswith("joint") else None, "dependents": [draw(people()) for _ in range(n_dependents)], + # The return's deduction, supplied without person amounts. + "supplied_unit_deduction": draw( + st.one_of(st.none(), st.integers(0, 20_000).map(float)) + ), } @@ -144,6 +159,9 @@ def person(): "head": person(), "spouse": person() if kind.startswith("joint") else None, "dependents": [person() for _ in range(n_dependents)], + "supplied_unit_deduction": ( + float(rng.integers(0, 20_001)) if rng.random() < 0.15 else None + ), } ) return units @@ -153,15 +171,35 @@ def _may_have_deduction(values): return values["self_employed"] or values["supplied_deduction"] is not None -def _increases(values, variant): - """Premium increases for the raised twin, for people with no deduction.""" - if variant != "raised" or _may_have_deduction(values): +def _entries(unit): + entries = [("head", unit["head"])] + if unit["spouse"] is not None: + entries.append(("spouse", unit["spouse"])) + return entries + [("dependent", d) for d in unit["dependents"]] + + +def _raisable(unit, role, values): + """Whether no deduction can absorb a premium increase for this person. + + The return's deduction is pooled over the head's and spouse's premiums, + so any filer's deduction can absorb another filer's increase. + """ + if role == "dependent": + return not _may_have_deduction(values) + filers = [v for r, v in _entries(unit) if r != "dependent"] + return unit["supplied_unit_deduction"] is None and not any( + _may_have_deduction(v) for v in filers + ) + + +def _increases(unit, role, values, variant): + if variant != "raised" or not _raisable(unit, role, values): return 0.0, 0.0 return values["premium_increase"], values["long_term_care_increase"] -def _person_inputs(values, role, variant): - premium_increase, long_term_care_increase = _increases(values, variant) +def _person_inputs(unit, role, values, variant): + premium_increase, long_term_care_increase = _increases(unit, role, values, variant) premium = values["premium"] + premium_increase source = values["premium_source"] inputs = { @@ -192,9 +230,9 @@ def _person_inputs(values, role, variant): return inputs -def _expected_premium(values, variant): +def _expected_premium(unit, role, values, variant): """Premiums counted as federal medical expenses, from the inputs alone.""" - premium = values["premium"] + _increases(values, variant)[0] + premium = values["premium"] + _increases(unit, role, values, variant)[0] if values["premium_source"] == "direct" and premium != 0: return premium part_b = values["part_b"] if values["premium_source"] == "medicare" else 0.0 @@ -203,21 +241,17 @@ def _expected_premium(values, variant): def _situation(units, year): people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} - expected_premium, increase, roles, supplied = [], [], [], [] + expected_premium, increase, roles, supplied, unit_supplied = [], [], [], [], [] for variant in VARIANTS: for i, unit in enumerate(units): tag = f"{variant}_{i}" members, couple = [], [] - entries = [("head", unit["head"])] - if unit["spouse"] is not None: - entries.append(("spouse", unit["spouse"])) - entries += [("dependent", d) for d in unit["dependents"]] unit_increase = 0.0 - for j, (role, values) in enumerate(entries): + for j, (role, values) in enumerate(_entries(unit)): name = f"{tag}_{role}_{j}" - people[name] = _person_inputs(values, role, variant) + people[name] = _person_inputs(unit, role, values, variant) members.append(name) - expected_premium.append(_expected_premium(values, variant)) + expected_premium.append(_expected_premium(unit, role, values, variant)) roles.append(role) supplied.append( np.nan @@ -228,10 +262,16 @@ def _situation(units, year): groups["marital_units"][f"{tag}_single_{j}"] = {"members": [name]} else: couple.append(name) - unit_increase += sum(_increases(values, "raised")) + unit_increase += sum(_increases(unit, role, values, "raised")) increase.append(unit_increase) groups["marital_units"][f"{tag}_couple"] = {"members": couple} - groups["tax_units"][f"{tag}_tax_unit"] = {"members": members} + tax_unit = {"members": members} + if unit["supplied_unit_deduction"] is not None: + tax_unit["self_employed_health_insurance_ald"] = { + year: unit["supplied_unit_deduction"] + } + groups["tax_units"][f"{tag}_tax_unit"] = tax_unit + unit_supplied.append(unit["supplied_unit_deduction"] is not None) groups["households"][f"{tag}_household"] = { "members": members, "state_code": "MT", @@ -246,6 +286,7 @@ def _situation(units, year): np.array(increase), np.array(roles), np.array(supplied), + np.array(unit_supplied), ) @@ -262,6 +303,8 @@ def _situation(units, year): ] UNIT_VARIABLES = [ "mt_agi_joint", + "self_employed_health_insurance_ald", + "mt_premium_deduction_joint", "itemized_medical_expenses", "itemized_medical_expenses_applied_exclusion", "medical_expense_deduction", @@ -269,7 +312,9 @@ def _situation(units, year): def _run(units, year): - situation, expected_premium, increase, roles, supplied = _situation(units, year) + situation, expected_premium, increase, roles, supplied, unit_supplied = _situation( + units, year + ) sim = Simulation(situation=situation) out = { name: np.asarray(sim.calculate(name, year), dtype=float) @@ -281,6 +326,7 @@ def _run(units, year): out["increase"] = increase out["role"] = roles out["supplied"] = supplied + out["unit_supplied"] = unit_supplied return out @@ -308,34 +354,42 @@ def _check(units, year): premium = run["medical_expense_health_insurance_premiums"] long_term_care = run["long_term_health_insurance_premiums"] - se_deduction = np.maximum(run["self_employed_health_insurance_ald_person"], 0) + own_deduction = np.maximum(run["self_employed_health_insurance_ald_person"], 0) line_2 = run["mt_eligible_medical_insurance_premiums"] line_3 = run["mt_eligible_long_term_care_insurance_premiums"] other = run["other_medical_expenses"] + dependent = role == "dependent" + filer = ~dependent - # 2. Premium conservation against a closed form. + # 2. Premium conservation against closed forms. np.testing.assert_allclose(premium, run["expected_premium"], atol=TOLERANCE) supplied = ~np.isnan(run["supplied"]) np.testing.assert_allclose( - se_deduction[supplied], run["supplied"][supplied], atol=TOLERANCE + own_deduction[supplied], run["supplied"][supplied], atol=TOLERANCE ) np.testing.assert_allclose( line_2 + line_3, - np.maximum(run["expected_premium"] + long_term_care - se_deduction, 0), + np.maximum(run["expected_premium"] + long_term_care - own_deduction, 0), atol=TOLERANCE, ) np.testing.assert_allclose( line_2, - run["expected_premium"] - np.minimum(run["expected_premium"], se_deduction), + run["expected_premium"] - np.minimum(run["expected_premium"], own_deduction), atol=TOLERANCE, ) assert (line_3 <= long_term_care + TOLERANCE).all() + return_deduction = np.maximum(run["self_employed_health_insurance_ald"], 0) + expected_joint_premiums = np.maximum( + _unit_sum(run, filer * (run["expected_premium"] + long_term_care)) + - return_deduction, + 0, + ) + _unit_sum(run, dependent * (line_2 + line_3)) + full = run["mt_premium_deduction_joint"] + np.testing.assert_allclose(full, expected_joint_premiums, atol=TOLERANCE) # 1. Accounting, joint and separate. - full = _unit_sum(run, line_2 + line_3) floored = np.maximum(_unit_sum(run, other) - FORM_FLOOR * run["mt_agi_joint"], 0) np.testing.assert_allclose(deduction, floored + full, atol=TOLERANCE) - filer = role != "dependent" expected_indiv = filer * ( np.maximum(other - FORM_FLOOR * run["mt_agi_indiv"], 0) + line_2 + line_3 ) @@ -345,7 +399,7 @@ def _check(units, year): assert (deduction >= full - TOLERANCE).all() assert (deduction <= full + _unit_sum(run, other) + TOLERANCE).all() - # 4. Slope of one for people with no self-employed deduction. + # 4. Slope of one where no deduction can absorb the increase. np.testing.assert_allclose( run["mt_agi_joint"][raised], run["mt_agi_joint"][base], atol=TOLERANCE ) @@ -356,6 +410,7 @@ def _check(units, year): ) # 5. Pre-tax premiums never reach lines 2 or 3. + np.testing.assert_allclose(full[no_pre_tax], full[base], atol=TOLERANCE) np.testing.assert_allclose( _unit_sum(run, line_2 + line_3)[no_pre_tax], _unit_sum(run, line_2 + line_3)[base], @@ -371,8 +426,8 @@ def _check(units, year): atol=TOLERANCE, ) - # 7. Single filers: joint and separate variants agree. - single = run["filing_status"] == "SINGLE" + # 7. Single filers with person-level deductions: both variants agree. + single = (run["filing_status"] == "SINGLE") & ~run["unit_supplied"] np.testing.assert_allclose( deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE ) @@ -403,9 +458,10 @@ def test_seeded_population(year): run, floored, full = result # Non-vacuity: some returns deduct premiums while line 1 is below the # floor, where flooring premiums with other expenses would cut the - # deduction; some deduct line 1 too; some people have a self-employed - # deduction larger than their medical premiums that reduces line 3; and - # there are single and joint returns. + # deduction; some deduct line 1 too; some people's deduction exceeds + # their medical premiums and reduces line 3; some returns carry only a + # return-level deduction that reduces lines 2 and 3; and there are single + # and joint returns. agi_floor = FORM_FLOOR * run["mt_agi_joint"] total = _unit_sum( run, @@ -421,5 +477,23 @@ def test_seeded_population(year): (deduction > run["medical_expense_health_insurance_premiums"]) & (line_3 < long_term_care - 1) ) + filer = run["role"] != "dependent" + gross = _unit_sum( + run, + filer + * ( + run["medical_expense_health_insurance_premiums"] + + run["long_term_health_insurance_premiums"] + ), + ) + dependent_part = _unit_sum( + run, + ~filer + * ( + run["mt_eligible_medical_insurance_premiums"] + + run["mt_eligible_long_term_care_insurance_premiums"] + ), + ) + assert np.any(run["unit_supplied"] & (full - dependent_part < gross - 1)) assert np.any(run["filing_status"] == "SINGLE") assert np.any(run["filing_status"] == "JOINT") diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py index 32c294f6b62..b7503768242 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py @@ -13,7 +13,10 @@ class mt_eligible_long_term_care_insurance_premiums(Variable): "person deducted through the self-employed health insurance " "deduction. That deduction is applied first to the medical insurance " "premiums on line 2 and the remainder to these premiums; lines 2 and " - "3 are both deducted in full, so the order does not change the total." + "3 are both deducted in full, so the order does not change the total. " + "This per-person amount serves separate returns and dependents; a " + "joint return nets the return's deduction in " + "mt_premium_deduction_joint." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py index 23e8eb423d2..c1c98c51259 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py @@ -16,7 +16,10 @@ class mt_eligible_medical_insurance_premiums(Variable): "return, so those premiums were not paid by the filer. Pre-tax " "premiums are a separate input that never enters " "medical_expense_health_insurance_premiums. Premium inputs use payer " - "attribution: each person reports the premiums that person paid." + "attribution: each person reports the premiums that person paid. " + "This per-person amount serves separate returns and dependents; a " + "joint return nets the return's deduction in " + "mt_premium_deduction_joint." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 68a8759b7ae..7bbd60495bc 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -33,14 +33,7 @@ def formula(person, period, parameters): floored_expense = max_(0, expense - floor * montana_agi) # Lines 2 and 3: medical and long-term care insurance premiums not # deducted elsewhere, in full. - premiums = add( - tax_unit, - period, - [ - "mt_eligible_medical_insurance_premiums", - "mt_eligible_long_term_care_insurance_premiums", - ], - ) + premiums = tax_unit("mt_premium_deduction_joint", period) deduction = floored_expense + premiums else: # From 2024, Form 2 line 2 takes federal itemized deductions diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py new file mode 100644 index 00000000000..a79a6fb62f3 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py @@ -0,0 +1,56 @@ +from policyengine_us.model_api import * + + +class mt_premium_deduction_joint(Variable): + value_type = float + entity = TaxUnit + definition_period = YEAR + label = "Montana insurance premiums deducted in full on a joint return" + documentation = ( + "Itemized Deductions Schedule lines 2 and 3 for a single-column " + "return (2021-2023): medical and long-term care insurance premiums " + "deducted in full. The return's self-employed health insurance " + "deduction (Schedule 1, line 17) was taken in Montana AGI for " + "premiums the head and spouse paid, so it is netted from their " + "premiums once: first from medical insurance premiums, as on federal " + "Schedule A, then from long-term care premiums. A supplied tax-unit " + "deduction is honored. A dependent's deduction belongs on the " + "dependent's own return and is netted from that dependent's premiums. " + "Premium inputs use payer attribution." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + # MCA 2021 § 15-30-2131(1)(a)(iii)-(iv) and (1)(g) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + # 26 U.S.C. 162(l)(2)(C) and (l)(3) + "https://www.law.cornell.edu/uscode/text/26/162#l_2_C", + ) + unit = USD + defined_for = StateCode.MT + + def formula(tax_unit, period, parameters): + premiums = tax_unit_non_dep_sum( + "medical_expense_health_insurance_premiums", tax_unit, period + ) + long_term_care_premiums = tax_unit_non_dep_sum( + "long_term_health_insurance_premiums", tax_unit, period + ) + deduction = max_(0, tax_unit("self_employed_health_insurance_ald", period)) + # The same exclusion federal Schedule A applies to these premiums. + used_on_line_2 = tax_unit("itemized_medical_expenses_excluded_premiums", period) + filer_amount = ( + premiums + - used_on_line_2 + + max_(0, long_term_care_premiums - (deduction - used_on_line_2)) + ) + person = tax_unit.members + is_dependent = person("is_tax_unit_dependent", period) + dependent_amount = tax_unit.sum( + is_dependent + * ( + person("mt_eligible_medical_insurance_premiums", period) + + person("mt_eligible_long_term_care_insurance_premiums", period) + ) + ) + return filer_amount + dependent_amount From b320855f279bbc213c62058f04e41ec7a0073493 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:45:49 -0400 Subject: [PATCH 36/45] Key the invariants test's state code by year A bare state_code in a situation is not applied to the run year, so every generated return fell back to the default state and the Montana outputs were zero. Key it by year and assert every return is in Montana. Co-Authored-By: Claude Opus 5.5 --- .../unit/test_mt_medical_premium_deduction_invariants.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 43a06c5c1c4..5f6dc9b6e05 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -274,7 +274,8 @@ def _situation(units, year): unit_supplied.append(unit["supplied_unit_deduction"] is not None) groups["households"][f"{tag}_household"] = { "members": members, - "state_code": "MT", + # Keyed by year: a bare value is not applied to the run year. + "state_code": {year: "MT"}, } people = { name: {key: {year: value} for key, value in values.items()} @@ -322,6 +323,7 @@ def _run(units, year): } out["unit"] = sim.populations["tax_unit"].members_entity_id out["filing_status"] = sim.calculate("filing_status", year).decode_to_str() + out["state_code"] = sim.calculate("state_code", year).decode_to_str() out["expected_premium"] = expected_premium out["increase"] = increase out["role"] = roles @@ -342,6 +344,7 @@ def _check(units, year): joint = run["mt_medical_expense_deduction_joint"] indiv = run["mt_medical_expense_deduction_indiv"] deduction = _unit_sum(run, joint) + assert (run["state_code"] == "MT").all() assert (joint[role != "head"] == 0).all() if year == FEDERAL_YEAR: From 306bffed813bd18ae069a51baa725710874cdfd0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:53:29 -0400 Subject: [PATCH 37/45] Choose the self-employed deduction supply mode per invariant batch Core treats a formula variable supplied for some members as an input for all of them and fills the rest with 0, so mixing supplied and derived deductions in one batch zeroed the return deduction of returns that did not supply one. Each batch now derives the deduction, supplies it for every person, or supplies it for every return; slope twins, the single-filer differential and the seeded non-vacuity checks follow the mode. Co-Authored-By: Claude Opus 5.5 --- ...mt_medical_premium_deduction_invariants.py | 204 +++++++++--------- 1 file changed, 105 insertions(+), 99 deletions(-) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 5f6dc9b6e05..ebb2e037f3d 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -17,11 +17,15 @@ Why Python rather than YAML: Hypothesis generates mixed populations (single, head of household and joint returns with dependents; premiums reported -directly, decomposed or with Medicare Part B; derived deductions, deductions -supplied per person, and deductions supplied only for the return), and every -batch also holds twins of each return whose differences are asserted. YAML -cases check fixed examples; these properties check relations between -generated returns. +directly, decomposed or with Medicare Part B), and every batch also holds +twins of each return whose differences are asserted. YAML cases check fixed +examples; these properties check relations between generated returns. + +Each batch takes the self-employed deduction one way: derived from +self-employment income ("derived"), supplied for every person ("person"), or +supplied for every return with person amounts derived ("unit"). The mode is +per batch because core treats a formula variable supplied for some members as +an input for all of them, filling the rest with 0. Each batch runs as one vectorized simulation holding three copies of every tax unit: the drawn return, a twin with higher after-tax and long-term care @@ -71,10 +75,12 @@ STATE_YEARS = [2021, 2022, 2023] FEDERAL_YEAR = 2024 YEARS = STATE_YEARS + [FEDERAL_YEAR] +MODES = ["derived", "person", "unit"] VARIANTS = ["base", "raised", "no_pre_tax"] amount = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) small_amount = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) +deduction_amount = st.one_of(st.just(0.0), st.integers(1, 20_000).map(float)) @st.composite @@ -86,10 +92,8 @@ def people(draw): "self_employment_income": ( float(draw(st.integers(-20_000, 60_000))) if self_employed else 0.0 ), - # A deduction claimed on Schedule 1, line 17, supplied as an input. - "supplied_deduction": draw( - st.one_of(st.none(), st.integers(0, 20_000).map(float)) - ), + # A deduction claimed on Schedule 1, line 17, used in "person" mode. + "supplied_deduction": draw(deduction_amount), "premium_source": draw(st.sampled_from(["direct", "decomposed", "medicare"])), "premium": draw(amount), "part_b": draw(st.integers(0, 3_000).map(float)), @@ -111,10 +115,8 @@ def tax_units(draw): "head": draw(people()), "spouse": draw(people()) if kind.startswith("joint") else None, "dependents": [draw(people()) for _ in range(n_dependents)], - # The return's deduction, supplied without person amounts. - "supplied_unit_deduction": draw( - st.one_of(st.none(), st.integers(0, 20_000).map(float)) - ), + # The return's deduction, used in "unit" mode. + "supplied_unit_deduction": draw(deduction_amount), } @@ -135,9 +137,7 @@ def person(): "self_employment_income": ( float(rng.integers(-20_000, 60_001)) if self_employed else 0.0 ), - "supplied_deduction": ( - float(rng.integers(0, 20_001)) if rng.random() < 0.2 else None - ), + "supplied_deduction": maybe(20_000), "premium_source": str(rng.choice(["direct", "decomposed", "medicare"])), "premium": maybe(15_000), "part_b": float(rng.integers(0, 3_001)), @@ -159,18 +159,12 @@ def person(): "head": person(), "spouse": person() if kind.startswith("joint") else None, "dependents": [person() for _ in range(n_dependents)], - "supplied_unit_deduction": ( - float(rng.integers(0, 20_001)) if rng.random() < 0.15 else None - ), + "supplied_unit_deduction": maybe(20_000), } ) return units -def _may_have_deduction(values): - return values["self_employed"] or values["supplied_deduction"] is not None - - def _entries(unit): entries = [("head", unit["head"])] if unit["spouse"] is not None: @@ -178,28 +172,37 @@ def _entries(unit): return entries + [("dependent", d) for d in unit["dependents"]] -def _raisable(unit, role, values): +def _no_own_deduction(values, mode): + """Whether the person's own deduction is known to be zero.""" + if mode == "person": + return values["supplied_deduction"] == 0 + return not values["self_employed"] + + +def _raisable(unit, role, values, mode): """Whether no deduction can absorb a premium increase for this person. The return's deduction is pooled over the head's and spouse's premiums, so any filer's deduction can absorb another filer's increase. """ if role == "dependent": - return not _may_have_deduction(values) + return _no_own_deduction(values, mode) filers = [v for r, v in _entries(unit) if r != "dependent"] - return unit["supplied_unit_deduction"] is None and not any( - _may_have_deduction(v) for v in filers - ) + if not all(_no_own_deduction(v, mode) for v in filers): + return False + return mode != "unit" or unit["supplied_unit_deduction"] == 0 -def _increases(unit, role, values, variant): - if variant != "raised" or not _raisable(unit, role, values): +def _increases(unit, role, values, mode, variant): + if variant != "raised" or not _raisable(unit, role, values, mode): return 0.0, 0.0 return values["premium_increase"], values["long_term_care_increase"] -def _person_inputs(unit, role, values, variant): - premium_increase, long_term_care_increase = _increases(unit, role, values, variant) +def _person_inputs(unit, role, values, mode, variant): + premium_increase, long_term_care_increase = _increases( + unit, role, values, mode, variant + ) premium = values["premium"] + premium_increase source = values["premium_source"] inputs = { @@ -223,25 +226,25 @@ def _person_inputs(unit, role, values, variant): "medicare_enrolled": source == "medicare", "medicare_part_b_premium": values["part_b"] if source == "medicare" else 0.0, } - if values["supplied_deduction"] is not None: + if mode == "person": inputs["self_employed_health_insurance_ald_person"] = values[ "supplied_deduction" ] return inputs -def _expected_premium(unit, role, values, variant): +def _expected_premium(unit, role, values, mode, variant): """Premiums counted as federal medical expenses, from the inputs alone.""" - premium = values["premium"] + _increases(unit, role, values, variant)[0] + premium = values["premium"] + _increases(unit, role, values, mode, variant)[0] if values["premium_source"] == "direct" and premium != 0: return premium part_b = values["part_b"] if values["premium_source"] == "medicare" else 0.0 return (0.0 if values["premium_source"] == "direct" else premium) + part_b -def _situation(units, year): +def _situation(units, year, mode): people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} - expected_premium, increase, roles, supplied, unit_supplied = [], [], [], [], [] + expected_premium, increase, roles = [], [], [] for variant in VARIANTS: for i, unit in enumerate(units): tag = f"{variant}_{i}" @@ -249,29 +252,25 @@ def _situation(units, year): unit_increase = 0.0 for j, (role, values) in enumerate(_entries(unit)): name = f"{tag}_{role}_{j}" - people[name] = _person_inputs(unit, role, values, variant) + people[name] = _person_inputs(unit, role, values, mode, variant) members.append(name) - expected_premium.append(_expected_premium(unit, role, values, variant)) - roles.append(role) - supplied.append( - np.nan - if values["supplied_deduction"] is None - else values["supplied_deduction"] + expected_premium.append( + _expected_premium(unit, role, values, mode, variant) ) + roles.append(role) if role == "dependent": groups["marital_units"][f"{tag}_single_{j}"] = {"members": [name]} else: couple.append(name) - unit_increase += sum(_increases(unit, role, values, "raised")) + unit_increase += sum(_increases(unit, role, values, mode, "raised")) increase.append(unit_increase) groups["marital_units"][f"{tag}_couple"] = {"members": couple} tax_unit = {"members": members} - if unit["supplied_unit_deduction"] is not None: + if mode == "unit": tax_unit["self_employed_health_insurance_ald"] = { year: unit["supplied_unit_deduction"] } groups["tax_units"][f"{tag}_tax_unit"] = tax_unit - unit_supplied.append(unit["supplied_unit_deduction"] is not None) groups["households"][f"{tag}_household"] = { "members": members, # Keyed by year: a bare value is not applied to the run year. @@ -286,8 +285,6 @@ def _situation(units, year): np.array(expected_premium), np.array(increase), np.array(roles), - np.array(supplied), - np.array(unit_supplied), ) @@ -312,10 +309,8 @@ def _situation(units, year): ] -def _run(units, year): - situation, expected_premium, increase, roles, supplied, unit_supplied = _situation( - units, year - ) +def _run(units, year, mode): + situation, expected_premium, increase, roles = _situation(units, year, mode) sim = Simulation(situation=situation) out = { name: np.asarray(sim.calculate(name, year), dtype=float) @@ -327,8 +322,6 @@ def _run(units, year): out["expected_premium"] = expected_premium out["increase"] = increase out["role"] = roles - out["supplied"] = supplied - out["unit_supplied"] = unit_supplied return out @@ -336,8 +329,8 @@ def _unit_sum(run, values): return np.bincount(run["unit"], weights=values, minlength=len(run["mt_agi_joint"])) -def _check(units, year): - run = _run(units, year) +def _check(units, year, mode): + run = _run(units, year, mode) n = len(units) base, raised, no_pre_tax = (slice(k * n, (k + 1) * n) for k in range(3)) role = run["role"] @@ -366,10 +359,23 @@ def _check(units, year): # 2. Premium conservation against closed forms. np.testing.assert_allclose(premium, run["expected_premium"], atol=TOLERANCE) - supplied = ~np.isnan(run["supplied"]) - np.testing.assert_allclose( - own_deduction[supplied], run["supplied"][supplied], atol=TOLERANCE - ) + if mode == "person": + supplied = np.array( + [ + v["supplied_deduction"] + for _ in VARIANTS + for unit in units + for _, v in _entries(unit) + ] + ) + np.testing.assert_allclose(own_deduction, supplied, atol=TOLERANCE) + if mode == "unit": + supplied_unit = np.array( + [unit["supplied_unit_deduction"] for _ in VARIANTS for unit in units] + ) + np.testing.assert_allclose( + run["self_employed_health_insurance_ald"], supplied_unit, atol=TOLERANCE + ) np.testing.assert_allclose( line_2 + line_3, np.maximum(run["expected_premium"] + long_term_care - own_deduction, 0), @@ -430,10 +436,11 @@ def _check(units, year): ) # 7. Single filers with person-level deductions: both variants agree. - single = (run["filing_status"] == "SINGLE") & ~run["unit_supplied"] - np.testing.assert_allclose( - deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE - ) + if mode != "unit": + single = run["filing_status"] == "SINGLE" + np.testing.assert_allclose( + deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE + ) return run, floored, full @@ -448,23 +455,30 @@ def _check(units, year): @settings(**SETTINGS) -@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=20)) -def test_mt_medical_premium_deduction_invariants(year, units): - _check(units, year) +@given( + st.sampled_from(YEARS), + st.sampled_from(MODES), + st.lists(tax_units(), min_size=5, max_size=20), +) +def test_mt_medical_premium_deduction_invariants(year, mode, units): + _check(units, year, mode) -@pytest.mark.parametrize("year", YEARS) -def test_seeded_population(year): - result = _check(_seeded_units(), year) +# Each state year takes the deduction a different way; 2024 checks the federal +# branch. +SEEDED_CASES = [(2021, "derived"), (2022, "person"), (2023, "unit"), (2024, "unit")] + + +@pytest.mark.parametrize("year,mode", SEEDED_CASES) +def test_seeded_population(year, mode): + result = _check(_seeded_units(), year, mode) if result is None: return run, floored, full = result # Non-vacuity: some returns deduct premiums while line 1 is below the # floor, where flooring premiums with other expenses would cut the - # deduction; some deduct line 1 too; some people's deduction exceeds - # their medical premiums and reduces line 3; some returns carry only a - # return-level deduction that reduces lines 2 and 3; and there are single - # and joint returns. + # deduction; some deduct line 1 too; there are single and joint returns; + # and each mode's deduction reduces lines 2 and 3 somewhere. agi_floor = FORM_FLOOR * run["mt_agi_joint"] total = _unit_sum( run, @@ -473,30 +487,22 @@ def test_seeded_population(year): ) assert np.any((full > 0) & (total < agi_floor)) assert np.any((full > 0) & (floored > 0)) + assert np.any(run["filing_status"] == "SINGLE") + assert np.any(run["filing_status"] == "JOINT") deduction = run["self_employed_health_insurance_ald_person"] + premiums = run["medical_expense_health_insurance_premiums"] long_term_care = run["long_term_health_insurance_premiums"] + line_2 = run["mt_eligible_medical_insurance_premiums"] line_3 = run["mt_eligible_long_term_care_insurance_premiums"] - assert np.any( - (deduction > run["medical_expense_health_insurance_premiums"]) - & (line_3 < long_term_care - 1) - ) - filer = run["role"] != "dependent" - gross = _unit_sum( - run, - filer - * ( - run["medical_expense_health_insurance_premiums"] - + run["long_term_health_insurance_premiums"] - ), - ) - dependent_part = _unit_sum( - run, - ~filer - * ( - run["mt_eligible_medical_insurance_premiums"] - + run["mt_eligible_long_term_care_insurance_premiums"] - ), - ) - assert np.any(run["unit_supplied"] & (full - dependent_part < gross - 1)) - assert np.any(run["filing_status"] == "SINGLE") - assert np.any(run["filing_status"] == "JOINT") + if mode == "derived": + # A deduction derived from self-employment income reduces line 2. + assert np.any((deduction > 0) & (line_2 < premiums - 1)) + if mode == "person": + # A deduction above the medical premiums reduces line 3. + assert np.any((deduction > premiums) & (line_3 < long_term_care - 1)) + if mode == "unit": + # A return-level deduction reduces the filers' lines 2 and 3. + filer = run["role"] != "dependent" + gross = _unit_sum(run, filer * (premiums + long_term_care)) + dependent_part = _unit_sum(run, ~filer * (line_2 + line_3)) + assert np.any(full - dependent_part < gross - 1) From f5e977b943239b231ef48e5efaf9add5182c4b16 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 12:55:59 -0400 Subject: [PATCH 38/45] Keep a supplied federal medical subtotal on Montana line 1 The r3 review found the joint 2021-2023 line 1 read only primitive other medical expenses, so a supplied itemized_medical_expenses subtotal was lost ($10,000 at $40,000 AGI gave $0, not $7,000). Line 1 now starts from the federal subtotal, restores the exclusion its formula applied (zero for a supplied subtotal, as North Dakota and New Mexico use it after #10055), and takes out every member's premiums; for a computed subtotal that is the members' other medical expenses. Also narrow two claims in mt_premium_deduction_joint's documentation. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 48 +++++++++++++++++++ .../mt_medical_expense_deduction_joint.py | 23 ++++++--- .../medical/mt_premium_deduction_joint.py | 20 ++++---- 3 files changed, 76 insertions(+), 15 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 05322431e40..88e2f20d184 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -397,3 +397,51 @@ # Line 1: 0. Line 2: 6,000 - 2,000 = 4,000. Ignoring the supplied # deduction would give 6,000. mt_medical_expense_deduction_joint: [4_000, 0] + +- name: A supplied federal medical expense subtotal reaches line 1 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + person2: + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + itemized_medical_expenses: 10_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: 10,000 - 7.5% x 40,000 = 7,000. + mt_medical_expense_deduction_joint: [7_000, 0] + +- name: A supplied subtotal with premiums moves the premiums to line 2 + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + tax_units: + tax_unit: + members: [person1] + itemized_medical_expenses: 10_000 + mt_agi_joint: 30_000 + households: + household: + members: [person1] + state_code: MT + output: + # The supplied subtotal is the medical expenses paid, as North Dakota and + # New Mexico use it; no federal exclusion was applied to it. + itemized_medical_expenses_applied_exclusion: 0 + # Line 1: (10,000 - 6,000) - 7.5% x 30,000 = 1,750. + # Line 2: 6,000 - 1,000 = 5,000. + mt_medical_expense_deduction_joint: 6_750 diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 7bbd60495bc..956551e77d9 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -22,10 +22,22 @@ def formula(person, period, parameters): if p.state_specific_deduction_applies: tax_unit = person.tax_unit floor = parameters(period).gov.irs.deductions.itemized.medical.floor - # Itemized Deductions Schedule line 1 takes medical and dental - # expenses other than the insurance premiums deducted in full on - # lines 2 and 3. - expense = add(tax_unit, period, ["other_medical_expenses"]) + # Itemized Deductions Schedule line 1 takes the medical and dental + # expenses allowed under the Internal Revenue Code other than the + # insurance premiums deducted in full on lines 2 and 3. Start + # from the federal subtotal, restore the self-employed premiums + # its formula excluded (zero for a supplied subtotal), and take + # out every member's premiums. For a computed subtotal this is + # the members' other medical expenses. + premiums = add( + tax_unit, period, ["medical_expense_health_insurance_premiums"] + ) + expense = max_( + 0, + tax_unit("itemized_medical_expenses", period) + + tax_unit("itemized_medical_expenses_applied_exclusion", period) + - premiums, + ) # Line 1b takes Montana AGI from page 1, line 14. A joint return # reports that line in a single column, so the floor applies to # the return's pooled Montana AGI. @@ -33,8 +45,7 @@ def formula(person, period, parameters): floored_expense = max_(0, expense - floor * montana_agi) # Lines 2 and 3: medical and long-term care insurance premiums not # deducted elsewhere, in full. - premiums = tax_unit("mt_premium_deduction_joint", period) - deduction = floored_expense + premiums + deduction = floored_expense + tax_unit("mt_premium_deduction_joint", period) else: # From 2024, Form 2 line 2 takes federal itemized deductions # (Worksheet A), so the federal floor on federal AGI applies. diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py index a79a6fb62f3..a4038d9edf7 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py @@ -9,14 +9,15 @@ class mt_premium_deduction_joint(Variable): documentation = ( "Itemized Deductions Schedule lines 2 and 3 for a single-column " "return (2021-2023): medical and long-term care insurance premiums " - "deducted in full. The return's self-employed health insurance " - "deduction (Schedule 1, line 17) was taken in Montana AGI for " - "premiums the head and spouse paid, so it is netted from their " - "premiums once: first from medical insurance premiums, as on federal " - "Schedule A, then from long-term care premiums. A supplied tax-unit " - "deduction is honored. A dependent's deduction belongs on the " - "dependent's own return and is netted from that dependent's premiums. " - "Premium inputs use payer attribution." + "deducted in full, except premiums already deducted. The " + "self-employed health insurance deduction reported for the return " + "(Schedule 1, line 17, for premiums the head and spouse paid) is " + "netted from their premiums once: first from medical insurance " + "premiums, by the same bounded exclusion the computed federal " + "Schedule A subtotal uses, then from long-term care premiums. A " + "deduction supplied for the return is honored. A dependent's " + "deduction belongs on the dependent's own return and is netted from " + "that dependent's premiums. Premium inputs use payer attribution." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", @@ -37,7 +38,8 @@ def formula(tax_unit, period, parameters): "long_term_health_insurance_premiums", tax_unit, period ) deduction = max_(0, tax_unit("self_employed_health_insurance_ald", period)) - # The same exclusion federal Schedule A applies to these premiums. + # The bounded exclusion the computed federal subtotal applies to these + # premiums: min(premiums, deduction). used_on_line_2 = tax_unit("itemized_medical_expenses_excluded_premiums", period) filer_amount = ( premiums From 7fdca0cc7e15a6b47d5cc2d0a18c4df02f054bc0 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 13:09:44 -0400 Subject: [PATCH 39/45] Remove eligible long-term care premiums from a supplied subtotal on line 1 The r4 review found a supplied itemized_medical_expenses subtotal that legally includes eligible long-term care premiums (26 U.S.C. 213(d)(1)(D), (d)(10)) had them deducted again on Montana line 3. - New parameter gov.irs.deductions.itemized.medical.long_term_care_premium_limit: the 213(d)(10) age-based limits for 2021-2026 from Rev. Procs. 2020-45, 2021-45, 2022-38, 2023-34, 2024-40 and 2025-32 (band tops, right=True). - New eligible_long_term_care_premiums (person) = min(premiums, limit). - New itemized_medical_expenses_long_term_care_premiums (tax unit): the eligible premiums inside the subtotal, zero for the computed subtotal (which counts none yet) and the members' eligible premiums for a supplied one. - Montana's line 1 base subtracts it, so line 3 deducts those premiums once, in full. Co-Authored-By: Claude Opus 5.5 --- .../medical/long_term_care_premium_limit.yaml | 66 +++++++++++++++++++ .../eligible_long_term_care_premiums.yaml | 66 +++++++++++++++++++ ...ical_expenses_long_term_care_premiums.yaml | 41 ++++++++++++ .../mt_medical_expense_deduction_joint.yaml | 49 ++++++++++++++ ...mt_medical_premium_deduction_invariants.py | 7 +- .../eligible_long_term_care_premiums.py | 26 ++++++++ ...edical_expenses_long_term_care_premiums.py | 28 ++++++++ .../mt_medical_expense_deduction_joint.py | 8 ++- 8 files changed, 287 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py create mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml new file mode 100644 index 00000000000..3b8603767db --- /dev/null +++ b/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml @@ -0,0 +1,66 @@ +description: The IRS limits the long-term care insurance premiums counted as medical care to this amount per insured person, based on the person's age at the close of the taxable year. +metadata: + type: single_amount + period: year + threshold_unit: year + amount_unit: currency-USD + label: Eligible long-term care premium limit + reference: + - title: 26 U.S. Code § 213(d)(10) + href: https://www.law.cornell.edu/uscode/text/26/213#d_10 + - title: Rev. Proc. 2020-45, eligible long-term care premiums (2021) + href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf#page=17 + - title: "Rev. Proc. 2021-45, eligible long-term care premiums (2022) #page 17-18" + href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf#page=17 + - title: Rev. Proc. 2022-38, eligible long-term care premiums (2023) + href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=17 + - title: Rev. Proc. 2023-34, eligible long-term care premiums (2024) + href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf#page=16 + - title: "Rev. Proc. 2024-40, eligible long-term care premiums (2025) #page 15-16" + href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=15 + - title: Rev. Proc. 2025-32, eligible long-term care premiums (2026) + href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=22 +# The tables read "40 or less", "more than 40 but not more than 50", and so on, +# so thresholds are band tops: look the scale up with right=True. +brackets: + - threshold: + 2021-01-01: -.inf + amount: + 2021-01-01: 450 + 2023-01-01: 480 + 2024-01-01: 470 + 2025-01-01: 480 + 2026-01-01: 500 + - threshold: + 2021-01-01: 40 + amount: + 2021-01-01: 850 + 2023-01-01: 890 + 2024-01-01: 880 + 2025-01-01: 900 + 2026-01-01: 930 + - threshold: + 2021-01-01: 50 + amount: + 2021-01-01: 1_690 + 2023-01-01: 1_790 + 2024-01-01: 1_760 + 2025-01-01: 1_800 + 2026-01-01: 1_860 + - threshold: + 2021-01-01: 60 + amount: + 2021-01-01: 4_520 + 2022-01-01: 4_510 + 2023-01-01: 4_770 + 2024-01-01: 4_710 + 2025-01-01: 4_810 + 2026-01-01: 4_960 + - threshold: + 2021-01-01: 70 + amount: + 2021-01-01: 5_640 + 2023-01-01: 5_960 + 2024-01-01: 5_880 + 2025-01-01: 6_020 + 2026-01-01: 6_200 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml new file mode 100644 index 00000000000..8acf47db529 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml @@ -0,0 +1,66 @@ +- name: Premiums below the limit count in full + period: 2022 + absolute_error_margin: 0.01 + input: + age: 30 + long_term_health_insurance_premiums: 300 + output: + eligible_long_term_care_premiums: 300 + +- name: Age 40 is in the 40 or less band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 40 + long_term_health_insurance_premiums: 1_000 + output: + # Rev. Proc. 2021-45: 40 or less, $450. + eligible_long_term_care_premiums: 450 + +- name: Age 41 is in the more than 40 band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 41 + long_term_health_insurance_premiums: 1_000 + output: + # More than 40 but not more than 50: $850. + eligible_long_term_care_premiums: 850 + +- name: Age 70 is in the more than 60 band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 70 + long_term_health_insurance_premiums: 10_000 + output: + # More than 60 but not more than 70: $4,510 for 2022. + eligible_long_term_care_premiums: 4_510 + +- name: Age 71 is in the more than 70 band + period: 2022 + absolute_error_margin: 0.01 + input: + age: 71 + long_term_health_insurance_premiums: 10_000 + output: + eligible_long_term_care_premiums: 5_640 + +- name: 2026 limit for more than 70 + period: 2026 + absolute_error_margin: 0.01 + input: + age: 75 + long_term_health_insurance_premiums: 7_000 + output: + # Rev. Proc. 2025-32: more than 70, $6,200. + eligible_long_term_care_premiums: 6_200 + +- name: No premiums + period: 2023 + absolute_error_margin: 0.01 + input: + age: 55 + long_term_health_insurance_premiums: 0 + output: + eligible_long_term_care_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml new file mode 100644 index 00000000000..e9d191a7c65 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml @@ -0,0 +1,41 @@ +- name: A computed subtotal contains no long-term care premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + long_term_health_insurance_premiums: 1_000 + other_medical_expenses: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + output: + itemized_medical_expenses: 5_000 + itemized_medical_expenses_long_term_care_premiums: 0 + +- name: A supplied subtotal contains the members' eligible long-term care premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + long_term_health_insurance_premiums: 1_000 + person2: + age: 10 + is_tax_unit_dependent: true + long_term_health_insurance_premiums: 200 + tax_units: + tax_unit: + members: [person1, person2] + itemized_medical_expenses: 10_650 + households: + household: + members: [person1, person2] + output: + # Head: min(1,000, 450). Dependent: min(200, 450). + itemized_medical_expenses_long_term_care_premiums: 650 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 88e2f20d184..94e7210e2ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -445,3 +445,52 @@ # Line 1: (10,000 - 6,000) - 7.5% x 30,000 = 1,750. # Line 2: 6,000 - 1,000 = 5,000. mt_medical_expense_deduction_joint: 6_750 + +- name: Long-term care premiums inside a supplied subtotal are not deducted twice + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + other_medical_expenses: 10_000 + long_term_health_insurance_premiums: 400 + tax_units: + tax_unit: + members: [person1] + # Schedule A line 1: 10,000 + the 400 of eligible premiums. + itemized_medical_expenses: 10_400 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: (10,400 - 400) - 7.5% x 40,000 = 7,000. Line 3: 400. + mt_medical_expense_deduction_joint: 7_400 + +- name: Only the eligible part of long-term care premiums sits inside a supplied subtotal + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 65 + medicare_enrolled: false + long_term_health_insurance_premiums: 6_000 + tax_units: + tax_unit: + members: [person1] + # Schedule A line 1: 10,000 + 4,510 (the 2022 limit for ages 61-70). + itemized_medical_expenses: 14_510 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: (14,510 - 4,510) - 3,000 = 7,000. Line 3 deducts all 6,000, + # without the federal limit. + mt_medical_expense_deduction_joint: 13_000 diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index ebb2e037f3d..fc33796ec8d 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -305,6 +305,7 @@ def _situation(units, year, mode): "mt_premium_deduction_joint", "itemized_medical_expenses", "itemized_medical_expenses_applied_exclusion", + "itemized_medical_expenses_long_term_care_premiums", "medical_expense_deduction", ] @@ -427,11 +428,15 @@ def _check(units, year, mode): ) # 6. Line 1 base tracks the federal definition apart from the premiums. + # The generator never supplies the subtotal, so it holds no long-term care + # premiums. + assert (run["itemized_medical_expenses_long_term_care_premiums"] == 0).all() np.testing.assert_allclose( _unit_sum(run, other), run["itemized_medical_expenses"] + run["itemized_medical_expenses_applied_exclusion"] - - _unit_sum(run, premium), + - _unit_sum(run, premium) + - run["itemized_medical_expenses_long_term_care_premiums"], atol=TOLERANCE, ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py new file mode 100644 index 00000000000..7fa641c562e --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class eligible_long_term_care_premiums(Variable): + value_type = float + entity = Person + label = "Eligible long-term care premiums" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/213#d_10", + "https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=17", + ) + documentation = ( + "Long-term care insurance premiums counted as medical care under " + "26 U.S.C. 213(d)(1)(D): the premiums up to the age-based limit of " + "213(d)(10), using the person's age as the insured's age. The " + "computed itemized_medical_expenses does not yet include them." + ) + + def formula(person, period, parameters): + premiums = person("long_term_health_insurance_premiums", period) + p = parameters(period).gov.irs.deductions.itemized.medical + # "40 or less", "more than 40 but not more than 50": band tops. + limit = p.long_term_care_premium_limit.calc(person("age", period), right=True) + return min_(premiums, limit) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py new file mode 100644 index 00000000000..e9f0fcb4313 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py @@ -0,0 +1,28 @@ +from policyengine_us.model_api import * + + +class itemized_medical_expenses_long_term_care_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Long-term care premiums included in itemized medical expenses" + unit = USD + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/213#d_10", + "https://www.irs.gov/instructions/i1040sca", + ) + documentation = ( + "The eligible long-term care premiums inside itemized_medical_expenses. " + "The computed subtotal counts health insurance premiums and other " + "medical expenses only, so this is zero for it. A supplied subtotal " + "is the Schedule A line 1 amount, which includes the members' " + "eligible long-term care premiums. States that deduct long-term care " + "premiums separately remove this amount from the subtotal first." + ) + + def formula(tax_unit, period, parameters): + supplied = has_input_for_period( + tax_unit.simulation, "itemized_medical_expenses", period + ) + eligible = add(tax_unit, period, ["eligible_long_term_care_premiums"]) + return eligible * supplied diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 956551e77d9..5e7203a43d8 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -27,8 +27,9 @@ def formula(person, period, parameters): # insurance premiums deducted in full on lines 2 and 3. Start # from the federal subtotal, restore the self-employed premiums # its formula excluded (zero for a supplied subtotal), and take - # out every member's premiums. For a computed subtotal this is - # the members' other medical expenses. + # out every member's medical insurance premiums and the + # long-term care premiums the subtotal includes. For a computed + # subtotal this is the members' other medical expenses. premiums = add( tax_unit, period, ["medical_expense_health_insurance_premiums"] ) @@ -36,7 +37,8 @@ def formula(person, period, parameters): 0, tax_unit("itemized_medical_expenses", period) + tax_unit("itemized_medical_expenses_applied_exclusion", period) - - premiums, + - premiums + - tax_unit("itemized_medical_expenses_long_term_care_premiums", period), ) # Line 1b takes Montana AGI from page 1, line 14. A joint return # reports that line in a single column, so the floor applies to From f9204be8b4d209a4017c402d67625c1cb4996022 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 13:18:41 -0400 Subject: [PATCH 40/45] Take long-term care premiums inside a supplied subtotal as an input The r5 review found that inferring the long-term care premiums inside a supplied itemized_medical_expenses from the premium input fails when the self-employed deduction took them (162(l)(2)(C), (l)(3)) and uses the payer's age rather than the insured's. Replace the inference with a pure input, itemized_medical_expenses_long_term_care_premiums (default 0: a supplied subtotal has the computed subtotal's contents), and document the supplied-subtotal contract on itemized_medical_expenses. Remove the 213(d)(10) parameter and eligible_long_term_care_premiums, which nothing uses now; the federal long-term care follow-up adds them with insured-age attribution. Co-Authored-By: Claude Opus 5.5 --- .../medical/long_term_care_premium_limit.yaml | 66 -------------- .../eligible_long_term_care_premiums.yaml | 66 -------------- ...ical_expenses_long_term_care_premiums.yaml | 41 --------- .../mt_medical_expense_deduction_joint.yaml | 88 ++++++++++++++++++- .../eligible_long_term_care_premiums.py | 26 ------ .../itemizing/itemized_medical_expenses.py | 7 +- ...edical_expenses_long_term_care_premiums.py | 20 ++--- 7 files changed, 97 insertions(+), 217 deletions(-) delete mode 100644 policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml delete mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml delete mode 100644 policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml delete mode 100644 policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py diff --git a/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml b/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml deleted file mode 100644 index 3b8603767db..00000000000 --- a/policyengine_us/parameters/gov/irs/deductions/itemized/medical/long_term_care_premium_limit.yaml +++ /dev/null @@ -1,66 +0,0 @@ -description: The IRS limits the long-term care insurance premiums counted as medical care to this amount per insured person, based on the person's age at the close of the taxable year. -metadata: - type: single_amount - period: year - threshold_unit: year - amount_unit: currency-USD - label: Eligible long-term care premium limit - reference: - - title: 26 U.S. Code § 213(d)(10) - href: https://www.law.cornell.edu/uscode/text/26/213#d_10 - - title: Rev. Proc. 2020-45, eligible long-term care premiums (2021) - href: https://www.irs.gov/pub/irs-drop/rp-20-45.pdf#page=17 - - title: "Rev. Proc. 2021-45, eligible long-term care premiums (2022) #page 17-18" - href: https://www.irs.gov/pub/irs-drop/rp-21-45.pdf#page=17 - - title: Rev. Proc. 2022-38, eligible long-term care premiums (2023) - href: https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=17 - - title: Rev. Proc. 2023-34, eligible long-term care premiums (2024) - href: https://www.irs.gov/pub/irs-drop/rp-23-34.pdf#page=16 - - title: "Rev. Proc. 2024-40, eligible long-term care premiums (2025) #page 15-16" - href: https://www.irs.gov/pub/irs-drop/rp-24-40.pdf#page=15 - - title: Rev. Proc. 2025-32, eligible long-term care premiums (2026) - href: https://www.irs.gov/pub/irs-drop/rp-25-32.pdf#page=22 -# The tables read "40 or less", "more than 40 but not more than 50", and so on, -# so thresholds are band tops: look the scale up with right=True. -brackets: - - threshold: - 2021-01-01: -.inf - amount: - 2021-01-01: 450 - 2023-01-01: 480 - 2024-01-01: 470 - 2025-01-01: 480 - 2026-01-01: 500 - - threshold: - 2021-01-01: 40 - amount: - 2021-01-01: 850 - 2023-01-01: 890 - 2024-01-01: 880 - 2025-01-01: 900 - 2026-01-01: 930 - - threshold: - 2021-01-01: 50 - amount: - 2021-01-01: 1_690 - 2023-01-01: 1_790 - 2024-01-01: 1_760 - 2025-01-01: 1_800 - 2026-01-01: 1_860 - - threshold: - 2021-01-01: 60 - amount: - 2021-01-01: 4_520 - 2022-01-01: 4_510 - 2023-01-01: 4_770 - 2024-01-01: 4_710 - 2025-01-01: 4_810 - 2026-01-01: 4_960 - - threshold: - 2021-01-01: 70 - amount: - 2021-01-01: 5_640 - 2023-01-01: 5_960 - 2024-01-01: 5_880 - 2025-01-01: 6_020 - 2026-01-01: 6_200 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml deleted file mode 100644 index 8acf47db529..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.yaml +++ /dev/null @@ -1,66 +0,0 @@ -- name: Premiums below the limit count in full - period: 2022 - absolute_error_margin: 0.01 - input: - age: 30 - long_term_health_insurance_premiums: 300 - output: - eligible_long_term_care_premiums: 300 - -- name: Age 40 is in the 40 or less band - period: 2022 - absolute_error_margin: 0.01 - input: - age: 40 - long_term_health_insurance_premiums: 1_000 - output: - # Rev. Proc. 2021-45: 40 or less, $450. - eligible_long_term_care_premiums: 450 - -- name: Age 41 is in the more than 40 band - period: 2022 - absolute_error_margin: 0.01 - input: - age: 41 - long_term_health_insurance_premiums: 1_000 - output: - # More than 40 but not more than 50: $850. - eligible_long_term_care_premiums: 850 - -- name: Age 70 is in the more than 60 band - period: 2022 - absolute_error_margin: 0.01 - input: - age: 70 - long_term_health_insurance_premiums: 10_000 - output: - # More than 60 but not more than 70: $4,510 for 2022. - eligible_long_term_care_premiums: 4_510 - -- name: Age 71 is in the more than 70 band - period: 2022 - absolute_error_margin: 0.01 - input: - age: 71 - long_term_health_insurance_premiums: 10_000 - output: - eligible_long_term_care_premiums: 5_640 - -- name: 2026 limit for more than 70 - period: 2026 - absolute_error_margin: 0.01 - input: - age: 75 - long_term_health_insurance_premiums: 7_000 - output: - # Rev. Proc. 2025-32: more than 70, $6,200. - eligible_long_term_care_premiums: 6_200 - -- name: No premiums - period: 2023 - absolute_error_margin: 0.01 - input: - age: 55 - long_term_health_insurance_premiums: 0 - output: - eligible_long_term_care_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml deleted file mode 100644 index e9d191a7c65..00000000000 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.yaml +++ /dev/null @@ -1,41 +0,0 @@ -- name: A computed subtotal contains no long-term care premiums - period: 2022 - absolute_error_margin: 0.01 - input: - people: - person1: - age: 40 - long_term_health_insurance_premiums: 1_000 - other_medical_expenses: 5_000 - tax_units: - tax_unit: - members: [person1] - households: - household: - members: [person1] - output: - itemized_medical_expenses: 5_000 - itemized_medical_expenses_long_term_care_premiums: 0 - -- name: A supplied subtotal contains the members' eligible long-term care premiums - period: 2022 - absolute_error_margin: 0.01 - input: - people: - person1: - age: 40 - long_term_health_insurance_premiums: 1_000 - person2: - age: 10 - is_tax_unit_dependent: true - long_term_health_insurance_premiums: 200 - tax_units: - tax_unit: - members: [person1, person2] - itemized_medical_expenses: 10_650 - households: - household: - members: [person1, person2] - output: - # Head: min(1,000, 450). Dependent: min(200, 450). - itemized_medical_expenses_long_term_care_premiums: 650 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 94e7210e2ef..3fc0865bea3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -454,13 +454,13 @@ person1: is_tax_unit_head: true age: 30 - other_medical_expenses: 10_000 long_term_health_insurance_premiums: 400 tax_units: tax_unit: members: [person1] - # Schedule A line 1: 10,000 + the 400 of eligible premiums. + # Schedule A line 1: 10,000 of other expenses + the 400 of premiums. itemized_medical_expenses: 10_400 + itemized_medical_expenses_long_term_care_premiums: 400 mt_agi_joint: 40_000 households: household: @@ -470,7 +470,7 @@ # Line 1: (10,400 - 400) - 7.5% x 40,000 = 7,000. Line 3: 400. mt_medical_expense_deduction_joint: 7_400 -- name: Only the eligible part of long-term care premiums sits inside a supplied subtotal +- name: Only the part of long-term care premiums inside a supplied subtotal leaves line 1 period: 2022 absolute_error_margin: 0.01 input: @@ -483,8 +483,10 @@ tax_units: tax_unit: members: [person1] - # Schedule A line 1: 10,000 + 4,510 (the 2022 limit for ages 61-70). + # Schedule A line 1 counts 4,510 of the premiums (the 2022 limit for + # ages 61-70, Rev. Proc. 2021-45). itemized_medical_expenses: 14_510 + itemized_medical_expenses_long_term_care_premiums: 4_510 mt_agi_joint: 40_000 households: household: @@ -494,3 +496,81 @@ # Line 1: (14,510 - 4,510) - 3,000 = 7,000. Line 3 deducts all 6,000, # without the federal limit. mt_medical_expense_deduction_joint: 13_000 + +- name: Long-term care premiums taken as the self-employed deduction are neither in the subtotal nor on line 3 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 400 + tax_units: + tax_unit: + members: [person1] + # Schedule A line 1 leaves out premiums deducted under 162(l). + itemized_medical_expenses: 10_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: 10,000 - 3,000 = 7,000. Line 3: 400 - 400 = 0. + mt_medical_expense_deduction_joint: 7_000 + +- name: A filer's premiums for an older parent's long-term care policy + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 40 + # Payer attribution: the filer paid the parent's 5,000 policy. + long_term_health_insurance_premiums: 5_000 + person2: + is_tax_unit_dependent: true + age: 75 + medicare_enrolled: false + tax_units: + tax_unit: + members: [person1, person2] + # Schedule A line 1: 10,000 + all 5,000, within the parent's age-75 + # limit of 5,640. + itemized_medical_expenses: 15_000 + itemized_medical_expenses_long_term_care_premiums: 5_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: (15,000 - 5,000) - 3,000 = 7,000. Line 3: 5,000. + mt_medical_expense_deduction_joint: [12_000, 0] + +- name: A supplied subtotal with no long-term care premiums keeps them on line 3 only + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + long_term_health_insurance_premiums: 400 + tax_units: + tax_unit: + members: [person1] + # Same contents as the computed subtotal: no long-term care premiums. + itemized_medical_expenses: 10_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + itemized_medical_expenses_long_term_care_premiums: 0 + # Line 1: 10,000 - 3,000 = 7,000. Line 3: 400. + mt_medical_expense_deduction_joint: 7_400 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py deleted file mode 100644 index 7fa641c562e..00000000000 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/eligible_long_term_care_premiums.py +++ /dev/null @@ -1,26 +0,0 @@ -from policyengine_us.model_api import * - - -class eligible_long_term_care_premiums(Variable): - value_type = float - entity = Person - label = "Eligible long-term care premiums" - unit = USD - definition_period = YEAR - reference = ( - "https://www.law.cornell.edu/uscode/text/26/213#d_10", - "https://www.irs.gov/pub/irs-drop/rp-22-38.pdf#page=17", - ) - documentation = ( - "Long-term care insurance premiums counted as medical care under " - "26 U.S.C. 213(d)(1)(D): the premiums up to the age-based limit of " - "213(d)(10), using the person's age as the insured's age. The " - "computed itemized_medical_expenses does not yet include them." - ) - - def formula(person, period, parameters): - premiums = person("long_term_health_insurance_premiums", period) - p = parameters(period).gov.irs.deductions.itemized.medical - # "40 or less", "more than 40 but not more than 50": band tops. - limit = p.long_term_care_premium_limit.calc(person("age", period), right=True) - return min_(premiums, limit) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index 91538c9b38a..214b2e815dc 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -28,7 +28,12 @@ class itemized_medical_expenses(Variable): "of whom it covers. A parent-paid dependent policy belongs on the parent. " "The exclusion is bounded to filer-paid premiums; beneficiary-attributed " "inputs with an ALD exceeding that pool are inconsistent with this contract " - "and cannot establish conservation of the supplied ALD." + "and cannot establish conservation of the supplied ALD. A supplied value " + "bypasses this formula and is used as given: no self-employed exclusion is " + "applied to it (itemized_medical_expenses_applied_exclusion is 0), and " + "states that start from medical costs paid take it as that amount. If a " + "supplied value includes long-term care premiums, as Schedule A line 1 " + "can, report them in itemized_medical_expenses_long_term_care_premiums." ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py index e9f0fcb4313..1500d4cfb5a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py @@ -4,7 +4,7 @@ class itemized_medical_expenses_long_term_care_premiums(Variable): value_type = float entity = TaxUnit - label = "Long-term care premiums included in itemized medical expenses" + label = "Long-term care premiums included in supplied itemized medical expenses" unit = USD definition_period = YEAR reference = ( @@ -12,17 +12,11 @@ class itemized_medical_expenses_long_term_care_premiums(Variable): "https://www.irs.gov/instructions/i1040sca", ) documentation = ( - "The eligible long-term care premiums inside itemized_medical_expenses. " - "The computed subtotal counts health insurance premiums and other " - "medical expenses only, so this is zero for it. A supplied subtotal " - "is the Schedule A line 1 amount, which includes the members' " - "eligible long-term care premiums. States that deduct long-term care " + "The long-term care insurance premiums that a supplied " + "itemized_medical_expenses includes, such as eligible premiums " + "counted on Schedule A line 1 and not taken as the self-employed " + "health insurance deduction. The computed subtotal counts no " + "long-term care premiums, so leave this at 0 unless you supply a " + "subtotal that includes them. States that deduct long-term care " "premiums separately remove this amount from the subtotal first." ) - - def formula(tax_unit, period, parameters): - supplied = has_input_for_period( - tax_unit.simulation, "itemized_medical_expenses", period - ) - eligible = add(tax_unit, period, ["eligible_long_term_care_premiums"]) - return eligible * supplied From e63e73ade085460d631257419977433e1bd69604 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 13:26:10 -0400 Subject: [PATCH 41/45] Document how a net Schedule A subtotal states its self-employed exclusion The r6 review found a supplied Schedule A subtotal that already leaves out self-employed premiums had them removed again from Montana line 1. A supplied subtotal is taken as medical costs paid by default (as North Dakota and New Mexico use it); a net Schedule A amount now comes with its excluded premiums in itemized_medical_expenses_applied_exclusion, which every consumer (federal, ND, NM, MT) then reads consistently. Document this on itemized_medical_expenses and the applied exclusion, and add net-subtotal YAML cases with person- and return-level deductions beside the gross-cost case. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 80 +++++++++++++++++++ .../itemizing/itemized_medical_expenses.py | 13 +-- ...ized_medical_expenses_applied_exclusion.py | 10 ++- 3 files changed, 96 insertions(+), 7 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 3fc0865bea3..9000f34d8df 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -574,3 +574,83 @@ itemized_medical_expenses_long_term_care_premiums: 0 # Line 1: 10,000 - 3,000 = 7,000. Line 3: 400. mt_medical_expense_deduction_joint: 7_400 + +- name: A net Schedule A subtotal with its self-employed exclusion keeps all other expenses on line 1 + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + health_insurance_premiums: 6_000 + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 6_400 + tax_units: + tax_unit: + members: [person1] + # Schedule A line 1: 10,000 of other expenses; the 6,000 of medical + # premiums and the 400 of long-term care premiums were taken as the + # self-employed deduction. + itemized_medical_expenses: 10_000 + itemized_medical_expenses_applied_exclusion: 6_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: (10,000 + 6,000 - 6,000) - 3,000 = 7,000. Lines 2 and 3: 0. + mt_premium_deduction_joint: 0 + mt_medical_expense_deduction_joint: 7_000 + +- name: A net Schedule A subtotal with a return-level self-employed deduction + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + health_insurance_premiums: 6_000 + tax_units: + tax_unit: + members: [person1] + self_employed_health_insurance_ald: 6_000 + itemized_medical_expenses: 10_000 + itemized_medical_expenses_applied_exclusion: 6_000 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: 10,000 - 3,000 = 7,000. Line 2: 6,000 - 6,000 = 0. + mt_medical_expense_deduction_joint: 7_000 + +- name: A net Schedule A subtotal with part of the premiums taken as the self-employed deduction + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + tax_units: + tax_unit: + members: [person1] + # Schedule A line 1: (6,000 - 1,000) of premiums + 5,000 of other + # expenses. Compare the gross-cost case above, where the same 10,000 + # includes all 6,000 of the premiums. + itemized_medical_expenses: 10_000 + itemized_medical_expenses_applied_exclusion: 1_000 + mt_agi_joint: 30_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: (10,000 + 1,000 - 6,000) - 7.5% x 30,000 = 2,750. + # Line 2: 6,000 - 1,000 = 5,000. + mt_medical_expense_deduction_joint: 7_750 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index 214b2e815dc..d69059af64a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -29,11 +29,14 @@ class itemized_medical_expenses(Variable): "The exclusion is bounded to filer-paid premiums; beneficiary-attributed " "inputs with an ALD exceeding that pool are inconsistent with this contract " "and cannot establish conservation of the supplied ALD. A supplied value " - "bypasses this formula and is used as given: no self-employed exclusion is " - "applied to it (itemized_medical_expenses_applied_exclusion is 0), and " - "states that start from medical costs paid take it as that amount. If a " - "supplied value includes long-term care premiums, as Schedule A line 1 " - "can, report them in itemized_medical_expenses_long_term_care_premiums." + "bypasses this formula and is used as given. By default it is taken as " + "medical costs paid before any self-employed exclusion " + "(itemized_medical_expenses_applied_exclusion is 0). If it is a Schedule " + "A line 1 amount that already leaves out premiums taken as the " + "self-employed health insurance deduction, also supply that amount as " + "itemized_medical_expenses_applied_exclusion. If it includes long-term " + "care premiums, report them in " + "itemized_medical_expenses_long_term_care_premiums." ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py index f034c37903a..083583d547d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -14,8 +14,14 @@ class itemized_medical_expenses_applied_exclusion(Variable): documentation = ( "Premiums actually excluded by the federal medical expense formula. " "A caller-supplied subtotal bypasses that formula, so no exclusion " - "was applied. States retaining gross paid medical costs restore this " - "amount only for computed subtotals. Premium inputs use payer " + "was applied, unless the caller also supplies this amount: the " + "premiums a supplied Schedule A subtotal already leaves out because " + "they were taken as the self-employed health insurance deduction. " + "In a simulation with several tax units, supply it for each of them, " + "since a formula variable supplied for some units is 0 for the rest. " + "States retaining gross paid medical costs restore this amount; " + "Montana's 2021-2023 schedule uses it to take only the premiums still " + "inside the subtotal out of line 1. Premium inputs use payer " "attribution, including family coverage paid by a filer. The required " "policyengine-core>=3.32.27 clears input provenance on deletion, so " "recalculating a deleted subtotal also restores its applied exclusion." From 96d2f72b41ef3d9f39cb3d3a2f5722dda7bd0034 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 13:33:30 -0400 Subject: [PATCH 42/45] Take only medical premiums still in a supplied subtotal out of line 1 The r7 review found the applied-exclusion override could not serve every consumer when the self-employed deduction also covered long-term care premiums: North Dakota and New Mexico restore the full exclusion to get costs paid, while Montana must not treat the long-term care part as other expenses. Montana's line 1 base now removes only the medical premiums still in the subtotal, max(0, premiums - exclusion), with the exclusion taken from medical premiums first as the computed formula does. A supplied override is all premiums the self-employed deduction took. Document this on the federal variables and on the North Dakota and New Mexico consumers, and test the same net composition in all three. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 8 +++++--- .../nd_renters_refund_income.yaml | 13 +++++++++++++ .../nm_medical_expense_exemption.yaml | 13 +++++++++++++ .../itemizing/itemized_medical_expenses.py | 8 +++++--- ...emized_medical_expenses_applied_exclusion.py | 13 ++++++++----- .../mt_medical_expense_deduction_joint.py | 17 ++++++++++------- .../renters_refund/nd_renters_refund_income.py | 11 +++++++---- .../income/credits/nm_medical_expense_credit.py | 9 ++++++--- .../exemptions/nm_medical_expense_exemption.py | 9 ++++++--- 9 files changed, 73 insertions(+), 28 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 9000f34d8df..24177afc3ab 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -591,16 +591,18 @@ members: [person1] # Schedule A line 1: 10,000 of other expenses; the 6,000 of medical # premiums and the 400 of long-term care premiums were taken as the - # self-employed deduction. + # self-employed deduction, so all 6,400 are the excluded amount. itemized_medical_expenses: 10_000 - itemized_medical_expenses_applied_exclusion: 6_000 + itemized_medical_expenses_applied_exclusion: 6_400 mt_agi_joint: 40_000 households: household: members: [person1] state_code: MT output: - # Line 1: (10,000 + 6,000 - 6,000) - 3,000 = 7,000. Lines 2 and 3: 0. + # Medical premiums left in the subtotal: max(0, 6,000 - 6,400) = 0; the + # other 400 of the exclusion covered long-term care premiums, which are + # not in it. Line 1: 10,000 - 3,000 = 7,000. Lines 2 and 3: 0. mt_premium_deduction_joint: 0 mt_medical_expense_deduction_joint: 7_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index a8a8257979a..f7b07d2ac3c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -212,3 +212,16 @@ itemized_medical_expenses_applied_exclusion: 1_000 # 40,000 - 3,000 - (5,500 + 1,000) = 30,500. nd_renters_refund_income: 30_500 + +- name: North Dakota restores the exclusion supplied with a net Schedule A subtotal + period: 2025 + input: + state_code: ND + employment_income: 30_000 + # Schedule A line 1 of 10,000 leaves out 6,400 of premiums (6,000 + # medical, 400 long-term care) taken as the self-employed deduction. + itemized_medical_expenses: 10_000 + itemized_medical_expenses_applied_exclusion: 6_400 + output: + # Medical costs paid: 10,000 + 6,400 = 16,400. + nd_renters_refund_income: 13_600 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml index 0f3d7ef4e76..699e8768ad5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml @@ -177,3 +177,16 @@ state_code: NM output: nm_medical_expense_exemption: 3_000 + +- name: New Mexico restores the exclusion supplied with a net Schedule A subtotal + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + # Schedule A line 1 of 25,000 leaves out 6,400 of premiums taken as the + # self-employed deduction; costs paid are 31,400, above the 28,000 floor. + itemized_medical_expenses: 25_000 + itemized_medical_expenses_applied_exclusion: 6_400 + output: + nm_medical_expense_exemption: 3_000 diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index d69059af64a..c33d499d0b2 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -33,9 +33,11 @@ class itemized_medical_expenses(Variable): "medical costs paid before any self-employed exclusion " "(itemized_medical_expenses_applied_exclusion is 0). If it is a Schedule " "A line 1 amount that already leaves out premiums taken as the " - "self-employed health insurance deduction, also supply that amount as " - "itemized_medical_expenses_applied_exclusion. If it includes long-term " - "care premiums, report them in " + "self-employed health insurance deduction, also supply all of those " + "premiums, medical and long-term care, as " + "itemized_medical_expenses_applied_exclusion; the exclusion is taken " + "from medical insurance premiums first, as this formula does. Report " + "any long-term care premiums still inside the supplied value in " "itemized_medical_expenses_long_term_care_premiums." ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py index 083583d547d..5499d0deaa4 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -14,14 +14,17 @@ class itemized_medical_expenses_applied_exclusion(Variable): documentation = ( "Premiums actually excluded by the federal medical expense formula. " "A caller-supplied subtotal bypasses that formula, so no exclusion " - "was applied, unless the caller also supplies this amount: the " - "premiums a supplied Schedule A subtotal already leaves out because " - "they were taken as the self-employed health insurance deduction. " + "was applied, unless the caller also supplies this amount: all the " + "premiums, medical and long-term care, that a supplied Schedule A " + "subtotal already leaves out because they were taken as the " + "self-employed health insurance deduction, taken from medical " + "insurance premiums first. " "In a simulation with several tax units, supply it for each of them, " "since a formula variable supplied for some units is 0 for the rest. " "States retaining gross paid medical costs restore this amount; " - "Montana's 2021-2023 schedule uses it to take only the premiums still " - "inside the subtotal out of line 1. Premium inputs use payer " + "Montana's 2021-2023 schedule uses it to take only the medical " + "premiums still inside the subtotal out of line 1. Premium inputs use " + "payer " "attribution, including family coverage paid by a filer. The required " "policyengine-core>=3.32.27 clears input provenance on deletion, so " "recalculating a deleted subtotal also restores its applied exclusion." diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 5e7203a43d8..637593d25f9 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -25,19 +25,22 @@ def formula(person, period, parameters): # Itemized Deductions Schedule line 1 takes the medical and dental # expenses allowed under the Internal Revenue Code other than the # insurance premiums deducted in full on lines 2 and 3. Start - # from the federal subtotal, restore the self-employed premiums - # its formula excluded (zero for a supplied subtotal), and take - # out every member's medical insurance premiums and the - # long-term care premiums the subtotal includes. For a computed - # subtotal this is the members' other medical expenses. + # from the federal subtotal and take out the premiums still in + # it: the members' medical insurance premiums less the + # self-employed exclusion applied to them (applied to medical + # premiums first; any excess covered long-term care premiums, + # which are not in the subtotal), and the long-term care premiums + # the subtotal includes. For a computed subtotal this leaves the + # members' other medical expenses. premiums = add( tax_unit, period, ["medical_expense_health_insurance_premiums"] ) + excluded = tax_unit("itemized_medical_expenses_applied_exclusion", period) + premiums_in_subtotal = max_(0, premiums - excluded) expense = max_( 0, tax_unit("itemized_medical_expenses", period) - + tax_unit("itemized_medical_expenses_applied_exclusion", period) - - premiums + - premiums_in_subtotal - tax_unit("itemized_medical_expenses_long_term_care_premiums", period), ) # Line 1b takes Montana AGI from page 1, line 14. A joint return diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index 4c0fe2b3557..2330827d41a 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -15,9 +15,11 @@ class nd_renters_refund_income(Variable): documentation = ( "Renter refund income after unreimbursed medical costs actually paid. " - "The paid-cost base restores self-employed premiums only when the " - "federal formula excluded them. Caller-supplied subtotals remain " - "unchanged, including when components are supplied. Premium inputs use payer " + "The paid-cost base restores the self-employed premiums excluded from " + "the federal subtotal: by the federal formula, or as supplied with a " + "caller's subtotal in itemized_medical_expenses_applied_exclusion. A " + "caller-supplied subtotal without that amount is used unchanged, " + "including when components are supplied. Premium inputs use payer " "attribution: each person reports premiums that person paid, including " "family coverage regardless of whom it covers." ) @@ -30,7 +32,8 @@ def formula(tax_unit, period, parameters): # These premiums are disjoint from itemized_medical_expenses, # whose medical definition follows state income tax law. excluded_premiums = add(tax_unit, period, ["pre_tax_health_insurance_premiums"]) - # Restore only premiums excluded from a computed federal subtotal. + # Restore premiums excluded from the federal subtotal (computed, or + # supplied with a caller's subtotal). medical_expenses = add( tax_unit, period, diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index 2441d3159ee..f7a54906c80 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -16,8 +16,10 @@ class nm_medical_expense_credit(Variable): documentation = ( "Senior medical credit based on gross unreimbursed costs paid, preserving " "premiums deducted through the federal self-employed health insurance ALD. " - "Only the exclusion applied to a computed federal medical amount is " - "restored; caller-supplied subtotals remain unchanged even with components. " + "The exclusion applied to the federal medical amount is restored: by " + "the federal formula, or as supplied with a caller's subtotal in " + "itemized_medical_expenses_applied_exclusion. A caller-supplied " + "subtotal without that amount is used unchanged even with components. " "This assumes qualifying costs were funded from income included in AGI. " "Premium inputs use payer attribution: each person reports premiums that " "person paid, including family coverage regardless of whom it covers. " @@ -28,7 +30,8 @@ def formula(tax_unit, period, parameters): pcredits = parameters(period).gov.states.nm.tax.income.credits p = pcredits.unreimbursed_medical_care_expense age = person("age", period) - # Restore only premiums excluded from a computed federal subtotal. + # Restore premiums excluded from the federal subtotal (computed, or + # supplied with a caller's subtotal). medical_expense = add( tax_unit, period, diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index b88e65879a3..238e35715d1 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -15,8 +15,10 @@ class nm_medical_expense_exemption(Variable): documentation = ( "Senior medical exemption based on gross unreimbursed costs paid, " "preserving premiums deducted through the federal self-employed health " - "insurance ALD. Only the exclusion applied to a computed federal medical " - "amount is restored; caller-supplied subtotals remain unchanged even " + "insurance ALD. The exclusion applied to the federal medical amount is " + "restored: by the federal formula, or as supplied with a caller's " + "subtotal in itemized_medical_expenses_applied_exclusion. A " + "caller-supplied subtotal without that amount is used unchanged even " "with components. " "This assumes qualifying costs were funded from income " "included in AGI. Premium inputs use payer attribution: each person " @@ -30,7 +32,8 @@ def formula(tax_unit, period, parameters): period ).gov.states.nm.tax.income.exemptions.unreimbursed_medical_care_expense age = person("age", period) - # Restore only premiums excluded from a computed federal subtotal. + # Restore premiums excluded from the federal subtotal (computed, or + # supplied with a caller's subtotal). medical_expense = add( tax_unit, period, From 1b0a7714f461898b28f53dea021a11665794eb5b Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 14:01:05 -0400 Subject: [PATCH 43/45] Bound the supplied exclusion by the filers' medical premiums on line 1 The r8 review found that a supplied exclusion above the head's and spouse's medical premiums (its long-term care part) was taken from dependents' premiums too, so Montana line 1 kept them while line 2 deducted them. Take only min(filer premiums, exclusion) off the members' premiums, matching the federal bound; computed subtotals are unchanged. Also document that the long-term care input follows the same medical-first split, qualify itemized_medical_expenses_excluded_premiums, add the regression, partial-deduction and New Mexico credit cases, and add a differential invariant: a net Schedule A subtotal supplied with its exclusion gives the computed path's Montana deduction. Co-Authored-By: Claude Opus 5.5 --- .../mt_medical_expense_deduction_joint.yaml | 71 ++++++++++++++++++- .../credits/nm_medical_expense_credit.yaml | 14 ++++ ...mt_medical_premium_deduction_invariants.py | 66 ++++++++++++++++- .../itemizing/itemized_medical_expenses.py | 5 +- ...ized_medical_expenses_applied_exclusion.py | 17 +++-- ...ized_medical_expenses_excluded_premiums.py | 5 +- ...edical_expenses_long_term_care_premiums.py | 7 +- .../mt_medical_expense_deduction_joint.py | 17 +++-- 8 files changed, 176 insertions(+), 26 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 24177afc3ab..9352357c2a5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -510,8 +510,10 @@ tax_units: tax_unit: members: [person1] - # Schedule A line 1 leaves out premiums deducted under 162(l). + # Schedule A line 1 leaves out the 400 of premiums deducted under + # 162(l), and says so. itemized_medical_expenses: 10_000 + itemized_medical_expenses_applied_exclusion: 400 mt_agi_joint: 40_000 households: household: @@ -653,6 +655,71 @@ members: [person1] state_code: MT output: - # Line 1: (10,000 + 1,000 - 6,000) - 7.5% x 30,000 = 2,750. + # Medical premiums left in the subtotal: 6,000 - min(6,000, 1,000) = 5,000. + # Line 1: (10,000 - 5,000) - 7.5% x 30,000 = 2,750. # Line 2: 6,000 - 1,000 = 5,000. mt_medical_expense_deduction_joint: 7_750 + +- name: A supplied exclusion above the filers' medical premiums is not taken from a dependent's premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + health_insurance_premiums: 6_000 + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 6_400 + person2: + is_tax_unit_dependent: true + age: 10 + health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [person1, person2] + # Schedule A line 1: 10,000 of other expenses + the dependent's 1,000; + # the head's 6,000 and 400 of long-term care were taken as the + # self-employed deduction. + itemized_medical_expenses: 11_000 + itemized_medical_expenses_applied_exclusion: 6_400 + mt_agi_joint: 40_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Medical premiums left in the subtotal: 7,000 - min(6,000, 6,400) = 1,000. + # Line 1: (11,000 - 1,000) - 3,000 = 7,000. Line 2: the dependent's 1,000. + mt_premium_deduction_joint: 1_000 + mt_medical_expense_deduction_joint: [8_000, 0] + +- name: A partial self-employed deduction leaves part of the long-term care premiums in the subtotal + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + age: 30 + health_insurance_premiums: 6_000 + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 6_200 + tax_units: + tax_unit: + members: [person1] + # The 6,200 deduction covers the 6,000 of medical premiums and 200 of + # the long-term care premiums (medical first); Schedule A line 1 is + # 10,000 of other expenses + the other 200. + itemized_medical_expenses: 10_200 + itemized_medical_expenses_applied_exclusion: 6_200 + itemized_medical_expenses_long_term_care_premiums: 200 + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # Line 1: (10,200 - 0 - 200) - 3,000 = 7,000. + # Line 3: 400 - (6,200 - 6,000) = 200. + mt_medical_expense_deduction_joint: 7_200 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml index bccb87ffc58..be256f04348 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml @@ -501,3 +501,17 @@ state_code: NM output: nm_medical_expense_credit: 2800 + +- name: New Mexico credit restores the exclusion supplied with a net Schedule A subtotal + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + claimed_as_dependent_on_another_return: false + # Schedule A line 1 of 25,000 leaves out 6,400 of premiums taken as the + # self-employed deduction; costs paid are 31,400, above the 28,000 floor. + itemized_medical_expenses: 25_000 + itemized_medical_expenses_applied_exclusion: 6_400 + output: + nm_medical_expense_credit: 2_800 diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index fc33796ec8d..db69b9862ff 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -60,6 +60,10 @@ per person, the joint and separate variants agree. 8. From 2024 the joint deduction is the federal medical expense deduction and the separate variant is zero. +9. Differential (seeded, 2021-2023): supplying a net Schedule A subtotal, the + exclusion of all premiums the self-employed deduction took (medical + first), and the long-term care premiums still inside gives the same + Montana deduction as the computed path. """ import numpy as np @@ -242,9 +246,10 @@ def _expected_premium(unit, role, values, mode, variant): return (0.0 if values["premium_source"] == "direct" else premium) + part_b -def _situation(units, year, mode): +def _situation(units, year, mode, unit_inputs=None): people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} expected_premium, increase, roles = [], [], [] + unit_index = 0 for variant in VARIANTS: for i, unit in enumerate(units): tag = f"{variant}_{i}" @@ -270,6 +275,10 @@ def _situation(units, year, mode): tax_unit["self_employed_health_insurance_ald"] = { year: unit["supplied_unit_deduction"] } + # Supplied for every tax unit, so none falls back to 0. + for name, values in (unit_inputs or {}).items(): + tax_unit[name] = {year: float(values[unit_index])} + unit_index += 1 groups["tax_units"][f"{tag}_tax_unit"] = tax_unit groups["households"][f"{tag}_household"] = { "members": members, @@ -305,13 +314,16 @@ def _situation(units, year, mode): "mt_premium_deduction_joint", "itemized_medical_expenses", "itemized_medical_expenses_applied_exclusion", + "itemized_medical_expenses_excluded_premiums", "itemized_medical_expenses_long_term_care_premiums", "medical_expense_deduction", ] -def _run(units, year, mode): - situation, expected_premium, increase, roles = _situation(units, year, mode) +def _run(units, year, mode, unit_inputs=None): + situation, expected_premium, increase, roles = _situation( + units, year, mode, unit_inputs + ) sim = Simulation(situation=situation) out = { name: np.asarray(sim.calculate(name, year), dtype=float) @@ -511,3 +523,51 @@ def test_seeded_population(year, mode): gross = _unit_sum(run, filer * (premiums + long_term_care)) dependent_part = _unit_sum(run, ~filer * (line_2 + line_3)) assert np.any(full - dependent_part < gross - 1) + _check_net_subtotal(run, units, year, mode) + + +def _check_net_subtotal(run, units, year, mode): + """9. A net Schedule A subtotal gives the computed path's deduction. + + Supply, for every return, the subtotal as Schedule A line 1 would hold it + (the computed subtotal plus the filers' long-term care premiums the + self-employed deduction did not take), the exclusion as all premiums that + deduction took (medical first), and the long-term care premiums still + inside. Montana's deduction must not change. + """ + filer = run["role"] != "dependent" + return_deduction = np.maximum(run["self_employed_health_insurance_ald"], 0) + medical_excluded = run["itemized_medical_expenses_excluded_premiums"] + filer_long_term_care = _unit_sum( + run, filer * run["long_term_health_insurance_premiums"] + ) + long_term_care_excluded = np.minimum( + filer_long_term_care, return_deduction - medical_excluded + ) + inside = filer_long_term_care - long_term_care_excluded + net = _run( + units, + year, + mode, + { + "itemized_medical_expenses": run["itemized_medical_expenses"] + inside, + "itemized_medical_expenses_applied_exclusion": medical_excluded + + long_term_care_excluded, + "itemized_medical_expenses_long_term_care_premiums": inside, + }, + ) + np.testing.assert_allclose(net["mt_agi_joint"], run["mt_agi_joint"], atol=TOLERANCE) + np.testing.assert_allclose( + _unit_sum(net, net["mt_medical_expense_deduction_joint"]), + _unit_sum(run, run["mt_medical_expense_deduction_joint"]), + atol=TOLERANCE, + ) + # Non-vacuity: with supplied deductions, some exclusions cover long-term + # care premiums on returns with dependents' premiums, where taking the + # exclusion from every member's premiums would differ. A derived + # deduction never exceeds the filers' own medical premiums. + if mode != "derived": + dependent_premiums = _unit_sum( + run, ~filer * run["medical_expense_health_insurance_premiums"] + ) + assert np.any((long_term_care_excluded > 1) & (dependent_premiums > 1)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index c33d499d0b2..f953f779949 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -36,8 +36,9 @@ class itemized_medical_expenses(Variable): "self-employed health insurance deduction, also supply all of those " "premiums, medical and long-term care, as " "itemized_medical_expenses_applied_exclusion; the exclusion is taken " - "from medical insurance premiums first, as this formula does. Report " - "any long-term care premiums still inside the supplied value in " + "from the head's and spouse's medical insurance premiums first, as this " + "formula does. Report any long-term care premiums still inside the " + "supplied value, by that same split, in " "itemized_medical_expenses_long_term_care_premiums." ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py index 5499d0deaa4..e0078156600 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -17,15 +17,14 @@ class itemized_medical_expenses_applied_exclusion(Variable): "was applied, unless the caller also supplies this amount: all the " "premiums, medical and long-term care, that a supplied Schedule A " "subtotal already leaves out because they were taken as the " - "self-employed health insurance deduction, taken from medical " - "insurance premiums first. " - "In a simulation with several tax units, supply it for each of them, " - "since a formula variable supplied for some units is 0 for the rest. " - "States retaining gross paid medical costs restore this amount; " - "Montana's 2021-2023 schedule uses it to take only the medical " - "premiums still inside the subtotal out of line 1. Premium inputs use " - "payer " - "attribution, including family coverage paid by a filer. The required " + "self-employed health insurance deduction, taken from the head's and " + "spouse's medical insurance premiums first. In a simulation with " + "several tax units, supply it for each of them, since a formula " + "variable supplied for some units is 0 for the rest. States retaining " + "gross paid medical costs restore this amount; Montana's 2021-2023 " + "schedule uses it to take only the medical premiums still inside the " + "subtotal out of line 1. Premium inputs use payer attribution, " + "including family coverage paid by a filer. The required " "policyengine-core>=3.32.27 clears input provenance on deletion, so " "recalculating a deleted subtotal also restores its applied exclusion." ) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py index c07cab1c17e..05fdcb6893d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py @@ -17,8 +17,9 @@ class itemized_medical_expenses_excluded_premiums(Variable): "including family coverage paid by a filer. This preserves supplied " "tax-unit ALDs without allocating them to individual filers. States " "that retain gross paid medical costs use " - "itemized_medical_expenses_applied_exclusion to restore only premiums " - "actually excluded by the federal formula." + "itemized_medical_expenses_applied_exclusion to restore the premiums " + "excluded from the subtotal: these, for a computed subtotal, or the " + "exclusion a caller supplies with a supplied one." ) def formula(tax_unit, period, parameters): diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py index 1500d4cfb5a..b6ed728983e 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_long_term_care_premiums.py @@ -18,5 +18,10 @@ class itemized_medical_expenses_long_term_care_premiums(Variable): "health insurance deduction. The computed subtotal counts no " "long-term care premiums, so leave this at 0 unless you supply a " "subtotal that includes them. States that deduct long-term care " - "premiums separately remove this amount from the subtotal first." + "premiums separately remove this amount from the subtotal first. With " + "a subtotal that leaves out premiums taken as the self-employed " + "deduction, follow the split itemized_medical_expenses_applied_exclusion " + "uses, whatever the return itself did: this is the eligible long-term " + "care premiums less any part of that exclusion above the head's and " + "spouse's medical insurance premiums." ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 637593d25f9..8bf845b16ac 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -26,17 +26,20 @@ def formula(person, period, parameters): # expenses allowed under the Internal Revenue Code other than the # insurance premiums deducted in full on lines 2 and 3. Start # from the federal subtotal and take out the premiums still in - # it: the members' medical insurance premiums less the - # self-employed exclusion applied to them (applied to medical - # premiums first; any excess covered long-term care premiums, - # which are not in the subtotal), and the long-term care premiums - # the subtotal includes. For a computed subtotal this leaves the - # members' other medical expenses. + # it: the members' medical insurance premiums less the part of + # the self-employed exclusion applied to them, which is bounded by + # the head's and spouse's medical premiums (any excess covered + # long-term care premiums, which are not in the subtotal), and the + # long-term care premiums the subtotal includes. For a computed + # subtotal this leaves the members' other medical expenses. premiums = add( tax_unit, period, ["medical_expense_health_insurance_premiums"] ) + filer_premiums = tax_unit_non_dep_sum( + "medical_expense_health_insurance_premiums", tax_unit, period + ) excluded = tax_unit("itemized_medical_expenses_applied_exclusion", period) - premiums_in_subtotal = max_(0, premiums - excluded) + premiums_in_subtotal = premiums - min_(filer_premiums, max_(0, excluded)) expense = max_( 0, tax_unit("itemized_medical_expenses", period) From bf1661d1a86d93ecefcee043393375cde13836e2 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 14:10:34 -0400 Subject: [PATCH 44/45] Define the seeded units before the net-subtotal differential Co-Authored-By: Claude Opus 5.5 --- .../tests/unit/test_mt_medical_premium_deduction_invariants.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index db69b9862ff..9593739c935 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -488,7 +488,8 @@ def test_mt_medical_premium_deduction_invariants(year, mode, units): @pytest.mark.parametrize("year,mode", SEEDED_CASES) def test_seeded_population(year, mode): - result = _check(_seeded_units(), year, mode) + units = _seeded_units() + result = _check(units, year, mode) if result is None: return run, floored, full = result From 715ac2cd7757206426651ec0cd27cd571b1f5eee Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sun, 18 Oct 2026 19:31:56 -0400 Subject: [PATCH 45/45] Count only the taxpayer's own premiums in Montana's 2021-2023 deduction --- .../fix-mt-medical-premiums-in-full.fixed.md | 2 +- .../mt_medical_expense_deduction_joint.yaml | 49 ++++++++++++++--- .../itemized/mt_premium_deduction_joint.yaml | 10 ++-- ...mt_medical_premium_deduction_invariants.py | 53 ++++++++++++------- ...gible_long_term_care_insurance_premiums.py | 4 +- .../mt_eligible_medical_insurance_premiums.py | 4 +- .../medical/mt_premium_deduction_joint.py | 19 ++----- 7 files changed, 88 insertions(+), 53 deletions(-) diff --git a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md index 1be85896d4b..73d4d8a2304 100644 --- a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md +++ b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md @@ -1 +1 @@ -Deduct Montana's 2021–2023 medical and long-term care insurance premiums in full on the Itemized Deductions Schedule (lines 2 and 3), net of premiums taken as the self-employed health insurance deduction, and apply the 7.5% floor only to other medical and dental expenses. +Deduct Montana's 2021–2023 medical and long-term care insurance premiums paid by the head or spouse in full on the Itemized Deductions Schedule (lines 2 and 3), net of premiums taken as the self-employed health insurance deduction, and apply the 7.5% floor only to other medical and dental expenses. diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index 9352357c2a5..2bd044b3c36 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -269,7 +269,7 @@ # Line 1: 10,000 - 7.5% x 40,000 = 7,000. mt_medical_expense_deduction_joint: 7_000 -- name: A dependent's premiums count on line 2 while line 1 is below the floor +- name: Only filer-paid premiums count on line 2 while line 1 is below the floor period: 2022 absolute_error_margin: 0.01 input: @@ -293,10 +293,42 @@ state_code: MT output: # Line 1: max(0, 1,000 - 7.5% x 50,000) = 0. - # Line 2: 3,000 + 1,200 = 4,200. - # Flooring the premiums with the other expenses would give - # 5,200 - 3,750 = 1,450. - mt_medical_expense_deduction_joint: [4_200, 0, 0] + # Line 2: the head's 3,000. The dependent paid their own 1,200, + # so it is not a payment made by either filer. + mt_medical_expense_deduction_joint: [3_000, 0, 0] + +- name: Dependent-paid qualified long-term care premiums do not become the filers' deduction + period: 2022 + absolute_error_margin: 0.01 + input: + people: + head: + is_tax_unit_head: true + health_insurance_premiums: 0 + long_term_health_insurance_premiums: 0 + spouse: + is_tax_unit_spouse: true + health_insurance_premiums: 0 + long_term_health_insurance_premiums: 0 + dependent: + is_tax_unit_dependent: true + long_term_health_insurance_premiums: 1_000 + self_employed_health_insurance_ald_person: 0 + tax_units: + tax_unit: + members: [head, spouse, dependent] + mt_agi_joint: 50_000 + self_employed_health_insurance_ald: 0 + households: + household: + members: [head, spouse, dependent] + state_code: MT + output: + # Former MCA 15-30-2131(1)(g) allows premiums paid by the taxpayer. + # These premiums were paid by the dependent, so neither filer claims them. + mt_medical_expense_deduction_joint: [0, 0, 0] + mt_medical_expense_deduction_indiv: [0, 0, 0] + mt_premium_deduction_joint: 0 - name: Joint return computed from wages, with premiums and other medical expenses period: 2022 @@ -690,9 +722,10 @@ state_code: MT output: # Medical premiums left in the subtotal: 7,000 - min(6,000, 6,400) = 1,000. - # Line 1: (11,000 - 1,000) - 3,000 = 7,000. Line 2: the dependent's 1,000. - mt_premium_deduction_joint: 1_000 - mt_medical_expense_deduction_joint: [8_000, 0] + # Line 1: (11,000 - 1,000) - 3,000 = 7,000. The filer's premiums were + # already deducted, and the dependent's own payments do not enter line 2. + mt_premium_deduction_joint: 0 + mt_medical_expense_deduction_joint: [7_000, 0] - name: A partial self-employed deduction leaves part of the long-term care premiums in the subtotal period: 2022 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml index 64e1a061c8f..090e75eacba 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_premium_deduction_joint.yaml @@ -88,7 +88,7 @@ # (6,000 + 3,000) - (1,000 + 2,000) = 6,000. mt_premium_deduction_joint: 6_000 -- name: A dependent's own deduction is netted from the dependent's premiums only +- name: A dependent's own premiums and deduction do not enter the filer's deduction period: 2022 absolute_error_margin: 0.01 input: @@ -109,12 +109,10 @@ members: [person1, person2] state_code: MT output: - # The dependent's deduction is on the dependent's own return, so it is - # not part of the return's deduction and does not reduce the head's - # 3,000. The dependent's line 2 is 1,000 - 1,000 = 0 and line 3 is - # 300 - (1,200 - 1,000) = 100. + # Neither the dependent's premiums nor their deduction belongs to the + # filer. Only the head's 3,000 counts, with no self-employed exclusion. self_employed_health_insurance_ald: 0 - mt_premium_deduction_joint: 3_100 + mt_premium_deduction_joint: 3_000 - name: Outside Montana the amount is zero period: 2022 diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py index 9593739c935..a5a90d8dceb 100644 --- a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -8,12 +8,12 @@ 15-30-2131(1)(a)(iii)-(iv) and (1)(g). From 2024 Montana takes federal itemized deductions, where premiums stay under the federal floor. -For the head and spouse that deduction is part of Montana AGI. A dependent -takes it on the dependent's own return, where it does not reduce the filer's -Montana AGI; under PolicyEngine's payer attribution those premiums were paid -by the dependent, so they are netted from the dependent's own premiums. The -properties below hold for the premium inputs as attributed and the deduction -as modeled; they do not establish who paid a premium. +For the head and spouse that deduction is part of Montana AGI. Premium inputs +use payer attribution: payments for dependent coverage belong on the paying +filer's input. A dependent's own premiums and self-employed deduction belong +on the dependent's own return and do not enter the filers' premium deduction. +The properties below hold for those attributed inputs and the deduction as +modeled; they do not independently establish who paid a premium. Why Python rather than YAML: Hypothesis generates mixed populations (single, head of household and joint returns with dependents; premiums reported @@ -40,17 +40,18 @@ head. The separate variant is max(0, own other expenses - 7.5% x own Montana AGI) + own lines 2 and 3 for the head and spouse; dependents hold nothing. -2. Premium conservation against closed forms. Per person (separate returns - and dependents): line 2 + line 3 = max(0, premiums + long-term care +2. Premium conservation against closed forms. Per-person helpers: + line 2 + line 3 = max(0, premiums + long-term care premiums - own deduction), and line 2 = premiums - min(premiums, own deduction). Joint return: lines 2 and 3 = max(0, filers' premiums + - filers' long-term care premiums - the return's deduction) + each - dependent's per-person amount. Premiums are computed from the raw inputs. - So each deduction is netted exactly once. + filers' long-term care premiums - the return's deduction). Only the head + and spouse claim the per-person helpers on separate returns. Premiums are + computed from the raw inputs. Each deduction is netted exactly once. 3. Bounds (2021-2023): premiums are never floored, so the deduction is at least lines 2 and 3 and at most those plus other expenses. -4. Slope (2021-2023): raising after-tax or long-term care premiums that no - deduction can absorb raises the deduction by exactly the increase. +4. Slope (2021-2023): raising filers' after-tax or long-term care premiums + that no deduction can absorb raises the deduction by exactly the increase. + Raising dependent-paid premiums does not increase the filers' deduction. Montana AGI is unchanged by those increases (asserted). 5. Pre-tax premiums never change lines 2 or 3. 6. Line 1 base: other expenses equal federal itemized medical expenses plus @@ -267,7 +268,7 @@ def _situation(units, year, mode, unit_inputs=None): groups["marital_units"][f"{tag}_single_{j}"] = {"members": [name]} else: couple.append(name) - unit_increase += sum(_increases(unit, role, values, mode, "raised")) + unit_increase += sum(_increases(unit, role, values, mode, "raised")) increase.append(unit_increase) groups["marital_units"][f"{tag}_couple"] = {"members": couple} tax_unit = {"members": members} @@ -367,8 +368,7 @@ def _check(units, year, mode): line_2 = run["mt_eligible_medical_insurance_premiums"] line_3 = run["mt_eligible_long_term_care_insurance_premiums"] other = run["other_medical_expenses"] - dependent = role == "dependent" - filer = ~dependent + filer = role != "dependent" # 2. Premium conservation against closed forms. np.testing.assert_allclose(premium, run["expected_premium"], atol=TOLERANCE) @@ -405,7 +405,7 @@ def _check(units, year, mode): _unit_sum(run, filer * (run["expected_premium"] + long_term_care)) - return_deduction, 0, - ) + _unit_sum(run, dependent * (line_2 + line_3)) + ) full = run["mt_premium_deduction_joint"] np.testing.assert_allclose(full, expected_joint_premiums, atol=TOLERANCE) @@ -421,7 +421,8 @@ def _check(units, year, mode): assert (deduction >= full - TOLERANCE).all() assert (deduction <= full + _unit_sum(run, other) + TOLERANCE).all() - # 4. Slope of one where no deduction can absorb the increase. + # 4. Slope of one for filer increases no deduction can absorb, and zero + # for dependent increases. The raised twin changes both sets of payers. np.testing.assert_allclose( run["mt_agi_joint"][raised], run["mt_agi_joint"][base], atol=TOLERANCE ) @@ -493,6 +494,19 @@ def test_seeded_population(year, mode): if result is None: return run, floored, full = result + # The slope check includes dependent-only increases: their premiums rise + # while the filers' premiums and deduction remain unchanged. + dependent_increase = np.array( + [ + sum( + sum(_increases(unit, role, values, mode, "raised")) + for role, values in _entries(unit) + if role == "dependent" + ) + for unit in units + ] + ) + assert np.any((dependent_increase > 1) & (run["increase"][: len(units)] == 0)) # Non-vacuity: some returns deduct premiums while line 1 is below the # floor, where flooring premiums with other expenses would cut the # deduction; some deduct line 1 too; there are single and joint returns; @@ -522,8 +536,7 @@ def test_seeded_population(year, mode): # A return-level deduction reduces the filers' lines 2 and 3. filer = run["role"] != "dependent" gross = _unit_sum(run, filer * (premiums + long_term_care)) - dependent_part = _unit_sum(run, ~filer * (line_2 + line_3)) - assert np.any(full - dependent_part < gross - 1) + assert np.any(full < gross - 1) _check_net_subtotal(run, units, year, mode) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py index b7503768242..647f01994f4 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py @@ -14,8 +14,8 @@ class mt_eligible_long_term_care_insurance_premiums(Variable): "deduction. That deduction is applied first to the medical insurance " "premiums on line 2 and the remainder to these premiums; lines 2 and " "3 are both deducted in full, so the order does not change the total. " - "This per-person amount serves separate returns and dependents; a " - "joint return nets the return's deduction in " + "This per-person amount serves the head and spouse on separate " + "returns; a joint return nets the return's deduction in " "mt_premium_deduction_joint." ) reference = ( diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py index c1c98c51259..7f358a784ff 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py @@ -17,8 +17,8 @@ class mt_eligible_medical_insurance_premiums(Variable): "premiums are a separate input that never enters " "medical_expense_health_insurance_premiums. Premium inputs use payer " "attribution: each person reports the premiums that person paid. " - "This per-person amount serves separate returns and dependents; a " - "joint return nets the return's deduction in " + "This per-person amount serves the head and spouse on separate " + "returns; a joint return nets the return's deduction in " "mt_premium_deduction_joint." ) reference = ( diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py index a4038d9edf7..08bcaadd5de 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_premium_deduction_joint.py @@ -15,9 +15,10 @@ class mt_premium_deduction_joint(Variable): "netted from their premiums once: first from medical insurance " "premiums, by the same bounded exclusion the computed federal " "Schedule A subtotal uses, then from long-term care premiums. A " - "deduction supplied for the return is honored. A dependent's " - "deduction belongs on the dependent's own return and is netted from " - "that dependent's premiums. Premium inputs use payer attribution." + "deduction supplied for the return is honored. Premium inputs use " + "payer attribution: only the head's and spouse's payments count, " + "including payments for dependent coverage. A dependent's own " + "premium payments do not enter the filers' deduction." ) reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", @@ -41,18 +42,8 @@ def formula(tax_unit, period, parameters): # The bounded exclusion the computed federal subtotal applies to these # premiums: min(premiums, deduction). used_on_line_2 = tax_unit("itemized_medical_expenses_excluded_premiums", period) - filer_amount = ( + return ( premiums - used_on_line_2 + max_(0, long_term_care_premiums - (deduction - used_on_line_2)) ) - person = tax_unit.members - is_dependent = person("is_tax_unit_dependent", period) - dependent_amount = tax_unit.sum( - is_dependent - * ( - person("mt_eligible_medical_insurance_premiums", period) - + person("mt_eligible_long_term_care_insurance_premiums", period) - ) - ) - return filer_amount + dependent_amount