diff --git a/changelog.d/fix-oh-medical-premium-lines.fixed.md b/changelog.d/fix-oh-medical-premium-lines.fixed.md new file mode 100644 index 00000000000..02e7706f9f9 --- /dev/null +++ b/changelog.d/fix-oh-medical-premium-lines.fixed.md @@ -0,0 +1 @@ +Count Ohio health insurance premiums on the unreimbursed medical care worksheet: line 3 now takes premiums of anyone eligible for Medicare or an employer-paid plan (R.C. 5747.01(A)(10)), and line 1 reads the premium inputs the data fills, net of the federal self-employed health insurance deduction. diff --git a/policyengine_us/tests/core/test_oh_unreimbursed_medical_deduction_invariants.py b/policyengine_us/tests/core/test_oh_unreimbursed_medical_deduction_invariants.py index e1be0da4318..f14becb8240 100644 --- a/policyengine_us/tests/core/test_oh_unreimbursed_medical_deduction_invariants.py +++ b/policyengine_us/tests/core/test_oh_unreimbursed_medical_deduction_invariants.py @@ -6,14 +6,18 @@ (premiums with no Medicare or employer plan, and long-term care premiums) are deducted in full. Each person carries their own lines 1 and 2 and a share of line 8 in proportion to their line 5 expenses, and Ohio AGI is the sum over -the members. +the members. Premiums go on line 1 if the person is eligible for neither +Medicare nor a subsidized plan of the taxpayer's or spouse's employer +(R.C. 5747.01(A)(10)(a)), and on line 3, part of line 5, otherwise. Hypothesis draws batches of tax units (single, joint, head of household or -joint with dependents), each person with wages, medical expenses, uninsured -premiums and long-term care premiums, and some heads with a business loss -that makes federal AGI negative. A seeded population of 200 units adds -breadth. Each batch runs as one vectorized simulation, and again with every -medical amount set to zero. For every tax unit: +joint with dependents), each person with wages, medical expenses, health +insurance premiums, long-term care premiums, Medicare eligibility, employer +coverage, an employer coverage offer and an employer contribution category, +and some heads with a business loss that makes federal AGI negative. A seeded +population of 200 units adds breadth. Each batch runs as one vectorized +simulation, and again with every medical amount set to zero. For every tax +unit: 1. Conservation: the members' line 8 shares sum to the return's line 8. 2. Differential: line 8 and each share equal an independent numpy worksheet, @@ -25,6 +29,11 @@ deduction, and raises each person's Ohio AGI (and joint filing credit qualifying income) by exactly their own share, so a spouse never loses qualifying income for the other spouse's expenses. +5. Partition: each person's premiums are on exactly one of lines 1 and 3, so + line 1 plus the premiums on line 3 equals the premiums paid. Line 1 is + zero for everyone on a return where the head or spouse has employer + coverage, an offer of it, or an employer paying some or all of their + premiums, and for every Medicare-eligible person. """ import numpy as np @@ -48,6 +57,12 @@ "health_insurance_premiums", "long_term_health_insurance_premiums", ] +COVERAGE = [ + "is_medicare_eligible", + "has_esi", + "offered_aca_disqualifying_esi", + "employer_contribution_to_health_insurance_premiums_category", +] amounts = st.one_of( st.just(0.0), @@ -56,6 +71,10 @@ ) wages = st.one_of(st.just(0.0), st.integers(1, 200_000).map(float)) losses = st.one_of(st.just(0.0), st.integers(-100_000, -1).map(float)) +# Weighted toward the defaults: no Medicare, no employer coverage and the NONE +# category, which says nothing about whether an employer plan exists. +CATEGORIES = ["NONE", "NONE", "NONE", "SOME", "ALL", "NA"] +rare = st.sampled_from([False, False, False, True]) @st.composite @@ -65,6 +84,12 @@ def people(draw): "other_medical_expenses": draw(amounts), "health_insurance_premiums": draw(amounts), "long_term_health_insurance_premiums": draw(amounts), + "is_medicare_eligible": draw(rare), + "has_esi": draw(rare), + "offered_aca_disqualifying_esi": draw(rare), + "employer_contribution_to_health_insurance_premiums_category": draw( + st.sampled_from(CATEGORIES) + ), } @@ -97,6 +122,12 @@ def person(): "other_medical_expenses": amount(), "health_insurance_premiums": amount(), "long_term_health_insurance_premiums": amount(), + "is_medicare_eligible": bool(rng.random() < 0.25), + "has_esi": bool(rng.random() < 0.25), + "offered_aca_disqualifying_esi": bool(rng.random() < 0.25), + "employer_contribution_to_health_insurance_premiums_category": str( + rng.choice(CATEGORIES) + ), } units = [] @@ -121,14 +152,15 @@ def _situation(units, year, *, medical): def add(name, values, role): people[name] = { - # Under 65 and not disabled, so no one is eligible for Medicare - # and worksheet line 3 is zero. "age": {"head": 45, "spouse": 43, "dependent": 10}[role], "is_tax_unit_head": role == "head", "is_tax_unit_spouse": role == "spouse", "is_tax_unit_dependent": role == "dependent", "employment_income": values["employment_income"], - "employer_contribution_to_health_insurance_premiums_category": "NONE", + **{name: values[name] for name in COVERAGE}, + # No modeled Medicare Part B premium, so the premiums are only + # the drawn ones and zeroing them removes every medical amount. + "takes_up_medicare_if_eligible": False, **{name: values[name] * medical for name in MEDICAL}, } @@ -162,6 +194,11 @@ def add(name, values, role): PERSON = [ "other_medical_expenses", + "health_insurance_premiums", + "oh_medical_care_insurance_premiums", + "is_medicare_eligible", + "has_esi", + "offered_aca_disqualifying_esi", "oh_insured_unreimbursed_medical_care_expense_amount", "oh_insured_unreimbursed_medical_care_expenses_person", "oh_uninsured_unreimbursed_medical_care_expenses", @@ -172,6 +209,7 @@ def add(name, values, role): ] TAX_UNIT = [ "adjusted_gross_income", + "oh_employer_subsidized_health_plan_eligible", "oh_insured_unreimbursed_medical_care_expenses", "oh_unreimbursed_medical_care_expense_deduction", "oh_agi", @@ -185,6 +223,9 @@ def _run(units, year, *, medical=True): for name in PERSON + TAX_UNIT } out["filer"] = np.asarray(sim.calculate("is_tax_unit_head_or_spouse", year)) + out["category"] = sim.calculate( + "employer_contribution_to_health_insurance_premiums_category", year + ).decode_to_str() out["unit"] = sim.populations["tax_unit"].members_entity_id return out @@ -199,11 +240,28 @@ def _check(units, year): line_5_person = base["oh_insured_unreimbursed_medical_care_expense_amount"] line_8 = base["oh_insured_unreimbursed_medical_care_expenses"] share = base["oh_insured_unreimbursed_medical_care_expenses_person"] + line_1 = base["oh_uninsured_unreimbursed_medical_care_expenses"] - # No one is Medicare eligible, so line 5 is each person's medical care. + # 5. Partition, against an independent reading of the worksheet. No one + # is self-employed, so the premiums are the drawn ones. + premiums = base["health_insurance_premiums"] np.testing.assert_allclose( - line_5_person, base["other_medical_expenses"], atol=TOLERANCE + base["oh_medical_care_insurance_premiums"], premiums, atol=TOLERANCE + ) + employer_plan = ( + (base["has_esi"] > 0) + | (base["offered_aca_disqualifying_esi"] > 0) + | np.isin(base["category"], ["SOME", "ALL"]) ) + return_eligible = _unit_sum(base, base["filer"] * employer_plan) > 0 + np.testing.assert_array_equal( + base["oh_employer_subsidized_health_plan_eligible"] > 0, return_eligible + ) + on_line_3 = (base["is_medicare_eligible"] > 0) | return_eligible[unit] + line_3 = line_5_person - base["other_medical_expenses"] + np.testing.assert_allclose(line_1, np.where(on_line_3, 0, premiums), atol=TOLERANCE) + np.testing.assert_allclose(line_3, np.where(on_line_3, premiums, 0), atol=TOLERANCE) + np.testing.assert_allclose(line_1 + line_3, premiums, atol=TOLERANCE) line_5 = _unit_sum(base, line_5_person) # 1. Conservation. @@ -250,13 +308,20 @@ def _check(units, year): np.testing.assert_allclose( without["oh_unreimbursed_medical_care_expense_deduction"], 0, atol=TOLERANCE ) - np.testing.assert_allclose( - without["oh_agi"] - base["oh_agi"], deduction, atol=TOLERANCE - ) + _assert_difference(without["oh_agi"], base["oh_agi"], deduction, "oh_agi") for name in ["oh_agi_person", "oh_joint_filing_credit_qualifying_income"]: - np.testing.assert_allclose( - without[name] - base[name], person_deduction, atol=TOLERANCE, err_msg=name - ) + _assert_difference(without[name], base[name], person_deduction, name) + + +def _assert_difference(minuend, subtrahend, expected, name): + # Incomes are float32, so above 131,072 the spacing between values is + # 0.016 or more, and a difference of two incomes is only exact to about + # one spacing of the larger. + spacing = np.spacing( + np.maximum(np.abs(minuend), np.abs(subtrahend)).astype(np.float32) + ) + error = np.abs(minuend - subtrahend - expected) + assert (error <= TOLERANCE + 2 * spacing).all(), name # A batch's cost is mostly per-variable overhead, so each example is a large diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/integration.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/integration.yaml index 738c898e2d0..2ac9114478a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/integration.yaml @@ -20,7 +20,9 @@ other_medical_expenses: 600 employer_contribution_to_health_insurance_premiums_category: "SOME" output: - oh_insured_unreimbursed_medical_care_expenses: 0 + # Medicare eligible, so the premiums are on line 3 whatever the employer + # paid: 500 + 600 - 10,000 * 7.5% = 350. + oh_insured_unreimbursed_medical_care_expenses: 350 - name: moop > agi * rate period: 2022 @@ -106,5 +108,7 @@ members: [head, spouse] state_code: OH output: - oh_insured_unreimbursed_medical_care_expenses: 950 + # Both are Medicare eligible, so both spouses' premiums are on line 3: + # 300 + 500 + 200 + 300 - 2,000 * 7.5% = 1,150. + oh_insured_unreimbursed_medical_care_expenses: 1_150 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.yaml index 3ddeb38614e..928bbe7e150 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.yaml @@ -9,7 +9,9 @@ output: oh_insured_unreimbursed_medical_care_expense_amount: 1_100 -- name: Medicare ineligible with moop +- name: Medicare eligible with an employer-paid plan and moop + # Line 3 takes premiums of anyone eligible for Medicare or an employer-paid + # plan, so an employer paying part of the premium does not remove them. period: 2022 input: state_code: OH @@ -18,9 +20,9 @@ other_medical_expenses: 600 employer_contribution_to_health_insurance_premiums_category: "SOME" output: - oh_insured_unreimbursed_medical_care_expense_amount: 600 + oh_insured_unreimbursed_medical_care_expense_amount: 1_100 -- name: Medicare ineligible without moop +- name: Medicare eligible with an employer-paid plan and no moop period: 2022 input: state_code: OH @@ -29,4 +31,71 @@ other_medical_expenses: 0 employer_contribution_to_health_insurance_premiums_category: "SOME" output: - oh_insured_unreimbursed_medical_care_expense_amount: 0 + oh_insured_unreimbursed_medical_care_expense_amount: 500 + +# Worksheet line 3 (2025 instructions, p. 41): premiums paid while "eligible +# for Medicare or an employer-paid health care plan through your or your +# spouse's employer", added to line 4 medical care, R.C. 5747.01(A)(10)(b). + +- name: After-tax premiums of a person with employer coverage count on line 3 + period: 2025 + input: + people: + person1: + age: 45 + has_esi: true + employment_income: 60_000 + health_insurance_premiums_without_medicare_part_b: 6_500 + other_medical_expenses: 800 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + oh_insured_unreimbursed_medical_care_expense_amount: 7_300 + +- name: Premiums with no Medicare or employer plan go to line 1, not line 3 + period: 2025 + input: + people: + person1: + age: 45 + employment_income: 60_000 + health_insurance_premiums_without_medicare_part_b: 6_000 + other_medical_expenses: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + oh_insured_unreimbursed_medical_care_expense_amount: 500 + oh_uninsured_unreimbursed_medical_care_expenses: 6_000 + +- name: Premiums paid through pre-tax payroll deductions are not deducted + # Line 44 instructions (p. 26) and FAQ Q2: no deduction for amounts paid + # with pre-tax dollars. They are excluded from federal wages instead. + period: 2025 + input: + people: + person1: + age: 45 + has_esi: true + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 500 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + irs_employment_income: 58_000 + oh_insured_unreimbursed_medical_care_expense_amount: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_employer_subsidized_health_plan_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_employer_subsidized_health_plan_eligible.yaml new file mode 100644 index 00000000000..bf0205f82c4 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_employer_subsidized_health_plan_eligible.yaml @@ -0,0 +1,102 @@ +# R.C. 5747.01(A)(10)(a) bars the full premium deduction for "any taxpayer who +# is eligible to participate in any subsidized health plan maintained by any +# employer of the taxpayer or of the taxpayer's spouse". The 2025 worksheet +# (p. 41) asks whether "you" were eligible for "an employer-paid health care +# plan through your or your spouse's employer". + +- name: Head with employer coverage + period: 2025 + input: + people: + head: + has_esi: true + spouse: + has_esi: false + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + oh_employer_subsidized_health_plan_eligible: true + +- name: Spouse with employer coverage makes the return eligible + period: 2025 + input: + people: + head: + has_esi: false + spouse: + has_esi: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + oh_employer_subsidized_health_plan_eligible: true + +- name: Offer of employer coverage the filer declined + period: 2025 + input: + state_code: OH + offered_aca_disqualifying_esi: true + output: + oh_employer_subsidized_health_plan_eligible: true + +- name: Employer pays some of the filer's premiums + period: 2025 + input: + state_code: OH + employer_contribution_to_health_insurance_premiums_category: SOME + output: + oh_employer_subsidized_health_plan_eligible: true + +- name: Employer pays all of the filer's premiums + period: 2025 + input: + state_code: OH + employer_contribution_to_health_insurance_premiums_category: ALL + output: + oh_employer_subsidized_health_plan_eligible: true + +- name: Employer pays none of the premiums and no employer coverage + period: 2025 + input: + state_code: OH + employer_contribution_to_health_insurance_premiums_category: NONE + output: + oh_employer_subsidized_health_plan_eligible: false + +- name: Employer contribution not applicable + period: 2025 + input: + state_code: OH + employer_contribution_to_health_insurance_premiums_category: NA + output: + oh_employer_subsidized_health_plan_eligible: false + +- name: Only a dependent has employer coverage + period: 2025 + input: + people: + head: + age: 50 + child: + age: 20 + is_tax_unit_dependent: true + has_esi: true + employer_contribution_to_health_insurance_premiums_category: SOME + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: OH + output: + oh_employer_subsidized_health_plan_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml new file mode 100644 index 00000000000..36c1c46fd9c --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_medical_care_insurance_premiums.yaml @@ -0,0 +1,53 @@ +# Dental, vision and health insurance premiums the taxpayer paid, for +# worksheet lines 1 and 3 (2025 instructions, p. 41), to the extent not +# otherwise deducted or excluded in computing federal AGI +# (R.C. 5747.01(A)(10)(a)-(b)). + +- name: Direct premium input + period: 2025 + input: + state_code: OH + health_insurance_premiums: 3_000 + output: + oh_medical_care_insurance_premiums: 3_000 + +- name: Non-Medicare premiums are counted once, not with the SPM component + period: 2025 + input: + state_code: OH + age: 40 + health_insurance_premiums_without_medicare_part_b: 6_500 + other_health_insurance_premiums: 6_500 + employer_sponsored_insurance_premiums: 20_000 + output: + oh_medical_care_insurance_premiums: 6_500 + +- name: Premiums paid through pre-tax payroll deductions are left out + period: 2025 + input: + state_code: OH + employment_income: 50_000 + pre_tax_health_insurance_premiums: 2_000 + output: + oh_medical_care_insurance_premiums: 0 + +- name: Premiums deducted as self-employed health insurance are left out + period: 2025 + input: + state_code: OH + is_self_employed: true + self_employment_income: 3_000 + health_insurance_premiums: 5_000 + output: + # 5,000 - min(3,000, 5,000). + oh_medical_care_insurance_premiums: 2_000 + +- name: The self-employed deduction cannot make premiums negative + period: 2025 + input: + state_code: OH + is_self_employed: true + self_employment_income: 80_000 + health_insurance_premiums: 5_000 + output: + oh_medical_care_insurance_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml index 413b070a4fe..d9eac668dff 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_uninsured_unreimbursed_medical_care_expenses.yaml @@ -27,3 +27,174 @@ employer_contribution_to_health_insurance_premiums_category: "ALL" output: oh_uninsured_unreimbursed_medical_care_expenses: 0 + +# Worksheet line 1 (2025 instructions, p. 41): premiums paid while "not +# eligible for Medicare or an employer-paid health care plan through your or +# your spouse's employer", R.C. 5747.01(A)(10)(a). + +- name: Premiums reported as non-Medicare premiums count on line 1 + # Microdata and most household inputs report premiums through + # health_insurance_premiums_without_medicare_part_b, not the direct input. + period: 2025 + input: + people: + person1: + age: 40 + employment_income: 50_000 + health_insurance_premiums_without_medicare_part_b: 6_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: 6_000 + +- name: The spouse's employer plan moves the head's private premiums off line 1 + # Ohio FAQ Q9: a taxpayer who declines the spouse's employer plan and buys + # private insurance enters those premiums on line 3. + period: 2025 + input: + people: + head: + age: 40 + health_insurance_premiums_without_medicare_part_b: 4_000 + spouse: + age: 38 + has_esi: true + employment_income: 50_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: [0, 0] + oh_insured_unreimbursed_medical_care_expense_amount: [4_000, 0] + # Line 8 = 4,000 - 0.075 * 50,000 = 250. + oh_unreimbursed_medical_care_expense_deduction: 250 + +- name: A dependent's premiums follow the filer's employer plan + period: 2025 + input: + people: + head: + age: 40 + has_esi: true + employment_income: 50_000 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums_without_medicare_part_b: 1_000 + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: [0, 0] + oh_insured_unreimbursed_medical_care_expense_amount: [0, 1_000] + +- name: A dependent's own employer coverage does not bar the filer's line 1 + # The bar is a plan "maintained by any employer of the taxpayer or of the + # taxpayer's spouse"; a dependent's employer is neither. + period: 2025 + input: + people: + head: + age: 50 + employment_income: 50_000 + health_insurance_premiums_without_medicare_part_b: 3_000 + child: + age: 20 + is_tax_unit_dependent: true + is_full_time_student: true + has_esi: true + tax_units: + tax_unit: + members: [head, child] + households: + household: + members: [head, child] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: [3_000, 0] + +- name: An offer of employer coverage the filer declined still bars line 1 + period: 2025 + input: + people: + person1: + age: 40 + employment_income: 50_000 + offered_aca_disqualifying_esi: true + health_insurance_premiums_without_medicare_part_b: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: 0 + oh_insured_unreimbursed_medical_care_expense_amount: 5_000 + +- name: Only the Medicare-eligible spouse's premiums leave line 1 + period: 2025 + input: + people: + head: + age: 66 + is_medicare_eligible: true + health_insurance_premiums: 2_000 + spouse: + age: 60 + is_medicare_eligible: false + health_insurance_premiums_without_medicare_part_b: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + oh_uninsured_unreimbursed_medical_care_expenses: [0, 3_000] + oh_insured_unreimbursed_medical_care_expense_amount: [2_000, 0] + +- name: Line 1 is reduced by the federal self-employed health insurance deduction + # Worksheet line 1 note: "You must reduce the amount you enter on this line + # by your federal self-employed health insurance deduction". + period: 2025 + input: + people: + person1: + age: 40 + is_self_employed: true + self_employment_income: 3_000 + health_insurance_premiums: 5_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OH + output: + # min(3,000 of earnings, 5,000 of premiums). + self_employed_health_insurance_ald: 3_000 + # 5,000 - 3,000. + oh_uninsured_unreimbursed_medical_care_expenses: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_unreimbursed_medical_care_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_unreimbursed_medical_care_expense_deduction.yaml index 5691fa861dc..e0016b82f27 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_unreimbursed_medical_care_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_unreimbursed_medical_care_expense_deduction.yaml @@ -143,3 +143,69 @@ # tax 342 + 2.75% * (52,350 - 26,050) = 1,065.25 (2025 instructions, p. 18). # MAGI less exemptions is 52,350, so the credit is 10% = 106.53. oh_joint_filing_credit: 106.53 + +- name: PolicyBench scenario_025, after-tax premiums of a spouse with employer coverage count on line 3 + # R.C. 5747.01(A)(10)(a) bars the full premium deduction (worksheet line 1) + # for a taxpayer eligible for a subsidized plan of the taxpayer's or the + # spouse's employer. Division (b) still counts premiums the taxpayer paid, + # not otherwise deducted or excluded, toward medical care above 7.5% of + # federal AGI. The 2025 worksheet (p. 41) puts them on line 3, for anyone + # "eligible for Medicare or an employer-paid health care plan", and Ohio's + # FAQ (Q8) says the same of after-tax premiums for an employer plan. The + # inputs are PolicyBench scenario_025's; it previously deducted nothing. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 61 + has_esi: true + hours_worked_last_week: 40 + health_insurance_premiums_without_medicare_part_b: 6_500 + # The same 6,500 under the SPM premium decomposition; not added again. + other_health_insurance_premiums: 6_500 + # Employer-paid, excluded from income under IRC 106, so not paid by + # the taxpayer. + employer_sponsored_insurance_premiums: 21_207.525390625 + other_medical_expenses: 800 + # Not prescribed drugs, so not medical care under IRC 213(b). + over_the_counter_health_expenses: 50 + spouse: + age: 57 + has_esi: true + employment_income: 62_725.29296875 + taxable_private_pension_income: 32_200 + hourly_wage: 19.450000762939453 + hours_worked_last_week: 40 + is_paid_hourly: true + bank_account_assets: 54_394.1171875 + tax_units: + tax_unit: + members: [head, spouse] + first_home_mortgage_balance: 366_000 + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + household_vehicles_value: 9_510 + output: + # 62,725.29 + 32,200. + adjusted_gross_income: 94_925.29 + # Line 1: barred by the employer plan. + oh_uninsured_unreimbursed_medical_care_expenses: [0, 0] + # Line 3 (6,500) + line 4 (800). + oh_insured_unreimbursed_medical_care_expense_amount: [7_300, 0] + # Line 8 = 7,300 - 0.075 * 94,925.29 = 7,300 - 7,119.40 = 180.60. + oh_insured_unreimbursed_medical_care_expenses: 180.60 + oh_unreimbursed_medical_care_expense_deduction: 180.60 + # 94,925.29 - 180.60. + oh_agi: 94_744.69 + # Less 2 * 1,900 exemptions (MAGI above 80,000; R.C. 5747.025). + oh_taxable_income: 90_944.69 + # R.C. 5747.02(A) for 2026: 332 + 2.75% * (90,944.69 - 26,050) = 2,116.60, + # less the 200 retirement income credit (R.C. 5747.055(B)). Was 1,921.57. + oh_income_tax_before_refundable_credits: 1_916.60 + state_income_tax_before_refundable_credits: 1_916.60 diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.py index b7adead891b..fac1790799c 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expense_amount.py @@ -8,25 +8,22 @@ class oh_insured_unreimbursed_medical_care_expense_amount(Variable): unit = USD definition_period = YEAR reference = ( - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=27", - "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(10) + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10)(b) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=25", # Line 44, pp. 25-26 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet lines 3 and 4 + "https://tax.ohio.gov/help-center/faqs/income-medical-and-health-care-expenses/income-medical-and-health-care-expenses", # Q8 and Q9: after-tax premiums go on line 3 ) defined_for = StateCode.OH def formula(person, period, parameters): - employer_premium_contribution = person( - "employer_contribution_to_health_insurance_premiums_category", - period, - ) - status = employer_premium_contribution.possible_values + premiums = person("oh_medical_care_insurance_premiums", period) medicare_eligible = person("is_medicare_eligible", period) - # Line 3 - eligible_premiums = ( - person("medical_expense_health_insurance_premiums", period) - * medicare_eligible - ) * (employer_premium_contribution == status.NONE) - # Premiums only count if the employer paid none. + employer_plan_eligible = person.tax_unit( + "oh_employer_subsidized_health_plan_eligible", period + ) + # Line 3: premiums paid while eligible for Medicare or an + # employer-paid plan. + eligible_premiums = premiums * (medicare_eligible | employer_plan_eligible) # Line 4 other_medical_expenses = person("other_medical_expenses", period) return eligible_premiums + other_medical_expenses diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses_person.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses_person.py index 03d07755a80..6386719ef97 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses_person.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses_person.py @@ -8,9 +8,9 @@ class oh_insured_unreimbursed_medical_care_expenses_person(Variable): unit = USD definition_period = YEAR reference = ( - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=27", - "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(10) + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=25", # Line 44, pp. 25-26 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet ) defined_for = StateCode.OH diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_employer_subsidized_health_plan_eligible.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_employer_subsidized_health_plan_eligible.py new file mode 100644 index 00000000000..fa6ae208be7 --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_employer_subsidized_health_plan_eligible.py @@ -0,0 +1,30 @@ +from policyengine_us.model_api import * + + +class oh_employer_subsidized_health_plan_eligible(Variable): + value_type = bool + entity = TaxUnit + label = "Ohio filer eligible for an employer-subsidized health plan" + definition_period = YEAR + reference = ( + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10)(a) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet lines 1 and 3 + ) + defined_for = StateCode.OH + + def formula(tax_unit, period, parameters): + person = tax_unit.members + # Enrolled in, or offered, employer coverage. + has_employer_coverage = person("has_esi", period) | person( + "offered_aca_disqualifying_esi", period + ) + contribution = person( + "employer_contribution_to_health_insurance_premiums_category", + period, + ) + status = contribution.possible_values + employer_pays = (contribution == status.SOME) | (contribution == status.ALL) + # The plan must be maintained by the taxpayer's or the spouse's + # employer, so a dependent's own coverage does not count. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + return tax_unit.any(head_or_spouse & (has_employer_coverage | employer_pays)) diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_medical_care_insurance_premiums.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_medical_care_insurance_premiums.py new file mode 100644 index 00000000000..3094d5705a6 --- /dev/null +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_medical_care_insurance_premiums.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class oh_medical_care_insurance_premiums(Variable): + value_type = float + entity = Person + label = "Ohio medical care insurance premiums for the unreimbursed medical care deduction" + unit = USD + definition_period = YEAR + reference = ( + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10)(a)-(b) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=25", # Line 44, pp. 25-26 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet lines 1 and 3 + ) + defined_for = StateCode.OH + + def formula(person, period, parameters): + # Premiums counted as IRC 213 medical care, as in the federal medical + # expense deduction. Premiums paid through pre-tax payroll deductions + # are a separate input, excluded from wages, so they are not included. + premiums = person("medical_expense_health_insurance_premiums", period) + # Premiums already deducted in computing federal AGI are not deducted + # again (worksheet line 1 note). + self_employed_deduction = person( + "self_employed_health_insurance_ald_person", period + ) + return max_(premiums - self_employed_deduction, 0) diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_uninsured_unreimbursed_medical_care_expenses.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_uninsured_unreimbursed_medical_care_expenses.py index bd9befac63b..166bd2ab754 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_uninsured_unreimbursed_medical_care_expenses.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_uninsured_unreimbursed_medical_care_expenses.py @@ -8,22 +8,18 @@ class oh_uninsured_unreimbursed_medical_care_expenses(Variable): unit = USD definition_period = YEAR reference = ( - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=27", - "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(10) + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10)(a) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=25", # Line 44, pp. 25-26 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet line 1 ) defined_for = StateCode.OH def formula(person, period, parameters): - not_medicare_eligible = ~person("is_medicare_eligible", period) - employer_premium_contribution = person( - "employer_contribution_to_health_insurance_premiums_category", - period, + premiums = person("oh_medical_care_insurance_premiums", period) + medicare_eligible = person("is_medicare_eligible", period) + employer_plan_eligible = person.tax_unit( + "oh_employer_subsidized_health_plan_eligible", period ) - status = employer_premium_contribution.possible_values - health_insurance_premiums = person("health_insurance_premiums", period) - # Premiums only count if the employer paid none. - eligible_health_insurance_premiums = health_insurance_premiums * ( - employer_premium_contribution == status.NONE - ) - return not_medicare_eligible * eligible_health_insurance_premiums + # Line 1: premiums paid while eligible for neither Medicare nor an + # employer-paid plan. Otherwise they go on line 3. + return premiums * ~(medicare_eligible | employer_plan_eligible) diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction.py index 2c9bbe3ffd9..ee0cc284978 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction.py @@ -8,9 +8,9 @@ class oh_unreimbursed_medical_care_expense_deduction(Variable): unit = USD definition_period = YEAR reference = ( - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 - "https://tax.ohio.gov/static/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=27", - "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(10) + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10) + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=25", # Line 44, pp. 25-26 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet ) defined_for = StateCode.OH