From cd24bdd4af8aebd7dfc67e64496c72bda7adedc1 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 09:45:10 -0400 Subject: [PATCH 1/4] Make state income taxes independent of which spouse is labelled head Swapping a married couple's is_tax_unit_head and is_tax_unit_spouse labels changed state income tax in Arkansas, Iowa, Mississippi, Montana and Oklahoma (Delaware's code had the same pattern without an observed effect). - Arkansas, Delaware, Iowa, Mississippi and Montana put dependents' income on the head's column of a separate-column return. A shared helper, move_dependent_amounts_to_filer, now puts it on the column of the spouse with the greater income of their own and splits an exact tie, following the rule for parents filing separately (26 U.S.C. 1(g)(5)(B), Form 8814). - Montana's joint itemized deductions counted only the head's mortgage and investment interest; they now count every member's. - Oklahoma's property tax credit accepted a disabled head but not a disabled spouse; either spouse now qualifies, as for age. Adds YAML cases for each formula and a Hypothesis property test that swapping the labels leaves state income tax unchanged to the cent. Co-Authored-By: Claude Opus 5.5 --- .../fix-state-head-spouse-swap.fixed.md | 1 + policyengine_us/model_api.py | 31 ++ ...t_state_tax_head_spouse_swap_invariance.py | 456 ++++++++++++++++++ .../states/ar/tax/income/ar_agi_indiv.yaml | 67 ++- .../states/de/tax/income/de_agi_indiv.yaml | 67 ++- .../states/ia/tax/income/ia_net_income.yaml | 73 ++- .../gov/states/ms/tax/income/ms_agi.yaml | 101 +++- ...uctions_for_federal_itemization_joint.yaml | 56 +++ .../mt_itemized_deductions_joint.yaml | 56 +++ .../states/mt/tax/income/mt_agi_indiv.yaml | 85 +++- .../gov/states/ok/tax/income/ok_ptc.yaml | 16 + .../gov/states/ar/tax/income/ar_agi_indiv.py | 17 +- .../itemized/ar_itemized_deductions_indiv.py | 4 +- .../gov/states/de/tax/income/de_agi_indiv.py | 18 +- .../ia/tax/income/net_income/ia_net_income.py | 13 +- .../gov/states/ms/tax/income/ms_agi.py | 14 +- .../states/mt/tax/income/base/mt_agi_indiv.py | 17 +- ...eductions_for_federal_itemization_joint.py | 9 +- .../general/mt_itemized_deductions_joint.py | 9 +- .../states/ok/tax/income/credits/ok_ptc.py | 11 +- 20 files changed, 1076 insertions(+), 45 deletions(-) create mode 100644 changelog.d/fix-state-head-spouse-swap.fixed.md create mode 100644 policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py diff --git a/changelog.d/fix-state-head-spouse-swap.fixed.md b/changelog.d/fix-state-head-spouse-swap.fixed.md new file mode 100644 index 00000000000..bf5ecdf41aa --- /dev/null +++ b/changelog.d/fix-state-head-spouse-swap.fixed.md @@ -0,0 +1 @@ +Make Arkansas, Delaware, Iowa, Mississippi, Montana and Oklahoma income taxes independent of which spouse is labelled head: dependents' income on a separate-column return goes to the spouse with the greater income (an exact tie splits it), Montana's joint itemized deductions count both spouses' mortgage and investment interest, and Oklahoma's property tax credit accepts a totally disabled spouse. diff --git a/policyengine_us/model_api.py b/policyengine_us/model_api.py index 56abf55953c..f348b328758 100644 --- a/policyengine_us/model_api.py +++ b/policyengine_us/model_api.py @@ -55,6 +55,37 @@ def allocate_joint_amount_to_minimize_combined_tax( return best_head_allocation +def move_dependent_amounts_to_filer(person, period, amount): + """Puts the tax unit dependents' `amount` on a filer's own column. + + Some states count dependents' income on their filers' return. When spouses + file separately on one return, each column is a separate return, and a + child's income that parents filing separately report goes on the return + of the parent with the greater taxable income (26 U.S.C. 1(g)(5)(B); IRS + Form 8814 instructions). The spouse whose own `amount` is greater takes + the dependents' total, and an exact tie splits it equally, so the result + does not depend on which spouse is labelled head. Without a spouse the + head takes it. Dependents keep nothing of their own. + """ + tax_unit = person.tax_unit + is_dependent = person("is_tax_unit_dependent", period) + is_head = person("is_tax_unit_head", period) + is_spouse = person("is_tax_unit_spouse", period) + dependents_amount = tax_unit.sum(is_dependent * amount) + head_amount = tax_unit.sum(is_head * amount) + spouse_amount = tax_unit.sum(is_spouse * amount) + own = where(is_head, head_amount, spouse_amount) + other = where(is_head, spouse_amount, head_amount) + has_spouse = tax_unit.any(is_spouse) + share = where( + has_spouse, + where(own > other, 1, where(own == other, 0.5, 0)), + 1, + ) + filer_share = (is_head | is_spouse) * share + return where(is_dependent, 0, amount) + filer_share * dependents_amount + + STATES = [ "AL", "AK", diff --git a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py new file mode 100644 index 00000000000..d8afeaa2090 --- /dev/null +++ b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py @@ -0,0 +1,456 @@ +"""State income taxes do not depend on which spouse is labelled head. + +Which spouse a married couple lists first on a return is a convention of the +form. States tax "the taxpayer and spouse" alike, and spouses who file +separately on one return each report their own income in their own column +(Arkansas Filing Status 4, Delaware Filing Status 4, Iowa status 3, +Mississippi's combined return, Montana Filing Status 2a). So exchanging the +two adults' `is_tax_unit_head` and `is_tax_unit_spouse` labels, with every +other input kept, must leave each state's income tax unchanged. + +Arkansas, Delaware, Iowa, Mississippi and Montana counted dependents' income +on the head's column before taxing each column separately, Montana's joint +itemized deductions counted only the head's mortgage and investment interest, +and Oklahoma's property tax credit counted only the head's disability. +Dependents' income now goes on the column of the spouse with the greater +income of their own, and an exact tie splits it equally, as parents filing +separately report a child's income on the return of the parent with the +greater taxable income (26 U.S.C. 1(g)(5)(B); IRS Form 8814 instructions). + +Hypothesis draws batches of married couples, with and without dependents +(who may have income), in the six states above for 2021-2026. A seeded +population of those states, with crafted edge cases (offsetting incomes, +spouses with equal income, one disabled or elderly spouse, interest paid by +one spouse), adds breadth in every year, and three seeded couples in every +state guard current law (2026) everywhere else. Each batch runs as one +vectorized simulation that holds every couple twice, as drawn and with the +head and spouse labels exchanged. For each couple: + +1. Swap invariance: state income tax, before and after refundable credits, + is the same to the cent. +2. The per-person columns (`ar_agi_indiv`, `de_agi_indiv`, `ia_net_income`, + `ms_agi`, `mt_agi_indiv`) are the same for each person. +3. Differential: in Arkansas, Delaware, Iowa and Mississippi those columns + equal an independent numpy allocation of each person's own amount, which + conserves the tax unit's total. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation +from policyengine_us.model_api import STATES + +TOLERANCE = 0.01 # dollars: the same to the cent +YEARS = [2021, 2022, 2023, 2024, 2025, 2026] +AFFECTED = ["AR", "DE", "IA", "MS", "MT", "OK"] +ALL_STATES = [s for s in STATES if s not in ("PR", "VI")] +TAX_UNIT_OUTPUTS = [ + "state_income_tax", + "state_income_tax_before_refundable_credits", +] +COLUMNS = ["ar_agi_indiv", "de_agi_indiv", "ia_net_income", "ms_agi", "mt_agi_indiv"] + + +def money(high): + return st.one_of(st.just(0.0), st.integers(1, high).map(float)) + + +def gain_or_loss(low, high): + return st.one_of(st.just(0.0), st.integers(low, high).map(float)) + + +@st.composite +def adults(draw): + return { + "age": draw(st.integers(18, 90)), + "is_blind": draw(st.booleans()), + "is_disabled": draw(st.booleans()), + "employment_income": draw(money(150_000)), + "self_employment_income": draw(gain_or_loss(-20_000, 80_000)), + "taxable_interest_income": draw(money(10_000)), + "qualified_dividend_income": draw(money(10_000)), + "long_term_capital_gains": draw(gain_or_loss(-5_000, 50_000)), + "rental_income": draw(gain_or_loss(-10_000, 30_000)), + "social_security_retirement": draw(money(40_000)), + "taxable_private_pension_income": draw(money(50_000)), + "taxable_ira_distributions": draw(money(30_000)), + "military_retirement_pay": draw(money(30_000)), + "unemployment_compensation": draw(money(12_000)), + "traditional_ira_contributions": draw(money(7_000)), + "student_loan_interest": draw(money(2_500)), + "real_estate_taxes": draw(money(12_000)), + "deductible_mortgage_interest": draw(money(20_000)), + "investment_interest_expense": draw(money(5_000)), + "charitable_cash_donations": draw(money(8_000)), + "other_medical_expenses": draw(money(15_000)), + } + + +@st.composite +def dependents(draw): + age = draw(st.integers(0, 23)) + return { + "age": age, + "is_full_time_student": age >= 18, + "employment_income": draw(money(25_000)), + "self_employment_income": draw(gain_or_loss(-3_000, 8_000)), + "taxable_interest_income": draw(money(3_000)), + "qualified_dividend_income": draw(money(3_000)), + "long_term_capital_gains": draw(gain_or_loss(-2_000, 5_000)), + "pre_subsidy_childcare_expenses": draw(money(10_000)), + } + + +@st.composite +def couples(draw): + return { + "state": draw(st.sampled_from(AFFECTED)), + "adults": [draw(adults()), draw(adults())], + "dependents": draw(st.lists(dependents(), max_size=3)), + } + + +SEED = 20261007 + + +def _seeded_adult(rng): + def some(high, p): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + def signed(low, high, p): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + age = int(rng.integers(18, 91)) + return { + "age": age, + "is_blind": bool(rng.random() < 0.1), + "is_disabled": bool(rng.random() < 0.1), + "employment_income": some(150_000, 0.7), + "self_employment_income": signed(-20_000, 80_000, 0.25), + "taxable_interest_income": some(10_000, 0.4), + "qualified_dividend_income": some(10_000, 0.25), + "long_term_capital_gains": signed(-5_000, 50_000, 0.2), + "rental_income": signed(-10_000, 30_000, 0.12), + "social_security_retirement": some(40_000, 0.5 if age >= 62 else 0.03), + "taxable_private_pension_income": some(50_000, 0.45 if age >= 55 else 0.05), + "taxable_ira_distributions": some(30_000, 0.2 if age >= 59 else 0.03), + "military_retirement_pay": some(30_000, 0.04), + "unemployment_compensation": some(12_000, 0.06), + "traditional_ira_contributions": some(7_000, 0.15), + "student_loan_interest": some(2_500, 0.1), + "real_estate_taxes": some(12_000, 0.35), + "deductible_mortgage_interest": some(20_000, 0.25), + "investment_interest_expense": some(5_000, 0.08), + "charitable_cash_donations": some(8_000, 0.3), + "other_medical_expenses": some(15_000, 0.2), + } + + +def _seeded_dependent(rng): + def some(high, p): + return float(round(rng.uniform(1, high))) if rng.random() < p else 0.0 + + age = int(rng.integers(0, 24)) + return { + "age": age, + "is_full_time_student": age >= 18, + "employment_income": some(25_000, 0.5 if age >= 14 else 0.0), + "self_employment_income": some(8_000, 0.1 if age >= 14 else 0.0), + "taxable_interest_income": some(3_000, 0.3), + "qualified_dividend_income": some(3_000, 0.15), + "long_term_capital_gains": some(5_000, 0.08), + "pre_subsidy_childcare_expenses": some(10_000, 0.5 if age < 13 else 0.0), + } + + +def _review_cases(): + # The cases in the review of PolicyEngine/policyengine-us#9617: adults + # with $60,000 and $10,000 in Arkansas and $100,000 and $30,000 in + # Delaware, each with a dependent earning $20,000. + def adult(wages): + return {"age": 45, "employment_income": wages} + + dependent = {"age": 17, "employment_income": 20_000.0} + return [ + { + "state": "AR", + "adults": [adult(60_000.0), adult(10_000.0)], + "dependents": [dependent], + }, + { + "state": "DE", + "adults": [adult(100_000.0), adult(30_000.0)], + "dependents": [dependent], + }, + ] + + +def _edge_cases(states): + # Couples that random draws rarely produce, in each of the states. + def adult(age=45, **amounts): + return {"age": age, **amounts} + + teen = {"age": 17, "employment_income": 9_000.0, "taxable_interest_income": 900.0} + itemized = { + "real_estate_taxes": 9_000.0, + "deductible_mortgage_interest": 30_000.0, + "charitable_cash_donations": 4_000.0, + } + couples = [ + # Offsetting self-employment income, so the couple's total is zero. + ( + [ + adult(self_employment_income=30_000.0, **itemized), + adult(self_employment_income=-30_000.0), + ], + [teen], + ), + # Spouses with equal income and a dependent with income. + ( + [adult(employment_income=45_000.0), adult(employment_income=45_000.0)], + [teen], + ), + ([adult(), adult()], [teen]), + # One spouse has all the income; the dependent too. + ( + [adult(employment_income=90_000.0), adult()], + [teen, {"age": 15, "taxable_interest_income": 2_000.0}], + ), + # Interest paid by one spouse, enough to itemize. + ( + [ + adult(employment_income=120_000.0), + adult( + employment_income=30_000.0, + **itemized, + investment_interest_expense=6_000.0, + taxable_interest_income=9_000.0, + ), + ], + [], + ), + # Low income, property tax, one totally disabled spouse under 65. + ( + [ + adult(40, employment_income=6_000.0, real_estate_taxes=900.0), + adult(40, is_disabled=True), + ], + [], + ), + # One spouse 65 or older, the other young, low income. + ( + [ + adult( + 70, social_security_retirement=14_000.0, real_estate_taxes=2_500.0 + ), + adult(50, employment_income=8_000.0), + ], + [], + ), + # One blind spouse. + ( + [ + adult(80, social_security_retirement=20_000.0, is_blind=True), + adult(30, employment_income=25_000.0), + ], + [], + ), + ] + return [ + {"state": state, "adults": adults, "dependents": deps} + for state in states + for adults, deps in couples + ] + + +def _seeded_couples(states, per_state): + rng = np.random.default_rng(SEED) + return [ + { + "state": state, + "adults": [_seeded_adult(rng), _seeded_adult(rng)], + # Most couples have dependents. + "dependents": [ + _seeded_dependent(rng) for _ in range(int(rng.choice([0, 1, 1, 2, 3]))) + ], + } + for state in states + for _ in range(per_state) + ] + + +def _situation(units, year): + people = {} + groups = { + "tax_units": {}, + "spm_units": {}, + "families": {}, + "marital_units": {}, + "households": {}, + } + for i, unit in enumerate(units): + # The second copy exchanges the head and spouse labels; people keep + # their order and every other input. + for copy in (0, 1): + a, b = f"a_{i}_{copy}", f"b_{i}_{copy}" + for name, amounts, is_head in ( + (a, unit["adults"][0], copy == 0), + (b, unit["adults"][1], copy == 1), + ): + people[name] = { + **amounts, + "is_tax_unit_head": is_head, + "is_tax_unit_spouse": not is_head, + "is_tax_unit_dependent": False, + } + members = [a, b] + groups["marital_units"][f"couple_{i}_{copy}"] = {"members": [a, b]} + for j, amounts in enumerate(unit["dependents"]): + child = f"dependent_{i}_{copy}_{j}" + people[child] = { + **amounts, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + } + members.append(child) + groups["marital_units"][f"single_{i}_{copy}_{j}"] = {"members": [child]} + for group, prefix in ( + ("tax_units", "tax_unit"), + ("spm_units", "spm_unit"), + ("families", "family"), + ): + groups[group][f"{prefix}_{i}_{copy}"] = {"members": members} + groups["households"][f"household_{i}_{copy}"] = { + "members": members, + "state_code": {year: unit["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _own_amounts(sim, year, column): + # Each person's own amount before dependents' amounts are moved, from the + # column's inputs. + def calc(name): + return np.asarray(sim.calculate(name, year), dtype=float) + + if column == "ar_agi_indiv": + return np.maximum(calc("ar_gross_income_indiv") - calc("ar_exemptions"), 0) + if column == "de_agi_indiv": + return np.maximum( + calc("de_pre_exclusions_agi") + - calc("de_elderly_or_disabled_income_exclusion_indiv"), + 0, + ) + if column == "ia_net_income": + return calc("ia_gross_income") - calc("ia_income_adjustments") + if column == "ms_agi": + p = sim.tax_benefit_system.parameters(year).gov.states.ms.tax.income + net = sum(calc(source) for source in p.income_sources) - calc( + "ms_agi_adjustments" + ) + dependent = calc("is_tax_unit_dependent").astype(bool) + return np.where(dependent, np.maximum(net, 0), net) + raise ValueError(column) + + +def _reference_allocation(own, unit, head, spouse, dependent): + # Independent numpy statement of the rule: the spouse whose own amount is + # greater takes the dependents' total; an exact tie splits it equally. + n_units = unit.max() + 1 + + def unit_sum(mask): + return np.bincount(unit, weights=own * mask, minlength=n_units) + + head_own = unit_sum(head)[unit] + spouse_own = unit_sum(spouse)[unit] + dependents_total = unit_sum(dependent)[unit] + has_spouse = np.bincount(unit, weights=spouse, minlength=n_units)[unit] > 0 + mine = np.where(head, head_own, spouse_own) + other = np.where(head, spouse_own, head_own) + share = np.where(mine > other, 1.0, np.where(mine == other, 0.5, 0.0)) + share = np.where(has_spouse, share, 1.0) * (head | spouse) + return np.where(dependent, 0.0, own) + share * dependents_total + + +def _check(units, year): + sim = Simulation(situation=_situation(units, year)) + states = np.array([u["state"] for u in units]) + + # 1. Swap invariance of each state's income tax. + for name in TAX_UNIT_OUTPUTS: + values = np.asarray(sim.calculate(name, year), dtype=float) + drawn, swapped = values[0::2], values[1::2] + differs = np.abs(swapped - drawn) > TOLERANCE + assert not differs.any(), ( + f"{name} in {year} changes when the head and spouse labels are " + f"exchanged: " + + ", ".join( + f"{states[i]} {drawn[i]:.2f} -> {swapped[i]:.2f}" + for i in np.flatnonzero(differs)[:10] + ) + ) + + # Tax units are numbered in the order _situation adds them: couple i's + # first copy is unit 2i and its second copy is unit 2i + 1. + unit = np.asarray(sim.populations["tax_unit"].members_entity_id) + head = np.asarray(sim.calculate("is_tax_unit_head", year), dtype=bool) + spouse = np.asarray(sim.calculate("is_tax_unit_spouse", year), dtype=bool) + dependent = np.asarray(sim.calculate("is_tax_unit_dependent", year), dtype=bool) + # People keep their order in both copies, so person k of a couple's first + # copy is person k of its second. + first_copy = unit % 2 == 0 + for column in COLUMNS: + values = np.asarray(sim.calculate(column, year), dtype=float) + # 2. Each person's column is the same under either labelling. + np.testing.assert_allclose( + values[~first_copy], + values[first_copy], + atol=TOLERANCE, + err_msg=f"{column} in {year}", + ) + if column == "mt_agi_indiv": + continue + # 3. Differential against the numpy allocation, where the column + # applies (its state, and its years for Iowa). + own = _own_amounts(sim, year, column) + reference = _reference_allocation(own, unit, head, spouse, dependent) + in_state = states[unit // 2] == column[:2].upper() + np.testing.assert_allclose( + values[in_state], + reference[in_state], + atol=TOLERANCE, + err_msg=f"{column} in {year}", + ) + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +@settings( + max_examples=12, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) +@given(st.sampled_from(YEARS), st.lists(couples(), min_size=10, max_size=40)) +def test_state_income_tax_does_not_depend_on_head_label(year, units): + _check(units, year) + + +@pytest.mark.parametrize("year", YEARS) +def test_seeded_population(year): + units = _seeded_couples(AFFECTED, per_state=20) + _check(units + _edge_cases(AFFECTED) + _review_cases(), year) + + +def test_every_state_under_current_law(): + # Simulating every state's earlier years makes this the costliest batch, + # so the guard outside the five states runs for one year. + _check(_seeded_couples(ALL_STATES, per_state=3), 2026) diff --git a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/ar_agi_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/ar_agi_indiv.yaml index c4c6ba6cd8c..3a523a4c5e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/ar_agi_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ar/tax/income/ar_agi_indiv.yaml @@ -19,7 +19,7 @@ output: ar_agi_indiv: [900, 1_800] -- name: Dependent AGI gets attributed to the head, AGI capped at 0 +- name: Dependent AGI goes to the spouse with the greater AGI, AGI capped at 0 period: 2022 input: people: @@ -64,3 +64,68 @@ ar_gross_income_indiv: 56_000 ar_capped_retirement_or_disability_benefits_exemption_person: 6_000 ar_agi_indiv: 50_000 + +- name: Dependent AGI goes to the spouse with the greater AGI when that spouse is not the head + period: 2022 + input: + people: + person1: + ar_gross_income_indiv: 1_000 + ar_exemptions: 100 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person2: + ar_gross_income_indiv: 100 + ar_exemptions: 200 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + person3: + ar_gross_income_indiv: 500 + ar_exemptions: 200 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + # The same couple as above with the head and spouse labels exchanged. + ar_agi_indiv: [1_200, 0, 0] + +- name: Spouses with equal AGI split the dependent AGI equally + period: 2022 + input: + people: + person1: + ar_gross_income_indiv: 1_000 + ar_exemptions: 0 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + person2: + ar_gross_income_indiv: 1_000 + ar_exemptions: 0 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person3: + ar_gross_income_indiv: 300 + ar_exemptions: 0 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + ar_agi_indiv: [1_150, 1_150, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_agi_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_agi_indiv.yaml index 7884fd15c73..8ed4a71f642 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_agi_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_agi_indiv.yaml @@ -19,7 +19,7 @@ output: de_agi_indiv: [900, 1_800] -- name: Dependent AGI gets attributed to the head, AGI capped at 0 +- name: Dependent AGI goes to the spouse with the greater AGI, AGI capped at 0 period: 2022 input: people: @@ -44,3 +44,68 @@ state_code: DE output: de_agi_indiv: [1_200, 0, 0] + +- name: Dependent AGI goes to the spouse with the greater AGI when that spouse is not the head + period: 2022 + input: + people: + person1: + de_pre_exclusions_agi: 1_000 + de_elderly_or_disabled_income_exclusion_indiv: 100 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person2: + de_pre_exclusions_agi: 100 + de_elderly_or_disabled_income_exclusion_indiv: 200 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + person3: + de_pre_exclusions_agi: 500 + de_elderly_or_disabled_income_exclusion_indiv: 200 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: DE + output: + # The same couple as above with the head and spouse labels exchanged. + de_agi_indiv: [1_200, 0, 0] + +- name: Spouses with equal AGI split the dependent AGI equally + period: 2022 + input: + people: + person1: + de_pre_exclusions_agi: 1_000 + de_elderly_or_disabled_income_exclusion_indiv: 0 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + person2: + de_pre_exclusions_agi: 1_000 + de_elderly_or_disabled_income_exclusion_indiv: 0 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person3: + de_pre_exclusions_agi: 300 + de_elderly_or_disabled_income_exclusion_indiv: 0 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: DE + output: + de_agi_indiv: [1_150, 1_150, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_net_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_net_income.yaml index 48cca945a7b..92178c63871 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ia/tax/income/ia_net_income.yaml @@ -26,7 +26,7 @@ output: ia_net_income: [9_400, 0, 0] -- name: IA net income unit test 2 +- name: Spouses with equal net income split the dependent net income equally absolute_error_margin: 0.01 period: 2022 input: @@ -52,7 +52,76 @@ members: [person1, person2, person3] state_code: IA output: - ia_net_income: [8_600, 8_000, 0] + # The spouses' net incomes are equal, so they split the dependent's. + ia_net_income: [8_300, 8_300, 0] + +- name: Dependent net income goes to the spouse with the greater net income, here not the head + absolute_error_margin: 0.01 + period: 2021 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + age: 40 + ia_gross_income: 10_000 + ia_income_adjustments: 2_000 + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + age: 38 + ia_gross_income: 30_000 + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + age: 17 + ia_gross_income: 600 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: IA + output: + ia_net_income: [8_000, 30_600, 0] + +- name: Dependent net income stays with the same spouse when the head and spouse labels are exchanged + absolute_error_margin: 0.01 + period: 2021 + input: + people: + person1: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + age: 40 + ia_gross_income: 10_000 + ia_income_adjustments: 2_000 + person2: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + age: 38 + ia_gross_income: 30_000 + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + age: 17 + ia_gross_income: 600 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: IA + output: + ia_net_income: [8_000, 30_600, 0] - name: 2022 single filer with wages and traditional IRA distribution period: 2022 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml index 19d71f5778f..9cfc026c9e0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ms/tax/income/ms_agi.yaml @@ -1,4 +1,4 @@ -- name: The dependent income is allocated to the head +- name: The dependent income goes to the spouse with the greater income, here not the head period: 2021 input: people: @@ -22,7 +22,104 @@ members: [person1, person2, person3] state_code: MS output: - ms_agi: [12_000, 10_000, 0] + # The spouse's $10,000 is greater than the head's $9,000. + ms_agi: [9_000, 13_000, 0] + +- name: The dependent income stays with the same spouse when the head and spouse labels are exchanged + period: 2021 + input: + people: + person1: + employment_income: 10_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + ms_agi_adjustments: 1_000 + person2: + employment_income: 12_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ms_agi_adjustments: 2_000 + person3: + employment_income: 3_000 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ms_agi_adjustments: 0 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MS + output: + ms_agi: [9_000, 13_000, 0] + +- name: Spouses with equal income split the dependent income equally + period: 2021 + input: + people: + person1: + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ms_agi_adjustments: 0 + person2: + employment_income: 10_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + ms_agi_adjustments: 0 + person3: + employment_income: 3_000 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ms_agi_adjustments: 0 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MS + output: + ms_agi: [11_500, 11_500, 0] + +- name: A dependent's negative income is not moved to a spouse + period: 2021 + input: + people: + person1: + employment_income: 10_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + ms_agi_adjustments: 0 + person2: + employment_income: 4_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + ms_agi_adjustments: 0 + person3: + employment_income: 1_000 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + ms_agi_adjustments: 3_000 + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MS + output: + ms_agi: [10_000, 4_000, 0] - name: A negative amount is kept, so a joint return can combine it with the other column period: 2021 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml index 8a1114f4ef5..cafa4cb709d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml @@ -74,3 +74,59 @@ mt_federal_income_tax_deduction_for_federal_itemization: 600 output: mt_itemized_deductions_for_federal_itemization_joint: [2_212, 0, 0] + +- name: A joint return deducts the mortgage and investment interest either spouse paid + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + mortgage_interest: 6_000 + investment_interest_expense: 500 + households: + household: + members: [person1, person2] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2] + charitable_deduction: 0 + mt_federal_income_tax_deduction_for_federal_itemization: 0 + output: + mt_itemized_deductions_for_federal_itemization_joint: [10_500, 0] + +- name: The joint deduction is the same when the head and spouse labels are exchanged + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 6_000 + investment_interest_expense: 500 + households: + household: + members: [person1, person2] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2] + charitable_deduction: 0 + mt_federal_income_tax_deduction_for_federal_itemization: 0 + output: + mt_itemized_deductions_for_federal_itemization_joint: [0, 10_500] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml index 6b25795e07a..c01f7fb59f2 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml @@ -74,3 +74,59 @@ mt_federal_income_tax_deduction_unit: 600 output: mt_itemized_deductions_joint: [2_212, 0, 0] + +- name: A joint return deducts the mortgage and investment interest either spouse paid + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + mortgage_interest: 6_000 + investment_interest_expense: 500 + households: + household: + members: [person1, person2] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2] + charitable_deduction: 0 + mt_federal_income_tax_deduction_unit: 0 + output: + mt_itemized_deductions_joint: [10_500, 0] + +- name: The joint deduction is the same when the head and spouse labels are exchanged + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 6_000 + investment_interest_expense: 500 + households: + household: + members: [person1, person2] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2] + charitable_deduction: 0 + mt_federal_income_tax_deduction_unit: 0 + output: + mt_itemized_deductions_joint: [0, 10_500] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv.yaml index 377b0d7d460..157f8aa56a0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv.yaml @@ -1,4 +1,4 @@ -- name: The dependent incoome is allocated to the head +- name: The dependent income goes to the spouse with the greater income period: 2021 input: people: @@ -67,3 +67,86 @@ state_code: MT output: mt_agi_indiv: [9_200, 1_600, 0] + +- name: The dependent income goes to the spouse with the greater income, here not the head + period: 2021 + input: + people: + person1: + is_tax_unit_head: false + is_tax_unit_spouse: true + adjusted_gross_income_person: 10_000 + mt_additions: 500 + mt_subtractions: 3_000 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: false + person2: + is_tax_unit_head: true + is_tax_unit_spouse: false + adjusted_gross_income_person: 2_000 + mt_additions: 300 + mt_subtractions: 700 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: false + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + adjusted_gross_income_person: 2_000 + mt_additions: 300 + mt_subtractions: 700 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + # The first test's couple with the head and spouse labels exchanged. + mt_agi_indiv: [9_100, 1_600, 0] + +- name: Spouses with equal income split the dependent income equally + period: 2021 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + adjusted_gross_income_person: 5_000 + mt_additions: 0 + mt_subtractions: 0 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: false + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + adjusted_gross_income_person: 5_000 + mt_additions: 0 + mt_subtractions: 0 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: false + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + adjusted_gross_income_person: 0 + mt_additions: 1_000 + mt_subtractions: 0 + mt_taxable_social_security: 0 + taxable_social_security: 0 + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MT + output: + mt_agi_indiv: [5_500, 5_500, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_ptc.yaml b/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_ptc.yaml index 0350d505792..0f41f9ff6f9 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_ptc.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ok/tax/income/ok_ptc.yaml @@ -106,3 +106,19 @@ # Excess = max(0, $300 - 0.01 * $9,000) = max(0, $300 - $90) = $210 # Credit = min($200, $210) = $200 ok_ptc: 200 + +- name: OK PTC - 2025 - a disabled spouse under 65 qualifies + period: 2025 + input: + age_head: 55 + age_spouse: 55 + head_is_disabled: false + spouse_is_disabled: true + ok_gross_income: 9_000 + real_estate_taxes: 400 + state_code: OK + output: + # Either spouse can claim as the disabled head of household, so the + # couple qualifies whichever of them is listed first. + # Excess tax: $400 - 1% * $9,000 = $310, capped at $200. + ok_ptc: 200 diff --git a/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py b/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py index bd630daeb90..55f7000d1ea 100644 --- a/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py +++ b/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py @@ -7,15 +7,20 @@ class ar_agi_indiv(Variable): label = "Arkansas adjusted gross income for each individual" unit = USD definition_period = YEAR - reference = "https://www.dfa.arkansas.gov/wp-content/uploads/2022_AR1000F_and_AR1000NR_Instructions.pdf#page=14" + reference = ( + "https://www.dfa.arkansas.gov/wp-content/uploads/2022_AR1000F_and_AR1000NR_Instructions.pdf#page=14", + # Filing Status 4: the primary's income in column A, the spouse's in B + "https://www.dfa.arkansas.gov/wp-content/uploads/2025_AR1000F_and_AR1000NR_Instructions.pdf#page=12", + # Parents filing separately: the parent with the greater taxable income + "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", + "https://www.irs.gov/instructions/i8814", + ) defined_for = StateCode.AR def formula(person, period, parameters): gross_income = person("ar_gross_income_indiv", period) income_exemptions = person("ar_exemptions", period) net_income = max_(gross_income - income_exemptions, 0) - # allocate any dependent gross income to tax unit head - is_dependent = person("is_tax_unit_dependent", period) - sum_dep_net_income = person.tax_unit.sum(is_dependent * net_income) - is_head = person("is_tax_unit_head", period) - return ~is_dependent * net_income + is_head * sum_dep_net_income + # Each spouse's column holds their own income. Dependents' income goes + # on the column of the spouse with the greater income. + return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/ar/tax/income/deductions/itemized/ar_itemized_deductions_indiv.py b/policyengine_us/variables/gov/states/ar/tax/income/deductions/itemized/ar_itemized_deductions_indiv.py index 84c1a80dacf..6ee9e6729a1 100644 --- a/policyengine_us/variables/gov/states/ar/tax/income/deductions/itemized/ar_itemized_deductions_indiv.py +++ b/policyengine_us/variables/gov/states/ar/tax/income/deductions/itemized/ar_itemized_deductions_indiv.py @@ -26,6 +26,6 @@ def formula(person, period, parameters): # Round prorate to the nearest percent and then divide by 100 prorate = np.round(prorate * 100) / 100 - # Dependents should always return 0 as their AGI is always - # attributed to the head of the tax unit in ar_agi + # Dependents should always return 0 as ar_agi_indiv puts their + # income on a spouse's column return unit_deds * prorate diff --git a/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py b/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py index 27788a5f357..d6d4a216528 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py @@ -7,6 +7,13 @@ class de_agi_indiv(Variable): label = "Delaware adjusted gross income for each individual when married filing separately" unit = USD definition_period = YEAR + reference = ( + # Filing Status 3 or 4: each spouse reports their own income + "https://revenuefiles.delaware.gov/2025/PITForms_Instructions/Instructions/PIT-RES_Instructions_2025-01.pdf#page=5", + # Parents filing separately: the parent with the greater taxable income + "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", + "https://www.irs.gov/instructions/i8814", + ) defined_for = StateCode.DE def formula(person, period, parameters): @@ -15,10 +22,7 @@ def formula(person, period, parameters): "de_elderly_or_disabled_income_exclusion_indiv", period ) net_income = max_(pre_exclusions_agi - indv_exclusions, 0) - # allocate any dependent gross income to tax unit head - is_dependent = person("is_tax_unit_dependent", period) - sum_dep_net_income = person.tax_unit.sum(is_dependent * net_income) - is_head = person("is_tax_unit_head", period) - head_spouse_net_income = ~is_dependent * net_income - head_allocated_dependent_net_income = is_head * sum_dep_net_income - return head_spouse_net_income + head_allocated_dependent_net_income + # On a combined separate return each spouse reports their own income. + # Dependents' income goes on the return of the spouse with the + # greater income. + return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py b/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py index 403a3ec3876..184cf6f0816 100644 --- a/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py +++ b/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py @@ -12,6 +12,11 @@ class ia_net_income(Variable): "https://revenue.iowa.gov/media/2650/download?inline", "https://revenue.iowa.gov/sites/default/files/2023-01/2022IA1040%2841001%29.pdf", "https://revenue.iowa.gov/media/2721/download?inline", + # Status 3: married filing separately on a combined return + "https://revenue.iowa.gov/media/2650/download?inline#page=7", + # Parents filing separately: the parent with the greater taxable income + "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", + "https://www.irs.gov/instructions/i8814", ) defined_for = StateCode.IA @@ -19,8 +24,6 @@ def formula(person, period, parameters): gross_income = person("ia_gross_income", period) income_adjustments = person("ia_income_adjustments", period) net_income = gross_income - income_adjustments - # allocate any dependent net_income to tax unit head - is_dependent = person("is_tax_unit_dependent", period) - sum_dep_net_income = person.tax_unit.sum(is_dependent * net_income) - is_head = person("is_tax_unit_head", period) - return ~is_dependent * net_income + is_head * sum_dep_net_income + # Each spouse's column holds their own income. Dependents' net income + # goes on the column of the spouse with the greater net income. + return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py index 3ac23b4329a..28675016569 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py @@ -10,6 +10,11 @@ class ms_agi(Variable): reference = ( "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100221.pdf#page=14", "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80105228.pdf", # Line 66 + # Combined return: one spouse's income in Column A, the other's in B + "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100221.pdf#page=5", + # Parents filing separately: the parent with the greater taxable income + "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", + "https://www.irs.gov/instructions/i8814", ) defined_for = StateCode.MS @@ -20,8 +25,9 @@ def formula(person, period, parameters): # A spouse's column can be negative on a joint or combined return; # the tax computation combines it with the other column. net_income = gross_income - adjustments - # Allocate income from dependents to tax unit head. + # Each spouse's column holds their own income. Dependents' positive + # income goes on the column of the spouse with the greater income. is_dependent = person("is_tax_unit_dependent", period) - sum_dep_net_income = person.tax_unit.sum(is_dependent * max_(net_income, 0)) - is_head = person("is_tax_unit_head", period) - return ~is_dependent * net_income + is_head * sum_dep_net_income + return move_dependent_amounts_to_filer( + person, period, where(is_dependent, max_(net_income, 0), net_income) + ) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py index 943c22aca8c..14811f1a6fa 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py @@ -7,6 +7,13 @@ class mt_agi_indiv(Variable): label = "Montana Adjusted Gross Income for each individual" unit = USD definition_period = YEAR + reference = ( + # Filing separately: report items of income as on the federal return + "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=12", + # Parents filing separately: the parent with the greater taxable income + "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", + "https://www.irs.gov/instructions/i8814", + ) defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): @@ -28,9 +35,7 @@ def formula(person, period, parameters): # 2024 and after: no longer apply the social security adjustment tax_unit_mt_agi = reduced_agi - # in all years - # allocate any dependent net_income to tax unit head - is_dependent = person("is_tax_unit_dependent", period) - sum_dep_net_income = person.tax_unit.sum(is_dependent * tax_unit_mt_agi) - is_head = person("is_tax_unit_head", period) - return ~is_dependent * tax_unit_mt_agi + is_head * sum_dep_net_income + # In all years, dependents' income goes on the return of the spouse + # with the greater income: filing separately, each spouse reports + # income as on a separate federal return. + return move_dependent_amounts_to_filer(person, period, tax_unit_mt_agi) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py index 3469bb0b4b3..2f14478834b 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py @@ -16,9 +16,12 @@ class mt_itemized_deductions_for_federal_itemization_joint(Variable): def formula(person, period, parameters): charitable_deduction = person.tax_unit("charitable_deduction", period) - investment_interest = person("investment_interest_expense", period) - mortgage_interest = person("mortgage_interest", period) - interest_ded = investment_interest + mortgage_interest + # A joint return deducts interest either spouse paid. + interest_ded = add( + person.tax_unit, + period, + ["investment_interest_expense", "mortgage_interest"], + ) other_deductions = add( person.tax_unit, period, diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py index a3328b0290d..7bc86a75657 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py @@ -16,9 +16,12 @@ class mt_itemized_deductions_joint(Variable): def formula(person, period, parameters): charitable_deduction = person.tax_unit("charitable_deduction", period) - investment_interest = person("investment_interest_expense", period) - mortgage_interest = person("mortgage_interest", period) - interest_ded = investment_interest + mortgage_interest + # A joint return deducts interest either spouse paid. + interest_ded = add( + person.tax_unit, + period, + ["investment_interest_expense", "mortgage_interest"], + ) other_deductions = add( person.tax_unit, period, diff --git a/policyengine_us/variables/gov/states/ok/tax/income/credits/ok_ptc.py b/policyengine_us/variables/gov/states/ok/tax/income/credits/ok_ptc.py index 5c54da9362d..8340c327ae0 100644 --- a/policyengine_us/variables/gov/states/ok/tax/income/credits/ok_ptc.py +++ b/policyengine_us/variables/gov/states/ok/tax/income/credits/ok_ptc.py @@ -10,6 +10,9 @@ class ok_ptc(Variable): reference = ( # 2025 Form 538-H (Property Tax Credit form) "https://oklahoma.gov/content/dam/ok/en/tax/documents/forms/individuals/current/538-H.pdf", + # 68 O.S. 2906: a person 65 or older or totally disabled who heads + # the household + "https://www.oscn.net/applications/oscn/DeliverDocument.asp?CiteID=93068", ) defined_for = StateCode.OK documentation = """ @@ -21,7 +24,7 @@ class ok_ptc(Variable): Eligibility requirements (must meet ALL): 1. Age requirement: Head OR spouse must be age 65 or older, OR - head must be totally disabled + head OR spouse must be totally disabled 2. Income requirement: Gross household income must be $12,000 or less 3. Must have paid real estate taxes on Oklahoma homestead @@ -58,8 +61,12 @@ def formula(tax_unit, period, parameters): # Step 1: Check age/disability eligibility elderly_head = tax_unit("age_head", period) >= p.age_minimum elderly_spouse = tax_unit("age_spouse", period) >= p.age_minimum + # The claimant is a totally disabled head of household: a person who + # owns or jointly owns and maintains the home. On a joint return + # either spouse can be that claimant, as either spouse's age counts. disabled_head = tax_unit("head_is_disabled", period) - unit_eligible = elderly_head | elderly_spouse | disabled_head + disabled_spouse = tax_unit("spouse_is_disabled", period) + unit_eligible = elderly_head | elderly_spouse | disabled_head | disabled_spouse # Step 2: Check income eligibility (gross income <= $12,000) income = tax_unit("ok_gross_income", period) income_eligible = income <= p.income_limit From 92ff1b0a42b51899b7654bcf2040e8e4e93aac93 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Wed, 7 Oct 2026 15:38:45 -0400 Subject: [PATCH 2/4] Address review: fix the remaining head-label dependences and tighten tests The independent review of #9981 found four more state formulas that change when a couple's head and spouse labels are exchanged: - Minnesota's dependent care credit and Montana's dependent care deduction counted an incapacitated spouse but not an incapacitated head. - Delaware's combined separate return sent the EITC (and the dependent care credit) to the head's column on equal taxable incomes, and split the dependent personal credits with a floor/ceil heuristic that was not symmetric. Ties now go to the column where they cut tax most, and the split searches every allocation. - Missouri's property tax credit tested the surviving-spouse pathway on the head only; either spouse can be the claimant. Also: - Montana's joint itemized deductions count the spouses' interest, not a dependent's own. - The helper's docstring and the state comments call the dependent allocation a modelling convention, and state its one-spouse precondition. - The property test covers MN and MO, incapacity, adult care expenses, survivor benefits, veterans and dependents' DE/MT additions, adds the review's counterexamples, and asserts finite values with rtol=0. Co-Authored-By: Claude Opus 5.5 --- .../fix-state-head-spouse-swap.fixed.md | 2 +- policyengine_us/model_api.py | 20 ++-- ...t_state_tax_head_spouse_swap_invariance.py | 106 ++++++++++++++---- ...ax_before_refundable_credits_separate.yaml | 86 ++++++++++++++ .../credits/cdcc/mn_cdcc_dependent_count.yaml | 70 ++++++++++++ .../property_tax/mo_ptc_taxunit_eligible.yaml | 25 ++++- ...pense_deduction_qualifying_individual.yaml | 44 ++++++++ ...uctions_for_federal_itemization_joint.yaml | 32 ++++++ .../mt_itemized_deductions_joint.yaml | 32 ++++++ .../gov/states/ar/tax/income/ar_agi_indiv.py | 9 +- .../tax/income/credits/cdcc/de_cdcc_indv.py | 18 ++- .../personal/de_personal_credit_indv.py | 57 ++++++---- .../gov/states/de/tax/income/de_agi_indiv.py | 9 +- ..._tax_before_refundable_credits_separate.py | 13 ++- .../ia/tax/income/net_income/ia_net_income.py | 9 +- .../credits/cdcc/mn_cdcc_dependent_count.py | 19 +++- .../property_tax/mo_ptc_taxunit_eligible.py | 18 +-- .../gov/states/ms/tax/income/ms_agi.py | 10 +- .../states/mt/tax/income/base/mt_agi_indiv.py | 13 ++- ...expense_deduction_qualifying_individual.py | 10 +- ...eductions_for_federal_itemization_joint.py | 11 +- .../general/mt_itemized_deductions_joint.py | 11 +- 22 files changed, 525 insertions(+), 99 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml diff --git a/changelog.d/fix-state-head-spouse-swap.fixed.md b/changelog.d/fix-state-head-spouse-swap.fixed.md index bf5ecdf41aa..cee1d8a77b4 100644 --- a/changelog.d/fix-state-head-spouse-swap.fixed.md +++ b/changelog.d/fix-state-head-spouse-swap.fixed.md @@ -1 +1 @@ -Make Arkansas, Delaware, Iowa, Mississippi, Montana and Oklahoma income taxes independent of which spouse is labelled head: dependents' income on a separate-column return goes to the spouse with the greater income (an exact tie splits it), Montana's joint itemized deductions count both spouses' mortgage and investment interest, and Oklahoma's property tax credit accepts a totally disabled spouse. +Make state income taxes independent of which spouse is labelled head: dependents' income on Arkansas, Delaware, Iowa, Mississippi and Montana separate-column returns goes to the spouse with the greater income (an exact tie splits it); Delaware's EITC, dependent care credit and dependent personal credits no longer go to the head on ties; Montana's joint itemized deductions count both spouses' mortgage and investment interest; and an incapacitated head now qualifies for Minnesota's dependent care credit and Montana's dependent care deduction, a surviving-spouse claimant for Missouri's property tax credit, and a totally disabled spouse for Oklahoma's property tax credit. diff --git a/policyengine_us/model_api.py b/policyengine_us/model_api.py index f348b328758..5bcf5df7f0c 100644 --- a/policyengine_us/model_api.py +++ b/policyengine_us/model_api.py @@ -58,14 +58,20 @@ def allocate_joint_amount_to_minimize_combined_tax( def move_dependent_amounts_to_filer(person, period, amount): """Puts the tax unit dependents' `amount` on a filer's own column. - Some states count dependents' income on their filers' return. When spouses - file separately on one return, each column is a separate return, and a - child's income that parents filing separately report goes on the return + Some states' formulas count dependents' income on their filers' return, + although a dependent mostly reports it on their own return, which the + model does not compute. Where spouses file separately on one return, this + is a modelling convention for which column gets those amounts, not a state + rule: the spouse whose own `amount` is greater takes the dependents' + total, and an exact tie splits it equally, so the result does not depend + on which spouse is labelled head. It borrows the federal rule that a + child's income reported by parents who file separately goes on the return of the parent with the greater taxable income (26 U.S.C. 1(g)(5)(B); IRS - Form 8814 instructions). The spouse whose own `amount` is greater takes - the dependents' total, and an exact tie splits it equally, so the result - does not depend on which spouse is labelled head. Without a spouse the - head takes it. Dependents keep nothing of their own. + Form 8814 instructions). Without a spouse the head takes it. Dependents + keep nothing of their own. + + Assumes each tax unit has one head and at most one spouse, as the role + variables assign; with two spouses the total would not be conserved. """ tax_unit = person.tax_unit is_dependent = person("is_tax_unit_dependent", period) diff --git a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py index d8afeaa2090..7a9175706fa 100644 --- a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py +++ b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py @@ -8,31 +8,37 @@ two adults' `is_tax_unit_head` and `is_tax_unit_spouse` labels, with every other input kept, must leave each state's income tax unchanged. -Arkansas, Delaware, Iowa, Mississippi and Montana counted dependents' income -on the head's column before taxing each column separately, Montana's joint -itemized deductions counted only the head's mortgage and investment interest, -and Oklahoma's property tax credit counted only the head's disability. -Dependents' income now goes on the column of the spouse with the greater -income of their own, and an exact tie splits it equally, as parents filing -separately report a child's income on the return of the parent with the -greater taxable income (26 U.S.C. 1(g)(5)(B); IRS Form 8814 instructions). +On main these depended on the label: +- Arkansas, Delaware, Iowa, Mississippi and Montana put the dependents' income + the model counts on the head's column before taxing each column separately. + It now goes on the column of the spouse with the greater income of their + own, and an exact tie splits it (move_dependent_amounts_to_filer). +- Delaware's combined separate return sent the EITC, the dependent care + credit and the dependent personal credits to the head's column on ties. +- Montana's joint itemized deductions counted only the head's mortgage and + investment interest. +- Minnesota's child and dependent care credit and Montana's dependent care + deduction accepted an incapacitated spouse but not an incapacitated head. +- Missouri's property tax credit tested the survivor pathway, and Oklahoma's + the disability pathway, on the head only. Hypothesis draws batches of married couples, with and without dependents -(who may have income), in the six states above for 2021-2026. A seeded +(who may have income), in the eight states above for 2021-2026. A seeded population of those states, with crafted edge cases (offsetting incomes, -spouses with equal income, one disabled or elderly spouse, interest paid by -one spouse), adds breadth in every year, and three seeded couples in every -state guard current law (2026) everywhere else. Each batch runs as one -vectorized simulation that holds every couple twice, as drawn and with the -head and spouse labels exchanged. For each couple: +spouses with equal income, one disabled, incapacitated, elderly or surviving +spouse, interest paid by one spouse), adds breadth in every year, and three +seeded couples in every state guard current law (2026) everywhere else. Each +batch runs as one vectorized simulation that holds every couple twice, as +drawn and with the head and spouse labels exchanged. For each couple: 1. Swap invariance: state income tax, before and after refundable credits, - is the same to the cent. + is finite and the same to the cent. 2. The per-person columns (`ar_agi_indiv`, `de_agi_indiv`, `ia_net_income`, - `ms_agi`, `mt_agi_indiv`) are the same for each person. + `ms_agi`, `mt_agi_indiv`) are the same for each person, to the cent. 3. Differential: in Arkansas, Delaware, Iowa and Mississippi those columns equal an independent numpy allocation of each person's own amount, which - conserves the tax unit's total. + conserves the tax unit's total. Montana's own amounts are not + reconstructed, so it is checked by 1 and 2 only. """ import numpy as np @@ -44,8 +50,11 @@ from policyengine_us.model_api import STATES TOLERANCE = 0.01 # dollars: the same to the cent +# The differential compares float32 model values with float64 numpy sums of +# them, so it also allows float32 rounding. +FLOAT32_RTOL = 1e-6 YEARS = [2021, 2022, 2023, 2024, 2025, 2026] -AFFECTED = ["AR", "DE", "IA", "MS", "MT", "OK"] +AFFECTED = ["AR", "DE", "IA", "MN", "MO", "MS", "MT", "OK"] ALL_STATES = [s for s in STATES if s not in ("PR", "VI")] TAX_UNIT_OUTPUTS = [ "state_income_tax", @@ -86,6 +95,10 @@ def adults(draw): "investment_interest_expense": draw(money(5_000)), "charitable_cash_donations": draw(money(8_000)), "other_medical_expenses": draw(money(15_000)), + "is_incapable_of_self_care": draw(st.booleans()), + "pre_subsidy_care_expenses": draw(money(6_000)), + "social_security_survivors": draw(money(20_000)), + "is_fully_disabled_service_connected_veteran": draw(st.booleans()), } @@ -101,6 +114,10 @@ def dependents(draw): "qualified_dividend_income": draw(money(3_000)), "long_term_capital_gains": draw(gain_or_loss(-2_000, 5_000)), "pre_subsidy_childcare_expenses": draw(money(10_000)), + # The model counts these on the filers' return in Delaware and + # Montana; no formula computes them. + "de_additions": draw(money(3_000)), + "mt_additions": draw(money(3_000)), } @@ -146,6 +163,10 @@ def signed(low, high, p): "investment_interest_expense": some(5_000, 0.08), "charitable_cash_donations": some(8_000, 0.3), "other_medical_expenses": some(15_000, 0.2), + "is_incapable_of_self_care": bool(rng.random() < 0.05), + "pre_subsidy_care_expenses": some(6_000, 0.05), + "social_security_survivors": some(20_000, 0.05 if age >= 55 else 0.0), + "is_fully_disabled_service_connected_veteran": bool(rng.random() < 0.03), } @@ -163,6 +184,8 @@ def some(high, p): "qualified_dividend_income": some(3_000, 0.15), "long_term_capital_gains": some(5_000, 0.08), "pre_subsidy_childcare_expenses": some(10_000, 0.5 if age < 13 else 0.0), + "de_additions": some(3_000, 0.1), + "mt_additions": some(3_000, 0.1), } @@ -258,6 +281,43 @@ def adult(age=45, **amounts): ], [], ), + # One incapacitated spouse with care expenses; the other earns and + # pays property tax (review of #9981). + ( + [ + adult(employment_income=20_000.0, real_estate_taxes=12_000.0), + adult( + is_incapable_of_self_care=True, pre_subsidy_care_expenses=4_000.0 + ), + ], + [], + ), + # Equal incomes, different ages and a child without income: credit + # routing on ties (review of #9981). + ( + [ + adult(60, employment_income=20_000.0), + adult(45, employment_income=20_000.0), + ], + [{"age": 10}], + ), + # A surviving spouse aged 60 with a low-income spouse who pays + # property tax (review of #9981). + ( + [ + adult(60, employment_income=6_000.0, real_estate_taxes=900.0), + adult(60, social_security_survivors=6_000.0), + ], + [], + ), + # One spouse a 100% disabled veteran, low income, rent. + ( + [ + adult(55, employment_income=8_000.0, rent=7_200.0), + adult(55, is_fully_disabled_service_connected_veteran=True), + ], + [], + ), ] return [ {"state": state, "adults": adults, "dependents": deps} @@ -387,8 +447,9 @@ def _check(units, year): # 1. Swap invariance of each state's income tax. for name in TAX_UNIT_OUTPUTS: values = np.asarray(sim.calculate(name, year), dtype=float) + assert np.isfinite(values).all(), f"{name} in {year} is not finite" drawn, swapped = values[0::2], values[1::2] - differs = np.abs(swapped - drawn) > TOLERANCE + differs = ~(np.abs(swapped - drawn) <= TOLERANCE) assert not differs.any(), ( f"{name} in {year} changes when the head and spouse labels are " f"exchanged: " @@ -409,11 +470,14 @@ def _check(units, year): first_copy = unit % 2 == 0 for column in COLUMNS: values = np.asarray(sim.calculate(column, year), dtype=float) + assert np.isfinite(values).all(), f"{column} in {year} is not finite" # 2. Each person's column is the same under either labelling. np.testing.assert_allclose( values[~first_copy], values[first_copy], + rtol=0, atol=TOLERANCE, + equal_nan=False, err_msg=f"{column} in {year}", ) if column == "mt_agi_indiv": @@ -426,7 +490,9 @@ def _check(units, year): np.testing.assert_allclose( values[in_state], reference[in_state], + rtol=FLOAT32_RTOL, atol=TOLERANCE, + equal_nan=False, err_msg=f"{column} in {year}", ) @@ -452,5 +518,5 @@ def test_seeded_population(year): def test_every_state_under_current_law(): # Simulating every state's earlier years makes this the costliest batch, - # so the guard outside the five states runs for one year. + # so the guard outside the eight states runs for one year. _check(_seeded_couples(ALL_STATES, per_state=3), 2026) diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml new file mode 100644 index 00000000000..e79154299cf --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml @@ -0,0 +1,86 @@ +- name: Equal taxable incomes route the dependent credit and the EITC to minimise tax + period: 2023 + absolute_error_margin: 0.01 + input: + people: + older: + age: 60 + employment_income: 20_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + younger: + age: 45 + employment_income: 20_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + child: + age: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [older, younger, child] + marital_units: + couple: + members: [older, younger] + child_unit: + members: [child] + households: + household: + members: [older, younger, child] + state_code: DE + output: + # Equal Line 23 taxable incomes, so either column can take the EITC. + # The dependent's $110 goes to the older spouse's column, which also has + # the aged credit, and the EITC reduces the other column: + # 255 + (475 - 419.32) = 310.68. Giving the dependent credit to the + # younger spouse would leave 365 in each column and a total of 365. + de_taxable_income_indv: [16_750, 16_750, 0] + de_personal_credit_indv: [220, 110, 0] + de_income_tax_after_non_refundable_credits_indv: [255, 475, 0] + de_non_refundable_eitc: 419.32 + de_income_tax_before_refundable_credits_separate: 310.68 + +- name: The result is the same when the head and spouse labels are exchanged + period: 2023 + absolute_error_margin: 0.01 + input: + people: + older: + age: 60 + employment_income: 20_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + younger: + age: 45 + employment_income: 20_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + child: + age: 10 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + tax_units: + tax_unit: + members: [older, younger, child] + marital_units: + couple: + members: [older, younger] + child_unit: + members: [child] + households: + household: + members: [older, younger, child] + state_code: DE + output: + de_taxable_income_indv: [16_750, 16_750, 0] + de_personal_credit_indv: [220, 110, 0] + de_income_tax_after_non_refundable_credits_indv: [255, 475, 0] + de_non_refundable_eitc: 419.32 + de_income_tax_before_refundable_credits_separate: 310.68 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.yaml index 29e734c53c9..ebd9e738165 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.yaml @@ -49,3 +49,73 @@ state_code: MN output: mn_cdcc_dependent_count: 0 + +- name: An incapacitated head counts as the qualifying spouse on a joint return + absolute_error_margin: 0.01 + period: 2026 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + age: 45 + is_incapable_of_self_care: true + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + age: 45 + employment_income: 20_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MN + output: + mn_cdcc_dependent_count: 1 + +- name: Two incapacitated spouses count once + absolute_error_margin: 0.01 + period: 2026 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + age: 45 + is_incapable_of_self_care: true + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + age: 45 + is_incapable_of_self_care: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MN + output: + mn_cdcc_dependent_count: 1 + +- name: A single filer is not their own qualifying individual + absolute_error_margin: 0.01 + period: 2026 + input: + people: + person1: + is_tax_unit_head: true + age: 45 + is_incapable_of_self_care: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MN + output: + mn_cdcc_dependent_count: 0 + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.yaml index 93cb6458c08..a27b6c90b5f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.yaml @@ -92,7 +92,7 @@ output: mo_ptc_taxunit_eligible: true -- name: Survivor pathway is claimant-only, a qualifying spouse does not count +- name: A spouse aged 60 or older with their own survivor benefits can be the claimant period: 2022 absolute_error_margin: 0 input: @@ -110,6 +110,29 @@ members: [person1, person2] state_code: MO output: + # A married couple files one claim and chooses who is listed first as + # the claimant (RSMo 135.010(1): "a person or persons"). + mo_ptc_taxunit_eligible: true + +- name: Survivor pathway tests age and benefits on the same person + period: 2022 + absolute_error_margin: 0 + input: + people: + person1: + is_tax_unit_head: true + age: 62 + rent: 3_600 + person2: + is_tax_unit_spouse: true + age: 58 + social_security_survivors: 6_000 + households: + household: + members: [person1, person2] + state_code: MO + output: + # Neither spouse is both 60 or older and receiving survivor benefits. mo_ptc_taxunit_eligible: false - name: A child's survivor benefits do not qualify an aged head diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.yaml index b7ccaccc8ba..6f83ee1c21e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.yaml @@ -107,3 +107,47 @@ state_code: MT output: mt_child_dependent_care_expense_deduction_qualifying_individual: [false, true] + +- name: An incapacitated head is the other spouse's qualifying individual + period: 2023 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + age: 45 + is_incapable_of_self_care: true + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + age: 45 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MT + output: + mt_child_dependent_care_expense_deduction_qualifying_individual: [true, false] + +- name: An incapacitated single filer is not their own qualifying individual + period: 2023 + input: + people: + person1: + is_tax_unit_head: true + age: 45 + is_incapable_of_self_care: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: MT + output: + mt_child_dependent_care_expense_deduction_qualifying_individual: [false] + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml index cafa4cb709d..b811c2a840e 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.yaml @@ -130,3 +130,35 @@ mt_federal_income_tax_deduction_for_federal_itemization: 0 output: mt_itemized_deductions_for_federal_itemization_joint: [0, 10_500] + +- name: A dependent's own mortgage interest is not deducted on the parents' joint return + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + mortgage_interest: 50_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2, person3] + charitable_deduction: 0 + mt_federal_income_tax_deduction_for_federal_itemization: 0 + output: + mt_itemized_deductions_for_federal_itemization_joint: [4_000, 0, 0] + diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml index c01f7fb59f2..6193c7dee3f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/deductions/itemized/mt_itemized_deductions_joint.yaml @@ -130,3 +130,35 @@ mt_federal_income_tax_deduction_unit: 0 output: mt_itemized_deductions_joint: [0, 10_500] + +- name: A dependent's own mortgage interest is not deducted on the parents' joint return + period: 2021 + absolute_error_margin: 1 + input: + people: + person1: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + mortgage_interest: 4_000 + person2: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + person3: + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + mortgage_interest: 50_000 + households: + household: + members: [person1, person2, person3] + state_code: MT + tax_units: + tax_unit: + members: [person1, person2, person3] + charitable_deduction: 0 + mt_federal_income_tax_deduction_unit: 0 + output: + mt_itemized_deductions_joint: [4_000, 0, 0] + diff --git a/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py b/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py index 55f7000d1ea..17a3135e45d 100644 --- a/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py +++ b/policyengine_us/variables/gov/states/ar/tax/income/ar_agi_indiv.py @@ -11,7 +11,9 @@ class ar_agi_indiv(Variable): "https://www.dfa.arkansas.gov/wp-content/uploads/2022_AR1000F_and_AR1000NR_Instructions.pdf#page=14", # Filing Status 4: the primary's income in column A, the spouse's in B "https://www.dfa.arkansas.gov/wp-content/uploads/2025_AR1000F_and_AR1000NR_Instructions.pdf#page=12", - # Parents filing separately: the parent with the greater taxable income + # Borrowed for dependents' income: parents filing separately report a + # child's income on the return of the parent with the greater + # taxable income "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", "https://www.irs.gov/instructions/i8814", ) @@ -21,6 +23,7 @@ def formula(person, period, parameters): gross_income = person("ar_gross_income_indiv", period) income_exemptions = person("ar_exemptions", period) net_income = max_(gross_income - income_exemptions, 0) - # Each spouse's column holds their own income. Dependents' income goes - # on the column of the spouse with the greater income. + # Each spouse's column holds their own income. The dependents' income + # the model counts here goes on the column of the spouse with the + # greater income (a modelling convention; see the helper). return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py b/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py index 8e6293187a0..c1e78c9fbf3 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py @@ -15,15 +15,25 @@ def formula(person, period, parameters): # the tax imposed on the spouse with the lower taxable # income reported on Line 23." Locked to the lower-income # spouse's column under combined separate filing. - # Tie-break: when taxable incomes are equal, head is treated - # as the higher-income spouse, so CDCC routes to spouse. + # With equal taxable incomes either spouse has the lower income, so + # the credit goes to the column with more tax left after that + # spouse's aged credit (each column's own $110 personal credit is the + # same), whichever spouse is labelled head. is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) person_taxable = person("de_taxable_income_indv", period) head_taxable = person.tax_unit.sum(is_head * person_taxable) spouse_taxable = person.tax_unit.sum(is_spouse * person_taxable) - head_higher = head_taxable >= spouse_taxable - is_lower_income = (is_head & ~head_higher) | (is_spouse & head_higher) + room = person( + "de_income_tax_before_non_refundable_credits_indv", period + ) - person("de_aged_personal_credit_indv", period) + head_room = person.tax_unit.sum(is_head * room) + spouse_room = person.tax_unit.sum(is_spouse * room) + head_takes = (head_taxable < spouse_taxable) | ( + (head_taxable == spouse_taxable) & (head_room > spouse_room) + ) + + is_lower_income = (is_head & head_takes) | (is_spouse & ~head_takes) return is_lower_income * person.tax_unit("de_cdcc", period) diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py b/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py index 6722f22b3c2..3fadbe2062e 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py @@ -18,7 +18,8 @@ def formula(person, period, parameters): # own column, and only the DEPENDENT credits may be split between # columns. We allocate the dependent increments to maximise total # credits used (i.e. minimise tax); the taxpayer may choose any - # dependent split, so the optimal one is the tax-minimising one. + # dependent split, so the optimal one is the tax-minimising one, + # including the EITC applied afterwards (Line 34). p = parameters(period).gov.states.de.tax.income.credits is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) @@ -43,26 +44,42 @@ def formula(person, period, parameters): # Dependent increments: exemptions beyond the two spouses. dep_units = max_(total_units - 2, 0) - # Optimal dependent split: try proportional floor and ceil, pick - # whichever maximises effective credits (min of alloc vs - # remaining capacity in each column). - total_capacity = head_capacity + spouse_capacity - ratio = where(total_capacity > 0, head_capacity / total_capacity, 0) + # PIT-RES Line 34 applies the non-refundable EITC only to the column + # of the spouse with the higher Line 23 taxable income; with equal + # incomes either column qualifies. + taxable = person("de_taxable_income_indv", period) + head_taxable = person.tax_unit.sum(is_head * taxable) + spouse_taxable = person.tax_unit.sum(is_spouse * taxable) - n_low = np.floor(dep_units * ratio) - n_high = n_low + 1 - n_low = max_(min_(n_low, dep_units), 0) - n_high = max_(min_(n_high, dep_units), 0) + # Try every split of the dependent increments. Keep the one that uses + # the most credits, then the one that leaves the most tax in the + # column the EITC can reduce. Both criteria treat the spouses alike, + # so the result does not depend on which one is labelled head, and + # together they minimise the column taxes after the EITC. + best_n = np.zeros_like(head_capacity) + best_used = np.full_like(head_capacity, -np.inf) + best_eitc_room = np.full_like(head_capacity, -np.inf) + for n in range(int(dep_units.max(initial=0)) + 1): + valid = n <= dep_units + head_used = min_(n * credit_per, head_capacity) + spouse_used = min_((dep_units - n) * credit_per, spouse_capacity) + used = head_used + spouse_used + head_room = head_capacity - head_used + spouse_room = spouse_capacity - spouse_used + eitc_room = select( + [head_taxable > spouse_taxable, head_taxable < spouse_taxable], + [head_room, spouse_room], + max_(head_room, spouse_room), + ) + better = valid & ( + (used > best_used) + | ((used == best_used) & (eitc_room > best_eitc_room)) + ) + best_n = where(better, n, best_n) + best_used = where(better, used, best_used) + best_eitc_room = where(better, eitc_room, best_eitc_room) - eff_low = min_(n_low * credit_per, head_capacity) + min_( - (dep_units - n_low) * credit_per, spouse_capacity - ) - eff_high = min_(n_high * credit_per, head_capacity) + min_( - (dep_units - n_high) * credit_per, spouse_capacity - ) - - n_head_dep = where(eff_high > eff_low, n_high, n_low) - head_alloc = credit_per + n_head_dep * credit_per - spouse_alloc = credit_per + (dep_units - n_head_dep) * credit_per + head_alloc = credit_per + best_n * credit_per + spouse_alloc = credit_per + (dep_units - best_n) * credit_per return is_head_or_spouse * where(is_head, head_alloc, spouse_alloc) diff --git a/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py b/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py index d6d4a216528..0722df0ce53 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/de_agi_indiv.py @@ -10,7 +10,9 @@ class de_agi_indiv(Variable): reference = ( # Filing Status 3 or 4: each spouse reports their own income "https://revenuefiles.delaware.gov/2025/PITForms_Instructions/Instructions/PIT-RES_Instructions_2025-01.pdf#page=5", - # Parents filing separately: the parent with the greater taxable income + # Borrowed for dependents' income: parents filing separately report a + # child's income on the return of the parent with the greater + # taxable income "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", "https://www.irs.gov/instructions/i8814", ) @@ -23,6 +25,7 @@ def formula(person, period, parameters): ) net_income = max_(pre_exclusions_agi - indv_exclusions, 0) # On a combined separate return each spouse reports their own income. - # Dependents' income goes on the return of the spouse with the - # greater income. + # The dependents' income the model counts here goes on the return of + # the spouse with the greater income (a modelling convention; see the + # helper). return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py b/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py index 4b611021fdb..97a41a01b95 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py +++ b/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py @@ -9,6 +9,8 @@ class de_income_tax_before_refundable_credits_separate(Variable): ) unit = USD definition_period = YEAR + # Line 34: the EITC only reduces the tax of the spouse with the higher + # Line 23 taxable income. reference = "https://revenuefiles.delaware.gov/2025/PITForms_Instructions/Instructions/PIT-RES_Instructions_2025-01.pdf#page=10" defined_for = StateCode.DE @@ -25,12 +27,17 @@ def formula(tax_unit, period, parameters): head_line33 = tax_unit.sum(is_head * line33) spouse_line33 = tax_unit.sum(is_spouse * line33) - # Tie-break: when taxable incomes are equal, head is treated as the - # higher-income spouse, so EITC routes to head. + # The EITC "may only be applied against the tax imposed on the spouse + # with the higher taxable income reported on Line 23". With equal + # taxable incomes either spouse has the higher income, so the EITC + # goes to the column with more tax to reduce, whichever spouse is + # labelled head. person_taxable = members("de_taxable_income_indv", period) head_taxable = tax_unit.sum(is_head * person_taxable) spouse_taxable = tax_unit.sum(is_spouse * person_taxable) - head_higher = head_taxable >= spouse_taxable + head_higher = (head_taxable > spouse_taxable) | ( + (head_taxable == spouse_taxable) & (head_line33 >= spouse_line33) + ) eitc = tax_unit("de_non_refundable_eitc", period) head_eitc = where(head_higher, min_(eitc, head_line33), 0) diff --git a/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py b/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py index 184cf6f0816..92215df662b 100644 --- a/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py +++ b/policyengine_us/variables/gov/states/ia/tax/income/net_income/ia_net_income.py @@ -14,7 +14,9 @@ class ia_net_income(Variable): "https://revenue.iowa.gov/media/2721/download?inline", # Status 3: married filing separately on a combined return "https://revenue.iowa.gov/media/2650/download?inline#page=7", - # Parents filing separately: the parent with the greater taxable income + # Borrowed for dependents' income: parents filing separately report a + # child's income on the return of the parent with the greater + # taxable income "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", "https://www.irs.gov/instructions/i8814", ) @@ -24,6 +26,7 @@ def formula(person, period, parameters): gross_income = person("ia_gross_income", period) income_adjustments = person("ia_income_adjustments", period) net_income = gross_income - income_adjustments - # Each spouse's column holds their own income. Dependents' net income - # goes on the column of the spouse with the greater net income. + # Each spouse's column holds their own income. The dependents' net + # income the model counts here goes on the column of the spouse with + # the greater net income (a modelling convention; see the helper). return move_dependent_amounts_to_filer(person, period, net_income) diff --git a/policyengine_us/variables/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.py b/policyengine_us/variables/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.py index d24b60e56ec..0478f785e18 100644 --- a/policyengine_us/variables/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.py +++ b/policyengine_us/variables/gov/states/mn/tax/income/credits/cdcc/mn_cdcc_dependent_count.py @@ -10,6 +10,8 @@ class mn_cdcc_dependent_count(Variable): reference = ( "https://www.revenue.state.mn.us/sites/default/files/2023-02/m1cd_21.pdf", "https://www.revenue.state.mn.us/sites/default/files/2023-01/m1cd_22_0.pdf", + "https://www.revisor.mn.gov/statutes/cite/290.067", + "https://www.law.cornell.edu/uscode/text/26/21#b_1", ) defined_for = "mn_cdcc_eligible" @@ -20,8 +22,17 @@ def formula(tax_unit, period, parameters): # ... children age = person("age", period) qualifies_by_age = age < p.child_age - # ... disability - non_head = ~person("is_tax_unit_head", period) + # ... disability: a dependent, or the taxpayer's spouse, who cannot + # care for themselves (IRC 21(b)(1)(B)-(C)). On a joint return either + # spouse can be the other's qualifying spouse, and, as in the federal + # count_cdcc_eligible, the couple counts at most one such spouse. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) disabled = person("is_incapable_of_self_care", period) - qualifies_by_disability = non_head & disabled - return tax_unit.sum(qualifies_by_age | qualifies_by_disability) + qualifies_by_disability = ~head_or_spouse & disabled + has_spouse = tax_unit.any(person("is_tax_unit_spouse", period)) + qualifying_spouse = has_spouse & tax_unit.any( + head_or_spouse & disabled & ~qualifies_by_age + ) + return tax_unit.sum(qualifies_by_age | qualifies_by_disability) + ( + qualifying_spouse + ) diff --git a/policyengine_us/variables/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.py b/policyengine_us/variables/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.py index 6a09542d4c1..0277af16479 100644 --- a/policyengine_us/variables/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.py +++ b/policyengine_us/variables/gov/states/mo/tax/income/credits/property_tax/mo_ptc_taxunit_eligible.py @@ -30,16 +30,16 @@ def formula(tax_unit, period, parameters): ) # The surviving-spouse pathway applies to the claimant only: the # claimant must be 60 or older and have received surviving spouse - # Social Security benefits themselves. The model treats the tax - # unit head as the filer/claimant, so the same-person test runs on - # the head; the other three pathways are claimant-or-spouse. + # Social Security benefits themselves. RSMo 135.010(1) defines the + # claimant as "a person or persons", and a married couple claims + # jointly, so either spouse who meets both tests can be the + # claimant, whichever is listed first. Age and benefits are tested + # on the same person. person = tax_unit.members - head_survivor_benefits = tax_unit.sum( - person("social_security_survivors", period) - * person("is_tax_unit_head", period) - ) - aged_survivor = (age_head >= p.aged_survivor_min_age) & ( - head_survivor_benefits > 0 + aged_survivor = tax_unit.any( + person("is_tax_unit_head_or_spouse", period) + & (person("age", period) >= p.aged_survivor_min_age) + & (person("social_security_survivors", period) > 0) ) categorical = elderly | disabled | military_disabled | aged_survivor # RSMo 135.025 bases the credit on property taxes accrued and rent diff --git a/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py b/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py index 28675016569..8c0c6ff2188 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/ms_agi.py @@ -12,7 +12,9 @@ class ms_agi(Variable): "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80105228.pdf", # Line 66 # Combined return: one spouse's income in Column A, the other's in B "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100221.pdf#page=5", - # Parents filing separately: the parent with the greater taxable income + # Borrowed for dependents' income: parents filing separately report a + # child's income on the return of the parent with the greater + # taxable income "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", "https://www.irs.gov/instructions/i8814", ) @@ -25,8 +27,10 @@ def formula(person, period, parameters): # A spouse's column can be negative on a joint or combined return; # the tax computation combines it with the other column. net_income = gross_income - adjustments - # Each spouse's column holds their own income. Dependents' positive - # income goes on the column of the spouse with the greater income. + # Each spouse's column holds their own income. The dependents' + # positive income the model counts here goes on the column of the + # spouse with the greater income (a modelling convention; see the + # helper). is_dependent = person("is_tax_unit_dependent", period) return move_dependent_amounts_to_filer( person, period, where(is_dependent, max_(net_income, 0), net_income) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py index 14811f1a6fa..381629fad52 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_agi_indiv.py @@ -8,9 +8,11 @@ class mt_agi_indiv(Variable): unit = USD definition_period = YEAR reference = ( - # Filing separately: report items of income as on the federal return + # Filing separately: each column is its own return "https://revenuefiles.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=12", - # Parents filing separately: the parent with the greater taxable income + # Borrowed for dependents' income: parents filing separately report a + # child's income on the return of the parent with the greater + # taxable income "https://www.law.cornell.edu/uscode/text/26/1#g_5_B", "https://www.irs.gov/instructions/i8814", ) @@ -35,7 +37,8 @@ def formula(person, period, parameters): # 2024 and after: no longer apply the social security adjustment tax_unit_mt_agi = reduced_agi - # In all years, dependents' income goes on the return of the spouse - # with the greater income: filing separately, each spouse reports - # income as on a separate federal return. + # The dependents' income the model counts here goes on the return of + # the spouse with the greater income (a modelling convention; see the + # helper). The instructions' Line 5 exclusion of a child's income + # reported on federal Form 8814 is not modelled. return move_dependent_amounts_to_filer(person, period, tax_unit_mt_agi) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.py index b7b5cf4bb3a..3ce9c430a8c 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/cdcc/mt_child_dependent_care_expense_deduction_qualifying_individual.py @@ -20,12 +20,16 @@ def formula(person, period, parameters): ).gov.states.mt.tax.income.deductions.child_dependent_care_expense age = person("age", period) dependent = person("is_tax_unit_dependent", period) - spouse = person("is_tax_unit_spouse", period) eligible_child = dependent & (age < p.age_limit) # MCA 15-30-2131(1)(c)(i)(B)-(C) waives the age limit for a # dependent unable to provide self-care because of physical or # mental illness and includes a spouse who is unable to provide - # self-care. + # self-care. Either married filer is the other's spouse, whichever + # is listed first. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + married = person.tax_unit.any(person("is_tax_unit_spouse", period)) incapable_of_self_care = person("is_incapable_of_self_care", period) - eligible_adult = (dependent | spouse) & incapable_of_self_care + eligible_adult = (dependent | (head_or_spouse & married)) & ( + incapable_of_self_care + ) return eligible_child | eligible_adult diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py index 2f14478834b..f4bf48cc8e0 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/federal_itemization/mt_itemized_deductions_for_federal_itemization_joint.py @@ -16,11 +16,12 @@ class mt_itemized_deductions_for_federal_itemization_joint(Variable): def formula(person, period, parameters): charitable_deduction = person.tax_unit("charitable_deduction", period) - # A joint return deducts interest either spouse paid. - interest_ded = add( - person.tax_unit, - period, - ["investment_interest_expense", "mortgage_interest"], + # A joint return deducts the interest either spouse paid, not a + # dependent's own. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + interest_ded = person.tax_unit.sum( + head_or_spouse + * add(person, period, ["investment_interest_expense", "mortgage_interest"]) ) other_deductions = add( person.tax_unit, diff --git a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py index 7bc86a75657..ad89bc1bc85 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/deductions/itemized/general/mt_itemized_deductions_joint.py @@ -16,11 +16,12 @@ class mt_itemized_deductions_joint(Variable): def formula(person, period, parameters): charitable_deduction = person.tax_unit("charitable_deduction", period) - # A joint return deducts interest either spouse paid. - interest_ded = add( - person.tax_unit, - period, - ["investment_interest_expense", "mortgage_interest"], + # A joint return deducts the interest either spouse paid, not a + # dependent's own. + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + interest_ded = person.tax_unit.sum( + head_or_spouse + * add(person, period, ["investment_interest_expense", "mortgage_interest"]) ) other_deductions = add( person.tax_unit, From f1ecd5f0782e63afa061ef1a9273a59daba526ef Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 09:34:36 -0400 Subject: [PATCH 3/4] Address review round 2: Delaware joint credit choice and Oregon WFHDC - Delaware: the dependent personal-credit split, and on equal Line 23 incomes the dependent care credit and EITC columns, are now chosen together to minimise the columns' tax after the EITC (de_combined_separate_credit_choices). Maximising credits used first, or sending the dependent care credit to the roomier column, could leave more tax than main's labelling did. - Oregon: either married filer with a disability is the other's qualifying spouse for the working family household and dependent care credit, and either filer's earnings, or a disabled spouse's imputed earnings, meet the employment test ("you" also means your spouse on a joint return). - The property test covers Oregon and a disabled, incapacitated spouse, and states that couples with a spouse claimed elsewhere are out of scope. Co-Authored-By: Claude Opus 5.5 --- .../fix-state-head-spouse-swap.fixed.md | 2 +- ...t_state_tax_head_spouse_swap_invariance.py | 31 +++- ...ax_before_refundable_credits_separate.yaml | 136 ++++++++++++++++++ .../wfhdc/or_wfhdc_employment_eligible.yaml | 47 ++++++ ...hdc_has_qualified_individual_eligible.yaml | 24 +++- .../tax/income/credits/cdcc/de_cdcc_indv.py | 29 ++-- .../personal/de_personal_credit_indv.py | 76 ++-------- .../income/de_combined_separate_credits.py | 86 +++++++++++ ..._tax_before_refundable_credits_separate.py | 16 +-- .../wfhdc/or_wfhdc_employment_eligible.py | 16 ++- ...wfhdc_has_qualified_individual_eligible.py | 14 +- 11 files changed, 371 insertions(+), 106 deletions(-) create mode 100644 policyengine_us/variables/gov/states/de/tax/income/de_combined_separate_credits.py diff --git a/changelog.d/fix-state-head-spouse-swap.fixed.md b/changelog.d/fix-state-head-spouse-swap.fixed.md index cee1d8a77b4..e8731a26dc2 100644 --- a/changelog.d/fix-state-head-spouse-swap.fixed.md +++ b/changelog.d/fix-state-head-spouse-swap.fixed.md @@ -1 +1 @@ -Make state income taxes independent of which spouse is labelled head: dependents' income on Arkansas, Delaware, Iowa, Mississippi and Montana separate-column returns goes to the spouse with the greater income (an exact tie splits it); Delaware's EITC, dependent care credit and dependent personal credits no longer go to the head on ties; Montana's joint itemized deductions count both spouses' mortgage and investment interest; and an incapacitated head now qualifies for Minnesota's dependent care credit and Montana's dependent care deduction, a surviving-spouse claimant for Missouri's property tax credit, and a totally disabled spouse for Oklahoma's property tax credit. +Make state income taxes independent of which spouse is labelled head: dependents' income on Arkansas, Delaware, Iowa, Mississippi and Montana separate-column returns goes to the spouse with the greater income (an exact tie splits it); Delaware chooses the dependent credit split and, on equal incomes, the EITC and dependent care credit columns together to minimise tax; Montana's joint itemized deductions count both spouses' mortgage and investment interest; and an incapacitated or disabled head now qualifies as the other spouse's qualifying individual for Minnesota's dependent care credit, Montana's dependent care deduction and Oregon's working family household and dependent care credit, a surviving-spouse claimant for Missouri's property tax credit, and a totally disabled spouse for Oklahoma's property tax credit. diff --git a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py index 7a9175706fa..f391cce535d 100644 --- a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py +++ b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py @@ -13,17 +13,25 @@ the model counts on the head's column before taxing each column separately. It now goes on the column of the spouse with the greater income of their own, and an exact tie splits it (move_dependent_amounts_to_filer). -- Delaware's combined separate return sent the EITC, the dependent care - credit and the dependent personal credits to the head's column on ties. +- Delaware's combined separate return routed the EITC and the dependent care + credit by label on equal incomes and split the dependent personal credits + with a heuristic. They are now chosen together to minimise tax. - Montana's joint itemized deductions counted only the head's mortgage and investment interest. - Minnesota's child and dependent care credit and Montana's dependent care deduction accepted an incapacitated spouse but not an incapacitated head. - Missouri's property tax credit tested the survivor pathway, and Oklahoma's the disability pathway, on the head only. +- Oregon's working family household and dependent care credit excluded a + disabled head as a qualifying spouse and counted only the head's earnings. + +Couples where a spouse is claimed as a dependent on another return are out +of scope here: the federal standard deduction and several state formulas +read only `head_is_dependent_elsewhere`, so this test leaves that input at +its default. Hypothesis draws batches of married couples, with and without dependents -(who may have income), in the eight states above for 2021-2026. A seeded +(who may have income), in the nine states above for 2021-2026. A seeded population of those states, with crafted edge cases (offsetting incomes, spouses with equal income, one disabled, incapacitated, elderly or surviving spouse, interest paid by one spouse), adds breadth in every year, and three @@ -54,7 +62,7 @@ # them, so it also allows float32 rounding. FLOAT32_RTOL = 1e-6 YEARS = [2021, 2022, 2023, 2024, 2025, 2026] -AFFECTED = ["AR", "DE", "IA", "MN", "MO", "MS", "MT", "OK"] +AFFECTED = ["AR", "DE", "IA", "MN", "MO", "MS", "MT", "OK", "OR"] ALL_STATES = [s for s in STATES if s not in ("PR", "VI")] TAX_UNIT_OUTPUTS = [ "state_income_tax", @@ -310,6 +318,19 @@ def adult(age=45, **amounts): ], [], ), + # One disabled spouse who cannot care for themselves, with care + # expenses; the other earns (review round 2 of #9981). + ( + [ + adult(employment_income=20_000.0), + adult( + is_disabled=True, + is_incapable_of_self_care=True, + pre_subsidy_care_expenses=4_000.0, + ), + ], + [], + ), # One spouse a 100% disabled veteran, low income, rent. ( [ @@ -518,5 +539,5 @@ def test_seeded_population(year): def test_every_state_under_current_law(): # Simulating every state's earlier years makes this the costliest batch, - # so the guard outside the eight states runs for one year. + # so the guard outside the nine states runs for one year. _check(_seeded_couples(ALL_STATES, per_state=3), 2026) diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml index e79154299cf..9f8fd0858cb 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.yaml @@ -84,3 +84,139 @@ de_income_tax_after_non_refundable_credits_indv: [255, 475, 0] de_non_refundable_eitc: 419.32 de_income_tax_before_refundable_credits_separate: 310.68 + +- name: Equal incomes, the dependent care credit and EITC columns are chosen together + period: 2023 + absolute_error_margin: 0.01 + input: + people: + younger: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 345 + de_aged_personal_credit_indv: 0 + older: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 345 + de_aged_personal_credit_indv: 110 + tax_units: + tax_unit: + members: [younger, older] + exemptions_count: 3 + de_cdcc: 13.5 + de_non_refundable_eitc: 250 + households: + household: + members: [younger, older] + state_code: DE + output: + # The dependent's $110 and the dependent care credit go to the older + # spouse's column (345 - 110 - 110 - 110 - 13.50 = 1.50) and the EITC + # clears the younger spouse's 345 - 110 = 235. Sending the dependent care + # credit to the column with more room instead leaves 15. + de_income_tax_before_refundable_credits_separate: 1.5 + +- name: Equal incomes, the same result when the older spouse is head + period: 2023 + absolute_error_margin: 0.01 + input: + people: + younger: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 345 + de_aged_personal_credit_indv: 0 + older: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 345 + de_aged_personal_credit_indv: 110 + tax_units: + tax_unit: + members: [younger, older] + exemptions_count: 3 + de_cdcc: 13.5 + de_non_refundable_eitc: 250 + households: + household: + members: [younger, older] + state_code: DE + output: + de_income_tax_before_refundable_credits_separate: 1.5 + +- name: Equal incomes, the dependent split minimises tax after the EITC + period: 2023 + absolute_error_margin: 0.01 + input: + people: + younger: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 300 + de_aged_personal_credit_indv: 0 + older: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 300 + de_aged_personal_credit_indv: 110 + tax_units: + tax_unit: + members: [younger, older] + exemptions_count: 3 + de_cdcc: 0 + de_non_refundable_eitc: 200 + households: + household: + members: [younger, older] + state_code: DE + output: + # The dependent's $110 goes to the older spouse's column, where the aged + # credit already uses most of the tax, so the EITC clears the younger + # spouse's 300 - 110 = 190. Maximising the personal credits used first + # would give the younger column the $110 and leave 80. + de_income_tax_before_refundable_credits_separate: 0 + +- name: Equal incomes, the dependent split minimises tax after the EITC when the older spouse is head + period: 2023 + absolute_error_margin: 0.01 + input: + people: + younger: + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 300 + de_aged_personal_credit_indv: 0 + older: + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + de_taxable_income_indv: 11_750 + de_income_tax_before_non_refundable_credits_indv: 300 + de_aged_personal_credit_indv: 110 + tax_units: + tax_unit: + members: [younger, older] + exemptions_count: 3 + de_cdcc: 0 + de_non_refundable_eitc: 200 + households: + household: + members: [younger, older] + state_code: DE + output: + de_income_tax_before_refundable_credits_separate: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.yaml index 5cd1cdbd1f5..ef7a0ad09f3 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.yaml @@ -159,3 +159,50 @@ state_code: OR output: or_wfhdc_employment_eligible: false + +- name: On a joint return the spouse's earnings count when the head has none + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_disabled: false + person2: + age: 45 + is_tax_unit_head: false + is_tax_unit_spouse: true + employment_income: 20_000 + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OR + output: + or_wfhdc_employment_eligible: true + +- name: On a joint return a disabled spouse has earned income imputed + period: 2025 + input: + people: + person1: + age: 45 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_disabled: true + person2: + age: 45 + is_tax_unit_head: false + is_tax_unit_spouse: true + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: OR + output: + or_wfhdc_employment_eligible: true diff --git a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.yaml index bb2ae99e241..4c05229de7b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.yaml @@ -64,7 +64,7 @@ output: or_wfhdc_has_qualified_individual_eligible: true -- name: Household with disabled head member is not eligible +- name: A disabled head is the other filer's qualifying spouse on a joint return period: 2021 input: people: @@ -72,10 +72,12 @@ age: 40 is_disabled: true is_tax_unit_head: true + is_tax_unit_spouse: false person2: age: 35 is_disabled: false is_tax_unit_head: false + is_tax_unit_spouse: true tax_units: tax_unit: members: [person1, person2] @@ -83,5 +85,25 @@ household: members: [person1, person2] state_code: OR + output: + # On a joint return "you" also means your spouse, so either spouse + # with a disability is the other's qualifying individual. + or_wfhdc_has_qualified_individual_eligible: true + +- name: A disabled single filer is not their own qualifying individual + period: 2021 + input: + people: + person1: + age: 40 + is_disabled: true + is_tax_unit_head: true + tax_units: + tax_unit: + members: [person1] + households: + household: + members: [person1] + state_code: OR output: or_wfhdc_has_qualified_individual_eligible: false diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py b/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py index c1e78c9fbf3..029dd901c80 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/cdcc/de_cdcc_indv.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.de.tax.income.de_combined_separate_credits import ( + de_combined_separate_credit_choices, +) class de_cdcc_indv(Variable): @@ -15,25 +18,13 @@ def formula(person, period, parameters): # the tax imposed on the spouse with the lower taxable # income reported on Line 23." Locked to the lower-income # spouse's column under combined separate filing. - # With equal taxable incomes either spouse has the lower income, so - # the credit goes to the column with more tax left after that - # spouse's aged credit (each column's own $110 personal credit is the - # same), whichever spouse is labelled head. + # With equal taxable incomes either spouse has the lower income; the + # column is then chosen with the other column credits to minimise tax + # (see de_combined_separate_credit_choices). is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) - - person_taxable = person("de_taxable_income_indv", period) - head_taxable = person.tax_unit.sum(is_head * person_taxable) - spouse_taxable = person.tax_unit.sum(is_spouse * person_taxable) - - room = person( - "de_income_tax_before_non_refundable_credits_indv", period - ) - person("de_aged_personal_credit_indv", period) - head_room = person.tax_unit.sum(is_head * room) - spouse_room = person.tax_unit.sum(is_spouse * room) - head_takes = (head_taxable < spouse_taxable) | ( - (head_taxable == spouse_taxable) & (head_room > spouse_room) + _, cdcc_head, _ = de_combined_separate_credit_choices( + person.tax_unit, period, parameters ) - - is_lower_income = (is_head & head_takes) | (is_spouse & ~head_takes) - return is_lower_income * person.tax_unit("de_cdcc", period) + takes = (is_head & cdcc_head) | (is_spouse & ~cdcc_head) + return takes * person.tax_unit("de_cdcc", period) diff --git a/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py b/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py index 3fadbe2062e..5ae155c8b8b 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py +++ b/policyengine_us/variables/gov/states/de/tax/income/credits/personal/de_personal_credit_indv.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.de.tax.income.de_combined_separate_credits import ( + de_combined_separate_credit_choices, +) class de_personal_credit_indv(Variable): @@ -16,70 +19,17 @@ def formula(person, period, parameters): # a married couple with no dependents enters "$110 in each column # if Filing Status 4" - each spouse's own credit belongs to their # own column, and only the DEPENDENT credits may be split between - # columns. We allocate the dependent increments to maximise total - # credits used (i.e. minimise tax); the taxpayer may choose any - # dependent split, so the optimal one is the tax-minimising one, - # including the EITC applied afterwards (Line 34). + # columns. The taxpayer may choose any dependent split; it is chosen + # together with the Line 31 and Line 34 columns to minimise tax (see + # de_combined_separate_credit_choices). p = parameters(period).gov.states.de.tax.income.credits + credit_per = p.personal_credits.personal is_head = person("is_tax_unit_head", period) is_spouse = person("is_tax_unit_spouse", period) - is_head_or_spouse = is_head | is_spouse - - credit_per = p.personal_credits.personal - person_tax = person("de_income_tax_before_non_refundable_credits_indv", period) - head_tax = person.tax_unit.sum(is_head * person_tax) - spouse_tax = person.tax_unit.sum(is_spouse * person_tax) - - fixed = person("de_aged_personal_credit_indv", period) + person( - "de_cdcc_indv", period + dep_units = max_(person.tax_unit("exemptions_count", period) - 2, 0) + head_dep, _, _ = de_combined_separate_credit_choices( + person.tax_unit, period, parameters ) - # Each column's own $110 personal credit is fixed to that column. - head_fixed = person.tax_unit.sum(is_head * fixed) + credit_per - spouse_fixed = person.tax_unit.sum(is_spouse * fixed) + credit_per - - head_capacity = max_(head_tax - head_fixed, 0) - spouse_capacity = max_(spouse_tax - spouse_fixed, 0) - - total_units = person.tax_unit("exemptions_count", period) - # Dependent increments: exemptions beyond the two spouses. - dep_units = max_(total_units - 2, 0) - - # PIT-RES Line 34 applies the non-refundable EITC only to the column - # of the spouse with the higher Line 23 taxable income; with equal - # incomes either column qualifies. - taxable = person("de_taxable_income_indv", period) - head_taxable = person.tax_unit.sum(is_head * taxable) - spouse_taxable = person.tax_unit.sum(is_spouse * taxable) - - # Try every split of the dependent increments. Keep the one that uses - # the most credits, then the one that leaves the most tax in the - # column the EITC can reduce. Both criteria treat the spouses alike, - # so the result does not depend on which one is labelled head, and - # together they minimise the column taxes after the EITC. - best_n = np.zeros_like(head_capacity) - best_used = np.full_like(head_capacity, -np.inf) - best_eitc_room = np.full_like(head_capacity, -np.inf) - for n in range(int(dep_units.max(initial=0)) + 1): - valid = n <= dep_units - head_used = min_(n * credit_per, head_capacity) - spouse_used = min_((dep_units - n) * credit_per, spouse_capacity) - used = head_used + spouse_used - head_room = head_capacity - head_used - spouse_room = spouse_capacity - spouse_used - eitc_room = select( - [head_taxable > spouse_taxable, head_taxable < spouse_taxable], - [head_room, spouse_room], - max_(head_room, spouse_room), - ) - better = valid & ( - (used > best_used) - | ((used == best_used) & (eitc_room > best_eitc_room)) - ) - best_n = where(better, n, best_n) - best_used = where(better, used, best_used) - best_eitc_room = where(better, eitc_room, best_eitc_room) - - head_alloc = credit_per + best_n * credit_per - spouse_alloc = credit_per + (dep_units - best_n) * credit_per - - return is_head_or_spouse * where(is_head, head_alloc, spouse_alloc) + head_alloc = credit_per + head_dep * credit_per + spouse_alloc = credit_per + (dep_units - head_dep) * credit_per + return where(is_head, head_alloc, where(is_spouse, spouse_alloc, 0)) diff --git a/policyengine_us/variables/gov/states/de/tax/income/de_combined_separate_credits.py b/policyengine_us/variables/gov/states/de/tax/income/de_combined_separate_credits.py new file mode 100644 index 00000000000..b1546eacfc6 --- /dev/null +++ b/policyengine_us/variables/gov/states/de/tax/income/de_combined_separate_credits.py @@ -0,0 +1,86 @@ +from policyengine_us.model_api import * + + +def de_combined_separate_credit_choices(tax_unit, period, parameters): + """The taxpayer's choices for column credits on a combined separate return. + + On a Delaware combined separate return (Filing Status 4) the couple + decides three things that move credits between the two columns: + - how to split the dependents' $110 personal credits (PIT-RES Line 27a: + "split the total between Columns A and B in increments of $110"); + - which column takes the dependent care credit, which "may only be + applied against the tax imposed on the spouse with the lower taxable + income reported on Line 23" (Line 31); + - which column takes the non-refundable EITC, which "may only be applied + against the tax imposed on the spouse with the higher taxable income + reported on Line 23" (Line 34). + With equal Line 23 incomes either column qualifies for Line 31 and Line 34. + The dependent split, and the Line 31 and Line 34 columns where both + qualify, are chosen together to minimise the two columns' tax after the + EITC. Each column keeps its own $110 personal credit and aged credit. + The search treats the spouses alike, so the minimum does not depend on + which spouse is labelled head. + + Returns tax-unit arrays: the number of dependent increments in the head's + column, whether the head's column takes the dependent care credit, and + whether the head's column takes the EITC. + """ + person = tax_unit.members + is_head = person("is_tax_unit_head", period) + is_spouse = person("is_tax_unit_spouse", period) + + def column(values): + return tax_unit.sum(is_head * values), tax_unit.sum(is_spouse * values) + + p = parameters(period).gov.states.de.tax.income.credits + credit_per = p.personal_credits.personal + head_tax, spouse_tax = column( + person("de_income_tax_before_non_refundable_credits_indv", period) + ) + head_aged, spouse_aged = column(person("de_aged_personal_credit_indv", period)) + head_taxable, spouse_taxable = column(person("de_taxable_income_indv", period)) + cdcc = tax_unit("de_cdcc", period) + eitc = tax_unit("de_non_refundable_eitc", period) + dep_units = max_(tax_unit("exemptions_count", period) - 2, 0) + + # Columns allowed for each credit; both on equal Line 23 incomes. + head_may_take_cdcc = head_taxable <= spouse_taxable + spouse_may_take_cdcc = spouse_taxable <= head_taxable + head_may_take_eitc = head_taxable >= spouse_taxable + spouse_may_take_eitc = spouse_taxable >= head_taxable + + # Among choices with equal tax the first found is kept. The search tries + # main's earlier tie routing first (dependent care credit to the spouse, + # EITC to the head). That only matters when the columns are identical. + best_tax = np.full_like(head_tax, np.inf) + best_n = np.zeros_like(head_tax) + best_cdcc_head = np.zeros_like(head_tax, dtype=bool) + best_eitc_head = np.zeros_like(head_tax, dtype=bool) + for n in range(int(dep_units.max(initial=0)) + 1): + for cdcc_head in (False, True): + for eitc_head in (True, False): + allowed = ( + (n <= dep_units) + & where(cdcc_head, head_may_take_cdcc, spouse_may_take_cdcc) + & where(eitc_head, head_may_take_eitc, spouse_may_take_eitc) + ) + head_credits = ( + credit_per + head_aged + n * credit_per + cdcc_head * cdcc + ) + spouse_credits = ( + credit_per + + spouse_aged + + (dep_units - n) * credit_per + + (not cdcc_head) * cdcc + ) + # Line 33: each column's tax after its credits, at least zero. + head_line33 = max_(head_tax - head_credits, 0) + spouse_line33 = max_(spouse_tax - spouse_credits, 0) + eitc_column = head_line33 if eitc_head else spouse_line33 + total = head_line33 + spouse_line33 - min_(eitc, eitc_column) + better = allowed & (total < best_tax - 1e-6) + best_tax = where(better, total, best_tax) + best_n = where(better, n, best_n) + best_cdcc_head = where(better, cdcc_head, best_cdcc_head) + best_eitc_head = where(better, eitc_head, best_eitc_head) + return best_n, best_cdcc_head, best_eitc_head diff --git a/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py b/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py index 97a41a01b95..e94a9e8c1b8 100644 --- a/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py +++ b/policyengine_us/variables/gov/states/de/tax/income/de_income_tax_before_refundable_credits_separate.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.states.de.tax.income.de_combined_separate_credits import ( + de_combined_separate_credit_choices, +) class de_income_tax_before_refundable_credits_separate(Variable): @@ -29,14 +32,11 @@ def formula(tax_unit, period, parameters): # The EITC "may only be applied against the tax imposed on the spouse # with the higher taxable income reported on Line 23". With equal - # taxable incomes either spouse has the higher income, so the EITC - # goes to the column with more tax to reduce, whichever spouse is - # labelled head. - person_taxable = members("de_taxable_income_indv", period) - head_taxable = tax_unit.sum(is_head * person_taxable) - spouse_taxable = tax_unit.sum(is_spouse * person_taxable) - head_higher = (head_taxable > spouse_taxable) | ( - (head_taxable == spouse_taxable) & (head_line33 >= spouse_line33) + # taxable incomes either column qualifies, and the column is chosen + # with the other column credits to minimise tax (see + # de_combined_separate_credit_choices). + _, _, head_higher = de_combined_separate_credit_choices( + tax_unit, period, parameters ) eitc = tax_unit("de_non_refundable_eitc", period) diff --git a/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.py b/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.py index c6149d80869..cee883dd4d5 100644 --- a/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.py +++ b/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_employment_eligible.py @@ -9,26 +9,32 @@ class or_wfhdc_employment_eligible(Variable): definition_period = YEAR reference = ( "https://www.oregon.gov/dor/forms/FormsPubs/schedule-or-wfhdc-inst_101-195-1_2022.pdf#pahe=1", + "https://www.oregon.gov/dor/forms/FormsPubs/schedule-or-wfhdc-inst_101-195-1_2025.pdf#page=1", "https://law.justia.com/codes/oregon/2021/volume-08/chapter-315/section-315-264/", ) defined_for = StateCode.OR def formula(tax_unit, period, parameters): person = tax_unit.members - # 1) you are working + # 1) you are working. On a joint return "you" also means your + # spouse, so either filer's earnings count. earned_income = person("earned_income", period) head = person("is_tax_unit_head", period) - earned_income_eligible = tax_unit.any(head * earned_income) > 0 + is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) + earned_income_eligible = tax_unit.any(is_head_or_spouse & (earned_income > 0)) # 2) you are single and you attended school (full-time or part-time) filing_status = tax_unit("filing_status", period) attend_school = tax_unit.any(head & person("is_in_k12_school", period)) single_student = ( filing_status == filing_status.possible_values.SINGLE ) & attend_school - # 3) you are married filing jointly and one spouse attended school (full-time) or was disabled - is_head_or_spouse = person("is_tax_unit_head_or_spouse", period) + # 3) you are married filing jointly and one spouse attended school + # (full-time) or was disabled, so has earned income imputed is_full_time_student = person("is_full_time_student", period) - married_eligible = tax_unit.any(is_full_time_student & is_head_or_spouse) + disabled = person("is_disabled", period) + married_eligible = tax_unit.any( + (is_full_time_student | disabled) & is_head_or_spouse + ) joint_head_or_spouse_student = ( filing_status == filing_status.possible_values.JOINT ) & married_eligible diff --git a/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.py b/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.py index 25cee2d8d8f..926da4b280e 100644 --- a/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.py +++ b/policyengine_us/variables/gov/states/or/tax/income/credits/wfhdc/or_wfhdc_has_qualified_individual_eligible.py @@ -9,6 +9,7 @@ class or_wfhdc_has_qualified_individual_eligible(Variable): definition_period = YEAR reference = ( "https://www.oregon.gov/dor/forms/FormsPubs/schedule-or-wfhdc-inst_101-195-1_2022.pdf#pahe=1", + "https://www.oregon.gov/dor/forms/FormsPubs/schedule-or-wfhdc-inst_101-195-1_2025.pdf#page=1", "https://law.justia.com/codes/oregon/2021/volume-08/chapter-315/section-315-264/", ) defined_for = StateCode.OR @@ -16,12 +17,17 @@ class or_wfhdc_has_qualified_individual_eligible(Variable): def formula(tax_unit, period, parameters): p = parameters(period).gov.states["or"].tax.income.credits.wfhdc - # Check if the household has a child or disabled member other than the household head. - # Note that a disabled spouse is a qualifying individual. + # Check if the household has a child, a disabled dependent, or a + # disabled spouse. On a joint return "you" also means your spouse, + # so either married filer is the other's qualifying spouse; a single + # filer is not their own qualifying individual. person = tax_unit.members age = person("age", period) disabled = person("is_disabled", period) - head = person("is_tax_unit_head", period) - qualifying_individuals = (age <= p.child_age_limit) | (disabled & ~head) + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + married = tax_unit.project(tax_unit.any(person("is_tax_unit_spouse", period))) + qualifying_individuals = (age <= p.child_age_limit) | ( + disabled & (~head_or_spouse | married) + ) return tax_unit.any(qualifying_individuals) From d4641289a04701c2fbe9ed6984bad20d70ee16e7 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 8 Oct 2026 10:44:43 -0400 Subject: [PATCH 4/4] Split Mississippi's unattributed tax-unit adjustments between the spouses The #9617 cross-check found one Mississippi pure head/spouse swap that still changed state tax: the microdata supply the health savings account deduction at the tax-unit level, and person_share_of_tax_unit_amount gave a deduction with no per-person amounts to the head's column. It now takes an opt-in split_between_spouses flag, which the Mississippi health savings account, self-employed health insurance and self-employed retirement adjustments use: the head and spouse each take half. Medicaid AGI keeps the head fallback. The property test now draws tax-unit deductions with no per-person split, and fails on the previous head for this case. Co-Authored-By: Claude Opus 5.5 --- .../fix-state-head-spouse-swap.fixed.md | 2 +- ...h_savings_account_adjustment_invariants.py | 17 +++-- ...t_state_tax_head_spouse_swap_invariance.py | 33 +++++++++- .../ms_health_savings_account_adjustment.yaml | 65 ++++++++++++++----- policyengine_us/tools/general.py | 26 ++++++-- .../ms_health_savings_account_adjustment.py | 6 +- ...lf_employed_health_insurance_adjustment.py | 4 +- .../ms_self_employed_retirement_adjustment.py | 4 +- 8 files changed, 121 insertions(+), 36 deletions(-) diff --git a/changelog.d/fix-state-head-spouse-swap.fixed.md b/changelog.d/fix-state-head-spouse-swap.fixed.md index e8731a26dc2..ebec9b83450 100644 --- a/changelog.d/fix-state-head-spouse-swap.fixed.md +++ b/changelog.d/fix-state-head-spouse-swap.fixed.md @@ -1 +1 @@ -Make state income taxes independent of which spouse is labelled head: dependents' income on Arkansas, Delaware, Iowa, Mississippi and Montana separate-column returns goes to the spouse with the greater income (an exact tie splits it); Delaware chooses the dependent credit split and, on equal incomes, the EITC and dependent care credit columns together to minimise tax; Montana's joint itemized deductions count both spouses' mortgage and investment interest; and an incapacitated or disabled head now qualifies as the other spouse's qualifying individual for Minnesota's dependent care credit, Montana's dependent care deduction and Oregon's working family household and dependent care credit, a surviving-spouse claimant for Missouri's property tax credit, and a totally disabled spouse for Oklahoma's property tax credit. +Make state income taxes independent of which spouse is labelled head: dependents' income on Arkansas, Delaware, Iowa, Mississippi and Montana separate-column returns goes to the spouse with the greater income (an exact tie splits it); Mississippi splits a tax-unit health savings account or self-employed deduction with no per-person amounts equally between the spouses; Delaware chooses the dependent credit split and, on equal incomes, the EITC and dependent care credit columns together to minimise tax; Montana's joint itemized deductions count both spouses' mortgage and investment interest; and an incapacitated or disabled head now qualifies as the other spouse's qualifying individual for Minnesota's dependent care credit, Montana's dependent care deduction and Oregon's working family household and dependent care credit, a surviving-spouse claimant for Missouri's property tax credit, and a totally disabled spouse for Oklahoma's property tax credit. 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 index 8c34b72a919..109a9967ecc 100644 --- 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 @@ -24,8 +24,8 @@ 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. + deduction, each gets their own amount; when they have none, the head and + spouse split it equally, whichever is labelled head. 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.) """ @@ -215,6 +215,9 @@ def _run(units, year, *, zero_deduction): 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_spouse"] = np.asarray( + sim.calculate("is_tax_unit_spouse", year), dtype=bool + ) person["is_dependent"] = np.asarray( sim.calculate("is_tax_unit_dependent", year), dtype=bool ) @@ -252,7 +255,7 @@ def _check(units, year): assert (shares[person["is_dependent"]] == 0).all() # 3. Attribution: own amounts when they sum to the deduction; otherwise, - # without any, the head's. + # without any, an equal share for the head and the spouse. filer = ~person["is_dependent"] filers_own = _unit_sum( person, person["health_savings_account_ald_person"] * filer, n @@ -263,9 +266,13 @@ def _check(units, year): person["health_savings_account_ald_person"][matches], atol=TOLERANCE, ) - no_own_head = (filers_own == 0)[unit] & person["is_head"] + head_or_spouse = person["is_head"] | person["is_spouse"] + n_spouses = _unit_sum(person, head_or_spouse.astype(float), n) + no_own = (filers_own == 0)[unit] & head_or_spouse np.testing.assert_allclose( - shares[no_own_head], deduction[unit][no_own_head], atol=TOLERANCE + shares[no_own], + (deduction / np.maximum(n_spouses, 1))[unit][no_own], + atol=TOLERANCE, ) # 4. The deduction lowers total Mississippi AGI by no more than itself. diff --git a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py index f391cce535d..03dd693cc4d 100644 --- a/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py +++ b/policyengine_us/tests/core/test_state_tax_head_spouse_swap_invariance.py @@ -24,6 +24,8 @@ the disability pathway, on the head only. - Oregon's working family household and dependent care credit excluded a disabled head as a qualifying spouse and counted only the head's earnings. +- Mississippi's health savings account and self-employed adjustments put a + tax-unit deduction with no per-person split on the head's column. Couples where a spouse is claimed as a dependent on another return are out of scope here: the federal standard deduction and several state formulas @@ -135,6 +137,12 @@ def couples(draw): "state": draw(st.sampled_from(AFFECTED)), "adults": [draw(adults()), draw(adults())], "dependents": draw(st.lists(dependents(), max_size=3)), + # Tax-unit deductions with no per-person split, as microdata supply + # the health savings account deduction. + "tax_unit": { + "health_savings_account_ald": draw(money(8_550)), + "self_employed_health_insurance_ald": draw(money(6_000)), + }, } @@ -340,11 +348,26 @@ def adult(age=45, **amounts): [], ), ] - return [ + edge = [ {"state": state, "adults": adults, "dependents": deps} for state in states for adults, deps in couples ] + # A tax-unit health savings account deduction with no per-person split + # and unequal incomes, as in the microdata (#9617 cross-check). + edge += [ + { + "state": state, + "adults": [ + adult(employment_income=300_000.0), + adult(employment_income=2_000.0), + ], + "dependents": [teen], + "tax_unit": {"health_savings_account_ald": 9_000.0}, + } + for state in states + ] + return edge def _seeded_couples(states, per_state): @@ -357,6 +380,11 @@ def _seeded_couples(states, per_state): "dependents": [ _seeded_dependent(rng) for _ in range(int(rng.choice([0, 1, 1, 2, 3]))) ], + "tax_unit": { + "health_savings_account_ald": ( + float(round(rng.uniform(1, 8_550))) if rng.random() < 0.3 else 0.0 + ), + }, } for state in states for _ in range(per_state) @@ -405,6 +433,9 @@ def _situation(units, year): ("families", "family"), ): groups[group][f"{prefix}_{i}_{copy}"] = {"members": members} + groups["tax_units"][f"tax_unit_{i}_{copy}"].update( + {k: {year: v} for k, v in unit.get("tax_unit", {}).items()} + ) groups["households"][f"household_{i}_{copy}"] = { "members": members, "state_code": {year: unit["state"]}, 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 index 999c32009f1..8776ba67bff 100644 --- 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 @@ -23,7 +23,7 @@ ms_agi_adjustments: 1_500 ms_agi: 38_500 -- name: A joint return without per-person amounts attributes the deduction once, to the head +- name: A joint return without per-person amounts attributes the deduction once, half to each spouse period: 2025 input: people: @@ -42,10 +42,11 @@ 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] + # The account holder is unknown, so each spouse takes half, whichever is + # labelled head; the deduction is still taken once. + ms_health_savings_account_adjustment: [1_500, 1_500] + ms_agi_adjustments: [1_500, 1_500] + ms_agi: [38_500, 28_500] - name: The spouse whose account it is takes the deduction period: 2025 @@ -143,11 +144,13 @@ 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] + # The filers' 3,000 is split between the spouses; the child's own + # account is left out. + ms_health_savings_account_adjustment: [1_500, 1_500, 0] + ms_agi_adjustments: [1_500, 1_500, 0] + # The child's 5,000 reaches the Mississippi AGI of the spouse with the + # greater income in full: 40,000 - 1,500 + 5,000. + ms_agi: [43_500, 28_500, 0] - name: A married person filing a separate federal return takes their own deduction period: 2025 @@ -198,7 +201,7 @@ # 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 +- name: Without per-person amounts each spouse takes half, even above one spouse's own income period: 2025 input: people: @@ -217,13 +220,39 @@ 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 - # column goes negative (1,000 - 3,000 = -2,000). On a joint return the - # tax computation combines a negative column with the other one (Form - # 80-100 line 17, #9885), so the whole deduction is used. - ms_health_savings_account_adjustment: [3_000, 0] - ms_agi: [-2_000, 50_000] + # The model does not know whose account it is, so each spouse takes + # half, whichever is labelled head. The head's Mississippi AGI column + # goes negative (1,000 - 1,500 = -500). On a joint return the tax + # computation combines a negative column with the other one (Form 80-100 + # line 17, #9885), so the whole deduction is used. + ms_health_savings_account_adjustment: [1_500, 1_500] + ms_agi: [-500, 48_500] # Combined: 1,000 + 33,400 - 3,000 = 31,400 of taxable income in Column A; # (31,400 - 10,000) x 4.4% = 941.60. ms_income_tax_before_credits_unit: 941.6 + +- name: Without per-person amounts the split is the same when the head and spouse labels are exchanged + period: 2025 + input: + people: + first: + age: 40 + employment_income: 40_000 + is_tax_unit_head: false + is_tax_unit_spouse: true + second: + age: 40 + employment_income: 30_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + tax_units: + tax_unit: + members: [first, second] + health_savings_account_ald: 3_000 + households: + household: + members: [first, second] + state_code: MS + output: + ms_health_savings_account_adjustment: [1_500, 1_500] + ms_agi: [38_500, 28_500] diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index b730a0a30ca..620f4bd492a 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -44,7 +44,13 @@ def tax_unit_non_dep_add(tax_unit, period, variables, include_dependents=()): return total -def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_variable): +def person_share_of_tax_unit_amount( + person, + period, + tax_unit_variable, + person_variable, + split_between_spouses=False, +): """ Attribute a tax unit's amount to its members. @@ -53,9 +59,11 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va own amounts when those sum to the tax unit's amount. When the tax-unit amount differs, as when it is an input or a reform changes its formula, the head's and spouse's own amounts are scaled to sum to it; if they have - no own amounts, the head takes it. With non-negative own amounts and a - non-negative tax-unit amount, as for deductions, no share is negative. - Every tax unit is assumed to have a head. + no own amounts, the head takes it, or with `split_between_spouses` the + head and spouse take equal halves, which does not depend on which spouse + is labelled head. With non-negative own amounts and a non-negative + tax-unit amount, as for deductions, no share is negative. Every tax unit + is assumed to have a head. """ # The person-level projector returns tax-unit values for each member. tax_unit = person.tax_unit @@ -72,9 +80,13 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va out=np.zeros_like(filers_own, dtype=float), where=has_own, ) - reconciled = own * scale + person("is_tax_unit_head", period) * where( - has_own, 0, amount - ) + is_head = person("is_tax_unit_head", period) + if split_between_spouses: + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + takes_unattributed = head_or_spouse / max_(tax_unit.sum(head_or_spouse), 1) + else: + takes_unattributed = is_head + reconciled = own * scale + takes_unattributed * where(has_own, 0, amount) filer_share = where(matches, own, reconciled) return where(filer, filer_share, own) 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 index db83be36356..a54cf964c12 100644 --- 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 @@ -15,8 +15,9 @@ class ms_health_savings_account_adjustment(Variable): "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." + "head and spouse each take half, whichever is labelled head, since the " + "account holder is unknown. A tax unit dependent has no deduction on " + "this return." ) definition_period = YEAR reference = ( @@ -34,6 +35,7 @@ def formula(person, period, parameters): period, "health_savings_account_ald", "health_savings_account_ald_person", + split_between_spouses=True, ) # 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 diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py index b0de576e2e9..6af56b19082 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_health_insurance_adjustment.py @@ -11,7 +11,8 @@ class ms_self_employed_health_insurance_adjustment(Variable): "80-105 line 56). Mississippi reports each spouse's income and " "adjustments separately, so the tax unit's deduction is not repeated " "for every member. A tax-unit deduction that differs from the head's " - "and spouse's own amounts is shared in proportion to them." + "and spouse's own amounts is shared in proportion to them; without own amounts the head and " + "spouse each take half." ) definition_period = YEAR reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" @@ -23,4 +24,5 @@ def formula(person, period, parameters): period, "self_employed_health_insurance_ald", "self_employed_health_insurance_ald_person", + split_between_spouses=True, ) diff --git a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py index 77ac9425c84..8e00fee0b9b 100644 --- a/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py +++ b/policyengine_us/variables/gov/states/ms/tax/income/adjustments/ms_self_employed_retirement_adjustment.py @@ -11,7 +11,8 @@ class ms_self_employed_retirement_adjustment(Variable): "deduction (Form 80-105 line 51). Mississippi reports each spouse's " "income and adjustments separately, so the tax unit's deduction is not " "repeated for every member. A tax-unit deduction that differs from the " - "head's and spouse's own amounts is shared in proportion to them." + "head's and spouse's own amounts is shared in proportion to them; without own amounts the head and " + "spouse each take half." ) definition_period = YEAR reference = "https://www.dor.ms.gov/sites/default/files/tax-forms/individual/80100251%202.pdf#page=12" @@ -23,4 +24,5 @@ def formula(person, period, parameters): period, "self_employed_pension_contribution_ald", "self_employed_pension_contribution_ald_person", + split_between_spouses=True, )