diff --git a/changelog.d/dependent-magi-filing-requirement.fixed.md b/changelog.d/dependent-magi-filing-requirement.fixed.md new file mode 100644 index 00000000000..d233d1b661b --- /dev/null +++ b/changelog.d/dependent-magi-filing-requirement.fixed.md @@ -0,0 +1 @@ +Count a dependent's modified AGI in premium tax credit and Medicaid/CHIP household income only when the dependent is required to file, and keep each person's Social Security in their own MAGI, so a child's survivor benefits or tax-exempt interest no longer raise household income unless the child must file. diff --git a/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml b/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml index 24c48d75830..73efc6a434a 100644 --- a/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml +++ b/policyengine_us/parameters/gov/hhs/medicaid/income/modification.yaml @@ -4,7 +4,8 @@ values: # Form 2555 lines 45 and 50 (Form 8962 Worksheet 1-1 line 3). - section_911_excluded_income - tax_exempt_interest_income - - tax_exempt_social_security + # Each person's own benefits, so a dependent's stay with the dependent. + - tax_exempt_social_security_person metadata: unit: list label: Medicaid/CHIP/ACA-related MAGI additions over AGI diff --git a/policyengine_us/tests/core/test_dependent_magi_household_income_invariants.py b/policyengine_us/tests/core/test_dependent_magi_household_income_invariants.py new file mode 100644 index 00000000000..8946001133a --- /dev/null +++ b/policyengine_us/tests/core/test_dependent_magi_household_income_invariants.py @@ -0,0 +1,388 @@ +"""Invariants for dependents' income in ACA and Medicaid household income. + +Household income counts another member's modified AGI only if that member is +required to file a return (26 CFR 1.36B-1(e)(1)(ii); 42 CFR 435.603(d)(2)). +So `medicaid_magi`, which `aca_magi` adds, is the head's and spouse's modified +AGI plus the whole modified AGI of each tax unit dependent who must file, and +each person's Social Security is their own (`tax_exempt_social_security_person`). + +Hypothesis draws batches of tax units (single, head of household, joint with +and without dependents) whose members have wages, taxable and tax-exempt +interest, and Social Security, and a seeded population of 200 such units adds +breadth. Dependents are children and disabled adult children, with benefits up +to $60,000 so that some are taxable on the dependent's own return. Each batch +runs as one vectorized simulation twice: as drawn, and with every dependent's +tax-exempt interest and Social Security set to zero. For every tax unit: + +1. Differential: `medicaid_magi` equals an independent numpy computation of + Form 8962 lines 2a, 2b and 3: the return's AGI plus the filers' tax-exempt + interest and untaxed benefits, plus, for each dependent required to file + under IRS Pub. 501 Table 2, their wages, interest, tax-exempt interest and + all their benefits, floored at zero. The dependents' taxable benefits (IRC + 86 on their own income) and filing requirements match the same computation. +2. Exclusion: where no dependent must file, removing the dependents' + tax-exempt interest and benefits leaves `medicaid_magi` unchanged, and it + never changes the head's or spouse's `medicaid_magi_person`. +3. Identities: each person's tax-exempt, filer-taxable and dependent-taxable + benefits add up to their benefits; the head's and spouse's tax-exempt + benefits add up to `filer_tax_exempt_social_security`; `aca_magi` equals + `medicaid_magi`, which is never negative. +4. Without dependents, `medicaid_magi` equals the previous formula, AGI plus + every member's tax-exempt interest plus `tax_exempt_social_security`. +5. Where everyone in the tax unit follows the tax-filer rules, each member's + person-level Medicaid household income equals `medicaid_magi`. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars + +# IRC 86(c)(1)(A), (c)(2)(A) and (a): statutory, not indexed. +SS_BASE = 25_000 +SS_ADJUSTED_BASE = 34_000 + +OUTPUTS = [ + "adjusted_gross_income", + "medicaid_magi", + "aca_magi", + "tax_unit_taxable_social_security", + "tax_exempt_social_security", + "filer_tax_exempt_social_security", +] +PERSON_OUTPUTS = [ + "medicaid_person_is_required_to_file", + "dependent_taxable_social_security", + "taxable_social_security", + "tax_exempt_social_security_person", + "social_security", + "medicaid_magi_person", + "medicaid_household_income", + "medicaid_uses_non_filer_rules", +] +INPUTS = [ + "employment_income", + "taxable_interest_income", + "tax_exempt_interest_income", +] + +small = st.one_of(st.just(0.0), st.integers(1, 4_000).map(float)) +wages = st.one_of(st.just(0.0), st.integers(1, 30_000).map(float)) +benefits = st.one_of(st.just(0.0), st.integers(1, 60_000).map(float)) + + +@st.composite +def person_amounts(draw): + return { + "employment_income": draw(wages), + "taxable_interest_income": draw(small), + "tax_exempt_interest_income": draw(small), + "social_security": draw(benefits), + } + + +@st.composite +def dependents(draw): + adult = draw(st.booleans()) + return { + "age": draw(st.integers(19, 40)) if adult else draw(st.integers(1, 17)), + "disabled": adult, + **draw(person_amounts()), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "hoh", "joint", "joint_dependents"])) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + return { + "head": draw(person_amounts()), + "spouse": draw(person_amounts()) if kind.startswith("joint") else None, + "dependents": [draw(dependents()) for _ in range(n_dependents)], + } + + +SEED = 20261011 + + +def _seeded_amounts(rng): + def some(high, p=0.5): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + return { + "employment_income": some(30_000), + "taxable_interest_income": some(4_000), + "tax_exempt_interest_income": some(4_000), + "social_security": some(60_000), + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "hoh", "joint", "joint_dependents"]) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + deps = [] + for _ in range(n_dependents): + adult = bool(rng.random() < 0.3) + deps.append( + { + "age": int(rng.integers(19, 41) if adult else rng.integers(1, 18)), + "disabled": adult, + **_seeded_amounts(rng), + } + ) + units.append( + { + "head": _seeded_amounts(rng), + "spouse": _seeded_amounts(rng) if kind.startswith("joint") else None, + "dependents": deps, + } + ) + return units + + +def _inputs(amounts, benefit_variable): + values = {name: amounts[name] for name in INPUTS} + values[benefit_variable] = amounts["social_security"] + return values + + +def _situation(units, year, *, zero_dependents_untaxed=False): + people = {} + groups = { + "tax_units": {}, + "households": {}, + "marital_units": {}, + "families": {}, + "spm_units": {}, + } + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 45, + "is_tax_unit_head": True, + "is_tax_unit_dependent": False, + **_inputs(u["head"], "social_security_retirement"), + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = { + "age": 43, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **_inputs(u["spouse"], "social_security_retirement"), + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + values = _inputs(d, "social_security_survivors") + if zero_dependents_untaxed: + values["tax_exempt_interest_income"] = 0.0 + values["social_security_survivors"] = 0.0 + people[child] = { + "age": d["age"], + "is_disabled": d["disabled"], + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **values, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = {"members": members} + groups["families"][f"family_{i}"] = {"members": members} + groups["spm_units"][f"spm_unit_{i}"] = {"members": members} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: "TX"}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _run(units, year, **kwargs): + sim = Simulation(situation=_situation(units, year, **kwargs)) + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in OUTPUTS + PERSON_OUTPUTS + } + out["unit"] = sim.populations["tax_unit"].members_entity_id + out["parameters"] = sim.tax_benefit_system.parameters(f"{year}-01-01") + return out + + +def _by_person(units, key): + """Person-level input values in member order; key "role" gives a flag + for dependents.""" + rows = [] + for u in units: + rows.append((u["head"], False)) + if u["spouse"] is not None: + rows.append((u["spouse"], False)) + rows += [(d, True) for d in u["dependents"]] + if key == "role": + return np.array([dependent for _, dependent in rows]) + return np.array([amounts[key] for amounts, _ in rows], dtype=float) + + +def _unit_sum(run, values): + return np.bincount(run["unit"], weights=values, minlength=len(run["medicaid_magi"])) + + +def _dependent_taxable_benefits(modified_agi, benefits): + # IRC 86(a) and (b) on a single dependent's own return. + combined = modified_agi + benefits / 2 + tier_1 = np.minimum(benefits / 2, (combined - SS_BASE) / 2) + tier_2 = np.minimum( + 0.85 * (combined - SS_ADJUSTED_BASE) + + np.minimum(benefits / 2, (SS_ADJUSTED_BASE - SS_BASE) / 2), + 0.85 * benefits, + ) + return np.where( + combined < SS_BASE, 0, np.where(combined < SS_ADJUSTED_BASE, tier_1, tier_2) + ) + + +def _check(units, year): + run = _run(units, year) + zeroed = _run(units, year, zero_dependents_untaxed=True) + dependent = _by_person(units, "role") + filer = ~dependent + wages_ = _by_person(units, "employment_income") + interest = _by_person(units, "taxable_interest_income") + exempt_interest = _by_person(units, "tax_exempt_interest_income") + benefits_ = _by_person(units, "social_security") + np.testing.assert_allclose(run["social_security"], benefits_, atol=TOLERANCE) + + # 1. Differential: Form 8962 lines 2a, 2b and 3 from the inputs. + standard = run["parameters"].gov.irs.deductions.standard + dependent_amount = standard.dependent.amount + earned_addition = standard.dependent.additional_earned_income + single_amount = standard.amount["SINGLE"] + taxable = _dependent_taxable_benefits( + wages_ + interest + exempt_interest, benefits_ + ) + gross = wages_ + interest + taxable + required = ( + (interest + taxable > dependent_amount) + | (wages_ > single_amount) + | ( + gross + > np.minimum( + single_amount, np.maximum(dependent_amount, wages_ + earned_addition) + ) + ) + ) + np.testing.assert_allclose( + run["dependent_taxable_social_security"][dependent], + taxable[dependent], + atol=TOLERANCE, + ) + np.testing.assert_array_equal( + run["medicaid_person_is_required_to_file"][dependent].astype(bool), + required[dependent], + ) + filer_magi = ( + run["adjusted_gross_income"] + + _unit_sum(run, filer * (exempt_interest + benefits_)) + - run["tax_unit_taxable_social_security"] + ) + dependent_magi = wages_ + interest + exempt_interest + benefits_ + line_2b = _unit_sum(run, (dependent & required) * dependent_magi) + expected = np.maximum(0, filer_magi + line_2b) + np.testing.assert_allclose(run["medicaid_magi"], expected, atol=TOLERANCE) + + # 2. Exclusion: a dependent who need not file adds nothing, and the + # filers' own Medicaid MAGI never holds a dependent's benefits. + any_required = _unit_sum(run, (dependent & required).astype(float)) > 0 + np.testing.assert_allclose( + run["medicaid_magi"][~any_required], + zeroed["medicaid_magi"][~any_required], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + run["medicaid_magi_person"][filer], + zeroed["medicaid_magi_person"][filer], + atol=TOLERANCE, + ) + + # 3. Identities. + np.testing.assert_allclose( + run["tax_exempt_social_security_person"] + + run["taxable_social_security"] + + run["dependent_taxable_social_security"], + run["social_security"], + atol=TOLERANCE, + ) + np.testing.assert_allclose( + _unit_sum(run, filer * run["tax_exempt_social_security_person"]), + run["filer_tax_exempt_social_security"], + atol=TOLERANCE, + ) + np.testing.assert_allclose(run["aca_magi"], run["medicaid_magi"], atol=TOLERANCE) + assert (run["medicaid_magi"] >= 0).all() + + # 4. Without dependents, the previous formula. + has_dependent = _unit_sum(run, dependent.astype(float)) > 0 + previous = np.maximum( + 0, + run["adjusted_gross_income"] + + _unit_sum(run, exempt_interest) + + run["tax_exempt_social_security"], + ) + np.testing.assert_allclose( + run["medicaid_magi"][~has_dependent], + previous[~has_dependent], + atol=TOLERANCE, + ) + + # 5. The person-level Medicaid household income agrees with the tax + # unit's where every member follows the tax-filer rules. + tax_route = _unit_sum(run, run["medicaid_uses_non_filer_rules"]) == 0 + person_tax_route = tax_route[run["unit"]] + np.testing.assert_allclose( + run["medicaid_household_income"][person_tax_route], + run["medicaid_magi"][run["unit"]][person_tax_route], + atol=TOLERANCE, + ) + return has_dependent, any_required, tax_route + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=5, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_dependent_magi_counts_only_when_required_to_file(units): + _check(units, 2025) + + +@pytest.mark.parametrize("year", [2025, 2026]) +def test_seeded_population(year): + has_dependent, any_required, tax_route = _check(_seeded_units(), year) + # The population exercises both branches of each property. + assert has_dependent.sum() > 50 + assert any_required.sum() > 20 + assert (has_dependent & ~any_required).sum() > 20 + assert tax_route.sum() > 50 diff --git a/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml b/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml index 1a5d780bf1d..74ccb253709 100644 --- a/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/aca/eligibility/aca_magi.yaml @@ -41,3 +41,54 @@ # Form 8962 Worksheet 1-1 line 3 takes Form 2555 lines 45 and 50. medicaid_magi: 65_000 aca_magi: 65_000 + +- name: 2025 a child's survivor benefits stay out of premium tax credit household income when the child need not file + # 26 CFR 1.36B-1(e)(1)(ii): household income counts other family members' + # modified AGI only if they are required to file. The child's gross income + # is zero, since none of their benefits is taxable. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 12_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # Before the fix: 52,000 and 2.54. + aca_magi: 40_000 + # 40,000 / 20,440 (the 2024 poverty line for two), truncated. + aca_magi_fraction: 1.95 + +- name: 2025 a teen who must file adds their modified AGI to premium tax credit household income + # Wages of $16,000 are more than $15,750, so the teen must file (IRS Pub. + # 501 (2025), Table 2), and Form 8962 line 2b takes their modified AGI. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + teen: + age: 17 + employment_income: 16_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: TX + output: + # Before the fix: 40,000 and 1.95. + aca_magi: 56_000 + # 56,000 / 20,440, truncated. + aca_magi_fraction: 2.73 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_income_level.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_income_level.yaml index fa51c1a9ad6..d1afb8de500 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_income_level.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_income_level.yaml @@ -435,3 +435,82 @@ medicaid_household_income: 148_224.12 output: is_older_child_for_medicaid_fc: false + +- name: Case 26, a child's survivor benefits stay with the child, so they leave the parent's Medicaid MAGI and household income. + # The child need not file (none of the benefits is taxable, so the child's + # gross income is zero), and 42 CFR 435.603(d)(2)(i) excludes their + # MAGI-based income. Before the fix, the tax unit's tax-exempt Social + # Security, the child's benefits included, was the parent's. + period: 2025 + input: + people: + person1: + age: 40 + employment_income: 40_000 + own_children_in_household: 1 + person2: + age: 12 + social_security_survivors: 12_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + families: + family: + members: [person1, person2] + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] + output: + medicaid_person_is_required_to_file: [true, false] + tax_exempt_social_security_person: [0, 12_000] + medicaid_magi_person: [40_000, 12_000] + medicaid_household_income_member: [40_000, 0] + medicaid_household_income: [40_000, 40_000] + # 40,000 / 21,150, the 2025 poverty guideline for two. + medicaid_income_level: [1.891253, 1.891253] + tax_unit_medicaid_income_level: 1.891253 + absolute_error_margin: 0.001 + +- name: Case 27, a child with survivor benefits who must file adds all their benefits to Medicaid household income. + # $2,000 of interest is more than the $1,350 unearned income limit. The + # child's MAGI-based income is $2,000 of AGI plus $12,000 of benefits. + period: 2025 + input: + people: + person1: + age: 40 + employment_income: 40_000 + own_children_in_household: 1 + person2: + age: 12 + social_security_survivors: 12_000 + taxable_interest_income: 2_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + families: + family: + members: [person1, person2] + marital_units: + marital_unit1: + members: [person1] + marital_unit2: + members: [person2] + output: + medicaid_person_is_required_to_file: [true, true] + medicaid_magi_person: [40_000, 14_000] + medicaid_household_income: [54_000, 54_000] + # 54,000 / 21,150. + medicaid_income_level: [2.553191, 2.553191] + tax_unit_medicaid_income_level: 2.553191 + absolute_error_margin: 0.001 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_irs_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_irs_gross_income.yaml new file mode 100644 index 00000000000..6c86b73567e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_irs_gross_income.yaml @@ -0,0 +1,54 @@ +- name: Case 1, a single filer's gross income counts only their taxable benefits. + # Combined income is $20,000 of pension plus half of $40,000, $40,000. The + # taxable amount is the lesser of 85% of $40,000 or 85% of the $6,000 + # excess over $34,000 plus the lesser of half the $15,000 excess over + # $25,000 ($7,500) or $4,500: $9,600 (IRC 86(a)(2)). + period: 2025 + input: + social_security_retirement: 40_000 + taxable_pension_income: 20_000 + output: + taxable_social_security: 9_600 + medicaid_irs_gross_income: 29_600 + +- name: Case 2, a dependent's untaxed benefits are not gross income. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 20_000 + tax_units: + tax_unit: + members: [parent, child] + output: + medicaid_irs_gross_income: [40_000, 0] + medicaid_person_is_required_to_file: [true, false] + +- name: Case 3, a dependent's taxable benefits, figured on their own return, are gross income. + # IRS Pub. 915 (2025), "Who is taxed": half of a child's benefits is added + # to the child's own income. $15,000 + $18,000 = $33,000 of combined income + # makes $4,000 taxable, so gross income is $19,000, more than the $15,450 + # limit for a dependent with $15,000 of earned income. + period: 2025 + input: + people: + parent: + age: 60 + employment_income: 40_000 + adult_child: + age: 30 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + is_disabled: true + employment_income: 15_000 + social_security_dependents: 36_000 + tax_units: + tax_unit: + members: [parent, adult_child] + output: + medicaid_irs_gross_income: [40_000, 19_000] + medicaid_person_is_required_to_file: [true, true] diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi.yaml new file mode 100644 index 00000000000..6af19a96881 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicaid/income/medicaid_magi.yaml @@ -0,0 +1,266 @@ +# Household income counts a dependent's modified AGI only when the dependent +# is required to file a return (26 CFR 1.36B-1(e)(1)(ii); 42 CFR +# 435.603(d)(2)). In 2025 a single dependent under 65 and not blind must file +# if unearned income is more than $1,350, earned income is more than $15,750, +# or gross income is more than the larger of $1,350 or earned income plus +# $450 (IRS Pub. 501 (2025), Table 2). Social Security counts toward these +# tests only to the extent it is taxable (Pub. 501, Table 2: "taxable social +# security benefits"), figured on the dependent's own return (Pub. 915 +# (2025), "Who is taxed"). + +- name: Case 1, a child's survivor benefits below the filing threshold stay out of household income. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 12_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, false] + adjusted_gross_income: 40_000 + # Before the fix: 40,000 + 12,000. + medicaid_magi: 40_000 + +- name: Case 2, a child's survivor benefits above the $15,750 standard deduction still stay out, since none is taxable. + # Combined income is half of $20,000, less than the $25,000 base amount, so + # no benefits are taxable and the child's gross income is zero. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 20_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + dependent_taxable_social_security: [0, 0] + medicaid_irs_gross_income: [40_000, 0] + medicaid_person_is_required_to_file: [true, false] + medicaid_magi: 40_000 + +- name: Case 3, a child with survivor benefits and $2,000 of interest must file, so all their benefits count. + # Unearned income of $2,000 is more than $1,350. The child's modified AGI is + # their $2,000 AGI plus $12,000 of benefits, none of them taxable (combined + # income $2,000 + $6,000 is below $25,000). + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 12_000 + taxable_interest_income: 2_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, true] + adjusted_gross_income: 40_000 + # Before the fix: 52,000, with the benefits but not the interest. + medicaid_magi: 54_000 + +- name: Case 4, a child's tax-exempt interest stays out when the child need not file. + # Tax-exempt interest is not gross income, so the child's gross income is + # zero. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + tax_exempt_interest_income: 20_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, false] + # Before the fix: 60,000. + medicaid_magi: 40_000 + +- name: Case 5, a child with tax-exempt interest and $2,000 of taxable interest must file, so both count. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + tax_exempt_interest_income: 20_000 + taxable_interest_income: 2_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, true] + # Form 8962 Worksheet 1-2: AGI 2,000 (line 1) plus tax-exempt interest + # 20,000 (line 2). Before the fix: 60,000. + medicaid_magi: 62_000 + +- name: Case 6, a teen whose wages are more than $15,750 must file, so their AGI counts. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + teen: + age: 17 + employment_income: 16_000 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, true] + # Before the fix: 40,000, without the teen's wages. + medicaid_magi: 56_000 + +- name: Case 7, a teen whose wages are $15,750 need not file, so their AGI stays out. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + teen: + age: 17 + employment_income: 15_750 + tax_units: + tax_unit: + members: [parent, teen] + households: + household: + members: [parent, teen] + state_code: TX + output: + medicaid_person_is_required_to_file: [true, false] + medicaid_magi: 40_000 + +- name: Case 8, taxable benefits put a disabled adult child over the filing threshold, so all their income counts. + # The dependent's $15,000 of wages alone is below the gross income limit of + # $15,000 + $450. Combined income is $15,000 + $18,000 = $33,000, between + # the $25,000 base and $34,000 adjusted base amounts, so the lesser of half + # the benefits ($18,000) or half the $8,000 excess, $4,000, is taxable (IRC + # 86(a)(1)). Gross income of $19,000 is more than $15,450, and unearned + # income of $4,000 is more than $1,350. Modified AGI is $15,000 of wages plus + # all $36,000 of benefits. + period: 2025 + input: + people: + parent: + age: 60 + employment_income: 40_000 + adult_child: + age: 30 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + is_disabled: true + employment_income: 15_000 + social_security_dependents: 36_000 + tax_units: + tax_unit: + members: [parent, adult_child] + households: + household: + members: [parent, adult_child] + state_code: TX + output: + dependent_taxable_social_security: [0, 4_000] + medicaid_irs_gross_income: [40_000, 19_000] + medicaid_person_is_required_to_file: [true, true] + # Before the fix: 76,000, with the benefits but not the wages. + medicaid_magi: 91_000 + +- name: Case 9, only the dependent who must file adds to household income. + period: 2025 + input: + people: + parent1: + age: 40 + employment_income: 30_000 + parent2: + age: 40 + employment_income: 10_000 + tax_exempt_interest_income: 1_000 + child1: + age: 16 + employment_income: 20_000 + child2: + age: 10 + social_security_survivors: 9_000 + tax_exempt_interest_income: 500 + tax_units: + tax_unit: + members: [parent1, parent2, child1, child2] + households: + household: + members: [parent1, parent2, child1, child2] + state_code: TX + output: + # child1's wages are more than $15,750; child2's gross income is zero. + # (The filers' values apply the dependents' test and are unused.) + medicaid_person_is_required_to_file: [true, false, true, false] + adjusted_gross_income: 40_000 + # The couple's AGI and parent2's tax-exempt interest, plus child1's + # $20,000 of wages. Before the fix: 50,500. + medicaid_magi: 61_000 + +- name: Case 10, a dependent's tax-exempt interest is in tax_unit_medicaid_income_level only when they must file. + period: 2025 + absolute_error_margin: 0.001 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + tax_exempt_interest_income: 20_000 + tax_units: + tax_unit: + members: [parent, child] + households: + household: + members: [parent, child] + state_code: TX + output: + # 40,000 / 21,150, the 2025 poverty guideline for two. + tax_unit_medicaid_income_level: 1.891 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.yaml new file mode 100644 index 00000000000..25fe04a475f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.yaml @@ -0,0 +1,79 @@ +- name: Case 1, a single filer's benefits below the base amount are all tax-exempt. + period: 2025 + input: + social_security_retirement: 20_000 + output: + taxable_social_security: 0 + tax_exempt_social_security_person: 20_000 + +- name: Case 2, each spouse keeps their own share of the joint return's tax-exempt benefits. + # Combined income is $30,000 of pension plus half of $40,000 of benefits, + # $50,000. Over the $44,000 adjusted base amount, the taxable amount is the + # lesser of 85% of $40,000 or 85% of the $6,000 excess plus the lesser of + # half the $18,000 excess over $32,000 ($9,000) or $6,000: $11,100 (IRC + # 86(a)(2)). taxable_social_security splits it by each spouse's benefits, + # 30/40 and 10/40. + period: 2025 + input: + people: + head: + age: 70 + social_security_retirement: 30_000 + taxable_pension_income: 30_000 + spouse: + age: 68 + social_security_retirement: 10_000 + tax_units: + tax_unit: + members: [head, spouse] + marital_units: + marital_unit: + members: [head, spouse] + output: + tax_unit_taxable_social_security: 11_100 + taxable_social_security: [8_325, 2_775] + tax_exempt_social_security_person: [21_675, 7_225] + filer_tax_exempt_social_security: 28_900 + +- name: Case 3, a dependent's benefits are the dependent's own, not the filer's. + period: 2025 + input: + people: + parent: + age: 40 + employment_income: 40_000 + child: + age: 12 + social_security_survivors: 12_000 + tax_units: + tax_unit: + members: [parent, child] + output: + tax_exempt_social_security_person: [0, 12_000] + filer_tax_exempt_social_security: 0 + tax_exempt_social_security: 12_000 + +- name: Case 4, a dependent's taxable benefits, figured on their own return, are not tax-exempt. + # Combined income is $15,000 of wages plus half of $36,000, $33,000; the + # lesser of $18,000 or half the $8,000 excess over $25,000 is taxable. + period: 2025 + input: + people: + parent: + age: 60 + employment_income: 40_000 + adult_child: + age: 30 + is_tax_unit_dependent: true + is_tax_unit_spouse: false + is_disabled: true + employment_income: 15_000 + social_security_dependents: 36_000 + tax_units: + tax_unit: + members: [parent, adult_child] + output: + dependent_taxable_social_security: [0, 4_000] + tax_exempt_social_security_person: [0, 32_000] + # The tax unit amount subtracts only the filers' taxable benefits. + tax_exempt_social_security: 36_000 diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_irs_gross_income.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_irs_gross_income.py index ee69e983556..fc8083d2904 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_irs_gross_income.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_irs_gross_income.py @@ -6,14 +6,31 @@ class medicaid_irs_gross_income(Variable): entity = Person label = "Gross income used for Medicaid MAGI filing-threshold checks" unit = USD + documentation = ( + "The person's own gross income. Social Security counts only to the " + "extent section 86 taxes it: taxable_social_security gives the head " + "and spouse their shares of their return's taxable benefits, and a " + "tax unit dependent's benefits are taxed on the dependent's own " + "return (dependent_taxable_social_security)." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/26/61", "https://www.law.cornell.edu/cfr/text/42/435.603#d_2", + # IRS Pub. 501 (2025), Table 2: a dependent's unearned income + # includes taxable Social Security benefits. + "https://www.irs.gov/pub/irs-prior/p501--2025.pdf#page=4", + # IRS Pub. 915 (2025), "Who is taxed": a child's benefits are added + # to the child's own income. + "https://www.irs.gov/pub/irs-prior/p915--2025.pdf#page=5", ) def formula(person, period, parameters): + sources = parameters(period).gov.irs.gross_income.sources total = 0 - for source in parameters(period).gov.irs.gross_income.sources: + for source in sources: total += max_(0, add(person, period, [source])) + if "taxable_social_security" in sources: + # taxable_social_security is zero for a tax unit dependent. + total += person("dependent_taxable_social_security", period) return total diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi.py b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi.py index 773fd6318c7..520f3002cb9 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/medicaid_magi.py @@ -1,19 +1,62 @@ from policyengine_us.model_api import * +def magi_household_income(tax_unit, period, parameters, counted_dependents): + """The modified AGI of a tax unit's head and spouse plus that of the + dependents `counted_dependents` (a person-level mask) selects, before + any floor at zero. + + Tax unit AGI already leaves dependents' income out, so each person-level + MAGI addition is summed over the head and spouse only, and a counted + dependent's whole modified AGI, their own AGI and additions, is added + signed, as Form 8962 line 3 combines the two "even if one or both of them + are negative". + """ + additions = parameters(period).gov.hhs.medicaid.income.modification + filer_magi = tax_unit("adjusted_gross_income", period) + tax_unit_non_dep_add( + tax_unit, period, additions + ) + person = tax_unit.members + person_level_additions = [ + addition + for addition in additions + if tax_unit.entity.get_variable(addition, check_existence=True).entity.is_person + ] + dependent_magi = person("medicaid_adjusted_gross_income_person", period) + add( + person, period, person_level_additions + ) + counted = person("is_tax_unit_dependent", period) & counted_dependents + return filer_magi + tax_unit.sum(counted * dependent_magi) + + class medicaid_magi(Variable): value_type = float entity = TaxUnit label = "Medicaid/CHIP/ACA-related Modified AGI" unit = USD - documentation = "Medicaid/CHIP/ACA-related modified AGI for this tax unit." + documentation = ( + "Medicaid/CHIP/ACA-related household income for this tax unit: the " + "modified AGI of the head and spouse, plus the modified AGI of each " + "tax unit dependent who is required to file a return " + "(medicaid_person_is_required_to_file), floored at zero. A dependent " + "who is not required to file adds nothing, whether or not they file, " + "so their tax-exempt interest and Social Security, such as a child's " + "survivor benefits, stay out. Social Security counts toward that " + "filing requirement only to the extent it is taxable." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#e_14_G", # Medicaid law pointing to IRC "https://www.law.cornell.edu/uscode/text/26/36B#d_2", # IRC defining income + "https://www.ecfr.gov/current/title-26/section-1.36B-1#p-1.36B-1(e)(1)", + "https://www.ecfr.gov/current/title-42/section-435.603#p-435.603(d)(2)", + # Instructions for Form 8962 (2025), Worksheet 1-2 and line 3. + "https://www.irs.gov/pub/irs-prior/i8962--2025.pdf#page=8", ) def formula(tax_unit, period, parameters): - agi = tax_unit("adjusted_gross_income", period) - agi_additions = parameters(period).gov.hhs.medicaid.income.modification - return max_(0, agi + add(tax_unit, period, agi_additions)) + required_to_file = tax_unit.members( + "medicaid_person_is_required_to_file", period + ) + income = magi_household_income(tax_unit, period, parameters, required_to_file) + return max_(0, income) 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..a6c9bfa93c5 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,14 +1,19 @@ 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 or " + "tax_exempt_social_security_person, is the person's own, so a tax " + "unit dependent's Social Security stays in the dependent's amount; " + "an addition recorded only for the tax unit is divided between the " + "head and spouse." + ) definition_period = YEAR reference = ( "https://www.law.cornell.edu/uscode/text/42/1396a#e_14_G", @@ -21,7 +26,9 @@ def formula(person, period, parameters): person_level_additions = [ addition for addition in additions - if addition in PERSON_LEVEL_MEDICAID_MAGI_ADDITIONS + if person.entity.get_variable( + addition, check_existence=True + ).entity.is_person ] tax_unit_level_additions = [ addition for addition in additions if addition not in person_level_additions diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.py new file mode 100644 index 00000000000..c28191029f9 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/irs_gross_income/social_security/tax_exempt_social_security_person.py @@ -0,0 +1,31 @@ +from policyengine_us.model_api import * + + +class tax_exempt_social_security_person(Variable): + value_type = float + entity = Person + label = "Tax-exempt Social Security (person)" + unit = USD + documentation = ( + "The person's own Social Security benefits that section 86 leaves out " + "of gross income on the return that reports them. For the head and " + "spouse, that return is theirs, and taxable_social_security gives " + "each their share of its taxable benefits. A tax unit dependent's " + "benefits are taxed on the dependent's own return " + "(dependent_taxable_social_security), not on the claimant's. Summed " + "over the head and spouse, this is filer_tax_exempt_social_security." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/86", + # IRS Pub. 915 (2025), "Who is taxed": one-half of a child's benefits + # is added to the child's other income, not the parent's. + "https://www.irs.gov/pub/irs-prior/p915--2025.pdf#page=5", + ) + + def formula(person, period, parameters): + social_security = person("social_security", period) + # Each is zero for the people the other covers. + filer_taxable = person("taxable_social_security", period) + dependent_taxable = person("dependent_taxable_social_security", period) + return social_security - filer_taxable - dependent_taxable