diff --git a/changelog.d/fix-mt-medical-premiums-in-full.fixed.md b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md new file mode 100644 index 00000000000..1be85896d4b --- /dev/null +++ b/changelog.d/fix-mt-medical-premiums-in-full.fixed.md @@ -0,0 +1 @@ +Deduct Montana's 2021–2023 medical and long-term care insurance premiums in full on the Itemized Deductions Schedule (lines 2 and 3), net of premiums taken as the self-employed health insurance deduction, and apply the 7.5% floor only to other medical and dental expenses. diff --git a/changelog.d/hub-schedA-se-premiums-1010.fixed.md b/changelog.d/hub-schedA-se-premiums-1010.fixed.md new file mode 100644 index 00000000000..2e6af14fa53 --- /dev/null +++ b/changelog.d/hub-schedA-se-premiums-1010.fixed.md @@ -0,0 +1 @@ +Exclude self-employed health insurance premiums already deducted above the line from federal and applicable state itemized medical expenses, cap New Jersey dependent exclusions per payer, preserve North Dakota and New Mexico gross medical costs and caller-supplied subtotals by restoring only exclusions applied by the federal formula, and exclude already-deducted premiums from Missouri's qualifying pool using its required whole-percent overlap ratio. Require policyengine-core>=3.32.27 so deleting and recalculating a supplied medical subtotal also preserves correct North Dakota and New Mexico results. diff --git a/policyengine_us/tests/core/test_medical_expense_premium_conservation.py b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py new file mode 100644 index 00000000000..6cffa421527 --- /dev/null +++ b/policyengine_us/tests/core/test_medical_expense_premium_conservation.py @@ -0,0 +1,550 @@ +"""Premium conservation between Schedule A and the section 162(l) deduction. + +IRC 162(l)(3) excludes premiums deducted under section 162(l) from medical +expenses under section 213. Hypothesis varies earnings caps, covered premium +subsets, direct/decomposed premium inputs, spouses, dependents and separate +tax units. +Premium inputs use payer attribution: a person's inputs include all premiums +that person paid, including family coverage regardless of who is covered. +The federal conservation properties generate deductions within the filers' +paid premiums; beneficiary-assigned inputs cannot establish that invariant. +The independent accounting invariant is that Schedule A's premium base plus +the self-employed health insurance deduction never exceeds premiums paid. +Non-premium medical expenses must remain available in full before the floor. + +Examples use small vectorized simulations and a shared read-only reference +system. Supplied and computed subtotal scenarios use separate simulations to +preserve their input provenance. YAML covers the individual policy examples; +these properties check conservation across generated combinations and +filing-unit boundaries. +""" + +import numpy as np +import pytest +from hypothesis import example, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.system import system + +PERIOD = "2025" + + +@pytest.fixture(scope="module") +def reference_system(): + return system + + +@st.composite +def person_expenses(draw): + premiums = draw(st.integers(0, 25_000)) + direct = draw(st.booleans()) + self_employed = draw(st.booleans()) + covered = draw(st.integers(0, premiums)) if self_employed else 0 + # A direct premium can also exercise the default derivation of the SE + # premium input. Decomposed premiums need the covered subset explicitly. + if direct and draw(st.booleans()): + covered = None + return { + "premiums": premiums, + "other": draw(st.integers(0, 10_000)), + "earnings": draw(st.integers(-25_000, 50_000)) if self_employed else 0, + "self_employed": self_employed, + "direct": direct, + "covered": covered, + } + + +units = st.lists( + st.lists(person_expenses(), min_size=1, max_size=3), + min_size=2, + max_size=4, +) + + +@st.composite +def units_with_deduction(draw): + members = draw(units) + deductions = [ + # Payer attribution puts parent-paid family premiums on that parent. + # The head's and spouse's deduction covers their paid premium pool. A + # dependent's own SE deduction belongs on their separate return. + draw(st.integers(0, sum(person["premiums"] for person in unit[:2]))) + for unit in members + ] + return members, deductions + + +def _simulation(members, reference_system, deductions=None): + people, tax_units, households, marital_units = {}, {}, {}, {} + for unit_index, unit in enumerate(members): + names = [] + for person_index, person in enumerate(unit): + name = f"person_{unit_index}_{person_index}" + names.append(name) + premium_variable = ( + "health_insurance_premiums" + if person["direct"] + else "health_insurance_premiums_without_medicare_part_b" + ) + values = { + "age": 12 if person_index == 2 else 40, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + "is_tax_unit_dependent": person_index == 2, + "is_self_employed": person["self_employed"], + "self_employment_income": person["earnings"], + "medicare_enrolled": False, + premium_variable: person["premiums"], + "other_medical_expenses": person["other"], + } + if person["covered"] is not None: + values["self_employed_health_insurance_premiums"] = person["covered"] + people[name] = { + variable: {PERIOD: value} for variable, value in values.items() + } + tax_unit = {"members": names} + if deductions is not None: + tax_unit["self_employed_health_insurance_ald"] = { + PERIOD: deductions[unit_index] + } + tax_units[f"tax_unit_{unit_index}"] = tax_unit + households[f"household_{unit_index}"] = { + "members": names, + "state_code": {PERIOD: "TX"}, + } + marital_units[f"marital_unit_{unit_index}"] = {"members": names[:2]} + for name in names[2:]: + marital_units[f"dependent_marital_unit_{unit_index}"] = {"members": [name]} + return Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + + +def _assert_conservation(members, simulation): + paid = np.array([sum(person["premiums"] for person in unit) for unit in members]) + other = np.array([sum(person["other"] for person in unit) for unit in members]) + dependent_premiums = np.array( + [sum(person["premiums"] for person in unit[2:]) for unit in members] + ) + medical_base = simulation.calculate("itemized_medical_expenses", PERIOD) + se_deduction = simulation.calculate("self_employed_health_insurance_ald", PERIOD) + schedule_a_premium_use = medical_base - other + + # Neither the SE deduction nor a different filing unit can consume the + # non-premium expenses. Check the bound separately from conservation. + assert (schedule_a_premium_use >= 0).all() + assert (schedule_a_premium_use >= dependent_premiums).all() + assert (schedule_a_premium_use + se_deduction <= paid).all() + # All premiums here are otherwise eligible: unused premiums remain in + # Schedule A's base, even when earnings cap the SE deduction. + np.testing.assert_array_equal(schedule_a_premium_use + se_deduction, paid) + + +EXAMPLE_UNITS = [ + [ + dict( + premiums=6_000, + other=500, + earnings=1_000, + self_employed=True, + direct=True, + covered=None, + ), + dict( + premiums=2_000, + other=300, + earnings=5_000, + self_employed=True, + direct=False, + covered=1_000, + ), + dict( + premiums=12_000, + other=200, + earnings=15_000, + self_employed=True, + direct=True, + covered=None, + ), + ], + [ + dict( + premiums=5_000, + other=700, + earnings=-2_000, + self_employed=True, + direct=True, + covered=None, + ) + ], +] + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example(members=EXAMPLE_UNITS) +@given(members=units) +def test_premiums_are_not_duplicated_with_derived_se_deduction( + reference_system, members +): + _assert_conservation(members, _simulation(members, reference_system)) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example(case=(EXAMPLE_UNITS, [8_000, 0])) +@given(case=units_with_deduction()) +def test_premiums_are_not_duplicated_with_tax_unit_se_deduction(reference_system, case): + members, deductions = case + _assert_conservation( + members, _simulation(members, reference_system, deductions=deductions) + ) + + +@st.composite +def nj_dependent_expenses(draw): + return { + "premiums": draw(st.integers(0, 25_000)), + "other": draw(st.integers(0, 10_000)), + # Independent person ALDs deliberately include amounts above the + # person's paid medical premiums, as supported by separate SE inputs. + "deduction": draw(st.integers(0, 50_000)), + } + + +nj_units = st.lists( + st.lists(nj_dependent_expenses(), min_size=2, max_size=4), + min_size=2, + max_size=4, +) + + +def _nj_simulation(members, reference_system): + people, tax_units, households, marital_units = {}, {}, {}, {} + for unit_index, unit in enumerate(members): + parent = f"parent_{unit_index}" + names = [parent] + people[parent] = {"age": {PERIOD: 40}} + marital_units[f"parent_marital_unit_{unit_index}"] = {"members": [parent]} + for person_index, person in enumerate(unit): + name = f"dependent_{unit_index}_{person_index}" + names.append(name) + people[name] = { + variable: {PERIOD: value} + for variable, value in { + "age": 17, + "is_tax_unit_dependent": True, + "medicare_enrolled": False, + "health_insurance_premiums": person["premiums"], + "other_medical_expenses": person["other"], + "self_employed_health_insurance_ald_person": person["deduction"], + }.items() + } + marital_units[f"dependent_marital_unit_{unit_index}_{person_index}"] = { + "members": [name] + } + tax_units[f"tax_unit_{unit_index}"] = { + "members": names, + "self_employed_health_insurance_ald": {PERIOD: 0}, + "nj_agi": {PERIOD: 0}, + } + households[f"household_{unit_index}"] = { + "members": names, + "state_code": {PERIOD: "NJ"}, + } + return Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + members=[ + [ + dict(premiums=0, other=0, deduction=1_000), + dict(premiums=1_000, other=0, deduction=0), + ], + [ + dict(premiums=300, other=200, deduction=800), + dict(premiums=500, other=100, deduction=0), + ], + ] +) +@given(members=nj_units) +def test_nj_excludes_each_dependents_deduction_only_from_their_own_premiums( + reference_system, members +): + simulation = _nj_simulation(members, reference_system) + paid = np.array([sum(person["premiums"] for person in unit) for unit in members]) + other = np.array([sum(person["other"] for person in unit) for unit in members]) + deductions = np.array( + [sum(person["deduction"] for person in unit) for unit in members] + ) + person_exclusions = [ + [min(person["deduction"], person["premiums"]) for person in unit] + for unit in members + ] + for unit, exclusions in zip(members, person_exclusions): + assert all( + 0 <= exclusion <= person["premiums"] + for person, exclusion in zip(unit, exclusions) + ) + excluded = np.array([sum(exclusions) for exclusions in person_exclusions]) + nj_deduction = simulation.calculate("nj_medical_expense_deduction", PERIOD) + actual_exclusion = paid + other + deductions - nj_deduction + + # C.54A:3-3(c)(3) excludes only amounts separately deducted. Each person's + # exclusion is bounded before summation, so no ALD can consume a different + # person's premiums. This property also covers ALDs above own paid costs. + # https://pub.njleg.gov/bills/9899/PL99/222_.HTM + assert (actual_exclusion >= 0).all() + assert (actual_exclusion <= excluded).all() + np.testing.assert_array_equal(actual_exclusion, excluded) + np.testing.assert_array_equal(nj_deduction, paid + other + deductions - excluded) + + +# Flat strategies keep generation small while varying independent caller +# subtotals, component inputs, filing units and the senior-benefit gates. +state_expenses = st.fixed_dictionaries( + { + "premiums": st.tuples(*(st.integers(0, 25_000) for _ in range(3))), + "other": st.tuples(*(st.integers(0, 30_000) for _ in range(3))), + "direct": st.tuples(*(st.booleans() for _ in range(3))), + "income": st.integers(0, 100_000), + "age": st.sampled_from([64, 65, 80]), + "filing_status": st.sampled_from(["SINGLE", "JOINT", "SEPARATE"]), + "dependent_elsewhere": st.booleans(), + "subtotal_mode": st.sampled_from(["derived", "supplied", "subtotal_only"]), + "subtotal": st.one_of( + st.integers(0, 100_000), st.sampled_from([27_999, 28_000, 28_001]) + ), + "deduction": st.integers(-10_000, 75_000), + } +) +state_units = st.lists(state_expenses, min_size=2, max_size=3) + + +def _state_case(**values): + return { + "premiums": (0, 0, 0), + "other": (0, 0, 0), + "direct": (True, True, True), + "income": 30_000, + "age": 65, + "filing_status": "SINGLE", + "dependent_elsewhere": False, + "subtotal_mode": "derived", + "subtotal": 0, + "deduction": 1_000, + **values, + } + + +def _state_simulation(cases, reference_system, with_se_deduction): + people, tax_units, households, marital_units = {}, {}, {}, {} + expectations = { + "nd_renters_refund_income": [], + "nm_medical_expense_credit": [], + "nm_medical_expense_exemption": [], + } + accounting = { + "main_base": [], + "gross": [], + "other": [], + "filer_premiums": [], + "excluded": [], + "applied_exclusion": [], + } + for case_index, case in enumerate(cases): + joint = case["filing_status"] == "JOINT" + person_indices = [0, 1, 2] if joint else [0, 2] + subtotal_only = case["subtotal_mode"] == "subtotal_only" + premiums = [0 if subtotal_only else case["premiums"][i] for i in person_indices] + other = [0 if subtotal_only else case["other"][i] for i in person_indices] + deduction = case["deduction"] if with_se_deduction else 0 + filer_premiums = premiums[0] + (premiums[1] if joint else 0) + excluded = min(filer_premiums, max(0, deduction)) + gross = sum(premiums) + sum(other) + supplied = case["subtotal_mode"] != "derived" + # Caller subtotals are independent of their component inputs and SE + # deduction. The oracle preserves the supplied amount exactly as main + # does; only the computed path restores an applied federal exclusion. + main_base = case["subtotal"] if supplied else gross + senior_eligible = case["age"] >= 65 and main_base >= 28_000 + denominator = 2 if case["filing_status"] == "SEPARATE" else 1 + + for state in ("ND", "NM"): + unit_name = f"{state}_{case_index}" + names = [] + for position, person_index in enumerate(person_indices): + name = f"{unit_name}_person_{person_index}" + names.append(name) + premium_variable = ( + "health_insurance_premiums" + if case["direct"][person_index] + else "health_insurance_premiums_without_medicare_part_b" + ) + values = { + "age": case["age"] + if person_index == 0 + else 40 + if person_index == 1 + else 17, + "is_tax_unit_head": person_index == 0, + "is_tax_unit_spouse": person_index == 1, + "is_tax_unit_dependent": person_index == 2, + "employment_income": case["income"] if person_index == 0 else 0, + "medicare_enrolled": False, + premium_variable: premiums[position], + "other_medical_expenses": other[position], + } + people[name] = { + variable: {PERIOD: value} for variable, value in values.items() + } + if person_index == 2: + marital_units[f"{unit_name}_dependent_marital_unit"] = { + "members": [name] + } + marital_units[f"{unit_name}_marital_unit"] = {"members": names[:-1]} + tax_unit = { + "members": names, + "self_employed_health_insurance_ald": {PERIOD: deduction}, + "filing_status": {PERIOD: case["filing_status"]}, + "head_is_dependent_elsewhere": {PERIOD: case["dependent_elsewhere"]}, + } + if supplied: + tax_unit["itemized_medical_expenses"] = {PERIOD: case["subtotal"]} + tax_units[unit_name] = tax_unit + households[unit_name] = {"members": names, "state_code": {PERIOD: state}} + accounting["main_base"].append(main_base) + accounting["gross"].append(gross) + accounting["other"].append(sum(other)) + accounting["filer_premiums"].append(filer_premiums) + accounting["excluded"].append(excluded) + accounting["applied_exclusion"].append(0 if supplied else excluded) + # This independent accounting oracle reproduces main's state + # outputs from its paid-cost base rather than reading this head's + # medical subtotal or excluded-premium calculations as expectations. + expectations["nd_renters_refund_income"].append( + max(0, case["income"] - main_base) if state == "ND" else 0 + ) + expectations["nm_medical_expense_credit"].append( + 2_800 / denominator + if state == "NM" and senior_eligible and not case["dependent_elsewhere"] + else 0 + ) + expectations["nm_medical_expense_exemption"].append( + 3_000 / denominator if state == "NM" and senior_eligible else 0 + ) + simulation = Simulation( + tax_benefit_system=reference_system, + situation={ + "people": people, + "tax_units": tax_units, + "households": households, + "marital_units": marital_units, + }, + ) + return simulation, expectations, accounting + + +def _assert_state_outputs(cases, reference_system, with_se_deduction): + # Core records input provenance for an entire tax-unit vector. Keep supplied + # and computed cases in separate simulations so computed rows are never + # turned into supplied inputs by filling an otherwise partial input vector. + for supplied in (False, True): + same_provenance = [ + case for case in cases if (case["subtotal_mode"] != "derived") == supplied + ] + if not same_provenance: + continue + simulation, expectations, accounting = _state_simulation( + same_provenance, reference_system, with_se_deduction + ) + medical_base = simulation.calculate("itemized_medical_expenses", PERIOD) + applied_exclusion = simulation.calculate( + "itemized_medical_expenses_applied_exclusion", PERIOD + ) + np.testing.assert_array_equal( + applied_exclusion, accounting["applied_exclusion"] + ) + np.testing.assert_array_equal( + medical_base + applied_exclusion, accounting["main_base"] + ) + if not supplied: + excluded = simulation.calculate( + "itemized_medical_expenses_excluded_premiums", PERIOD + ) + # Computed Schedule A costs conserve the full paid amount when the + # capped premium exclusion is restored. These bounds deliberately + # do not constrain an independent caller-supplied subtotal. + np.testing.assert_array_equal(excluded, accounting["excluded"]) + np.testing.assert_array_equal(medical_base + excluded, accounting["gross"]) + assert (excluded >= 0).all() + assert (excluded <= np.array(accounting["filer_premiums"])).all() + assert (medical_base >= np.array(accounting["other"])).all() + for variable, expected in expectations.items(): + np.testing.assert_array_equal( + simulation.calculate(variable, PERIOD), expected + ) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + cases=[ + _state_case(subtotal_mode="subtotal_only", subtotal=6_500), + _state_case(subtotal_mode="subtotal_only", subtotal=28_000), + _state_case( + premiums=(6_000, 0, 0), + other=(22_000, 0, 0), + subtotal_mode="supplied", + subtotal=27_999, + ), + _state_case(premiums=(6_000, 0, 0), other=(21_999, 0, 0)), + _state_case(premiums=(6_000, 0, 0), other=(22_001, 0, 0)), + ] +) +@given(cases=state_units) +def test_state_paid_cost_bases_match_main_without_se_deduction(reference_system, cases): + _assert_state_outputs(cases, reference_system, with_se_deduction=False) + + +@settings(max_examples=12, deadline=None, derandomize=True) +@example( + cases=[ + _state_case(premiums=(6_000, 0, 0), other=(500, 0, 0)), + _state_case( + premiums=(6_000, 0, 0), + other=(500, 0, 0), + subtotal_mode="supplied", + subtotal=6_500, + ), + _state_case( + premiums=(6_000, 0, 0), + other=(22_000, 0, 0), + subtotal_mode="supplied", + subtotal=27_999, + ), + _state_case(premiums=(1_000, 0, 0), subtotal_mode="supplied", subtotal=500), + _state_case(premiums=(1_000, 0, 0), subtotal_mode="supplied", subtotal=0), + _state_case(premiums=(6_000, 0, 0), other=(21_999, 0, 0), deduction=75_000), + _state_case(subtotal_mode="subtotal_only", subtotal=28_000, deduction=75_000), + _state_case(premiums=(0, 0, 28_000), deduction=75_000), + ] +) +@given(cases=state_units) +def test_state_paid_cost_bases_preserve_main_inputs_with_se_deduction( + reference_system, cases +): + _assert_state_outputs(cases, reference_system, with_se_deduction=True) diff --git a/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py new file mode 100644 index 00000000000..8f700ae0cae --- /dev/null +++ b/policyengine_us/tests/core/test_state_medical_expense_input_deletion.py @@ -0,0 +1,86 @@ +"""State paid-cost bases after a supplied medical subtotal is deleted. + +The intact inputs remain supplied. Deleted inputs must instead use the +recomputed subtotal and restore its federal premium exclusion. The required +Core 3.32.27 release clears input provenance when the subtotal is deleted. +""" + +import numpy as np +import pytest + +from policyengine_us import Simulation +from policyengine_us.system import system + + +PERIOD = 2025 +SUBTOTAL_DELETION_CASES = [ + pytest.param(False, id="intact-input"), + pytest.param(True, id="deleted-input"), +] + + +def _simulation(state, other_expenses): + simulation = Simulation( + tax_benefit_system=system, + situation={ + "people": { + "head": { + "age": {PERIOD: 65}, + "is_tax_unit_head": {PERIOD: True}, + "employment_income": {PERIOD: 30_000}, + "medicare_enrolled": {PERIOD: False}, + "health_insurance_premiums": {PERIOD: 6_000}, + "other_medical_expenses": {PERIOD: other_expenses}, + } + }, + "tax_units": { + "tax_unit": { + "members": ["head"], + "filing_status": {PERIOD: "SINGLE"}, + "head_is_dependent_elsewhere": {PERIOD: False}, + "self_employed_health_insurance_ald": {PERIOD: 1_000}, + } + }, + "households": { + "household": { + "members": ["head"], + "state_code": {PERIOD: state}, + } + }, + }, + ) + simulation.set_input( + "itemized_medical_expenses", PERIOD, np.array([6_000 + other_expenses]) + ) + return simulation + + +@pytest.mark.parametrize("delete_subtotal", SUBTOTAL_DELETION_CASES) +def test_nd_paid_cost_base_after_medical_subtotal_deletion(delete_subtotal): + simulation = _simulation("ND", other_expenses=500) + if delete_subtotal: + simulation.delete_arrays("itemized_medical_expenses", PERIOD) + + # Calculate the state first: it recomputes the deleted subtotal as 5,500 + # before inspecting provenance. Restore its 1,000 exclusion to retain + # 6,500 paid costs. + assert simulation.calculate("nd_renters_refund_income", PERIOD).tolist() == [23_500] + + +@pytest.mark.parametrize("delete_subtotal", SUBTOTAL_DELETION_CASES) +def test_nm_senior_benefits_after_medical_subtotal_deletion(delete_subtotal): + simulation = _simulation("NM", other_expenses=22_000) + if delete_subtotal: + simulation.delete_arrays("itemized_medical_expenses", PERIOD) + + # The recomputed federal subtotal is 27,000, but restoring its 1,000 + # exclusion meets NM's 28,000 paid-cost threshold. Calculate both before + # asserting to preserve evidence. + benefits = tuple( + simulation.calculate(variable, PERIOD).item() + for variable in ( + "nm_medical_expense_credit", + "nm_medical_expense_exemption", + ) + ) + assert benefits == (2_800, 3_000) diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml index 0ed831fd439..1228ba0acf8 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/deductions/itemizing/medical_expense_deduction.yaml @@ -6,3 +6,186 @@ output: itemized_medical_expenses: 10_000 medical_expense_deduction: 2_500 + +- name: Schedule A excludes premiums deducted on Schedule 1 before applying its floor. + # 2025 Schedule A instructions, medical insurance premiums and line 1. + period: 2025 + input: + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + medical_expense_health_insurance_premiums: 6_000 + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + +- name: An earnings-limited derived SE deduction leaves unused premiums on Schedule A. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 1_000 + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + +- name: Fully deducted premiums leave only non-premium medical expenses. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: 10_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 6_000 + itemized_medical_expenses: 500 + medical_expense_deduction: 0 + +- name: Self-employment losses do not remove premiums from Schedule A. + period: 2025 + input: + is_self_employed: true + health_insurance_premiums: 6_000 + self_employment_income: -1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: 0 + itemized_medical_expenses: 6_500 + medical_expense_deduction: 4_250 + +- name: The SE deduction cannot reduce other medical expenses when premiums are exhausted. + period: 2025 + input: + health_insurance_premiums: 300 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 0 + output: + itemized_medical_expenses: 500 + medical_expense_deduction: 500 + +- name: The exclusion also applies to decomposed medical and Medicare premiums. + period: 2025 + input: + age: 65 + medicare_enrolled: true + health_insurance_premiums_without_medicare_part_b: 1_200 + medicare_part_b_premium: 2_400 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + output: + medical_expense_health_insurance_premiums: 3_600 + itemized_medical_expenses: 3_100 + medical_expense_deduction: 850 + +- name: Each filing unit excludes its own SE deduction independently of pretax premiums. + period: 2025 + input: + people: + person1: + is_self_employed: true + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 0 + self_employment_income: 1_000 + other_medical_expenses: 500 + person2: + is_self_employed: true + health_insurance_premiums: 6_000 + pre_tax_health_insurance_premiums: 2_000 + self_employment_income: 1_000 + other_medical_expenses: 500 + person3: + health_insurance_premiums: 6_000 + self_employment_income: 0 + other_medical_expenses: 500 + tax_units: + first: + members: [person1] + adjusted_gross_income: 30_000 + second: + members: [person2] + adjusted_gross_income: 30_000 + third: + members: [person3] + adjusted_gross_income: 30_000 + output: + self_employed_health_insurance_ald: [1_000, 1_000, 0] + itemized_medical_expenses: [5_500, 5_500, 6_500] + medical_expense_deduction: [3_250, 3_250, 4_250] + +- name: A filer SE deduction cannot consume premiums attributed to a dependent. + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + other_medical_expenses: 100 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + other_medical_expenses: 200 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 1_000 + adjusted_gross_income: 0 + output: + itemized_medical_expenses: 800 + medical_expense_deduction: 800 + +- name: Parent-paid family coverage is attributed entirely to the parent. + period: 2025 + input: + people: + parent: + age: 40 + # The parent paid the full family premium, including dependent coverage. + health_insurance_premiums: 800 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 0 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 800 + adjusted_gross_income: 0 + output: + # Payer attribution: 800 paid - 800 already deducted = 0 on Schedule A. + itemized_medical_expenses: 0 + medical_expense_deduction: 0 + +- name: Beneficiary-attributed family inputs cannot transfer the parent's exclusion to a dependent. + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + child: + age: 10 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 800 + adjusted_gross_income: 0 + output: + # The exclusion is min(800 ALD, 300 filer-paid premiums) = 300, leaving + # the dependent's 500. Attributed premium uses are 300 + 500 = 800. + # Supplying an 800 parent ALD with only 300 parent-paid premiums violates + # the payer contract; adding the full supplied ALD to Schedule A gives + # 1,300 and is not a conservation guarantee for these inconsistent inputs. + # A parent who actually paid all 800 must use the preceding input pattern. + itemized_medical_expenses: 500 + medical_expense_deduction: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml index 5d0a3298c34..88943f6144e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/al/tax/income/deductions/itemized/al_medical_deduction.yaml @@ -17,3 +17,16 @@ output: al_medical_expense_deduction: 3_400 #7000-0.04 * 90,000=3400 + +- name: Alabama excludes premiums already deducted for self-employed health insurance + period: 2025 + input: + state_code: AL + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + al_agi: 30_000 + output: + # Alabama follows federal eligible expenses, with its own 4% floor. + itemized_medical_expenses: 5_500 + al_medical_expense_deduction: 4_300 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml index 0497db85afa..c86f264256e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_indiv.yaml @@ -203,3 +203,14 @@ output: # 9_000 - 0.075 * 50_000; Form AR3 Line 3B: "if either you or your spouse were age 65 or over" ar_medical_expense_deduction_indiv: [5_250, 0] + +- name: Arkansas separate medical deduction excludes self-employed premiums before its 10% floor + period: 2025 + input: + state_code: AR + ar_agi_indiv: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + output: + ar_medical_expense_deduction_indiv: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml index 142d394f993..d059cdafa4b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/deductions/itemized/ar_medical_expense_deduction_joint.yaml @@ -203,3 +203,14 @@ output: # 9_000 - 0.075 * 50_000; Form AR3 Line 3B: "if either you or your spouse were age 65 or over" ar_medical_expense_deduction_joint: [5_250, 0] + +- name: Arkansas joint medical deduction excludes self-employed premiums before its 10% floor + period: 2025 + input: + state_code: AR + ar_agi_joint: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + output: + ar_medical_expense_deduction_joint: 2_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml index 620371ff617..3cfeaae9e08 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/az/tax/income/deductions/itemized/az_itemized_deductions.yaml @@ -126,3 +126,18 @@ output: # min($8,000, $10,000) = $8,000 az_itemized_deductions: 8_000 + +- name: Arizona excludes self-employed premiums from its medical deduction without an AGI floor + period: 2025 + input: + state_code: AZ + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + salt_deduction: 0 + charitable_deduction: 0 + interest_deduction: 0 + casualty_loss_deduction: 0 + output: + # A.R.S. 43-1042(B) removes the floor, while retaining federal eligible expenses. + az_itemized_deductions: 5_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml index abb0de0bc96..71b2f64df4f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/hi/tax/income/deductions/itemized/hi_medical_expense_deduction.yaml @@ -73,3 +73,14 @@ state_code: HI output: hi_medical_expense_deduction: 0 + +- name: Hawaii excludes premiums already deducted for self-employed health insurance + period: 2025 + input: + state_code: HI + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + hi_agi: 30_000 + output: + hi_medical_expense_deduction: 3_250 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 b7d9991ce35..e2cd3149159 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 @@ -317,8 +317,8 @@ - name: Derived claimant medical overlap uses premiums excluding pretax payroll payments # Pretax payroll premiums are separate; remove only the Schedule 1 amount. # Qualified premiums: $6,000 - $1,000 = $5,000; floor: $30,000 * 7.5% = $2,250. - # Federal medical: $6,000 + $500 - $2,250 = $4,250 (unchanged). - # Claimant overlap: min($4,250, $5,000 + $500 - $2,250) = $3,250. + # Federal medical: $6,000 - $1,000 + $500 - $2,250 = $3,250. + # Claimant overlap: min($3,250, $5,000 + $500 - $2,250) = $3,250. # Itemized route: $20,000 + ($5,000 - $3,250) = $21,750, exceeding # standard route $15,750 + $5,000 = $20,750; subtraction remains $1,750. period: 2025 @@ -332,7 +332,7 @@ standard_deduction: 15_750 id_itemized_deductions: 20_000 output: - medical_expense_deduction: 4_250 + medical_expense_deduction: 3_250 id_qualified_health_insurance_premiums: 5_000 id_health_insurance_premiums_medical_deduction: 3_250 id_itemizes: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml index 42dbfce011a..a5caf4fc9b9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.yaml @@ -17,12 +17,15 @@ input: health_insurance_premiums: 5_000 other_medical_expenses: 0 - medical_expense_deduction: 4_625 #medical expenses over 7.5% of taxable_income, equals to 92.5% of income and insurance_premiums + medical_expense_deduction: 4_625 tax_unit_itemizes: True taxable_income: 5_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 375 + # Form 5695 line 12: 4,625 / 5,000 = 92.5%, rounded to 93%. + # Lines 13-14: 5,000 * (1 - 0.93) = 350. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 350 - name: Itemizes, more health expenses than taxable income, max federal itemized health deduction period: 2021 @@ -35,7 +38,10 @@ taxable_income: 7_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 525 + # Form 5695 lines 12-14: 11,475 / 12,000 = 95.625% -> 96%; + # 12,000 * (1 - 0.96) = 480. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 480 - name: Itemizes, equal amounts of other medical expenses and health insurance premiums period: 2021 @@ -48,7 +54,10 @@ taxable_income: 7_000 state_code: MO output: - mo_qualified_health_insurance_premiums: 262.5 + # Form 5695 lines 12-14: 11,475 / 12,000 = 95.625% -> 96%; + # 6,000 * (1 - 0.96) = 240. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2021.pdf#page=36 + mo_qualified_health_insurance_premiums: 240 - name: Head and spouse have different amounts, no itemizing. period: 2021 @@ -92,3 +101,145 @@ output: # Half of their expenses were deducted from federal return. mo_qualified_health_insurance_premiums: [50, 150] + +- name: Missouri's medical overlap ratio follows federal Schedule A after the SE premium exclusion + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + adjusted_gross_income: 30_000 + taxable_income: 30_000 + tax_unit_itemizes: true + output: + # MO-1040 instructions page 15 excludes the Schedule 1 deduction: + # qualifying pool = 6,000 - 1,000 = 5,000. + # Schedule A: 5,000 + 500 - 0.075 * 30,000 = 3,250. + # Form 5695 lines 10-14: 3,250 / 5,500 = 59.0909% -> 59%; + # Missouri subtraction = 5,000 * (1 - 0.59) = 2,050. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=15 + # https://dor.mo.gov/forms/5695.pdf + itemized_medical_expenses: 5_500 + medical_expense_deduction: 3_250 + mo_qualified_health_insurance_premiums: 2_050 + +- name: Missouri's qualifying pool is unchanged without an SE deduction + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 0 + other_medical_expenses: 0 + adjusted_gross_income: 40_000 + taxable_income: 40_000 + tax_unit_itemizes: true + output: + # 6,000 - 0.075 * 40,000 = 3,000; ratio = 50% exactly. + # 6,000 * (1 - 0.5) = 3,000, matching the previous formula. + itemized_medical_expenses: 6_000 + medical_expense_deduction: 3_000 + mo_qualified_health_insurance_premiums: 3_000 + +- name: Missouri excludes SE-deducted premiums even without itemization + period: 2025 + input: + state_code: MO + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + mo_qualified_health_insurance_premiums: 5_000 + +- name: Missouri's full-percent rounding just below a half-percent tie + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 10_000 + other_medical_expenses: 0 + medical_expense_deduction: 5_849 + tax_unit_itemizes: true + taxable_income: 30_000 + output: + # 58.49% rounds to 58%; 10,000 * (1 - 0.58) = 4,200. + mo_qualified_health_insurance_premiums: 4_200 + +- name: Missouri's full-percent rounding at a half-percent tie + period: 2025 + absolute_error_margin: 0.01 + input: + state_code: MO + health_insurance_premiums: 10_000 + other_medical_expenses: 0 + medical_expense_deduction: 5_850 + tax_unit_itemizes: true + taxable_income: 30_000 + output: + # Form 5695 line 12 requires full-percent rounding. Half-up ties follow + # the MO-1040 instructions' percentage example on page 7 (97.5% -> 98%). + # 58.5% rounds to 59%; 10,000 * (1 - 0.59) = 4,100. + # https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=7 + mo_qualified_health_insurance_premiums: 4_100 + +- name: Missouri caps each payer's SE exclusion before combining premiums + period: 2025 + input: + people: + parent: + health_insurance_premiums: 300 + self_employed_health_insurance_ald_person: 800 + spouse: + health_insurance_premiums: 500 + self_employed_health_insurance_ald_person: 0 + tax_units: + tax_unit: + members: [parent, spouse] + tax_unit_itemizes: false + taxable_income: 30_000 + households: + household: + members: [parent, spouse] + state_code: MO + output: + # Premiums belong to their payer. The parent's unmatched $500 deduction + # cannot exclude the spouse's $500 of premiums. + self_employed_health_insurance_ald: 800 + mo_qualified_health_insurance_premiums: [0, 500] + +- name: Missouri honors a supplied zero federal SE deduction + period: 2025 + input: + state_code: MO + health_insurance_premiums: 1_000 + is_self_employed: true + self_employed_health_insurance_premiums: 1_000 + total_self_employment_income: 10_000 + self_employed_health_insurance_ald: 0 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + # Personal eligibility is $1,000, but the actual federal return claims $0. + # No premiums were deducted federally, so the qualifying pool stays $1,000. + self_employed_health_insurance_ald_person: 1_000 + mo_qualified_health_insurance_premiums: 1_000 + +- name: Missouri honors a supplied partial federal SE deduction + period: 2025 + input: + state_code: MO + health_insurance_premiums: 1_000 + is_self_employed: true + self_employed_health_insurance_premiums: 1_000 + total_self_employment_income: 10_000 + self_employed_health_insurance_ald: 500 + tax_unit_itemizes: false + taxable_income: 30_000 + output: + # Limit the personal $1,000 deduction to the actual $500 claimed federally. + # The remaining qualifying pool is 1,000 - 500 = 500. + self_employed_health_insurance_ald_person: 1_000 + mo_qualified_health_insurance_premiums: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_long_term_care_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_long_term_care_insurance_premiums.yaml new file mode 100644 index 00000000000..ec8426839bf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_long_term_care_insurance_premiums.yaml @@ -0,0 +1,56 @@ +- name: Long-term care premiums qualify in full without the federal age-based limit + period: 2021 + absolute_error_margin: 0.01 + input: + age: 40 + long_term_health_insurance_premiums: 3_000 + state_code: MT + output: + # Montana line 3 deducts the premiums without the federal age-based + # limit on eligible long-term care premiums (26 U.S.C. 213(d)(10)). + mt_eligible_long_term_care_insurance_premiums: 3_000 + +- name: Long-term care premiums taken as the self-employed health insurance deduction do not qualify + period: 2022 + absolute_error_margin: 0.01 + input: + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 400 + state_code: MT + output: + mt_eligible_long_term_care_insurance_premiums: 0 + +- name: The deduction applies first to medical premiums, then to long-term care premiums + period: 2023 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 2_000 + long_term_health_insurance_premiums: 1_500 + self_employed_health_insurance_ald_person: 3_000 + state_code: MT + output: + # 3,000 - 2,000 used on line 2 leaves 1,000 for line 3: 1,500 - 1,000. + self_employed_health_insurance_ald_excluded_premiums: 2_000 + mt_eligible_medical_insurance_premiums: 0 + mt_eligible_long_term_care_insurance_premiums: 500 + +- name: A deduction smaller than the medical premiums leaves long-term care premiums in full + period: 2023 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 5_000 + long_term_health_insurance_premiums: 1_500 + self_employed_health_insurance_ald_person: 3_000 + state_code: MT + output: + mt_eligible_medical_insurance_premiums: 2_000 + mt_eligible_long_term_care_insurance_premiums: 1_500 + +- name: Outside Montana the amount is zero + period: 2022 + absolute_error_margin: 0.01 + input: + long_term_health_insurance_premiums: 1_000 + state_code: ID + output: + mt_eligible_long_term_care_insurance_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml new file mode 100644 index 00000000000..2557e410540 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_eligible_medical_insurance_premiums.yaml @@ -0,0 +1,94 @@ +- name: Premiums paid with after-tax dollars qualify in full + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 4_000 + state_code: MT + output: + mt_eligible_medical_insurance_premiums: 4_000 + +- name: Medicare Part B premiums qualify alongside other premiums + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums_without_medicare_part_b: 1_000 + medicare_enrolled: true + medicare_part_b_premium: 2_000 + state_code: MT + output: + # The 2022 Form 2 instructions for line 2 make Medicare Part B premiums + # 100 percent deductible on this line. + medical_expense_health_insurance_premiums: 3_000 + mt_eligible_medical_insurance_premiums: 3_000 + +- name: Pre-tax premiums do not qualify + period: 2022 + absolute_error_margin: 0.01 + input: + employment_income: 50_000 + pre_tax_health_insurance_premiums: 3_000 + state_code: MT + output: + # Premiums paid through a cafeteria plan are excluded from wages and are + # not counted as medical expenses. + medical_expense_health_insurance_premiums: 0 + mt_eligible_medical_insurance_premiums: 0 + +- name: Premiums fully taken as the self-employed health insurance deduction do not qualify + period: 2022 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 40_000 + health_insurance_premiums: 6_000 + state_code: MT + output: + self_employed_health_insurance_ald_person: 6_000 + mt_eligible_medical_insurance_premiums: 0 + +- name: Premiums above the self-employed health insurance deduction qualify + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 6_000 + # The deduction claimed on Schedule 1, line 17, after its earnings limit. + self_employed_health_insurance_ald_person: 2_000 + state_code: MT + output: + # Only the 2,000 deducted in Montana AGI is excluded: 6,000 - 2,000. + mt_eligible_medical_insurance_premiums: 4_000 + +- name: A deduction above the medical premiums excludes no more than the premiums + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 1_000 + long_term_health_insurance_premiums: 2_000 + self_employed_health_insurance_ald_person: 2_500 + state_code: MT + output: + # The deduction covers all 1,000 of the medical premiums; the other 1,500 + # covers long-term care premiums on line 3. + mt_eligible_medical_insurance_premiums: 0 + mt_eligible_long_term_care_insurance_premiums: 500 + +- name: Self-employed person with a loss deducts none of the premiums in AGI + period: 2023 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: -5_000 + health_insurance_premiums: 3_000 + state_code: MT + output: + self_employed_health_insurance_ald_person: 0 + mt_eligible_medical_insurance_premiums: 3_000 + +- name: Outside Montana the amount is zero + period: 2022 + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 4_000 + state_code: ID + output: + mt_eligible_medical_insurance_premiums: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml index 3804de55c08..c3cc0f15446 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_indiv.yaml @@ -22,8 +22,151 @@ period: 2021 input: other_medical_expenses: 1_000 + health_insurance_premiums: 2_000 + mt_agi_indiv: 10_000 + state_code: MT + is_tax_unit_head_or_spouse: false + output: + mt_medical_expense_deduction_indiv: 0 + +- name: Montana separate medical deductions exclude each spouse's own self-employed premiums + period: 2023 + input: + people: + head: + age: 40 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + other_medical_expenses: 500 + mt_agi_indiv: 30_000 + mt_married_filing_separately_on_same_return_eligible: true + spouse: + age: 40 + health_insurance_premiums: 3_000 + self_employed_health_insurance_ald_person: 2_000 + other_medical_expenses: 500 + mt_agi_indiv: 10_000 + mt_married_filing_separately_on_same_return_eligible: true + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Line 1 floors only the other expenses; line 2 adds the premiums net of + # each spouse's own self-employed health insurance deduction, in full. + # Head: max(0, 500 - 7.5% x 30,000) + (6,000 - 1,000) = 0 + 5,000. + # Spouse: max(0, 500 - 7.5% x 10,000) + (3,000 - 2,000) = 0 + 1,000. + mt_medical_expense_deduction_indiv: [5_000, 1_000] + +- name: Medical insurance premiums are deducted in full on line 2, not under the floor + period: 2022 + absolute_error_margin: 0.01 + input: + other_medical_expenses: 1_000 + health_insurance_premiums: 2_000 mt_agi_indiv: 20_000 state_code: MT is_tax_unit_head_or_spouse: true output: - mt_medical_expense_deduction_indiv: 0 + # Line 1: max(0, 1,000 - 7.5% x 20,000) = max(0, 1,000 - 1,500) = 0. + # Line 2: 2,000 in full. + # Flooring the premiums with the other expenses would give + # 3,000 - 1,500 = 1,500. + mt_medical_expense_deduction_indiv: 2_000 + +- name: Long-term care insurance premiums are deducted in full on line 3 + period: 2023 + absolute_error_margin: 0.01 + input: + long_term_health_insurance_premiums: 1_500 + mt_agi_indiv: 50_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + mt_medical_expense_deduction_indiv: 1_500 + +- name: Premiums taken as the self-employed health insurance deduction are not deducted again + period: 2021 + absolute_error_margin: 0.01 + input: + is_self_employed: true + self_employment_income: 30_000 + health_insurance_premiums: 5_000 + other_medical_expenses: 4_000 + mt_agi_indiv: 25_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + self_employed_health_insurance_ald_person: 5_000 + # Line 1: 4,000 - 7.5% x 25,000 = 2,125. Line 2: 0. + mt_medical_expense_deduction_indiv: 2_125 + +- name: Each spouse filing separately on the same form deducts their own premiums + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 3_000 + other_medical_expenses: 2_000 + mt_agi_indiv: 20_000 + person2: + is_tax_unit_spouse: true + health_insurance_premiums: 1_000 + long_term_health_insurance_premiums: 800 + mt_agi_indiv: 10_000 + person3: + is_tax_unit_dependent: true + health_insurance_premiums: 600 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Head: line 1 max(0, 2,000 - 7.5% x 20,000) = 500, line 2 3,000. + # Spouse: line 2 1,000, line 3 800. + mt_medical_expense_deduction_indiv: [3_500, 1_800, 0] + +- name: Long-term care premiums taken as the self-employed health insurance deduction are not deducted again + period: 2022 + absolute_error_margin: 0.01 + input: + long_term_health_insurance_premiums: 400 + self_employed_health_insurance_ald_person: 400 + other_medical_expenses: 1_000 + mt_agi_indiv: 10_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + # The deduction took all 400 of the long-term care premiums in Montana + # AGI, so line 3 is 0. Line 1: 1,000 - 7.5% x 10,000 = 250. + mt_medical_expense_deduction_indiv: 250 + +- name: A reform that allows separate filing in 2024 keeps premiums under the floor + period: 2024 + reforms: policyengine_us.tests.policy.baseline.gov.states.mt.tax.income.enable_mt_mfss_2024_reform.enable_mt_mfss_2024_reform + absolute_error_margin: 0.01 + input: + health_insurance_premiums: 4_000 + long_term_health_insurance_premiums: 1_000 + other_medical_expenses: 2_000 + mt_agi_indiv: 60_000 + state_code: MT + is_tax_unit_head_or_spouse: true + output: + # The Itemized Deductions Schedule ended after 2023, so lines 2 and 3 do + # not apply: (4,000 + 2,000) - 7.5% x 60,000 = 1,500. Applying them would + # give 0 + 4,000 + 1,000 = 5,000. + mt_married_filing_separately_on_same_return_eligible: true + mt_medical_expense_deduction_indiv: 1_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml index f683598aa05..5cc3b2cfd07 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_medical_expense_deduction_joint.yaml @@ -158,3 +158,218 @@ # Form 2 line 2, Worksheet A, takes federal Schedule A: # 10,000 - 7.5% x 100,000 = 2,500. mt_medical_expense_deduction_joint: 2_500 + +- name: Montana joint medical deduction excludes spouses' self-employed premiums + period: 2023 + input: + people: + head: + age: 40 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald_person: 1_000 + other_medical_expenses: 500 + spouse: + age: 40 + health_insurance_premiums: 3_000 + self_employed_health_insurance_ald_person: 2_000 + other_medical_expenses: 500 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + mt_agi_joint: 40_000 + marital_units: + marital_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Line 1: max(0, (500 + 500) - 7.5% x 40,000) = 0. + # Line 2: (6,000 - 1,000) + (3,000 - 2,000) = 6,000, in full. + # The self-employed deductions are netted once, on line 2. + mt_medical_expense_deduction_joint: [6_000, 0] + +- name: Medical insurance premiums are deducted in full on line 2, not under the floor + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 4_000 + other_medical_expenses: 2_000 + person2: + is_tax_unit_head: false + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 60_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: max(0, 2,000 - 7.5% x 60,000) = max(0, 2,000 - 4,500) = 0. + # Line 2: 4,000 in full. Line 3: 0. + # Flooring the premiums with the other expenses would give + # 6,000 - 4,500 = 1,500. + mt_medical_expense_deduction_joint: [4_000, 0] + +- name: Long-term care insurance premiums are deducted in full on line 3 + period: 2021 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + other_medical_expenses: 5_000 + long_term_health_insurance_premiums: 3_000 + person2: + is_tax_unit_head: false + long_term_health_insurance_premiums: 2_500 + tax_units: + tax_unit: + members: [person1, person2] + mt_agi_joint: 40_000 + households: + household: + members: [person1, person2] + state_code: MT + output: + # Line 1: 5,000 - 7.5% x 40,000 = 2,000. + # Line 3: 3,000 + 2,500 = 5,500. + # Total: 2,000 + 5,500 = 7,500, held by the head. + mt_medical_expense_deduction_joint: [7_500, 0] + +- name: Premiums taken as the self-employed health insurance deduction are not deducted again + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + is_self_employed: true + self_employment_income: 50_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 10_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # The self-employed health insurance deduction takes all 6,000 of the + # premiums in federal and so in Montana AGI, so line 2 is 0. + self_employed_health_insurance_ald: 6_000 + # Line 1: 10,000 - 7.5% x 40,000 = 7,000. + mt_medical_expense_deduction_joint: 7_000 + +- name: A dependent's premiums count on line 2 while line 1 is below the floor + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 3_000 + person2: + is_tax_unit_spouse: true + person3: + is_tax_unit_dependent: true + health_insurance_premiums: 1_200 + other_medical_expenses: 1_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + mt_agi_joint: 50_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # Line 1: max(0, 1,000 - 7.5% x 50,000) = 0. + # Line 2: 3,000 + 1,200 = 4,200. + # Flooring the premiums with the other expenses would give + # 5,200 - 3,750 = 1,450. + mt_medical_expense_deduction_joint: [4_200, 0, 0] + +- name: Joint return computed from wages, with premiums and other medical expenses + period: 2022 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 80_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 3_000 + person2: + age: 40 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_agi_joint: 80_000 + # Line 1: max(0, 3,000 - 7.5% x 80,000) = max(0, 3,000 - 6,000) = 0. + # Line 2: 6,000. + # Flooring the premiums with the other expenses would give + # 9,000 - 6,000 = 3,000. + mt_medical_expense_deduction_joint: [6_000, 0] + +- name: From 2024 medical insurance premiums stay under the federal floor + period: 2024 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 60_000 + health_insurance_premiums: 4_000 + other_medical_expenses: 2_000 + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + adjusted_gross_income: 60_000 + # Federal Schedule A: (4,000 + 2,000) - 7.5% x 60,000 = 1,500. + medical_expense_deduction: 1_500 + mt_medical_expense_deduction_joint: 1_500 + +- name: The self-employed deduction left after medical premiums reduces long-term care premiums + period: 2023 + absolute_error_margin: 0.01 + input: + people: + person1: + is_tax_unit_head: true + health_insurance_premiums: 2_000 + long_term_health_insurance_premiums: 1_500 + self_employed_health_insurance_ald_person: 3_000 + other_medical_expenses: 4_000 + tax_units: + tax_unit: + members: [person1] + mt_agi_joint: 40_000 + households: + household: + members: [person1] + state_code: MT + output: + # The 3,000 deduction in Montana AGI covers the 2,000 of medical premiums + # and 1,000 of the long-term care premiums. + # Line 1: 4,000 - 7.5% x 40,000 = 1,000. + # Line 2: 2,000 - 2,000 = 0. Line 3: 1,500 - 1,000 = 500. + mt_medical_expense_deduction_joint: 1_500 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml index 31a7a288278..eb8ccc0a96c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nc/tax/income/deductions/nc_itemized_deductions.yaml @@ -45,3 +45,18 @@ state_code: NC output: nc_itemized_deductions: 14_000 + +- name: North Carolina follows the federal exclusion for self-employed insurance premiums + period: 2025 + input: + state_code: NC + adjusted_gross_income: 30_000 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + mortgage_interest: 0 + real_estate_taxes: 0 + charitable_deduction: 0 + output: + medical_expense_deduction: 3_250 + nc_itemized_deductions: 3_250 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml index 189fd53d517..9dcdb1e7932 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.yaml @@ -62,3 +62,77 @@ state_code: ND output: nd_renters_refund_income: 12_000 + +- name: North Dakota renter refund deducts medical expenses actually paid from gross income + period: 2025 + input: + state_code: ND + employment_income: 30_000 + health_insurance_premiums: 6_000 + other_medical_expenses: 500 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 5_500 + # The property tax program separately deducts the full 6,500 paid. + nd_renters_refund_income: 23_500 + +- name: North Dakota renter refund preserves caller-supplied medical expenses + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + output: + nd_renters_refund_income: 23_500 + +- name: North Dakota cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + self_employed_health_insurance_ald: 1_000 + output: + nd_renters_refund_income: 23_500 + +- name: North Dakota supplied subtotal takes precedence over premiums and SE deduction + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 6_500 + health_insurance_premiums: 6_000 + other_medical_expenses: 500 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 6_500 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nd_renters_refund_income: 23_500 + +- name: North Dakota supplied subtotal below potential exclusion remains unchanged + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 500 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 500 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + # The supplied paid-cost base is $500, not $1,500. + nd_renters_refund_income: 29_500 + +- name: North Dakota supplied zero subtotal does not restore a potential exclusion + period: 2025 + input: + state_code: ND + employment_income: 30_000 + itemized_medical_expenses: 0 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses_applied_exclusion: 0 + nd_renters_refund_income: 30_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml index ddcd2cb333c..6aa0d6ff82c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.yaml @@ -20,6 +20,32 @@ output: nj_medical_expense_deduction: 1_000 +- name: New Jersey preserves dependent premiums when the filer's deduction exceeds reported filer premiums + period: 2025 + input: + people: + parent: + age: 40 + health_insurance_premiums: 300 + other_medical_expenses: 100 + child: + age: 12 + is_tax_unit_dependent: true + health_insurance_premiums: 500 + other_medical_expenses: 200 + tax_units: + tax_unit: + members: [parent, child] + self_employed_health_insurance_ald: 1_000 + nj_agi: 0 + households: + household: + members: [parent, child] + state_code: NJ + output: + itemized_medical_expenses: 800 + nj_medical_expense_deduction: 1_800 + - name: Partial out of pocket expenses deductable period: 2022 absolute_error_margin: 0 @@ -55,3 +81,73 @@ # child's 1,000 of self-employed health insurance is deducted too. nj_agi: 50_000 nj_medical_expense_deduction: 1_000 + +- name: New Jersey counts self-employed premiums once across its medical deductions + period: 2025 + input: + state_code: NJ + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + other_medical_expenses: 500 + nj_agi: 30_000 + output: + # Worksheet F adds the SE deduction after applying the 2% floor to the remainder. + nj_medical_expense_deduction: 5_900 + +- name: New Jersey excludes a dependent's separately deducted premiums from its medical base + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 30_000 + child: + age: 17 + self_employment_income: 10_000 + health_insurance_premiums: 1_000 + is_self_employed: true + other_medical_expenses: 500 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: NJ + output: + nj_agi: 40_000 + dependents_self_employed_health_insurance_ald: 1_000 + # Only the 500 of other expenses remains subject to the 800 floor. + nj_medical_expense_deduction: 1_000 + +- name: New Jersey cannot subtract one dependent's deduction from another dependent's premiums + period: 2025 + input: + people: + parent: + age: 40 + dependent_a: + age: 17 + is_tax_unit_dependent: true + self_employment_income: 10_000 + self_employed_health_insurance_premiums: 1_000 + dependent_b: + age: 16 + is_tax_unit_dependent: true + health_insurance_premiums: 1_000 + tax_units: + tax_unit: + members: [parent, dependent_a, dependent_b] + nj_agi: 0 + households: + household: + members: [parent, dependent_a, dependent_b] + state_code: NJ + output: + dependents_self_employed_health_insurance_ald: 1_000 + itemized_medical_expenses: 1_000 + # C.54A:3-3(c)(3) excludes separately deducted amounts, not another + # person's paid premiums: min(1,000, 0) + min(0, 1,000) = 0 excluded. + # Separate SE deduction 1,000 + general medical premiums 1,000 = 2,000. + # https://pub.njleg.gov/bills/9899/PL99/222_.HTM + nj_medical_expense_deduction: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml index df55277d73a..10a46317f43 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/credits/nm_medical_expense_credit.yaml @@ -8,6 +8,43 @@ output: nm_medical_expense_credit: 0 +- name: New Mexico senior medical credit uses expenses paid before federal premium deductions + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + health_insurance_premiums: 6_000 + other_medical_expenses: 22_000 + self_employed_health_insurance_ald: 1_000 + head_is_dependent_elsewhere: false + output: + itemized_medical_expenses: 27_000 + nm_medical_expense_credit: 2_800 + +- name: New Mexico senior credit preserves caller-supplied medical expenses + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 28_000 + head_is_dependent_elsewhere: false + output: + nm_medical_expense_credit: 2_800 + +- name: New Mexico credit cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 27_999 + self_employed_health_insurance_ald: 1_000 + head_is_dependent_elsewhere: false + output: + nm_medical_expense_credit: 0 + - name: Below expense eligibility period: 2023 input: @@ -63,3 +100,20 @@ head_is_dependent_elsewhere: true output: nm_medical_expense_credit: 0 + +- name: New Mexico supplied subtotal and SE premiums do not create senior credit eligibility + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + head_is_dependent_elsewhere: false + itemized_medical_expenses: 27_999 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_999 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nm_medical_expense_credit: 0 + nm_medical_expense_exemption: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml index 65264be9353..54d7b1c8e0a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.yaml @@ -8,6 +8,40 @@ output: nm_medical_expense_exemption: 0 +- name: New Mexico senior medical exemption uses expenses paid before federal premium deductions + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + health_insurance_premiums: 6_000 + other_medical_expenses: 22_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_000 + nm_medical_expense_exemption: 3_000 + +- name: New Mexico senior exemption preserves caller-supplied medical expenses + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 28_000 + output: + nm_medical_expense_exemption: 3_000 + +- name: New Mexico exemption cannot add back an SE deduction without reported premiums + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + itemized_medical_expenses: 27_999 + self_employed_health_insurance_ald: 1_000 + output: + nm_medical_expense_exemption: 0 + - name: Below expense eligibility period: 2023 input: @@ -48,3 +82,20 @@ filing_status: SURVIVING_SPOUSE output: nm_medical_expense_exemption: 3_000 + +- name: New Mexico supplied subtotal and SE premiums do not create senior exemption eligibility + period: 2025 + input: + state_code: NM + age: 65 + medicare_enrolled: false + head_is_dependent_elsewhere: false + itemized_medical_expenses: 27_999 + health_insurance_premiums: 6_000 + self_employed_health_insurance_ald: 1_000 + output: + itemized_medical_expenses: 27_999 + itemized_medical_expenses_excluded_premiums: 1_000 + itemized_medical_expenses_applied_exclusion: 0 + nm_medical_expense_credit: 0 + nm_medical_expense_exemption: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml index e11a5c61b86..d0a92fc4e10 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/tax/income/state_itemized_deductions.yaml @@ -11,6 +11,22 @@ output: state_itemized_deductions: 0 +- name: Federal itemized deduction states inherit the self-employed premium exclusion + period: 2025 + input: + state_code: [GA, ND, SC, UT] + adjusted_gross_income: [30_000, 30_000, 30_000, 30_000] + health_insurance_premiums: [6_000, 6_000, 6_000, 6_000] + self_employed_health_insurance_ald: [1_000, 1_000, 1_000, 1_000] + other_medical_expenses: [500, 500, 500, 500] + salt_deduction: [0, 0, 0, 0] + charitable_deduction: [0, 0, 0, 0] + interest_deduction: [0, 0, 0, 0] + casualty_loss_deduction: [0, 0, 0, 0] + output: + medical_expense_deduction: [3_250, 3_250, 3_250, 3_250] + state_itemized_deductions: [3_250, 3_250, 3_250, 3_250] + - name: Federal itemized deductions adopted for Georgia period: 2023 input: diff --git a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py index aa925d3fb6d..c9ea424b401 100644 --- a/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py +++ b/policyengine_us/tests/unit/test_mt_joint_agi_deductions_properties.py @@ -11,9 +11,10 @@ The deterministic tests evaluate every point of an input grid in one vectorized simulation; Hypothesis adds generated batches that check the same invariants: -- 2021-2023: the medical deduction is max(0, expenses - 7.5% of the return's - Montana AGI), and the standard deduction is 20% of that AGI held between the - filing status minimum and maximum; +- 2021-2023: the medical deduction is max(0, expenses other than premiums - + 7.5% of the return's Montana AGI) plus the premiums deducted in full on + lines 2 and 3, and the standard deduction is 20% of that AGI held between + the filing status minimum and maximum; - moving an income item between spouses preserves both deductions when the return's applicable AGI and medical expenses are held fixed; - each deduction is held once, by the head; @@ -163,23 +164,30 @@ def test_premises(grid, year): @pytest.mark.parametrize("year", STATE_YEARS) def test_medical_floor_uses_the_return_montana_agi(grid, year): sim, unit, role = grid - expenses = sim.calculate("itemized_medical_expenses", year) - expected = np.maximum( - expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0 + # Line 1 floors expenses other than premiums; lines 2 and 3 deduct the + # premiums in full. The grid's age-70 single filers have modeled + # Medicare Part B premiums. + expenses = per_unit(sim, "other_medical_expenses", year, unit) + premiums = per_unit( + sim, "mt_eligible_medical_insurance_premiums", year, unit + ) + per_unit(sim, "mt_eligible_long_term_care_insurance_premiums", year, unit) + expected = ( + np.maximum(expenses - MEDICAL_FLOOR * montana_agi_reference(sim, year), 0) + + premiums ) deduction = per_unit(sim, "mt_medical_expense_deduction_joint", year, unit) np.testing.assert_allclose(deduction, expected, atol=0.01) held = sim.calculate("mt_medical_expense_deduction_joint", year) assert np.all(held[role != "head"] == 0) - assert np.all(deduction >= 0) - assert np.all(deduction <= expenses + 0.01) + assert np.all(deduction >= premiums - 0.01) + assert np.all(deduction <= expenses + premiums + 0.01) spouse_side, head_side = twin_pairs() np.testing.assert_allclose(deduction[spouse_side], deduction[head_side]) assert deduction.max() > 0 # Adding each spouse's Montana AGI after flooring it at zero would change # the deduction somewhere on the grid. summed_individual = per_unit(sim, "mt_agi_indiv", year, unit) - unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + unpooled = np.maximum(expenses - MEDICAL_FLOOR * summed_individual, 0) + premiums assert np.any(np.abs(unpooled - expected) > 1) diff --git a/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py new file mode 100644 index 00000000000..55e26b677b6 --- /dev/null +++ b/policyengine_us/tests/unit/test_mt_medical_premium_deduction_invariants.py @@ -0,0 +1,425 @@ +"""Invariants for Montana's 2021-2023 medical insurance premium deduction. + +The 2021-2023 Form 2 Itemized Deductions Schedule floors only line 1, medical +and dental expenses other than insurance premiums, at 7.5% of Montana AGI. +Line 2 deducts medical insurance premiums in full and line 3 long-term care +insurance premiums in full, except premiums already deducted in Montana AGI +through the self-employed health insurance deduction, and pre-tax premiums. +Former MCA 15-30-2131(1)(a)(iii)-(iv) and (1)(g). From 2024 Montana takes +federal itemized deductions, where premiums stay under the federal floor. + +Why Python rather than YAML: Hypothesis generates mixed populations (single, +head of household and joint returns with dependents; premiums reported +directly, decomposed or with Medicare Part B; derived and supplied +self-employed deductions), and every batch also holds twins of each return +whose differences are asserted. YAML cases check fixed examples; these +properties check relations between generated returns. + +Each batch runs as one vectorized simulation holding three copies of every +tax unit: the drawn return, a twin with higher after-tax and long-term care +premiums for people with no self-employed deduction, and a twin with no +pre-tax premiums. The 7.5% rate is taken from Form 2 line 1c rather than the +model's parameters. Montana AGI and the self-employed deduction are read from +the model; they are inputs to these properties, not derived independently. +For every tax unit: + +1. Accounting (2021-2023): the joint deduction is + max(0, other expenses - 7.5% x Montana AGI) + line 2 + line 3, held by the + head. The separate variant is the same per head and spouse on their own + Montana AGI; dependents hold nothing. +2. Premium conservation, per person, against a closed form: line 2 + line 3 + = max(0, premiums + long-term care premiums - self-employed deduction), + with premiums computed from the raw inputs. Line 2 alone is the premiums + less the part of the deduction they absorb. So the deduction is netted + exactly once. +3. Bounds (2021-2023): premiums are never floored, so the deduction is at + least lines 2 and 3 and at most those plus other expenses. +4. Slope (2021-2023): raising after-tax premiums or long-term care premiums + of people with no self-employed deduction raises the deduction by exactly + the increase. Montana AGI is unchanged by those increases (asserted). +5. Pre-tax premiums never change lines 2 or 3. +6. Line 1 base: other expenses equal federal itemized medical expenses plus + the self-employed premiums those exclude, less the premiums. Line 1 + therefore tracks the federal definition apart from the premiums. +7. Differential (2021-2023): for single filers the joint and separate + variants agree. +8. From 2024 the joint deduction is the federal medical expense deduction and + the separate variant is zero. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +# Dollars; the model computes in float32 and unit amounts reach about $100,000. +TOLERANCE = 0.05 +FORM_FLOOR = 0.075 # Itemized Deductions Schedule line 1c +STATE_YEARS = [2021, 2022, 2023] +FEDERAL_YEAR = 2024 +YEARS = STATE_YEARS + [FEDERAL_YEAR] +VARIANTS = ["base", "raised", "no_pre_tax"] + +amount = st.one_of(st.just(0.0), st.integers(1, 15_000).map(float)) +small_amount = st.one_of(st.just(0.0), st.integers(1, 5_000).map(float)) + + +@st.composite +def people(draw): + self_employed = draw(st.booleans()) + return { + "employment_income": float(draw(st.integers(0, 150_000))), + "self_employed": self_employed, + "self_employment_income": ( + float(draw(st.integers(-20_000, 60_000))) if self_employed else 0.0 + ), + # A deduction claimed on Schedule 1, line 17, supplied as an input. + "supplied_deduction": draw( + st.one_of(st.none(), st.integers(0, 20_000).map(float)) + ), + "premium_source": draw(st.sampled_from(["direct", "decomposed", "medicare"])), + "premium": draw(amount), + "part_b": draw(st.integers(0, 3_000).map(float)), + "other_medical": draw( + st.one_of(st.just(0.0), st.integers(1, 30_000).map(float)) + ), + "long_term_care": draw(small_amount), + "pre_tax": draw(small_amount), + "premium_increase": draw(small_amount), + "long_term_care_increase": draw(small_amount), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(people()), + "spouse": draw(people()) if kind.startswith("joint") else None, + "dependents": [draw(people()) for _ in range(n_dependents)], + } + + +SEED = 20261006 + + +def _seeded_units(n=120): + rng = np.random.default_rng(SEED) + + def maybe(high): + return float(rng.integers(1, high + 1)) if rng.random() < 0.6 else 0.0 + + def person(): + self_employed = bool(rng.random() < 0.3) + return { + "employment_income": float(rng.integers(0, 150_001)), + "self_employed": self_employed, + "self_employment_income": ( + float(rng.integers(-20_000, 60_001)) if self_employed else 0.0 + ), + "supplied_deduction": ( + float(rng.integers(0, 20_001)) if rng.random() < 0.2 else None + ), + "premium_source": str(rng.choice(["direct", "decomposed", "medicare"])), + "premium": maybe(15_000), + "part_b": float(rng.integers(0, 3_001)), + "other_medical": maybe(30_000), + "long_term_care": maybe(5_000), + "pre_tax": maybe(5_000), + "premium_increase": maybe(5_000), + "long_term_care_increase": maybe(5_000), + } + + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "head": person(), + "spouse": person() if kind.startswith("joint") else None, + "dependents": [person() for _ in range(n_dependents)], + } + ) + return units + + +def _may_have_deduction(values): + return values["self_employed"] or values["supplied_deduction"] is not None + + +def _increases(values, variant): + """Premium increases for the raised twin, for people with no deduction.""" + if variant != "raised" or _may_have_deduction(values): + return 0.0, 0.0 + return values["premium_increase"], values["long_term_care_increase"] + + +def _person_inputs(values, role, variant): + premium_increase, long_term_care_increase = _increases(values, variant) + premium = values["premium"] + premium_increase + source = values["premium_source"] + inputs = { + "age": 10 if role == "dependent" else 40, + "is_tax_unit_head": role == "head", + "is_tax_unit_spouse": role == "spouse", + "is_tax_unit_dependent": role == "dependent", + "employment_income": values["employment_income"], + "is_self_employed": values["self_employed"], + "self_employment_income": values["self_employment_income"], + "other_medical_expenses": values["other_medical"], + "long_term_health_insurance_premiums": values["long_term_care"] + + long_term_care_increase, + "pre_tax_health_insurance_premiums": ( + 0.0 if variant == "no_pre_tax" else values["pre_tax"] + ), + "health_insurance_premiums": premium if source == "direct" else 0.0, + "health_insurance_premiums_without_medicare_part_b": ( + 0.0 if source == "direct" else premium + ), + "medicare_enrolled": source == "medicare", + "medicare_part_b_premium": values["part_b"] if source == "medicare" else 0.0, + } + if values["supplied_deduction"] is not None: + inputs["self_employed_health_insurance_ald_person"] = values[ + "supplied_deduction" + ] + return inputs + + +def _expected_premium(values, variant): + """Premiums counted as federal medical expenses, from the inputs alone.""" + premium = values["premium"] + _increases(values, variant)[0] + if values["premium_source"] == "direct" and premium != 0: + return premium + part_b = values["part_b"] if values["premium_source"] == "medicare" else 0.0 + return (0.0 if values["premium_source"] == "direct" else premium) + part_b + + +def _situation(units, year): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + expected_premium, increase, roles, supplied = [], [], [], [] + for variant in VARIANTS: + for i, unit in enumerate(units): + tag = f"{variant}_{i}" + members, couple = [], [] + entries = [("head", unit["head"])] + if unit["spouse"] is not None: + entries.append(("spouse", unit["spouse"])) + entries += [("dependent", d) for d in unit["dependents"]] + unit_increase = 0.0 + for j, (role, values) in enumerate(entries): + name = f"{tag}_{role}_{j}" + people[name] = _person_inputs(values, role, variant) + members.append(name) + expected_premium.append(_expected_premium(values, variant)) + roles.append(role) + supplied.append( + np.nan + if values["supplied_deduction"] is None + else values["supplied_deduction"] + ) + if role == "dependent": + groups["marital_units"][f"{tag}_single_{j}"] = {"members": [name]} + else: + couple.append(name) + unit_increase += sum(_increases(values, "raised")) + increase.append(unit_increase) + groups["marital_units"][f"{tag}_couple"] = {"members": couple} + groups["tax_units"][f"{tag}_tax_unit"] = {"members": members} + groups["households"][f"{tag}_household"] = { + "members": members, + "state_code": "MT", + } + people = { + name: {key: {year: value} for key, value in values.items()} + for name, values in people.items() + } + return ( + {"people": people, **groups}, + np.array(expected_premium), + np.array(increase), + np.array(roles), + np.array(supplied), + ) + + +PERSON_VARIABLES = [ + "other_medical_expenses", + "long_term_health_insurance_premiums", + "medical_expense_health_insurance_premiums", + "self_employed_health_insurance_ald_person", + "mt_eligible_medical_insurance_premiums", + "mt_eligible_long_term_care_insurance_premiums", + "mt_agi_indiv", + "mt_medical_expense_deduction_joint", + "mt_medical_expense_deduction_indiv", +] +UNIT_VARIABLES = [ + "mt_agi_joint", + "itemized_medical_expenses", + "itemized_medical_expenses_applied_exclusion", + "medical_expense_deduction", +] + + +def _run(units, year): + situation, expected_premium, increase, roles, supplied = _situation(units, year) + sim = Simulation(situation=situation) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in PERSON_VARIABLES + UNIT_VARIABLES + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + out["filing_status"] = sim.calculate("filing_status", year).decode_to_str() + out["expected_premium"] = expected_premium + out["increase"] = increase + out["role"] = roles + out["supplied"] = supplied + return out + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values, minlength=len(run["mt_agi_joint"])) + + +def _check(units, year): + run = _run(units, year) + n = len(units) + base, raised, no_pre_tax = (slice(k * n, (k + 1) * n) for k in range(3)) + role = run["role"] + joint = run["mt_medical_expense_deduction_joint"] + indiv = run["mt_medical_expense_deduction_indiv"] + deduction = _unit_sum(run, joint) + assert (joint[role != "head"] == 0).all() + + if year == FEDERAL_YEAR: + # 8. Federal treatment from 2024. + np.testing.assert_allclose( + deduction, run["medical_expense_deduction"], atol=TOLERANCE + ) + assert (indiv == 0).all() + return None + + premium = run["medical_expense_health_insurance_premiums"] + long_term_care = run["long_term_health_insurance_premiums"] + se_deduction = np.maximum(run["self_employed_health_insurance_ald_person"], 0) + line_2 = run["mt_eligible_medical_insurance_premiums"] + line_3 = run["mt_eligible_long_term_care_insurance_premiums"] + other = run["other_medical_expenses"] + + # 2. Premium conservation against a closed form. + np.testing.assert_allclose(premium, run["expected_premium"], atol=TOLERANCE) + supplied = ~np.isnan(run["supplied"]) + np.testing.assert_allclose( + se_deduction[supplied], run["supplied"][supplied], atol=TOLERANCE + ) + np.testing.assert_allclose( + line_2 + line_3, + np.maximum(run["expected_premium"] + long_term_care - se_deduction, 0), + atol=TOLERANCE, + ) + np.testing.assert_allclose( + line_2, + run["expected_premium"] - np.minimum(run["expected_premium"], se_deduction), + atol=TOLERANCE, + ) + assert (line_3 <= long_term_care + TOLERANCE).all() + + # 1. Accounting, joint and separate. + full = _unit_sum(run, line_2 + line_3) + floored = np.maximum(_unit_sum(run, other) - FORM_FLOOR * run["mt_agi_joint"], 0) + np.testing.assert_allclose(deduction, floored + full, atol=TOLERANCE) + filer = role != "dependent" + expected_indiv = filer * ( + np.maximum(other - FORM_FLOOR * run["mt_agi_indiv"], 0) + line_2 + line_3 + ) + np.testing.assert_allclose(indiv, expected_indiv, atol=TOLERANCE) + + # 3. Bounds. + assert (deduction >= full - TOLERANCE).all() + assert (deduction <= full + _unit_sum(run, other) + TOLERANCE).all() + + # 4. Slope of one for people with no self-employed deduction. + np.testing.assert_allclose( + run["mt_agi_joint"][raised], run["mt_agi_joint"][base], atol=TOLERANCE + ) + np.testing.assert_allclose( + deduction[raised] - deduction[base], + run["increase"][raised], + atol=TOLERANCE, + ) + + # 5. Pre-tax premiums never reach lines 2 or 3. + np.testing.assert_allclose( + _unit_sum(run, line_2 + line_3)[no_pre_tax], + _unit_sum(run, line_2 + line_3)[base], + atol=TOLERANCE, + ) + + # 6. Line 1 base tracks the federal definition apart from the premiums. + np.testing.assert_allclose( + _unit_sum(run, other), + run["itemized_medical_expenses"] + + run["itemized_medical_expenses_applied_exclusion"] + - _unit_sum(run, premium), + atol=TOLERANCE, + ) + + # 7. Single filers: joint and separate variants agree. + single = run["filing_status"] == "SINGLE" + np.testing.assert_allclose( + deduction[single], _unit_sum(run, indiv)[single], atol=TOLERANCE + ) + return run, floored, full + + +# A batch's cost is mostly the simulation build and per-variable overhead, so +# each example is a large batch and there are few examples. +SETTINGS = dict( + max_examples=6, + 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=20)) +def test_mt_medical_premium_deduction_invariants(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + result = _check(_seeded_units(), year) + if result is None: + return + run, floored, full = result + # Non-vacuity: some returns deduct premiums while line 1 is below the + # floor, where flooring premiums with other expenses would cut the + # deduction; some deduct line 1 too; some people have a self-employed + # deduction larger than their medical premiums that reduces line 3; and + # there are single and joint returns. + agi_floor = FORM_FLOOR * run["mt_agi_joint"] + total = _unit_sum( + run, + run["other_medical_expenses"] + + run["medical_expense_health_insurance_premiums"], + ) + assert np.any((full > 0) & (total < agi_floor)) + assert np.any((full > 0) & (floored > 0)) + deduction = run["self_employed_health_insurance_ald_person"] + long_term_care = run["long_term_health_insurance_premiums"] + line_3 = run["mt_eligible_long_term_care_insurance_premiums"] + assert np.any( + (deduction > run["medical_expense_health_insurance_premiums"]) + & (line_3 < long_term_care - 1) + ) + assert np.any(run["filing_status"] == "SINGLE") + assert np.any(run["filing_status"] == "JOINT") diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py index 6b2d9b21d76..91538c9b38a 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses.py @@ -8,8 +8,9 @@ class itemized_medical_expenses(Variable): unit = USD definition_period = YEAR reference = [ - "https://www.law.cornell.edu/uscode/text/26/213#a", - "https://www.law.cornell.edu/uscode/text/26/213#d_1", + "https://www.law.cornell.edu/uscode/text/26/213", + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", ] documentation = ( "Medical expenses counted before applying the itemized medical " @@ -19,10 +20,24 @@ class itemized_medical_expenses(Variable): "medical care; qualified long-term care services; and insurance " "premiums covering medical care. Current modeling uses health " "insurance premiums and other medical expenses, excluding general " - "over-the-counter health expenses." + "over-the-counter health expenses. Premiums deducted through the " + "filer's self-employed health insurance deduction are excluded under " + "IRC Section 162(l)(3) before the medical expense floor is applied. " + "Premium inputs use payer attribution: each person reports premiums " + "that person paid, including the full cost of family coverage regardless " + "of whom it covers. A parent-paid dependent policy belongs on the parent. " + "The exclusion is bounded to filer-paid premiums; beneficiary-attributed " + "inputs with an ALD exceeding that pool are inconsistent with this contract " + "and cannot establish conservation of the supplied ALD." ) - adds = [ - "medical_expense_health_insurance_premiums", - "other_medical_expenses", - ] + def formula(tax_unit, period, parameters): + premiums = add(tax_unit, period, ["medical_expense_health_insurance_premiums"]) + # The federal ALD covers the head and spouse; dependents take their + # own SE deductions on their own returns. Bound the exclusion to the + # corresponding filer premiums, leaving other medical costs intact. + excluded_premiums = tax_unit( + "itemized_medical_expenses_excluded_premiums", period + ) + other_expenses = add(tax_unit, period, ["other_medical_expenses"]) + return premiums - excluded_premiums + other_expenses diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py new file mode 100644 index 00000000000..f034c37903a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_applied_exclusion.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class itemized_medical_expenses_applied_exclusion(Variable): + value_type = float + entity = TaxUnit + label = "Self-employed premium exclusion applied to Schedule A medical expenses" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", + ] + documentation = ( + "Premiums actually excluded by the federal medical expense formula. " + "A caller-supplied subtotal bypasses that formula, so no exclusion " + "was applied. States retaining gross paid medical costs restore this " + "amount only for computed subtotals. Premium inputs use payer " + "attribution, including family coverage paid by a filer. The required " + "policyengine-core>=3.32.27 clears input provenance on deletion, so " + "recalculating a deleted subtotal also restores its applied exclusion." + ) + + def formula(tax_unit, period, parameters): + if has_input_for_period( + tax_unit.simulation, "itemized_medical_expenses", period + ): + return 0 + return tax_unit("itemized_medical_expenses_excluded_premiums", period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py new file mode 100644 index 00000000000..c07cab1c17e --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/itemized_medical_expenses_excluded_premiums.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class itemized_medical_expenses_excluded_premiums(Variable): + value_type = float + entity = TaxUnit + label = "Self-employed health insurance premiums excluded from Schedule A" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://www.irs.gov/instructions/i1040sca", + ] + documentation = ( + "The federal self-employed health insurance ALD, limited to premiums " + "paid by the head and spouse. Premium inputs use payer attribution, " + "including family coverage paid by a filer. This preserves supplied " + "tax-unit ALDs without allocating them to individual filers. States " + "that retain gross paid medical costs use " + "itemized_medical_expenses_applied_exclusion to restore only premiums " + "actually excluded by the federal formula." + ) + + def formula(tax_unit, period, parameters): + filer_premiums = tax_unit_non_dep_sum( + "medical_expense_health_insurance_premiums", tax_unit, period + ) + se_health_insurance_ald = tax_unit("self_employed_health_insurance_ald", period) + return min_(filer_premiums, max_(0, se_health_insurance_ald)) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py new file mode 100644 index 00000000000..002b80703b3 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/deductions/itemizing/self_employed_health_insurance_ald_excluded_premiums.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class self_employed_health_insurance_ald_excluded_premiums(Variable): + value_type = float + entity = Person + label = "Medical premiums excluded by a person's self-employed deduction" + unit = USD + definition_period = YEAR + reference = [ + "https://www.law.cornell.edu/uscode/text/26/162#l_3", + "https://pub.njleg.gov/bills/9899/PL99/222_.HTM", + ] + documentation = ( + "Medical premiums paid by a person and separately deducted through " + "that person's self-employed health insurance ALD. Premium inputs " + "use payer attribution, including family coverage paid by that " + "person. The exclusion cannot consume another person's premiums." + ) + + def formula(person, period, parameters): + premiums = person("medical_expense_health_insurance_premiums", period) + deduction = person("self_employed_health_insurance_ald_person", period) + return min_(premiums, max_(0, deduction)) diff --git a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py index ccd57a3c240..f447826f91d 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/subtractions/mo_qualified_health_insurance_premiums.py @@ -4,58 +4,99 @@ class mo_qualified_health_insurance_premiums(Variable): value_type = float entity = Person - label = "Missouri qualified healh insurance premiums" + label = "Missouri qualified health insurance premiums" unit = USD definition_period = YEAR reference = ( - "https://dor.mo.gov/forms/5695.pdf", # MO Form 5695 - "https://www.irs.gov/pub/irs-pdf/f1040sa.pdf", # US Form 1040 Sch A - "https://www.irs.gov/pub/irs-pdf/f1040.pdf", # US Form 1040 + "https://dor.mo.gov/forms/5695.pdf", # Lines 8-17 + "https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=15", + "https://dor.mo.gov/forms/MO-1040%20Instructions_2025.pdf#page=7", + "https://www.irs.gov/pub/irs-pdf/f1040sa.pdf", + ) + documentation = ( + "Health insurance premiums eligible for the Missouri subtraction, " + "excluding premiums already deducted through the self-employed health " + "insurance deduction or federal itemization. Premium inputs follow " + "payer attribution: record a person's premiums on the person who paid " + "them, including all parent-paid family coverage on the parent. Each " + "person's SE deduction exclusion is capped at that person's premiums. " + "Filers' person deductions are limited to the actual tax-unit federal " + "deduction; dependents retain their own deductions. " + "A supplied tax-unit SE deduction without corresponding person " + "deductions is allocated across the filers' remaining premiums. " + "Form 5695's federal medical deduction ratio is rounded to a whole " + "percent; half-percent ties round up, consistent with Missouri's " + "percentage-rounding examples." ) defined_for = StateCode.MO def formula(person, period, parameters): - # Variable involves person- and tax unit-level variables. tax_unit = person.tax_unit - - # total_health_insurance_premiums is also a primary input to - # the MO side of calculation, MO Form 5695, Line 8 - # Federal Schedule A, Line 4 - med_expense_deduction = add(tax_unit, period, ["medical_expense_deduction"]) - - # the ratio of federal medical expense deduction to - # total medical expenses (out of pocket + premiums) - # need division because med_dental_out_of_pocket is - # in federal tax, but no MO tax - # this ratio is then used to scale the health_insurance_premium - # amount that can be claimed - # IRS Schedule A Line 1 - tax_unit_health_expenses = tax_unit("itemized_medical_expenses", period) - med_expense_ratio = np.zeros_like(tax_unit_health_expenses) - mask = tax_unit_health_expenses > 0 - med_expense_ratio[mask] = ( - med_expense_deduction[mask] / tax_unit_health_expenses[mask] + person_premiums = max_(person("health_insurance_premiums", period), 0) + person_se_ald = max_( + person("self_employed_health_insurance_ald_person", period), 0 + ) + filer = not_(person("is_tax_unit_dependent", period)) + tax_unit_se_ald = max_( + tax_unit("self_employed_health_insurance_ald", period), 0 + ) + filers_person_se_ald = tax_unit.sum(person_se_ald * filer) + # A supplied unit deduction can be smaller than the modeled personal + # deductions. Respect the actual amount claimed on the filers' return. + filer_scale = np.divide( + min_(tax_unit_se_ald, filers_person_se_ald), + filers_person_se_ald, + out=np.zeros_like(filers_person_se_ald), + where=filers_person_se_ald > 0, ) + attributed_se_ald = where(filer, person_se_ald * filer_scale, person_se_ald) + # MO-1040 instructions, page 15, exclude federally deducted premiums + # from Form 5695's pool before applying its Schedule A overlap ratio. + person_eligible_premiums = max_(person_premiums - attributed_se_ald, 0) + # Reconcile a directly supplied tax-unit ALD. Compare against actual + # person ALDs, so an unmatched payer's deduction cannot consume another + # person's premiums merely because its own exclusion was capped. + unattributed_ald = max_(tax_unit_se_ald - filers_person_se_ald, 0) + filer_premiums = person_eligible_premiums * filer + tax_unit_filer_premiums = tax_unit.sum(filer_premiums) + unattributed_share = np.divide( + filer_premiums, + tax_unit_filer_premiums, + out=np.zeros_like(filer_premiums), + where=tax_unit_filer_premiums > 0, + ) + person_eligible_premiums -= unattributed_share * min_( + unattributed_ald, tax_unit_filer_premiums + ) + tax_unit_premiums = tax_unit.sum(person_eligible_premiums) - person_premiums = person("health_insurance_premiums", period) - tax_unit_premiums = tax_unit.sum(person_premiums) + # Form 5695 lines 10-12 use Schedule A lines 1 and 4, with the ratio + # rounded to a full percent. Half-up ties follow the percentage example + # in the MO-1040 instructions, page 7 (97.5% becomes 98%). + medical_expenses = tax_unit("itemized_medical_expenses", period) + medical_deduction = tax_unit("medical_expense_deduction", period) + # Divide in float64 before rounding so float32 error cannot turn an + # exact half-percent tie, such as 5,850 / 10,000, into a value below it. + medical_percent = np.divide( + medical_deduction.astype(np.float64) * 100, + medical_expenses, + out=np.zeros_like(medical_expenses, dtype=np.float64), + dtype=np.float64, + where=medical_expenses > 0, + ) + rounded_ratio = np.floor(medical_percent + 0.5) / 100 + remaining_premiums = max_(tax_unit_premiums * (1 - rounded_ratio), 0) - # Line 13 of MO Form 5695, represents the portion of - # medical expenses already deducted via federal tax itemization - deducted_portion = tax_unit_premiums * med_expense_ratio - # subtracts the portion of premiums already deducted from federal tax - itemized_premiums_amount = max_(tax_unit_premiums - deducted_portion, 0) + # Lines 16-17 cap the unit's subtraction at federal taxable income, + # then allocate it by each person's share of the qualifying premiums. + person_share = np.divide( + person_eligible_premiums, + tax_unit_premiums, + out=np.zeros_like(person_eligible_premiums), + where=tax_unit_premiums > 0, + ) itemizes = tax_unit("tax_unit_itemizes", period) - # Cap at federal taxable income. taxable_income = tax_unit("taxable_income", period) - # Allocate proportionally across people in the tax unit based on share - # of premiums. - # Use a mask rather than where to avoid a divide-by-zero warning. Default to zero. - person_share = np.zeros_like(tax_unit_premiums) - mask = tax_unit_premiums > 0 - person_share[mask] = person_premiums[mask] / tax_unit_premiums[mask] - return person_share * where( - itemizes, - min_(itemized_premiums_amount, taxable_income), - min_(tax_unit_premiums, taxable_income), + return person_share * min_( + where(itemizes, remaining_premiums, tax_unit_premiums), taxable_income ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py new file mode 100644 index 00000000000..32c294f6b62 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_long_term_care_insurance_premiums.py @@ -0,0 +1,36 @@ +from policyengine_us.model_api import * + + +class mt_eligible_long_term_care_insurance_premiums(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Montana long-term care insurance premiums deductible in full" + documentation = ( + "A person's long-term care insurance premiums that Montana's " + "2021-2023 Itemized Deductions Schedule line 3 deducts in full, " + "without the federal age-based limit, less any of those premiums the " + "person deducted through the self-employed health insurance " + "deduction. That deduction is applied first to the medical insurance " + "premiums on line 2 and the remainder to these premiums; lines 2 and " + "3 are both deducted in full, so the order does not change the total." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + # MCA 2021 § 15-30-2131(1)(a)(iv) and (1)(g)(ii) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + # 26 U.S.C. 162(l)(2)(C): eligible long-term care premiums can be part + # of the self-employed health insurance deduction. + "https://www.law.cornell.edu/uscode/text/26/162#l_2_C", + ) + unit = USD + defined_for = StateCode.MT + + def formula(person, period, parameters): + premiums = person("long_term_health_insurance_premiums", period) + deduction = max_(0, person("self_employed_health_insurance_ald_person", period)) + used_on_line_2 = person( + "self_employed_health_insurance_ald_excluded_premiums", period + ) + return max_(0, premiums - (deduction - used_on_line_2)) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py new file mode 100644 index 00000000000..23e8eb423d2 --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_eligible_medical_insurance_premiums.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class mt_eligible_medical_insurance_premiums(Variable): + value_type = float + entity = Person + definition_period = YEAR + label = "Montana medical insurance premiums deductible in full" + documentation = ( + "A person's medical insurance premiums that Montana's 2021-2023 " + "Itemized Deductions Schedule line 2 deducts in full: the premiums " + "counted as federal medical expenses, including Medicare Part B, " + "less the premiums that person deducted through the self-employed " + "health insurance deduction. For the head and spouse that deduction " + "is part of Montana AGI; a dependent takes it on the dependent's own " + "return, so those premiums were not paid by the filer. Pre-tax " + "premiums are a separate input that never enters " + "medical_expense_health_insurance_premiums. Premium inputs use payer " + "attribution: each person reports the premiums that person paid." + ) + reference = ( + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=33", + # MCA 2021 § 15-30-2131(1)(a)(iii) and (1)(g)(i) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", + ) + unit = USD + defined_for = StateCode.MT + + def formula(person, period, parameters): + premiums = person("medical_expense_health_insurance_premiums", period) + # Premiums deducted in determining Montana AGI do not qualify: the + # self-employed health insurance deduction (Schedule 1, line 17). + deducted = person( + "self_employed_health_insurance_ald_excluded_premiums", period + ) + return premiums - deducted diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py index f369afc173e..3237224cb20 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_indiv.py @@ -9,20 +9,47 @@ class mt_medical_expense_deduction_indiv(Variable): "Montana medical expense deduction when married couples are filing separately" ) reference = ( + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=36", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", - "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(g)(i) + # PDF pages 32-33 + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", + # MCA 2021 § 15-30-2131(1)(a) and (1)(g) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", ) unit = USD defined_for = "mt_married_filing_separately_on_same_return_eligible" + documentation = ( + "Each spouse's medical deduction when filing separately on the same " + "form. While the Itemized Deductions Schedule applies (2021-2023), " + "line 1 floors the spouse's medical and dental expenses other than " + "premiums at 7.5% of their Montana AGI, and lines 2 and 3 add their " + "medical and long-term care insurance premiums in full, net of their " + "self-employed health insurance deduction. If a reform allows " + "separate filing in a later year, the premiums net of that deduction " + "stay under the floor with the other expenses. Premium inputs use " + "payer attribution: report all premiums paid by a person on that " + "person, including family coverage regardless of the covered " + "beneficiary." + ) def formula(person, period, parameters): - expense = person("medical_expense_health_insurance_premiums", period) + person( - "other_medical_expenses", period - ) - p = parameters(period).gov.irs.deductions.itemized.medical + p = parameters(period).gov.states.mt.tax.income.deductions.itemized + floor = parameters(period).gov.irs.deductions.itemized.medical.floor + expense = person("other_medical_expenses", period) # Law does not define Montana AGI as the cap. # Tax form points to page 1, line 14, which is Montana AGI. - medical_floor = p.floor * person("mt_agi_indiv", period) + medical_floor = floor * person("mt_agi_indiv", period) + premiums = person("mt_eligible_medical_insurance_premiums", period) + if p.state_specific_deduction_applies: + # Line 1 floors expenses other than premiums; lines 2 and 3 + # deduct the premiums in full. + long_term_care_premiums = person( + "mt_eligible_long_term_care_insurance_premiums", period + ) + deduction = ( + max_(0, expense - medical_floor) + premiums + long_term_care_premiums + ) + else: + deduction = max_(0, premiums + expense - medical_floor) is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return is_head_or_spouse * max_(0, expense - medical_floor) + return is_head_or_spouse * deduction diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py index 182f474d45d..68a8759b7ae 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/medical/mt_medical_expense_deduction_joint.py @@ -8,10 +8,11 @@ class mt_medical_expense_deduction_joint(Variable): label = "Montana medical expense deduction when married couples are filing jointly" reference = ( "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2022_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=7", + # PDF pages 32-33 "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2022_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=32", "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=15", - "https://law.justia.com/codes/montana/2022/title-15/chapter-30/part-21/section-15-30-2131/", - # MT Code § 15-30-2131 (2022) (1)(a) + # MCA 2021 § 15-30-2131(1)(a) and (1)(g) + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0310/0150-0300-0210-0310.html", ) unit = USD defined_for = StateCode.MT @@ -19,13 +20,28 @@ class mt_medical_expense_deduction_joint(Variable): def formula(person, period, parameters): p = parameters(period).gov.states.mt.tax.income.deductions.itemized if p.state_specific_deduction_applies: - expense = person.tax_unit("itemized_medical_expenses", period) + tax_unit = person.tax_unit floor = parameters(period).gov.irs.deductions.itemized.medical.floor - # Itemized Deductions Schedule line 1b takes Montana AGI from - # page 1, line 14. A joint return reports that line in a single - # column, so the floor applies to the return's pooled Montana AGI. - montana_agi = person.tax_unit("mt_agi_joint", period) - deduction = max_(0, expense - floor * montana_agi) + # Itemized Deductions Schedule line 1 takes medical and dental + # expenses other than the insurance premiums deducted in full on + # lines 2 and 3. + expense = add(tax_unit, period, ["other_medical_expenses"]) + # Line 1b takes Montana AGI from page 1, line 14. A joint return + # reports that line in a single column, so the floor applies to + # the return's pooled Montana AGI. + montana_agi = tax_unit("mt_agi_joint", period) + floored_expense = max_(0, expense - floor * montana_agi) + # Lines 2 and 3: medical and long-term care insurance premiums not + # deducted elsewhere, in full. + premiums = add( + tax_unit, + period, + [ + "mt_eligible_medical_insurance_premiums", + "mt_eligible_long_term_care_insurance_premiums", + ], + ) + deduction = floored_expense + premiums else: # From 2024, Form 2 line 2 takes federal itemized deductions # (Worksheet A), so the federal floor on federal AGI applies. diff --git a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py index cf6e3da8f99..7f9aaeaab05 100644 --- a/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py +++ b/policyengine_us/variables/gov/states/nd/tax/property/renters_refund/nd_renters_refund_income.py @@ -13,10 +13,27 @@ class nd_renters_refund_income(Variable): ) defined_for = StateCode.ND + documentation = ( + "Renter refund income after unreimbursed medical costs actually paid. " + "The paid-cost base restores self-employed premiums only when the " + "federal formula excluded them. Caller-supplied subtotals remain " + "unchanged, including when components are supplied. Premium inputs use payer " + "attribution: each person reports premiums that person paid, including " + "family coverage regardless of whom it covers." + ) + def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nd.tax.property.renters_refund + # Restore only premiums excluded from a computed federal subtotal. + medical_expenses = add( + tax_unit, + period, + [ + "itemized_medical_expenses", + "itemized_medical_expenses_applied_exclusion", + ], + ) return max_( - add(tax_unit, period, p.income_sources) - - tax_unit("itemized_medical_expenses", period), + add(tax_unit, period, p.income_sources) - medical_expenses, 0, ) diff --git a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py index 03bbb514baf..b5d778fcfef 100644 --- a/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/states/nj/tax/income/deductions/nj_medical_expense_deduction.py @@ -8,6 +8,20 @@ class nj_medical_expense_deduction(Variable): unit = USD definition_period = YEAR defined_for = StateCode.NJ + documentation = ( + "New Jersey's separate self-employed health insurance deduction plus " + "medical expenses above the state floor, excluding premiums already " + "deducted separately. Premium inputs follow payer attribution: each " + "person reports the premiums they paid, including family coverage " + "regardless of who is covered. Each dependent's exclusion is limited " + "to that dependent's own medical premiums. A dependent aggregate ALD " + "override must identify the corresponding person ALDs for exclusion; " + "the total alone does not establish which person paid the premiums." + ) + reference = ( + "https://pub.njleg.gov/bills/9899/PL99/222_.HTM", + "https://www.nj.gov/treasury/taxation/pdf/current/1040i.pdf#page=25", + ) def formula(tax_unit, period, parameters): p = parameters(period).gov.states.nj.tax.income.deductions.medical_expenses @@ -21,7 +35,18 @@ def formula(tax_unit, period, parameters): "dependents_self_employed_health_insurance_ald", ], ) - medical_expenses = tax_unit("itemized_medical_expenses", period) + # C.54A:3-3 excludes premiums counted by the separate SE deduction + # under C.54A:3-5, including dependents' deductions recognized by NJ. + # The federal base already excludes the filers' deduction. Apply + # each dependent's deduction only to that person's own paid premiums. + person = tax_unit.members + excluded_dependent_premiums = tax_unit.sum( + person("self_employed_health_insurance_ald_excluded_premiums", period) + * person("is_tax_unit_dependent", period) + ) + medical_expenses = ( + tax_unit("itemized_medical_expenses", period) - excluded_dependent_premiums + ) agi = tax_unit("nj_agi", period) floor = p.rate * agi applicable_medical_expenses = max_(0, medical_expenses - floor) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py index e516884f45e..7d584b8d7df 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/credits/nm_medical_expense_credit.py @@ -9,12 +9,30 @@ class nm_medical_expense_credit(Variable): reference = "https://nmonesource.com/nmos/nmsa/en/item/4340/index.do#!fragment/zoupio-_Toc140503776/BQCwhgziBcwMYgK4DsDWszIQewE4BUBTADwBdoAvbRABwEtsBaAfX2zgEYAWABgFYeAZgDswgGwBKADTJspQhACKiQrgCe0AOSapEQmFwJlqjdt37DIAMp5SAIQ0AlAKIAZZwDUAggDkAws5SpGAARtCk7BISQA" defined_for = StateCode.NM + documentation = ( + "Senior medical credit based on gross unreimbursed costs paid, preserving " + "premiums deducted through the federal self-employed health insurance ALD. " + "Only the exclusion applied to a computed federal medical amount is " + "restored; caller-supplied subtotals remain unchanged even with components. " + "This assumes qualifying costs were funded from income included in AGI. " + "Premium inputs use payer attribution: each person reports premiums that " + "person paid, including family coverage regardless of whom it covers. " + ) + def formula(tax_unit, period, parameters): person = tax_unit.members pcredits = parameters(period).gov.states.nm.tax.income.credits p = pcredits.unreimbursed_medical_care_expense age = person("age", period) - medical_expense = tax_unit("itemized_medical_expenses", period) + # Restore only premiums excluded from a computed federal subtotal. + medical_expense = add( + tax_unit, + period, + [ + "itemized_medical_expenses", + "itemized_medical_expenses_applied_exclusion", + ], + ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses dependent_elsewhere = tax_unit("head_is_dependent_elsewhere", period) diff --git a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py index 3b5bed34f4a..9ded2f096e7 100644 --- a/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py +++ b/policyengine_us/variables/gov/states/nm/tax/income/exemptions/nm_medical_expense_exemption.py @@ -9,13 +9,33 @@ class nm_medical_expense_exemption(Variable): reference = "https://nmonesource.com/nmos/nmsa/en/item/4340/index.do#!fragment/zoupio-_Toc140503680/BQCwhgziBcwMYgK4DsDWszIQewE4BUBTADwBdoAvbRABwEtsBaAfX2zgEYAWABgFYeAZgBsADh4BKADTJspQhACKiQrgCe0AOSapEQmFwJlqjdt37DIAMp5SAIQ0AlAKIAZZwDUAggDkAws5SpGAARtCk7BISQA" defined_for = StateCode.NM + documentation = ( + "Senior medical exemption based on gross unreimbursed costs paid, " + "preserving premiums deducted through the federal self-employed health " + "insurance ALD. Only the exclusion applied to a computed federal medical " + "amount is restored; caller-supplied subtotals remain unchanged even " + "with components. " + "This assumes qualifying costs were funded from income " + "included in AGI. Premium inputs use payer attribution: each person " + "reports premiums that person paid, including family coverage regardless " + "of whom it covers. " + ) + def formula(tax_unit, period, parameters): person = tax_unit.members p = parameters( period ).gov.states.nm.tax.income.exemptions.unreimbursed_medical_care_expense age = person("age", period) - medical_expense = tax_unit("itemized_medical_expenses", period) + # Restore only premiums excluded from a computed federal subtotal. + medical_expense = add( + tax_unit, + period, + [ + "itemized_medical_expenses", + "itemized_medical_expenses_applied_exclusion", + ], + ) age_eligible = tax_unit.any(age >= p.age_eligibility) expense_eligible = medical_expense >= p.min_expenses eligible = age_eligible & expense_eligible diff --git a/pyproject.toml b/pyproject.toml index a24313f371f..4e718696fb3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ dependencies = [ # pandas 3 copy-on-write enables the shallow-copy fast path in dataset # extension and is available on every supported Python version. "pandas>=3.0", - "policyengine-core>=3.32.8", + "policyengine-core>=3.32.27", "spm-calculator>=1.0.0,<=1.0.0.post1", "tables>=3.9", "tqdm>=4.67.1", diff --git a/uv.lock b/uv.lock index 09f9f330dc9..365deb9c83c 100644 --- a/uv.lock +++ b/uv.lock @@ -1791,7 +1791,7 @@ wheels = [ [[package]] name = "policyengine-core" -version = "3.32.8" +version = "3.32.27" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dpath" }, @@ -1811,9 +1811,9 @@ dependencies = [ { name = "standard-imghdr" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/12/df/2fe428d690f02cb109e91d22b8af31ca6dcb047920d7177a2b28487a8ea1/policyengine_core-3.32.8.tar.gz", hash = "sha256:925a8990985ec22885f4d6c4ed129cd13dcc2eab02cf9a130939ca563394fcc3", size = 426369, upload-time = "2026-09-27T19:10:50.428Z" } +sdist = { url = "https://files.pythonhosted.org/packages/de/24/84c0747c64fba95e89d3acd332604ad01e8a220ce7e80cf8d342b3b213bd/policyengine_core-3.32.27.tar.gz", hash = "sha256:632e8708ec243f7b592ec857e1978df207547c1510039373e707c310173cf4e4", size = 599334, upload-time = "2026-10-09T05:44:09.236Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/45/bbba25be7ddc52ad9bb6f2b8ea044eb3c670f4168c67044328f631ccb025/policyengine_core-3.32.8-py3-none-any.whl", hash = "sha256:84e470d1f03d531d1d5ee77c173c59444bbbdea50132faf9150a82aa00d54397", size = 246996, upload-time = "2026-09-27T19:10:48.871Z" }, + { url = "https://files.pythonhosted.org/packages/cc/b3/fc1a10b03f2a159bc5623f0aa465887d1c1c98ee2f3118fd9ee3e8275921/policyengine_core-3.32.27-py3-none-any.whl", hash = "sha256:d0b0135939252ed628efd496988f76c0ab89bfa3b3437aa90f39000667c37687", size = 278496, upload-time = "2026-10-09T05:44:07.764Z" }, ] [[package]] @@ -1850,7 +1850,7 @@ requires-dist = [ { name = "jupyter-book", marker = "extra == 'dev'", specifier = ">=1.0.4.post1" }, { name = "microdf-python", specifier = ">=1.0.0" }, { name = "pandas", specifier = ">=3.0" }, - { name = "policyengine-core", specifier = ">=3.32.8" }, + { name = "policyengine-core", specifier = ">=3.32.27" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.9.0" }, { name = "setuptools", marker = "extra == 'dev'", specifier = ">=80.9.0" }, { name = "spm-calculator", specifier = ">=1.0.0,<=1.0.0.post1" },