Skip to content

Commit 7544c4a

Browse files
Route pension and Social Security to older spouse in mixed-age households (#925)
* Use age-aware allocation for Social Security input Closes #924. `gssi` is a household-aggregate TAXSIM input with no per-spouse pair field. The previous unconditional 50/50 split pushed half the SS onto the younger spouse in mixed-age households, eliminating per-person state elderly exclusions (CO, MD) on that half. Apply the same age-gated rule already used for pensions: split only when both spouses are 60+, otherwise keep the full amount on the primary filer. For taxsim #924 (CO, primary 75 / spouse 40, $94K gssi), this brings CO subtractions to $80,220 — matching the TaxAct DR 0104AD. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Allocate age-gated income to older spouse in mixed-age households In mixed-age MFJ couples, route pension and Social Security to the older spouse (matching the original intent in #774) rather than unconditionally to the primary filer. The previous primary-filer rule missed the #774 case (page=54, sage=55) where the qualifying spouse is the secondary filer. Both-60+ still splits 50/50; both-under-60 still defaults to primary. Verified: - #924 CO (primary 75, spouse 40, $94K gssi): SS on primary, co_subtractions = $80,221 vs TaxAct $80,220 ($1 rounding). - #774 IA (primary 54, spouse 55, $262K pension): pension on spouse, ia_income_tax = $0 (full eligible exclusion applied). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Apply ruff format Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6750454 commit 7544c4a

3 files changed

Lines changed: 94 additions & 34 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
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.

policyengine_taxsim/runners/policyengine_runner.py

Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -190,17 +190,19 @@ def _initialize_dataset_structure(self) -> dict:
190190
"long_term_capital_gains",
191191
"partnership_s_corp_income",
192192
"short_term_capital_gains",
193-
"social_security_retirement",
194193
}
195194
)
196195

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

205207
@staticmethod
206208
def _make_primary_split(source_field):
@@ -223,34 +225,48 @@ def accessor(row):
223225
return accessor
224226

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

229235
def accessor(row):
230236
value = float(row.get(source_field, 0))
231237
if int(row.get("mstat", 1)) != 2:
232238
return value
239+
page = int(row.get("page", 0))
240+
sage = int(row.get("sage", 0))
233241
both_old = (
234-
int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE
235-
and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE
242+
page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE
236243
)
237-
return value / 2 if both_old else value
244+
if both_old:
245+
return value / 2
246+
primary_is_older_or_equal = page >= sage
247+
return value if primary_is_older_or_equal else 0.0
238248

239249
return accessor
240250

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

245257
def accessor(row):
246258
value = float(row.get(source_field, 0))
247259
if int(row.get("mstat", 1)) != 2:
248260
return 0.0
261+
page = int(row.get("page", 0))
262+
sage = int(row.get("sage", 0))
249263
both_old = (
250-
int(row.get("page", 0)) >= cls._PENSION_SPLIT_AGE
251-
and int(row.get("sage", 0)) >= cls._PENSION_SPLIT_AGE
264+
page >= cls._AGE_GATED_SPLIT_AGE and sage >= cls._AGE_GATED_SPLIT_AGE
252265
)
253-
return value / 2 if both_old else 0.0
266+
if both_old:
267+
return value / 2
268+
spouse_is_strictly_older = sage > page
269+
return value if spouse_is_strictly_older else 0.0
254270

255271
return accessor
256272

@@ -309,12 +325,14 @@ def _get_taxsim_to_pe_variable_mapping(self) -> dict:
309325
"dependent": 0.0,
310326
"default": 0.0,
311327
}
312-
elif pe_var == self._PENSION_FIELD:
313-
# Pension requires the age-aware split (both
314-
# spouses must be 60+ to share the exclusion).
328+
elif pe_var in self._AGE_GATED_FIELDS:
329+
# Pension and Social Security require the
330+
# age-aware split (both spouses must be 60+
331+
# to share, otherwise it stays with the primary
332+
# filer so age-based exclusions aren't lost).
315333
variable_mapping[pe_var] = {
316-
"primary": self._make_pension_primary(taxsim_var),
317-
"spouse": self._make_pension_spouse(taxsim_var),
334+
"primary": self._make_age_gated_primary(taxsim_var),
335+
"spouse": self._make_age_gated_spouse(taxsim_var),
318336
"dependent": 0.0,
319337
"default": 0.0,
320338
}

tests/test_spouse_income_splitting.py

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
exclusion, DE pension exclusion) apply to both spouses. See issues #665
77
and #838.
88
9-
Pension income uses an age-aware rule: split only when both spouses are
10-
60 or older; otherwise the full amount stays with the primary filer to
11-
preserve state per-person elderly exclusions (GA, MD, NJ, etc.).
9+
Pension and Social Security income use an age-aware rule: split only when
10+
both spouses are 60 or older; otherwise the full amount stays with the
11+
primary filer to preserve state per-person elderly exclusions (CO, GA, MD,
12+
NJ, etc.). See issue #924.
1213
"""
1314

1415
import numpy as np
@@ -54,7 +55,6 @@ def _base_mfj_record(taxsimid=1, **overrides):
5455
("dividends", "qualified_dividend_income", 80000),
5556
("ltcg", "long_term_capital_gains", 60000),
5657
("stcg", "short_term_capital_gains", 30000),
57-
("gssi", "social_security_retirement", 40000),
5858
("scorp", "partnership_s_corp_income", 50000),
5959
],
6060
)
@@ -107,25 +107,66 @@ def test_pension_splits_when_both_spouses_are_60_plus():
107107
np.testing.assert_allclose(values, [20000.0, 20000.0])
108108

109109

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

119119

120+
def test_pension_goes_to_spouse_when_spouse_is_older():
121+
"""Pension: mixed-age, spouse older — assign full pension to spouse
122+
so the qualifying filer claims the exclusion. See taxsim issue #774:
123+
IA pension subtraction is age-55+, and with page=54 / sage=55 the
124+
older spouse should hold the pension."""
125+
df = pd.DataFrame([_base_mfj_record(page=54, sage=55, pensions=40000)])
126+
values = _run_allocation(df, "taxable_private_pension_income")
127+
np.testing.assert_allclose(values, [0.0, 40000.0])
128+
129+
120130
def test_pension_stays_on_primary_when_both_under_60():
121131
"""Pension: when neither spouse is 60+, state elderly exclusions
122-
do not apply. Keep on primary to match pre-fix behavior for the
123-
non-elderly case."""
132+
do not apply. Default to primary."""
124133
df = pd.DataFrame([_base_mfj_record(page=45, sage=45, pensions=30000)])
125134
values = _run_allocation(df, "taxable_private_pension_income")
126135
np.testing.assert_allclose(values, [30000.0, 0.0])
127136

128137

138+
def test_gssi_splits_when_both_spouses_are_60_plus():
139+
"""gssi: when both spouses ≥ 60, allocate 50/50 so both qualify
140+
for state per-person SS exclusions."""
141+
df = pd.DataFrame([_base_mfj_record(page=65, sage=65, gssi=40000)])
142+
values = _run_allocation(df, "social_security_retirement")
143+
np.testing.assert_allclose(values, [20000.0, 20000.0])
144+
145+
146+
def test_gssi_goes_to_older_spouse_for_mixed_age_couple():
147+
"""gssi: mixed-age (primary 75, spouse 40), keep full SS on the older
148+
primary so age-based state exclusions (CO, MD) reach the qualifying
149+
filer. See taxsim issue #924."""
150+
df = pd.DataFrame([_base_mfj_record(page=75, sage=40, gssi=40000)])
151+
values = _run_allocation(df, "social_security_retirement")
152+
np.testing.assert_allclose(values, [40000.0, 0.0])
153+
154+
155+
def test_gssi_goes_to_spouse_when_spouse_is_older():
156+
"""gssi: mixed-age, spouse older — assign all SS to spouse so they
157+
claim the age-based subtraction."""
158+
df = pd.DataFrame([_base_mfj_record(page=40, sage=75, gssi=40000)])
159+
values = _run_allocation(df, "social_security_retirement")
160+
np.testing.assert_allclose(values, [0.0, 40000.0])
161+
162+
163+
def test_gssi_stays_on_primary_when_both_under_60():
164+
"""gssi: when neither spouse is 60+, default to primary."""
165+
df = pd.DataFrame([_base_mfj_record(page=45, sage=45, gssi=40000)])
166+
values = _run_allocation(df, "social_security_retirement")
167+
np.testing.assert_allclose(values, [40000.0, 0.0])
168+
169+
129170
def test_de_elderly_pension_matches_issue_838():
130171
"""End-to-end: DE elderly couple with pension income should produce
131172
a state tax close to TAXSIM after the 50/50 split. Issue #838 reported

0 commit comments

Comments
 (0)