diff --git a/changelog.d/schedule-a-pretax-premium-tests.changed.md b/changelog.d/schedule-a-pretax-premium-tests.changed.md new file mode 100644 index 00000000000..8d0235f6187 --- /dev/null +++ b/changelog.d/schedule-a-pretax-premium-tests.changed.md @@ -0,0 +1 @@ +Add tests confirming that Schedule A medical expenses exclude health insurance premiums paid through pre-tax payroll deductions. diff --git a/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py new file mode 100644 index 00000000000..2d158a813f6 --- /dev/null +++ b/policyengine_us/tests/core/test_schedule_a_pre_tax_premium_invariants.py @@ -0,0 +1,264 @@ +"""Invariants for pre-tax payroll premiums and Schedule A medical expenses. + +IRC 213(a) allows "the expenses paid during the taxable year, not compensated +for by insurance or otherwise, for medical care", above 7.5% of adjusted gross +income. A premium paid by salary reduction under a section 125 cafeteria plan +is paid by the employer, not the employee, and is excluded from wages under +IRC 106(a) (Rev. Rul. 2002-3), so it is not an expense the taxpayer paid. + +pre_tax_health_insurance_premiums holds those premiums. The other premium +inputs hold premiums not paid that way, and the two are disjoint: a premium is +reported in one or the other, never both. + +Hypothesis draws batches of single and joint filers, each person with wages, +pre-tax premiums, after-tax premiums and other medical expenses. The after-tax +premiums are supplied either directly or as non-Medicare premiums. Each batch +runs as one vectorized simulation, again with every pre-tax premium removed, +and again with every pre-tax premium paid after tax instead. + +The invariants are stated for these filers only: wage earners under 65 who pay +their own costs, with no self-employment (so no self-employed health insurance +deduction), no Medicare premiums and no reimbursed expenses. Dependents are +left out, because whether a dependent's own payments belong on the filer's +Schedule A is a separate question. + +The YAML cases in itemized_medical_expenses.yaml pin hand-computed households. This +file adds what YAML cannot express: relations between paired runs of the same +generated households (invariant 5), and the same checks across a mixed batch +in one vectorized simulation. For every batch: + +1. Exclusion: Schedule A medical expenses equal the filers' after-tax + premiums plus other medical expenses, whatever the pre-tax premiums are. +2. One tax benefit per dollar: each person's wage exclusion is their pre-tax + premiums, up to their wages, and their Schedule A premiums are their + after-tax premiums. The two never cover the same dollar, and together they + never exceed the premiums paid. +3. Differential: the deduction equals an independent numpy calculation, with + the 7.5% floor taken from the statute rather than the parameters. +4. Bounds: 0 <= deduction <= Schedule A medical expenses. +5. Payment method: paying the pre-tax premiums after tax instead raises + Schedule A medical expenses by exactly those premiums and wages by exactly + the wage exclusion, and never lowers the deduction. Removing the pre-tax + premiums leaves Schedule A medical expenses unchanged and never raises the + deduction. +""" + +import numpy as np +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.1 # dollars; incomes are float32 + +# IRC 213(a): "to the extent that such expenses exceed 7.5 percent of +# adjusted gross income". The rate has applied since 2017. +STATUTORY_FLOOR = 0.075 +YEARS = [2024, 2026] + +amounts = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) +wages = st.one_of(st.just(0.0), st.integers(1, 200_000).map(float)) + + +@st.composite +def people(draw): + return { + "wages": draw(wages), + "pre_tax": draw(amounts), + "after_tax": draw(amounts), + "other": draw(st.one_of(st.just(0.0), st.integers(1, 40_000).map(float))), + # Which input carries the after-tax premiums. + "direct": draw(st.booleans()), + } + + +@st.composite +def tax_units(draw): + return {"head": draw(people()), "spouse": draw(st.none() | people())} + + +def _person(wages=0.0, pre_tax=0.0, after_tax=0.0, other=0.0, direct=False): + return { + "wages": wages, + "pre_tax": pre_tax, + "after_tax": after_tax, + "other": other, + "direct": direct, + } + + +def _situation(units, year, treatment): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + + def add(name, values, role): + pre_tax = values["pre_tax"] if treatment == "drawn" else 0 + after_tax = values["after_tax"] + if treatment == "paid_after_tax": + after_tax += values["pre_tax"] + people[name] = { + "age": {"head": 45, "spouse": 43}[role], + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "employment_income": values["wages"], + "pre_tax_health_insurance_premiums": pre_tax, + "health_insurance_premiums": after_tax * values["direct"], + "health_insurance_premiums_without_medicare_part_b": after_tax + * (not values["direct"]), + "other_medical_expenses": values["other"], + # No modeled Medicare Part B premium, so the premiums are only + # the drawn ones. + "medicare_enrolled": False, + } + + for i, unit in enumerate(units): + head = f"head_{i}" + add(head, unit["head"], "head") + members = [head] + if unit["spouse"] is not None: + spouse = f"spouse_{i}" + add(spouse, unit["spouse"], "spouse") + members.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +PERSON = [ + "employment_income", + "irs_employment_income", + "medical_expense_health_insurance_premiums", +] +TAX_UNIT = [ + "adjusted_gross_income", + "itemized_medical_expenses", + "medical_expense_deduction", +] + + +def _run(units, year, treatment): + sim = Simulation(situation=_situation(units, year, treatment)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON + TAX_UNIT + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + return out + + +def _members(units): + return [ + person + for unit in units + for person in [unit["head"], unit["spouse"]] + if person is not None + ] + + +def _check(units, year): + drawn = _run(units, year, "drawn") + unit = drawn["unit"] + members = _members(units) + pre_tax = np.array([person["pre_tax"] for person in members]) + after_tax = np.array([person["after_tax"] for person in members]) + other = np.array([person["other"] for person in members]) + earned = np.array([person["wages"] for person in members]) + np.testing.assert_array_equal(drawn["employment_income"], earned) + + def unit_sum(values): + return np.bincount(unit, weights=values, minlength=len(units)) + + expenses = drawn["itemized_medical_expenses"] + deduction = drawn["medical_expense_deduction"] + + # 1. Exclusion, against the drawn amounts. + np.testing.assert_allclose(expenses, unit_sum(after_tax + other), atol=TOLERANCE) + + # 2. One tax benefit per dollar. + wage_exclusion = earned - drawn["irs_employment_income"] + schedule_a_premiums = drawn["medical_expense_health_insurance_premiums"] + np.testing.assert_allclose( + wage_exclusion, np.minimum(pre_tax, earned), atol=TOLERANCE + ) + np.testing.assert_allclose(schedule_a_premiums, after_tax, atol=TOLERANCE) + assert ( + wage_exclusion + schedule_a_premiums <= pre_tax + after_tax + TOLERANCE + ).all() + + # 3. Differential against an independent calculation. + agi = drawn["adjusted_gross_income"] + expected = np.maximum( + unit_sum(after_tax + other) - STATUTORY_FLOOR * np.maximum(agi, 0), 0 + ) + np.testing.assert_allclose(deduction, expected, atol=TOLERANCE) + + # 4. Bounds. + assert (deduction >= 0).all() + assert (deduction <= expenses + TOLERANCE).all() + + # 5. Payment method. + removed = _run(units, year, "removed") + np.testing.assert_allclose( + removed["itemized_medical_expenses"], expenses, atol=TOLERANCE + ) + np.testing.assert_allclose(removed["irs_employment_income"], earned, atol=TOLERANCE) + assert (removed["medical_expense_deduction"] <= deduction + TOLERANCE).all() + + paid_after_tax = _run(units, year, "paid_after_tax") + np.testing.assert_allclose( + paid_after_tax["itemized_medical_expenses"] - expenses, + unit_sum(pre_tax), + atol=TOLERANCE, + ) + np.testing.assert_allclose( + paid_after_tax["irs_employment_income"], earned, atol=TOLERANCE + ) + assert (paid_after_tax["medical_expense_deduction"] >= deduction - TOLERANCE).all() + + +# Each example is a batch of filers run three ways, at about 0.1 seconds. +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + +EDGE_CASES = [ + # Nothing paid. + {"head": _person(wages=50_000), "spouse": None}, + # Pre-tax premiums only. + {"head": _person(wages=60_000, pre_tax=2_000, other=9_000), "spouse": None}, + # Pre-tax premiums above wages: the wage exclusion stops at wages. + { + "head": _person(wages=1_000, pre_tax=2_500, after_tax=400, direct=True), + "spouse": None, + }, + # Each spouse pays a different way. + { + "head": _person(wages=50_000, pre_tax=2_400, other=6_000), + "spouse": _person(wages=30_000, after_tax=1_800, other=1_000, direct=True), + }, + # One spouse pays both ways. + { + "head": _person(wages=40_000, pre_tax=600, after_tax=300, other=250), + "spouse": _person(), + }, +] + + +@settings(**SETTINGS) +@example(year=2025, units=EDGE_CASES) +@given( + year=st.sampled_from(YEARS), + units=st.lists(tax_units(), min_size=5, max_size=30), +) +def test_schedule_a_pre_tax_premium_invariants(year, units): + _check(units, year) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml index e9dfb1c6311..580d1f7e672 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.yaml @@ -11,3 +11,163 @@ members: [person1, person2] output: itemized_medical_expenses: 1_500 + +# Premiums paid through pre-tax payroll deductions. +# +# IRC 213(a) allows "the expenses paid during the taxable year, not +# compensated for by insurance or otherwise, for medical care". A premium paid +# by salary reduction under a section 125 cafeteria plan is paid by the +# employer, not the employee, and is excluded from wages under IRC 106(a) +# (Rev. Rul. 2002-3). It is therefore not an expense the taxpayer paid. +# IRS Publication 502 (2025), "Employer-Sponsored Health Insurance Plan": +# "Don't include in your medical and dental expenses any insurance premiums +# paid by an employer-sponsored health insurance plan unless the premiums are +# included on your Form W-2, Wage and Tax Statement." +# +# pre_tax_health_insurance_premiums and the other premium inputs are disjoint: +# a premium is reported in one or the other, never both. Schedule A therefore +# counts the other premium inputs in full and never the pre-tax input. + +- name: Pre-tax payroll premiums reduce wages and are not Schedule A medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 9_000 + output: + # 60,000 - 2,000, for income tax and for FICA. + irs_employment_income: 58_000 + payroll_tax_gross_wages: 58_000 + adjusted_gross_income: 58_000 + medical_expense_health_insurance_premiums: 0 + # Counting the premium again would give 11,000. + itemized_medical_expenses: 9_000 + # 9,000 - 7.5% of 58,000. + medical_expense_deduction: 4_650 + +- name: A worker who pays premiums both ways counts only the after-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums_without_medicare_part_b: 3_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + adjusted_gross_income: 58_000 + # The 3,000 is not reduced by the 2,000: the inputs do not overlap. + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + # 12,000 - 7.5% of 58,000. + medical_expense_deduction: 7_650 + +- name: The directly supplied premium input likewise excludes pre-tax premiums. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + health_insurance_premiums: 3_000 + other_medical_expenses: 9_000 + output: + medical_expense_health_insurance_premiums: 3_000 + itemized_medical_expenses: 12_000 + medical_expense_deduction: 7_650 + +- name: The same premiums paid after tax are medical expenses and do not reduce wages. + period: 2025 + input: + age: 40 + employment_income: 60_000 + health_insurance_premiums_without_medicare_part_b: 5_000 + other_medical_expenses: 9_000 + output: + irs_employment_income: 60_000 + payroll_tax_gross_wages: 60_000 + adjusted_gross_income: 60_000 + medical_expense_health_insurance_premiums: 5_000 + itemized_medical_expenses: 14_000 + # 14,000 - 7.5% of 60,000. + medical_expense_deduction: 9_500 + +- name: A working Medicare enrollee counts Part B premiums but not pre-tax payroll premiums. + period: 2025 + input: + age: 67 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + medicare_enrolled: true + medicare_part_b_premium: 2_220 + other_medical_expenses: 9_000 + output: + irs_employment_income: 58_000 + medical_expense_health_insurance_premiums: 2_220 + itemized_medical_expenses: 11_220 + # 11,220 - 7.5% of 58,000. + medical_expense_deduction: 6_870 + +- name: Joint filers count one spouse's after-tax premiums and not the other's pre-tax premiums. + period: 2025 + input: + people: + person1: + age: 40 + is_tax_unit_head: true + employment_income: 50_000 + pre_tax_health_insurance_premiums: 2_400 + other_medical_expenses: 6_000 + person2: + age: 40 + is_tax_unit_spouse: true + employment_income: 30_000 + health_insurance_premiums_without_medicare_part_b: 1_800 + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [person1, person2] + marital_units: + marital_unit: + members: [person1, person2] + output: + irs_employment_income: [47_600, 30_000] + adjusted_gross_income: 77_600 + medical_expense_health_insurance_premiums: [0, 1_800] + # 6,000 + 1,000 + 1,800. + itemized_medical_expenses: 8_800 + # 8,800 - 7.5% of 77,600. + medical_expense_deduction: 2_980 + +- name: An itemizer with pre-tax premiums deducts only the other medical expenses. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + other_medical_expenses: 30_000 + # Isolate the medical deduction from modeled state and local taxes. + salt_deduction: 0 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 30_000 + # 30,000 - 7.5% of 58,000. + medical_expense_deduction: 25_650 + tax_unit_itemizes: true + # 58,000 - 25,650. Counting the premium again would give 30,350. + taxable_income: 32_350 + +- name: A non-itemizer with pre-tax premiums gets the wage exclusion and the standard deduction. + period: 2025 + input: + age: 40 + employment_income: 60_000 + pre_tax_health_insurance_premiums: 2_000 + output: + adjusted_gross_income: 58_000 + itemized_medical_expenses: 0 + medical_expense_deduction: 0 + tax_unit_itemizes: false + standard_deduction: 15_750 + # 58,000 - 15,750. + taxable_income: 42_250