diff --git a/changelog.d/fix-ms-hsa-adjustment-double-count.fixed.md b/changelog.d/fix-ms-hsa-adjustment-double-count.fixed.md new file mode 100644 index 00000000000..a35dddf658c --- /dev/null +++ b/changelog.d/fix-ms-hsa-adjustment-double-count.fixed.md @@ -0,0 +1 @@ +Mississippi subtracts a tax unit's health savings account deduction once, from the account holder, rather than once per member. diff --git a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml index 6fcb76f5537..f07f5cabd30 100644 --- a/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml +++ b/policyengine_us/parameters/gov/states/ms/tax/income/adjustments/adjustments.yaml @@ -8,7 +8,7 @@ values: - ms_self_employed_retirement_adjustment # Line 51 - early_withdrawal_penalty # Line 52 - ms_self_employed_health_insurance_adjustment # Line 56 - - health_savings_account_ald # Line 57 + - ms_health_savings_account_adjustment # Line 59 (2022 and later) - ms_retirement_income_exemption - us_govt_interest_person - ms_529_deduction diff --git a/policyengine_us/tests/core/test_ms_health_savings_account_adjustment_invariants.py b/policyengine_us/tests/core/test_ms_health_savings_account_adjustment_invariants.py new file mode 100644 index 00000000000..8c34b72a919 --- /dev/null +++ b/policyengine_us/tests/core/test_ms_health_savings_account_adjustment_invariants.py @@ -0,0 +1,296 @@ +"""Invariants for Mississippi's health savings account adjustment. + +Mississippi excludes health savings account contributions from the account +holder's gross income (Miss. Code Ann. § 71-9-11(3)), and Form 80-105 reports +each spouse's adjustments in their own column. The model's deduction, +`health_savings_account_ald`, is a tax-unit amount, so it must reach the +members' Mississippi adjustments once, not once per member. + +The YAML cases pin the attribution for specific households. This test checks +what YAML cannot: that the deduction is subtracted once for every household +shape and input combination, through `ms_agi_adjustments` and the floors in +`ms_agi`. Hypothesis draws batches of Mississippi tax units (single, married +filing separately, head of household with dependents, joint with and without +dependents). Every member, dependents included, may have their own +`health_savings_account_ald_person`; the tax unit's deduction is the head's +and spouse's own amounts, a different amount, or zero. A seeded population +adds breadth. Each batch runs as one vectorized simulation, twice: as drawn +and with the tax unit's deduction set to zero. For every tax unit: + +1. Conservation: the members' `ms_health_savings_account_adjustment` sum to + the tax unit's `health_savings_account_ald`, and the deduction appears in + the members' `ms_agi_adjustments` exactly once. (On main it appeared once + per member.) +2. Bounds: no share is negative or above the tax unit's deduction, and a + dependent's is zero whatever their own amount. +3. Attribution: when the head's and spouse's own amounts sum to the + deduction, each gets their own amount; when they have none, the head gets + all of it. +4. The deduction lowers the members' total Mississippi AGI by at least zero + and at most the deduction itself. (On main it could lower it by more.) +""" + +import numpy as np +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +TOLERANCE = 0.01 # dollars +HSA = "ms_health_savings_account_adjustment" + +amount = st.integers(0, 60_000).map(float) +hsa = st.one_of(st.just(0.0), st.integers(1, 8_550).map(float)) + + +@st.composite +def person_amounts(draw, *, dependent): + return { + "employment_income": draw(st.one_of(st.just(0.0), amount)), + "self_employment_income": draw( + st.one_of(st.just(0.0), st.integers(-5_000, 40_000).map(float)) + ), + "traditional_ira_contributions": draw( + st.one_of(st.just(0.0), st.integers(1, 7_000).map(float)) + ), + # A dependent's own amount must not reach the return. + "health_savings_account_ald_person": draw(hsa), + } + + +@st.composite +def tax_units(draw): + kind = draw( + st.sampled_from(["single", "separate", "hoh", "joint", "joint_dependents"]) + ) + n_dependents = draw(st.integers(1, 2)) if kind in ("hoh", "joint_dependents") else 0 + head = draw(person_amounts(dependent=False)) + spouse = draw(person_amounts(dependent=False)) if kind.startswith("joint") else None + own = head["health_savings_account_ald_person"] + ( + spouse["health_savings_account_ald_person"] if spouse else 0.0 + ) + # The tax unit's deduction: the filers' own amounts, a different amount, + # or zero. + deduction = draw(st.sampled_from(["own", "other", "zero"])) + return { + "kind": kind, + "head": head, + "spouse": spouse, + "dependents": [ + {"age": draw(st.integers(5, 23)), **draw(person_amounts(dependent=True))} + for _ in range(n_dependents) + ], + "health_savings_account_ald": { + "own": own, + "other": draw(hsa), + "zero": 0.0, + }[deduction], + } + + +SEED = 20261006 + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + + def some(high, p=0.5, low=1): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + def amounts(dependent): + return { + "employment_income": some(60_000, 0.7), + "self_employment_income": some(40_000, 0.3, low=-5_000), + "traditional_ira_contributions": some(7_000, 0.3), + "health_savings_account_ald_person": some(8_550, 0.2 if dependent else 0.5), + } + + units = [] + for _ in range(n): + kind = str( + rng.choice(["single", "separate", "hoh", "joint", "joint_dependents"]) + ) + head = amounts(False) + spouse = amounts(False) if kind.startswith("joint") else None + own = head["health_savings_account_ald_person"] + ( + spouse["health_savings_account_ald_person"] if spouse else 0.0 + ) + n_dependents = ( + int(rng.integers(1, 3)) if kind in ("hoh", "joint_dependents") else 0 + ) + units.append( + { + "kind": kind, + "head": head, + "spouse": spouse, + "dependents": [ + {"age": int(rng.integers(5, 24)), **amounts(True)} + for _ in range(n_dependents) + ], + "health_savings_account_ald": float( + rng.choice([own, some(8_550, 1.0), 0.0]) + ), + } + ) + return units + + +# Roles are inputs, so a drawn dependent with a large income stays a +# dependent rather than failing the dependency tests and joining the return. +HEAD = dict( + is_tax_unit_head=True, is_tax_unit_spouse=False, is_tax_unit_dependent=False +) +SPOUSE = dict( + is_tax_unit_head=False, is_tax_unit_spouse=True, is_tax_unit_dependent=False +) +DEPENDENT = dict( + is_tax_unit_head=False, is_tax_unit_spouse=False, is_tax_unit_dependent=True +) + + +def _situation(units, year, *, zero_deduction): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = {"age": 45, **HEAD, **u["head"]} + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + people[spouse] = {"age": 43, **SPOUSE, **u["spouse"]} + 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}" + people[child] = { + "age": d["age"], + "is_full_time_student": d["age"] >= 19, + **DEPENDENT, + **{k: v for k, v in d.items() if k != "age"}, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + tax_unit = { + "members": members, + "health_savings_account_ald": ( + 0.0 if zero_deduction else u["health_savings_account_ald"] + ), + } + if u["kind"] == "separate": + tax_unit["filing_status"] = "SEPARATE" + groups["tax_units"][f"tax_unit_{i}"] = tax_unit + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": "MS", + } + + def by_year(values): + return {k: (v if k == "members" else {year: v}) for k, v in values.items()} + + return { + "people": {name: by_year(values) for name, values in people.items()}, + **{ + plural: {name: by_year(values) for name, values in entities.items()} + for plural, entities in groups.items() + }, + } + + +def _run(units, year, *, zero_deduction): + sim = Simulation(situation=_situation(units, year, zero_deduction=zero_deduction)) + adjustments = sim.tax_benefit_system.parameters( + year + ).gov.states.ms.tax.income.adjustments.adjustments + others = [name for name in adjustments if "health_savings_account" not in name] + person = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in [ + HSA, + "ms_agi_adjustments", + "ms_agi", + "health_savings_account_ald_person", + ] + } + person["other_adjustments"] = sum( + np.asarray(sim.calculate(name, year), dtype=float) for name in others + ) + person["is_head"] = np.asarray(sim.calculate("is_tax_unit_head", year), dtype=bool) + person["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + person["unit"] = sim.populations["tax_unit"].members_entity_id + deduction = np.asarray( + sim.calculate("health_savings_account_ald", year), dtype=float + ) + return person, deduction + + +def _unit_sum(person, values, n): + return np.bincount(person["unit"], weights=values, minlength=n) + + +def _check(units, year): + person, deduction = _run(units, year, zero_deduction=False) + without, _ = _run(units, year, zero_deduction=True) + n = len(deduction) + shares = person[HSA] + + # 1. Conservation: the deduction is attributed, and subtracted, once. + np.testing.assert_allclose(_unit_sum(person, shares, n), deduction, atol=TOLERANCE) + np.testing.assert_allclose( + _unit_sum( + person, person["ms_agi_adjustments"] - person["other_adjustments"], n + ), + deduction, + atol=TOLERANCE, + ) + + # 2. Bounds. + unit = person["unit"] + assert (shares >= -TOLERANCE).all() + assert (shares <= deduction[unit] + TOLERANCE).all() + assert (shares[person["is_dependent"]] == 0).all() + + # 3. Attribution: own amounts when they sum to the deduction; otherwise, + # without any, the head's. + filer = ~person["is_dependent"] + filers_own = _unit_sum( + person, person["health_savings_account_ald_person"] * filer, n + ) + matches = (np.abs(filers_own - deduction) < TOLERANCE)[unit] & filer + np.testing.assert_allclose( + shares[matches], + person["health_savings_account_ald_person"][matches], + atol=TOLERANCE, + ) + no_own_head = (filers_own == 0)[unit] & person["is_head"] + np.testing.assert_allclose( + shares[no_own_head], deduction[unit][no_own_head], atol=TOLERANCE + ) + + # 4. The deduction lowers total Mississippi AGI by no more than itself. + reduction = _unit_sum(without, without["ms_agi"], n) - _unit_sum( + person, person["ms_agi"], n + ) + assert (reduction >= -TOLERANCE).all() + assert (reduction <= deduction + TOLERANCE).all() + + +# Each example is one vectorized batch of tax units, so a few examples cover +# many units; the seeded population runs once. +SETTINGS = dict( + max_examples=3, + 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_ms_health_savings_account_adjustment_is_attributed_once(units): + _check(units, 2025) + + +def test_seeded_population(): + _check(_seeded_units(), 2025) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.yaml new file mode 100644 index 00000000000..096ef4c63b7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.yaml @@ -0,0 +1,225 @@ +# Mississippi excludes health savings account contributions from the account +# holder's gross income (Miss. Code Ann. § 71-9-11(3)), and Form 80-105 +# reports each spouse's adjustments in their own column, so the tax unit's +# deduction is subtracted once, from the account holder. + +- name: A single filer subtracts their whole deduction + period: 2025 + input: + people: + filer: + age: 40 + employment_income: 40_000 + tax_units: + tax_unit: + members: [filer] + health_savings_account_ald: 1_500 + households: + household: + members: [filer] + state_code: MS + output: + ms_health_savings_account_adjustment: 1_500 + ms_agi_adjustments: 1_500 + ms_agi: 38_500 + +- name: A joint return without per-person amounts attributes the deduction once, to the head + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 30_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: MS + output: + ms_health_savings_account_adjustment: [3_000, 0] + ms_agi_adjustments: [3_000, 0] + # 40,000 - 3,000 and 30,000: the spouse's income is not reduced again. + ms_agi: [37_000, 30_000] + +- name: The spouse whose account it is takes the deduction + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 30_000 + health_savings_account_ald_person: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: MS + output: + ms_health_savings_account_adjustment: [0, 3_000] + ms_agi_adjustments: [0, 3_000] + ms_agi: [40_000, 27_000] + +- name: Two account holders each take their own deduction + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + health_savings_account_ald_person: 1_000 + spouse: + age: 40 + employment_income: 30_000 + health_savings_account_ald_person: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: MS + output: + ms_health_savings_account_adjustment: [1_000, 2_000] + ms_agi: [39_000, 28_000] + +- name: Per-person amounts that differ from the tax unit's deduction split it in proportion + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + health_savings_account_ald_person: 1_000 + spouse: + age: 40 + employment_income: 30_000 + health_savings_account_ald_person: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 1_500 + households: + household: + members: [head, spouse] + state_code: MS + output: + # The return claims 1,500, shared 1:2. + ms_health_savings_account_adjustment: [500, 1_000] + +- name: A dependent's income is not reduced by the filer's deduction, nor by the dependent's own account + period: 2025 + input: + people: + head: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 30_000 + child: + age: 17 + employment_income: 5_000 + # The child's own account belongs on the child's own return. + health_savings_account_ald_person: 1_000 + tax_units: + tax_unit: + members: [head, spouse, child] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse, child] + state_code: MS + output: + ms_health_savings_account_adjustment: [3_000, 0, 0] + ms_agi_adjustments: [3_000, 0, 0] + # The child's 5,000 reaches the head's Mississippi AGI in full: + # 40,000 - 3,000 + 5,000. + ms_agi: [42_000, 30_000, 0] + +- name: A married person filing a separate federal return takes their own deduction + period: 2025 + input: + people: + filer: + age: 40 + employment_income: 40_000 + tax_units: + tax_unit: + members: [filer] + filing_status: SEPARATE + health_savings_account_ald: 2_000 + households: + household: + members: [filer] + state_code: MS + output: + ms_health_savings_account_adjustment: 2_000 + ms_agi: 38_000 + +- name: In separate-return columns each spouse subtracts only their own deduction + period: 2025 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 40_000 + spouse: + age: 40 + employment_income: 30_000 + health_savings_account_ald_person: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: MS + output: + ms_agi: [40_000, 27_000] + # Each spouse takes half of the $12,000 married exemption and of the + # $4,600 married standard deduction: 40,000 - 6,000 - 2,300 and + # 27,000 - 6,000 - 2,300. + ms_taxable_income_indiv: [31_700, 18_700] + # 4.4% above $10,000: 21,700 x 0.044 and 8,700 x 0.044. + ms_income_tax_before_credits_indiv: [954.8, 382.8] + +- name: Without per-person amounts the head takes the deduction, even above the head's own income + period: 2025 + input: + people: + head: + age: 40 + employment_income: 1_000 + spouse: + age: 40 + employment_income: 50_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: MS + output: + # The model does not know whose account it is, so the head takes it, as + # on a joint return with one income column. The head's Mississippi AGI + # cannot go below zero, so 2,000 of the deduction goes unused here; with + # health_savings_account_ald_person on the spouse it would all be used. + ms_health_savings_account_adjustment: [3_000, 0] + ms_agi: [0, 50_000] diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py new file mode 100644 index 00000000000..befa79d8480 --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class health_savings_account_ald_person(Variable): + value_type = float + entity = Person + label = "Health savings account ALD for each person" + unit = USD + documentation = ( + "This person's own health savings account deduction. A health savings " + "account belongs to one individual, and each spouse with one figures " + "the deduction on their own Form 8889. The tax unit's " + "health_savings_account_ald remains the deduction its return claims; " + "these amounts attribute it to the head and spouse where a state " + "reports each spouse's adjustments separately. When they do not sum to " + "it, it is shared in proportion to them, and when they are all zero, " + "the head takes it." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/223#d_1", + "https://www.irs.gov/pub/irs-pdf/i8889.pdf#page=2", + ) diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.py new file mode 100644 index 00000000000..db83be36356 --- /dev/null +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_health_savings_account_adjustment.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class ms_health_savings_account_adjustment(Variable): + value_type = float + entity = Person + label = "Mississippi health savings account adjustment" + unit = USD + documentation = ( + "Each spouse's health savings account deduction (Form 80-105, the " + "health savings account line of the adjustments, Column A for the " + "taxpayer and Column B for the spouse). Mississippi excludes the " + "amounts contributed to a health savings account from the account " + "holder's gross income, so the tax unit's deduction is attributed once, " + "to the account holders, rather than repeated for every member. The " + "head and spouse get their own health_savings_account_ald_person " + "amounts, scaled to sum to the tax unit's deduction; without them, the " + "head takes it, as on a joint return with one income column. A tax unit " + "dependent has no deduction on this return." + ) + definition_period = YEAR + reference = ( + # Miss. Code Ann. § 71-9-11(3), enacted by H.B. 1213 (2005), sec. 2. + "https://billstatus.ls.state.ms.us/documents/2005/html/HB/1200-1299/HB1213PS.htm", + "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80105228.pdf#page=2", + "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=6", + "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=13", + ) + defined_for = StateCode.MS + + def formula(person, period, parameters): + filer_share = person_share_of_tax_unit_amount( + person, + period, + "health_savings_account_ald", + "health_savings_account_ald_person", + ) + # The tax unit's deduction is the head's and spouse's. A dependent who + # holds an account reports it on their own Mississippi return, not + # this one (and has no federal deduction, 26 USC 223(b)(6)). + return where(person("is_tax_unit_dependent", period), 0, filer_share)