From 541d6b52cf17652aac0dc4e33386d3fd0cd573b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:05:11 -0400 Subject: [PATCH 01/26] 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 02/26] 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 03/26] 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 04/26] 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 05/26] 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 06/26] 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 07/26] 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 08/26] 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 09/26] 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 10/26] 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 11/26] 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 12/26] 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 13/26] 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 14/26] 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 15/26] 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 16/26] 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 17/26] 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 18/26] 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 19/26] 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 20/26] 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 21/26] 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 22/26] 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 23/26] 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 24/26] 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 25/26] 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 26/26] 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" },