diff --git a/changelog.d/fix-agi-person-ald-split.fixed.md b/changelog.d/fix-agi-person-ald-split.fixed.md new file mode 100644 index 00000000000..27761bd5fe4 --- /dev/null +++ b/changelog.d/fix-agi-person-ald-split.fixed.md @@ -0,0 +1 @@ +Give each spouse their own above-the-line deductions in per-person AGI on a joint return (IRA, capped educator expenses, early withdrawal penalty, student loan interest, alimony paid, business and capital losses, and the rest), instead of splitting every deduction other than the self-employment ones 50/50; attribute HSA deductions using person amounts when provided, and divide deductions recorded only at tax-unit level equally. This changes the state taxes and benefits that read each spouse's AGI (Kentucky, Montana through 2023, Delaware, Virginia and DC separate filing, Ohio's joint filing credit, West Virginia's senior deduction, state withholding, Illinois Family Planning). DC spouses who file separately on the same return each deduct their own capital losses up to their own capital gains plus $1,500. Montana divides nothing between spouses from 2024, when filing separately on the same form ended. diff --git a/changelog.d/fix-ky-loss-ald-person-integration.fixed.md b/changelog.d/fix-ky-loss-ald-person-integration.fixed.md new file mode 100644 index 00000000000..ac399f3bda0 --- /dev/null +++ b/changelog.d/fix-ky-loss-ald-person-integration.fixed.md @@ -0,0 +1 @@ +Avoid reallocating spouses' business and capital losses twice in Kentucky AGI after federal person AGI assigns their own deductions. diff --git a/policyengine_us/tests/core/test_ald_determinism.py b/policyengine_us/tests/core/test_ald_determinism.py index db209769b00..bd7bc997c92 100644 --- a/policyengine_us/tests/core/test_ald_determinism.py +++ b/policyengine_us/tests/core/test_ald_determinism.py @@ -1,12 +1,13 @@ """Above-the-line deductions must have stable float32 accumulation order.""" import importlib +import importlib.machinery import json import os from pathlib import Path import subprocess import sys -from types import SimpleNamespace +from types import ModuleType, SimpleNamespace import numpy as np import pytest @@ -29,29 +30,59 @@ ] -@pytest.mark.parametrize("variable_name", FORMULAS) -@pytest.mark.parametrize("reverse", [False, True]) -@pytest.mark.parametrize("empty", [False, True]) -def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( - monkeypatch, variable_name, reverse, empty -): - module = importlib.import_module(FORMULAS[variable_name]) +ROUNDING_PERIOD = 2026 +ROUNDING_INPUTS = { + "irs_employment_income": 2**25, + "irs_gross_income": 2**25, + "loss_ald": 2**24, + "alimony_expense_ald": 1, + "health_savings_account_ald": 1, + "student_loan_interest_ald_eligible": True, + "is_tax_unit_dependent": False, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "filing_status": "SINGLE", +} +PROBE_FORMULAS = { + "above_the_line_deductions": ( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions" + ), + "above_the_line_deductions_person": ( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions_person" + ), + "loss_ald_person": f"{AGI_MODULE}.above_the_line_deductions.loss_ald_person", + **FORMULAS, +} + + +def _deductions(reverse, empty): deductions = ( [] if empty else [ "loss_ald", - "educator_expense", + "educator_expense_ald", "alimony_expense_ald", "loss_ald", *PERSON_ALDS, "student_loan_interest_ald", "puerto_rico_income", - "educator_expense", + "educator_expense_ald", ] ) if reverse: deductions.reverse() + return deductions + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_tax_unit_deductions_are_ordered_unique_and_exclude_person_alds( + monkeypatch, reverse, empty +): + variable_name = "student_loan_interest_ald_magi" + module = importlib.import_module(FORMULAS[variable_name]) + deductions = _deductions(reverse, empty) magi = SimpleNamespace( person_alds=PERSON_ALDS, excluded_alds=["student_loan_interest_ald", "puerto_rico_income"], @@ -101,75 +132,324 @@ def capture_tax_unit_add(entity, period, variables, include_dependents=()): with pytest.raises(ReachedTaxUnitSum): getattr(module, variable_name).formula(person, 2026, lambda period: parameters) - if empty: - expected = [] - elif variable_name == "student_loan_interest_ald_magi": - expected = ["alimony_expense_ald", "educator_expense", "loss_ald"] - else: - expected = [ - "alimony_expense_ald", - "educator_expense", - "loss_ald", - "puerto_rico_income", - "student_loan_interest_ald", - ] + expected = ( + [] if empty else ["alimony_expense_ald", "educator_expense_ald", "loss_ald"] + ) assert captured == expected -def _household_results(): +PERSON_LEVEL = {"student_loan_interest_ald"} +WITH_PERSON_AMOUNTS = { + "loss_ald", + "alimony_expense_ald", + "educator_expense_ald", + *PERSON_ALDS, +} + + +@pytest.mark.parametrize("reverse", [False, True]) +@pytest.mark.parametrize("empty", [False, True]) +def test_person_deductions_are_summed_in_sorted_unique_order(reverse, empty): + module = importlib.import_module( + f"{AGI_MODULE}.above_the_line_deductions.above_the_line_deductions_person" + ) + deductions = _deductions(reverse, empty) + parameters = SimpleNamespace( + gov=SimpleNamespace( + irs=SimpleNamespace( + ald=SimpleNamespace( + deductions=deductions, + filer_amounts_recorded_on_dependents=[], + ) + ) + ) + ) + order = [] + + class Variable: + def __init__(self, is_person): + self.entity = SimpleNamespace(is_person=is_person) + + class Entity: + def get_variable(self, name, check_existence=False): + if name.endswith("_person"): + if name[: -len("_person")] in WITH_PERSON_AMOUNTS: + return Variable(True) + return None + order.append(name) + return Variable(name in PERSON_LEVEL) + + class TaxUnit: + def __call__(self, name, period): + return np.array([0], dtype=np.float32) + + def sum(self, values): + return np.asarray(values, dtype=np.float32) + + class Person: + entity = Entity() + tax_unit = TaxUnit() + + def __call__(self, name, period): + if name == "is_tax_unit_dependent": + return np.array([False]) + return np.array([0], dtype=np.float32) + + module.above_the_line_deductions_person.formula( + Person(), 2026, lambda period: parameters + ) + expected = [] if empty else sorted(set(deductions)) + assert order == expected + + +def _set_rounding_inputs(simulation, irs): + # Share the explicit boundary inputs between the full and small systems. + for name in irs.gross_income.sources: + simulation.set_input(name, ROUNDING_PERIOD, [0]) + for name in irs.ald.deductions: + simulation.set_input(name, ROUNDING_PERIOD, [0]) + for name in PERSON_ALDS: + simulation.set_input(f"{name}_person", ROUNDING_PERIOD, [0]) + for name, value in ROUNDING_INPUTS.items(): + simulation.set_input(name, ROUNDING_PERIOD, [value]) + + +def _serialized_results(simulation): + results = {} + for name in FORMULAS: + values = simulation.calculate(name, ROUNDING_PERIOD) + results[name] = { + "values": values.tolist(), + "dtype": str(values.dtype), + "bytes": values.tobytes().hex(), + } + return results + + +def _probe_fixture(simulation): + """Capture the real parameter lists and explicit dependency inputs.""" + from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.loss_ald_person import ( + BUSINESS_INCOME_SOURCES, + ) + + system = simulation.tax_benefit_system + parameters = system.parameters(f"{ROUNDING_PERIOD}-01-01") + irs = parameters.gov.irs + magi = irs.ald.student_loan_interest.magi + lists = { + "deductions": list(irs.ald.deductions), + "gross_income_sources": list(irs.gross_income.sources), + "filer_amounts_recorded_on_dependents": list( + irs.ald.filer_amounts_recorded_on_dependents + ), + "person_alds": list(magi.person_alds), + "excluded_alds": list(magi.excluded_alds), + } + names = set(lists["deductions"]) | set(lists["gross_income_sources"]) + for deduction in lists["deductions"]: + candidate = f"{deduction}_person" + if candidate in system.variables: + names.add(candidate) + names.update(f"{name}_person" for name in lists["person_alds"]) + names.update(BUSINESS_INCOME_SOURCES) + names.update(ROUNDING_INPUTS) + names.update( + ( + "section_911_excluded_income", + "other_net_gain", + "limited_business_loss", + "capital_losses", + # MAGI reads all unemployment compensation, even when only its + # taxable portion is in the gross-income parameter list. + "unemployment_compensation", + "basic_income", + ) + ) + # These classes remain real production formulas in every child. + names.difference_update(PROBE_FORMULAS) + variables = {} + for name in sorted(names): + variable = system.variables[name] + values = simulation.calculate(name, ROUNDING_PERIOD) + if name == "filing_status": + value_type = "filing_status" + default_value = variable.default_value.name + input_values = values.decode_to_str().tolist() + elif variable.value_type in (float, bool, int): + value_type = variable.value_type.__name__ + default_value = variable.value_type(variable.default_value) + input_values = values.tolist() + else: + raise TypeError(f"Unsupported probe input {name}: {variable.value_type}") + variables[name] = { + "entity": variable.entity.key, + "value_type": value_type, + "default_value": default_value, + "definition_period": variable.definition_period, + "input_values": input_values, + } + return { + "parameters": lists, + "basic_income_taxable": bool( + parameters.gov.contrib.ubi_center.basic_income.taxable + ), + "variables": variables, + } + + +def _household_results(fixture_path=None): """Run real formulas at a float32 rounding boundary, with no dataset.""" from policyengine_us import Simulation simulation = Simulation( situation={ - "people": {"person": {"age": {2026: 40}}}, + "people": {"person": {"age": {ROUNDING_PERIOD: 40}}}, "households": { "household": { "members": ["person"], - "state_code": {2026: "MT"}, - "county_fips": {2026: "30031"}, + "state_code": {ROUNDING_PERIOD: "MT"}, + "county_fips": {ROUNDING_PERIOD: "30031"}, } }, } ) - irs = simulation.tax_benefit_system.parameters("2026-01-01").gov.irs + irs = simulation.tax_benefit_system.parameters(f"{ROUNDING_PERIOD}-01-01").gov.irs # Explicit inputs isolate summation from deduction eligibility and phaseouts. - for name in irs.gross_income.sources: - simulation.set_input(name, 2026, [0]) - for name in irs.ald.deductions: - simulation.set_input(name, 2026, [0]) - for name in PERSON_ALDS: - simulation.set_input(f"{name}_person", 2026, [0]) - inputs = { - "irs_employment_income": 2**25, - "irs_gross_income": 2**25, - "loss_ald": 2**24, - "alimony_expense_ald": 1, - "health_savings_account_ald": 1, - "student_loan_interest_ald_eligible": True, - "is_tax_unit_dependent": False, - "is_tax_unit_head": True, - "is_tax_unit_spouse": False, - "filing_status": "SINGLE", - } - for name, value in inputs.items(): - simulation.set_input(name, 2026, [value]) - results = {} - for name in FORMULAS: - values = simulation.calculate(name, 2026) - results[name] = { - "values": values.tolist(), - "dtype": str(values.dtype), - "bytes": values.tobytes().hex(), - } + _set_rounding_inputs(simulation, irs) + results = _serialized_results(simulation) + if fixture_path is not None: + fixture_path.write_text(json.dumps(_probe_fixture(simulation))) return results -def test_household_float32_deductions_are_identical_across_hash_seeds(): +def _install_probe_package(): + """Avoid country startup only in a fresh hash-seed child process.""" + if "policyengine_us" in sys.modules: + raise RuntimeError("The hash-seed probe must precede country imports") + package_dir = Path(__file__).resolve().parents[2] + package = ModuleType("policyengine_us") + package.__path__ = [str(package_dir)] + package.__package__ = "policyengine_us" + package.__file__ = str(package_dir / "__init__.py") + package.__spec__ = importlib.machinery.ModuleSpec( + "policyengine_us", loader=None, is_package=True + ) + package.__spec__.submodule_search_locations = package.__path__ + sys.modules["policyengine_us"] = package + + +def _hash_seed_results(fixture_path): + """Run production formulas and actual core float32 holders under a seed.""" + _install_probe_package() + from policyengine_core.model_api import Enum, Variable + from policyengine_core.parameters import ParameterNode + from policyengine_core.simulations import Simulation as CoreSimulation + from policyengine_core.taxbenefitsystems import TaxBenefitSystem + from policyengine_us.entities import entities + from policyengine_us.variables.household.demographic.tax_unit.filing_status import ( + FilingStatus, + ) + + fixture = json.loads(fixture_path.read_text()) + system = TaxBenefitSystem(entities) + entity_by_key = {entity.key: entity for entity in system.entities} + for name, attributes in fixture["variables"].items(): + assert name not in PROBE_FORMULAS + if attributes["value_type"] == "filing_status": + value_type = Enum + default_value = FilingStatus[attributes["default_value"]] + extra = {"possible_values": FilingStatus} + else: + value_type = {"float": float, "bool": bool, "int": int}[ + attributes["value_type"] + ] + default_value = attributes["default_value"] + extra = {} + system.add_variable( + type( + name, + (Variable,), + { + "entity": entity_by_key[attributes["entity"]], + "value_type": value_type, + "default_value": default_value, + "definition_period": attributes["definition_period"], + "label": f"Explicit rounding-boundary fixture input: {name}", + **extra, + }, + ) + ) + for name, module_path in PROBE_FORMULAS.items(): + system.add_variable(getattr(importlib.import_module(module_path), name)) + + p = fixture["parameters"] + + def leaf(value): + return {"values": {f"{ROUNDING_PERIOD}-01-01": value}} + + system.parameters = ParameterNode( + "", + data={ + "gov": { + "irs": { + "gross_income": {"sources": leaf(p["gross_income_sources"])}, + "ald": { + "deductions": leaf(p["deductions"]), + "filer_amounts_recorded_on_dependents": leaf( + p["filer_amounts_recorded_on_dependents"] + ), + "student_loan_interest": { + "magi": { + "person_alds": leaf(p["person_alds"]), + "excluded_alds": leaf(p["excluded_alds"]), + } + }, + }, + }, + "contrib": { + "ubi_center": { + "basic_income": { + "taxable": leaf(fixture["basic_income_taxable"]) + } + } + }, + } + }, + ) + simulation = CoreSimulation( + tax_benefit_system=system, + situation={ + "people": {"person": {}}, + "tax_units": {"unit": {"members": ["person"]}}, + }, + default_input_period=ROUNDING_PERIOD, + default_calculation_period=ROUNDING_PERIOD, + ) + for name, attributes in fixture["variables"].items(): + simulation.set_input(name, ROUNDING_PERIOD, attributes["input_values"]) + _set_rounding_inputs( + simulation, system.parameters(f"{ROUNDING_PERIOD}-01-01").gov.irs + ) + results = _serialized_results(simulation) + if "policyengine_us.system" in sys.modules: + raise RuntimeError("The hash-seed probe imported the full country system") + return results + + +def test_household_float32_deductions_are_identical_across_hash_seeds(tmp_path): + fixture_path = tmp_path / "rounding-boundary.json" + # Keep one complete US integration as the oracle. Only the child processes + # replace unrelated formula dependencies with its captured boundary inputs. + reference = _household_results(fixture_path=fixture_path) results = {} for seed in (0, 20, 32, 34, 42, 123): completed = subprocess.run( - [sys.executable, str(Path(__file__).resolve()), "--household-probe"], + [ + sys.executable, + str(Path(__file__).resolve()), + "--hash-seed-probe", + str(fixture_path), + ], env={**os.environ, "PYTHONHASHSEED": str(seed)}, check=True, capture_output=True, @@ -178,12 +458,18 @@ def test_household_float32_deductions_are_identical_across_hash_seeds(): ) results[seed] = json.loads(completed.stdout) assert all(result == results[0] for result in results.values()), json.dumps(results) - # Both $1 deductions precede the large deduction in the canonical order; - # their sum and the resulting income are exactly representable in float32. - for result in results[0].values(): + assert results[0] == reference, json.dumps( + {"full_household": reference, "hash_seeds": results} + ) + # The boundary combines 2**25 of income with 2**24 and two $1 deductions. + # Keep its expected float32 result alongside the exact byte comparisons. + for result in reference.values(): assert result["dtype"] == "float32" assert result["values"] == [16_777_214.0] -if __name__ == "__main__" and sys.argv[1:] == ["--household-probe"]: - print(json.dumps(_household_results())) +if __name__ == "__main__": + if sys.argv[1:] == ["--household-probe"]: + print(json.dumps(_household_results())) + elif len(sys.argv) == 3 and sys.argv[1] == "--hash-seed-probe": + print(json.dumps(_hash_seed_results(Path(sys.argv[2])))) diff --git a/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py new file mode 100644 index 00000000000..0f42a20e099 --- /dev/null +++ b/policyengine_us/tests/core/test_spouse_above_the_line_deductions_invariants.py @@ -0,0 +1,778 @@ +"""Invariants for each spouse's own above-the-line deductions. + +Each person's AGI (adjusted_gross_income_person) is their own gross income +less their own above-the-line deductions (above_the_line_deductions_person). +On a joint return a deduction that belongs to one spouse, such as their IRA +deduction, educator expenses, early withdrawal penalty, alimony paid, student +loan interest or business and capital losses, lowers only that spouse's AGI. +Only deductions recorded for the tax unit alone (here the tuition and fees +deduction) are divided equally. HSA deductions follow the supplied account +holders' amounts, with an equal division when none are supplied. States that tax spouses +separately (Kentucky, Montana, Delaware, Virginia, Ohio's joint filing credit, +the District of Columbia, West Virginia's senior deduction) read these +amounts. + +Hypothesis draws batches of tax units (single, joint, joint with dependents) +in those states, with every kind of deduction input, occasional business +losses over the Section 461(l) limit, and some tax-unit deductions +(loss_ald, alimony_expense_ald, self_employment_tax_ald) set directly; a +seeded population of 200 units adds breadth. Each batch runs as vectorized +simulations, once as drawn and once with the spouse's own deduction inputs set +to zero. Units that set the same tax-unit deduction directly share a +simulation, and units that set none share another: an input given for one +tax unit is an input for every unit in its simulation. For every tax unit: + +1. Accounting identities. The head's and spouse's parts add up to the tax + unit's amount: above_the_line_deductions_person to + above_the_line_deductions, loss_ald_person to loss_ald, + limited_capital_loss_person to limited_capital_loss and + alimony_expense_ald_person to alimony_expense_ald. The members' + adjusted_gross_income_person add up to adjusted_gross_income, and so do + the head's and spouse's Medicaid AGIs. This holds when a tax-unit + deduction is set directly too, except that alimony_expense_ald_person + stays each payer's own alimony when alimony_expense_ald is set directly + (above_the_line_deductions_person divides the difference). +2. Differential. limited_business_loss and limited_capital_loss equal numpy + computations of the Section 461(l) and 1211(b) limits from the inputs and + parameters. For units with no deduction set directly, + above_the_line_deductions_person equals a numpy reference built from the + inputs: each person's own person-level deductions, their educator + expenses capped at the per-educator limit, their HSA share from the supplied + account-holder amounts and return total, their alimony under a + pre-2019 instrument, their share of the numpy-limited business loss by + their own business losses and of the capital loss deduction (losses up + to the filers' capital gains and distributions, plus the net loss up to + the 1211(b) limit) by their own capital losses, and an equal share of the + tuition and fees deduction. +3. Own deductions. Except for IRA and student-loan shared limits and MAGI + interactions, the head's above_the_line_deductions_person is never higher + with the spouse's deduction inputs than without them. It is the same, and so is the head's + AGI, when the head has no loss, IRA contribution or student loan interest + that shares a return-level limit with the spouse's, and no income (Social + Security, unemployment compensation) taxed by reference to the couple's AGI. The + spouse's own AGI and the couple's AGI never fall when the spouse's + deduction inputs are removed and the head has no IRA contribution. + This is checked for units with no deduction set directly. +4. Dependents. A dependent's adjusted_gross_income_person, loss_ald_person + and limited_capital_loss_person are zero, and their + above_the_line_deductions_person is their own person-level deductions. +5. Bounds. Every above_the_line_deductions_person and loss_ald_person is + non-negative. +6. DC separate filing on the same return. For the head and spouse of a joint + DC return, dc_separate_capital_loss_adjustment equals the capital part of + their loss_ald_person (loss_ald less the business loss, by their own + capital losses) less their own capital losses up to their own capital + gains and distributions plus the $1,500 separate limit; it is zero for + everyone else. For units with no deduction set directly, that capital part + equals the numpy capital loss deduction, and each couple's adjustments add + up to zero or more: filing separately never deducts more capital loss than + the joint return. (A loss_ald set directly below the losses can make the + sum negative.) + (The YAML tests check that the joint DC computation is unaffected.) +7. Montana from 2024. Spouses no longer file separately on the same form, so + mt_loss_ald_reallocation and mt_applicable_ald_deductions are zero. +""" + +import numpy as np +import pytest +from hypothesis import HealthCheck, given, settings +from hypothesis import strategies as st + +from policyengine_us import Simulation + +# Values are float32, and a business loss over the Section 461(l) limit puts +# AGI in the millions, so allow a relative error as well as cents. +TOLERANCE = 0.05 # dollars +RTOL = 2e-6 + +STATES = ["KY", "MT", "DE", "VA", "OH", "DC", "WV"] + +# A person's inputs behind deductions that belong to them. +OWN_DEDUCTION_INPUTS = [ + "traditional_ira_contributions", + "early_withdrawal_penalty", + "educator_expense", + "alimony_expense", + "qualified_adoption_assistance_expense", + "us_bonds_for_higher_ed", + "student_loan_interest", + "self_employed_health_insurance_premiums", + "self_employed_pension_contributions", +] +# Inputs whose negative values are losses. +LOSS_INPUTS = [ + "self_employment_income", + "rental_income", + "long_term_capital_gains", + "short_term_capital_gains", +] +BUSINESS_SOURCES = [ + "total_self_employment_income", + "farm_operations_income", + "rental_income", + "farm_rent_income", + "estate_income", + "partnership_s_corp_income", +] +# Tax-unit deductions that a unit may set directly. +DIRECT_INPUTS = ["loss_ald", "alimony_expense_ald", "self_employment_tax_ald"] +TAX_UNIT_OUTPUTS = [ + "adjusted_gross_income", + "above_the_line_deductions", + "loss_ald", + "limited_business_loss", + "limited_capital_loss", + "alimony_expense_ald", + "educator_expense_ald", + "health_savings_account_ald", + "other_net_gain", +] +PERSON_OUTPUTS = [ + "above_the_line_deductions_person", + "adjusted_gross_income_person", + "medicaid_adjusted_gross_income_person", + "loss_ald_person", + "limited_capital_loss_person", + "alimony_expense_ald_person", + "educator_expense", + "educator_expense_ald_person", + "health_savings_account_ald_person", + "irs_gross_income", + "capital_losses", + "capital_gains", + "non_sch_d_capital_gains", + "alimony_expense", + "divorce_year", + "student_loan_interest", + "traditional_ira_contributions", + "social_security", + "unemployment_compensation", + "dc_separate_capital_loss_adjustment", + "mt_loss_ald_reallocation", + "mt_applicable_ald_deductions", + *BUSINESS_SOURCES, +] + +amount = st.integers(1, 40_000).map(float) +small = st.one_of(st.just(0.0), st.integers(1, 7_000).map(float)) + + +def _maybe(draw, strategy): + return draw(st.one_of(st.just(0.0), strategy)) + + +@st.composite +def person_amounts(draw, *, dependent): + big_loss = draw(st.integers(0, 19)) == 0 + return { + "employment_income": _maybe(draw, amount), + "self_employment_income": ( + -1_500_000.0 + if big_loss + else _maybe(draw, st.integers(-20_000, 60_000).map(float)) + ), + "rental_income": _maybe(draw, st.integers(-15_000, 20_000).map(float)), + "long_term_capital_gains": _maybe( + draw, st.integers(-15_000, 15_000).map(float) + ), + "short_term_capital_gains": _maybe(draw, st.integers(-5_000, 5_000).map(float)), + "taxable_interest_income": _maybe(draw, st.integers(1, 2_000).map(float)), + "traditional_ira_contributions": draw(small), + "early_withdrawal_penalty": draw(st.integers(0, 500).map(float)), + "educator_expense": draw(st.integers(0, 400).map(float)), + "alimony_expense": draw(small), + "divorce_year": draw(st.sampled_from([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": draw(small), + "us_bonds_for_higher_ed": draw(small), + "student_loan_interest": _maybe(draw, st.integers(1, 3_500).map(float)), + "self_employed_health_insurance_premiums": draw(small), + "self_employed_pension_contributions": draw(small), + "social_security_retirement": (0.0 if dependent else _maybe(draw, amount)), + "unemployment_compensation": _maybe(draw, st.integers(1, 15_000).map(float)), + } + + +@st.composite +def tax_units(draw): + kind = draw(st.sampled_from(["single", "joint", "joint", "joint_dependents"])) + return { + "state": draw(st.sampled_from(STATES)), + "health_savings_account_ald": _maybe(draw, st.integers(1, 8_000).map(float)), + "other_net_gain": _maybe(draw, st.integers(-5_000, 5_000).map(float)), + "direct": draw( + st.one_of( + st.none(), + st.tuples( + st.sampled_from(DIRECT_INPUTS), + st.integers(0, 8_000).map(float), + ), + ) + ), + "head": draw(person_amounts(dependent=False)), + "spouse": (draw(person_amounts(dependent=False)) if kind != "single" else None), + "dependents": [ + {"age": draw(st.integers(5, 23)), **draw(person_amounts(dependent=True))} + for _ in range(draw(st.integers(1, 2)) if kind == "joint_dependents" else 0) + ], + } + + +SEED = 20261006 + + +def _seeded_amounts(rng, *, dependent): + def some(low, high, p=0.5): + return float(round(rng.uniform(low, high))) if rng.random() < p else 0.0 + + return { + "employment_income": some(1, 40_000, 0.6), + "self_employment_income": ( + -1_500_000.0 if rng.random() < 0.05 else some(-20_000, 60_000, 0.6) + ), + "rental_income": some(-15_000, 20_000, 0.3), + "long_term_capital_gains": some(-15_000, 15_000, 0.4), + "short_term_capital_gains": some(-5_000, 5_000, 0.3), + "taxable_interest_income": some(1, 2_000), + "traditional_ira_contributions": some(1, 7_000), + "early_withdrawal_penalty": some(1, 500), + "educator_expense": some(1, 400), + "alimony_expense": some(1, 7_000, 0.3), + "divorce_year": int(rng.choice([2010, 2018, 2020])), + "qualified_adoption_assistance_expense": some(1, 7_000, 0.2), + "us_bonds_for_higher_ed": some(1, 7_000, 0.2), + "student_loan_interest": some(1, 3_500, 0.4), + "self_employed_health_insurance_premiums": some(1, 7_000, 0.3), + "self_employed_pension_contributions": some(1, 7_000, 0.3), + "social_security_retirement": 0.0 if dependent else some(1, 40_000, 0.3), + "unemployment_compensation": some(1, 15_000, 0.2), + } + + +def _seeded_units(n=200): + rng = np.random.default_rng(SEED) + units = [] + for _ in range(n): + kind = rng.choice(["single", "joint", "joint", "joint_dependents"]) + units.append( + { + "state": str(rng.choice(STATES)), + "health_savings_account_ald": ( + float(round(rng.uniform(1, 8_000))) if rng.random() < 0.3 else 0.0 + ), + "other_net_gain": ( + float(round(rng.uniform(-5_000, 5_000))) + if rng.random() < 0.2 + else 0.0 + ), + "direct": ( + ( + str(rng.choice(DIRECT_INPUTS)), + float(round(rng.uniform(0, 8_000))), + ) + if rng.random() < 0.15 + else None + ), + "head": _seeded_amounts(rng, dependent=False), + "spouse": ( + _seeded_amounts(rng, dependent=False) if kind != "single" else None + ), + "dependents": [ + { + "age": int(rng.integers(5, 24)), + **_seeded_amounts(rng, dependent=True), + } + for _ in range( + int(rng.integers(1, 3)) if kind == "joint_dependents" else 0 + ) + ], + } + ) + return units + + +def _zero_own_deductions(amounts): + zeroed = {**amounts, **{name: 0.0 for name in OWN_DEDUCTION_INPUTS}} + for name in LOSS_INPUTS: + zeroed[name] = max(0.0, zeroed[name]) + return zeroed + + +def _situation(units, year, *, zero_spouse): + people, groups = {}, {"tax_units": {}, "households": {}, "marital_units": {}} + for i, u in enumerate(units): + head = f"head_{i}" + people[head] = { + "age": 50, + "is_tax_unit_head": True, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": False, + **u["head"], + } + members, couple = [head], [head] + if u["spouse"] is not None: + spouse = f"spouse_{i}" + amounts = u["spouse"] + if zero_spouse: + amounts = _zero_own_deductions(amounts) + people[spouse] = { + "age": 48, + "is_tax_unit_head": False, + "is_tax_unit_spouse": True, + "is_tax_unit_dependent": False, + **amounts, + } + members.append(spouse) + couple.append(spouse) + groups["marital_units"][f"couple_{i}"] = {"members": couple} + for j, d in enumerate(u["dependents"]): + child = f"dependent_{i}_{j}" + people[child] = { + "age": d["age"], + "is_full_time_student": d["age"] >= 19, + "is_tax_unit_head": False, + "is_tax_unit_spouse": False, + "is_tax_unit_dependent": True, + **{k: v for k, v in d.items() if k != "age"}, + } + members.append(child) + groups["marital_units"][f"single_{i}_{j}"] = {"members": [child]} + groups["tax_units"][f"tax_unit_{i}"] = { + "members": members, + "health_savings_account_ald": {year: u["health_savings_account_ald"]}, + "other_net_gain": {year: u["other_net_gain"]}, + } + if u["direct"] is not None: + name, value = u["direct"] + groups["tax_units"][f"tax_unit_{i}"][name] = {year: value} + groups["households"][f"household_{i}"] = { + "members": members, + "state_code": {year: u["state"]}, + } + people = { + name: {k: {year: v} for k, v in values.items()} + for name, values in people.items() + } + return {"people": people, **groups} + + +def _direct_name(unit): + return unit["direct"][0] if unit["direct"] is not None else "" + + +def _grouped(units): + """The units ordered so those setting the same variable directly are together. + + A variable given as an input for one tax unit becomes an input for every + tax unit in the simulation, at its default value for the others, so a + loss_ald set directly for one unit would set every other unit's loss_ald + to zero. Each group therefore runs as its own simulation. + """ + names = sorted({_direct_name(u) for u in units}) + return [[u for u in units if _direct_name(u) == name] for name in names] + + +def _run(units, year, *, zero_spouse): + """Run each group of _grouped(units) and join the results in that order.""" + runs = [ + _run_group(group, year, zero_spouse=zero_spouse) for group in _grouped(units) + ] + offset = 0 + for run in runs: + run["unit"] = run["unit"] + offset + offset += len(run["adjusted_gross_income"]) + return {key: np.concatenate([run[key] for run in runs]) for key in runs[0]} + + +def _run_group(units, year, *, zero_spouse): + sim = Simulation(situation=_situation(units, year, zero_spouse=zero_spouse)) + tbs = sim.tax_benefit_system + deductions = tbs.parameters(f"{year}-01-01").gov.irs.ald.deductions + out = { + name: np.asarray(sim.calculate(name, year), dtype=float) + for name in TAX_UNIT_OUTPUTS + PERSON_OUTPUTS + } + # The person-level deductions and the person amounts behind the tax-unit + # deductions other than losses, alimony, educator expenses and HSA, + # which the reference below builds independently from inputs and limits. + own_names, shared_names = [], [] + for deduction in sorted(set(deductions)): + if tbs.variables[deduction].entity.is_person: + own_names.append(deduction) + elif deduction in ( + "loss_ald", + "alimony_expense_ald", + "educator_expense_ald", + "health_savings_account_ald", + ): + continue + elif f"{deduction}_person" in tbs.variables: + own_names.append(f"{deduction}_person") + else: + shared_names.append(deduction) + # Amounts that are the filer's even when recorded on a dependent. + out["filer_amounts"] = sum( + ( + np.asarray(sim.calculate(name, year), dtype=float) + for name in tbs.parameters( + f"{year}-01-01" + ).gov.irs.ald.filer_amounts_recorded_on_dependents + ), + np.zeros(len(out["adjusted_gross_income_person"])), + ) + out["own_direct"] = sum( + np.asarray(sim.calculate(name, year), dtype=float) for name in own_names + ) + out["shared"] = sum( + (np.asarray(sim.calculate(name, year), dtype=float) for name in shared_names), + np.zeros(len(out["adjusted_gross_income"])), + ) + out["is_dependent"] = np.asarray( + sim.calculate("is_tax_unit_dependent", year), dtype=bool + ) + out["is_head"] = np.asarray(sim.calculate("is_tax_unit_head", year), dtype=bool) + out["is_spouse"] = np.asarray(sim.calculate("is_tax_unit_spouse", year), dtype=bool) + out["unit"] = sim.populations["tax_unit"].members_entity_id + # The Section 461(l) and 1211(b) limits for each unit's filing status. + filing_status = sim.calculate("filing_status", year).decode_to_str() + loss = tbs.parameters(f"{year}-01-01").gov.irs.ald.loss + out["business_loss_limit"] = np.asarray(loss.max[filing_status], dtype=float) + out["capital_loss_limit"] = np.asarray(loss.capital.max[filing_status], dtype=float) + out["separate_capital_loss_limit"] = np.full( + len(filing_status), float(loss.capital.max["SEPARATE"]) + ) + out["educator_cap"] = float( + tbs.parameters(f"{year}-01-01").gov.irs.ald.educator_expense.cap + ) + # Keep every run output an array so grouped runs concatenate uniformly. + out["educator_cap"] = np.full( + len(out["adjusted_gross_income_person"]), out["educator_cap"] + ) + out["joint"] = filing_status == "JOINT" + out["state"] = np.array([u["state"] for u in units]) + return out + + +def _unit_sum(run, values): + return np.bincount( + run["unit"], weights=values, minlength=len(run["adjusted_gross_income"]) + ) + + +def _per_person(run, unit_values): + return np.asarray(unit_values)[run["unit"]] + + +def _share(amounts, totals, fallback): + return np.where( + totals > 0, + np.divide(amounts, totals, out=np.zeros_like(amounts), where=totals > 0), + fallback, + ) + + +def _limits(run): + """The limited business and capital losses of each unit, with numpy.""" + filer = ~run["is_dependent"] + # Section 461(l)(3)(A) compares the aggregate deductions of the trades or + # businesses with their aggregate gross income or gain, so each source's + # gain and loss count separately, not netted for the person first. + gains = sum(np.maximum(0, run[source]) for source in BUSINESS_SOURCES) + losses = sum(np.maximum(0, -run[source]) for source in BUSINESS_SOURCES) + other_net_gain = run["other_net_gain"] + income = _unit_sum(run, filer * gains) + np.maximum(0, other_net_gain) + loss = _unit_sum(run, filer * losses) + np.maximum(0, -other_net_gain) + limited_business = np.minimum(loss, income + run["business_loss_limit"]) + # 26 USC 1211(b): capital losses are allowed to the extent of capital + # gains, each filer's net gain and capital gain distributions (Schedule D + # lines 13 and 16), plus a net loss up to the limit (line 21). + capital_losses = _unit_sum(run, filer * run["capital_losses"]) + capital_gains = _unit_sum( + run, + filer + * ( + np.maximum(0, run["capital_gains"]) + + np.maximum(0, run["non_sch_d_capital_gains"]) + ), + ) + allowed_against_gains = np.minimum(capital_losses, capital_gains) + limited_capital = np.minimum( + run["capital_loss_limit"], capital_losses - allowed_against_gains + ) + return limited_business, limited_capital, allowed_against_gains + + +def _even(run): + """An equal share for each filer of a unit.""" + filer = ~run["is_dependent"] + filers = _per_person(run, _unit_sum(run, filer.astype(float))) + return np.divide( + filer.astype(float), filers, out=np.zeros_like(filers), where=filers > 0 + ) + + +def _capital_deduction_person(run): + """Each filer's share of the capital loss deduction, by their own losses.""" + capital_loss = ~run["is_dependent"] * run["capital_losses"] + _, limited_capital, allowed_against_gains = _limits(run) + capital_deduction = _per_person(run, allowed_against_gains + limited_capital) + return capital_deduction * _share( + capital_loss, _per_person(run, _unit_sum(run, capital_loss)), _even(run) + ) + + +def _capital_part_of_loss_ald(run): + """Each filer's share of loss_ald less the business loss, by own losses.""" + business_part = np.minimum(run["limited_business_loss"], run["loss_ald"]) + capital_part = np.maximum(0, run["loss_ald"] - business_part) + capital_loss = ~run["is_dependent"] * run["capital_losses"] + return _per_person(run, capital_part) * _share( + capital_loss, _per_person(run, _unit_sum(run, capital_loss)), _even(run) + ) + + +def _educator_deduction_person(run): + """26 USC 62(a)(2)(D): the cap applies separately to each educator.""" + return np.minimum(np.maximum(0, run["educator_expense"]), run["educator_cap"]) + + +def _hsa_deduction_person(run): + """Attribute the supplied return total by the account holders' inputs.""" + filer = ~run["is_dependent"] + own = run["health_savings_account_ald_person"] + filer_own = filer * own + total = _per_person(run, _unit_sum(run, filer_own)) + share = _share(filer_own, total, _even(run)) + return_total = _per_person(run, run["health_savings_account_ald"]) + reconciled = filer_own + (return_total - total) * share + # A dependent's own input is preserved and never enters the filer's sum. + return np.where(filer, reconciled, own) + + +def _reference(run): + """above_the_line_deductions_person, built from the inputs with numpy.""" + filer = ~run["is_dependent"] + even = _even(run) + business_loss = filer * sum( + np.maximum(0, -run[source]) for source in BUSINESS_SOURCES + ) + business_loss = business_loss + even * np.maximum( + 0, -_per_person(run, run["other_net_gain"]) + ) + limited_business, _, _ = _limits(run) + limited_business = _per_person(run, limited_business) + losses = limited_business * _share( + business_loss, _per_person(run, _unit_sum(run, business_loss)), even + ) + _capital_deduction_person(run) + alimony = run["alimony_expense"] * (run["divorce_year"] < 2019) + # Tax-unit deductions with no person amounts are divided equally. + shared = _per_person(run, run["shared"]) * even + # A filer amount recorded on a dependent (gov.irs.ald. + # filer_amounts_recorded_on_dependents) is not on the dependent's own + # return; the head and spouse divide it equally. + dependent_filer_amounts = _per_person( + run, _unit_sum(run, run["is_dependent"] * run["filer_amounts"]) + ) + educator = _educator_deduction_person(run) + hsa = _hsa_deduction_person(run) + filer_part = run["own_direct"] + educator + hsa + alimony + losses + shared + filer_part = filer_part + dependent_filer_amounts * even + return np.where( + filer, + filer_part, + run["own_direct"] + educator + hsa + alimony - run["filer_amounts"], + ) + + +def _close(actual, desired, **kwargs): + np.testing.assert_allclose(actual, desired, atol=TOLERANCE, rtol=RTOL, **kwargs) + + +def _at_most(lower, upper): + return (lower <= upper + TOLERANCE + RTOL * np.abs(upper)).all() + + +def _check(units, year): + # The order in which _run returns the units. + units = [u for group in _grouped(units) for u in group] + run = _run(units, year, zero_spouse=False) + zeroed = _run(units, year, zero_spouse=True) + filer = ~run["is_dependent"] + dependent = run["is_dependent"] + direct_unit = np.array([u["direct"] is not None for u in units]) + direct = _per_person(run, direct_unit) + + # 1. Accounting identities. + pairs = [ + ("above_the_line_deductions_person", "above_the_line_deductions"), + ("loss_ald_person", "loss_ald"), + ("limited_capital_loss_person", "limited_capital_loss"), + ("alimony_expense_ald_person", "alimony_expense_ald"), + ] + set_directly = np.array([_direct_name(u) for u in units]) + for person_name, unit_name in pairs: + # alimony_expense_ald_person is each payer's own alimony, so it adds up + # to alimony_expense_ald unless that is set directly; + # above_the_line_deductions_person reconciles the difference. + kept = (unit_name != "alimony_expense_ald") | (set_directly != unit_name) + _close( + _unit_sum(run, filer * run[person_name])[kept], + run[unit_name][kept], + err_msg=person_name, + ) + _close( + _unit_sum(run, run["adjusted_gross_income_person"]), + run["adjusted_gross_income"], + ) + _close( + _unit_sum(run, filer * run["medicaid_adjusted_gross_income_person"]), + run["adjusted_gross_income"], + ) + + # 2. Differential against numpy, including each educator's separate cap. + educator = _educator_deduction_person(run) + _close(run["educator_expense_ald_person"], educator) + _close(run["educator_expense_ald"], _unit_sum(run, filer * educator)) + limited_business, limited_capital, _ = _limits(run) + _close(run["limited_business_loss"], limited_business) + _close(run["limited_capital_loss"], limited_capital) + _close(run["above_the_line_deductions_person"][~direct], _reference(run)[~direct]) + + # 3. Shared deduction limits and MAGI interactions can change the head's + # own deduction. Spouse deductions can lower joint MAGI and increase the + # head's IRA or student-loan deduction; removing the spouse's IRA + # contributions can also free compensation under Section 219(c). + head = run["is_head"] + spouse = run["is_spouse"] + has_spouse = _per_person(run, _unit_sum(run, spouse.astype(float))) > 0 + paired_head = head & has_spouse & ~direct + # Each filer's deductions as their AGI shows them. + deductions = "deductions_in_agi" + for r in (run, zeroed): + r[deductions] = r["irs_gross_income"] - r["adjusted_gross_income_person"] + agi = "adjusted_gross_income_person" + no_student_loan = run["student_loan_interest"] == 0 + no_ira = run["traditional_ira_contributions"] == 0 + checked = paired_head & no_student_loan & no_ira + assert _at_most(run[deductions][checked], zeroed[deductions][checked]) + # A Form 4797 loss, recorded for the tax unit, is a business loss of both + # the head and spouse. + has_loss = ( + (run["capital_losses"] > 0) + | (sum(np.maximum(0, -run[source]) for source in BUSINESS_SOURCES) > 0) + | (_per_person(run, run["other_net_gain"]) < 0) + ) + own_only = checked & ~has_loss + _close(run[deductions][own_only], zeroed[deductions][own_only]) + joint_taxed_income = (run["social_security"] > 0) | ( + run["unemployment_compensation"] > 0 + ) + unaffected = own_only & ~joint_taxed_income + _close(run[agi][unaffected], zeroed[agi][unaffected]) + # Removing a spouse's pension contributions can also change retirement + # plan coverage and release the head's IRA phase-out. That discontinuity + # can increase the head's deduction by more than the amount removed. + head_has_ira = _unit_sum(run, head & ~no_ira) > 0 + independent_units = ~direct_unit & ~head_has_ira + checked_spouse = spouse & _per_person(run, independent_units) + assert _at_most(run[agi][checked_spouse], zeroed[agi][checked_spouse]) + assert _at_most( + run["adjusted_gross_income"][independent_units], + zeroed["adjusted_gross_income"][independent_units], + ) + + # 4. Dependents. + for name in ["adjusted_gross_income_person", "loss_ald_person"]: + assert (run[name][dependent] == 0).all(), name + assert (run["limited_capital_loss_person"][dependent] == 0).all() + + # 5. Bounds. + for name in ["above_the_line_deductions_person", "loss_ald_person"]: + assert (run[name] >= -TOLERANCE).all(), name + + # 6. DC separate filing on the same return. + adjustment = run["dc_separate_capital_loss_adjustment"] + dc_unit = run["state"] == "DC" + dc_joint = _per_person(run, dc_unit & run["joint"]) & (head | spouse) + own_gains = np.maximum(0, run["capital_gains"]) + np.maximum( + 0, run["non_sch_d_capital_gains"] + ) + separate = np.minimum( + run["capital_losses"], + own_gains + _per_person(run, run["separate_capital_loss_limit"]), + ) + # The capital part of each filer's loss_ald_person, which their federal + # AGI includes. For units with no deduction set directly it is the numpy + # capital loss deduction. + federal = _capital_part_of_loss_ald(run) + _close(federal[~direct], _capital_deduction_person(run)[~direct]) + _close(adjustment, dc_joint * (federal - separate)) + assert (adjustment[~dc_joint] == 0).all() + # A loss_ald set directly below the capital losses can leave the separate + # columns deducting more than federal AGI does. + assert (_unit_sum(run, adjustment)[~direct_unit] >= -TOLERANCE).all() + + # 7. Montana from 2024. + if year >= 2024: + for name in ["mt_loss_ald_reallocation", "mt_applicable_ald_deductions"]: + assert (run[name] == 0).all(), name + + +# A batch's cost is mostly per-variable overhead, so each example is a large +# batch and there are few examples. +SETTINGS = dict( + max_examples=4, + deadline=None, + derandomize=True, + suppress_health_check=[HealthCheck.too_slow, HealthCheck.data_too_large], +) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_own_deductions_2026(units): + _check(units, 2026) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_own_deductions_2023(units): + # 2023 is the last year Montana lets spouses who file jointly for federal + # purposes file separately on the same form, a path that reads each + # spouse's AGI. + _check(units, 2023) + + +@settings(**SETTINGS) +@given(st.lists(tax_units(), min_size=10, max_size=40)) +def test_own_deductions_2020(units): + # 2020 adds the tuition and fees deduction, divided equally, and the + # unemployment compensation exclusion, which reads the couple's AGI. + _check(units, 2020) + + +@pytest.mark.parametrize("year", [2020, 2023, 2026]) +def test_seeded_population(year): + _check(_seeded_units(), year) + + +def test_every_deduction_is_attributed_or_divided_equally_on_purpose(): + # A tax-unit deduction with no person amount falls back to an equal + # division between the head and spouse. Only those listed, with their + # reasons, in EQUALLY_DIVIDED_DEDUCTIONS may do so. + from policyengine_us.variables.gov.irs.income.taxable_income.adjusted_gross_income.above_the_line_deductions.above_the_line_deductions_person import ( + EQUALLY_DIVIDED_DEDUCTIONS, + ) + + # This audit only reads the baseline registry and parameter histories. + # Reuse the already initialized system rather than uprating it again. + system = Simulation.default_tax_benefit_system_instance + deductions = system.parameters.gov.irs.ald.deductions + seen = set() + for value in deductions.values_list: + seen.update(deductions(value.instant_str)) + for deduction in sorted(seen): + variable = system.variables[deduction] + attributed = ( + variable.entity.is_person or f"{deduction}_person" in system.variables + ) + assert attributed != (deduction in EQUALLY_DIVIDED_DEDUCTIONS), deduction diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml new file mode 100644 index 00000000000..925efbcb5ca --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.yaml @@ -0,0 +1,151 @@ +- name: Under the Section 461(l) limit, the allowed business loss is divided by each spouse's own losses. + period: 2024 + absolute_error_margin: 0.1 + input: + people: + head: + age: 50 + self_employment_income: -1_000_000 + spouse: + age: 50 + employment_income: 50_000 + rental_income: -200_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The 2024 joint threshold is $610,000 (Rev. Proc. 2023-34). + limited_business_loss: 610_000 + loss_ald: 610_000 + # 610,000 x 1,000,000 / 1,200,000 and 610,000 x 200,000 / 1,200,000. + loss_ald_person: [508_333.33, 101_666.67] + adjusted_gross_income_person: [-508_333.33, -51_666.67] + +- name: A Form 4797 loss, recorded only for the tax unit, counts equally for the head and spouse. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + other_net_gain: -4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 4_000 + loss_ald_person: [2_000, 2_000] + +- name: Business and capital losses are divided separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 80_000 + self_employment_income: -5_000 + long_term_capital_gains: -4_000 + spouse: + age: 50 + employment_income: 20_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # $5,000 business loss plus the $3,000 capital loss limit. + loss_ald: 8_000 + # The head has all the business loss and two-thirds of the capital loss. + loss_ald_person: [7_000, 1_000] + adjusted_gross_income_person: [73_000, 19_000] + +- name: A dependent's losses are on their own return. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 50_000 + long_term_capital_gains: -1_000 + dependent: + age: 20 + is_full_time_student: true + is_tax_unit_spouse: false + is_tax_unit_dependent: true + self_employment_income: -3_000 + long_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, dependent] + households: + household: + members: [head, dependent] + state_code: TX + output: + loss_ald: 1_000 + loss_ald_person: [1_000, 0] + +- name: A loss deduction set directly, with no person losses, is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 2_500 + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald_person: [1_250, 1_250] + adjusted_gross_income_person: [28_750, 18_750] + +- name: A loss deduction set directly below the business loss goes to the business loss. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 50 + employment_income: 30_000 + self_employment_income: -5_000 + spouse: + age: 50 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 2_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + limited_business_loss: 5_000 + loss_ald_person: [2_000, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml index dc8b705e5ac..937128b9b5f 100644 --- a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.yaml @@ -77,8 +77,8 @@ members: [person1, person2, person3] output: adjusted_gross_income: 90_000 - # self-employment loss is shared evenly by the head and spouse - adjusted_gross_income_person: [55_000, 35_000, 0] + # The head's self-employment loss lowers only the head's AGI. + adjusted_gross_income_person: [50_000, 40_000, 0] - name: adjusted_gross_income_person unit test 4 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml new file mode 100644 index 00000000000..9b297f83a4a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person_own_deductions.yaml @@ -0,0 +1,397 @@ +# Each spouse's own above-the-line deductions lower only that spouse's AGI. +# Before this was fixed, every deduction other than the three self-employment +# ones was split 50/50 between the head and spouse on a joint return. + +- name: The spouse's IRA deduction lowers only the spouse's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + filing_status: JOINT + adjusted_gross_income: 94_000 + above_the_line_deductions_person: [0, 6_000] + # Before: [57_000, 37_000]. + adjusted_gross_income_person: [60_000, 34_000] + +- name: Educator expenses and an early withdrawal penalty go to the spouse who has them. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + educator_expense: 300 + spouse: + age: 45 + employment_income: 40_000 + early_withdrawal_penalty: 200 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 99_500 + adjusted_gross_income_person: [59_700, 39_800] + +- name: Alimony paid goes to the payer, and only under a pre-2019 instrument. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + alimony_expense: 12_000 + divorce_year: 2015 + spouse: + age: 45 + employment_income: 40_000 + # Not deductible: the instrument is after 2018 (26 USC 215, repealed + # by Pub. L. 115-97 sec. 11051). + alimony_expense: 5_000 + divorce_year: 2020 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + alimony_expense_ald_person: [12_000, 0] + alimony_expense_ald: 12_000 + adjusted_gross_income: 88_000 + # Before: [54_000, 34_000]. + adjusted_gross_income_person: [48_000, 40_000] + +- name: Student loan interest is divided in proportion to the interest each spouse paid. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 35 + employment_income: 60_000 + student_loan_interest: 1_000 + spouse: + age: 35 + employment_income: 40_000 + student_loan_interest: 2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The return's $2,500 cap (26 USC 221(b)(1)) is shared 1:2. + student_loan_interest_ald: [833.33, 1_666.67] + adjusted_gross_income: 97_500 + # Before: [58_750, 38_750]. + adjusted_gross_income_person: [59_166.67, 38_333.33] + +- name: An HSA deduction without account-holder amounts is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + # No account-holder amounts are supplied, so this return total + # uses the allocator's equal fallback between the filers. + health_savings_account_ald: 4_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 96_000 + adjusted_gross_income_person: [58_000, 38_000] + +- name: An HSA return total is reconciled in proportion to the account holders' amounts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + health_savings_account_ald_person: 1_000 + spouse: + age: 45 + employment_income: 40_000 + health_savings_account_ald_person: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + health_savings_account_ald: 6_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + # 26 USC 223(a) and (d)(1): the deduction belongs to the account holder. + # The supplied return total differs from the individual inputs; preserve + # their 1:3 ownership ratio while reconciling to the return's $6,000. + above_the_line_deductions: 6_000 + above_the_line_deductions_person: [1_500, 4_500] + adjusted_gross_income: 94_000 + adjusted_gross_income_person: [58_500, 35_500] + +- name: The head's capital loss, after the $3,000 limit, lowers only the head's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -10_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 3_000 + loss_ald_person: [3_000, 0] + adjusted_gross_income: 97_000 + # Before: [58_500, 38_500]. + adjusted_gross_income_person: [57_000, 40_000] + +- name: The limited capital loss is divided in proportion to each spouse's own loss. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + short_term_capital_gains: -2_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 3_000 + loss_ald_person: [750, 2_250] + adjusted_gross_income: 97_000 + adjusted_gross_income_person: [59_250, 37_750] + +- name: The spouse's business loss lowers only the spouse's AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + self_employment_income: -8_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + loss_ald: 8_000 + loss_ald_person: [0, 8_000] + adjusted_gross_income: 92_000 + # Before: [56_000, 36_000]. + adjusted_gross_income_person: [60_000, 32_000] + +- name: A tax-unit deduction set directly, with no person amounts, is divided equally. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + alimony_expense_ald: 3_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 97_000 + above_the_line_deductions_person: [1_500, 1_500] + adjusted_gross_income_person: [58_500, 38_500] + +- name: A tax-unit deduction set directly is divided by the spouses' own amounts. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_tax_ald_person: 1_000 + spouse: + age: 45 + employment_income: 40_000 + self_employment_tax_ald_person: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + # Set above the $4,000 the person amounts add up to. + self_employment_tax_ald: 6_000 + households: + household: + members: [head, spouse] + state_code: TX + output: + adjusted_gross_income: 94_000 + above_the_line_deductions_person: [1_500, 4_500] + adjusted_gross_income_person: [58_500, 35_500] + +- name: A single filer keeps all of the return's deductions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head] + health_savings_account_ald: 2_000 + households: + household: + members: [head] + state_code: TX + output: + adjusted_gross_income: 52_000 + adjusted_gross_income_person: 52_000 + +- name: A dependent's own deductions stay on the dependent's return. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 6_000 + dependent: + age: 20 + is_full_time_student: true + employment_income: 5_000 + traditional_ira_contributions: 1_000 + tax_units: + tax_unit: + members: [head, spouse, dependent] + households: + household: + members: [head, spouse, dependent] + state_code: TX + output: + adjusted_gross_income: 94_000 + # The dependent's amount is what they deduct on their own return. + above_the_line_deductions_person: [0, 6_000, 1_000] + adjusted_gross_income_person: [60_000, 34_000, 0] + +- name: Spousal IRA compensation limits preserve each contributor's deduction ownership. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 1_000 + traditional_ira_contributions: 3_000 + spouse: + age: 45 + employment_income: 3_000 + traditional_ira_contributions: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + traditional_ira_deduction: [1_000, 3_000] + above_the_line_deductions_person: [1_000, 3_000] + adjusted_gross_income_person: [0, 0] + adjusted_gross_income: 0 + +- name: Removing the higher earner's IRA contributions frees compensation for the lower earner. + period: 2020 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 1_000 + traditional_ira_contributions: 3_000 + spouse: + age: 45 + employment_income: 3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + traditional_ira_deduction: [3_000, 0] + above_the_line_deductions_person: [3_000, 0] + adjusted_gross_income_person: [-2_000, 3_000] + adjusted_gross_income: 1_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..7fd6336e203 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_agi_spouse_deductions.yaml @@ -0,0 +1,24 @@ +# Schedule S, Calculation J(a): each person's portion of federal AGI. + +- name: The head's IRA deduction lowers only the head's DC AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # Before: [57_000, 37_000]. + dc_agi: [54_000, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml index 8ff19166598..668ae86533d 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_self_employment_loss_addition.yaml @@ -194,7 +194,70 @@ state_code: DC output: adjusted_gross_income: 80_000 - # total IRS self-employment losses are shared evenly by head and spouse - adjusted_gross_income_person: [45_000, 35_000, 0] + # Each spouse's self-employment loss lowers their own federal AGI, as each + # reports their own income and deductions in Calculation J. + adjusted_gross_income_person: [40_000, 40_000, 0] dc_self_employment_loss_addition: [12_000, 6_000, 0] - dc_agi: [57_000, 41_000, 0] + dc_agi: [52_000, 46_000, 0] + +- name: Business and capital losses, computed. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # loss_ald: 20,000 business and 3,000 capital. + loss_ald: 23_000 + adjusted_gross_income_person: [37_000, 40_000] + # 20,000 - 12,000. + dc_self_employment_loss_addition: [8_000, 0] + # Separately the head deducts 1,500 of capital loss: 3,000 - 1,500. + dc_separate_capital_loss_adjustment: [1_500, 0] + # 37,000 + 8,000 + 1,500 - 29,200. + dc_taxable_income_indiv: [17_300, 40_000] + +- name: Business and capital losses with loss_ald set directly. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + self_employment_income: -20_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 22_000 + households: + household: + members: [head, spouse] + state_code: DC + output: + # The business loss comes first: 20,000 business and 2,000 capital, as in + # loss_ald_person. + adjusted_gross_income_person: [38_000, 40_000] + dc_self_employment_loss_addition: [8_000, 0] + # 2,000 - 1,500. + dc_separate_capital_loss_adjustment: [500, 0] + # 38,000 + 8,000 + 500 - 29,200. + dc_taxable_income_indiv: [17_300, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml new file mode 100644 index 00000000000..29b2cbd951d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.yaml @@ -0,0 +1,217 @@ +# Spouses who file separately on the same DC return each deduct their own +# capital losses up to their own capital gains plus $1,500, the limit for +# married people filing separately (2024 D-40 instructions, p. 16, line c). +# The joint DC computation keeps the federal joint deduction, up to $3,000. + +- name: A head's $5,000 capital loss is limited to $1,500 when filing separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The federal joint return deducts $3,000, all of it the head's. + adjusted_gross_income_person: [57_000, 40_000] + dc_agi: [57_000, 40_000] + # Separately the head deducts $1,500: 3,000 - 1,500. + dc_separate_capital_loss_adjustment: [1_500, 0] + # 57,000 + 1,500 - 29,200 and 40,000. + dc_taxable_income_indiv: [29_300, 40_000] + # Joint: 97,000 - 29,200, unchanged. + dc_taxable_income_joint: 67_800 + # 3,668 before, plus 6% of 1,500. Still less than the joint 4,163. + dc_income_tax_before_credits_indiv: 3_758 + dc_income_tax_before_credits_joint: 4_163 + dc_income_tax_before_credits: 3_758 + +- name: Each spouse deducts up to $1,500 of their own losses. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The federal $3,000 is divided in proportion to the losses: 2,000 and + # 1,000. Separately each spouse deducts 1,500. + adjusted_gross_income_person: [58_000, 39_000] + dc_separate_capital_loss_adjustment: [500, -500] + dc_taxable_income_indiv: [29_300, 38_500] + dc_taxable_income_joint: 67_800 + +- name: One spouse's capital gain does not absorb the other's loss when filing separately. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: 4_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # Jointly the head's $5,000 loss offsets the spouse's $4,000 gain plus + # $1,000 more. Separately the head deducts $1,500 and the spouse reports + # the whole gain. + adjusted_gross_income_person: [55_000, 44_000] + dc_separate_capital_loss_adjustment: [3_500, 0] + # The deduction is reallocated to the spouses' separate AGIs (58,500 and + # 44,000), still adding up to 29,200. + dc_deduction_indiv: [18_500, 10_700] + dc_taxable_income_indiv: [40_000, 33_300] + dc_taxable_income_joint: 69_800 + dc_income_tax_before_credits_indiv: 3_998 + dc_income_tax_before_credits_joint: 4_333 + +- name: A spouse's own capital gain distributions absorb their own loss first. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -2_000 + non_sch_d_capital_gains: 1_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DC + output: + # The whole $2,000 loss is deductible either way: $1,000 against the + # distributions and a $1,000 net loss. + dc_separate_capital_loss_adjustment: [0, 0] + dc_taxable_income_indiv: [29_800, 40_000] + +- name: No adjustment for a single filer or a dependent. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + child: + age: 10 + long_term_capital_gains: -1_000 + single: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + tax_units: + couple_unit: + members: [head, spouse, child] + single_unit: + members: [single] + households: + couple_household: + members: [head, spouse, child] + state_code: DC + single_household: + members: [single] + state_code: DC + output: + dc_separate_capital_loss_adjustment: [1_500, 0, 0, 0] + dc_taxable_income_indiv: [29_300, 40_000, 0, 42_400] + dc_taxable_income_joint: [67_800, 42_400] + +- name: A loss_ald set directly to zero leaves no capital deduction in federal AGI to remove. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 0 + households: + household: + members: [head, spouse] + state_code: DC + output: + adjusted_gross_income_person: [60_000, 40_000] + # Federal AGI deducts nothing; separately the head deducts 1,500. + dc_separate_capital_loss_adjustment: [-1_500, 0] + dc_taxable_income_indiv: [29_300, 40_000] + dc_taxable_income_joint: 70_800 + dc_income_tax_before_credits_indiv: 3_758 + +- name: A loss_ald set directly below the separate limit. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -5_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + loss_ald: 1_000 + households: + household: + members: [head, spouse] + state_code: DC + output: + # The head's federal AGI deducts the whole 1,000; separately 1,500. + adjusted_gross_income_person: [59_000, 40_000] + dc_separate_capital_loss_adjustment: [-500, 0] + dc_taxable_income_indiv: [29_300, 40_000] + dc_taxable_income_joint: 69_800 diff --git a/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..3c11ccfc845 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/de/tax/income/de_pre_exclusions_agi_spouse_deductions.yaml @@ -0,0 +1,27 @@ +# Spouses filing combined separate returns each report their own income, +# losses and deductions: PIT-RES instructions (2025), page 5, line 1; +# 30 Del. C. 1162(b)(1). + +- name: The head's IRA deduction lowers only the head's Delaware AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: DE + output: + # PIT-RES line 1, columns B and A. Before: [57_000, 37_000]. + de_pre_exclusions_agi: [54_000, 40_000] + de_agi_indiv: [54_000, 40_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml index c05e65e94c2..629a8a38f1c 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/il/hfs/fpp/il_fpp_income_level.yaml @@ -25,3 +25,27 @@ adjusted_gross_income_person: 0 output: il_fpp_income_level: 0 + +- name: Only the applicant's own deductions lower the applicant's income. + period: 2024 + absolute_error_margin: 0.001 + input: + people: + head: + age: 30 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + spouse: + age: 30 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: IL + output: + # Before: [27_000, 17_000]. + adjusted_gross_income_person: [24_000, 20_000] + il_fpp_income_level: [1.1742, 0.9785] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml new file mode 100644 index 00000000000..5ccdaed15c6 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_agi_spouse_deductions.yaml @@ -0,0 +1,30 @@ +# Each spouse claims their own income and deductions on a combined return: +# Form 740 instructions (2024), page 13, filing status 2; KRS 141.180(3). + +- name: The head's IRA deduction lowers only the head's Kentucky AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: KY + output: + # Form 740, line 5, columns B and A. Before: [57_000, 37_000]. + adjusted_gross_income_person: [54_000, 40_000] + ky_agi: [54_000, 40_000] + # Line 11: line 9 less the $3,160 standard deduction. + ky_taxable_income_indiv: [50_840, 36_840] + # 4% of AGI less the standard deduction. + ky_withheld_income_tax: [2_033.60, 1_473.60] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml index 4d3e4e2e4a4..db205d26cf0 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_federal_agi.yaml @@ -19,7 +19,7 @@ members: [head, spouse] state_code: KY output: - adjusted_gross_income_person: [25_000, -15_000] + adjusted_gross_income_person: [40_000, -30_000] adjusted_gross_income: 10_000 ky_federal_agi: [40_000, -30_000] ky_agi: [40_000, -30_000] @@ -155,6 +155,69 @@ ky_agi: [40_000, 10_000, 0] ky_modified_agi_if_separate: 50_000 +- name: Kentucky reverses reconciled federal losses when supplied person amounts are zero + period: 2024 + input: + people: + head: + age: 45 + employment_income: 40_000 + loss_ald_person: 0 + spouse: + age: 43 + self_employment_income: -30_000 + loss_ald_person: 0 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: KY + output: + # Federal ALD reconciles the supplied zero person amounts to the allowed + # 30,000 loss with its equal fallback. Kentucky still assigns the loss + # to its owner and preserves the joint federal AGI. + loss_ald: 30_000 + adjusted_gross_income_person: [25_000, -15_000] + adjusted_gross_income: 10_000 + ky_federal_agi: [40_000, -30_000] + ky_agi: [40_000, -30_000] + ky_modified_agi_if_joint: 10_000 + ky_modified_agi_if_separate: 40_000 + +- name: Supplied dependent loss amounts do not enter Kentucky's federal loss reversal + period: 2024 + input: + people: + head: + age: 45 + employment_income: 40_000 + loss_ald_person: 0 + spouse: + age: 43 + self_employment_income: -30_000 + loss_ald_person: 30_000 + child: + age: 10 + is_tax_unit_dependent: true + loss_ald_person: 50_000 + tax_units: + tax_unit: + members: [head, spouse, child] + households: + household: + members: [head, spouse, child] + state_code: KY + output: + loss_ald: 30_000 + adjusted_gross_income_person: [40_000, -30_000, 0] + adjusted_gross_income: 10_000 + ky_federal_agi: [40_000, -30_000, 0] + ky_agi: [40_000, -30_000, 0] + ky_modified_agi_if_joint: 10_000 + ky_modified_agi_if_separate: 40_000 + - name: A reform removing loss deductions does not restore them through allocation period: 2024 input: diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml index e0860148c1b..9bf7fc33ee1 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/tax/income/ky_files_separately.yaml @@ -224,7 +224,7 @@ # (120,000 - 3,160) x 4% = 4,673.60. Both credit rates are zero. ky_income_tax: 3_873.60 ky_files_separately: false - adjusted_gross_income_person: [95_000, 5_000] + adjusted_gross_income_person: [120_000, -20_000] ky_agi: [120_000, -20_000] ky_modified_agi_if_joint: 100_000 ky_modified_agi_if_separate: 120_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml index 2928eff14e0..3b461c771ef 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mi/tax/income/mi_withheld_income_tax.yaml @@ -27,3 +27,12 @@ state_code: MI output: mi_withheld_income_tax: 0 + +- name: A negative AGI, as from a spouse's business loss, withholds nothing. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_gross_income_person: -1_000 + state_code: MI + output: + mi_withheld_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml new file mode 100644 index 00000000000..fdd80c83110 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/tax/income/credits/mn_renters_credit_household_agi.yaml @@ -0,0 +1,25 @@ +- name: Household AGI counts the couple's AGI once, however it is divided between them. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 30 + employment_income: 30_000 + traditional_ira_contributions: 6_000 + spouse: + age: 30 + tax_units: + tax_unit: + members: [head, spouse] + spm_units: + spm_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MN + output: + # Before, the spouse's AGI was -3,000 and the head's 27,000. + adjusted_gross_income_person: [24_000, 0] + mn_renters_credit_household_agi: 24_000 diff --git a/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml new file mode 100644 index 00000000000..7e0125ab39e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mt/tax/income/mt_agi_indiv_spouse_deductions.yaml @@ -0,0 +1,291 @@ +# Montana spouses who file jointly for federal purposes and separately on the +# same form (through 2023) each claim the items attributable to them; an item +# not clearly attributable to one spouse is divided equally (ARM 42.15.206(1); +# MCA 15-30-2110(6) to (8), 2021). The IRA deduction goes to the spouse who made +# the contribution (MCA 15-30-2110(8)). +# +# Montana's own allocation of business and capital losses is not modeled yet: +# the capital loss elections of ARM 42.15.206(3) (2010 MAR p. 621) and the +# Section 461(l) limit recomputed with the single filer's threshold. Until it +# is, the return's losses are divided equally between the spouses +# (mt_loss_ald_reallocation), as before each spouse's own deductions were +# attributed to them. + +- name: The head's IRA deduction lowers only the head's Montana AGI. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Before: [57_000, 37_000]. + mt_agi_indiv: [54_000, 40_000] + +- name: Losses are divided equally, as before, whichever spouse has them. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The federal per-spouse amounts give the $3,000 allowed loss to the head; + # Montana divides it equally: 60,000 - 1,500 and 40,000 - 1,500. + limited_capital_loss_person: [3_000, 0] + mt_loss_ald_reallocation: [1_500, -1_500] + mt_agi_indiv: [58_500, 38_500] + mt_applicable_ald_deductions: [1_500, 1_500] + +- name: Both spouses' capital losses are divided equally. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + spouse: + age: 45 + employment_income: 40_000 + short_term_capital_gains: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + limited_capital_loss_person: [2_000, 1_000] + mt_agi_indiv: [58_500, 38_500] + mt_applicable_ald_deductions: [1_500, 1_500] + +- name: Rental losses are divided equally. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + rental_income: -8_000 + spouse: + age: 45 + employment_income: 40_000 + rental_income: -2_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The $10,000 of losses, 5,000 each. + loss_ald_person: [8_000, 2_000] + mt_agi_indiv: [55_000, 35_000] + +- name: One spouse's capital gain and the other's loss are divided as before. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 10_000 + long_term_capital_gains: 1_000 + spouse: + age: 40 + employment_income: 10_000 + long_term_capital_gains: -3_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The $3,000 loss deduction ($1,000 against the head's gain and the $2,000 + # net loss) is divided equally: 11,000 - 1,500 and 10,000 - 1,500. + mt_agi_indiv: [9_500, 8_500] + +- name: A business loss over the joint Section 461(l) limit is divided equally. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 40 + employment_income: 650_000 + self_employment_income: -600_000 + spouse: + age: 40 + employment_income: 10_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The joint 2023 limit allows $578,000 of the loss; each spouse takes + # $289,000. Head: 650,000 - 289,000. Spouse: 10,000 - 289,000, floored. + mt_agi_indiv: [361_000, 0] + +- name: A spousal IRA above the spouse's income still lowers the joint deductions' AGI. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 30_000 + spouse: + age: 45 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # The spouse's own Montana AGI, -6,000, is floored at zero, but the joint + # return's Montana AGI is 30,000 - 6,000 = 24,000. The joint standard + # deduction is 20% of it, 4,800, raised to the 4,920 floor. + mt_agi_indiv: [30_000, 0] + mt_agi_joint: 24_000 + mt_standard_deduction_joint: [4_920, 0] + +- name: A single filer's loss is their own. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + filer: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -5_000 + tax_units: + tax_unit: + members: [filer] + households: + household: + members: [filer] + state_code: MT + output: + # The $3,000 allowed capital loss: 40,000 - 3,000. + mt_loss_ald_reallocation: 0 + mt_agi_indiv: 37_000 + +- name: Each spouse's student loan interest and IRA deduction stay in their own column. + period: 2023 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + student_loan_interest: 2_000 + spouse: + age: 45 + employment_income: 40_000 + traditional_ira_contributions: 5_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Joint MAGI for the student loan interest phase-out is 95,000, under the + # 2023 joint start of 155,000, so the full 2,000 is deducted. Splitting + # all deductions equally would give [3_500, 3_500] and [56_500, 36_500]. + mt_applicable_ald_deductions: [2_000, 5_000] + mt_agi_indiv: [58_000, 35_000] + +- name: From 2024 Montana no longer divides anything between spouses. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + long_term_capital_gains: -4_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + # Married filing separately on the same form ended after 2023, and the + # return starts from the couple's federal taxable income. + mt_married_filing_separately_on_same_return_eligible: [false, false] + mt_loss_ald_reallocation: [0, 0] + mt_applicable_ald_deductions: [0, 0] + mt_agi_indiv: [0, 0] + # The same as main, and the same when the spouse has the loss and the IRA + # deduction (next case). + mt_income_tax: 3_154.20 + +- name: From 2024 Montana's tax does not depend on which spouse has the deductions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 40_000 + long_term_capital_gains: -4_000 + traditional_ira_contributions: 6_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: MT + output: + mt_loss_ald_reallocation: [0, 0] + mt_applicable_ald_deductions: [0, 0] + mt_income_tax: 3_154.20 diff --git a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml index b3c1e4b597f..e1bdea1c3d5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/oh/tax/income/credits/joint_filing_credit/oh_joint_filing_credit_qualifying_income.yaml @@ -55,3 +55,29 @@ # from "adjusted gross income", so qualifying income is 600, not # 40_600 - 100_000. oh_joint_filing_credit_qualifying_income: [600, 50_000] + +- name: The spouse's own IRA deduction counts against the spouse's qualifying income. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + spouse: + age: 45 + employment_income: 3_000 + traditional_ira_contributions: 2_800 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: OH + output: + # The spouse's AGI is $200, under the $500 each spouse needs + # (R.C. 5747.05(E)). Before, half the IRA deduction went to the head: + # [58_600, 1_600], and the couple qualified. + oh_joint_filing_credit_qualifying_income: [60_000, 200] + oh_joint_filing_credit_eligible: false diff --git a/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml b/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml index dfc9747cbe5..f16dcae99f4 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/pa/tax/income/pa_withheld_income_tax.yaml @@ -27,3 +27,12 @@ state_code: PA output: pa_withheld_income_tax: 0 + +- name: A negative AGI, as from a spouse's business loss, withholds nothing. + period: 2024 + absolute_error_margin: 0.01 + input: + adjusted_gross_income_person: -1_000 + state_code: PA + output: + pa_withheld_income_tax: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml index b27b791a35c..e3e1cd35aa8 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/va/tax/income/va_agi_person.yaml @@ -107,3 +107,51 @@ state_code: VA output: va_agi_person: [-247, 55_728] + +- name: The head's IRA deduction lowers only the head's separate Virginia AGI. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 45 + employment_income: 60_000 + traditional_ira_contributions: 6_000 + spouse: + age: 45 + employment_income: 40_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: VA + output: + # Worksheet for Determining Separate Virginia Adjusted Gross Income, + # line 10 (adjustments) and line 11. Before: [57_000, 37_000]. + va_agi_person: [54_000, 40_000] + +- name: A spouse with negative federal AGI gets no share of the additions. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + adjusted_gross_income_person: 10_000 + va_subtractions_person: 0 + spouse: + adjusted_gross_income_person: -1_000 + va_subtractions_person: 0 + tax_units: + tax_unit: + members: [head, spouse] + va_additions: 1_000 + households: + household: + members: [head, spouse] + state_code: VA + output: + # Prorating by signed AGI would give the head 1,111.11 and the spouse + # -111.11 of the additions. + va_agi_person: [11_000, -1_000] diff --git a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml index 071d8ec0c16..0db631f2408 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/wv/tax/income/subtractions/senior_citizen_disability/wv_senior_citizen_disability_deduction_person.yaml @@ -244,3 +244,27 @@ wv_senior_citizen_disability_deduction_person: 5_000 wv_529_plan_deduction: 1_000 wv_subtractions: 6_000 + +- name: Each spouse's $8,000 limit uses their own AGI, after their own IRA deduction. + period: 2024 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + employment_income: 6_000 + traditional_ira_contributions: 2_000 + spouse: + age: 66 + employment_income: 20_000 + tax_units: + tax_unit: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: WV + output: + # W. Va. Code 11-21-12(c): up to $8,000 of each eligible individual's + # federal AGI. Before: [5_000, 8_000]. + wv_senior_citizen_disability_deduction_person: [4_000, 8_000] diff --git a/policyengine_us/tools/general.py b/policyengine_us/tools/general.py index c5ac8779e6e..d2bd4f5fd5f 100644 --- a/policyengine_us/tools/general.py +++ b/policyengine_us/tools/general.py @@ -157,6 +157,34 @@ def person_share_of_tax_unit_amount(person, period, tax_unit_variable, person_va return where(filer, filer_share, own) +def filer_share(person, period, amounts): + """ + Each head's or spouse's share of the tax unit's total of `amounts`. + + `amounts` is a person-level array. A tax unit dependent's share is zero, + as their items belong on their own returns. When the head's and spouse's + amounts add up to zero or less, they share equally. + """ + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + filer_amounts = filer * amounts + total = tax_unit.sum(filer_amounts) + filers = tax_unit.sum(filer) + equal = np.divide( + filer.astype(float), + filers, + out=np.zeros_like(filers, dtype=float), + where=filers > 0, + ) + proportional = np.divide( + filer_amounts, + total, + out=np.zeros_like(total, dtype=float), + where=total > 0, + ) + return where(total > 0, proportional, equal) + + def sum_contained_tax_units(var, population, period): tax_unit = population.members.tax_unit.reference_entity values = tax_unit(var, period) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md index 90be8e0597e..2ff6334919d 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/README.md @@ -16,4 +16,19 @@ Above-the-line deductions are deductions made from gross income to arrive at adj | [223](https://www.law.cornell.edu/uscode/text/26/223) | Health savings accounts | | [469](https://www.law.cornell.edu/uscode/text/26/469) | Passive activity | | [911](https://www.law.cornell.edu/uscode/text/26/911) | Foreign earned income | -| [933](https://www.law.cornell.edu/uscode/text/26/933) | Income from Puerto Rico | \ No newline at end of file +| [933](https://www.law.cornell.edu/uscode/text/26/933) | Income from Puerto Rico | + +## Each person's deductions + +Several state taxes and benefits need each spouse's own AGI on a joint return: Kentucky, Montana (through 2023), Delaware, Virginia and the District of Columbia, where spouses can file separately on one form; Ohio's joint filing credit; West Virginia's senior citizen and disability deduction; and state withholding estimates. `adjusted_gross_income_person` is each person's gross income less `above_the_line_deductions_person`. The head's and spouse's amounts add up to `above_the_line_deductions` and `adjusted_gross_income`. + +| Deduction | Who takes it | +| --- | --- | +| Person-level deductions: IRA, early withdrawal penalty, student loan interest, adoption assistance, U.S. savings bonds for education | The person who has it. Student loan interest is the return's limited deduction, divided by the interest each spouse paid. | +| Self-employment tax, self-employed health insurance and retirement plans, alimony paid, capped educator expenses, HSA deductions | The person who has it, through `_person`. A directly supplied return total is reconciled in proportion to those amounts, or equally when neither spouse has an amount. | +| Business and capital losses (`loss_ald`) | `loss_ald_person`: the return's business loss after Section 461(l), by each spouse's own business losses; capital losses allowed against gains plus the net loss under the $3,000 joint limit (`limited_capital_loss_person`), by each spouse's own capital losses. A Form 4797 loss, recorded for the tax unit, counts equally for both. | +| Tuition and fees, domestic production, possession and Puerto Rico income | Divided equally, since the input is the tax unit's. | + +A tax unit dependent's deductions are on their own return, so they never lower the filer's AGI. The dependent's `above_the_line_deductions_person` is their own person-level deductions, other than amounts that are the filer's even when recorded on the dependent (`gov.irs.ald.filer_amounts_recorded_on_dependents`), which the head and spouse divide equally; the dependent's losses and tax-unit-only deductions are not modeled there. + +The states that read these amounts tell spouses to report their own income and deductions: Kentucky (KRS 141.180(3)), Delaware (30 Del. C. 1162(b)(1) and the PIT-RES line 1 instructions), the District (Schedule S, Calculation J), Virginia (Va. Code 58.1-324(C)(2) and the separate VAGI worksheet) and Montana through 2023 (ARM 42.15.206(1), which divides equally only an item not clearly attributable to one spouse). Montana's own allocation of business and capital losses (the elections of ARM 42.15.206(3) and a separate Section 461(l) limit) is not modeled yet, so `mt_loss_ald_reallocation` keeps the return's losses divided equally there. diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py new file mode 100644 index 00000000000..5688633357b --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/above_the_line_deductions_person.py @@ -0,0 +1,92 @@ +from policyengine_us.model_api import * + +# Above-the-line deductions recorded only for the tax unit, with no person-level +# amount to attribute them by. Their amount is divided equally between the head +# and spouse. +EQUALLY_DIVIDED_DEDUCTIONS = ( + # Tuition and fees (26 USC 222, through 2020) the taxpayer paid for + # themself, their spouse or a dependent, under one limit for the return. + "capped_qualified_tuition_expenses_ald", + # Domestic production activities (26 USC 199, through 2017), an input for + # the tax unit. + "domestic_production_ald", + # Income of a bona fide resident of a possession (26 USC 931) or of Puerto + # Rico (26 USC 933), inputs for the tax unit. + "specified_possession_income", + "puerto_rico_income", +) + + +class above_the_line_deductions_person(Variable): + value_type = float + entity = Person + label = "Above-the-line deductions for each person" + unit = USD + documentation = ( + "Each person's above-the-line deductions. The head's and spouse's " + "amounts divide the tax unit's above_the_line_deductions between them. " + "Each has their own person-level deductions, such as their IRA " + "deduction, educator expenses and early withdrawal penalty, and their " + "own part of each tax-unit deduction that has a person-level amount " + "(_person), such as self-employment tax, alimony paid, " + "capped educator expenses, HSA deductions and business and capital " + "losses. Supplied person amounts attribute an HSA deduction to its " + "account holders; if they are all zero, the return's amount is " + "divided equally. A deduction recorded only for the tax " + "unit is divided equally between them. A tax unit dependent's amount " + "is their own person-level deductions, for their own return, other " + "than amounts that are the filer's even when recorded on the " + "dependent (gov.irs.ald.filer_amounts_recorded_on_dependents), which " + "the head and spouse divide equally; the dependent's losses and " + "tax-unit-only deductions are not modeled here." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/62" + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filer = ~person("is_tax_unit_dependent", period) + p = parameters(period).gov.irs.ald + total = 0 + # Sum in a fixed order so float32 results do not depend on the hash + # seed. + for deduction in sorted(set(p.deductions)): + variable = person.entity.get_variable(deduction, check_existence=True) + if variable.entity.is_person: + # The person's own deduction. For the head and spouse it is + # their part of the return's sum over non-dependents. + amount = person(deduction, period) + if deduction in p.filer_amounts_recorded_on_dependents: + # The filer's amount even when recorded on a dependent, so + # it is not on the dependent's own return. Which of the + # head and spouse it belongs to is not recorded, so a + # dependent's amount is divided equally between them. + on_dependents = tax_unit.sum(~filer * amount) + equal_share = filer_share(person, period, 0 * amount) + amount = filer * amount + on_dependents * equal_share + total = total + amount + continue + return_amount = tax_unit(deduction, period) + person_variable = f"{deduction}_person" + if person.entity.get_variable(person_variable) is None: + equal_share = filer_share(person, period, 0 * return_amount) + total = total + return_amount * equal_share + continue + own = person(person_variable, period) + filer_own = filer * own + # The return's amount is the sum of the head's and spouse's own + # amounts, unless it was set directly. Any difference is divided + # in proportion to their own amounts, or equally when neither has + # one, so the parts always add up to the return's amount. + difference = return_amount - tax_unit.sum(filer_own) + share = filer_share(person, period, own) + total = total + where(filer, filer_own + difference * share, own) + # The head's and spouse's parts add up to above_the_line_deductions. + # If that is set directly, the difference is divided in proportion to + # their parts, or equally when neither has any. + filer_parts = filer * total + difference = tax_unit("above_the_line_deductions", period) - tax_unit.sum( + filer_parts + ) + share = filer_share(person, period, total) + return where(filer, filer_parts + difference * share, total) diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py index b454cbceff0..4d16942c2de 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/alimony_expense_ald_person.py @@ -7,15 +7,18 @@ class alimony_expense_ald_person(Variable): label = "Alimony expense ALD for each person" unit = USD documentation = ( - "Each person's own above-the-line deduction for alimony paid under a " - "divorce or separation instrument executed before 2019 (Schedule 1, " - "line 19a), on that person's own return." + "The alimony each person pays under a divorce or separation instrument " + "executed before 2019, which they deduct on their own return. " + "alimony_expense_ald adds the head's and spouse's amounts for the tax " + "unit's return." ) definition_period = YEAR - reference = "https://www.irs.gov/taxtopics/tc452" + reference = ( + "https://www.law.cornell.edu/uscode/text/26/215", + "https://www.irs.gov/taxtopics/tc452", + ) def formula(person, period, parameters): - divorce_year = person("divorce_year", period) - alimony_expense = person("alimony_expense", period) p = parameters(period).gov.irs.ald.alimony_expense - return alimony_expense * p.divorce_year_threshold.calc(divorce_year) + eligible = p.divorce_year_threshold.calc(person("divorce_year", period)) + return person("alimony_expense", period) * eligible diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py index befa79d8480..0c6299a3692 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/health_savings_account_ald_person.py @@ -13,8 +13,10 @@ class health_savings_account_ald_person(Variable): "health_savings_account_ald remains the deduction its return claims; " "these amounts attribute it to the head and spouse where a state " "reports each spouse's adjustments separately. When they do not sum to " - "it, it is shared in proportion to them, and when they are all zero, " - "the head takes it." + "it, the return's amount is shared in proportion to them. With no " + "person amounts, above_the_line_deductions_person divides it equally " + "between the head and spouse; Mississippi's HSA adjustment attributes " + "it to the head." ) definition_period = YEAR reference = ( diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py new file mode 100644 index 00000000000..49a019df77d --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/limited_capital_loss_person.py @@ -0,0 +1,20 @@ +from policyengine_us.model_api import * + + +class limited_capital_loss_person(Variable): + value_type = float + entity = Person + label = "Limited capital loss deduction for each person" + unit = USD + documentation = ( + "Each head's or spouse's part of the tax unit's net capital loss " + "deduction after the Section 1211(b) limit (limited_capital_loss), in " + "proportion to their own net capital losses. A tax unit dependent's " + "capital losses are on their own return, so their part is zero." + ) + definition_period = YEAR + reference = "https://www.law.cornell.edu/uscode/text/26/1211#b" + + def formula(person, period, parameters): + capital_share = filer_share(person, period, person("capital_losses", period)) + return person.tax_unit("limited_capital_loss", period) * capital_share diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py new file mode 100644 index 00000000000..e375bd7902a --- /dev/null +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/above_the_line_deductions/loss_ald_person.py @@ -0,0 +1,53 @@ +from policyengine_us.model_api import * + +# The person-level business, rental and estate amounts whose losses +# limited_business_loss deducts. +BUSINESS_INCOME_SOURCES = [ + "total_self_employment_income", + "farm_operations_income", + "rental_income", + "farm_rent_income", + "estate_income", + "partnership_s_corp_income", +] + + +class loss_ald_person(Variable): + value_type = float + entity = Person + label = "Business and capital loss ALD for each person" + unit = USD + documentation = ( + "Each head's or spouse's part of the tax unit's business and capital " + "loss deduction (loss_ald). The business loss after the Section 461(l) " + "limit (limited_business_loss) is divided in proportion to each " + "spouse's own business, farm, rental, estate and partnership losses; " + "a Form 4797 loss, recorded only for the tax unit, counts equally for " + "the head and spouse, as other_net_gain_gross_income counts the gain. " + "The rest of loss_ald, the capital loss deduction, is divided in " + "proportion to each spouse's own net capital losses. A tax unit " + "dependent's losses are on their own return, so their part is zero." + ) + definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/26/165", + "https://www.law.cornell.edu/uscode/text/26/461#l", + "https://www.law.cornell.edu/uscode/text/26/1211#b", + ) + + def formula(person, period, parameters): + tax_unit = person.tax_unit + business_loss = 0 + for source in BUSINESS_INCOME_SOURCES: + business_loss = business_loss + max_(0, -person(source, period)) + other_net_loss = max_(0, -tax_unit("other_net_gain", period)) + equal_share = filer_share(person, period, 0 * other_net_loss) + business_loss = business_loss + other_net_loss * equal_share + business_share = filer_share(person, period, business_loss) + capital_share = filer_share(person, period, person("capital_losses", period)) + # The business part comes first, so a loss_ald set directly below the + # business loss leaves no negative capital part. + loss_ald = tax_unit("loss_ald", period) + business_part = min_(tax_unit("limited_business_loss", period), loss_ald) + capital_part = max_(0, loss_ald - business_part) + return business_part * business_share + capital_part * capital_share diff --git a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py index 710424f0fdd..fe8ae118077 100644 --- a/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py +++ b/policyengine_us/variables/gov/irs/income/taxable_income/adjusted_gross_income/adjusted_gross_income_person.py @@ -6,6 +6,15 @@ class adjusted_gross_income_person(Variable): entity = Person label = "Federal adjusted gross income for each person" unit = USD + documentation = ( + "Each person's part of the tax unit's federal AGI: their own gross " + "income less their own above-the-line deductions " + "(above_the_line_deductions_person). On a joint return a deduction " + "that belongs to one spouse, such as their IRA deduction, lowers only " + "that spouse's AGI. The head's and spouse's amounts add up to the tax " + "unit's adjusted_gross_income. A tax unit dependent's income and " + "deductions are on their own return, so their amount here is zero." + ) definition_period = YEAR reference = "https://www.law.cornell.edu/uscode/text/26/62" @@ -14,34 +23,15 @@ def formula(person, period, parameters): # A tax unit dependent's deductions are on their own return, as # irs_gross_income leaves their income off this one. not_dependent = ~person("is_tax_unit_dependent", period) - # calculate ald sums by person - PERSON_ALDS = [ - "self_employment_tax_ald", - "self_employed_health_insurance_ald", - "self_employed_pension_contribution_ald", - ] - person_ald_vars = [f"{ald}_person" for ald in PERSON_ALDS] - ald_sum_person = not_dependent * add(person, period, person_ald_vars) - # split other alds evenly between head and spouse - p = parameters(period).gov.irs.ald - all_alds = p.deductions - other_alds = sorted(set(all_alds) - set(PERSON_ALDS)) - ald_sum_taxunit = tax_unit_non_dep_add( - person.tax_unit, - period, - other_alds, - include_dependents=p.filer_amounts_recorded_on_dependents, - ) - is_head = person("is_tax_unit_head", period) - is_spouse = person("is_tax_unit_spouse", period) - fstatus = person.tax_unit("filing_status", period) - frac = where(fstatus == fstatus.possible_values.JOINT, 0.5, 1.0) - ald_sum_taxunit_shared = (is_head | is_spouse) * ald_sum_taxunit * frac - # calculate AGI by person - agi = gross_income - ald_sum_person - ald_sum_taxunit_shared + deductions = person("above_the_line_deductions_person", period) + agi = gross_income - not_dependent * deductions if parameters(period).gov.contrib.ubi_center.basic_income.taxable: basic_income = person.tax_unit("basic_income", period) # split basic income evenly between head and spouse + is_head = person("is_tax_unit_head", period) + is_spouse = person("is_tax_unit_spouse", period) + fstatus = person.tax_unit("filing_status", period) + frac = where(fstatus == fstatus.possible_values.JOINT, 0.5, 1.0) basic_income_shared = (is_head | is_spouse) * basic_income * frac agi += basic_income_shared return agi diff --git a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py index 08bf9789851..ff953ba0fcd 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/additions/dc_self_employment_loss_addition.py @@ -22,16 +22,13 @@ def formula(person, period, parameters): 0, -person("total_self_employment_income", period) ) loss_taxunit = person.tax_unit.sum(loss_person) - # Cap at the business losses actually deducted in federal AGI: loss_ald - # less its capital loss parts (self-employment, farm, rental, - # partnership, estate and other business losses after section 461(l)). + # Cap at the business losses actually deducted in federal AGI after + # section 461(l). Isolate the business part as loss_ald_person does, + # so a loss_ald set directly is divided the same way. loss_ald = person.tax_unit("loss_ald", period) - capital_loss_in_ald = add( - person.tax_unit, - period, - ["capital_losses_allowed_against_gains", "limited_capital_loss"], + business_loss_in_ald = min_( + person.tax_unit("limited_business_loss", period), loss_ald ) - business_loss_in_ald = max_(0, loss_ald - capital_loss_in_ald) effective_loss = min_(loss_taxunit, business_loss_in_ald) p = parameters(period).gov.states.dc.tax.income.additions addition_taxunit = max_(0, effective_loss - p.self_employment_loss.threshold) diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py new file mode 100644 index 00000000000..b1424610055 --- /dev/null +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_separate_capital_loss_adjustment.py @@ -0,0 +1,64 @@ +from policyengine_us.model_api import * + + +class dc_separate_capital_loss_adjustment(Variable): + value_type = float + entity = Person + label = "DC capital loss adjustment when spouses file separately on the same return" + unit = USD + documentation = ( + "For the head and spouse of a joint federal return, the amount added " + "to their DC AGI when they file separately on the same DC return. " + "Each spouse's federal AGI includes their share of the couple's " + "capital loss deduction (the capital part of loss_ald_person), in " + "proportion to their own capital losses. " + "Filing separately, each spouse instead deducts their own capital " + "losses up to their own capital gains plus $1,500, the limit for " + "married people filing separately. The adjustment is the first amount " + "less the second. It does not apply to the joint DC computation, " + "which keeps the federal joint deduction." + ) + definition_period = YEAR + defined_for = StateCode.DC + reference = ( + # Line c: "The maximum allowable annual capital loss claim is $3000 + # ($1500 if married or registered domestic partner filing + # separately)." + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2024_D40_Booklet_062325.pdf#page=16", + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2025_D40_Book_082026_v1.pdf#page=19", + # Schedule S, Calculation J: each spouse's portion of federal AGI. + "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2025_D40_Book_082026_v1.pdf#page=51", + "https://www.law.cornell.edu/uscode/text/26/1211#b", + ) + + def formula(person, period, parameters): + tax_unit = person.tax_unit + filing_status = tax_unit("filing_status", period) + joint = filing_status == filing_status.possible_values.JOINT + head_or_spouse = person("is_tax_unit_head_or_spouse", period) + p = parameters(period).gov.irs + capital_losses = person("capital_losses", period) + # The capital part of the spouse's loss_ald_person, which is what + # their federal AGI includes: loss_ald less the business loss, in + # proportion to each spouse's own capital losses. When computed, that + # is the losses against gains plus the net loss up to $3,000; it also + # follows a loss_ald set directly. + loss_ald = tax_unit("loss_ald", period) + business_part = min_(tax_unit("limited_business_loss", period), loss_ald) + capital_part = max_(0, loss_ald - business_part) + federal = capital_part * filer_share(person, period, capital_losses) + # Filing separately, the spouse's own capital gains, including capital + # gain distributions, absorb their own losses first, and the net loss + # deductible is at most $1,500. As in + # capital_losses_allowed_against_gains, there is no capital loss + # deduction when a reform takes capital gains out of gross income. + sources = p.gross_income.sources + separate = 0 * federal + if "capital_gains" in sources: + gains = 0 + for source in ["capital_gains", "non_sch_d_capital_gains"]: + if source in sources: + gains = gains + max_(0, person(source, period)) + separate_limit = p.ald.loss.capital.max["SEPARATE"] + separate = min_(capital_losses, gains + separate_limit) + return joint * head_or_spouse * (federal - separate) diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py index dba44f51fc1..e9663abc7e2 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_indiv.py @@ -12,5 +12,11 @@ class dc_taxable_income_indiv(Variable): "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2022_D-40_Booklet_Final_blk_01_23_23_Ordc.pdf#page=34", ) defined_for = StateCode.DC - adds = ["dc_agi"] + documentation = ( + "Each spouse's DC taxable income when they file separately on the " + "same return: their DC AGI, with their own capital losses limited as " + "on a separate return (dc_separate_capital_loss_adjustment), less " + "their part of the DC deduction." + ) + adds = ["dc_agi", "dc_separate_capital_loss_adjustment"] subtracts = ["dc_deduction_indiv"] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py index f44e770471a..d2350341577 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/dc_taxable_income_joint.py @@ -12,4 +12,7 @@ class dc_taxable_income_joint(Variable): "https://otr.cfo.dc.gov/sites/default/files/dc/sites/otr/publication/attachments/2022_D-40_Booklet_Final_blk_01_23_23_Ordc.pdf#page=34", ) defined_for = StateCode.DC - adds = ["dc_taxable_income_indiv"] + # The couple's DC AGI less their DC deduction. The separate filing + # capital loss limit (dc_separate_capital_loss_adjustment) does not apply. + adds = ["dc_agi"] + subtracts = ["dc_deduction_indiv"] diff --git a/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py b/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py index d3bd77568cd..614ff509c74 100644 --- a/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py +++ b/policyengine_us/variables/gov/states/dc/tax/income/deductions/dc_deduction_indiv.py @@ -15,7 +15,11 @@ class dc_deduction_indiv(Variable): def formula(person, period, parameters): tax_unit_deduction = person.tax_unit("dc_deduction_joint", period) - person_agi = person("dc_agi", period) + # Each spouse's DC AGI when filing separately. The parts add up to the + # joint deduction, so the joint computation is unaffected. + person_agi = person("dc_agi", period) + person( + "dc_separate_capital_loss_adjustment", period + ) head = person("is_tax_unit_head", period) spouse = person("is_tax_unit_spouse", period) tax = parameters(period).gov.states.dc.tax.income diff --git a/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py b/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py index 5286ea249d3..05e87080788 100644 --- a/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py +++ b/policyengine_us/variables/gov/states/ky/tax/income/ky_federal_agi.py @@ -24,8 +24,9 @@ def formula(person, period, parameters): return federal_agi # KRS 141.016(3): income and business deductions belong to the - # spouse to whom they are attributable. Federal person AGI instead - # shares the joint loss deduction; undo that sharing for Kentucky. + # spouse to whom they are attributable. Undo the loss allocation + # actually included in federal person AGI before applying Kentucky's + # allocation, so an already-owned loss is not reassigned twice. filing_status = person.tax_unit("filing_status", period) is_filer = person("is_tax_unit_head", period) | person( "is_tax_unit_spouse", period @@ -73,4 +74,11 @@ def allocate(owned, allowed): owned_loss = allocate(owned_business_loss, business_loss) + allocate( owned_capital_loss, capital_loss ) - return federal_agi + share * loss - owned_loss + # Match above_the_line_deductions_person's reconciliation when supplied + # person loss amounts differ from the return's loss deduction. Keep + # dependent amounts off the parents' return. + filer_loss = not_dependent * person("loss_ald_person", period) + federal_loss = filer_loss + ( + loss - person.tax_unit.sum(filer_loss) + ) * filer_share(person, period, filer_loss) + return federal_agi + federal_loss - owned_loss diff --git a/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py b/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py index 340ca08b7ea..7d3b59eeb26 100644 --- a/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py +++ b/policyengine_us/variables/gov/states/mi/tax/income/mi_withheld_income_tax.py @@ -14,4 +14,6 @@ def formula(person, period, parameters): p = parameters(period).gov.states.mi.tax.income # The MI standard deduction only applys for elderly # Wo do not apply deductions here - return p.rate * agi + # A spouse's AGI can be negative, as with a business loss; withholding + # is never negative. + return p.rate * max_(agi, 0) 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..ff6e632e58b 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 @@ -10,7 +10,11 @@ class mt_agi_indiv(Variable): defined_for = "mt_married_filing_separately_on_same_return_eligible" def formula(person, period, parameters): - agi = person("adjusted_gross_income_person", period) + # Each spouse's federal AGI, with the return's business and capital + # losses divided equally between them (mt_loss_ald_reallocation). + agi = person("adjusted_gross_income_person", period) + person( + "mt_loss_ald_reallocation", period + ) additions = person("mt_additions", period) subtractions = person("mt_subtractions", period) reduced_agi = max_(agi + additions - subtractions, 0) diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py index 42fc2d02191..6e172bccefa 100644 --- a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_applicable_ald_deductions.py @@ -6,11 +6,35 @@ class mt_applicable_ald_deductions(Variable): entity = Person label = "Montana applicable above-the-line deductions " unit = USD + documentation = ( + "Each spouse's own federal adjustments to income, for their column of " + "the Taxable Social Security Benefits Schedule (Form 2, line 7, before " + "removing student loan interest). Through 2023, while spouses could " + "file separately on the same form." + ) definition_period = YEAR - defined_for = StateCode.MT + defined_for = "mt_married_filing_separately_on_same_return_eligible" + reference = ( + # 2023 Form 2 instructions, page 6: deductions attributable to only one + # spouse, the student loan interest deduction included, must be claimed + # by that spouse. + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=13", + # MCA 15-30-2110(6) to (9) (2021), in force through tax year 2023. + "https://mca.legmt.gov/bills/2022/mca/title_0150/chapter_0300/part_0210/section_0100/0150-0300-0210-0100.html", + # Taxable Social Security Benefits Schedule, line 7. + "https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2023_Montana_Individual_Income_Tax_Return_Form_2.pdf#page=6", + ) def formula(person, period, parameters): - ald_deductions = person.tax_unit("above_the_line_deductions", period) - total_deductions = ald_deductions / person.tax_unit("head_spouse_count", period) + # Items attributable to one spouse are claimed by that spouse (ARM + # 42.15.206(1)), and an IRA deduction by the spouse who made the + # contribution (former MCA 15-30-2110(8)). MCA 15-30-2110(9)(a) also + # lets spouses split student loan interest equally or by AGI; the + # instructions have each spouse claim their own, which is what this + # uses. The return's business and capital losses are divided equally + # (mt_loss_ald_reallocation), pending Montana's own loss allocation. head_or_spouse = person("is_tax_unit_head_or_spouse", period) - return head_or_spouse * total_deductions + deductions = person("above_the_line_deductions_person", period) - person( + "mt_loss_ald_reallocation", period + ) + return head_or_spouse * deductions diff --git a/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py new file mode 100644 index 00000000000..4d3856b88ab --- /dev/null +++ b/policyengine_us/variables/gov/states/mt/tax/income/base/mt_loss_ald_reallocation.py @@ -0,0 +1,37 @@ +from policyengine_us.model_api import * + + +class mt_loss_ald_reallocation(Variable): + value_type = float + entity = Person + label = "Montana reallocation of business and capital losses between spouses" + unit = USD + documentation = ( + "Each spouse's federal business and capital loss deduction " + "(loss_ald_person) less an equal share of the return's loss_ald. " + "Montana's own allocation of losses between spouses who file " + "separately on the same form is not modeled here: the capital loss " + "elections of ARM 42.15.206(3), and the Section 461(l) limit " + "recomputed with the single filer's threshold. Until it is, Montana " + "divides the return's losses equally between the spouses, as " + "PolicyEngine did before each spouse's own deductions were " + "attributed to them. Adding this amount to a spouse's federal AGI " + "gives that equal division. Other deductions stay with the spouse " + "who has them. It applies through 2023: from 2024 spouses no longer " + "file separately on the same form, and Montana no longer allocates " + "losses between them." + ) + definition_period = YEAR + defined_for = "mt_married_filing_separately_on_same_return_eligible" + reference = ( + "https://sosmt.gov/wp-content/uploads/attachments/MAR10-05.pdf#page=35", + "https://revenue.mt.gov/files/forms/Montana-Individual-Income-Tax-Return-Form-2-Instructions/2023_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=23", + # 2024 Form 2 instructions, page i: "Capital losses, passive losses, + # and excess business losses will no longer be allocated by spouse". + "https://revenue.mt.gov/files/Forms/Montana-Individual-Income-Tax-Return-Form-2/2024_Montana_Individual_Income_Tax_Return_Form_2_Instructions.pdf#page=5", + ) + + def formula(person, period, parameters): + loss_ald = person.tax_unit("loss_ald", period) + equal_share = filer_share(person, period, 0 * loss_ald) + return person("loss_ald_person", period) - loss_ald * equal_share diff --git a/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py b/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py index 2081a74d973..fda42e111ac 100644 --- a/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py +++ b/policyengine_us/variables/gov/states/pa/tax/income/pa_withheld_income_tax.py @@ -14,4 +14,6 @@ def formula(person, period, parameters): # Pennsylvania does not have standard deductions, personal exemptions, or itemized deductions. # Also, They do not use the federal standard deduction amounts. p = parameters(period).gov.states.pa.tax.income - return agi * p.rate + # A spouse's AGI can be negative, as with a business loss; withholding + # is never negative. + return max_(agi, 0) * p.rate diff --git a/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py b/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py index bbf929fa323..a8e6439e271 100644 --- a/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py +++ b/policyengine_us/variables/gov/states/va/tax/income/va_agi_person.py @@ -22,9 +22,20 @@ def formula(person, period, parameters): # taxable income, so the couple should not qualify for the adjustment. person_subtractions = person("va_subtractions_person", period) # Additions are only defined at the tax-unit level, so prorate them by - # federal AGI share. + # federal AGI share. A spouse with negative federal AGI gets no share, + # so neither share is negative or more than the whole. additions = person.tax_unit("va_additions", period) - total_federal_agi = person.tax_unit.sum(person_fagi) - prorate = where(total_federal_agi > 0, person_fagi / total_federal_agi, 0) + positive_fagi = max_(person_fagi, 0) + total_federal_agi = person.tax_unit.sum(positive_fagi) + prorate = where( + total_federal_agi > 0, + np.divide( + positive_fagi, + total_federal_agi, + out=np.zeros_like(total_federal_agi, dtype=float), + where=total_federal_agi > 0, + ), + 0, + ) person_additions = additions * prorate return person_fagi + person_additions - person_subtractions