diff --git a/changelog.d/gssi-age-aware-split.fixed.md b/changelog.d/gssi-age-aware-split.fixed.md new file mode 100644 index 0000000..b336cd0 --- /dev/null +++ b/changelog.d/gssi-age-aware-split.fixed.md @@ -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. diff --git a/policyengine_taxsim/runners/policyengine_runner.py b/policyengine_taxsim/runners/policyengine_runner.py index 13dcfa3..dfa3d5d 100644 --- a/policyengine_taxsim/runners/policyengine_runner.py +++ b/policyengine_taxsim/runners/policyengine_runner.py @@ -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): @@ -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 @@ -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, } diff --git a/tests/test_spouse_income_splitting.py b/tests/test_spouse_income_splitting.py index ecb5666..4789641 100644 --- a/tests/test_spouse_income_splitting.py +++ b/tests/test_spouse_income_splitting.py @@ -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 @@ -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), ], ) @@ -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