Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog.d/gssi-age-aware-split.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allocate Social Security (`gssi`) using the same age-aware rule as pensions: in mixed-age MFJ households, keep the full amount on the primary filer so state per-person SS exclusions (CO, MD, etc.) reach the qualifying spouse.
64 changes: 41 additions & 23 deletions policyengine_taxsim/runners/policyengine_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,17 +190,19 @@ def _initialize_dataset_structure(self) -> dict:
"long_term_capital_gains",
"partnership_s_corp_income",
"short_term_capital_gains",
"social_security_retirement",
}
)

# Pension income is split only when both spouses meet the state-pension
# eligibility age (60, the lowest common threshold across states such
# as DE). When ages are mixed or both under 60, pension stays with the
# primary filer so the allocation still matches TAXSIM for records where
# age-based state rules don't apply.
_PENSION_FIELD = "taxable_private_pension_income"
_PENSION_SPLIT_AGE = 60
# Pension and Social Security income are split only when both spouses
# meet the state-level age threshold (60, the lowest common threshold
# across states such as DE). In mixed-age households (e.g. spouse under
# 60 while filer is elderly), the income stays with the primary filer so
# age-based state exclusions aren't lost on the younger spouse's
# incorrectly-allocated share.
_AGE_GATED_FIELDS = frozenset(
{"taxable_private_pension_income", "social_security_retirement"}
)
_AGE_GATED_SPLIT_AGE = 60

@staticmethod
def _make_primary_split(source_field):
Expand All @@ -223,34 +225,48 @@ def accessor(row):
return accessor

@classmethod
def _make_pension_primary(cls, source_field):
"""Pension stays on primary unless both spouses are 60 or older."""
def _make_age_gated_primary(cls, source_field):
"""Allocate age-gated income (pension, gssi) to the primary filer's
share. Both spouses 60+: 50/50. Mixed-age: assign entirely to the
older spouse so age-based state exclusions reach the qualifying
filer (see taxsim issues #774 for pensions, #924 for gssi). Both
under 60: all to primary by default."""

def accessor(row):
value = float(row.get(source_field, 0))
if int(row.get("mstat", 1)) != 2:
return value
page = int(row.get("page", 0))
sage = int(row.get("sage", 0))
both_old = (
int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE
and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE
page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE
)
return value / 2 if both_old else value
if both_old:
return value / 2
primary_is_older_or_equal = page >= sage
return value if primary_is_older_or_equal else 0.0

return accessor

@classmethod
def _make_pension_spouse(cls, source_field):
"""Spouse only receives a pension share if both spouses are 60+."""
def _make_age_gated_spouse(cls, source_field):
"""Spouse's share of age-gated income. Mirror of `_make_age_gated_primary`:
50/50 if both 60+, full amount to spouse only when spouse is strictly
older than primary in mixed-age cases."""

def accessor(row):
value = float(row.get(source_field, 0))
if int(row.get("mstat", 1)) != 2:
return 0.0
page = int(row.get("page", 0))
sage = int(row.get("sage", 0))
both_old = (
int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE
and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE
page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE
)
return value / 2 if both_old else 0.0
if both_old:
return value / 2
spouse_is_strictly_older = sage > page
return value if spouse_is_strictly_older else 0.0

return accessor

Expand Down Expand Up @@ -309,12 +325,14 @@ def _get_taxsim_to_pe_variable_mapping(self) -> dict:
"dependent": 0.0,
"default": 0.0,
}
elif pe_var == self._PENSION_FIELD:
# Pension requires the age-aware split (both
# spouses must be 60+ to share the exclusion).
elif pe_var in self._AGE_GATED_FIELDS:
# Pension and Social Security require the
# age-aware split (both spouses must be 60+
# to share, otherwise it stays with the primary
# filer so age-based exclusions aren't lost).
variable_mapping[pe_var] = {
"primary": self._make_pension_primary(taxsim_var),
"spouse": self._make_pension_spouse(taxsim_var),
"primary": self._make_age_gated_primary(taxsim_var),
"spouse": self._make_age_gated_spouse(taxsim_var),
"dependent": 0.0,
"default": 0.0,
}
Expand Down
63 changes: 52 additions & 11 deletions tests/test_spouse_income_splitting.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@
exclusion, DE pension exclusion) apply to both spouses. See issues #665
and #838.

Pension income uses an age-aware rule: split only when both spouses are
60 or older; otherwise the full amount stays with the primary filer to
preserve state per-person elderly exclusions (GA, MD, NJ, etc.).
Pension and Social Security income use an age-aware rule: split only when
both spouses are 60 or older; otherwise the full amount stays with the
primary filer to preserve state per-person elderly exclusions (CO, GA, MD,
NJ, etc.). See issue #924.
"""

import numpy as np
Expand Down Expand Up @@ -54,7 +55,6 @@ def _base_mfj_record(taxsimid=1, **overrides):
("dividends", "qualified_dividend_income", 80000),
("ltcg", "long_term_capital_gains", 60000),
("stcg", "short_term_capital_gains", 30000),
("gssi", "social_security_retirement", 40000),
("scorp", "partnership_s_corp_income", 50000),
],
)
Expand Down Expand Up @@ -107,25 +107,66 @@ def test_pension_splits_when_both_spouses_are_60_plus():
np.testing.assert_allclose(values, [20000.0, 20000.0])


def test_pension_stays_on_primary_for_mixed_age_couple():
"""Pension: when only one spouse is 60+, keep full pension on primary
so the qualifying spouse claims the per-person exclusion. Splitting
50/50 would push half the pension onto the under-60 spouse and
eliminate half the exclusion (see #838 validation)."""
def test_pension_goes_to_primary_when_primary_is_older():
"""Pension: mixed-age, primary older — keep full pension on primary
so they claim the per-person elderly exclusion. Splitting 50/50 would
push half onto the under-60 spouse and lose half the exclusion
(see #838 validation)."""
df = pd.DataFrame([_base_mfj_record(page=70, sage=50, pensions=40000)])
values = _run_allocation(df, "taxable_private_pension_income")
np.testing.assert_allclose(values, [40000.0, 0.0])


def test_pension_goes_to_spouse_when_spouse_is_older():
"""Pension: mixed-age, spouse older — assign full pension to spouse
so the qualifying filer claims the exclusion. See taxsim issue #774:
IA pension subtraction is age-55+, and with page=54 / sage=55 the
older spouse should hold the pension."""
df = pd.DataFrame([_base_mfj_record(page=54, sage=55, pensions=40000)])
values = _run_allocation(df, "taxable_private_pension_income")
np.testing.assert_allclose(values, [0.0, 40000.0])


def test_pension_stays_on_primary_when_both_under_60():
"""Pension: when neither spouse is 60+, state elderly exclusions
do not apply. Keep on primary to match pre-fix behavior for the
non-elderly case."""
do not apply. Default to primary."""
df = pd.DataFrame([_base_mfj_record(page=45, sage=45, pensions=30000)])
values = _run_allocation(df, "taxable_private_pension_income")
np.testing.assert_allclose(values, [30000.0, 0.0])


def test_gssi_splits_when_both_spouses_are_60_plus():
"""gssi: when both spouses ≥ 60, allocate 50/50 so both qualify
for state per-person SS exclusions."""
df = pd.DataFrame([_base_mfj_record(page=65, sage=65, gssi=40000)])
values = _run_allocation(df, "social_security_retirement")
np.testing.assert_allclose(values, [20000.0, 20000.0])


def test_gssi_goes_to_older_spouse_for_mixed_age_couple():
"""gssi: mixed-age (primary 75, spouse 40), keep full SS on the older
primary so age-based state exclusions (CO, MD) reach the qualifying
filer. See taxsim issue #924."""
df = pd.DataFrame([_base_mfj_record(page=75, sage=40, gssi=40000)])
values = _run_allocation(df, "social_security_retirement")
np.testing.assert_allclose(values, [40000.0, 0.0])


def test_gssi_goes_to_spouse_when_spouse_is_older():
"""gssi: mixed-age, spouse older — assign all SS to spouse so they
claim the age-based subtraction."""
df = pd.DataFrame([_base_mfj_record(page=40, sage=75, gssi=40000)])
values = _run_allocation(df, "social_security_retirement")
np.testing.assert_allclose(values, [0.0, 40000.0])


def test_gssi_stays_on_primary_when_both_under_60():
"""gssi: when neither spouse is 60+, default to primary."""
df = pd.DataFrame([_base_mfj_record(page=45, sage=45, gssi=40000)])
values = _run_allocation(df, "social_security_retirement")
np.testing.assert_allclose(values, [40000.0, 0.0])


def test_de_elderly_pension_matches_issue_838():
"""End-to-end: DE elderly couple with pension income should produce
a state tax close to TAXSIM after the 50/50 split. Issue #838 reported
Expand Down
Loading