From 541d6b52cf17652aac0dc4e33386d3fd0cd573b6 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 9 Oct 2026 08:05:11 -0400 Subject: [PATCH 01/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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/16] 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)