diff --git a/changelog.d/id-worksheet-dependent-medical.fixed.md b/changelog.d/id-worksheet-dependent-medical.fixed.md new file mode 100644 index 00000000000..8d596e91c7d --- /dev/null +++ b/changelog.d/id-worksheet-dependent-medical.fixed.md @@ -0,0 +1 @@ +Count every tax-unit member's other medical expenses, including bills the filers paid for dependents, in Idaho's Form 39R claimant medical worksheet, as federal Schedule A does. 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 7fa88266780..65b505b9b0f 100644 --- a/policyengine_us/tests/core/test_id_health_insurance_premiums.py +++ b/policyengine_us/tests/core/test_id_health_insurance_premiums.py @@ -231,11 +231,16 @@ def test_actual_medical_deduction_is_scoped_to_period_and_branch(): strict=True, reason=( "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." + "misclassifies a federal itemized total recalculated before the " + "Idaho formula as supplied; fixed by policyengine-core#561." ), ) -def test_deleted_medical_input_recalculated_federal_first(): +def test_deleted_itemized_input_recalculated_federal_first(): + # From derived inputs Idaho's claimant worksheet equals the federal medical + # deduction, so a deleted federal medical input cannot change any Idaho + # amount. Idaho's itemized total still tells a supplied federal total, + # used as given, from a derived one, whose medical component it replaces + # with an explicit Idaho claimant deduction. year = 2025 simulation = Simulation( tax_benefit_system=SYSTEM, @@ -249,10 +254,6 @@ def test_deleted_medical_input_recalculated_federal_first(): "dependent": { "age": {year: 20}, "is_tax_unit_dependent": {year: True}, - # Other costs, which the federal subtotal counts for every - # member and Idaho's claimant worksheet leaves out, keep - # the two amounts apart. A dependent's own premiums would - # not: neither counts them. "other_medical_expenses": {year: 500}, }, }, @@ -260,8 +261,10 @@ def test_deleted_medical_input_recalculated_federal_first(): "tax_unit": { "members": ["head", "dependent"], "adjusted_gross_income": {year: 30_000}, - "standard_deduction": {year: 15_750}, - "id_itemized_deductions": {year: 20_000}, + "interest_deduction": {year: 20_000}, + "salt_deduction": {year: 0}, + "id_salt_deduction": {year: 0}, + "id_health_insurance_premiums_medical_deduction": {year: 250}, } }, "households": { @@ -272,14 +275,187 @@ def test_deleted_medical_input_recalculated_federal_first(): }, }, ) - simulation.set_input("medical_expense_deduction", year, np.array([3_000])) - simulation.delete_arrays("medical_expense_deduction", year) + simulation.set_input("itemized_taxable_income_deductions", year, np.array([30_000])) + simulation.delete_arrays("itemized_taxable_income_deductions", 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 costs: medical overlap is - # 3,000 - 30,000 * 7.5% = 750, leaving 3,000 - 750 = 2,250. - # Core's provenance defect before 3.32.27 uses 1,250 and returns 1,750. + # Federal first: 20,000 interest + (3,000 + 500 - 30,000 * 7.5%) medical + # = 21,250. assert simulation.calculate( - "id_health_insurance_premiums_subtraction", year - ).tolist() == [2_250] + "itemized_taxable_income_deductions", year + ).tolist() == [21_250] + # Idaho replaces the 1,250 federal medical deduction with the explicit 250: + # 21,250 - 1,000 = 20,250. Core's provenance defect before 3.32.27 takes the + # recalculated total as supplied and keeps 21,250. + assert simulation.calculate("id_itemized_deductions", year).tolist() == [20_250] + + +# Form 39R worksheet lines 1-6 take the medical expenses claimed on federal +# Schedule A, which counts bills the filers paid for dependents. Premium inputs +# sit on the payer and other medical expenses on the person cared for, so the +# claimant worksheet counts the filers' premiums and every member's other +# costs. Each drawn return runs in one vectorized simulation per year with +# these variants: +VARIANTS = ( + "drawn", + # every member's other costs recorded on the head; + "bills_on_head", + # every member's other costs recorded on one dependent; + "bills_on_dependent", + # one dependent's other costs raised; + "dependent_bills_raised", + # every dependent's own premiums set to 0. + "no_dependent_premiums", +) +# The drawn high incomes reach the 2026 itemized limitation (IRC 68). +CLAIMANT_YEARS = (2025, 2026) +IDAHO_OUTPUTS = ( + "id_health_insurance_premiums_medical_deduction", + "id_qualified_health_insurance_premiums", + "id_itemized_deductions", + "id_itemizes", + "id_health_insurance_premiums_subtraction", + "id_deductions", +) +CENT = 0.01 + + +@st.composite +def claimant_returns(draw): + filers = draw(st.sampled_from([1, 2])) + dependents = draw(st.integers(1, 2)) + members = filers + dependents + return { + "filers": filers, + "premiums": [draw(st.integers(0, 20_000)) for _ in range(members)], + "other": [draw(st.integers(0, 40_000)) for _ in range(members)], + "agi": draw(st.integers(0, 150_000) | st.integers(600_000, 900_000)), + "interest": draw(st.integers(0, 40_000)), + "standard": draw(st.integers(0, 35_000)), + "above_line": draw(st.integers(0, 25_000)), + "receiver": filers + draw(st.integers(0, dependents - 1)), + "raise": draw(st.integers(1, 10_000)), + } + + +def _claimant_inputs(case, variant): + premiums = list(case["premiums"]) + other = list(case["other"]) + if variant == "bills_on_head": + other = [sum(other)] + [0] * (len(other) - 1) + elif variant == "bills_on_dependent": + moved = [0] * len(other) + moved[case["receiver"]] = sum(other) + other = moved + elif variant == "dependent_bills_raised": + other[case["receiver"]] += case["raise"] + elif variant == "no_dependent_premiums": + premiums[case["filers"] :] = [0] * (len(premiums) - case["filers"]) + return premiums, other + + +def _claimant_simulation(cases, year): + people, tax_units, households, rows = {}, {}, {}, [] + for index, case in enumerate(cases): + for variant in VARIANTS: + unit = f"case_{index}_{variant}" + premiums, other = _claimant_inputs(case, variant) + members = [f"{unit}_{member}" for member in range(len(premiums))] + for member, person in enumerate(members): + filer = member < case["filers"] + people[person] = { + "age": {year: 40 if filer else 10}, + "is_tax_unit_head": {year: member == 0}, + "is_tax_unit_spouse": {year: member == 1 and filer}, + "is_tax_unit_dependent": {year: not filer}, + "health_insurance_premiums": {year: premiums[member]}, + "other_medical_expenses": {year: other[member]}, + } + tax_units[unit] = { + "members": members, + "adjusted_gross_income": {year: case["agi"]}, + "self_employed_health_insurance_ald": {year: case["above_line"]}, + "interest_deduction": {year: case["interest"]}, + "salt_deduction": {year: 0}, + "id_salt_deduction": {year: 0}, + # Supplied to keep the model graph small: neither depends on + # medical expenses. + "standard_deduction": {year: case["standard"]}, + "taxable_income_before_itemized_deductions": {year: case["agi"]}, + } + households[unit] = {"members": members, "state_code": {year: "ID"}} + rows.append((index, variant)) + simulation = Simulation( + tax_benefit_system=SYSTEM, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + }, + ) + return simulation, rows + + +@settings(max_examples=8, deadline=None, derandomize=True) +@given(cases=st.lists(claimant_returns(), min_size=2, max_size=3)) +def test_claimant_worksheet_counts_bills_paid_for_dependents(cases): + for year in CLAIMANT_YEARS: + simulation, rows = _claimant_simulation(cases, year) + floor = SYSTEM.parameters(year).gov.irs.deductions.itemized.medical.floor + values = { + # id_itemizes compares as 0 or 1. + variable: dict( + zip(rows, simulation.calculate(variable, year).astype(float)) + ) + for variable in IDAHO_OUTPUTS + + ("medical_expense_deduction", "itemized_taxable_income_deductions") + } + for index, case in enumerate(cases): + + def value(variable, variant="drawn"): + return values[variable][(index, variant)] + + worksheet = value("id_health_insurance_premiums_medical_deduction") + # 1. Differential: an independent oracle, the federal Schedule A + # deduction, and Idaho's worksheet agree. + filer_premiums = sum(case["premiums"][: case["filers"]]) + oracle = max( + 0, + max(0, filer_premiums - case["above_line"]) + + sum(case["other"]) + - floor * max(0, case["agi"]), + ) + assert worksheet == pytest.approx(oracle, abs=CENT), case + assert value("medical_expense_deduction") == pytest.approx( + worksheet, abs=CENT + ), case + # 2. Idaho's medical correction leaves a derived federal itemized + # total unchanged: the two medical deductions agree. + assert value("id_itemized_deductions") == pytest.approx( + value("itemized_taxable_income_deductions"), abs=CENT + ), case + # 3. Relocation: whose input records a bill changes no Idaho + # amount, and neither do a dependent's own premiums. + for variant in ( + "bills_on_head", + "bills_on_dependent", + "no_dependent_premiums", + ): + for variable in IDAHO_OUTPUTS: + assert value(variable, variant) == pytest.approx( + value(variable), abs=CENT + ), (variable, variant, case) + # 4. Monotonicity: more bills for a dependent never lower the + # worksheet. + assert ( + value( + "id_health_insurance_premiums_medical_deduction", + "dependent_bills_raised", + ) + >= worksheet - CENT + ), case + # 5. Bounds: the subtraction stays within the qualified premiums. + assert ( + -CENT + <= value("id_health_insurance_premiums_subtraction") + <= value("id_qualified_health_insurance_premiums") + CENT + ), case 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 a4e5bbfee22..6402cdb20f5 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 @@ -55,6 +55,66 @@ id_health_insurance_premiums_subtraction: 2_250 id_taxable_income: 7_000 +- name: A bill the parent paid counts the same on the parent's input or the child's + # Other medical expenses sit on the person cared for, so unit a records the + # child's 3,000 bill on the parent and unit b on the child. Schedule A + # counts both, and Form 39R worksheet lines 3-6 take the Schedule A amounts. + # Medical deduction: 1,000 + 3,000 - 7.5% of 30,000 = 1,750. + # Itemized: 20,000 + 1,750 = 21,750; it beats standard 15,750 + 1,000. + # Subtraction: 1,000 - min(1,000, 1,750) = 0. + # Taxable income: 30,000 - 21,750 - 0 = 8,250. + # Leaving out the child's bill would take 1,750 off unit b's itemized + # deductions and restore its 1,000 subtraction: taxable income 9,000. + period: 2025 + input: + people: + a_parent: + age: 40 + health_insurance_premiums: 1_000 + other_medical_expenses: 3_000 + is_tax_unit_head: true + a_child: + age: 10 + is_tax_unit_dependent: true + b_parent: + age: 40 + health_insurance_premiums: 1_000 + is_tax_unit_head: true + b_child: + age: 10 + other_medical_expenses: 3_000 + is_tax_unit_dependent: true + tax_units: + a: + members: [a_parent, a_child] + adjusted_gross_income: 30_000 + interest_deduction: 20_000 + salt_deduction: 0 + id_salt_deduction: 0 + standard_deduction: 15_750 + b: + members: [b_parent, b_child] + adjusted_gross_income: 30_000 + interest_deduction: 20_000 + salt_deduction: 0 + id_salt_deduction: 0 + standard_deduction: 15_750 + households: + a: + members: [a_parent, a_child] + state_code: ID + b: + members: [b_parent, b_child] + state_code: ID + output: + medical_expense_deduction: [1_750, 1_750] + id_health_insurance_premiums_medical_deduction: [1_750, 1_750] + itemized_taxable_income_deductions: [21_750, 21_750] + id_itemized_deductions: [21_750, 21_750] + id_itemizes: [true, true] + id_health_insurance_premiums_subtraction: [0, 0] + id_taxable_income: [8_250, 8_250] + - 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. @@ -92,6 +152,44 @@ id_itemizes: true id_health_insurance_premiums_subtraction: 60_000 +- name: A dependent's bills stay in the 2026 limited Idaho itemized total + # Medical deduction: 40,000 + the child's 30,000 - 7.5% of 800,000 = 10,000. + # P.L. 119-21 sec. 70111: (30,000 + 10,000) * (1 - 2/37) = 37,837.83784. + # Leaving out the child's bills would take the 10,000 off before the + # limitation and give 28,378.37838. + # Subtraction: 40,000 - min(40,000, 10,000) = 30,000. + absolute_error_margin: 0.01 + period: 2026 + input: + people: + head: + age: 40 + health_insurance_premiums: 40_000 + is_tax_unit_head: true + child: + age: 10 + other_medical_expenses: 30_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + filing_status: SINGLE + adjusted_gross_income: 800_000 + interest_deduction: 30_000 + salt_deduction: 0 + id_salt_deduction: 0 + households: + household: + members: [head, child] + state_code: ID + output: + medical_expense_deduction: 10_000 + id_health_insurance_premiums_medical_deduction: 10_000 + itemized_taxable_income_deductions: 37_837.83784 + id_itemized_deductions: 37_837.83784 + id_itemizes: true + id_health_insurance_premiums_subtraction: 30_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/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 fffa2b999bf..a09558def91 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 @@ -282,8 +282,16 @@ 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. +- name: Claimant medical overlap counts bills for a dependent but not the dependent's own premiums + # 2025 Form 39R worksheet, PDF page 49, lines 1-8: lines 1-6 take the + # Schedule A amounts, and Schedule A line 1 counts bills the filers paid for + # dependents. Other medical expenses sit on the person cared for. + # Premiums: the filers' 3,000 + 2,000 = 5,000; the dependent's own 500 is + # not theirs. Other costs: 1,000 + 1,000 + the dependent's 2,000 = 4,000. + # Medical deduction: 5,000 + 4,000 - 7.5% of 80,000 = 3,000. + # Subtraction: 5,000 - min(5,000, 3,000) = 2,000. Leaving out the + # dependent's bills would give 4,000. + # Itemized route 40,000 + 2,000 = 42,000 beats standard 31,500 + 5,000. period: 2025 input: people: @@ -300,7 +308,7 @@ dependent: age: 20 health_insurance_premiums: 500 - other_medical_expenses: 4_000 + other_medical_expenses: 2_000 is_tax_unit_dependent: true tax_units: tax_unit: @@ -313,12 +321,48 @@ members: [head, spouse, dependent] state_code: ID output: - # Federal: the filers' 5,000 of premiums and every member's 6,000 of - # other costs, less the 6,000 floor; not the dependent's own 500. - medical_expense_deduction: 5_000 - id_health_insurance_premiums_medical_deduction: 1_000 + medical_expense_deduction: 3_000 + id_qualified_health_insurance_premiums: 5_000 + id_health_insurance_premiums_medical_deduction: 3_000 + id_itemizes: true + id_health_insurance_premiums_subtraction: 2_000 + +- name: A bill a parent paid for a dependent's care enters the claimant worksheet + # Schedule A line 1 counts "medical and dental bills you paid ... for ... + # All dependents you claim on your return" (2025 instructions), and Form + # 39R worksheet lines 3-6 take those Schedule A amounts. + # Medical deduction: 3,000 + 1,500 - 7.5% of 30,000 = 2,250. + # Subtraction: 3,000 - min(3,000, 2,250) = 750. Leaving out the child's + # bills would give a 750 deduction and a 2,250 subtraction. + # Itemized route 20,000 + 750 = 20,750 beats standard 15,750 + 3,000. + # Taxable income: 30,000 - 20,000 - 750 = 9,250. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums: 3_000 + is_tax_unit_head: true + child: + age: 10 + other_medical_expenses: 1_500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, child] + adjusted_gross_income: 30_000 + standard_deduction: 15_750 + id_itemized_deductions: 20_000 + households: + household: + members: [head, child] + state_code: ID + output: + medical_expense_deduction: 2_250 + id_health_insurance_premiums_medical_deduction: 2_250 id_itemizes: true - id_health_insurance_premiums_subtraction: 4_000 + id_health_insurance_premiums_subtraction: 750 + id_taxable_income: 9_250 - name: Derived claimant medical overlap uses premiums excluding pretax payroll payments # Pretax payroll premiums are separate; remove only the Schedule 1 amount. 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 1311e8e97cf..026ec50548e 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 @@ -43,8 +43,10 @@ def formula(tax_unit, period, parameters): ) 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. + # Use the claimant's medical deduction in derived itemized totals, as + # the premium overlap does. From derived inputs it equals the federal + # deduction; they differ only when a caller supplies an Idaho amount + # or an intermediate federal one. limitation_adjustment = 0 p = parameters(period).gov.irs.deductions.itemized.limitation if p.applies and p.obbb.applies: 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 04860677ccf..5195c616af9 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 @@ -13,11 +13,20 @@ class id_health_insurance_premiums_medical_deduction(Variable): # 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", + # CPS ASEC wording of the premium and medical care questions, PDF + # pages 2-3. + "https://www.census.gov/content/dam/Census/library/working-papers/2015/demo/Medical-Out-of-pocket-Expenses-CPSASEC-2013-2014.pdf#page=2", ) 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. " + "overlap. Form 39R worksheet lines 1-6 take the medical expenses " + "claimed on federal Schedule A, which include bills the claimant paid " + "for dependents. Premium inputs sit on the payer, so only the head's " + "and spouse's premiums count, including coverage they bought for " + "dependents; a dependent's own premium payments do not. Other medical " + "expenses sit on the person cared for, including amounts other " + "household members paid on that person's behalf, so every member's " + "count. " "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 " @@ -44,13 +53,18 @@ def formula(tax_unit, period, parameters): federal_medical = max_(0, tax_unit("medical_expense_deduction", period)) if supplied_medical: return federal_medical + # Qualified premiums are the head's and spouse's own payments. premiums = tax_unit("id_qualified_health_insurance_premiums", period) + # Form 39R lines 3-6 take the Schedule A bills, which include those + # the claimant paid for dependents. Other medical expenses sit on the + # person cared for, so a dependent's are the filers' payments. other_medical = tax_unit_non_dep_add( - tax_unit, period, ["other_medical_expenses"] + tax_unit, + period, + ["other_medical_expenses"], + include_dependents=["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) return min_(federal_medical, claimant_medical)