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_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..3bf6f36d639 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 @@ -27,3 +27,35 @@ is_tax_unit_head_or_spouse: true 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: + mt_medical_expense_deduction_indiv: [3_250, 750] 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..af405bf3b43 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,33 @@ # 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: + # (6,000 + 3,000 - 1,000 - 2,000 + 1,000) - 7.5% of 40,000. + mt_medical_expense_deduction_joint: [4_000, 0] 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/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_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..5faa21eefb9 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,6 +9,7 @@ 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) @@ -16,8 +17,22 @@ class mt_medical_expense_deduction_indiv(Variable): unit = USD defined_for = "mt_married_filing_separately_on_same_return_eligible" + documentation = ( + "Medical deduction after excluding the same person's self-employed " + "health insurance premiums and applying the medical floor. 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( + premiums = person("medical_expense_health_insurance_premiums", period) + se_health_insurance_ald = person( + "self_employed_health_insurance_ald_person", period + ) + # Separately computed medical expenses exclude the same person's + # premiums already deducted from income. + expense = max_(0, premiums - se_health_insurance_ald) + person( "other_medical_expenses", period ) p = parameters(period).gov.irs.deductions.itemized.medical 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 500ae45212a..77b25e155fc 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 802b412de09..29a0c09e1d4 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" },