diff --git a/changelog.d/medicaid-magi-joint-floor.fixed.md b/changelog.d/medicaid-magi-joint-floor.fixed.md new file mode 100644 index 00000000000..4cc013f3c77 --- /dev/null +++ b/changelog.d/medicaid-magi-joint-floor.fixed.md @@ -0,0 +1 @@ +Medicaid MAGI household income now nets one household member's negative MAGI-based income, such as a spouse's business loss on a joint return, against the others' income and floors only the household's total at zero (42 CFR 435.603(d)(1); Form 8962, line 3). Each person's Medicaid AGI now subtracts their own above-the-line deductions, and a self-employment loss no longer counts as unearned income in the Medicaid filing-requirement test. diff --git a/policyengine_us/tests/core/test_medicaid_household_income_invariants.py b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py new file mode 100644 index 00000000000..4ac079af13f --- /dev/null +++ b/policyengine_us/tests/core/test_medicaid_household_income_invariants.py @@ -0,0 +1,504 @@ +"""Invariants for Medicaid MAGI household income when members have losses. + +Household income is the sum of the MAGI-based income of every member of the +household (42 CFR 435.603(d)(1)), figured with the 36B methodology +(435.603(e)). On a joint return the couple has one AGI, so one spouse's loss +offsets the other's income, and Form 8962 line 3 combines the couple's and +their dependents' modified AGIs "even if one or both of them are negative" +before entering a negative total as zero. So no member's MAGI-based income is +floored; only the household's total is. + +Hypothesis draws batches of households: joint filers with up to two +dependent children, a married couple who do not file, cohabiting spouses who +file separately, and single filers. Spouses draw wages, self-employment and +rental income that can be losses, capital gains or losses, interest, Social +Security, and their own IRA, early withdrawal penalty, student loan interest +and alimony deductions. Children draw wages, interest, tax-exempt interest and +IRA contributions, so some are required to file. A seeded population of 200 +households adds breadth. Each batch runs as one vectorized simulation, and +again with metamorphic changes. For every household: + +1. Joint filers: each member's household income is + max(0, J + D), where J is the couple's MAGI, figured independently from + the tax unit's adjusted_gross_income plus the MAGI additions, and D is the + sum of the MAGI of the children required to file. When J >= 0 and every + counted child's MAGI is >= 0 this is max(0, J) + D. +2. Accounting: the head's and spouse's medicaid_magi_person add up to J, and + a dependent's Medicaid AGI is their own gross income less their own + above-the-line deductions. +3. Non-filing couples and cohabiting spouses filing separately: each + spouse's household income is max(0, the two spouses' MAGI added). + Single filers: max(0, their MAGI). +4. Household income is never negative, and is zero exactly when the + unfloored sum is not positive. +5. Metamorphic: removing the spouse's own IRA deduction, early withdrawal + penalty and alimony leaves the head's deductions unchanged apart from + IRA and student-loan deductions subject to shared limits; swapping + the head's and spouse's incomes and deductions leaves every member's + household income unchanged; and lowering the spouse's self-employment + income never raises household income. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +YEAR = 2025 +TOLERANCE = 0.01 # dollars + +# Remove these spouse inputs to check attribution of independent deductions. +# IRA and student-loan deductions are excluded from the head's comparison: +# joint MAGI affects both, and Section 219(c) shares IRA compensation. +SPOUSE_ONLY_DEDUCTION_INPUTS = [ + "traditional_ira_contributions", + "early_withdrawal_penalty", + "alimony_expense", +] +OUTPUTS = [ + "medicaid_household_income", + "medicaid_magi_person", + "medicaid_adjusted_gross_income_person", + "medicaid_irs_gross_income", + "medicaid_person_is_required_to_file", + "medicaid_uses_non_filer_rules", + "above_the_line_deductions_person", + "traditional_ira_deduction", + "student_loan_interest_ald", + "tax_exempt_interest_income", +] +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "tax_exempt_social_security", + "foreign_earned_income_exclusion", +] + +amount = st.integers(1, 60_000).map(float) +maybe = lambda strategy: st.one_of(st.just(0.0), strategy) # noqa: E731 + + +@st.composite +def spouse_amounts(draw): + return { + "employment_income": draw(maybe(amount)), + "self_employment_income": draw(maybe(st.integers(-40_000, 60_000).map(float))), + "rental_income": draw(maybe(st.integers(-15_000, 20_000).map(float))), + "long_term_capital_gains": draw(maybe(st.integers(-10_000, 10_000).map(float))), + "taxable_interest_income": draw(maybe(st.integers(1, 3_000).map(float))), + "tax_exempt_interest_income": draw(maybe(st.integers(1, 2_000).map(float))), + "social_security_retirement": draw(maybe(st.integers(1, 30_000).map(float))), + "traditional_ira_contributions": draw(maybe(st.integers(1, 7_000).map(float))), + "early_withdrawal_penalty": draw(maybe(st.integers(1, 500).map(float))), + "student_loan_interest": draw(maybe(st.integers(1, 3_000).map(float))), + "alimony_expense": draw(maybe(st.integers(1, 12_000).map(float))), + "divorce_year": draw(st.sampled_from([2010, 2020])), + } + + +@st.composite +def child_amounts(draw): + return { + "age": draw(st.integers(5, 17)), + "employment_income": draw(maybe(st.integers(1, 30_000).map(float))), + "taxable_interest_income": draw(maybe(st.integers(1, 3_000).map(float))), + "tax_exempt_interest_income": draw(maybe(st.integers(1, 1_000).map(float))), + "traditional_ira_contributions": draw(maybe(st.integers(1, 3_000).map(float))), + } + + +@st.composite +def households(draw): + kind = draw(st.sampled_from(["joint", "joint", "non_filer", "separate", "single"])) + n_children = draw(st.integers(0, 2)) if kind == "joint" else 0 + return { + "kind": kind, + "head": draw(spouse_amounts()), + "spouse": None if kind == "single" else draw(spouse_amounts()), + "children": [draw(child_amounts()) for _ in range(n_children)], + } + + +SEED = 20261006 + + +def _seeded_households(n=200): + rng = np.random.default_rng(SEED) + + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + def spouse(): + return { + "employment_income": some(1, 60_000, 0.6), + "self_employment_income": some(-40_000, 60_000, 0.4), + "rental_income": some(-15_000, 20_000, 0.2), + "long_term_capital_gains": some(-10_000, 10_000, 0.2), + "taxable_interest_income": some(1, 3_000), + "tax_exempt_interest_income": some(1, 2_000, 0.2), + "social_security_retirement": some(1, 30_000, 0.2), + "traditional_ira_contributions": some(1, 7_000, 0.3), + "early_withdrawal_penalty": some(1, 500, 0.2), + "student_loan_interest": some(1, 3_000, 0.3), + "alimony_expense": some(1, 12_000, 0.2), + "divorce_year": int(rng.choice([2010, 2020])), + } + + units = [] + for _ in range(n): + kind = str(rng.choice(["joint", "joint", "non_filer", "separate", "single"])) + n_children = int(rng.integers(0, 3)) if kind == "joint" else 0 + units.append( + { + "kind": kind, + "head": spouse(), + "spouse": None if kind == "single" else spouse(), + "children": [ + { + "age": int(rng.integers(5, 18)), + "employment_income": some(1, 30_000), + "taxable_interest_income": some(1, 3_000), + "tax_exempt_interest_income": some(1, 1_000, 0.3), + "traditional_ira_contributions": some(1, 3_000, 0.3), + } + for _ in range(n_children) + ], + } + ) + return units + + +def _situation(units): + people = {} + groups = { + "tax_units": {}, + "households": {}, + "marital_units": {}, + "families": {}, + "spm_units": {}, + } + kinds, roles = [], [] + for i, u in enumerate(units): + head, spouse = f"head_{i}", f"spouse_{i}" + people[head] = {"age": 45, **u["head"]} + members, roles_i = [head], ["head"] + if u["spouse"] is not None: + people[spouse] = {"age": 45, **u["spouse"]} + members.append(spouse) + roles_i.append("spouse") + groups["marital_units"][f"couple_{i}"] = {"members": [head, spouse]} + else: + groups["marital_units"][f"couple_{i}"] = {"members": [head]} + if u["kind"] == "separate": + people[head]["is_tax_unit_head"] = True + people[spouse]["is_tax_unit_head"] = True + for name, member in (("a", head), ("b", spouse)): + groups["tax_units"][f"tax_unit_{i}{name}"] = { + "members": [member], + "cohabitating_spouses": {YEAR: True}, + "tax_unit_is_filer": {YEAR: True}, + } + else: + people[head]["is_tax_unit_head"] = True + if u["spouse"] is not None: + people[spouse]["is_tax_unit_spouse"] = True + for j, child in enumerate(u["children"]): + name = f"child_{i}_{j}" + people[name] = {"is_tax_unit_dependent": True, **child} + members.append(name) + roles_i.append("child") + groups["marital_units"][f"child_{i}_{j}"] = {"members": [name]} + if u["kind"] != "separate": + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "tax_unit_is_filer": {YEAR: u["kind"] != "non_filer"}, + } + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {YEAR: "TX"}, + } + # Each household is its own family, so the non-filer rules' parents + # and children come from the household only. + groups["families"][f"family_{i}"] = {"members": members} + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + kinds += [u["kind"]] * len(members) + roles += roles_i + people = { + name: {k: {YEAR: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups}, np.array(kinds), np.array(roles) + + +def _run(units): + situation, kinds, roles = _situation(units) + sim = Simulation(situation=situation) + out = {name: np.asarray(sim.calculate(name, YEAR), dtype=float) for name in OUTPUTS} + for name in TAX_UNIT_OUTPUTS: + out[name] = np.asarray(sim.calculate(name, YEAR, map_to="person"), dtype=float) + out["kind"], out["role"] = kinds, roles + # People are listed household by household, so the household index + # groups each household's members. + out["household"] = sim.populations["household"].members_entity_id + return out + + +def _household_sum(run, values): + return np.bincount(run["household"], weights=values)[run["household"]] + + +def _swap_spouses(units): + swapped = [] + for u in units: + if u["spouse"] is None: + swapped.append(u) + else: + swapped.append({**u, "head": u["spouse"], "spouse": u["head"]}) + return swapped + + +def _without_spouse_deductions(units): + changed = [] + for u in units: + if u["spouse"] is None: + changed.append(u) + continue + spouse = {**u["spouse"], **{k: 0.0 for k in SPOUSE_ONLY_DEDUCTION_INPUTS}} + changed.append({**u, "spouse": spouse}) + return changed + + +def _lower_spouse_self_employment(units, by=7_500.0): + changed = [] + for u in units: + if u["spouse"] is None: + changed.append(u) + continue + spouse = { + **u["spouse"], + "self_employment_income": u["spouse"]["self_employment_income"] - by, + } + changed.append({**u, "spouse": spouse}) + return changed + + +def _check(units): + run = _run(units) + income = run["medicaid_household_income"] + magi = run["medicaid_magi_person"] + kind, role = run["kind"], run["role"] + adult = role != "child" + child = role == "child" + counted_child = child & (run["medicaid_person_is_required_to_file"] > 0) + + # 4. Never negative. + assert (income >= 0).all() + + # 1 and 2. Joint filers. The couple's MAGI, from the tax unit's AGI and + # the MAGI additions (36B(d)(2)(B)): the head's and spouse's tax-exempt + # interest, the foreign earned income exclusion and nontaxable Social + # Security, which the children here do not have. + joint = kind == "joint" + couple_magi = ( + run["adjusted_gross_income"] + + _household_sum(run, adult * run["tax_exempt_interest_income"]) + + run["foreign_earned_income_exclusion"] + + run["tax_exempt_social_security"] + ) + np.testing.assert_allclose( + _household_sum(run, adult * magi)[joint], + couple_magi[joint], + atol=TOLERANCE, + err_msg="spouses' MAGI do not add up to the couple's MAGI", + ) + children_magi = _household_sum(run, counted_child * magi) + assert not run["medicaid_uses_non_filer_rules"][joint].any() + np.testing.assert_allclose( + income[joint], + np.maximum(0, couple_magi + children_magi)[joint], + atol=TOLERANCE, + err_msg="joint household income is not max(0, J + D)", + ) + nonnegative = (couple_magi >= 0) & ( + _household_sum(run, counted_child * (magi < 0)) == 0 + ) + np.testing.assert_allclose( + income[joint & nonnegative], + (np.maximum(0, couple_magi) + children_magi)[joint & nonnegative], + atol=TOLERANCE, + ) + # A dependent's Medicaid AGI is their own gross income less their own + # deductions. + np.testing.assert_allclose( + run["medicaid_adjusted_gross_income_person"][child], + (run["medicaid_irs_gross_income"] - run["above_the_line_deductions_person"])[ + child + ], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + magi[child], + ( + run["medicaid_adjusted_gross_income_person"] + + run["tax_exempt_interest_income"] + )[child], + atol=TOLERANCE, + ) + + # 3. Couples outside a joint return, and single filers. + pair = (kind == "non_filer") | (kind == "separate") + np.testing.assert_allclose( + income[pair], + np.maximum(0, _household_sum(run, magi))[pair], + atol=TOLERANCE, + err_msg="spouses outside a joint return are not netted", + ) + single = kind == "single" + np.testing.assert_allclose( + income[single], np.maximum(0, magi)[single], atol=TOLERANCE + ) + + # 4. Zero exactly when the unfloored sum is not positive. + unfloored = np.where( + joint, + couple_magi + children_magi, + np.where(pair, _household_sum(run, magi), magi), + ) + np.testing.assert_allclose(income, np.maximum(0, unfloored), atol=TOLERANCE) + assert (income[unfloored <= 0] == 0).all() + + # 5. Metamorphic relations. The spouse's own deductions never change the + # head's independent deductions. IRA and student-loan deductions can + # change with joint MAGI, and removing the spouse's IRA contributions + # can free compensation for the head under Section 219(c). + head = role == "head" + without = _run(_without_spouse_deductions(units)) + head_deductions = ( + run["above_the_line_deductions_person"] + - run["student_loan_interest_ald"] + - run["traditional_ira_deduction"] + ) + np.testing.assert_allclose( + ( + without["above_the_line_deductions_person"] + - without["student_loan_interest_ald"] + - without["traditional_ira_deduction"] + )[head], + head_deductions[head], + atol=TOLERANCE, + err_msg="the spouse's own deductions changed the head's deductions", + ) + swapped = _run(_swap_spouses(units)) + np.testing.assert_allclose( + swapped["medicaid_household_income"], + income, + atol=TOLERANCE, + err_msg="swapping the spouses changed household income", + ) + lowered = _run(_lower_spouse_self_employment(units)) + assert (lowered["medicaid_household_income"] <= income + TOLERANCE).all() + + +SETTINGS = dict( + max_examples=20, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(households(), min_size=10, max_size=40)) +def test_medicaid_household_income_nets_members_before_the_floor(units): + _check(units) + + +def test_seeded_population(): + _check(_seeded_households()) + + +@pytest.mark.parametrize( + "head, spouse, expected", + [ + # The brief's example: 30,000 of wages and a 20,000 Schedule C loss. + ( + {"employment_income": 30_000.0}, + {"self_employment_income": -20_000.0}, + 10_000, + ), + # One spouse's 6,000 IRA deduction, the other spouse with no income. + ( + {"employment_income": 30_000.0, "traditional_ira_contributions": 6_000.0}, + {}, + 24_000, + ), + ], +) +def test_joint_examples(head, spouse, expected): + units = [{"kind": "joint", "head": head, "spouse": spouse, "children": []}] + run = _run(units) + np.testing.assert_allclose( + run["medicaid_household_income"], [expected, expected], atol=TOLERANCE + ) + + +@settings(**SETTINGS) +@given(st.lists(st.integers(-60_000, 60_000), min_size=6, max_size=6)) +def test_linked_non_filer_income_matches_signed_legal_membership(magi): + """Linked members net signed income; excluded relatives cannot affect it. + + The two unmarried parents share two children. The grandmother and an + unrelated adult live in the same family and household. Under + 42 CFR 435.603(f)(3), each parent's household includes their children, + and each child's includes both parents and their sibling. Neither child + includes the grandmother or unrelated adult. All members' counted MAGI + is supplied directly so this property isolates membership and netting. + """ + names = ["grandmother", "mother", "father", "child", "sibling", "unrelated"] + ages = [65, 35, 35, 10, 12, 30] + people = { + name: { + "person_id": {YEAR: i + 1}, + "age": {YEAR: ages[i]}, + "medicaid_magi_person": {YEAR: magi[i]}, + # Count the children's MAGI under 435.603(d)(2)(i), including + # negative amounts. Filing flags are inputs to this income test. + "medicaid_person_is_required_to_file": {YEAR: True}, + } + for i, name in enumerate(names) + } + people["mother"]["parent_1_id"] = {YEAR: 1} + for child in ("child", "sibling"): + people[child].update(parent_1_id={YEAR: 2}, parent_2_id={YEAR: 3}) + situation = { + "people": people, + "tax_units": { + name: {"members": [name], "tax_unit_is_filer": {YEAR: False}} + for name in names + }, + "marital_units": {name: {"members": [name]} for name in names}, + "families": {"family": {"members": names}}, + "households": {"home": {"members": names, "state_code": {YEAR: "OH"}}}, + } + # Independent reference membership, rather than reusing a model helper. + members = np.array( + [ + [1, 0, 0, 0, 0, 0], + [0, 1, 0, 1, 1, 0], + [0, 0, 1, 1, 1, 0], + [0, 1, 1, 1, 1, 0], + [0, 1, 1, 1, 1, 0], + [0, 0, 0, 0, 0, 1], + ] + ) + simulation = Simulation(situation=situation) + assert simulation.calculate("medicaid_uses_non_filer_rules", YEAR).all() + np.testing.assert_array_equal( + simulation.calculate("medicaid_household_size", YEAR), members.sum(axis=1) + ) + np.testing.assert_allclose( + simulation.calculate("medicaid_household_income", YEAR), + np.maximum(0, members @ np.asarray(magi)), + atol=TOLERANCE, + ) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml index b35bcc9e710..904d7092a73 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.yaml @@ -123,3 +123,144 @@ output: adjusted_gross_income: 47_000 medicaid_adjusted_gross_income_person: 47_000 + +- name: Case 6, a spouse's own deductions lower only that spouse's Medicaid AGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 50_000 + is_tax_unit_head: true + spouse: + age: 40 + employment_income: 20_000 + traditional_ira_contributions: 5_000 + early_withdrawal_penalty: 200 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Head: 50,000. Spouse: 20,000 - 5,000 - 200 = 14,800. They add up to the + # joint AGI, 64,800. + adjusted_gross_income: 64_800 + medicaid_adjusted_gross_income_person: [50_000, 14_800] + +- name: Case 7, a spouse's business loss lowers only that spouse's Medicaid AGI, below zero. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 10_000 + medicaid_adjusted_gross_income_person: [30_000, -20_000] + +- name: Case 8, savings bond interest recorded on a dependent is the filer's exclusion. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 50_000 + is_tax_unit_head: true + student: + age: 20 + is_full_time_student: true + employment_income: 20_000 + us_bonds_for_higher_ed: 1_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, student] + households: + household: + members: [parent, student] + state_code: TX + output: + # gov.irs.ald.filer_amounts_recorded_on_dependents: the exclusion is the + # parent's (Form 8815), so it lowers the parent's AGI, 50,000 - 1,000, + # and not the student's own, 20,000. + adjusted_gross_income: 49_000 + medicaid_adjusted_gross_income_person: [49_000, 20_000] + +- name: Case 9, a supplied spouse HSA deduction lowers only that spouse's AGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 60_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + health_savings_account_ald_person: 4_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # Each spouse figures their own Form 8889 deduction; the return combines + # the two line 13 amounts (2025 instructions, page 4). + # https://www.irs.gov/pub/irs-pdf/i8889.pdf#page=4 + adjusted_gross_income: 96_000 + above_the_line_deductions_person: [0, 4_000] + medicaid_adjusted_gross_income_person: [60_000, 36_000] + +- name: Case 10, unrecorded person HSA amounts preserve the equal filer fallback. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + is_tax_unit_head: true + employment_income: 60_000 + spouse: + age: 40 + is_tax_unit_spouse: true + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + filing_status: JOINT + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # The return's 4,000 deduction is supplied without either person's + # amount, so the existing filer_share fallback allocates 2,000 each. + adjusted_gross_income: 96_000 + above_the_line_deductions_person: [2_000, 2_000] + medicaid_adjusted_gross_income_person: [58_000, 38_000] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml new file mode 100644 index 00000000000..50ac0ef050b --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_household_income.yaml @@ -0,0 +1,445 @@ +# Medicaid MAGI household income is the sum of the MAGI-based income of every +# member of the household (42 CFR 435.603(d)(1)). A member's MAGI-based income +# can be negative, and it offsets the others' income before the total is +# floored at zero, as Form 8962 line 3 combines the taxpayer's and dependents' +# modified AGIs "even if one or both of them are negative" +# (https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=8). + +# The first three cases are CMS's worked examples in the MAGI-Based Household +# Income Eligibility Training Manual (December 2020), +# https://www.medicaid.gov/state-resource-center/mac-learning-collaboratives/downloads/household-composition-and-income-training.pdf +# Monthly amounts are annualized. + +- name: CMS Sam scenario, a joint couple's income is added (training manual, p. 83). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + sam: + age: 35 + employment_income: 12_000 # $1,000 a month + is_tax_unit_head: true + susan: + age: 35 + employment_income: 6_000 # $500 a month + is_tax_unit_spouse: true + sarah: + age: 6 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [sam, susan, sarah] + marital_units: + couple: + members: [sam, susan] + sarah_unit: + members: [sarah] + households: + household: + members: [sam, susan, sarah] + state_code: TX + output: + # $1,500 a month for a household of 3. + medicaid_household_income: [18_000, 18_000, 18_000] + +- name: CMS Marty scenario, a dependent adult child not required to file adds no income (training manual, p. 86). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + marty: + age: 50 + employment_income: 30_000 # $2,500 a month + is_tax_unit_head: true + mark: + age: 22 + is_full_time_student: true + employment_income: 9_600 # $800 a month, below the filing threshold + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [marty, mark] + households: + household: + members: [marty, mark] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, false] + medicaid_household_income: [30_000, 30_000] + +- name: CMS Sadie and Stephanie scenarios, a dependent child's income below the filing threshold is excluded (training manual, pp. 87-88). + period: 2025 + absolute_error_margin: 0.01 + input: + people: + sadie: + age: 55 + employment_income: 36_000 # $3,000 a month + is_tax_unit_head: true + stephanie: + age: 17 + employment_income: 6_000 # $500 a month + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [sadie, stephanie] + households: + household: + members: [sadie, stephanie] + state_code: TX + output: + # $3,000 a month for Sadie and for Stephanie. The manual's household also + # has Stephanie's daughter Sarah, whom Sadie claims; she has no income and + # uses the non-filer rules, so she is left out here. + medicaid_person_is_required_to_file: [true, false] + medicaid_household_income: [36_000, 36_000] + +- name: A spouse's business loss offsets the other spouse's wages on their joint return. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Joint AGI: wages 30,000 less the 20,000 Schedule C loss (Schedule 1, + # line 3) = 10,000. Each spouse's MAGI-based income is their own part, + # 30,000 and -20,000, and the household's total is 10,000. Flooring each + # spouse at zero would give 30,000; splitting the loss equally and then + # flooring would give 20,000. + adjusted_gross_income: 10_000 + medicaid_magi_person: [30_000, -20_000] + medicaid_household_income: [10_000, 10_000] + +- name: One spouse's IRA deduction lowers the couple's income once. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + is_tax_unit_head: true + spouse: + age: 40 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # 30,000 - 6,000 = 24,000. The deduction is the head's; splitting it + # equally would leave the spouse at -3,000, floored to 0, and the head at + # 27,000. + adjusted_gross_income: 24_000 + medicaid_magi_person: [24_000, 0] + medicaid_household_income: [24_000, 24_000] + +- name: A negative joint MAGI offsets a dependent's income when the dependent is required to file. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + self_employment_income: -30_000 + is_tax_unit_head: true + spouse: + age: 45 + employment_income: 10_000 + is_tax_unit_spouse: true + child: + age: 17 + employment_income: 25_000 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: TX + output: + # Joint AGI: 10,000 - 30,000 = -20,000 (Form 8962, line 2a). The child's + # wages exceed the 15,750 standard deduction, so the child is required to + # file and the child's MAGI, 25,000, is added (line 2b; 42 CFR + # 435.603(d)(2)). Line 3 combines them even though line 2a is negative: + # -20,000 + 25,000 = 5,000. + adjusted_gross_income: -20_000 + medicaid_person_is_required_to_file: [false, false, true] + medicaid_magi_person: [-30_000, 10_000, 25_000] + medicaid_household_income: [5_000, 5_000, 5_000] + +- name: A dependent's negative MAGI offsets the filer's income when the dependent is required to file. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + employment_income: 50_000 + is_tax_unit_head: true + mother: + age: 72 + taxable_private_pension_income: 4_000 + alimony_expense: 6_000 + divorce_year: 2010 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [filer, mother] + households: + household: + members: [filer, mother] + state_code: TX + output: + # The mother's unearned income, 4,000, exceeds 1,350 plus the 2,000 + # additional amount for age, so she is required to file. Her own AGI is + # 4,000 less alimony paid under a 2010 instrument, 6,000: -2,000. The + # filer's household counts her MAGI (42 CFR 435.603(d)(2)(ii)), which + # offsets the filer's: 50,000 - 2,000 = 48,000. As a parent claimed by her + # child she uses the non-filer rules (435.603(f)(2)(i)); her own + # household is herself, whose negative total is floored at zero. + medicaid_person_is_required_to_file: [true, true] + medicaid_magi_person: [50_000, -2_000] + medicaid_uses_non_filer_rules: [false, true] + medicaid_household_income: [48_000, 0] + +- name: Cohabiting spouses filing separately offset each other's income in their shared household. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + employment_income: 30_000 + person2: + age: 40 + self_employment_income: -20_000 + tax_units: + tax_unit1: + members: [person1] + cohabitating_spouses: true + tax_unit_is_filer: true + tax_unit2: + members: [person2] + cohabitating_spouses: true + tax_unit_is_filer: true + marital_units: + couple: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Each spouse is in the other's household whether or not they file + # jointly (42 CFR 435.603(f)(4)), so each household's income is the sum + # of the two spouses' MAGI-based income: 30,000 - 20,000 = 10,000. + medicaid_magi_person: [30_000, -20_000] + medicaid_household_income: [10_000, 10_000] + +- name: A non-filer spouse's business loss offsets the other spouse's wages. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -5_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + tax_unit_is_filer: false + households: + household: + members: [head, spouse] + state_code: TX + output: + # Neither spouse files, so each household is the individual and their + # spouse (42 CFR 435.603(f)(3)(i)): 12,000 - 5,000 = 7,000. + medicaid_uses_non_filer_rules: [true, true] + medicaid_magi_person: [12_000, -5_000] + medicaid_household_income: [7_000, 7_000] + +- name: A household whose total MAGI-based income is negative has zero household income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + person: + age: 40 + self_employment_income: -10_000 + taxable_interest_income: 1_000 + tax_units: + tax_unit: + members: [person] + households: + household: + members: [person] + state_code: TX + output: + medicaid_magi_person: -9_000 + medicaid_household_income: 0 + +- name: A grandparent outside a child's non-filer household does not lower its income. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + grandmother: + age: 50 + self_employment_income: -5_000 + own_children_in_household: 1 + is_tax_unit_head: true + mother: + age: 30 + employment_income: 12_000 + own_children_in_household: 1 + is_tax_unit_head: true + child: + age: 8 + is_tax_unit_head: true + tax_units: + tax_unit1: + members: [grandmother] + tax_unit_is_filer: false + tax_unit2: + members: [mother] + tax_unit_is_filer: false + tax_unit3: + members: [child] + tax_unit_is_filer: false + families: + family: + members: [grandmother, mother, child] + marital_units: + grandmother_unit: + members: [grandmother] + mother_unit: + members: [mother] + child_unit: + members: [child] + households: + household: + members: [grandmother, mother, child] + state_code: TX + output: + # The child's household is the child and her mother (42 CFR + # 435.603(f)(3)(iii)); her grandmother is not in it, so the grandmother's + # loss does not offset the mother's 12,000. The grandmother's own + # household's total, -5,000, is floored at zero. + medicaid_uses_non_filer_rules: [true, true, true] + medicaid_household_income: [0, 12_000, 12_000] + +- name: A dependent's household includes the claimant's cohabiting spouse who files separately. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent_a: + age: 50 + employment_income: 30_000 + child: + age: 21 + is_full_time_student: true + taxable_interest_income: 2_000 + early_withdrawal_penalty: 5_000 + is_tax_unit_dependent: true + parent_b: + age: 50 + self_employment_income: -20_000 + tax_units: + tax_unit_a: + members: [parent_a, child] + cohabitating_spouses: true + tax_unit_is_filer: true + tax_unit_b: + members: [parent_b] + cohabitating_spouses: true + tax_unit_is_filer: true + marital_units: + couple: + members: [parent_a, parent_b] + child_unit: + members: [child] + households: + household: + members: [parent_a, child, parent_b] + state_code: TX + output: + # The child's unearned income, 2,000, exceeds 1,350, so the child is + # required to file, and the child's MAGI is 2,000 - 5,000 = -3,000. Parent + # A's household includes the child and parent B (435.603(f)(4)): 30,000 + # - 3,000 - 20,000 = 7,000. The child is in parent A's household + # (435.603(f)(2)), so the child's is the same. Parent B's household is + # parent B and parent A: -20,000 + 30,000 = 10,000. + medicaid_person_is_required_to_file: [true, true, false] + medicaid_magi_person: [30_000, -3_000, -20_000] + medicaid_household_income: [7_000, 7_000, 10_000] + +- name: Married non-filer parents' loss offsets their wages in their child's household. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + mother: + age: 35 + employment_income: 12_000 + own_children_in_household: 1 + is_tax_unit_head: true + father: + age: 35 + self_employment_income: -5_000 + own_children_in_household: 1 + is_tax_unit_spouse: true + child: + age: 8 + is_tax_unit_head: true + tax_units: + parents: + members: [mother, father] + tax_unit_is_filer: false + child_unit: + members: [child] + tax_unit_is_filer: false + families: + family: + members: [mother, father, child] + marital_units: + couple: + members: [mother, father] + child_marital_unit: + members: [child] + households: + household: + members: [mother, father, child] + state_code: TX + output: + # Each parent's household is the parent, their spouse and their child + # (42 CFR 435.603(f)(3)(i)-(ii)), and the child's is the child and both + # parents ((f)(3)(iii)): 12,000 - 5,000 = 7,000 for all three. + medicaid_uses_non_filer_rules: [true, true, true] + medicaid_household_income: [7_000, 7_000, 7_000] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml new file mode 100644 index 00000000000..cb6769142fe --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi_person.yaml @@ -0,0 +1,57 @@ +- name: Case 1, each spouse's MAGI is their own AGI plus their own additions, unfloored. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 30_000 + tax_exempt_interest_income: 1_000 + is_tax_unit_head: true + spouse: + age: 40 + self_employment_income: -20_000 + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [head, spouse] + foreign_earned_income_exclusion: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # The head's tax-exempt interest is the head's own. The foreign earned + # income exclusion is recorded only for the tax unit, so it is divided + # equally. Head: 30,000 + 1,000 + 2,000 = 33,000. Spouse: -20,000 + 2,000 + # = -18,000. They add up to the couple's MAGI, 10,000 + 1,000 + 4,000. + medicaid_magi: 15_000 + medicaid_magi_person: [33_000, -18_000] + medicaid_household_income: [15_000, 15_000] + +- name: Case 2, a dependent's own tax-exempt interest is in the dependent's MAGI. + period: 2025 + absolute_error_margin: 0.01 + input: + people: + parent: + age: 50 + employment_income: 40_000 + is_tax_unit_head: true + child: + age: 16 + employment_income: 16_000 + tax_exempt_interest_income: 500 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # The child's wages exceed the 15,750 standard deduction, so the child is + # required to file and counts: 40,000 + 16,000 + 500. + medicaid_magi_person: [40_000, 16_500] + medicaid_household_income: [56_500, 56_500] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml index 045350b9415..94f2f9c5ef7 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.yaml @@ -107,3 +107,41 @@ members: [person1] output: medicaid_person_is_required_to_file: false + +- name: Case 7, a self-employment loss does not count as unearned income. + # IRS Pub. 501 (2025), p. 4, Table 2: unearned income is interest, + # dividends, capital gain distributions, pensions and the like; earned + # income is wages and pay for work. Here unearned income is the 100 of + # interest, not 100 less the 5,000 loss, so it is not more than 1,350, and + # gross income, 100, is not more than 1,350. The dependent is not required + # to file. + period: 2025 + input: + people: + person1: + age: 17 + self_employment_income: -5_000 + taxable_interest_income: 100 + tax_units: + tax_unit: + members: [person1] + output: + medicaid_person_is_required_to_file: false + +- name: Case 8, wages deferred to a 401(k) plan do not hide unearned income. + # Box 1 wages leave out the 2,000 deferral, so gross income is 8,000 of + # wages and 1,500 of interest. Unearned income, 1,500, is more than 1,350 + # (Pub. 501 (2025), Table 2), so the dependent must file. + period: 2025 + input: + people: + person1: + age: 17 + employment_income: 10_000 + traditional_401k_contributions: 2_000 + taxable_interest_income: 1_500 + tax_units: + tax_unit: + members: [person1] + output: + medicaid_person_is_required_to_file: true diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py index 4e10854fd09..f1c1a243d50 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_adjusted_gross_income_person.py @@ -1,80 +1,42 @@ from policyengine_us.model_api import * -# Above-the-line deductions the model attributes to the person who has them. -TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS = { - "self_employment_tax_ald": "self_employment_tax_ald_person", - "self_employed_health_insurance_ald": "self_employed_health_insurance_ald_person", - "self_employed_pension_contribution_ald": "self_employed_pension_contribution_ald_person", - "alimony_expense_ald": "alimony_expense_ald_person", - # Each educator's own capped expenses, a tax unit dependent's included, - # since the dependent deducts them on their own return. - "educator_expense_ald": "educator_expense_ald_person", -} - - class medicaid_adjusted_gross_income_person(Variable): value_type = float entity = Person label = "Federal adjusted gross income for Medicaid MAGI household rules" unit = USD + documentation = ( + "Each person's AGI for Medicaid MAGI-based income: their own gross " + "income less their own above-the-line deductions " + "(above_the_line_deductions_person). On a joint return a deduction " + "that belongs to one spouse, such as their IRA deduction, lowers only " + "that spouse's amount. A business loss is divided between the spouses " + "as loss_ald_person divides it (see that variable), so it can lower " + "both spouses' amounts. The head's and spouse's amounts add up to the " + "return's AGI. " + "A tax unit dependent's amount is " + "their own income less their own person-level deductions, alimony " + "paid included; the dependent's own business and capital losses are " + "not modeled. It can be negative; " + "medicaid_household_income floors the household's total, not each " + "person's amount." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/uscode/text/26/62" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/62", + "https://www.law.cornell.edu/cfr/text/42/435.603#e", + ) def formula(person, period, parameters): gross_income = person("medicaid_irs_gross_income", period) - p = parameters(period).gov.irs.ald - all_alds = p.deductions - # Each listed deduction with a person-level equivalent goes to the - # person who has it, reconciled to the tax-unit amount when that is an - # input or a reform changes it. - ald_sum_person = 0 - for ald in all_alds: - if ald in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS: - ald_sum_person = ald_sum_person + person_share_of_tax_unit_amount( - person, - period, - ald, - TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS[ald], - ) - other_alds = [ - ald - for ald in all_alds - if ald not in TAX_UNIT_AGI_ALDS_WITH_PERSON_LEVEL_EQUIVALENTS - ] - # The head's and spouse's deductions are shared between them, as on - # their return. A tax unit dependent's own person-level deductions, - # such as their IRA deduction, reduce the dependent's own AGI, since - # the dependent's income is figured on the dependent's own return. - # Amounts that are the filer's even when recorded on a dependent stay - # with the head and spouse. - filer_amounts = p.filer_amounts_recorded_on_dependents - ald_sum_taxunit = tax_unit_non_dep_add( - person.tax_unit, - period, - other_alds, - include_dependents=filer_amounts, - ) - person_level_other_alds = [ - ald - for ald in other_alds - if person.entity.get_variable(ald, check_existence=True).entity.is_person - and ald not in filer_amounts - ] - is_dependent = person("is_tax_unit_dependent", period) - dependent_own_ald = is_dependent * add(person, period, person_level_other_alds) - filing_status = person.tax_unit("filing_status", period) - frac = where( - filing_status == filing_status.possible_values.JOINT, - 0.5, - 1.0, - ) - head_or_spouse = person("is_tax_unit_head_or_spouse", period) - shared_ald = head_or_spouse * ald_sum_taxunit * frac - agi = gross_income - ald_sum_person - dependent_own_ald - shared_ald + deductions = person("above_the_line_deductions_person", period) + agi = gross_income - deductions if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) - agi += head_or_spouse * basic_income * frac + # Divided equally between the head and spouse, as in + # adjusted_gross_income_person. + agi += basic_income * filer_share(person, period, 0 * basic_income) return agi diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py index 880e31ea6b3..7decd3be91e 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income.py @@ -18,8 +18,32 @@ class medicaid_household_income(Variable): entity = Person label = "Medicaid MAGI household income" unit = USD + documentation = ( + "The sum of the MAGI-based income of every member of the person's " + "Medicaid household (42 CFR 435.603(d)(1)). Members' amounts are " + "added before any floor, so a loss of one member, such as a spouse's " + "business loss on a joint return, offsets the others' income, as it " + "does in the couple's joint AGI. Only the total is floored at zero, " + "as Form 8962 combines the taxpayer's and dependents' modified AGIs " + "'even if one or both of them are negative' before entering a " + "negative total as zero. In households with parent links " + "(parent_1_id, parent_2_id), the non-filer household is the members " + "medicaid_household_size counts, and every member's amount is added " + "signed. Without links, the non-filer household's parents and " + "siblings are read from the whole family, and those relatives' " + "amounts are added only when positive (each child's, and each " + "couple's parents' netted), so a relative outside the household " + "cannot lower its income. An adult's own amount and their spouse's " + "are added signed; a child's own amount and their siblings' enter " + "with the family's children, only when positive. Unmarried " + "co-resident parents are each counted only when positive." + ) definition_period = YEAR - reference = "https://www.law.cornell.edu/cfr/text/42/435.603#d" + reference = ( + "https://www.law.cornell.edu/cfr/text/42/435.603#d", + "https://www.law.cornell.edu/uscode/text/26/36B#d_2_A", + "https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=8", + ) def formula(person, period, parameters): child_age_eligible = person("medicaid_non_filer_child_age_eligible", period) @@ -52,9 +76,27 @@ def formula(person, period, parameters): 0, ) spouse_income = same_unit_spouse_income + separate_spouse_income - family_child_income = person.family.sum(child_age_eligible * member_income) + # The non-filer household's parents and siblings are read from the + # whole family, which can include people outside the household, such + # as a grandparent who is also a parent there. So their amounts are + # added only when positive: each child's, and each couple's parents' + # netted together, so married parents' losses still offset each + # other's income. An adult's own and their spouse's amounts are added + # signed; a child's own amount is among the family's children. + family_child_income = person.family.sum( + child_age_eligible * max_(0, member_income) + ) + is_parent = person("is_parent", period) + couple_parent_income = person.marital_unit.sum(is_parent * member_income) + couple_parents = person.marital_unit.sum(is_parent) + parent_share = np.divide( + is_parent.astype(float), + couple_parents, + out=np.zeros_like(couple_parents, dtype=float), + where=couple_parents > 0, + ) family_parent_income = person.family.sum( - person("is_parent", period) * member_income + parent_share * max_(0, couple_parent_income) ) non_filer_household_income = where( child_age_eligible, @@ -62,20 +104,30 @@ def formula(person, period, parameters): member_income + spouse_income + family_child_income, ) # With parent links in the household, sum over the same members that - # medicaid_household_size counts. + # medicaid_household_size counts, adding every selected amount signed. + has_parent_ids = household_has_parent_ids(person, period) non_filer_household_income = where( - household_has_parent_ids(person, period), + has_parent_ids, medicaid_non_filer_member_sum(person, period, member_income), non_filer_household_income, ) + # A tax household includes the cohabiting spouse of a filer who files + # separately (42 CFR 435.603(f)(4)), and so do the households of that + # filer's dependents, which are the filer's (435.603(f)(2)). + filer_separate_spouse_income = where( + head_or_spouse | claimed_by_another_return, + separate_spouse_income, + person.tax_unit.sum( + person("is_tax_unit_head", period) * separate_spouse_income + ), + ) tax_member_income_sum = person.tax_unit.sum(tax_member_income) - tax_household_income = tax_member_income_sum + separate_spouse_income + tax_household_income = tax_member_income_sum + filer_separate_spouse_income # With parent links, every dependent shares the taxpayer's # household, including that taxpayer's flagged separately filing - # spouse. Propagate the head's spouse income once across the unit, - # keeping the original expression in households without links. + # spouse. Propagate the head's spouse income once across the unit. tax_household_income = where( - household_has_parent_ids(person, period), + has_parent_ids, tax_member_income_sum + person.tax_unit.sum(head_or_spouse * separate_spouse_income), tax_household_income, @@ -95,6 +147,7 @@ def formula(person, period, parameters): claimant_tax_household_income = medicaid_claiming_tax_unit_value( person, period, tax_household_income ) + tax_route_income = where( known_claiming_tax_unit, claimant_tax_household_income, @@ -103,10 +156,14 @@ def formula(person, period, parameters): # With parent links in the household, add a tax dependent's # co-resident spouse, whom medicaid_household_size adds. tax_route_income = where( - household_has_parent_ids(person, period), + has_parent_ids, tax_route_income + medicaid_tax_dependent_spouse_sum(person, period, member_income), tax_route_income, ) - - return where(non_filer_rules, non_filer_household_income, tax_route_income) + household_income = where( + non_filer_rules, non_filer_household_income, tax_route_income + ) + # Each member's MAGI-based income can be negative; only the + # household's total is floored. + return max_(0, household_income) diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py index 732f49d9083..5cf5729b0c1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_household_income_member.py @@ -6,6 +6,12 @@ class medicaid_household_income_member(Variable): entity = Person label = "Countable person income for Medicaid MAGI household income" unit = USD + documentation = ( + "The person's MAGI-based income (medicaid_magi_person) as counted in " + "another member's non-filer household, or zero for a child excluded " + "under 42 CFR 435.603(d)(2)(i). It can be negative; " + "medicaid_household_income floors the household's total." + ) definition_period = YEAR reference = "https://www.law.cornell.edu/cfr/text/42/435.603#d" diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py index 901c708482e..ef23a5e0db1 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi_person.py @@ -1,43 +1,37 @@ from policyengine_us.model_api import * -PERSON_LEVEL_MEDICAID_MAGI_ADDITIONS = ["tax_exempt_interest_income"] - - class medicaid_magi_person(Variable): value_type = float entity = Person label = "Person-level Medicaid MAGI" unit = USD + documentation = ( + "Each person's MAGI-based income: their Medicaid AGI plus the MAGI " + "additions. A person-level addition, such as tax-exempt interest, is " + "the person's own; an addition recorded only for the tax unit is " + "divided equally between the head and spouse. The head's and " + "spouse's amounts add up to their return's MAGI before any floor. " + "This amount is not floored at zero, so one household member's loss " + "offsets the others' income in medicaid_household_income, which " + "floors the household's total." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#e_14_G", "https://www.law.cornell.edu/uscode/text/26/36B#d_2", + "https://www.law.cornell.edu/cfr/text/42/435.603#e", ) def formula(person, period, parameters): agi = person("medicaid_adjusted_gross_income_person", period) additions = parameters(period).gov.hhs.medicaid.income.modification - person_level_additions = [ - addition - for addition in additions - if addition in PERSON_LEVEL_MEDICAID_MAGI_ADDITIONS - ] - tax_unit_level_additions = [ - addition for addition in additions if addition not in person_level_additions - ] - filing_status = person.tax_unit("filing_status", period) - frac = where( - filing_status == filing_status.possible_values.JOINT, - 0.5, - 1.0, - ) - shared_additions = ( - person("is_tax_unit_head_or_spouse", period) - * add(person.tax_unit, period, tax_unit_level_additions) - * frac - ) - return max_( - 0, - agi + add(person, period, person_level_additions) + shared_additions, - ) + total = agi + for addition in additions: + variable = person.entity.get_variable(addition, check_existence=True) + if variable.entity.is_person: + total = total + person(addition, period) + else: + amount = person.tax_unit(addition, period) + total = total + amount * filer_share(person, period, 0 * amount) + return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py index d03c904be47..c5fec3109ad 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_person_is_required_to_file.py @@ -3,6 +3,13 @@ FilingStatus, ) +# The wage and self-employment sources in gov.irs.gross_income.sources. +EARNED_GROSS_INCOME_SOURCES = [ + "irs_employment_income", + "self_employment_income", + "sstb_self_employment_income", +] + class medicaid_person_is_required_to_file(Variable): value_type = bool @@ -12,6 +19,7 @@ class medicaid_person_is_required_to_file(Variable): reference = ( "https://www.law.cornell.edu/cfr/text/42/435.603#d_2", "https://www.irs.gov/publications/p501", + "https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4", ) def formula(person, period, parameters): @@ -19,7 +27,14 @@ def formula(person, period, parameters): filing_requirement = parameters(period).gov.irs.income.filing_requirement gross_income = person("medicaid_irs_gross_income", period) earned_income = person("earned_income", period) - unearned_income = gross_income - earned_income + # Unearned income is gross income other than wages and self-employment + # earnings (IRS Pub. 501, Table 2). Gross income counts each source's + # positive amount, so subtract those sources' positive amounts: a + # business loss does not turn other income into unearned income. + earned_gross_income = 0 + for source in EARNED_GROSS_INCOME_SOURCES: + earned_gross_income += max_(0, person(source, period)) + unearned_income = max_(0, gross_income - earned_gross_income) married = person.marital_unit.nb_persons() == 2 filing_status = where(married, FilingStatus.SEPARATE, FilingStatus.SINGLE)