diff --git a/changelog.d/fix-oh-medical-deduction-per-person.fixed.md b/changelog.d/fix-oh-medical-deduction-per-person.fixed.md new file mode 100644 index 00000000000..fba13acbb74 --- /dev/null +++ b/changelog.d/fix-oh-medical-deduction-per-person.fixed.md @@ -0,0 +1 @@ +Count Ohio's unreimbursed medical care deduction once per return: each member now takes their prorated share of worksheet line 8 rather than the full return amount, and federal AGI below zero is entered as zero on worksheet line 6. 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 new file mode 100644 index 00000000000..e1be0da4318 --- /dev/null +++ b/policyengine_us/tests/core/test_oh_unreimbursed_medical_deduction_invariants.py @@ -0,0 +1,279 @@ +"""Invariants for Ohio's unreimbursed medical care deduction, R.C. 5747.01(A)(10). + +The Unreimbursed Medical Care Expenses Worksheet (2025 IT 1040 instructions, +p. 41) computes line 8 once for the return: line 5 expenses less 7.5% of +federal AGI, with AGI entered as zero if less than zero (line 6). Lines 1 and 2 +(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. + +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: + +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, + with the 7.5% rate taken from the instructions rather than the parameters. +3. Bounds: 0 <= line 8 <= line 5, and 0 <= each share <= the person's own + line 5 expenses. +4. Accounting: the tax unit deduction is line 8 plus lines 1 and 2, counted + once. Removing the medical amounts raises Ohio AGI by exactly that + 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. +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# Worksheet line 7 multiplies line 6 by 7.5% in every year's instructions +# (2022 p. 27, 2023 p. 40, 2024 p. 41, 2025 p. 41). +PUBLISHED_RATE = 0.075 +# Nothing here varies by year, so a few years suffice: each simulation costs +# far more than the checks on it. +YEARS = [2022, 2025] +SEEDED_YEAR = 2024 + +MEDICAL = [ + "other_medical_expenses", + "health_insurance_premiums", + "long_term_health_insurance_premiums", +] + +amounts = st.one_of( + st.just(0.0), + st.integers(1, 3_000).map(float), + st.integers(3_000, 40_000).map(float), +) +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)) + + +@st.composite +def people(draw): + return { + "employment_income": draw(wages), + "other_medical_expenses": draw(amounts), + "health_insurance_premiums": draw(amounts), + "long_term_health_insurance_premiums": draw(amounts), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "hoh", "joint_dependents"])) + n_dependents = draw(st.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "loss": draw(losses), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def amount(): + return float( + [0, rng.integers(1, 3_001), rng.integers(3_000, 40_001)][rng.choice(3)] + ) + + def person(): + return { + "employment_income": float(rng.integers(0, 200_001)), + "other_medical_expenses": amount(), + "health_insurance_premiums": amount(), + "long_term_health_insurance_premiums": amount(), + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "hoh", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 4)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "loss": float(-rng.integers(1, 100_001)) if rng.random() < 0.2 else 0.0, + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + } + ) + return units + + +def _situation(units, year, *, medical): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + 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] * medical for name in MEDICAL}, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head") + people[head]["self_employment_income"] = unit["loss"] + members, couple = [head], [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse") + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, dependent in enumerate(unit["dependents"]): + name = f"dependent_{i}_{j}" + add(name, dependent, "dependent") + members.append(name) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [name]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "OH"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +PERSON = [ + "other_medical_expenses", + "oh_insured_unreimbursed_medical_care_expense_amount", + "oh_insured_unreimbursed_medical_care_expenses_person", + "oh_uninsured_unreimbursed_medical_care_expenses", + "long_term_health_insurance_premiums", + "oh_unreimbursed_medical_care_expense_deduction_person", + "oh_agi_person", + "oh_joint_filing_credit_qualifying_income", +] +TAX_UNIT = [ + "adjusted_gross_income", + "oh_insured_unreimbursed_medical_care_expenses", + "oh_unreimbursed_medical_care_expense_deduction", + "oh_agi", +] + + +def _run(units, year, *, medical=True): + sim = Simulation(situation=_situation(units, year, medical=medical)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON + TAX_UNIT + } + out["filer"] = np.asarray(sim.calculate("is_tax_unit_head_or_spouse", year)) + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values, minlength=len(run["oh_agi"])) + + +def _check(units, year): + base = _run(units, year) + unit = base["unit"] + 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"] + + # No one is Medicare eligible, so line 5 is each person's medical care. + np.testing.assert_allclose( + line_5_person, base["other_medical_expenses"], atol=TOLERANCE + ) + line_5 = _unit_sum(base, line_5_person) + + # 1. Conservation. + np.testing.assert_allclose(_unit_sum(base, share), line_8, atol=TOLERANCE) + + # 2. Differential against an independent worksheet. + line_6 = np.maximum(base["adjusted_gross_income"], 0) + expected_line_8 = np.maximum(line_5 - PUBLISHED_RATE * line_6, 0) + np.testing.assert_allclose(line_8, expected_line_8, atol=TOLERANCE) + expected_share = np.divide( + line_5_person * expected_line_8[unit], + line_5[unit], + out=np.zeros_like(line_5_person), + where=line_5[unit] > 0, + ) + np.testing.assert_allclose(share, expected_share, atol=TOLERANCE) + + # 3. Bounds. + assert (line_8 >= 0).all() + assert (line_8 <= line_5 + TOLERANCE).all() + assert (share >= 0).all() + assert (share <= line_5_person + TOLERANCE).all() + + # 4. Accounting: each amount counted once, and only for its own person. + person_deduction = base["oh_unreimbursed_medical_care_expense_deduction_person"] + np.testing.assert_allclose( + person_deduction, + share + + base["oh_uninsured_unreimbursed_medical_care_expenses"] + + base["long_term_health_insurance_premiums"], + atol=TOLERANCE, + ) + deduction = base["oh_unreimbursed_medical_care_expense_deduction"] + np.testing.assert_allclose( + deduction, _unit_sum(base, person_deduction), atol=TOLERANCE + ) + + without = _run(units, year, medical=False) + np.testing.assert_allclose( + without["adjusted_gross_income"], + base["adjusted_gross_income"], + atol=TOLERANCE, + ) + 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 + ) + 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 + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.sampled_from(YEARS), st.lists(tax_units(), min_size=5, max_size=30)) +def test_oh_unreimbursed_medical_deduction_invariants(year, units): + _check(units, year) + + +def test_seeded_population(): + _check(_seeded_units(), SEEDED_YEAR) diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.yaml index 891a60ce10c..b6f9393d6dc 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.yaml @@ -35,3 +35,23 @@ adjusted_gross_income: 1_000 output: oh_insured_unreimbursed_medical_care_expenses: 0 + +- name: Negative federal AGI is entered as zero on line 6 + period: 2025 + input: + state_code: OH + oh_insured_unreimbursed_medical_care_expense_amount: 1_000 + adjusted_gross_income: -20_000 + output: + # Worksheet line 6 is max(-20,000, 0) = 0, so line 7 is 0 and line 8 is + # 1,000 - 0 = 1,000. The deduction never exceeds the expenses paid. + oh_insured_unreimbursed_medical_care_expenses: 1_000 + +- name: Negative federal AGI with no expenses gives no deduction + period: 2025 + input: + state_code: OH + oh_insured_unreimbursed_medical_care_expense_amount: 0 + adjusted_gross_income: -20_000 + output: + oh_insured_unreimbursed_medical_care_expenses: 0 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 new file mode 100644 index 00000000000..5691fa861dc --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/deductions/medical_expense/oh_unreimbursed_medical_care_expense_deduction.yaml @@ -0,0 +1,145 @@ +# The Unreimbursed Medical Care Expenses Worksheet (2025 instructions, p. 41) +# computes line 8 once for the return. Each person carries their share of it, +# in proportion to their line 5 expenses, so the return deducts it once. + +- name: Joint couple and child, head has all the expenses + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + other_medical_expenses: 6_000 + spouse: + age: 38 + employment_income: 10_000 + child: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse, child] + state_code: OH + output: + # Line 5 = 6,000; line 6 = 50,000; line 7 = 50,000 * 0.075 = 3,750; + # line 8 = 6,000 - 3,750 = 2,250, all of it the head's share. + oh_insured_unreimbursed_medical_care_expenses: 2_250 + oh_insured_unreimbursed_medical_care_expenses_person: [2_250, 0, 0] + oh_unreimbursed_medical_care_expense_deduction_person: [2_250, 0, 0] + oh_unreimbursed_medical_care_expense_deduction: 2_250 + # 40,000 - 2,250; 10,000; 0. + oh_agi_person: [37_750, 10_000, 0] + # 50,000 - 2,250. + oh_agi: 47_750 + +- name: Expenses for every member are prorated by each person's line 5 share + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + other_medical_expenses: 3_000 + spouse: + age: 38 + employment_income: 10_000 + other_medical_expenses: 1_000 + child: + age: 5 + is_tax_unit_dependent: true + other_medical_expenses: 2_000 + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse, child] + state_code: OH + output: + # Line 5 = 6,000; line 8 = 6,000 - 3,750 = 2,250. + oh_insured_unreimbursed_medical_care_expenses: 2_250 + # 2,250 * 3/6, 2,250 * 1/6, 2,250 * 2/6. + oh_unreimbursed_medical_care_expense_deduction_person: [1_125, 375, 750] + oh_unreimbursed_medical_care_expense_deduction: 2_250 + oh_agi_person: [38_875, 9_625, -750] + oh_agi: 47_750 + +- name: Uninsured and long-term care premiums stay with the person who paid them + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + other_medical_expenses: 6_000 + health_insurance_premiums: 1_200 + employer_contribution_to_health_insurance_premiums_category: NONE + spouse: + age: 38 + employment_income: 10_000 + long_term_health_insurance_premiums: 500 + child: + age: 5 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse, child] + state_code: OH + output: + # Line 1 = 1,200 (head, no Medicare or employer plan); line 2 = 500 + # (spouse); line 8 = 6,000 - 3,750 = 2,250 (head). + oh_uninsured_unreimbursed_medical_care_expenses: [1_200, 0, 0] + oh_unreimbursed_medical_care_expense_deduction_person: [3_450, 500, 0] + # Line 9 = 1,200 + 500 + 2,250. + oh_unreimbursed_medical_care_expense_deduction: 3_950 + oh_agi: 46_050 + +- name: The head's medical deduction does not reduce the spouse's joint filing credit income + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 60_000 + other_medical_expenses: 10_000 + spouse: + age: 38 + employment_income: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + # Line 5 = 10,000; line 7 = 62,000 * 0.075 = 4,650; line 8 = 5,350. + oh_unreimbursed_medical_care_expense_deduction_person: [5_350, 0] + oh_unreimbursed_medical_care_expense_deduction: 5_350 + oh_agi: 56_650 + # The spouse keeps 2,000 of qualifying income, above the 500 threshold. + oh_joint_filing_credit_qualifying_income: [54_650, 2_000] + oh_joint_filing_credit_eligible: true + # Exemptions 2 * 2,150 = 4,300 (p. 17, MAGI 40,001-80,000), taxable 52,350, + # 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 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration_2025.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration_2025.yaml index 3f85a33e02d..7490f01777e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration_2025.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/integration_2025.yaml @@ -266,9 +266,15 @@ members: [person1, person2] state_code: OH output: - oh_taxable_income: 35_038.3 - oh_income_tax_before_non_refundable_credits: 579.18 + # Unreimbursed medical care worksheet: line 3 is each spouse's Medicare + # Part B premium, 2 * 2,434.80 = 4,869.60; federal AGI is 40,000 + + # 23,850 taxable Social Security = 63,850, so line 7 is 4,788.75 and + # line 8 is 80.85, deducted once for the return. + # Ohio AGI = 63,850 - 23,850 - 80.85 = 39,919.15; less 2 * 2,400 exemptions. + oh_taxable_income: 35_119.15 + # 332.00 + 2.75% * (35,119.15 - 26,050). + oh_income_tax_before_non_refundable_credits: 581.40 oh_retirement_credit_potential: 200 oh_retirement_credit: 200 oh_non_refundable_credits: 250 - state_income_tax: 329.18 + state_income_tax: 331.40 diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.py index 56f9870f755..3a9acf8f65b 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/insured_unreimbursed_expenses/oh_insured_unreimbursed_medical_care_expenses.py @@ -8,9 +8,9 @@ class oh_insured_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://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10)(b) ) defined_for = StateCode.OH @@ -21,8 +21,8 @@ def formula(tax_unit, period, parameters): period, ["oh_insured_unreimbursed_medical_care_expense_amount"], ) - # Line 6 - federal_agi = tax_unit("adjusted_gross_income", period) + # Line 6: federal AGI, entered as zero if less than zero. + federal_agi = max_(tax_unit("adjusted_gross_income", period), 0) # Can deduct medical expenses in excess of 7.5% of federal AGI. # Line 7 diff --git a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction_person.py b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction_person.py index d20a345b203..5dc4426d205 100644 --- a/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction_person.py +++ b/policyengine_us/variables/gov/states/oh/tax/income/deductions/medical_exepenses/oh_unreimbursed_medical_care_expense_deduction_person.py @@ -8,14 +8,17 @@ class oh_unreimbursed_medical_care_expense_deduction_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://dam.assets.ohio.gov/image/upload/tax.ohio.gov/forms/ohio_individual/individual/2022/it1040-sd100-instruction-booklet.pdf#page=18", # Line 36 + "https://dam.assets.ohio.gov/image/upload/v1767095693/tax.ohio.gov/forms/ohio_individual/individual/2025/it1040-booklet.pdf#page=41", # Worksheet + "https://codes.ohio.gov/ohio-revised-code/section-5747.01", # R.C. 5747.01(A)(10) ) defined_for = StateCode.OH + # The worksheet computes line 8 once for the return. Each person takes + # their share of it, so the shares sum to the return's amount. Listing + # the tax unit amount here would give every member the full amount. adds = [ - "oh_insured_unreimbursed_medical_care_expenses", + "oh_insured_unreimbursed_medical_care_expenses_person", "oh_uninsured_unreimbursed_medical_care_expenses", "long_term_health_insurance_premiums", ]