diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md new file mode 100644 index 00000000000..327aa10ade4 --- /dev/null +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -0,0 +1 @@ +Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Under SSI, apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep MN deeming budgets consistent. Connecticut SSP and Maryland PAA count an applicant's own earnings without that offset, which no Connecticut or Maryland text adopts, so a spouse's loss no longer reduces them. Floor New Hampshire adult assistance's combined self-employment income before adding wages. For Kentucky State Supplementation, read 921 KAR 2:015 section 8(1) and Volume IVA MS 2450 B.4, C.7 and C.8(b) to count each business's profit at no less than zero: no Kentucky text authorizes losses against other income, and C.8(b) bars transfers between businesses. Losses reduce neither another business's profit, wages, nor a spouse's earnings, unlike SSI under POMS SI 00820.210 A.2 and B.2.b-c. Keep Minnesota gross-income premium restoration from creating income while business losses remain unabsorbed. In the SSI substantial gainful activity test, a self-employment loss from any business source no longer reduces wages (POMS DI 10505.015 D.3); losses still net between businesses. diff --git a/policyengine_us/parameters/gov/ssa/ssi/income/sources/earned.yaml b/policyengine_us/parameters/gov/ssa/ssi/income/sources/earned.yaml index f4701537b0f..c547b944902 100644 --- a/policyengine_us/parameters/gov/ssa/ssi/income/sources/earned.yaml +++ b/policyengine_us/parameters/gov/ssa/ssi/income/sources/earned.yaml @@ -1,11 +1,17 @@ -description: The US counts these income sources as unearned income for Supplemental Security Income. +description: The US counts these income sources as earned income for Supplemental Security Income. values: 1975-01-01: - employment_income - self_employment_income - sstb_self_employment_income + - farm_operations_income + - partnership_self_employment_net_earnings metadata: label: SSI earned income sources reference: - title: 20 CFR § 416.1110 - What is earned income. href: https://www.law.cornell.edu/cfr/text/20/416.1110 + - title: 20 CFR § 404.1081 - Figuring your net earnings from self-employment. + href: https://www.ssa.gov/OP_Home/cfr20/404/404-1081.htm + - title: POMS SI 00820.200 - Net Earnings from Self-Employment (NESE). + href: https://secure.ssa.gov/poms.nsf/lnx/0500820200 diff --git a/policyengine_us/parameters/gov/states/ky/dcbs/ssp/earned/self_employment_sources.yaml b/policyengine_us/parameters/gov/states/ky/dcbs/ssp/earned/self_employment_sources.yaml new file mode 100644 index 00000000000..929a796b863 --- /dev/null +++ b/policyengine_us/parameters/gov/states/ky/dcbs/ssp/earned/self_employment_sources.yaml @@ -0,0 +1,14 @@ +description: Kentucky counts positive income from these self-employment sources as earned income for State Supplementation, without offsetting losses against another business. +values: + 0000-01-01: + - self_employment_income + - sstb_self_employment_income + - farm_operations_income + - partnership_self_employment_net_earnings +metadata: + label: Kentucky SSP self-employment income sources + reference: + - title: 921 KAR 2:015 Section 8(1), Medically needy income policy + href: https://apps.legislature.ky.gov/law/kar/titles/921/002/015/ + - title: Non-MAGI Medicaid and State Supplementation Manual, MS 2450 C.8(b) + href: https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=188 diff --git a/policyengine_us/parameters/gov/states/nh/dhhs/ssp/earned/self_employment_sources.yaml b/policyengine_us/parameters/gov/states/nh/dhhs/ssp/earned/self_employment_sources.yaml new file mode 100644 index 00000000000..247545584a5 --- /dev/null +++ b/policyengine_us/parameters/gov/states/nh/dhhs/ssp/earned/self_employment_sources.yaml @@ -0,0 +1,12 @@ +description: New Hampshire counts these self-employment income sources separately from wages under the adult category financial assistance program. +values: + 0000-01-01: + - self_employment_income + - sstb_self_employment_income + - farm_operations_income + - partnership_self_employment_net_earnings +metadata: + label: New Hampshire SSP self-employment income sources + reference: + - title: He-W 601.04(n) and 654.01(b)-(c), Self-employment income and losses + href: https://gc.nh.gov/rules/state_agencies/he-w600.html diff --git a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py new file mode 100644 index 00000000000..6283c4bfc64 --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -0,0 +1,917 @@ +"""SSI earned-source invariants and a differential against a reverted source list. + +The three profit inputs represent disjoint active businesses. SSI counts their +signed sum alike: moving profit between Schedule C, Schedule F and a partner's +SECA earnings must not change earned income, and more profit must not raise SSI. + +The reference reform freezes the earned-source list at main commit +8b17892d2cba99a82961ea32fbaf9d69c6ba73d6. With +farm and partnership earnings zero and the remaining business incomes +nonnegative, all SSI, MSP and direct state-reader outputs must remain exactly +equal to this list-reversion reference. Both policies retain this patch's +formula corrections: spouse-loss netting and nonnegative child-allocation and +student-exclusion income. The nonnegative domain excludes these corrections, +New Hampshire's prohibition on offsetting wages with a business loss, and +Kentucky's separate prohibition on one business loss offsetting another +business. Separate generated loss properties cover those intentional changes, +including losses belonging to either spouse, children and students, and +Connecticut's and Maryland's rule that a spouse's loss never reduces the +applicant's own earnings. An actual +git-base differential is run separately. A seeded, stratified sample of 200 +households supplements the smaller Hypothesis batches, including spouses and +disabled children. + +The SGA properties check that SGA earnings, wages plus the business net +floored at zero (POMS DI 10505.015 D.3), never fall below wages, rise with +wages, net losses between businesses, and match the signed sum of earned +sources when no business source is negative. + +All households in a batch share one vectorized simulation. The module reuses +the read-only country singleton and constructs its parameter-only reference +reform once, avoiding a country model for every example or household. +""" + +from copy import deepcopy +from pathlib import Path + +import numpy as np +import pytest +from hypothesis import HealthCheck, example, given, settings +from hypothesis import strategies as st +from policyengine_core.reforms import Reform + +from policyengine_us import Simulation +from policyengine_us.system import system + + +YEAR = 2026 +MONTH = "2026-01" +MAIN_EARNED_SOURCES = ( + "employment_income", + "self_employment_income", + "sstb_self_employment_income", +) +PROFIT_SOURCES = ( + "self_employment_income", + "farm_operations_income", + "partnership_self_employment_net_earnings", +) +BUSINESS_SOURCES = ( + "self_employment_income", + "sstb_self_employment_income", + "farm_operations_income", + "partnership_self_employment_net_earnings", +) +STATES = ( + "TX", + "CA", + "CO", + "CT", + "KY", + "MD", + "MN", + "MT", + "NC", + "NH", + "NJ", + "NV", + "NY", + "OH", + "OK", + "OR", + "PA", + "RI", + "SD", + "VA", + "VT", + "WI", + "WY", +) +KINDS = ("single", "couple", "ineligible_spouse", "one_parent", "two_parents") +STATE_READERS = ( + "ca_wdp_gross_earned_income", + "co_state_supplement_countable_income", + "co_oap_own_countable_income", + "co_oap_deemed_income", + "ct_ssp_countable_income", + "ct_ssp_earned_income_disregard", + "ct_ssp_gross_income", + "ct_ssp_own_earned_income", + "ky_ssp_countable_income", + "ky_ssp_earned_income", + "md_paa_countable_income", + "mn_msa_net_income_eligible", + "mn_msa_gross_income", + "mn_msa_person", + "mt_ssp", + "nc_sa_countable_income", + "nh_ssp_countable_income", + "nj_ssp", + "nv_oss", + "ny_ssp", + "oh_rss_countable_income", + "ok_ssp_countable_income", + "ok_ssp_own_countable_income", + "ok_ssp_spouse_child_allocation", + "or_ssp_countable_income", + "pa_ssp", + "ri_ssp", + "sd_ssp_care", + "sd_ssp_independent", + "tx_ssi_state_supplement_eligible", + "va_ag_countable_income", + "vt_aabd_ep_countable_income", + "vt_aabd_federal", + "wi_cts_gross_earned_income", + "wy_ssp_eligible", +) +OTHER_READERS = ( + "medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse", + "medicaid_optional_senior_or_disabled_unearned_income_deemed_from_ineligible_parent", + "medicare_part_b_premium", +) + + +class RevertAddedEarnedSources(Reform): + """Revert only the two added sources, retaining current variable formulas.""" + + def apply(self): + self.parameters.gov.ssa.ssi.income.sources.earned.update( + start="1975-01-01", value=list(MAIN_EARNED_SOURCES) + ) + + +@pytest.fixture(scope="module") +def policy(): + return system + + +@pytest.fixture(scope="module") +def policies(policy): + return policy, RevertAddedEarnedSources(policy) + + +def _program_outputs(policy): + """Include every variable defined within SSI/MSP, plus direct readers.""" + variables = Path(__file__).resolve().parents[2] / "variables" + names = set(STATE_READERS + OTHER_READERS) + for directory in ( + variables / "gov/ssa/ssi", + variables / "gov/hhs/medicare/savings_programs", + ): + names.update( + path.stem + for path in directory.rglob("*.py") + if not path.name.startswith("_") + ) + # A renamed or removed reader must fail instead of silently shrinking + # the differential's coverage. + assert names <= policy.variables.keys() + return sorted(names) + + +def _period(policy, name): + return MONTH if policy.variables[name].definition_period == "month" else YEAR + + +def _person(age=67, **inputs): + return { + "age": age, + "employment_income": 0, + "self_employment_income": 0, + "sstb_self_employment_income": 0, + "farm_operations_income": 0, + "partnership_self_employment_net_earnings": 0, + "bank_account_assets": 0, + "takes_up_ssi_if_eligible": True, + **inputs, + } + + +def _situation(households): + situation = { + name: {} + for name in ( + "people", + "tax_units", + "spm_units", + "families", + "marital_units", + "households", + ) + } + for i, household in enumerate(households): + adults = household["adults"] + children = household.get("children", []) + members = [] + for j, values in enumerate(adults + children): + member = f"p{i}_{j}" + child = j >= len(adults) + inputs = { + **values, + "is_tax_unit_head": j == 0, + "is_tax_unit_spouse": j == 1 and not child, + "is_tax_unit_dependent": child, + "is_household_head": j == 0, + "is_parent": bool(children) and not child, + } + situation["people"][member] = { + name: {YEAR: value} for name, value in inputs.items() + } + members.append(member) + situation["marital_units"][f"adults_{i}"] = {"members": members[: len(adults)]} + for child in members[len(adults) :]: + situation["marital_units"][child] = {"members": [child]} + for group in ("tax_units", "spm_units", "families"): + situation[group][f"{group}_{i}"] = {"members": members} + situation["households"][f"h{i}"] = { + "members": members, + "state_code": {YEAR: household["state"]}, + } + return situation + + +def _simulation(policy, households): + return Simulation( + tax_benefit_system=policy, + situation=_situation(households), + default_input_period=YEAR, + ) + + +def _assert_main_equal(policies, households, outputs=None): + situation = _situation(households) + names = _program_outputs(policies[0]) if outputs is None else outputs + snapshots = [] + # Release each simulation before creating the next one. Only the small + # copied output arrays remain alive, reducing differential-test memory. + for policy in policies: + simulation = Simulation( + tax_benefit_system=policy, + situation=deepcopy(situation), + default_input_period=YEAR, + ) + snapshots.append( + { + name: simulation.calculate(name, _period(policy, name)).copy() + for name in names + } + ) + del simulation + for name in names: + np.testing.assert_array_equal( + snapshots[0][name], snapshots[1][name], err_msg=name + ) + + +@st.composite +def profit_partitions(draw): + total = draw(st.integers(0, 80_000)) + farm = draw(st.integers(0, total)) + partnership = draw(st.integers(0, total - farm)) + return ( + draw(st.integers(0, 50_000)), + draw(st.integers(0, 10_000)), + total, + farm, + partnership, + ) + + +@settings(max_examples=12, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(12_000, 0, 6_000, 2_000, 3_000), (0, 500, 1, 1, 0)]) +@given(batch=st.lists(profit_partitions(), min_size=1, max_size=6)) +def test_profit_repartition_preserves_ssi_earned_income(policy, batch): + households = [] + expected = [] + for wages, sstb, total, farm, partnership in batch: + splits = ( + (total, 0, 0), + (0, total, 0), + (0, 0, total), + (total - farm - partnership, farm, partnership), + ) + for split in splits: + households.append( + { + "state": "TX", + "adults": [ + _person( + employment_income=wages, + sstb_self_employment_income=sstb, + **dict(zip(PROFIT_SOURCES, split)), + ) + ], + } + ) + expected.append(wages + sstb + total) + simulation = _simulation(policy, households) + earned = simulation.calculate("ssi_earned_income", YEAR) + np.testing.assert_array_equal(earned, expected) + np.testing.assert_array_equal( + earned.reshape(-1, 4), np.repeat(earned.reshape(-1, 4)[:, :1], 4, axis=1) + ) + + +@settings(max_examples=12, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(0, 0, 60_000, "farm_operations_income", False)]) +@example(batch=[(12_000, -6_000, 12_000, "farm_operations_income", True)]) +@given( + batch=st.lists( + st.tuples( + st.integers(0, 30_000), + st.integers(-40_000, 40_000), + st.integers(0, 40_000), + st.sampled_from(PROFIT_SOURCES), + st.booleans(), + ), + min_size=1, + max_size=6, + ) +) +def test_ssi_never_rises_when_business_profit_rises(policy, batch): + households = [] + for wages, initial, increase, source, married in batch: + for profit in (initial, initial + increase): + adults = [_person(employment_income=wages, **{source: profit})] + if married: + adults.append(_person(age=45)) + households.append({"state": "TX", "adults": adults}) + simulation = _simulation(policy, households) + # Sum within each household so the married case includes any payment + # attributed to a spouse as well as the applicant's own payment. + amounts = simulation.populations["household"].sum( + simulation.calculate("ssi", MONTH) + ) + pairs = np.asarray(amounts).reshape(-1, 2) + assert np.all(pairs[:, 1] <= pairs[:, 0]) + + +@st.composite +def unearned_income_with_losses(draw): + adults = [_person(interest_income=draw(st.integers(241, 24_000)))] + if draw(st.booleans()): + adults.append( + _person( + age=draw(st.sampled_from((60, 67))), + interest_income=draw(st.integers(241, 24_000)), + ) + ) + losses = deepcopy(adults) + # Every generated household has a loss; either spouse can own it, and + # both can have losses. Nobody has positive earnings in either version, + # so total countable income consists entirely of countable unearned income. + loss_owner = draw(st.integers(0, len(adults) - 1)) + for i, adult in enumerate(losses): + if i == loss_owner or draw(st.booleans()): + adult[draw(st.sampled_from(BUSINESS_SOURCES))] = -draw( + st.integers(1, 40_000) + ) + state = draw(st.sampled_from(STATES)) + return ( + {"state": state, "adults": adults}, + {"state": state, "adults": losses}, + ) + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given(batch=st.lists(unearned_income_with_losses(), min_size=1, max_size=4)) +def test_business_loss_never_reduces_countable_unearned_income(policy, batch): + simulation = _simulation( + policy, [household for pair in batch for household in pair] + ) + for name in ("ssi_unearned_income", "ssi_countable_income"): + totals = simulation.populations["household"].sum( + simulation.calculate(name, YEAR) + ) + pairs = np.asarray(totals).reshape(-1, 2) + np.testing.assert_array_equal(pairs[:, 0], pairs[:, 1], err_msg=name) + + +@st.composite +def couples_with_business_losses(draw): + loss_owner = draw(st.integers(0, 1)) + adults = [] + for i in range(2): + wages = draw(st.integers(0, 30_000)) + premiums = draw(st.integers(0, 40_000)) + # Draw signed final earnings, then obtain them with wages and one + # business. The selected spouse always has a residual business loss; + # the other spouse can have a profit or another residual loss. + earned = draw( + st.integers(-40_000, -1) + if i == loss_owner + else st.integers(-40_000, 40_000) + ) + business = earned - wages + min(wages, premiums) + source = draw(st.sampled_from(BUSINESS_SOURCES)) + adults.append( + _person( + age=67 if i == 0 else draw(st.sampled_from((60, 67))), + employment_income=wages, + pre_tax_health_insurance_premiums=premiums, + **{source: business}, + ) + ) + return {"state": draw(st.sampled_from(STATES)), "adults": adults} + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example( + batch=[ + { + "state": "TX", + "adults": [ + _person(employment_income=12_000), + _person(age=60, farm_operations_income=-5_000), + ], + }, + { + "state": "CT", + "adults": [ + _person(partnership_self_employment_net_earnings=-9_000), + _person(age=60, employment_income=4_000), + ], + }, + ] +) +@given(batch=st.lists(couples_with_business_losses(), min_size=1, max_size=4)) +def test_spousal_loss_pool_is_nonnegative_and_conserves_net_earnings(policy, batch): + simulation = _simulation(policy, batch) + earned = simulation.calculate("ssi_earned_income", YEAR).reshape(-1, 2) + after_loss = simulation.calculate("ssi_earned_income_after_spousal_loss", YEAR) + assert np.all(after_loss >= 0) + np.testing.assert_array_equal( + after_loss.reshape(-1, 2).sum(axis=1), + np.maximum(earned.sum(axis=1), 0), + ) + + +@st.composite +def ct_md_applicants_with_spouse_losses(draw): + # The applicant's own earned income is nonnegative: wages cover any own + # business loss. The ineligible spouse (under 65, not blind or disabled) + # has a residual business loss in the first version, so SSI applies part + # of it to the applicant's wages under POMS SI 00820.210 B.2.c, and any + # loss in the second. Varying the spouse's loss, from any business + # source, must leave the applicant's state figures unchanged. + wages = draw(st.integers(0, 40_000)) + applicant = _person( + age=draw(st.integers(65, 85)), + employment_income=wages, + social_security_retirement=draw(st.integers(0, 20_000)), + **{draw(st.sampled_from(BUSINESS_SOURCES)): draw(st.integers(-wages, 30_000))}, + ) + spouse_wages = draw(st.integers(0, 30_000)) + source = draw(st.sampled_from(BUSINESS_SOURCES)) + spouse_age = draw(st.integers(30, 64)) + losses = ( + spouse_wages + draw(st.integers(1, 40_000)), + draw(st.integers(0, spouse_wages + 40_000)), + ) + spouses = [ + _person(age=spouse_age, employment_income=spouse_wages, **{source: -loss}) + for loss in losses + ] + state = draw(st.sampled_from(("CT", "MD"))) + return [{"state": state, "adults": [applicant, spouse]} for spouse in spouses] + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example( + batch=[ + [ + { + "state": state, + "adults": [ + _person(employment_income=12_000), + _person( + age=60, employment_income=2_000, self_employment_income=-loss + ), + ], + } + for loss in (7_000, 30_000) + ] + for state in ("CT", "MD") + ] +) +@given(batch=st.lists(ct_md_applicants_with_spouse_losses(), min_size=1, max_size=4)) +def test_spouse_loss_never_reduces_ct_or_md_applicant_earnings(policy, batch): + simulation = _simulation( + policy, [household for pair in batch for household in pair] + ) + + def applicant(name): + # Rows are [applicant, spouse] per household, two households per pair. + return simulation.calculate(name, YEAR).reshape(-1, 2, 2)[:, :, 0] + + earned = applicant("ssi_earned_income") + assert np.all(earned >= 0) + couple = simulation.calculate("ssi_couple_computation_applies", MONTH) + assert not np.any(couple) + in_ct = np.array([pair[0]["state"] == "CT" for pair in batch]) + # Every Connecticut spouse here has a loss: own earnings floor at zero, so + # an own residual loss is not deducted again from deemed spouse earnings. + assert np.all(simulation.calculate("ct_ssp_own_earned_income", YEAR) >= 0) + own = applicant("ct_ssp_own_earned_income") + np.testing.assert_array_equal(own[:, 0], own[:, 1]) + np.testing.assert_array_equal(own[:, 0], np.where(in_ct, earned[:, 0], 0)) + # Holding SSI's spouse-deemed income at zero isolates the applicant's own + # earnings, which Maryland counts under COMAR 07.03.07.08. + deemed = applicant("ssi_income_deemed_from_ineligible_spouse") + unchanged = (deemed == 0).all(axis=1) & ~in_ct + paa = applicant("md_paa_countable_income") + np.testing.assert_array_equal(paa[unchanged, 0], paa[unchanged, 1]) + # Where SSI deems no spouse earnings or unearned income, each Connecticut + # reader's earned budget is the applicant's own earnings in both versions. + # SSI itself, which Connecticut counts as unearned income, may change. + no_deemed = in_ct & ( + (applicant("ssi_earned_income_deemed_from_ineligible_spouse") == 0) + & (applicant("ssi_unearned_income_deemed_from_ineligible_spouse") == 0) + ).all(axis=1) + + def monthly(name): + return simulation.calculate(name, MONTH).reshape(-1, 2, 2)[:, :, 0] + + unearned = applicant("ssi_unearned_income") / 12 + monthly("ssi") + gross_earned = monthly("ct_ssp_gross_income") - unearned + countable_earned = monthly("ct_ssp_countable_income") - np.maximum( + unearned - monthly("ct_ssp_unearned_income_disregard"), 0 + ) + for value in (gross_earned, monthly("ct_ssp_earned_income_disregard")): + np.testing.assert_allclose(value[no_deemed, 0], value[no_deemed, 1], atol=1e-3) + np.testing.assert_allclose(gross_earned[no_deemed], own[no_deemed] / 12, atol=1e-3) + np.testing.assert_allclose( + countable_earned[no_deemed, 0], countable_earned[no_deemed, 1], atol=1e-3 + ) + + +@st.composite +def child_and_student_losses(draw): + wages = draw(st.integers(0, 2_000)) + child = _person( + age=draw(st.integers(6, 17)), + interest_income=draw(st.integers(0, 6_000)), + is_ssi_ineligible_child=True, + ) + child_with_loss = { + **child, + "employment_income": wages, + draw(st.sampled_from(BUSINESS_SOURCES)): -wages - draw(st.integers(1, 20_000)), + } + student_wages = draw(st.integers(0, 12_000)) + student = _person( + age=draw(st.integers(18, 21)), + employment_income=student_wages, + is_ssi_blind_or_disabled_working_student_exclusion_eligible=True, + **{ + draw(st.sampled_from(BUSINESS_SOURCES)): -student_wages + - draw(st.integers(1, 30_000)) + }, + ) + state = draw(st.sampled_from(STATES)) + return ( + {"state": state, "adults": [_person(age=45)], "children": [child]}, + { + "state": state, + "adults": [_person(age=45)], + "children": [child_with_loss], + }, + {"state": state, "adults": [student]}, + ) + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given(batch=st.lists(child_and_student_losses(), min_size=1, max_size=4)) +def test_residual_loss_cannot_raise_child_allocation_or_negate_student_exclusion( + policy, batch +): + simulation = _simulation( + policy, [household for group in batch for household in group] + ) + allocations = simulation.populations["household"].sum( + simulation.calculate("ssi_ineligible_child_allocation", YEAR) + ) + allocations = np.asarray(allocations).reshape(-1, 3) + # Compare against zero earned income: a residual business loss cannot + # offset the child's unearned income or enlarge its allocation. + np.testing.assert_array_equal(allocations[:, 0], allocations[:, 1]) + exclusions = simulation.calculate( + "ssi_blind_or_disabled_working_student_exclusion", YEAR + ) + assert np.all(exclusions >= 0) + # Every eligible student's signed earnings are negative in this batch. + np.testing.assert_array_equal(exclusions, np.zeros_like(exclusions)) + + +@st.composite +def nonpositive_combined_business_income(draw): + businesses = [draw(st.integers(-20_000, 20_000)) for _ in range(3)] + businesses.append(-sum(businesses) - draw(st.integers(0, 20_000))) + return ( + draw(st.integers(0, 50_000)), + draw(st.booleans()), + businesses, + draw(st.sampled_from(tuple(state for state in STATES if state != "NH"))), + ) + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(12_000, False, [4_000, 0, -6_000, 0], "TX")]) +@given(batch=st.lists(nonpositive_combined_business_income(), min_size=1, max_size=4)) +def test_nh_nonpositive_combined_business_income_preserves_wages_only_value( + policy, batch +): + households = [] + for wages, blind, businesses, other_state in batch: + person = _person( + employment_income=wages, + is_blind=blind, + # Hold federal SSI at zero so its permitted wage-loss offset + # cannot conceal New Hampshire's separate earned-income rule. + takes_up_ssi_if_eligible=False, + ssi_unearned_income=0, + ) + with_businesses = {**person, **dict(zip(BUSINESS_SOURCES, businesses))} + households.extend( + ( + {"state": "NH", "adults": [person]}, + {"state": "NH", "adults": [with_businesses]}, + {"state": other_state, "adults": [with_businesses]}, + ) + ) + simulation = _simulation(policy, households) + countable = simulation.calculate("nh_ssp_countable_income", MONTH).reshape(-1, 3) + np.testing.assert_array_equal(countable[:, 0], countable[:, 1]) + np.testing.assert_array_equal(countable[:, 2], np.zeros(len(batch))) + + +@st.composite +def separate_business_profits_and_losses(draw): + # Every generated case has both a profit and a loss. Permuting sources + # exercises each business's floor, including farm and partnership inputs. + businesses = draw( + st.permutations( + ( + draw(st.integers(1, 40_000)), + -draw(st.integers(1, 40_000)), + draw(st.integers(-40_000, 40_000)), + draw(st.integers(-40_000, 40_000)), + ) + ) + ) + return ( + draw(st.integers(0, 60_000)), + draw(st.integers(-10_000, 100_000)), + businesses, + draw(st.sampled_from(tuple(state for state in STATES if state != "KY"))), + ) + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(12_000, 0, (10_000, 0, -6_000, 0), "NH")]) +@given(batch=st.lists(separate_business_profits_and_losses(), min_size=1, max_size=4)) +def test_ky_own_earned_income_preserves_each_business_profit(policy, batch): + households = [] + expected = [] + for wages, premiums, businesses, other_state in batch: + person = _person( + employment_income=wages, + pre_tax_health_insurance_premiums=premiums, + **dict(zip(BUSINESS_SOURCES, businesses)), + ) + households.extend( + ( + {"state": "KY", "adults": [person]}, + {"state": other_state, "adults": [person]}, + ) + ) + expected.extend( + ( + wages + - min(wages, max(premiums, 0)) + + sum(max(profit, 0) for profit in businesses), + 0, + ) + ) + simulation = _simulation(policy, households) + np.testing.assert_array_equal( + simulation.calculate("ky_ssp_earned_income", YEAR), expected + ) + + +@st.composite +def people_without_farm_or_partnership(draw, age): + wages = draw(st.integers(0, 40_000)) + disabled = draw(st.booleans()) + return _person( + age=age, + employment_income=wages, + self_employment_income=draw(st.integers(0, 30_000)), + sstb_self_employment_income=draw(st.integers(0, 10_000)), + pre_tax_health_insurance_premiums=draw(st.integers(0, min(wages, 6_000))), + social_security_retirement=draw(st.integers(0, 20_000)) if age >= 62 else 0, + taxable_interest_income=draw(st.integers(0, 1_200)), + bank_account_assets=draw(st.sampled_from((0, 500, 1_999, 2_001, 4_000))), + is_disabled=disabled, + meets_ssi_disability_criteria=disabled, + is_blind=draw(st.booleans()), + ) + + +@st.composite +def households_without_farm_or_partnership(draw): + kind = draw(st.sampled_from(KINDS)) + parental = kind in ("one_parent", "two_parents") + age = draw(st.integers(30, 55) if parental else st.integers(65, 85)) + adults = [draw(people_without_farm_or_partnership(age))] + if kind in ("couple", "ineligible_spouse", "two_parents"): + spouse_age = 45 if kind == "ineligible_spouse" else age + adults.append(draw(people_without_farm_or_partnership(spouse_age))) + children = [] + if parental: + child_age = draw(st.integers(6, 17)) + children = [ + _person( + age=child_age, + is_disabled=True, + meets_ssi_disability_criteria=True, + employment_income=draw(st.integers(0, 2_000)), + ) + ] + return { + "state": draw(st.sampled_from(STATES)), + "adults": adults, + "children": children, + } + + +@settings(max_examples=6, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@given( + households=st.lists( + households_without_farm_or_partnership(), + min_size=1, + max_size=5, + ) +) +def test_zero_farm_and_partnership_preserves_list_reference_on_nonnegative_domain( + policies, households +): + _assert_main_equal(policies, households) + + +def _seeded_households(): + rng = np.random.default_rng(20261029) + + def person(age): + wages = int(rng.integers(0, 40_001)) if rng.random() < 0.75 else 0 + disabled = bool(rng.random() < 0.35) + return _person( + age=age, + employment_income=wages, + self_employment_income=int(rng.integers(0, 30_001)) + if rng.random() < 0.65 + else 0, + sstb_self_employment_income=int(rng.integers(0, 10_001)) + if rng.random() < 0.25 + else 0, + pre_tax_health_insurance_premiums=int( + rng.integers(0, min(wages, 6_000) + 1) + ), + social_security_retirement=int(rng.integers(0, 20_001)) + if age >= 62 and rng.random() < 0.7 + else 0, + taxable_interest_income=int(rng.integers(0, 1_201)), + bank_account_assets=int(rng.choice((0, 500, 1_999, 2_001, 4_000))), + is_disabled=disabled, + meets_ssi_disability_criteria=disabled, + is_blind=bool(rng.random() < 0.15), + ) + + households = [] + for i in range(200): + kind = KINDS[(i // len(STATES)) % len(KINDS)] + parental = kind in ("one_parent", "two_parents") + age = int(rng.integers(30, 56) if parental else rng.integers(65, 86)) + adults = [person(age)] + if kind in ("couple", "ineligible_spouse", "two_parents"): + adults.append(person(45 if kind == "ineligible_spouse" else age)) + children = [] + if parental: + children = [ + _person( + age=int(rng.integers(6, 18)), + is_disabled=True, + meets_ssi_disability_criteria=True, + employment_income=int(rng.integers(0, 2_001)), + ) + ] + households.append( + { + "state": STATES[i % len(STATES)], + "adults": adults, + "children": children, + } + ) + return households + + +def test_200_households_match_reverted_list_without_farm_or_partnership(policies): + households = _seeded_households() + assert len(households) == 200 + assert {household["state"] for household in households} == set(STATES) + _assert_main_equal(policies, households) + + +SGA_ANNUAL = 1_690 * 12 # 2026 non-blind SGA amount, $1,690 a month. + + +@st.composite +def sga_workers(draw): + return ( + draw(st.integers(0, 40_000)), + draw(st.integers(0, 30_000)), + tuple(draw(st.integers(-30_000, 30_000)) for _ in BUSINESS_SOURCES), + draw(st.integers(0, 20_000)), + ) + + +def _unchanged(businesses): + return businesses + + +def _nonnegative(businesses): + return tuple(abs(b) for b in businesses) + + +def _collapsed(businesses): + return (sum(businesses),) + (0,) * (len(businesses) - 1) + + +def _sga_households(batch, transform=_unchanged): + households = [] + for wages, premiums, businesses, raise_ in batch: + for pay in (wages, wages + raise_): + households.append( + { + "state": "TX", + "adults": [ + _person( + age=40, + is_blind=False, + employment_income=pay, + pre_tax_health_insurance_premiums=premiums, + **dict(zip(BUSINESS_SOURCES, transform(businesses))), + ) + ], + } + ) + return households + + +@settings(max_examples=12, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(21_600, 2_400, (-3_000, 0, -6_000, 0), 0)]) +@example(batch=[(20_000, 0, (0, 0, 30_000, -12_000), 400)]) +@given(batch=st.lists(sga_workers(), min_size=1, max_size=6)) +def test_sga_earnings_never_fall_below_wages_and_rise_with_wages(policy, batch): + # POMS DI 10505.015 D.3: a business loss never reduces wages, so wages + # above the SGA amount always establish SGA, whatever the businesses + # earn. SGA status is also nondecreasing in wages. + simulation = _simulation(policy, _sga_households(batch)) + engaged = simulation.calculate("ssi_engaged_in_sga", YEAR).reshape(-1, 2) + wages = np.array([(w, w + raise_) for w, _, _, raise_ in batch], dtype=float) + assert np.all(engaged[wages > SGA_ANNUAL]) + assert np.all(engaged[:, 1] >= engaged[:, 0]) + + +@settings(max_examples=12, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(18_000, 2_400, (1_000, 0, 1_281, 0), 0)]) +@given(batch=st.lists(sga_workers(), min_size=1, max_size=6)) +def test_sga_without_business_losses_matches_signed_sum(policy, batch): + # With every business source nonnegative, the floor cannot bind, and SGA + # earnings equal the prior formula's: the sum of all earned sources, with + # payroll premiums kept in wages. + simulation = _simulation(policy, _sga_households(batch, _nonnegative)) + engaged = simulation.calculate("ssi_engaged_in_sga", YEAR) + expected = [ + pay + sum(_nonnegative(businesses)) > SGA_ANNUAL + for wages, _, businesses, raise_ in batch + for pay in (wages, wages + raise_) + ] + np.testing.assert_array_equal(engaged, expected) + + +@settings(max_examples=12, deadline=None, suppress_health_check=[HealthCheck.too_slow]) +@example(batch=[(0, 0, (0, 0, 24_000, -6_000), 0)]) +@given(batch=st.lists(sga_workers(), min_size=1, max_size=6)) +def test_sga_nets_losses_between_businesses(policy, batch): + # Business losses net against business profits (20 CFR 404.1081(c), POMS + # DI 10510.012 B), so moving the whole business net into one source + # cannot change SGA status. + engaged = [ + _simulation(policy, _sga_households(batch, transform)).calculate( + "ssi_engaged_in_sga", YEAR + ) + for transform in (_unchanged, _collapsed) + ] + np.testing.assert_array_equal(engaged[0], engaged[1]) diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml index df9c65a5611..8c06ee35a32 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/medicare/savings_programs/income/msp_countable_income.yaml @@ -427,3 +427,50 @@ # Rule 4.2(C)(5) withholds the exclusion only from income based on need: # 12,000 / 12 - 50 = 950. msp_countable_income: 950 +- name: Both Medicare spouses' earnings net a business loss once before MSP exclusions. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + earner: + age: 68 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 67 + farm_operations_income: -5_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [earner, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [earner, spouse] + output: + # (7,000 / 12 - 20 - 65) / 2 = 249.1667 per month. + msp_countable_income: [249.1667, 249.1667] + +- name: A non-Medicare spouse's business loss reduces the Medicare applicant's earned income even without deeming. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 60 + self_employment_income: -5_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + output: + # POMS SI 00820.210 B.2.c applies even when no positive income is deemed. + msp_countable_income: [249.1667, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml index 96f3e7376a9..dfe2f534cd2 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_parent.yaml @@ -116,3 +116,82 @@ is_ssi_spousal_deeming_applies: [false, false, false] ssi_income_deemed_from_ineligible_spouse: [0, 0, 0] ssi_countable_income: [1_548, 0, 0] + +- name: A disabled child's business loss neither raises parental earned income nor gives the child an allocation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + child: + age: 10 + meets_ssi_disability_criteria: true + takes_up_ssi_if_eligible: true + employment_income: 1_000 + self_employment_income: -4_000 + parent: + age: 45 + is_parent: true + employment_income: 30_000 + taxable_private_pension_income: 6_000 + tax_units: + tax_unit: + members: [child, parent] + marital_units: + child_unit: + members: [child] + parent_unit: + members: [parent] + households: + household: + members: [child, parent] + state_code: TX + output: + # 20 CFR 416.1111(b): the child's $3,000 net loss offsets only earned + # income, so the child has no countable earnings and nothing for the + # student exclusion (416.1112(c)(3)) to exclude. An eligible child gets + # no allocation (416.1165(b)), and 416.1163(b)(2) only reduces one. + ssi_blind_or_disabled_working_student_exclusion: [0, 0] + ssi_ineligible_child_allocation: [0, 0] + # 416.1121(a) counts the pension as unearned income. 416.1165(d): + # (6,000 - 240) + (30,000 - 780) / 2 - 11,928 = 8,442 deemed. + ssi_unearned_income_deemed_from_ineligible_parent: [8_442, 0] + # 416.1165(e)(1): 11,928 - (8,442 - 240) = 3,726. + ssi: [3_726, 0] + +- name: Without unearned parental income, a child's loss leaves the same deemed amount. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + child: + age: 10 + meets_ssi_disability_criteria: true + takes_up_ssi_if_eligible: true + employment_income: 1_000 + self_employment_income: -4_000 + parent: + age: 45 + is_parent: true + employment_income: 30_000 + tax_units: + tax_unit: + members: [child, parent] + marital_units: + child_unit: + members: [child] + parent_unit: + members: [parent] + households: + household: + members: [child, parent] + state_code: TX + output: + # (30,000 - 240 - 780) / 2 - 11,928 = 2,562 deemed, and + # 11,928 - (2,562 - 240) = 9,606. A negative student exclusion + # (+3,000 to parental earnings) and a 3,000 allocation for the eligible + # child (taken from earned income once unearned income is exhausted) + # would cancel here, so this case guards against a fix to only one. + ssi_blind_or_disabled_working_student_exclusion: [0, 0] + ssi_ineligible_child_allocation: [0, 0] + ssi_unearned_income_deemed_from_ineligible_parent: [2_562, 0] + ssi: [9_606, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml index 76164837620..99b17cf6f55 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.yaml @@ -17,3 +17,35 @@ # 20 CFR 416.1163(b)(1)-(2): (1,450 - 967) * 12 - 1,200 = 4,596. ssi_earned_income: 1_200 ssi_ineligible_child_allocation: 4_596 + +- name: An ineligible child's excess farm loss cannot offset their unearned income. + period: 2026 + input: + age: 14 + employment_income: 2_000 + farm_operations_income: -5_000 + ssi_unearned_income: 600 + output: + # 416.1111(b) permits loss offsets only against earned income. + # 416.1163(b): (1,491 - 994) * 12 - 600 = 5,364. + ssi_earned_income: -3_000 + ssi_ineligible_child_allocation: 5_364 + +- name: An ineligible child's Schedule C loss cannot increase the allocation above the federal rate difference. + period: 2026 + input: + age: 14 + self_employment_income: -5_000 + output: + # The same loss rule applies to the existing Schedule C source. + ssi_ineligible_child_allocation: 5_964 + +- name: An ineligible child's farm loss offsets wages before reducing the child allocation. + period: 2026 + input: + age: 14 + employment_income: 5_000 + farm_operations_income: -2_000 + output: + ssi_earned_income: 3_000 + ssi_ineligible_child_allocation: 2_964 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.yaml index 2e3b42da0f3..e10916e37ee 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.yaml @@ -103,3 +103,14 @@ is_ssi_blind_or_disabled_working_student_exclusion_eligible: true ssi_blind_or_disabled_working_student_exclusion: 9_460 ssi_countable_income: 2_560 + +- name: A student's business loss cannot create a negative earned income exclusion. + period: 2026 + input: + age: 20 + employment_income: 2_000 + farm_operations_income: -5_000 + is_ssi_blind_or_disabled_working_student_exclusion_eligible: true + output: + ssi_earned_income: -3_000 + ssi_blind_or_disabled_working_student_exclusion: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income.yaml index a8185d00ffe..6b51a9a01d9 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income.yaml @@ -71,3 +71,50 @@ employer_sponsored_insurance_premiums: 1_200 output: ssi_earned_income: 12_000 + +- name: Active Schedule F farm profit is earned income. + period: 2026 + input: + farm_operations_income: 60_000 + output: + ssi_earned_income: 60_000 + +- name: Partnership SECA earnings are earned income without counting the distributive share twice. + period: 2026 + input: + partnership_income: 60_000 + partnership_self_employment_net_earnings: 60_000 + output: + ssi_earned_income: 60_000 + +- name: Farm losses offset other businesses and wages before SSI exclusions. + period: 2026 + input: + age: 67 + employment_income: 12_000 + self_employment_income: 4_000 + farm_operations_income: -8_000 + partnership_self_employment_net_earnings: 2_000 + output: + # 20 CFR 404.1081(c)-(d) and 416.1111(b): aggregate signed NESE, + # then offset wages; (10,000 - 12 * (20 + 65)) / 2 = 4,490. + ssi_earned_income: 10_000 + ssi_countable_income: 4_490 + +- name: Excess farm losses cannot reduce unearned income. + period: 2026 + input: + age: 67 + employment_income: 2_000 + farm_operations_income: -5_000 + social_security_retirement: 6_000 + output: + ssi_earned_income: -3_000 + ssi_countable_income: 5_760 + +- name: Small farm profits remain SSI earnings below the SE tax reporting threshold. + period: 2026 + input: + farm_operations_income: 300 + output: + ssi_earned_income: 300 diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income_after_spousal_loss.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income_after_spousal_loss.yaml new file mode 100644 index 00000000000..39c0407e696 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income_after_spousal_loss.yaml @@ -0,0 +1,108 @@ +- name: An individual's excess business loss cannot reduce unearned income or create a negative student exclusion. + period: 2026 + input: + age: 20 + employment_income: 2_000 + farm_operations_income: -5_000 + ssi_unearned_income: 600 + is_ssi_blind_or_disabled_working_student_exclusion_eligible: true + output: + ssi_earned_income: -3_000 + ssi_earned_income_after_spousal_loss: 0 + ssi_blind_or_disabled_working_student_exclusion: 0 + +- name: An ineligible spouse's loss offsets their own wages first and then the applicant's wages. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 2_000 + farm_operations_income: -7_000 + is_tax_unit_spouse: true + households: + household: + members: [applicant, spouse] + state_code: TX + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + output: + # POMS SI 00820.210 B.2.c includes an eligible individual and ineligible spouse. + # Annual earned budget = 12,000 + 2,000 - 7,000 = 7,000. + ssi_earned_income: [12_000, -5_000] + ssi_earned_income_after_spousal_loss: [7_000, 0] + is_ssi_spousal_deeming_applies: [false, false] + ssi_countable_income: [2_990, 0] + medicaid_optional_senior_or_disabled_countable_income: [2_990, 0] + ssi: [8_938, 0] + +- name: A spouse's loss does not offset earnings when the spouses live in different households. + period: 2026 + input: + people: + earner: + age: 67 + ssi_earned_income: 12_000 + is_tax_unit_head: true + spouse: + age: 60 + ssi_earned_income: -5_000 + is_tax_unit_spouse: true + households: + applicant_home: + members: [earner] + spouse_home: + members: [spouse] + tax_units: + couple: + members: [earner, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [earner, spouse] + output: + ssi_earned_income_after_spousal_loss: [12_000, 0] + +- name: A third adult's business loss does not offset a married couple's earnings in a default marital unit. + period: 2026 + input: + people: + earner: + age: 67 + ssi_earned_income: 12_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + spouse: + age: 60 + ssi_earned_income: 0 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + other_adult: + age: 45 + ssi_earned_income: -5_000 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + tax_units: + couple: + members: [earner, spouse] + tax_unit_is_joint: true + other: + members: [other_adult] + marital_units: + default_group: + members: [earner, spouse, other_adult] + output: + ssi_earned_income_after_spousal_loss: [12_000, 0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_engaged_in_sga.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_engaged_in_sga.yaml index ecc98e6faa3..a919946b2d2 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_engaged_in_sga.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_engaged_in_sga.yaml @@ -53,3 +53,112 @@ # Only 12,000 of excluded wages is restored, below the SGA threshold. ssi_earned_income: 0 ssi_engaged_in_sga: false + +- name: Farm profit alone above the SGA amount is substantial gainful activity. + period: 2026 + input: + farm_operations_income: 24_000 + is_blind: false + output: + # 20 CFR 416.975 evaluates self-employment, and POMS DI 10510.012 B + # starts from net earnings from self-employment, which include a farm + # business (20 CFR 416.1110(b)): 24,000 / 12 = 2,000 monthly, above the + # 1,690 non-blind SGA amount for 2026. + ssi_earned_income: 24_000 + ssi_engaged_in_sga: true + +- name: Partnership self-employment earnings alone above the SGA amount are substantial gainful activity. + period: 2026 + input: + partnership_self_employment_net_earnings: 24_000 + is_blind: false + output: + # A partner's distributive share is net earnings from self-employment + # (20 CFR 416.1110(b)): 24,000 / 12 = 2,000 monthly, above 1,690. + ssi_earned_income: 24_000 + ssi_engaged_in_sga: true + +# POMS DI 10505.015 D.3: with wages, a self-employment loss does not reduce +# total earnings. Losses still net between businesses (20 CFR 404.1081(c), +# POMS DI 10510.012 B). 2026 non-blind SGA amount: $1,690 a month. +- name: A farm loss does not bring wages above the SGA amount below it. + period: 2026 + input: + employment_income: 21_600 + farm_operations_income: -6_000 + is_blind: false + output: + # SSI payment earnings net the loss: 21,600 - 6,000 = 15,600. + ssi_earned_income: 15_600 + # SGA earnings: wages 1,800 a month + max(-500, 0) = 1,800 > 1,690. + ssi_engaged_in_sga: true + +- name: A Schedule C loss does not bring wages above the SGA amount below it. + period: 2026 + input: + employment_income: 21_600 + self_employment_income: -6_000 + is_blind: false + output: + ssi_earned_income: 15_600 + # 1,800 + max(-500, 0) = 1,800 > 1,690. + ssi_engaged_in_sga: true + +- name: A partnership loss does not bring wages above the SGA amount below it. + period: 2026 + input: + employment_income: 21_600 + partnership_self_employment_net_earnings: -6_000 + is_blind: false + output: + ssi_earned_income: 15_600 + # 1,800 + max(-500, 0) = 1,800 > 1,690. + ssi_engaged_in_sga: true + +- name: Farm profit and a partnership loss net above the SGA amount. + period: 2026 + input: + farm_operations_income: 30_000 + partnership_self_employment_net_earnings: -6_000 + is_blind: false + output: + # Businesses net: (30,000 - 6,000) / 12 = 2,000 > 1,690. + ssi_earned_income: 24_000 + ssi_engaged_in_sga: true + +- name: Farm profit and a partnership loss net below the SGA amount. + period: 2026 + input: + farm_operations_income: 24_000 + partnership_self_employment_net_earnings: -6_000 + is_blind: false + output: + # (24,000 - 6,000) / 12 = 1,500, not above 1,690. Flooring each business + # separately would give 24,000 / 12 = 2,000 and wrongly find SGA. + ssi_earned_income: 18_000 + ssi_engaged_in_sga: false + +- name: A business loss with no wages is not substantial gainful activity. + period: 2026 + input: + farm_operations_income: -6_000 + is_blind: false + output: + ssi_earned_income: -6_000 + # 0 + max(-6,000, 0) = 0. + ssi_engaged_in_sga: false + +- name: Pretax premiums stay in SGA wages when the worker also has a business loss. + period: 2026 + input: + employment_income: 21_600 + pre_tax_health_insurance_premiums: 2_400 + self_employment_income: -3_000 + is_blind: false + output: + # SSI payment earnings: 21,600 - 2,400 - 3,000 = 16,200. + ssi_earned_income: 16_200 + # SGA earnings keep gross wages, premiums included: 21,600 / 12 = 1,800 + # > 1,690. Dropping the premiums (19,200 / 12 = 1,600) or netting the + # loss (18,600 / 12 = 1,550) would each fall below the SGA amount. + ssi_engaged_in_sga: true diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_marital_earned_income.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_marital_earned_income.yaml index 9b45bfaa00d..e06d8457270 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_marital_earned_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_marital_earned_income.yaml @@ -61,3 +61,57 @@ members: [head, spouse] output: ssi_marital_earned_income: [10, 10] + +- name: An eligible couple nets one spouse's farm loss against the other's wages exactly once. + period: 2026 + input: + people: + earner: + age: 67 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 67 + farm_operations_income: -5_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [earner, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [earner, spouse] + output: + ssi_earned_income_after_spousal_loss: [7_000, 0] + ssi_marital_earned_income: [7_000, 7_000] + +- name: The applicant's partnership loss reduces an ineligible spouse's earnings before deeming. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + partnership_self_employment_net_earnings: -5_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 20_000 + is_tax_unit_spouse: true + households: + household: + members: [applicant, spouse] + state_code: TX + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + output: + ssi_earned_income_after_spousal_loss: [0, 15_000] + ssi_marital_earned_income: [0, 15_000] + ssi_earned_income_deemed_from_ineligible_spouse: [15_000, 0] + ssi_income_deemed_from_ineligible_spouse: [6_990, 0] + medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse: [6_990, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi.yaml index 68611615b0d..6ae5a98af8c 100644 --- a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi.yaml +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi.yaml @@ -25,3 +25,77 @@ uncapped_ssi: -100 output: ssi: 0 + +- name: Texas senior with Schedule C profit receives no SSI or MSP premium coverage. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 67 + state_code: TX + self_employment_income: 60_000 + output: + ssi_earned_income: 60_000 + ssi_countable_income: 29_490 + ssi: 0 + msp_eligible: false + msp_part_b_premium_coverage: 0 + medicare_part_b_premium: 2_434.80 + +- name: Texas senior with farm profit receives no SSI or MSP premium coverage. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 67 + state_code: TX + farm_operations_income: 60_000 + output: + ssi_earned_income: 60_000 + ssi_countable_income: 29_490 + ssi: 0 + msp_eligible: false + msp_part_b_premium_coverage: 0 + medicare_part_b_premium: 2_434.80 + +- name: Texas senior with partnership earnings receives no SSI or MSP premium coverage. + period: 2026 + absolute_error_margin: 0.01 + input: + age: 67 + state_code: TX + partnership_self_employment_net_earnings: 60_000 + output: + ssi_earned_income: 60_000 + ssi_countable_income: 29_490 + ssi: 0 + msp_eligible: false + msp_part_b_premium_coverage: 0 + medicare_part_b_premium: 2_434.80 + +- name: Farm income of an ineligible spouse is deemed to a Texas senior. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + spouse: + age: 60 + farm_operations_income: 60_000 + households: + household: + members: [applicant, spouse] + state_code: TX + marital_units: + couple: + members: [applicant, spouse] + tax_units: + tax_unit: + members: [applicant, spouse] + spm_units: + spm_unit: + members: [applicant, spouse] + output: + ssi_earned_income: [0, 60_000] + ssi_earned_income_deemed_from_ineligible_spouse: [60_000, 0] + ssi_income_deemed_from_ineligible_spouse: [29_490, 0] + ssi: [0, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml new file mode 100644 index 00000000000..b7700a94ae9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml @@ -0,0 +1,113 @@ +# Counting farm net earnings moves the substantial gainful activity test, so it +# can move disability status and the couple computation. 2026 amounts: federal +# benefit rates $994 (individual) and $1,491 (couple); non-blind SGA $1,690. +- name: A spouse whose farm profit exceeds the SGA amount is an ineligible spouse whose income is deemed. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + spouse: + age: 50 + meets_ssi_disability_criteria: true + farm_operations_income: 24_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [head, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # The spouse's $2,000 of monthly farm earnings exceed $1,690, so the spouse + # is engaged in SGA and not disabled (20 CFR 416.905(a), 416.971). Not + # aged, blind or disabled, the spouse is an ineligible spouse + # (416.1160(d)), and the head is an eligible individual. + is_ssi_disabled: [false, false] + is_ssi_ineligible_spouse: [false, true] + # 416.1163(d): $2,000 exceeds the $497 threshold, so the head's benefit is + # figured as for a couple: ($2,000 - $20 - $65) / 2 = $957.50 countable, + # and $1,491 - $957.50 = $533.50 a month, or $6,402 a year. The spouse + # receives nothing. Without farm earnings both spouses were an eligible + # couple with no income, each receiving $1,491 / 2 = $745.50 a month. + # 416.1163(e)(2) cannot bind: $533.50 is below the $994 the head would + # receive with no deeming. + ssi: [6_402, 0] + +- name: Farm earnings that lift a spouse's wages over the SGA amount end the couple computation. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + spouse: + age: 50 + meets_ssi_disability_criteria: true + employment_income: 18_000 + farm_operations_income: 6_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [head, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Wages alone are $1,500 a month, under $1,690; with farm earnings the + # spouse's work earnings are $2,000, so the spouse is engaged in SGA and is + # an ineligible spouse. The head's deemed benefit is again + # $1,491 - ($2,000 - $85) / 2 = $533.50 a month, or $6,402 a year. + # Counting wages only, the spouses were an eligible couple with + # ($1,500 - $85) / 2 = $707.50 of combined countable income, each + # receiving ($1,491 - $707.50) / 2 = $391.75 a month, or $4,701 a year. + # 416.1163(e)(2) cannot bind: $533.50 is below $994. + is_ssi_disabled: [false, false] + is_ssi_ineligible_spouse: [false, true] + ssi: [6_402, 0] + +- name: A spouse's farm loss does not take wages above the SGA amount below it. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + head: + age: 67 + is_tax_unit_head: true + spouse: + age: 50 + meets_ssi_disability_criteria: true + employment_income: 21_600 + farm_operations_income: -6_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [head, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [head, spouse] + households: + household: + members: [head, spouse] + state_code: TX + output: + # Wages are $1,800 a month, above $1,690. POMS DI 10505.015 D.3 bars the + # $500 monthly farm loss from reducing them, so the spouse is engaged in + # SGA and not disabled (20 CFR 416.905(a), 416.971). Netting the loss + # would leave $1,300, under the SGA amount, and make the spouse disabled. + is_ssi_disabled: [false, false] + is_ssi_ineligible_spouse: [false, true] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml index 12c7afb14ea..d862dea707f 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ca/cdss/state_supplement/ca_state_supplement.yaml @@ -355,3 +355,33 @@ # 2,098.83 - 1,491 = 607.83; 2,098.83 - 2,098.83 = 0; # 2,356.57 - 2,098.83 = 257.74; 2,356.57 - 2,356.57 = 0. ca_state_supplement: [7_293.96, 0, 3_092.88, 0] + +- name: Case 10, 2026 aged individual with Social Security and farm profit. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 67 + is_tax_unit_head: true + immigration_status: CITIZEN + social_security_retirement: 13_200 # 1,100 a month + farm_operations_income: 30_000 # 2,500 a month + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_code: CA + living_arrangements_allow_for_food_preparation: false + output: + # The federally administered supplement uses SSI countable income + # (20 CFR 416.2025(b)), which counts farm net earnings as earned income. + # Countable unearned is 1,100 - 20 = 1,080 and countable earned is + # (2,500 - 65) / 2 = 1,217.50, for 2,297.50 a month. That exceeds both the + # 994 federal benefit rate and the 1,362.81 no-cooking payment standard, + # so SSI and the supplement are 0. Counting only the Social Security, + # the supplement would be (1,362.81 - 1,080) * 12 = 3,393.72. + ssi: 0 + ca_state_supplement: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.yaml index b818ebf1b25..a7661e08f7a 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.yaml @@ -190,3 +190,119 @@ # Countable unearned = max(600 - 562, 0) = $38. # Countable earned = 300 - (65 + 0.5 * (300 - 65)) = $117.50. ct_ssp_countable_income: 155.50 + +- name: A Connecticut applicant's partnership loss offsets spouse earnings exactly once. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 70 + partnership_self_employment_net_earnings: -5_000 + is_tax_unit_head: true + ssi: 0 + spouse: + age: 40 + employment_income: 20_000 + is_tax_unit_spouse: true + ssi: 0 + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: CT + output: + # Applicant: own earnings floor at 0, and SSI deems the spouse's + # 20,000 - 5,000 = 15,000 after-loss earnings, so the loss counts once: + # 15,000 / 12 = $1,250 monthly. $65 + half of ($1,250 - $65) = $657.50 is + # disregarded, leaving $592.50. + # Spouse: Connecticut counts this person's own 20,000 of wages in full + # (UPM 5020.05 A, 5020.70 C.3); the applicant's loss does not cross to + # them. 20,000 / 12 = $1,666.67; $65 + half of ($1,666.67 - $65) = + # $865.83 is disregarded, leaving $800.83. + ct_ssp_gross_income: [1_250, 1_666.6667] + ct_ssp_earned_income_disregard: [657.5, 865.8333] + ct_ssp_countable_income: [592.5, 800.8333] + +- name: An ineligible spouse's farm loss does not reduce Connecticut applicant earnings. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 70 + employment_income: 12_000 + is_tax_unit_head: true + ssi: 0 + spouse: + age: 40 + farm_operations_income: -5_000 + is_tax_unit_spouse: true + ssi: 0 + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: CT + output: + # UPM 5030.10 A takes the disregard from the applicant's own gross + # earnings, and no UPM text lets a spouse's loss reduce them, unlike SSI + # under POMS SI 00820.210 B.2.c. Applicant earnings stay 12,000 / 12 = + # $1,000 monthly. The spouse's after-loss earnings are 0, so nothing is + # deemed. Disregard: $65 + half of ($1,000 - $65) = $532.50; countable + # earned: $1,000 - $532.50 = $467.50. The spouse's own earnings floor at 0. + ct_ssp_gross_income: [1_000, 0] + ct_ssp_earned_income_disregard: [532.5, 0] + ct_ssp_countable_income: [467.5, 0] + +- name: Connecticut counts SSI raised by a spouse's loss, but not the loss itself. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 2_000 + self_employment_income: -7_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: CT + output: + # SSI applies the spouse's $5,000 residual loss to the applicant's wages + # (POMS SI 00820.210 B.2.c): countable income is + # (12,000 - 5,000 - 240 - 780) / 2 = $2,990 a year, so SSI is + # 12 * 994 - 2,990 = $8,938 a year, or $744.83 a month. + ssi: [744.8333, 0] + # Connecticut keeps the applicant's own $1,000 of monthly earnings and + # counts the SSI payment as unearned income: $1,000 + $744.83. + ct_ssp_gross_income: [1_744.8333, 0] + # $65 + half of ($1,000 - $65) = $532.50. + ct_ssp_earned_income_disregard: [532.5, 0] + # Earned: $1,000 - $532.50 = $467.50. Unearned: $744.83 - $562 community + # disregard = $182.83. Countable: $650.33. + ct_ssp_countable_income: [650.3333, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.yaml index 8a17594bee2..1ffb5c9be56 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.yaml @@ -51,6 +51,8 @@ members: [person1] state_code: KY output: + ky_ssp_earned_income: + 2026: [0] ky_ssp_countable_income: [0] - name: Case 4, ineligible person — countable income is zero. @@ -89,3 +91,254 @@ ky_ssp_countable_income: 2026-01: 500 2026-02: 1_000 + +- name: Case 6, active farming profit is earned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + farm_operations_income: 6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000] + # MS 2450 C: active farm income is earned; ($500 - $65) / 2. + ky_ssp_countable_income: [217.50] + +- name: Case 7, qualifying partnership profit is earned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + partnership_self_employment_net_earnings: 6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000] + ky_ssp_countable_income: [217.50] + +- name: Case 8, a farm loss cannot reduce wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 12_000 + farm_operations_income: -6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [12_000] + # Disallowed business losses do not reduce the $1,000 monthly wage. + ky_ssp_countable_income: [467.50] + +- name: Case 9, a Schedule C loss cannot reduce another business's farm profit. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + self_employment_income: -6_000 + farm_operations_income: 6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000] + # MS 2450 C.8(b) disallows loss from one business to another. + ky_ssp_countable_income: [217.50] + +- name: Case 10, a partnership loss cannot reduce positive SSTB income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + sstb_self_employment_income: 6_000 + partnership_self_employment_net_earnings: -6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000] + ky_ssp_countable_income: [217.50] + +- name: Case 11, pretax wage premiums cannot reduce business income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 1_200 + pre_tax_health_insurance_premiums: 2_400 + farm_operations_income: 6_000 + marital_units: + marital_unit: + members: [person1] + households: + household: + members: [person1] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000] + ky_ssp_countable_income: [217.50] + +- name: Case 12, existing eligible-couple computation pools Kentucky own earnings. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 6_000 + is_tax_unit_head: true + person2: + age: 70 + farm_operations_income: 6_000 + is_tax_unit_spouse: true + marital_units: + marital_unit: + members: [person1, person2] + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [6_000, 6_000] + ssi_couple_computation_applies: [true, true] + # Combined ($1,000 - $65) / 2, then half attributed to each person. + ky_ssp_countable_income: [233.75, 233.75] + +- name: Case 13, applicant farm loss cannot reduce ineligible spouse wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + farm_operations_income: -6_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 20_000 + is_tax_unit_spouse: true + marital_units: + couple: + members: [applicant, spouse] + tax_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [0, 20_000] + # Before this fix SSI-based deemed earnings were $14,000; now $20,000. + ky_ssp_earned_income_deemed_from_ineligible_spouse: + 2026: [20_000, 0] + # Before: $540.83. Retained deeming: ($20,000 / 12 - $85) / 2. + ky_ssp_countable_income: [790.83, 0] + +- name: Case 14, ineligible spouse business loss cannot reduce that spouse wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + farm_operations_income: 0 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 20_000 + self_employment_income: -6_000 + is_tax_unit_spouse: true + marital_units: + couple: + members: [applicant, spouse] + tax_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: KY + output: + ky_ssp_earned_income: + 2026: [0, 20_000] + # Before: $14,000 deemed earnings and $540.83 monthly countable income. + ky_ssp_earned_income_deemed_from_ineligible_spouse: + 2026: [20_000, 0] + ky_ssp_countable_income: [790.83, 0] + +- name: Case 15, combine applicant and spouse earnings before the earned disregard. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 600 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 20_000 + is_tax_unit_spouse: true + marital_units: + couple: + members: [applicant, spouse] + tax_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: KY + output: + ky_ssp_earned_income_deemed_from_ineligible_spouse: + 2026: [20_000, 0] + # Section 8(6): retained deeming combines $600 + $20,000 before exclusions. + ky_ssp_countable_income: [815.83, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/md/dhs/fia/paa/md_paa_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/md/dhs/fia/paa/md_paa_countable_income.yaml index b66a6235b4a..949f97e65f5 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/md/dhs/fia/paa/md_paa_countable_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/md/dhs/fia/paa/md_paa_countable_income.yaml @@ -45,3 +45,104 @@ # $2,500 monthly SSDI - $20 general exclusion. ssi_countable_income: 0 md_paa_countable_income: 2_480 + +- name: An ineligible spouse's business loss does not reduce the applicant's PAA earnings. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 12_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 2_000 + self_employment_income: -7_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: MD + output: + # SSI applies the spouse's $5,000 residual loss to the applicant's wages + # (POMS SI 00820.210 B.2.c), but COMAR 07.03.07.08 sets deductions for + # the individual and no Maryland text adopts that offset. PAA counts the + # applicant's own $12,000: (12,000 - 240 - 780) / 2 = $5,490. The spouse's + # after-loss earnings are 0, so no income is deemed. + ssi_income_deemed_from_ineligible_spouse: [0, 0] + md_paa_countable_income: [5_490, 0] + +- name: An applicant's own loss can bring the spouse's deemed earnings under the SSI threshold. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + self_employment_income: -5_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 10_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: MD + output: + # The applicant's own earnings floor at 0. POMS SI 00820.210 B.2.c applies + # the applicant's residual loss to the ineligible spouse's earnings, + # leaving 10,000 - 5,000 = 5,000, which does not exceed the + # (1,491 - 994) * 12 = 5,964 deeming threshold (20 CFR 416.1163(d)(1)). + # SSI deems nothing, so PAA counts 0, as on main. The spouse's own PAA + # figure counts the spouse's 10,000: (10,000 - 240 - 780) / 2 = 4,490. + ssi_income_deemed_from_ineligible_spouse: [0, 0] + md_paa_countable_income: [0, 4_490] + +- name: An applicant's own loss reduces deemed spouse earnings once. + period: 2026 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + self_employment_income: -5_000 + is_tax_unit_head: true + spouse: + age: 60 + employment_income: 15_000 + is_tax_unit_spouse: true + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: MD + output: + # SSI deems the spouse's 15,000 - 5,000 = 10,000 after-loss earnings, + # above the 5,964 threshold. Countable alone is 0; combined is + # (10,000 - 240 - 780) / 2 = 4,490, all deemed, and the applicant's own + # earnings floor at 0. The spouse's own figure is + # (15,000 - 240 - 780) / 2 = 6,990. + ssi_income_deemed_from_ineligible_spouse: [4_490, 0] + md_paa_countable_income: [4_490, 6_990] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/income/mn_msa_gross_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/income/mn_msa_gross_income.yaml new file mode 100644 index 00000000000..3eb064c878f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/income/mn_msa_gross_income.yaml @@ -0,0 +1,119 @@ +- name: A business loss absorbs restored pretax premiums before Minnesota gross earnings are floored. + period: 2026-01 + absolute_error_margin: 0.01 + input: + age: 67 + state_code: MN + employment_income: 1_200 + pre_tax_health_insurance_premiums: 1_200 + self_employment_income: -2_000 + ssi: 0 + output: + # Gross wage income 1,200 cannot cover the 2,000 business loss. + # Restoring the premium to an already floored SSI budget would create + # a false positive 100/month gross-income amount. + mn_msa_gross_income: 0 + +- name: Minnesota does not deduct an own loss again after it offsets the spouse's earnings. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 67 + employment_income: 1_200 + pre_tax_health_insurance_premiums: 1_200 + self_employment_income: -2_000 + is_tax_unit_head: true + ssi: 0 + spouse: + age: 60 + employment_income: 12_000 + is_tax_unit_spouse: true + ssi: 0 + households: + household: + members: [applicant, spouse] + state_code: MN + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + output: + people: + applicant: + # Deemed earnings = 12,000 - 2,000; own restoration = 1,200. + # (10,000 + 1,200) / 12 = 933.3333. + mn_msa_gross_income: 933.3333 + +- name: Both spouses' remaining losses absorb their own premiums once in Minnesota's gross test. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 67 + employment_income: 1_500 + pre_tax_health_insurance_premiums: 1_500 + farm_operations_income: -1_000 + is_tax_unit_head: true + ssi: 0 + second: + age: 67 + employment_income: 1_500 + pre_tax_health_insurance_premiums: 1_500 + partnership_self_employment_net_earnings: -1_000 + is_tax_unit_spouse: true + ssi: 0 + households: + household: + members: [first, second] + state_code: MN + tax_units: + couple: + members: [first, second] + tax_unit_is_joint: true + marital_units: + couple: + members: [first, second] + output: + # Each spouse has 1,500 gross wages less a 1,000 loss = 500/year. + mn_msa_gross_income: [41.6667, 41.6667] + +- name: A spouse's remaining loss also absorbs premiums when their own wages cannot cover it. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + first: + age: 67 + employment_income: 1_000 + pre_tax_health_insurance_premiums: 1_000 + farm_operations_income: -2_000 + is_tax_unit_head: true + ssi: 0 + second: + age: 67 + employment_income: 5_000 + pre_tax_health_insurance_premiums: 5_000 + partnership_self_employment_net_earnings: -2_000 + is_tax_unit_spouse: true + ssi: 0 + households: + household: + members: [first, second] + state_code: MN + tax_units: + couple: + members: [first, second] + tax_unit_is_joint: true + marital_units: + couple: + members: [first, second] + output: + # First spouse's 1,000 remaining loss offsets second spouse's gross + # earnings after their own 2,000 loss: 5,000 - 2,000 - 1,000 = 2,000. + mn_msa_gross_income: [0, 166.6667] diff --git a/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/mn_msa_person.yaml b/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/mn_msa_person.yaml index 550263a1c0a..778da58783b 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/mn_msa_person.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/mn_msa_person.yaml @@ -29,6 +29,76 @@ # Need standard $1,055 - max($994 SSI - $20 general, 0) - countable $0 = $81. mn_msa_person: 81 +- name: A Minnesota applicant's partnership loss offsets deemed spouse earnings exactly once. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 70 + partnership_self_employment_net_earnings: -5_000 + is_tax_unit_head: true + ssi_countable_resources: 500 + ssi: 0 + spouse: + age: 40 + employment_income: 35_000 + is_tax_unit_spouse: true + ssi_countable_resources: 500 + ssi: 0 + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: MN + output: + # 35,000 - 5,000 = 30,000 annual earnings, or $2,500 per monthly budget. + mn_msa_gross_income: [2_500, 2_500] + # ($2,500 - $20 - $65) / 2 = $1,207.50, above the $1,055 standard. + # Deducting the loss twice would incorrectly leave $999.17 countable. + mn_msa_net_income_eligible: [false, false] + mn_msa_person: [0, 0] + +- name: An ineligible spouse's farm loss reduces Minnesota applicant earnings before exclusions. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + applicant: + age: 70 + employment_income: 12_000 + is_tax_unit_head: true + ssi_countable_resources: 500 + ssi: 0 + spouse: + age: 40 + farm_operations_income: -5_000 + is_tax_unit_spouse: true + ssi_countable_resources: 500 + ssi: 0 + tax_units: + couple: + members: [applicant, spouse] + tax_unit_is_joint: true + marital_units: + couple: + members: [applicant, spouse] + households: + household: + members: [applicant, spouse] + state_code: MN + output: + mn_msa_gross_income: [583.3333, 0] + mn_msa_net_income_eligible: [true, true] + # $1,055 - ((7,000 / 12 - $20 - $65) / 2) = $805.83 for the applicant. + mn_msa_person: [805.8333, 0] + - name: Case 2, non-SSI track aged person with $1,000/mo unearned income living alone. period: 2026-01 absolute_error_margin: 0.01 diff --git a/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml b/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml new file mode 100644 index 00000000000..b6862df7bf4 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml @@ -0,0 +1,70 @@ +# He-W 601.04(n), 654.01(b)-(c), 654.15(b), 654.16(b): business profits +# are earned income; a net self-employment loss cannot reduce wages. +# https://gc.nh.gov/rules/state_agencies/he-w600.html +- name: Farm profit counts as New Hampshire earned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NH + age: 40 + is_blind: true + farm_operations_income: 12_000 + ssi: 0 + output: + # (12,000 / 12 - 85) * 0.5 - 13 = 444.50. + nh_ssp_countable_income: 444.5 + +- name: Partnership self-employment profit counts as New Hampshire earned income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NH + age: 40 + is_blind: true + partnership_self_employment_net_earnings: 12_000 + ssi: 0 + output: + nh_ssp_countable_income: 444.5 + +- name: A farm loss cannot reduce New Hampshire wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NH + age: 40 + is_blind: true + employment_income: 12_000 + farm_operations_income: -6_000 + ssi: 0 + output: + # New Hampshire floors the net business loss before adding wages. + nh_ssp_countable_income: 444.5 + +- name: A Schedule C loss cannot reduce New Hampshire wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NH + age: 40 + is_blind: true + employment_income: 12_000 + self_employment_income: -6_000 + ssi: 0 + output: + # He-W 654.01 applies equally to the existing Schedule C source. + nh_ssp_countable_income: 444.5 + +- name: New Hampshire floors aggregate self-employment before adding wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + state_code: NH + age: 40 + is_blind: true + employment_income: 12_000 + farm_operations_income: -6_000 + partnership_self_employment_net_earnings: 12_000 + ssi: 0 + output: + # (1,000 wages + 500 net business profit - 85) * 0.5 - 13 = 694.50. + nh_ssp_countable_income: 694.5 diff --git a/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp_independent.yaml b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp_independent.yaml new file mode 100644 index 00000000000..ae937bca061 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp_independent.yaml @@ -0,0 +1,22 @@ +# ARSD 67:12:14:03(2): any other income ends the independent-living supplement. +# https://sdlegislature.gov/Rules/Administrative/67%3A12%3A14%3A03 +- name: Farm profit is other income for South Dakota independent supplementation. + period: 2026-01 + input: + state_code: SD + age: 70 + ssi: 994 + farm_operations_income: 1_200 + output: + # The $100 monthly farm profit is other income even while SSI is paid. + sd_ssp_independent: 0 + +- name: Partnership earnings are other income for South Dakota independent supplementation. + period: 2026-01 + input: + state_code: SD + age: 70 + ssi: 994 + partnership_self_employment_net_earnings: 1_200 + output: + sd_ssp_independent: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/states/wy/health/ssp/wy_ssp_eligible.yaml b/policyengine_us/tests/policy/baseline/gov/states/wy/health/ssp/wy_ssp_eligible.yaml new file mode 100644 index 00000000000..53f16d03d86 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/states/wy/health/ssp/wy_ssp_eligible.yaml @@ -0,0 +1,22 @@ +# W.S. 42-2-103(d): an SSI recipient must have no other income in the month. +# https://wyoleg.gov/statutes/compress/title42.pdf#page=7 +- name: Farm profit prevents Wyoming supplement eligibility. + period: 2026-01 + input: + state_code: WY + age: 70 + ssi: 994 + farm_operations_income: 1_200 + output: + # The $100 monthly farm profit means SSI is not the sole income source. + wy_ssp_eligible: false + +- name: Partnership earnings prevent Wyoming supplement eligibility. + period: 2026-01 + input: + state_code: WY + age: 70 + ssi: 994 + partnership_self_employment_net_earnings: 1_200 + output: + wy_ssp_eligible: false diff --git a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py index a692202dac3..be9236cbf7a 100644 --- a/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/hhs/medicaid/income/deemed/from_ineligible_spouse/medicaid_optional_senior_or_disabled_income_deemed_from_ineligible_spouse.py @@ -26,7 +26,7 @@ def formula(person, period, parameters): spouse_unearned = person( "ssi_unearned_income_deemed_from_ineligible_spouse", period ) - individual_earned = person("ssi_earned_income", period) + individual_earned = person("ssi_earned_income_after_spousal_loss", period) individual_unearned = person("ssi_unearned_income", period) p = parameters( diff --git a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py index 99b10f12989..499ef640c58 100644 --- a/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py +++ b/policyengine_us/variables/gov/hhs/medicare/savings_programs/income/msp_countable_income.py @@ -44,7 +44,7 @@ class msp_countable_income(Variable): def formula(person, period, parameters): year = period.this_year - earned_income = person("ssi_earned_income", year) + earned_income = person("ssi_earned_income_after_spousal_loss", year) unearned_income = person("ssi_unearned_income", year) is_medicare_eligible = person("is_medicare_eligible", year) both_medicare_eligible = person.marital_unit.sum(is_medicare_eligible) == 2 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py index 2be85271634..f55e756b76d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_earned_income_deemed_from_ineligible_spouse.py @@ -19,7 +19,7 @@ def formula(person, period, parameters): marital_unit.sum(ineligible_spouse * unearned_income) - ineligible_spouse * unearned_income ) - earned_income = person("ssi_earned_income", period) + earned_income = person("ssi_earned_income_after_spousal_loss", period) ineligible_spousal_earned_income = ( marital_unit.sum(ineligible_spouse * earned_income) - ineligible_spouse * earned_income diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py index 8a885f8d66f..d035314e092 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/from_ineligible_spouse/ssi_income_deemed_from_ineligible_spouse.py @@ -37,7 +37,7 @@ def formula(person, period, parameters): ) # Get individual's own income - individual_earned = person("ssi_earned_income", period) + individual_earned = person("ssi_earned_income_after_spousal_loss", period) individual_unearned = person("ssi_unearned_income", period) # Calculate individual's countable income (alone) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py index 36aeedef7e9..4ba89b3d89d 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/deemed/ssi_ineligible_child_allocation.py @@ -11,7 +11,11 @@ class ssi_ineligible_child_allocation(Variable): reference = "https://www.law.cornell.edu/cfr/text/20/416.1163" def formula(person, period, parameters): - income = add(person, period, ["ssi_earned_income", "ssi_unearned_income"]) + # 416.1111(b) permits net losses to offset only earned income; + # 416.1163(b) reduces the allocation by the child's own income. + income = max_(person("ssi_earned_income", period), 0) + person( + "ssi_unearned_income", period + ) ssi = parameters(period).gov.ssa.ssi.amount allocation = ( person("is_ssi_ineligible_child", period) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py index 4e813069180..1fa3b725f37 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/marital/ssi_marital_earned_income.py @@ -9,7 +9,7 @@ class ssi_marital_earned_income(Variable): def formula(person, period, parameters): couple_computation = person("ssi_couple_computation_applies", period) - earned_income = person("ssi_earned_income", period) + earned_income = person("ssi_earned_income_after_spousal_loss", period) return where( couple_computation, diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.py index 39111dcf701..3ab18755d9c 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_blind_or_disabled_working_student_exclusion.py @@ -14,6 +14,8 @@ def formula(person, period, parameters): p = parameters( period ).gov.ssa.ssi.income.exclusions.blind_or_disabled_working_student - monthly_earned_income = person("ssi_earned_income", period) / MONTHS_IN_YEAR + monthly_earned_income = ( + max_(person("ssi_earned_income", period), 0) / MONTHS_IN_YEAR + ) max_amount = min_(p.amount, monthly_earned_income) * MONTHS_IN_YEAR return min_(max_amount, p.cap) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_earned_income_after_spousal_loss.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_earned_income_after_spousal_loss.py new file mode 100644 index 00000000000..b547e9fcd79 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_earned_income_after_spousal_loss.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) + + +class ssi_earned_income_after_spousal_loss(Variable): + value_type = float + entity = Person + label = "SSI earned income after self-employment losses of either spouse" + unit = USD + definition_period = YEAR + reference = ( + "https://www.ssa.gov/OP_Home/cfr20/416/416-1111.htm", + "https://secure.ssa.gov/poms.nsf/lnx/0500820210", + ) + documentation = ( + "SSI budget earned income after applying own business losses to own " + "earnings, then any excess business loss of a co-resident spouse. " + "Residual losses do not reduce unearned income. The signed individual " + "earnings remain available in ssi_earned_income for other programs." + ) + + def formula(person, period, parameters): + earned = person("ssi_earned_income", period) + if not np.any(earned < 0): + return earned + spouse = _ssi_spouse_index(person, period) + spouse_earned = where(spouse >= 0, earned[np.maximum(spouse, 0)], 0) + # POMS SI 00820.210 B.2.c applies losses to own earnings first, + # then to the spouse's earnings, including an ineligible spouse. + # Each positive earner absorbs only the other spouse's excess loss; + # the loss-maker's zero balance prevents double netting when pooled. + return max_(earned + min_(spouse_earned, 0), 0) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_engaged_in_sga.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_engaged_in_sga.py index d116dabb369..788094d32aa 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_engaged_in_sga.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_engaged_in_sga.py @@ -9,19 +9,34 @@ class ssi_engaged_in_sga(Variable): reference = ( "https://www.ssa.gov/OP_Home/cfr20/416/416-0971.htm", "https://secure.ssa.gov/poms.nsf/lnx/0410505010", + "https://secure.ssa.gov/poms.nsf/lnx/0410505015", + "https://secure.ssa.gov/poms.nsf/lnx/0410510012", ) documentation = ( - "Whether non-blind work earnings exceed the SGA limit. Payroll " - "health insurance premiums remain in these earnings, including " - "pretax salary reductions excluded from SSI payment income." + "Whether non-blind work earnings exceed the SGA limit. Work earnings " + "are wages plus the net of the business earned-income sources, " + "floored at zero: losses net across businesses but never reduce " + "wages. Payroll health insurance premiums remain in these earnings, " + "including pretax salary reductions excluded from SSI payment income." ) def formula(person, period, parameters): - income = person("ssi_earned_income", period) + sources = parameters(period).gov.ssa.ssi.income.sources.earned + business_sources = [ + source for source in sources if source != "employment_income" + ] + business_net = add(person, period, business_sources) if business_sources else 0 + # POMS DI 10505.015 D.3: a self-employment loss does not reduce total + # earnings when the individual also has wages. Losses still net + # between businesses (20 CFR 404.1081(c), POMS DI 10510.012 B), so + # only a net business loss is added back. + restored_loss = max_(-business_net, 0) + income = person("ssi_earned_income", period) + restored_loss # POMS DI 10505.010A counts payroll insurance premiums for SGA. # Restore exactly the wage-limited premium exclusion made by # ssi_earned_income, preserving the prior gross work-earnings base. - sources = parameters(period).gov.ssa.ssi.income.sources.earned + # With ssi_earned_income computed, income is wages plus the business + # net floored at zero. if "employment_income" in sources: wages = max_(person("employment_income", period), 0) pretax_premiums = max_( diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py index 44c69bb62e6..c72d8224a0a 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_countable_income.py @@ -33,7 +33,7 @@ def formula(person, period, parameters): person, period, [ - "ssi_earned_income", + "ct_ssp_own_earned_income", "ssi_earned_income_deemed_from_ineligible_spouse", ], ) diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py index ca004a4ce77..3482f9927ef 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_earned_income_disregard.py @@ -19,7 +19,7 @@ def formula(person, period, parameters): person, period, [ - "ssi_earned_income", + "ct_ssp_own_earned_income", "ssi_earned_income_deemed_from_ineligible_spouse", ], ) diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py index e8e2a0cb64a..9d767e6e73e 100644 --- a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_gross_income.py @@ -20,7 +20,7 @@ def formula(person, period, parameters): person, period, [ - "ssi_earned_income", + "ct_ssp_own_earned_income", "ssi_unearned_income", "ssi", "ssi_earned_income_deemed_from_ineligible_spouse", diff --git a/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_own_earned_income.py b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_own_earned_income.py new file mode 100644 index 00000000000..fd063e7a246 --- /dev/null +++ b/policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_own_earned_income.py @@ -0,0 +1,34 @@ +from policyengine_us.model_api import * + + +class ct_ssp_own_earned_income(Variable): + value_type = float + entity = Person + label = "Connecticut SSP own earned income" + unit = USD + definition_period = YEAR + defined_for = StateCode.CT + reference = ( + "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/upms/upm5---treatment-of-income-income-eligibility/5020_05.doc", + "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/upms/upm5---treatment-of-income-income-eligibility/5020_70.doc", + "https://portal.ct.gov/dss/-/media/departments-and-agencies/dss/upms/upm5---treatment-of-income-income-eligibility/5030_10.doc", + ) + documentation = ( + "A person's own earned income in a Connecticut AABD budget. UPM " + "5030.10 A subtracts earned disregards from the assistance unit's " + "monthly total available gross earned income. For a spouse's income, " + "UPM 5020.70 C.3.a reduces the deemor's self-employment earnings by " + "self-employment expenses and C.3.d deems the combined total of the " + "deemor's gross unearned and applied earned income; UPM 5020.05 A " + "counts the deemor's income in full except for that chapter's " + "reductions. No UPM text adopts SSI's income computation or lets a " + "spouse's residual business loss reduce this person's earnings, so " + "the SSI offset under POMS SI 00820.210 B.2.c does not apply here. " + "Own business losses still reduce own wages. The result is floored at " + "zero: an own residual loss already reduces any earnings deemed from " + "an ineligible spouse, which use that spouse's after-loss SSI " + "earnings, so it must not be deducted again." + ) + + def formula(person, period, parameters): + return max_(person("ssi_earned_income", period), 0) diff --git a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py index ed143bb9dfd..b5ede063daa 100644 --- a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_countable_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) class ky_ssp_countable_income(Variable): @@ -19,10 +22,22 @@ class ky_ssp_countable_income(Variable): ) def formula(person, period, parameters): - # Mirror ssi_countable_income but skip the $20 general exclusion per - # §8(8). The $65 earned exclusion and 50% earned share still apply. + # Kentucky uses its own business-loss rule under MS 2450 C.8(b), + # incorporated by 921 KAR 2:015 §8(1). Retain the existing couple + # computation flag and the $65 / 50% exclusions, without §8(8)'s + # disallowed $20 general exclusion. + both_eligible = person("ssi_couple_computation_applies", period) + own_earned = person("ky_ssp_earned_income", period.this_year) + marital_earned = ( + where( + both_eligible, + person.marital_unit.sum(own_earned), + own_earned, + ) + / MONTHS_IN_YEAR + ) earned = max_( - person("ssi_marital_earned_income", period) + marital_earned - person("ssi_blind_or_disabled_working_student_exclusion", period), 0, ) @@ -35,8 +50,41 @@ def formula(person, period, parameters): countable_earned = max_(earned - p.earned, 0) * (1 - p.earned_share) personal_countable = unearned + countable_earned - both_eligible = person("ssi_couple_computation_applies", period) - spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + # Retain the SSI deeming difference and threshold, using Kentucky + # earnings on both sides so neither spouse's loss crosses to the other. + # Section 8(6) combines earnings before the $65 / half disregard. + year = period.this_year + spouse_earned = person( + "ky_ssp_earned_income_deemed_from_ineligible_spouse", year + ) + spouse_unearned = person( + "ssi_unearned_income_deemed_from_ineligible_spouse", year + ) + own_unearned = person("ssi_unearned_income", year) + amount = parameters(period).gov.ssa.ssi.amount + deeming_applies = ( + spouse_earned + spouse_unearned + > (amount.couple - amount.individual) * MONTHS_IN_YEAR + ) & person("is_ssi_eligible_individual", year) + alone_countable = _apply_ssi_exclusions( + own_earned, own_unearned, parameters, year + ) + couple_countable = _apply_ssi_exclusions( + own_earned + spouse_earned, + own_unearned + spouse_unearned, + parameters, + year, + ) + spousal_deemed = ( + deeming_applies + * max_(couple_countable - alone_countable, 0) + / MONTHS_IN_YEAR + ) + + # Existing student/unearned and parent deeming inputs remain an + # SSI-based approximation. The ordering of Kentucky's separate + # §8(3) conservation and §8(6) earnings rules needs a fuller state + # methodology review; this loss correction does not decide it. is_eligible = person("is_ssi_eligible_individual", period.this_year) | person( "is_ssi_eligible_spouse", period.this_year ) diff --git a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py new file mode 100644 index 00000000000..bb061071449 --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py @@ -0,0 +1,42 @@ +from policyengine_us.model_api import * + + +class ky_ssp_earned_income(Variable): + value_type = float + entity = Person + label = "Kentucky SSP earned income" + unit = USD + definition_period = YEAR + defined_for = StateCode.KY + reference = ( + "https://apps.legislature.ky.gov/law/kar/titles/921/002/015/", + # PDF pages 187-188. + "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=187", + ) + documentation = ( + "Kentucky SSP own earned income, retaining the existing wage-limited " + "pretax premium exclusion. 921 KAR 2:015 section 8(1) incorporates " + "medically needy policy. Volume IVA MS 2450 B.4 measures business " + "profit; C.7 lists allowable deductions; C.8(b) forbids transferring " + "a loss between businesses. No Kentucky text authorizes a loss " + "against other income, so each business is floored at zero: its loss " + "reduces neither another business's profit nor wages or a spouse's " + "earnings. This departs from SSI's loss offsets under POMS SI " + "00820.210 A.2 and B.2.b-c. Inputs may " + "already aggregate multiple businesses; their separate profits and " + "Kentucky-specific expense adjustments are not exposed." + ) + + def formula(person, period, parameters): + sources = parameters( + period + ).gov.states.ky.dcbs.ssp.earned.self_employment_sources + # MS 2450 B.4, C.7 and C.8(b): profit after allowable expenses, + # without transferring losses to other businesses or other income. + floored_business_income = [ + max_(person(source, period), 0) for source in sources + ] + business_income = sum(floored_business_income) + wages = max_(person("employment_income", period), 0) + pretax_premiums = max_(person("pre_tax_health_insurance_premiums", period), 0) + return wages - min_(wages, pretax_premiums) + business_income diff --git a/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py new file mode 100644 index 00000000000..773ed2fc8a1 --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class ky_ssp_earned_income_deemed_from_ineligible_spouse(Variable): + value_type = float + entity = Person + label = "Kentucky SSP earned income deemed from an ineligible spouse" + unit = USD + definition_period = YEAR + defined_for = StateCode.KY + reference = ( + "https://apps.legislature.ky.gov/law/kar/titles/921/002/015/", + # PDF pages 187-188. + "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=187", + "https://www.law.cornell.edu/cfr/text/20/416.1163", + ) + documentation = ( + "Retain SSI's child-allocation arithmetic using the ineligible spouse's " + "own Kentucky earned income. MS 2450 B.4, C.7 and C.8(b), incorporated " + "by 921 KAR 2:015 section 8(1), count each business's profit without " + "offsetting other earnings with losses. Neither spouse's business " + "loss therefore reduces the ineligible spouse's deemed earnings." + ) + + def formula(person, period, parameters): + ineligible_spouse = person("is_ssi_ineligible_spouse", period) + unearned = person("ssi_unearned_income", period) + earned = person("ky_ssp_earned_income", period) + marital_unit = person.marital_unit + spouse_unearned = ( + marital_unit.sum(ineligible_spouse * unearned) + - ineligible_spouse * unearned + ) + spouse_earned = ( + marital_unit.sum(ineligible_spouse * earned) - ineligible_spouse * earned + ) + child_allocations = add( + person.tax_unit, period, ["ssi_ineligible_child_allocation"] + ) * person("is_ssi_aged_blind_disabled", period) + remaining_allocations = max_(child_allocations - spouse_unearned, 0) + return max_(spouse_earned - remaining_allocations, 0) diff --git a/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_countable_income.py b/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_countable_income.py index 22dcb53c08f..190d84b3db1 100644 --- a/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_countable_income.py +++ b/policyengine_us/variables/gov/states/md/dhs/fia/paa/md_paa_countable_income.py @@ -22,7 +22,19 @@ def formula(person, period, parameters): # exclusions, but it must count income for every PAA eligibility # pathway. Using ssi_countable_income would zero out SSDI-only # recipients unless an input also marks them as SSI-disabled. - earned = person("ssi_marital_earned_income", period) + both_eligible = person("ssi_couple_computation_applies", period.this_year) + # COMAR 07.03.07.08 sets earned-income deductions for an individual, + # and DHS states that "The assistance unit is always one person." No + # Maryland text adopts SSI's income computation or lets a spouse's + # residual business loss reduce the applicant's earnings, as POMS SI + # 00820.210 B.2.c does for SSI. Outside the couple computation, read + # the applicant's own signed earnings; the floor below stops an own + # residual loss from reducing unearned income. + earned = where( + both_eligible, + person("ssi_marital_earned_income", period), + person("ssi_earned_income", period), + ) student_exclusion = person( "ssi_blind_or_disabled_working_student_exclusion", period ) @@ -43,7 +55,6 @@ def formula(person, period, parameters): period, ) - both_eligible = person("ssi_couple_computation_applies", period.this_year) spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) return where( both_eligible, diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py index bd8a807be09..5a3158aec8a 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/eligibility/mn_msa_net_income_eligible.py @@ -70,7 +70,7 @@ def formula(person, period, parameters): person, period.this_year, [ - "ssi_earned_income", + "ssi_earned_income_after_spousal_loss", "ssi_earned_income_deemed_from_ineligible_spouse", ], ) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py b/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py index a6438e3c287..37cd332d221 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/income/mn_msa_gross_income.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.resources.deemed._ssi_spouses import ( + _ssi_spouse_index, +) class mn_msa_gross_income(Variable): @@ -21,7 +24,7 @@ def formula(person, period, parameters): person, period, [ - "ssi_earned_income", + "ssi_earned_income_after_spousal_loss", "ssi_unearned_income", "ssi_earned_income_deemed_from_ineligible_spouse", "ssi_unearned_income_deemed_from_ineligible_spouse", @@ -39,7 +42,24 @@ def formula(person, period, parameters): premiums = max_( person("pre_tax_health_insurance_premiums", period.this_year), 0 ) - income += min_(wages, premiums) / MONTHS_IN_YEAR + restoration = min_(wages, premiums) + earned = person("ssi_earned_income", period.this_year) + if np.any(earned < 0): + spouse = _ssi_spouse_index(person, period.this_year) + safe_spouse = np.maximum(spouse, 0) + spouse_earned = where(spouse >= 0, earned[safe_spouse], 0) + spouse_restoration = where(spouse >= 0, restoration[safe_spouse], 0) + # Restore premiums before flooring the gross earned pool. + # Losses already absorbed by a spouse's positive SSI earnings + # must not be deducted twice. Any remaining loss absorbs own + # premiums first, then the spouse's, just as other earnings do. + own_loss = max_(-earned - max_(spouse_earned, 0), 0) + spouse_loss = max_(-spouse_earned - max_(earned, 0), 0) + restoration = max_( + restoration - own_loss - max_(spouse_loss - spouse_restoration, 0), + 0, + ) + income += restoration / MONTHS_IN_YEAR # CM 0017 substitutes the full FBR for an SSI recipient's payment. ssi = person("ssi", period) diff --git a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py index 4019169214c..ac48eb42ca1 100644 --- a/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py +++ b/policyengine_us/variables/gov/states/mn/dhs/msa/mn_msa_person.py @@ -84,7 +84,7 @@ def formula(person, period, parameters): person, period.this_year, [ - "ssi_earned_income", + "ssi_earned_income_after_spousal_loss", "ssi_earned_income_deemed_from_ineligible_spouse", ], ) diff --git a/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py b/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py index 3df789f2c0c..163dceb1cc5 100644 --- a/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py +++ b/policyengine_us/variables/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.py @@ -28,8 +28,11 @@ def formula(person, period, parameters): # deductions", and He-W 600 excludes no cafeteria-plan salary reduction, # so pre-tax health premiums stay in the state's wage count. Federal SSI # excludes them (POMS SI 00820.102 C.2), and the ssi term keeps that. - sources = parameters(period).gov.ssa.ssi.income.sources.earned - earned = max_(add(person, period, sources), 0) + # He-W 654.01(b)-(c) floors self-employment income at zero before + # adding wages: business losses cannot offset other income. Keep a + # state source list because SSI permits that earned-income offset. + self_employment = max_(add(person, period, p.earned.self_employment_sources), 0) + earned = max_(person("employment_income", period), 0) + self_employment expenses = max_(person("nh_ssp_employment_expenses", period), 0) # With a nonapplicant spouse, combine earnings before the applicant's # category disregard under He-W 654.02(d)-(e). diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 5e4066769ca..9e3ab6fc785 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -9716,6 +9716,34 @@ candidates: which counts any SSI receipt among the benefits in gov.usda.disabled_programs (is_snap_alien_waiting_period_exempt.py, is_usda_disabled.py, disabled_programs.yaml). The statute-first rule requires revising the any-SSI exemption if the narrower reading holds; first check the cross-referenced disability definition and the preamble''s scope.' +- id: us:ssi-sga-self-employment-loss + program: ssi + question: In the SSI substantial gainful activity test, does a self-employment loss reduce the wages of a person + who has both wages and a business? + sources: + - kind: regulation + citation: 20 CFR 416.971 to 416.975 + url: https://www.ssa.gov/OP_Home/cfr20/416/416-0971.htm + - kind: manual + citation: POMS DI 10505.015 D.3 + url: https://secure.ssa.gov/poms.nsf/lnx/0410505015 + readings: + statute: No statute is supplied, and 20 CFR 416.971 to 416.975 do not settle the question. One reading sums wages + and net self-employment earnings, so a business loss reduces wages. + other: 'POMS DI 10505.015 D.3: "When an individual has wages and has a loss from self-employment, do not use the + loss to reduce total earnings." Losses still net between businesses (20 CFR 404.1081(c), POMS DI 10510.012 B).' + example: With $21,600 of 2026 wages ($1,800 a month) and a $6,000 farm loss, SGA earnings stay $1,800 a month, + above the $1,690 SGA amount, so the person is engaged in SGA; netting the loss would leave $1,300 and no SGA. + pe_us: + follows: gap-fill + code: + - policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_engaged_in_sga.py + prs: + - https://github.com/PolicyEngine/policyengine-us/pull/10532 + state: ruled + note: 'The regulations do not settle the question, so the administering agency''s reading governs. SGA earnings are + wages plus the net of the business earned-income sources floored at zero; ssi_earned_income keeps the signed + amount for SSI payment income. Code not yet on main: the floor in ssi_engaged_in_sga.py.' - id: us:ssi-spousal-deeming-child-scope program: ssi years: