From 32ac58c44772875fc34b1027c5d03e0727be1f94 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 07:33:26 -0400 Subject: [PATCH 01/16] Include farm and partnership earnings in SSI earned income Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 1 + .../gov/ssa/ssi/income/sources/earned.yaml | 8 +- .../gov/ssa/ssi/income/ssi_earned_income.yaml | 47 ++++++++++++ .../policy/baseline/gov/ssa/ssi/ssi.yaml | 74 +++++++++++++++++++ 4 files changed, 129 insertions(+), 1 deletion(-) create mode 100644 changelog.d/ssi-earned-farm-partnership.fixed.md 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..38d3fc51afc --- /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. 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/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/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] From 150b05372c7afaa5430f709a71b2d1486b425c4f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 07:39:24 -0400 Subject: [PATCH 02/16] Apply SSI business losses only to earned income and respect state budgets Net excess losses between co-resident spouses before SSI, MSP and Medicaid exclusions. Keep Connecticut and Minnesota own/deemed budgets consistent, prevent negative child/student exclusions, and isolate New Hampshire self-employment losses from wages. Add vectorized property and differential coverage. Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 2 +- .../ssp/earned/self_employment_sources.yaml | 12 + .../test_ssi_earned_sources_properties.py | 428 ++++++++++++++++++ .../income/msp_countable_income.yaml | 48 ++ .../ssi_ineligible_child_allocation.yaml | 32 ++ ...or_disabled_working_student_exclusion.yaml | 11 + .../ssi_earned_income_after_spousal_loss.yaml | 108 +++++ .../ssi/income/ssi_marital_earned_income.yaml | 54 +++ .../ssp/income/ct_ssp_countable_income.yaml | 66 +++ .../gov/states/mn/dhs/msa/mn_msa_person.yaml | 70 +++ .../nh/dhhs/ssp/nh_ssp_countable_income.yaml | 70 +++ ...ed_income_deemed_from_ineligible_spouse.py | 2 +- .../income/msp_countable_income.py | 2 +- ...ed_income_deemed_from_ineligible_spouse.py | 2 +- ...si_income_deemed_from_ineligible_spouse.py | 2 +- .../deemed/ssi_ineligible_child_allocation.py | 6 +- .../marital/ssi_marital_earned_income.py | 2 +- ...d_or_disabled_working_student_exclusion.py | 4 +- .../ssi_earned_income_after_spousal_loss.py | 34 ++ .../dss/ssp/income/ct_ssp_countable_income.py | 2 +- .../income/ct_ssp_earned_income_disregard.py | 2 +- .../ct/dss/ssp/income/ct_ssp_gross_income.py | 2 +- .../eligibility/mn_msa_net_income_eligible.py | 2 +- .../mn/dhs/msa/income/mn_msa_gross_income.py | 2 +- .../gov/states/mn/dhs/msa/mn_msa_person.py | 2 +- .../nh/dhhs/ssp/nh_ssp_countable_income.py | 7 +- 26 files changed, 958 insertions(+), 16 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/nh/dhhs/ssp/earned/self_employment_sources.yaml create mode 100644 policyengine_us/tests/core/test_ssi_earned_sources_properties.py create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_earned_income_after_spousal_loss.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/nh/dhhs/ssp/nh_ssp_countable_income.yaml create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/ssi_earned_income_after_spousal_loss.py diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index 38d3fc51afc..db0ba662c4c 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +1 @@ -Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. +Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. Follow New Hampshire adult assistance's separate rule preventing self-employment losses from reducing wages. 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..36cd314bc19 --- /dev/null +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -0,0 +1,428 @@ +"""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 +and New Hampshire's distinct prohibition on offsetting wages with a business +loss; YAML covers those intentional changes. 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. + +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", +) +STATES = ( + "TX", + "CA", + "CO", + "CT", + "MN", + "NC", + "NH", + "OH", + "OK", + "OR", + "SD", + "VA", + "VT", + "WI", +) +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", + "mn_msa_net_income_eligible", + "mn_msa_gross_income", + "mn_msa_person", + "nc_sa_countable_income", + "nh_ssp_countable_income", + "oh_rss_countable_income", + "ok_ssp_spouse_child_allocation", + "or_ssp_countable_income", + "sd_ssp_care", + "va_ag_countable_income", + "vt_aabd_ep_countable_income", + "wi_cts_gross_earned_income", +) +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)]) +@given( + batch=st.lists( + st.tuples( + st.integers(0, 30_000), + st.integers(0, 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 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) 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 1c823197a3b..720f80936e0 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 @@ -113,3 +113,51 @@ # (12,000 - 2,400 + 24,000 - 3,600) / 12 = 2,500 monthly. # Apply the couple's exclusions once: (2,500 - 20 - 65) / 2 = 1,207.50. msp_countable_income: [1_207.50, 1_207.50] + +- 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/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_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_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/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..01da8ae8c93 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,69 @@ # 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: + # 20,000 - 5,000 = 15,000 annual earnings, counted once as the spouse's + # own income and once in the applicant's separate budget through deeming. + ct_ssp_gross_income: [1_250, 1_250] + # $65 + half of ($1,250 - $65) = $657.50 disregarded. + ct_ssp_earned_income_disregard: [657.5, 657.5] + ct_ssp_countable_income: [592.5, 592.5] + +- name: An ineligible spouse's farm loss reduces Connecticut 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: 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: + # Applicant earnings are (12,000 - 5,000) / 12 = $583.33 monthly. + ct_ssp_gross_income: [583.3333, 0] + ct_ssp_earned_income_disregard: [324.1667, 0] + ct_ssp_countable_income: [259.1667, 0] 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/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 73a0ce26fb3..05932ae3481 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 @@ -33,7 +33,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/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..551a558a3cf 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", + "ssi_earned_income_after_spousal_loss", "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..9068c80d620 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", + "ssi_earned_income_after_spousal_loss", "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..897ff068beb 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", + "ssi_earned_income_after_spousal_loss", "ssi_unearned_income", "ssi", "ssi_earned_income_deemed_from_ineligible_spouse", 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..a5e34725fc9 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 @@ -21,7 +21,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", 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). From 6cb74d7c4ee2a566a30712673200a6aa371d3f69 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 29 Oct 2026 09:18:48 -0400 Subject: [PATCH 03/16] Keep state earned-income rules separate from SSI loss netting Use Kentucky's own business-source floors and preserve Minnesota gross-income premium restoration without resurrecting losses. Add SD and WY source-presence regressions and complete direct reader coverage across 23 states, including loss-to-profit monotonicity. Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 2 +- .../ssp/earned/self_employment_sources.yaml | 14 ++ .../test_ssi_earned_sources_properties.py | 34 +++- .../ky/dcbs/ssp/ky_ssp_countable_income.yaml | 160 ++++++++++++++++++ .../dhs/msa/income/mn_msa_gross_income.yaml | 119 +++++++++++++ .../states/sd/dss/ssp/sd_ssp_independent.yaml | 22 +++ .../states/wy/health/ssp/wy_ssp_eligible.yaml | 22 +++ .../ky/dcbs/ssp/ky_ssp_countable_income.py | 23 ++- .../ky/dcbs/ssp/ky_ssp_earned_income.py | 32 ++++ .../mn/dhs/msa/income/mn_msa_gross_income.py | 22 ++- 10 files changed, 440 insertions(+), 10 deletions(-) create mode 100644 policyengine_us/parameters/gov/states/ky/dcbs/ssp/earned/self_employment_sources.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/mn/dhs/msa/income/mn_msa_gross_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/sd/dss/ssp/sd_ssp_independent.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/states/wy/health/ssp/wy_ssp_eligible.yaml create mode 100644 policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index db0ba662c4c..6b84688f5a2 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +1 @@ -Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. Follow New Hampshire adult assistance's separate rule preventing self-employment losses from reducing wages. +Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. Follow New Hampshire adult assistance's separate rule preventing self-employment losses from reducing wages, and Kentucky State Supplementation's rule preventing one business's loss from reducing another business's earnings. Keep Minnesota gross-income premium restoration from creating income while business losses remain unabsorbed. 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/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index 36cd314bc19..f34657bb4c4 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -10,9 +10,10 @@ 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 -and New Hampshire's distinct prohibition on offsetting wages with a business -loss; YAML covers those intentional changes. An actual git-base differential +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; YAML covers those intentional changes. 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. @@ -51,16 +52,25 @@ "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 = ( @@ -71,18 +81,33 @@ "ct_ssp_countable_income", "ct_ssp_earned_income_disregard", "ct_ssp_gross_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", @@ -274,11 +299,12 @@ def test_profit_repartition_preserves_ssi_earned_income(policy, batch): @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(0, 40_000), + st.integers(-40_000, 40_000), st.integers(0, 40_000), st.sampled_from(PROFIT_SOURCES), st.booleans(), 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..4bc58634942 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,161 @@ 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] 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/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/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..2aad4c415c6 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 @@ -19,10 +19,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,7 +47,10 @@ 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) + # Existing student/unearned and spouse/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 own-source correction does not decide it. spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) 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..29e03e925c9 --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py @@ -0,0 +1,32 @@ +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/", + "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=188", + ) + documentation = ( + "Kentucky SSP own earned income, retaining the existing wage-limited " + "pretax premium exclusion. A loss in one modeled self-employment " + "source cannot reduce wages or another business source. 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 C.8(b) disallows losses from one business against another. + # Keep Kentucky's list separate from SSI's signed NESE aggregation. + business_income = sum(max_(person(source, period), 0) for source in sources) + 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/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 a5e34725fc9..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): @@ -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) From 695f1c5a14733d2215185ba9856ca5609c600066 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 03:03:13 -0400 Subject: [PATCH 04/16] Document Kentucky business loss floors and fix source summation --- .../ssi-earned-farm-partnership.fixed.md | 2 +- .../ky/dcbs/ssp/ky_ssp_earned_income.py | 19 ++++++++++++++----- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index 6b84688f5a2..ef2a968d396 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +1 @@ -Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. Follow New Hampshire adult assistance's separate rule preventing self-employment losses from reducing wages, and Kentucky State Supplementation's rule preventing one business's loss from reducing another business's earnings. Keep Minnesota gross-income premium restoration from creating income while business losses remain unabsorbed. +Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. 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. 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 index 29e03e925c9..17d296a1b3d 100644 --- 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 @@ -14,8 +14,14 @@ class ky_ssp_earned_income(Variable): ) documentation = ( "Kentucky SSP own earned income, retaining the existing wage-limited " - "pretax premium exclusion. A loss in one modeled self-employment " - "source cannot reduce wages or another business source. Inputs may " + "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." ) @@ -24,9 +30,12 @@ def formula(person, period, parameters): sources = parameters( period ).gov.states.ky.dcbs.ssp.earned.self_employment_sources - # MS 2450 C.8(b) disallows losses from one business against another. - # Keep Kentucky's list separate from SSI's signed NESE aggregation. - business_income = sum(max_(person(source, period), 0) for source in 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 From e4b4a448c7ca2c9a2e4db87a903bd9423d9f4266 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 03:22:57 -0400 Subject: [PATCH 05/16] Keep Kentucky business losses out of spouse deemed earnings --- .../ky/dcbs/ssp/ky_ssp_countable_income.yaml | 93 +++++++++++++++++++ .../ky/dcbs/ssp/ky_ssp_countable_income.py | 41 +++++++- ...ed_income_deemed_from_ineligible_spouse.py | 40 ++++++++ 3 files changed, 170 insertions(+), 4 deletions(-) create mode 100644 policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py 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 4bc58634942..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 @@ -249,3 +249,96 @@ 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/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 2aad4c415c6..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): @@ -47,11 +50,41 @@ def formula(person, period, parameters): countable_earned = max_(earned - p.earned, 0) * (1 - p.earned_share) personal_countable = unearned + countable_earned - # Existing student/unearned and spouse/parent deeming inputs remain - # an SSI-based approximation. The ordering of Kentucky's separate + # 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 own-source correction does not decide it. - spousal_deemed = person("ssi_income_deemed_from_ineligible_spouse", period) + # 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_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..e1782a13930 --- /dev/null +++ b/policyengine_us/variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py @@ -0,0 +1,40 @@ +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/", + "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=188", + "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) From a997c3e410dcc6149591f36c2810a07ebe31e27f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 03:31:54 -0400 Subject: [PATCH 06/16] Point Kentucky income references to the complete loss policy --- .../variables/gov/states/ky/dcbs/ssp/ky_ssp_earned_income.py | 3 ++- .../ssp/ky_ssp_earned_income_deemed_from_ineligible_spouse.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) 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 index 17d296a1b3d..bb061071449 100644 --- 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 @@ -10,7 +10,8 @@ class ky_ssp_earned_income(Variable): defined_for = StateCode.KY reference = ( "https://apps.legislature.ky.gov/law/kar/titles/921/002/015/", - "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=188", + # 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 " 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 index e1782a13930..773ed2fc8a1 100644 --- 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 @@ -10,7 +10,8 @@ class ky_ssp_earned_income_deemed_from_ineligible_spouse(Variable): defined_for = StateCode.KY reference = ( "https://apps.legislature.ky.gov/law/kar/titles/921/002/015/", - "https://www.chfs.ky.gov/agencies/dcbs/dfs/Documents/OMVOLIVA.pdf#page=188", + # 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 = ( From 4550dd0597deec0d367a61dfa5b0cd37a475d03c Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Fri, 30 Oct 2026 03:33:35 -0400 Subject: [PATCH 07/16] Generate signed business losses in SSI and state income properties --- .../test_ssi_earned_sources_properties.py | 268 +++++++++++++++++- 1 file changed, 265 insertions(+), 3 deletions(-) diff --git a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index f34657bb4c4..38e6f5d293a 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -13,9 +13,11 @@ 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; YAML covers those intentional changes. 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. +business. Separate generated loss properties cover those intentional changes, +including losses belonging to either spouse, children and students. 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. All households in a batch share one vectorized simulation. The module reuses the read-only country singleton and constructs its parameter-only reference @@ -47,6 +49,12 @@ "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", @@ -331,6 +339,260 @@ def test_ssi_never_rises_when_business_profit_rises(policy, batch): 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 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)) From d5a161915e6531f73f6a54a5725a9c459103b70f Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 05:23:20 -0400 Subject: [PATCH 08/16] Keep a spouse's business loss out of CT SSP and MD PAA applicant earnings POMS SI 00820.210 B.2.c binds SSI, not Connecticut's or Maryland's own programs. UPM 5020.05 A, 5020.70 C.3 and 5030.10 A count each person's own earnings and reduce self-employment earnings only by self-employment expenses; COMAR 07.03.07.08 sets deductions for an individual, and Maryland DHS states that "The assistance unit is always one person." Connecticut's three budget readers now read ct_ssp_own_earned_income, the person's signed SSI earnings floored at zero; spouse-deemed earnings still net an own loss through the spouse's after-loss SSI earnings. Maryland PAA reads ssi_marital_earned_income only under the couple computation and the applicant's own ssi_earned_income otherwise. SSI and its spouse-deemed amounts keep the POMS treatment. Tests: re-derive the Connecticut spouse-loss cases and pin the hub's probe couple; add Maryland own-earnings and reverse-direction cases, a California supplement case with farm profit, farm and partnership SGA cases, a couple whose farm profit ends the couple computation, the issue #10558 parent-deeming case, and a generated property that a spouse's loss never changes the applicant's CT or MD figures. Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 2 +- .../test_ssi_earned_sources_properties.py | 79 +++++++++++++- .../income/deemed/from_ineligible_parent.yaml | 79 ++++++++++++++ .../ssa/ssi/income/ssi_engaged_in_sga.yaml | 24 +++++ .../gov/ssa/ssi/ssi_spouse_farm_sga.yaml | 80 ++++++++++++++ .../state_supplement/ca_state_supplement.yaml | 30 ++++++ .../ssp/income/ct_ssp_countable_income.yaml | 72 +++++++++++-- .../dhs/fia/paa/md_paa_countable_income.yaml | 101 ++++++++++++++++++ .../dss/ssp/income/ct_ssp_countable_income.py | 2 +- .../income/ct_ssp_earned_income_disregard.py | 2 +- .../ct/dss/ssp/income/ct_ssp_gross_income.py | 2 +- .../ssp/income/ct_ssp_own_earned_income.py | 34 ++++++ .../md/dhs/fia/paa/md_paa_countable_income.py | 15 ++- 13 files changed, 504 insertions(+), 18 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml create mode 100644 policyengine_us/variables/gov/states/ct/dss/ssp/income/ct_ssp_own_earned_income.py diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index ef2a968d396..3d5e0178def 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +1 @@ -Count active farm income and partnership self-employment earnings as earned income for SSI and programs using its income sources. Apply excess business losses to a spouse's earnings without reducing unearned income or creating negative student exclusions, and keep CT/MN deeming budgets consistent. 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. +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. diff --git a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index 38e6f5d293a..ed6dd86130b 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -14,7 +14,9 @@ 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. An actual +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. @@ -89,6 +91,7 @@ "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", @@ -439,6 +442,80 @@ def test_spousal_loss_pool_is_nonnegative_and_conserves_net_earnings(policy, bat ) +@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]) + 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]) + + @st.composite def child_and_student_losses(draw): wages = draw(st.integers(0, 2_000)) 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/income/ssi_engaged_in_sga.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/income/ssi_engaged_in_sga.yaml index ecc98e6faa3..b7a72093935 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,27 @@ # 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 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..58420d844f1 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml @@ -0,0 +1,80 @@ +# 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] 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 01da8ae8c93..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 @@ -218,14 +218,19 @@ members: [applicant, spouse] state_code: CT output: - # 20,000 - 5,000 = 15,000 annual earnings, counted once as the spouse's - # own income and once in the applicant's separate budget through deeming. - ct_ssp_gross_income: [1_250, 1_250] - # $65 + half of ($1,250 - $65) = $657.50 disregarded. - ct_ssp_earned_income_disregard: [657.5, 657.5] - ct_ssp_countable_income: [592.5, 592.5] + # 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 reduces Connecticut applicant earnings before exclusions. +- name: An ineligible spouse's farm loss does not reduce Connecticut applicant earnings. period: 2026-01 absolute_error_margin: 0.01 input: @@ -252,7 +257,52 @@ members: [applicant, spouse] state_code: CT output: - # Applicant earnings are (12,000 - 5,000) / 12 = $583.33 monthly. - ct_ssp_gross_income: [583.3333, 0] - ct_ssp_earned_income_disregard: [324.1667, 0] - ct_ssp_countable_income: [259.1667, 0] + # 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/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/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 551a558a3cf..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_after_spousal_loss", + "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 9068c80d620..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_after_spousal_loss", + "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 897ff068beb..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_after_spousal_loss", + "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/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, From 9f0b72f6c7df53d559f03aaadf9aaaa1fa63bc5d Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:03:15 -0400 Subject: [PATCH 09/16] Check every Connecticut earned budget against a spouse's loss Extend the CT/MD property so that, where SSI deems no spouse income, the applicant's Connecticut gross earned income, earned disregard and countable earned income stay the same when only the spouse's business loss changes, and the gross earned income equals the applicant's own earnings. SSI itself, which Connecticut counts as unearned income, may still change. Co-Authored-By: Claude Opus 5.5 --- .../test_ssi_earned_sources_properties.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index ed6dd86130b..a2fdb8ce8ef 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -514,6 +514,28 @@ def applicant(name): 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 From 7f5467b8e11d03f528314faa05d28b1cbfd90e17 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Sat, 31 Oct 2026 06:48:55 -0400 Subject: [PATCH 10/16] Assert that Connecticut own earnings never go negative Every Connecticut spouse in the generated property has a business loss. Check that ct_ssp_own_earned_income stays at or above zero for all of them, so an own residual loss cannot be deducted again from earnings that SSI already nets through the spouse's after-loss amount. Co-Authored-By: Claude Opus 5.5 --- .../tests/core/test_ssi_earned_sources_properties.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index a2fdb8ce8ef..590a9bbe403 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -505,6 +505,9 @@ def applicant(name): 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)) From 298dda1d4e1afd7f505c91f1807c659339edab74 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 05:59:21 +0000 Subject: [PATCH 11/16] Keep a business loss from reducing wages in the SSI SGA test POMS DI 10505.015 D.3 says a self-employment loss does not reduce total earnings when the individual also has wages. Counting farm and partnership earnings widened the defect main already had for Schedule C losses, so SGA earnings are now wages plus the business net floored at zero. Losses still net between businesses (20 CFR 404.1081(c), POMS DI 10510.012 B). ssi_earned_income keeps the signed amount its other readers need. Co-Authored-By: Claude Opus 5.5 --- .../ssa/ssi/income/ssi_engaged_in_sga.yaml | 85 +++++++++++++++++++ .../gov/ssa/ssi/ssi_spouse_farm_sga.yaml | 33 +++++++ .../eligibility/income/ssi_engaged_in_sga.py | 25 ++++-- 3 files changed, 138 insertions(+), 5 deletions(-) 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 b7a72093935..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 @@ -77,3 +77,88 @@ # (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/ssi_spouse_farm_sga.yaml b/policyengine_us/tests/policy/baseline/gov/ssa/ssi/ssi_spouse_farm_sga.yaml index 58420d844f1..b7700a94ae9 100644 --- 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 @@ -78,3 +78,36 @@ 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/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_( From 45c6214a9e8691b6ad88fb9a7bd2bb45f6631c7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 06:52:04 +0000 Subject: [PATCH 12/16] Test the SGA loss floor and register the reading Properties check that SGA earnings never fall below wages, rise with wages, net losses between businesses, and match the signed sum when no business source is negative. The backlog records us:ssi-sga-self-employment-loss: the regulations do not settle it, so the agency's POMS DI 10505.015 D.3 reading governs. Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 2 +- .../test_ssi_earned_sources_properties.py | 99 +++++++++++++++++++ source_conflicts/candidates.yaml | 28 ++++++ 3 files changed, 128 insertions(+), 1 deletion(-) diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index 3d5e0178def..327aa10ade4 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +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. +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/tests/core/test_ssi_earned_sources_properties.py b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py index 590a9bbe403..6283c4bfc64 100644 --- a/policyengine_us/tests/core/test_ssi_earned_sources_properties.py +++ b/policyengine_us/tests/core/test_ssi_earned_sources_properties.py @@ -21,6 +21,11 @@ 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. @@ -816,3 +821,97 @@ def test_200_households_match_reverted_list_without_farm_or_partnership(policies 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/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: From 3953d3377b7491f5c6161a959f5e231b330f8df3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:31:49 +0000 Subject: [PATCH 13/16] Keep farm losses out of Idaho AABD and business losses out of Indiana SAPN SSI now nets farm and partnership losses against wages, and Idaho AABD and Indiana SAPN read SSI countable income, so those losses reached both programs. IDAPA 16.03.05.403.06 bars only a farm loss from reducing other income; IHCPPM 3410.15.00 bars any net self-employment loss. A shared helper restores the barred net loss and reapplies the SSI exclusions. Also pin the spouse farm-loss SSI payment, note the SGA loss add-back's assumption, and complete the SGA source-conflict register entry. Co-Authored-By: Claude Opus 5.5 --- .../ssi-earned-farm-partnership.fixed.md | 1 + .../gov/ssa/ssi/ssi_spouse_farm_sga.yaml | 5 ++ .../gov/states/id/dhw/aabd/id_aabd.yaml | 78 ++++++++++++++++ .../gov/states/in/fssa/ssp/in_ssp_sapn.yaml | 88 +++++++++++++++++++ ...si_countable_income_without_loss_offset.py | 43 +++++++++ .../eligibility/income/ssi_engaged_in_sga.py | 5 +- .../gov/states/id/dhw/aabd/id_aabd.py | 13 ++- .../gov/states/in/fssa/ssp/in_ssp_sapn.py | 15 +++- source_conflicts/candidates.yaml | 15 +++- 9 files changed, 256 insertions(+), 7 deletions(-) create mode 100644 policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index 327aa10ade4..627e0787528 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1 +1,2 @@ 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. + Idaho AABD counts a net farm loss as zero (IDAPA 16.03.05.403.06), and Indiana SAPN counts a net self-employment loss as zero (IHCPPM 3410.15.00), so neither lets that loss reduce wages. \ No newline at end of file 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 index b7700a94ae9..9fb1f81cc76 100644 --- 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 @@ -109,5 +109,10 @@ # $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. + # For the head's payment the loss does net against wages (POMS SI + # 00820.210): the spouse's $1,300 is deemed, ($1,300 - $85) / 2 = $607.50, + # so the head receives $1,491 - $607.50 = $883.50 a month, or $10,602 a + # year. 416.1163(e)(2) cannot bind: $883.50 is below $994. is_ssi_disabled: [false, false] is_ssi_ineligible_spouse: [false, true] + ssi: [10_602, 0] diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml index 8bf652af1ce..b6aa348fddd 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml @@ -106,3 +106,81 @@ # Per 501.02: COUPLE requires both to be AABD participants # Person1 gets individual rate ($53), not couple rate ($10) id_aabd: [53, 0] + +- name: Case 7, wages and Social Security with no business loss. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security: 6_000 + employment_income: 1_800 + id_aabd_living_arrangement: SINGLE + households: + household: + members: [person1] + state_code: ID + output: + # Unearned $500 - $20 = $480; earned ($150 - $65) / 2 = $42.50. + # Countable $522.50; $545 allowance - $522.50 = $22.50. + id_aabd: 22.5 + +- name: Case 8, a farm loss does not reduce wages (IDAPA 16.03.05.403.06). + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security: 6_000 + employment_income: 1_800 + farm_operations_income: -1_200 + id_aabd_living_arrangement: SINGLE + households: + household: + members: [person1] + state_code: ID + output: + # SSI nets the $100 monthly farm loss against $150 wages, but Idaho + # counts the farm loss as zero: countable stays $522.50, so $22.50. + id_aabd: 22.5 + +- name: Case 9, a partnership loss still nets against wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security: 6_000 + employment_income: 1_800 + partnership_self_employment_net_earnings: -1_200 + id_aabd_living_arrangement: SINGLE + households: + household: + members: [person1] + state_code: ID + output: + # Only farm losses are barred. Earned $150 - $100 = $50 < $65, so + # countable is $480; $545 - $480 = $65, capped at the $53 maximum. + id_aabd: 53 + +- name: Case 10, a Schedule C loss still nets against wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + social_security: 6_000 + employment_income: 1_800 + self_employment_income: -1_200 + id_aabd_living_arrangement: SINGLE + households: + household: + members: [person1] + state_code: ID + output: + # Same as the partnership case: countable $480, payment capped at $53. + id_aabd: 53 diff --git a/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml b/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml index 156616ec4fc..de43cd6b1de 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml @@ -75,3 +75,91 @@ # person1: individual amount = $22 # person2: ineligible = $0 in_ssp_sapn: [22, 0] + +- name: Case 5, a farm loss does not reduce wages (IHCPPM 3410.15.00). + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 2_400 + farm_operations_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # SSI: earned $200 - $100 = $100; ($100 - $20 - $65) / 2 = $7.50 + # countable, so the $30 facility SSI is $22.50. SAPN budgets earned + # income without the loss: ($200 - $85) / 2 = $57.50. + # $52 - $22.50 - $57.50 < 0, so SAPN is $0. + ssi: 22.5 + in_ssp_sapn: 0 + +- name: Case 6, a Schedule C loss does not reduce wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 2_400 + self_employment_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # Same budget as the farm case: $52 - $22.50 - $57.50 < 0. + in_ssp_sapn: 0 + +- name: Case 7, a partnership loss does not reduce wages. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 2_400 + partnership_self_employment_net_earnings: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # Same budget as the farm case: $52 - $22.50 - $57.50 < 0. + in_ssp_sapn: 0 + +- name: Case 8, business losses net against business gains before the floor. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 1_200 + self_employment_income: 1_200 + farm_operations_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # Business net is $0, so nothing is restored. Earned $100 a month; + # ($100 - $85) / 2 = $7.50 countable; SSI $30 - $7.50 = $22.50. + # SAPN = $52 - $22.50 - $7.50 = $22. + ssi: 22.5 + in_ssp_sapn: 22 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py new file mode 100644 index 00000000000..61141b91a93 --- /dev/null +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py @@ -0,0 +1,43 @@ +from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._apply_ssi_exclusions import ( + _apply_ssi_exclusions, +) + + +def ssi_countable_income_without_loss_offset(person, period, parameters, sources): + """ + Monthly SSI countable income as if the net loss of `sources` were 0. + + SSI nets a business loss against wages (POMS SI 00820.210). Some state + programs that otherwise budget SSI countable income bar that offset for + some or all businesses. This restores the net loss of `sources` to the + person's own earned income, reapplies the SSI exclusions, and adds the + difference to ssi_countable_income for the month. + + The adjustment works person by person on own earned and unearned income, + so couple computations, spousal deeming and the blind or disabled + working student exclusion are approximate. + """ + year = period.this_year + countable_income = person("ssi_countable_income", period) + if not sources: + return countable_income + restored_loss = max_(-add(person, year, sources), 0) + earned = person("ssi_earned_income", year) + # Same unearned pool as ssi_countable_income, so the $20 general + # exclusion splits between earned and unearned income the same way. + unearned = ( + person("ssi_unearned_income", year) + + person("ssi_unearned_income_deemed_from_ineligible_parent", year) + + person("ssi_in_kind_support_and_maintenance", year) + ) + with_offset = _apply_ssi_exclusions(earned, unearned, parameters, year) + without_offset = _apply_ssi_exclusions( + earned + restored_loss, unearned, parameters, year + ) + # ssi_countable_income is zero for a person who is not SSI eligible. + is_eligible = person("is_ssi_eligible_individual", year) | person( + "is_ssi_eligible_spouse", year + ) + adjustment = where(is_eligible, without_offset - with_offset, 0) + return countable_income + adjustment / MONTHS_IN_YEAR 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 788094d32aa..85e02ff4fb1 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 @@ -29,7 +29,10 @@ def formula(person, period, parameters): # 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. + # only a net business loss is added back. This assumes + # ssi_earned_income already holds that business net. A situation that + # supplies ssi_earned_income directly and also a business source adds + # the loss back to an amount that never subtracted it. restored_loss = max_(-business_net, 0) income = person("ssi_earned_income", period) + restored_loss # POMS DI 10505.010A counts payroll insurance premiums for SGA. diff --git a/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py b/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py index 822990e39b4..49be562bee5 100644 --- a/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py +++ b/policyengine_us/variables/gov/states/id/dhw/aabd/id_aabd.py @@ -2,6 +2,9 @@ from policyengine_us.variables.gov.states.id.dhw.aabd.id_aabd_living_arrangement import ( IDAAbdLivingArrangement, ) +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._ssi_countable_income_without_loss_offset import ( + ssi_countable_income_without_loss_offset, +) class id_aabd(Variable): @@ -12,7 +15,8 @@ class id_aabd(Variable): definition_period = MONTH defined_for = "id_aabd_eligible" reference = ( - "https://adminrules.idaho.gov/rules/current/16/160305.pdf#page=41", + # PDF pages 34, 41 + "https://adminrules.idaho.gov/rules/current/16/160305.pdf#page=34", "https://www.law.cornell.edu/regulations/idaho/IDAPA-16.03.05.514", ) @@ -32,6 +36,11 @@ def formula(person, period, parameters): # AABD cash = basic allowance - countable income, capped at max # (Section 514). Idaho mirrors SSI disregards (Sections 540-546). - countable_income = person("ssi_countable_income", period) + # IDAPA 16.03.05.403.06 disallows "Loss of Farm Income Subtracted + # From Other Income", so a net farm loss counts as zero. Other + # self-employment losses still net against other income as in SSI. + countable_income = ssi_countable_income_without_loss_offset( + person, period, parameters, ["farm_operations_income"] + ) financial_need = max_(0, per_person_allowance - countable_income) return min_(financial_need, per_person_max) diff --git a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py index a70a664f6df..5805f1a463c 100644 --- a/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py +++ b/policyengine_us/variables/gov/states/in/fssa/ssp/in_ssp_sapn.py @@ -1,4 +1,7 @@ from policyengine_us.model_api import * +from policyengine_us.variables.gov.ssa.ssi.eligibility.income._ssi_countable_income_without_loss_offset import ( + ssi_countable_income_without_loss_offset, +) class in_ssp_sapn(Variable): @@ -11,6 +14,7 @@ class in_ssp_sapn(Variable): reference = ( "https://www.in.gov/fssa/ompp/files/Medicaid_PM_5000.pdf", "https://secure.ssa.gov/poms.nsf/lnx/0501401001CHI", + "https://www.in.gov/fssa/ompp/files/Medicaid_PM_3400.pdf#page=12", ) def formula(person, period, parameters): @@ -24,7 +28,16 @@ def formula(person, period, parameters): both_eligible = person.marital_unit.sum(eligible) == 2 is_couple = joint_claim & both_eligible ssi_monthly = person("ssi", period) - countable_income = person("ssi_countable_income", period) + # IHCPPM 3410.15.00: self-employment business losses cannot offset + # or reduce non-self-employment income such as wages. Businesses + # net against each other, and a net loss counts as zero. + earned_sources = parameters(period).gov.ssa.ssi.income.sources.earned + business_sources = [ + source for source in earned_sources if source != "employment_income" + ] + countable_income = ssi_countable_income_without_loss_offset( + person, period, parameters, business_sources + ) max_individual = p.sapn.amount.individual max_couple = p.sapn.amount.couple pna = p.personal_needs_allowance diff --git a/source_conflicts/candidates.yaml b/source_conflicts/candidates.yaml index 9e3ab6fc785..d5e293e6440 100644 --- a/source_conflicts/candidates.yaml +++ b/source_conflicts/candidates.yaml @@ -9718,18 +9718,27 @@ candidates: 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 + years: + from: 1975 + type: calendar 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 + url: https://www.ecfr.gov/current/title-20/chapter-III/part-416/subpart-I + - kind: regulation + citation: 20 CFR 404.1081(c) + url: https://www.ssa.gov/OP_Home/cfr20/404/404-1081.htm - kind: manual citation: POMS DI 10505.015 D.3 url: https://secure.ssa.gov/poms.nsf/lnx/0410505015 + - kind: manual + citation: POMS DI 10510.012 B + url: https://secure.ssa.gov/poms.nsf/lnx/0410510012 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. + statute: No statute or regulation settles it. 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, From e38f568bee160a830c5d55659983a032783902b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:31:53 +0000 Subject: [PATCH 14/16] Keep the changelog fragment one paragraph Co-Authored-By: Claude Opus 5.5 --- changelog.d/ssi-earned-farm-partnership.fixed.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/changelog.d/ssi-earned-farm-partnership.fixed.md b/changelog.d/ssi-earned-farm-partnership.fixed.md index 627e0787528..2b54e81e118 100644 --- a/changelog.d/ssi-earned-farm-partnership.fixed.md +++ b/changelog.d/ssi-earned-farm-partnership.fixed.md @@ -1,2 +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. - Idaho AABD counts a net farm loss as zero (IDAPA 16.03.05.403.06), and Indiana SAPN counts a net self-employment loss as zero (IHCPPM 3410.15.00), so neither lets that loss reduce wages. \ No newline at end of file +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. Idaho AABD counts a net farm loss as zero (IDAPA 16.03.05.403.06), and Indiana SAPN counts a net self-employment loss as zero (IHCPPM 3410.15.00), so neither lets that loss reduce wages. From abd6632bde62a8d3265f8ee80d9152d215b2b231 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 20:33:34 +0000 Subject: [PATCH 15/16] Gate the loss-offset adjustment like SSI countable income Main now zeroes ssi_countable_income only for people who are not aged, blind or disabled, so the helper uses the same gate. Co-Authored-By: Claude Opus 5.5 --- .../income/_ssi_countable_income_without_loss_offset.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py index 61141b91a93..c49ad8675d3 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py @@ -35,9 +35,8 @@ def ssi_countable_income_without_loss_offset(person, period, parameters, sources without_offset = _apply_ssi_exclusions( earned + restored_loss, unearned, parameters, year ) - # ssi_countable_income is zero for a person who is not SSI eligible. - is_eligible = person("is_ssi_eligible_individual", year) | person( - "is_ssi_eligible_spouse", year - ) - adjustment = where(is_eligible, without_offset - with_offset, 0) + # Same gate as ssi_countable_income, which is zero for a person who is + # not aged, blind or disabled. + is_aged_blind_disabled = person("is_ssi_aged_blind_disabled", year) + adjustment = where(is_aged_blind_disabled, without_offset - with_offset, 0) return countable_income + adjustment / MONTHS_IN_YEAR From 4cab6b38aecdf8a6337286c60922b4a74a9350d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 3 Nov 2026 02:34:25 +0000 Subject: [PATCH 16/16] Take the working student exclusion in the loss-offset helper ssi_countable_income takes the blind or disabled working student exclusion off earned income before the other exclusions, but the helper reapplied the exclusions to raw earnings. For a working student whose restored earnings stay inside the exclusion, it counted the whole restored amount, so Indiana SAPN and Idaho AABD came out lower than main and the state rules give. Apply the same exclusion, with its monthly amount, annual cap, eligibility and floor, to the earnings before and after the restore. Add the two working-student cases, a positive-SAPN case that pins the restored amount, and a case that pins the aged, blind or disabled gate. State the deemed-spouse limit plainly in the docstring. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LwQq6KB8nw5rXxczcbQutq --- .../gov/states/id/dhw/aabd/id_aabd.yaml | 25 +++++++ .../gov/states/in/fssa/ssp/in_ssp_sapn.yaml | 75 +++++++++++++++++++ ...si_countable_income_without_loss_offset.py | 27 ++++++- 3 files changed, 123 insertions(+), 4 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml b/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml index b6aa348fddd..3f34b820c73 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/id/dhw/aabd/id_aabd.yaml @@ -184,3 +184,28 @@ output: # Same as the partnership case: countable $480, payment capped at $53. id_aabd: 53 + +- name: Case 11, a working student's restored farm earnings stay inside the student exclusion. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 19 + meets_ssi_disability_criteria: true + is_full_time_student: true + social_security: 6_000 + employment_income: 1_800 + farm_operations_income: -1_200 + id_aabd_living_arrangement: SINGLE + households: + household: + members: [person1] + state_code: ID + output: + # The 2026 blind or disabled working student exclusion (20 CFR + # 416.1112(c)(3)) is up to $2,410 a month. It takes all $50 of + # earnings after the loss, and all $150 with the farm loss counted as + # zero, so countable income is $500 - $20 = $480 either way. + # $545 - $480 = $65, capped at the $53 maximum. + id_aabd: 53 diff --git a/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml b/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml index de43cd6b1de..ea5a39cdd74 100644 --- a/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml +++ b/policyengine_us/tests/policy/baseline/gov/states/in/fssa/ssp/in_ssp_sapn.yaml @@ -163,3 +163,78 @@ # SAPN = $52 - $22.50 - $7.50 = $22. ssi: 22.5 in_ssp_sapn: 22 + +- name: Case 9, a working student's restored Schedule C earnings stay inside the student exclusion. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 19 + meets_ssi_disability_criteria: true + is_full_time_student: true + employment_income: 2_400 + self_employment_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # The 2026 blind or disabled working student exclusion (20 CFR + # 416.1112(c)(3)) is up to $2,410 a month. SSI earnings after the loss + # are $100 a month and the exclusion takes all of it, so SSI pays the + # full $30. With the loss counted as zero, earnings are $200 and the + # exclusion still takes all of it, so the budget is $0. + # SAPN = $52 - $30 - $0 = $22. + in_ssp_sapn: 22 + +- name: Case 10, a positive SAPN pins the restored farm loss. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + employment_income: 1_200 + farm_operations_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # SSI: earnings $100 - $100 = $0 a month, so SSI pays the full $30. + # SAPN budgets earnings without the loss: ($100 - $20 - $65) / 2 = + # $7.50. SAPN = $52 - $30 - $7.50 = $14.50, below the $22 maximum. + ssi: 30 + in_ssp_sapn: 14.5 + +- name: Case 11, no adjustment for a person who is not aged, blind or disabled. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 40 + receives_ssi: true + employment_income: 2_400 + farm_operations_income: -1_200 + in_ssp_sapn_eligible: true + ssi_lives_in_medical_treatment_facility: true + ssi_medicaid_pays_majority_of_care: true + households: + household: + members: [person1] + state_code: IN + output: + # SSI countable income is zero for a person who is not aged, blind or + # disabled, and the loss-offset adjustment takes the same gate, so the + # budget stays $0. SAPN = min($52 - $0 - $0, $22) = $22. Restoring the + # $100 loss regardless of the gate would budget ($200 - $85) / 2 - + # ($100 - $85) / 2 = $50, leaving $2. + in_ssp_sapn: 22 diff --git a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py index c49ad8675d3..2d178d0c304 100644 --- a/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py +++ b/policyengine_us/variables/gov/ssa/ssi/eligibility/income/_ssi_countable_income_without_loss_offset.py @@ -15,8 +15,12 @@ def ssi_countable_income_without_loss_offset(person, period, parameters, sources difference to ssi_countable_income for the month. The adjustment works person by person on own earned and unearned income, - so couple computations, spousal deeming and the blind or disabled - working student exclusion are approximate. + so couple computations and spousal deeming are approximate. It restores + only the person's own barred loss: an ineligible spouse's barred loss + still reduces the spouse's earnings before they are deemed, and the + person's own leftover loss can still reduce the spouse's deemed + earnings. Either can lower countable income below what the state rule + gives. """ year = period.this_year countable_income = person("ssi_countable_income", period) @@ -31,9 +35,24 @@ def ssi_countable_income_without_loss_offset(person, period, parameters, sources + person("ssi_unearned_income_deemed_from_ineligible_parent", year) + person("ssi_in_kind_support_and_maintenance", year) ) - with_offset = _apply_ssi_exclusions(earned, unearned, parameters, year) + # ssi_countable_income takes the blind or disabled working student + # exclusion (20 CFR 416.1112(c)(3)) off earned income before the other + # exclusions, so take it again from the restored earnings. + p = parameters(year).gov.ssa.ssi.income.exclusions.blind_or_disabled_working_student + is_student = ( + person("is_ssi_blind_or_disabled_working_student_exclusion_eligible", year) > 0 + ) + + def after_student_exclusion(amount): + monthly = max_(amount, 0) / MONTHS_IN_YEAR + exclusion = min_(min_(p.amount, monthly) * MONTHS_IN_YEAR, p.cap) + return max_(amount - where(is_student, exclusion, 0), 0) + + with_offset = _apply_ssi_exclusions( + after_student_exclusion(earned), unearned, parameters, year + ) without_offset = _apply_ssi_exclusions( - earned + restored_loss, unearned, parameters, year + after_student_exclusion(earned + restored_loss), unearned, parameters, year ) # Same gate as ssi_countable_income, which is zero for a person who is # not aged, blind or disabled.