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