From badc6b61a33a4d50fe1c9461631d38c01c3b923d Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 4 Jul 2026 19:28:23 -0400 Subject: [PATCH 1/9] Prorate income and expenses of SNAP-ineligible members per 7 CFR 273.11(c) Implements the treatment of income and deductible expenses of ineligible household members: proration for ineligible aliens (with state count-all and gross-test-only options), full exclusion of ineligible students' income under 273.11(d), and person-level income counting that also fixes the excluded child earner leak. Co-Authored-By: Claude Fable 5 --- .../snap-ineligible-member-income.added.md | 1 + .../count_all_income_states.yaml | 23 ++ .../gross_test_full_count_states.yaml | 18 ++ .../pre_prwora_statuses.yaml | 23 ++ .../gov/usda/snap/income/sources/earned.yaml | 10 - .../snap/income/sources/earned_person.yaml | 11 + .../{unearned.yaml => unearned_person.yaml} | 3 +- .../income/sources/unearned_spm_unit.yaml | 12 + .../federal_work_study_income_exclusion.yaml | 3 +- .../income/gross/snap_gross_test_income.yaml | 49 ++++ .../ineligible_members/integration.yaml | 235 ++++++++++++++++++ ...ap_gross_test_full_income_count_alien.yaml | 31 +++ .../is_snap_prorated_income_member.yaml | 54 ++++ .../snap_expense_counted_share.yaml | 58 +++++ .../snap_income_counted_share.yaml | 61 +++++ .../snap_prorated_income_fraction.yaml | 62 +++++ .../snap_excess_shelter_expense_deduction.py | 5 +- .../snap_child_support_deduction.py | 2 +- .../snap_countable_child_support_expense.py | 19 ++ .../snap_dependent_care_deduction.py | 10 +- .../snap_excess_medical_expense_deduction.py | 7 +- ...ap_child_support_gross_income_deduction.py | 2 +- .../gross/snap_gross_income_fpg_ratio.py | 2 +- .../income/gross/snap_gross_test_income.py | 27 ++ ...snap_gross_test_full_income_count_alien.py | 23 ++ .../is_snap_prorated_income_member.py | 29 +++ .../snap_expense_counted_share.py | 22 ++ .../snap_income_counted_share.py | 18 ++ .../snap_prorated_income_fraction.py | 23 ++ .../usda/snap/income/snap_earned_income.py | 21 +- .../snap/income/snap_earned_income_person.py | 33 ++- .../usda/snap/income/snap_unearned_income.py | 13 +- .../income/snap_unearned_income_person.py | 12 + ...k_requirement_income_proration_fraction.py | 16 -- 34 files changed, 880 insertions(+), 58 deletions(-) create mode 100644 changelog.d/snap-ineligible-member-income.added.md create mode 100644 policyengine_us/parameters/gov/usda/snap/income/ineligible_members/count_all_income_states.yaml create mode 100644 policyengine_us/parameters/gov/usda/snap/income/ineligible_members/gross_test_full_count_states.yaml create mode 100644 policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml delete mode 100644 policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml create mode 100644 policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml rename policyengine_us/parameters/gov/usda/snap/income/sources/{unearned.yaml => unearned_person.yaml} (93%) create mode 100644 policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml create mode 100644 policyengine_us/variables/gov/usda/snap/income/deductions/snap_countable_child_support_expense.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py delete mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_work_requirement_income_proration_fraction.py diff --git a/changelog.d/snap-ineligible-member-income.added.md b/changelog.d/snap-ineligible-member-income.added.md new file mode 100644 index 00000000000..e51b84abeb5 --- /dev/null +++ b/changelog.d/snap-ineligible-member-income.added.md @@ -0,0 +1 @@ +Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including state income counting options for ineligible immigrants and full exclusion of ineligible students' income. diff --git a/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/count_all_income_states.yaml b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/count_all_income_states.yaml new file mode 100644 index 00000000000..17bc1af1d54 --- /dev/null +++ b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/count_all_income_states.yaml @@ -0,0 +1,23 @@ +description: These states count all rather than all but a prorated share of the income of household members who are ineligible for the Supplemental Nutrition Assistance Program based on certain immigration statuses. +values: + # Elections as documented in the State Options Report, 16th Edition + # (current as of October 1, 2023); earlier editions not verified. + 2018-01-01: + - AZ + - GU + - ID + - IN + - KS + - ME + - MA + - TN + - UT +metadata: + label: SNAP ineligible member full income counting states + period: year + unit: list + reference: + - title: 7 CFR 273.11(c)(3)(i) + href: https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i + - title: SNAP State Options Report, 16th Edition + href: https://fns-prod.azureedge.us/sites/default/files/resource-files/snap-16th-state-options-report-june24.pdf#page=15 diff --git a/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/gross_test_full_count_states.yaml b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/gross_test_full_count_states.yaml new file mode 100644 index 00000000000..51fde74aab7 --- /dev/null +++ b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/gross_test_full_count_states.yaml @@ -0,0 +1,18 @@ +description: These states count all of the income of household members who are ineligible for the Supplemental Nutrition Assistance Program based on certain immigration statuses under the gross income test, while counting all but a prorated share under the net income test. +values: + # Elections as documented in the State Options Report, 16th Edition + # (current as of October 1, 2023); earlier editions not verified. + 2018-01-01: + - AR + - HI + - KY + - NC +metadata: + label: SNAP ineligible member gross income test full counting states + period: year + unit: list + reference: + - title: 7 CFR 273.11(c)(3)(i) + href: https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i + - title: SNAP State Options Report, 16th Edition + href: https://fns-prod.azureedge.us/sites/default/files/resource-files/snap-16th-state-options-report-june24.pdf#page=15 diff --git a/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml new file mode 100644 index 00000000000..22096c82631 --- /dev/null +++ b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml @@ -0,0 +1,23 @@ +description: The Department of Agriculture grants states discretion over counting the income of household members who are ineligible for the Supplemental Nutrition Assistance Program due to these immigration statuses. +values: + 2018-01-01: + # Statuses outside the excepted categories of 7 CFR 273.11(c)(3)(i)(A)-(G), + # for which states elect full counting, proration, or full counting under + # the gross income test only. + - UNDOCUMENTED + - DACA + - TPS + # Conditional entrants (INA 203(a)(7)) are not listed in the excepted + # categories of 273.11(c)(3)(i)(A)-(G). + - CONDITIONAL_ENTRANT +metadata: + label: SNAP ineligible member state income counting discretion immigration statuses + period: year + unit: list + reference: + - title: 7 CFR 273.11(c)(3)(i) + href: https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i + - title: 7 USC 2015(f) - Eligibility disqualifications + href: https://www.law.cornell.edu/uscode/text/7/2015#f + - title: SNAP State Options Report, 16th Edition + href: https://fns-prod.azureedge.us/sites/default/files/resource-files/snap-16th-state-options-report-june24.pdf#page=15 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml deleted file mode 100644 index d4d7e25e6ee..00000000000 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml +++ /dev/null @@ -1,10 +0,0 @@ -description: Income sources that count as earned income for SNAP - -values: - 2009-01-01: - - employment_income - - snap_self_employment_income_after_expense_deduction -metadata: - reference: - - title: United States Code, Title 7, Subsection 273.9(b)(1) - href: https://www.law.cornell.edu/cfr/text/7/273.9#b_1 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml new file mode 100644 index 00000000000..700b03d3eaa --- /dev/null +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml @@ -0,0 +1,11 @@ +description: Person-level income sources that count as earned income for SNAP + +values: + 2009-01-01: + - employment_income + # Self-employment income after the expense deduction is computed at the + # SPM unit level and attributed to members in snap_earned_income_person. +metadata: + reference: + - title: United States Code, Title 7, Subsection 273.9(b)(1) + href: https://www.law.cornell.edu/cfr/text/7/273.9#b_1 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_person.yaml similarity index 93% rename from policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml rename to policyengine_us/parameters/gov/usda/snap/income/sources/unearned_person.yaml index 3d6a1e2dc6a..41c76f18206 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_person.yaml @@ -1,10 +1,9 @@ -description: Income sources that count as unearned income for SNAP +description: Person-level income sources that count as unearned income for SNAP values: 2009-01-01: # (i) - ssi - - tanf - general_assistance # (ii) - pension_income # Includes annuities. diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml new file mode 100644 index 00000000000..2f2a51f5b47 --- /dev/null +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml @@ -0,0 +1,12 @@ +description: SPM unit-level income sources that count as unearned income for SNAP + +values: + 2009-01-01: + # (i) Unit-level assistance payments are counted in full since they + # cannot be attributed to an individual ineligible member. + - tanf + +metadata: + reference: + - title: United States Code, Title 7, Subsection 273.9(b)(2) + href: https://www.law.cornell.edu/cfr/text/7/273.9#b_2 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml index b0214a4a966..fec43f2cef8 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml @@ -44,5 +44,6 @@ spm_unit: members: [person1, person2] output: - snap_work_requirement_income_proration_fraction: [1, 0.5] + snap_prorated_income_fraction: 0.5 + snap_income_counted_share: [1, 0.5] snap_earned_income: 500 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml new file mode 100644 index 00000000000..491b856d630 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml @@ -0,0 +1,49 @@ +- name: Case 1, hybrid state counts the undocumented member's full income under the gross test only. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + # Net side counts 2/3 of the undocumented member's $2,000: $1,333.33. + snap_gross_income: 1_333.33 + # Gross test counts the full $2,000. + snap_gross_test_income: 2_000 + +- name: Case 2, prorating state uses the prorated income for both tests. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_gross_income: 1_333.33 + snap_gross_test_income: 1_333.33 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml new file mode 100644 index 00000000000..5e0fa131257 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -0,0 +1,235 @@ +# Integration tests for 7 CFR 273.11(c)/(d): treatment of income and +# deductible expenses of ineligible household members. +# Family of 3: working parent earns $24,000/year ($2,000/month), +# non-working spouse, child age 8. + +- name: Case 1, all-citizen baseline household. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: CITIZEN + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 3 + snap_earned_income: 2_000 + snap: 367.7 + +- name: Case 2, refugee earner is prorated after OBBBA. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: REFUGEE + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + # Counted share: 2 eligible / 3 household members. + # $2,000 x 2/3 = $1,333.33 + snap_earned_income: 1_333.33 + snap: 288.9 + +- name: Case 3, undocumented earner in a prorating state. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + snap_earned_income: 1_333.33 + snap: 288.9 + +- name: Case 4, undocumented earner in a count-all state. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: MA + output: + snap_unit_size: 2 + # Massachusetts counts all of the undocumented member's income. + snap_earned_income: 2_000 + snap: 194.4 + +- name: Case 5, undocumented earner in a hybrid gross-test state. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + snap_unit_size: 2 + # Net side prorated, gross test counts the full income. + snap_earned_income: 1_333.33 + snap_gross_test_income: 2_000 + snap: 288.9 + +- name: Case 6, prorated member's shares of shelter and dependent care expenses are not deductible. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + housing_cost: 12_000 + childcare_expenses: 2_400 + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_expense_counted_share: 0.5 + # $200/month childcare x 0.5 = $100 + snap_dependent_care_deduction: 100 + # ($1,000/month housing x 0.5) - 0.5 x net income pre-shelter = $254.50 + snap_excess_shelter_expense_deduction: 254.5 + snap_earned_income: 1_000 + snap: 227.2 + +- name: Case 7, ineligible student's income is not counted. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 40 + employment_income: 12_000 + person2: + age: 20 + dividend_income: 2_400 + is_full_time_college_student: true + weekly_hours_worked_before_lsr: 0 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_unit_size: 1 + snap_earned_income: 1_000 + # The ineligible student's dividend income is excluded entirely. + snap_unearned_income: 0 + snap: 120.7 + +- name: Case 8, California delays OBBBA so refugees remain eligible in January 2026. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + immigration_status: REFUGEE + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + output: + snap_unit_size: 3 + snap_income_counted_share: [1, 1, 1] + snap_earned_income: 2_000 + snap: 225.2 + +- name: Case 9, household with no eligible members receives nothing. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + is_snap_eligible: false + snap: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.yaml new file mode 100644 index 00000000000..46b65846854 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.yaml @@ -0,0 +1,31 @@ +- name: Case 1, undocumented member in a state counting all income under the gross test. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + state_code: AR + output: + is_snap_gross_test_full_income_count_alien: true + +- name: Case 2, undocumented member in a prorating state. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + state_code: TX + output: + is_snap_gross_test_full_income_count_alien: false + +- name: Case 3, refugee is prorated under both tests. + period: 2026-01 + input: + immigration_status: REFUGEE + state_code: AR + output: + is_snap_gross_test_full_income_count_alien: false + +- name: Case 4, citizen is not an ineligible alien. + period: 2026-01 + input: + immigration_status: CITIZEN + state_code: AR + output: + is_snap_gross_test_full_income_count_alien: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml new file mode 100644 index 00000000000..948d0f8d5e7 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml @@ -0,0 +1,54 @@ +- name: Case 1, undocumented member in a prorating state. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + meets_snap_work_requirements_person: true + state_code: TX + output: + is_snap_prorated_income_member: true + +- name: Case 2, undocumented member in a state that counts all income. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + meets_snap_work_requirements_person: true + state_code: MA + output: + is_snap_prorated_income_member: false + +- name: Case 3, refugee is prorated in every state after OBBBA. + period: 2026-01 + input: + immigration_status: REFUGEE + meets_snap_work_requirements_person: true + state_code: MA + output: + is_snap_prorated_income_member: true + +- name: Case 4, citizen meeting work requirements is not prorated. + period: 2026-01 + input: + immigration_status: CITIZEN + meets_snap_work_requirements_person: true + state_code: TX + output: + is_snap_prorated_income_member: false + +- name: Case 5, citizen failing work requirements is prorated. + period: 2026-01 + input: + immigration_status: CITIZEN + meets_snap_work_requirements_person: false + state_code: TX + output: + is_snap_prorated_income_member: true + +- name: Case 6, ineligible student is a nonhousehold member, not prorated. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + meets_snap_work_requirements_person: true + is_snap_ineligible_student: true + state_code: TX + output: + is_snap_prorated_income_member: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml new file mode 100644 index 00000000000..ca4ee233c9a --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml @@ -0,0 +1,58 @@ +- name: Case 1, prorated member's per capita expense share is not deductible. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_expense_counted_share: 0.5 + +- name: Case 2, count-all states keep full deductible expenses. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MA + output: + snap_expense_counted_share: 1 + +- name: Case 3, no ineligible members. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_expense_counted_share: 1 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml new file mode 100644 index 00000000000..b32a8ea520e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml @@ -0,0 +1,61 @@ +- name: Case 1, undocumented member's income share is prorated in a prorating state. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_income_counted_share: [0.6667, 1, 1] + +- name: Case 2, ineligible student's income is not counted at all. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 40 + person2: + age: 20 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_income_counted_share: [1, 0] + +- name: Case 3, undocumented member's income is fully counted in a count-all state. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: MA + output: + snap_income_counted_share: [1, 1] diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml new file mode 100644 index 00000000000..238d03a3dff --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml @@ -0,0 +1,62 @@ +- name: Case 1, one ineligible member of three household members. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + # 2 eligible members / 3 household members + snap_prorated_income_fraction: 0.6667 + +- name: Case 2, ineligible students are excluded from the division. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 40 + person2: + age: 20 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # 1 eligible member / 1 household member (student is a nonhousehold member) + snap_prorated_income_fraction: 1 + +- name: Case 3, no ineligible members. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_prorated_income_fraction: 1 diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py index b20f22ab2ee..de2495fb693 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py @@ -18,7 +18,10 @@ def formula(spm_unit, period, parameters): # income threshold. net_income_pre_shelter = spm_unit("snap_net_income_pre_shelter", period) subtracted_income = p.income_share_disregard * net_income_pre_shelter - housing_cost = add(spm_unit, period, ["snap_utility_allowance", "housing_cost"]) + expense_share = spm_unit("snap_expense_counted_share", period) + housing_cost = expense_share * add( + spm_unit, period, ["snap_utility_allowance", "housing_cost"] + ) uncapped_ded = max_(housing_cost - subtracted_income, 0) # Calculate capped deduction based on state group parameter. state_group = spm_unit.household("snap_region_str", period) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py index 3e66ccbe87a..e8a79f725f2 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_child_support_deduction.py @@ -13,7 +13,7 @@ class snap_child_support_deduction(Variable): # Excluding deduction for child support, which is applies to the gross income # calculation def formula(spm_unit, period, parameters): - child_support = add(spm_unit, period, ["child_support_expense"]) + child_support = spm_unit("snap_countable_child_support_expense", period) gross_income_deduction = spm_unit( "snap_child_support_gross_income_deduction", period ) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_countable_child_support_expense.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_countable_child_support_expense.py new file mode 100644 index 00000000000..8c365b837b9 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_countable_child_support_expense.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class snap_countable_child_support_expense(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP countable child support expense" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_4", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_iii", + ) + unit = USD + + def formula(spm_unit, period, parameters): + person = spm_unit.members + expense = person("child_support_expense", period) + share = person("snap_income_counted_share", period) + return spm_unit.sum(expense * share) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_dependent_care_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_dependent_care_deduction.py index 233e11e9b16..17e30048dc8 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_dependent_care_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_dependent_care_deduction.py @@ -8,6 +8,12 @@ class snap_dependent_care_deduction(Variable): unit = USD documentation = "Deduction from SNAP gross income for dependent care" definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_3" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_3", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_iii", + ) - adds = ["childcare_expenses"] + def formula(spm_unit, period, parameters): + expenses = add(spm_unit, period, ["childcare_expenses"]) + share = spm_unit("snap_expense_counted_share", period) + return expenses * share diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py index 9a5860c921f..c276932d130 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py @@ -15,11 +15,14 @@ class snap_excess_medical_expense_deduction(Variable): def formula(spm_unit, period, parameters): # Deduction applies to medical expenses incurred by elderly or disabled - # members only. + # household members only; expenses of nonhousehold members and + # prorated ineligible members do not count. person = spm_unit.members elderly = person("is_usda_elderly", period) disabled = person("is_usda_disabled", period) - moop = person("snap_allowable_medical_expenses", period) + student = person("is_snap_ineligible_student", period) + prorated = person("is_snap_prorated_income_member", period) + moop = person("snap_allowable_medical_expenses", period) * ~(student | prorated) elderly_disabled_moop = spm_unit.sum(moop * (elderly | disabled)) p = parameters(period).gov.usda.snap.income.deductions.excess_medical_expense excess = max_(elderly_disabled_moop - p.disregard, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py index ed2ca911305..b34930a2494 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_child_support_gross_income_deduction.py @@ -13,7 +13,7 @@ class snap_child_support_gross_income_deduction(Variable): reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_4" def formula(spm_unit, period, parameters): - child_support = add(spm_unit, period, ["child_support_expense"]) + child_support = spm_unit("snap_countable_child_support_expense", period) state = spm_unit.household("state_code_str", period) is_deductible = parameters( period diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py index 33ac942d3c1..c619b597b01 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py @@ -10,6 +10,6 @@ class snap_gross_income_fpg_ratio(Variable): unit = "/1" def formula(spm_unit, period): - income = spm_unit("snap_gross_income", period) + income = spm_unit("snap_gross_test_income", period) fpg = spm_unit("snap_fpg", period) return income / fpg diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py new file mode 100644 index 00000000000..4d11a427aa3 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py @@ -0,0 +1,27 @@ +from policyengine_us.model_api import * + + +class snap_gross_test_income(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP gross income for the gross income test" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#c", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i", + ) + unit = USD + + def formula(spm_unit, period, parameters): + # Certain states count the full income of certain ineligible aliens + # under the gross income test while prorating it under the net + # income test. + gross_income = spm_unit("snap_gross_income", period) + person = spm_unit.members + full_count_gross = person("is_snap_gross_test_full_income_count_alien", period) + income = person("snap_earned_income_person", period) + person( + "snap_unearned_income_person", period + ) + fraction = spm_unit("snap_prorated_income_fraction", period) + excluded_share = spm_unit.sum(full_count_gross * income) * (1 - fraction) + return gross_income + excluded_share diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py new file mode 100644 index 00000000000..c56a0f3b267 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class is_snap_gross_test_full_income_count_alien(Variable): + value_type = bool + entity = Person + label = ( + "SNAP ineligible alien with fully counted income under the gross income test" + ) + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i" + + def formula(person, period, parameters): + p = parameters(period).gov.usda.snap.income.ineligible_members + immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) + status = person("immigration_status", period.this_year).decode_to_str() + state = person.household("state_code_str", period) + state_discretion_status = np.isin(status, p.pre_prwora_statuses) + hybrid_state = np.isin(state, p.gross_test_full_count_states) + student = person("is_snap_ineligible_student", period) + return ( + ~student & immigration_ineligible & state_discretion_status & hybrid_state + ) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py new file mode 100644 index 00000000000..ff0a51cb68a --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py @@ -0,0 +1,29 @@ +from policyengine_us.model_api import * + + +class is_snap_prorated_income_member(Variable): + value_type = bool + entity = Person + label = "SNAP ineligible member with prorated income" + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + "https://www.law.cornell.edu/uscode/text/7/2015#f", + ) + + def formula(person, period, parameters): + p = parameters(period).gov.usda.snap.income.ineligible_members + immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) + status = person("immigration_status", period.this_year).decode_to_str() + state = person.household("state_code_str", period) + state_discretion_status = np.isin(status, p.pre_prwora_statuses) + counts_all = np.isin(state, p.count_all_income_states) + full_count_alien = immigration_ineligible & state_discretion_status & counts_all + prorated_alien = immigration_ineligible & ~full_count_alien + # NOTE: All members failing work requirements are prorated under + # 273.11(c)(2); full counting for 273.7 sanctions under 273.11(c)(1) + # is not yet modeled. + fails_work_requirements = ~person("meets_snap_work_requirements_person", period) + student = person("is_snap_ineligible_student", period) + return ~student & (prorated_alien | fails_work_requirements) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py new file mode 100644 index 00000000000..b487a6461f5 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py @@ -0,0 +1,22 @@ +from policyengine_us.model_api import * + + +class snap_expense_counted_share(Variable): + value_type = float + entity = SPMUnit + label = "SNAP share of expenses counted toward deductions" + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_iii", + "https://www.law.cornell.edu/cfr/text/7/273.11#d_1", + ) + + def formula(spm_unit, period, parameters): + # Shared expenses are assumed to be paid evenly per capita; the + # shares of nonhousehold members and prorated ineligible members + # are not deductible. + size = spm_unit("spm_unit_size", period) + students = add(spm_unit, period, ["is_snap_ineligible_student"]) + prorated = add(spm_unit, period, ["is_snap_prorated_income_member"]) + counted_members = size - students - prorated + return where(size > 0, counted_members / max_(size, 1), 1) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py new file mode 100644 index 00000000000..6f24e50ce94 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class snap_income_counted_share(Variable): + value_type = float + entity = Person + label = "SNAP share of income counted toward the household" + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii", + "https://www.law.cornell.edu/cfr/text/7/273.11#d", + ) + + def formula(person, period, parameters): + student = person("is_snap_ineligible_student", period) + prorated = person("is_snap_prorated_income_member", period) + fraction = person.spm_unit("snap_prorated_income_fraction", period) + return select([student, prorated], [0, fraction], default=1) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py new file mode 100644 index 00000000000..b73f10c11ea --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py @@ -0,0 +1,23 @@ +from policyengine_us.model_api import * + + +class snap_prorated_income_fraction(Variable): + value_type = float + entity = SPMUnit + label = "SNAP prorated income fraction" + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii" + + def formula(spm_unit, period, parameters): + # The ineligible member's income is divided evenly among household + # members (excluding nonhousehold members such as ineligible + # students); all but the ineligible members' shares are counted. + size = spm_unit("spm_unit_size", period) + students = add(spm_unit, period, ["is_snap_ineligible_student"]) + household_members = size - students + eligible_members = spm_unit("snap_unit_size", period) + return where( + household_members > 0, + eligible_members / max_(household_members, 1), + 0, + ) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index eaef5e8148e..0a4aae9bb6f 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -7,19 +7,14 @@ class snap_earned_income(Variable): definition_period = MONTH label = "SNAP earned income" documentation = "Earned income for calculating the SNAP earned income deduction" - reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", + "https://www.law.cornell.edu/cfr/text/7/273.11#c", + ) unit = USD - def formula(spm_unit, period): + def formula(spm_unit, period, parameters): person = spm_unit.members - income = spm_unit("snap_earned_income_person", period) - countable_earner = person("snap_countable_earner", period) - employment_income = person("employment_income", period) - full_employment_income = spm_unit.sum( - where(countable_earner, employment_income, 0) - ) - fraction = person("snap_work_requirement_income_proration_fraction", period) - prorated_employment_income = spm_unit.sum( - where(countable_earner, employment_income * fraction, 0) - ) - return income - full_employment_income + prorated_employment_income + earned = person("snap_earned_income_person", period) + share = person("snap_income_counted_share", period) + return spm_unit.sum(earned * share) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py index 0cc7cc23296..4887d00213e 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py @@ -3,14 +3,35 @@ class snap_earned_income_person(Variable): value_type = float - entity = SPMUnit + entity = Person definition_period = MONTH - label = "SNAP earned income person" - documentation = ( - "Earned income per person for calculating the SNAP earned income deduction" - ) + label = "SNAP earned income per person" reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" unit = USD defined_for = "snap_countable_earner" - adds = "gov.usda.snap.income.sources.earned" + def formula(person, period, parameters): + p = parameters(period).gov.usda.snap.income.sources + employment = add(person, period, p.earned_person) + # Self-employment income net of the expense deduction is computed at + # the SPM unit level; attribute it to members in proportion to their + # gross self-employment income. + self_employment = add( + person, + period, + [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ], + ) + weight = max_(self_employment, 0) + unit_weight = person.spm_unit.sum(weight) + unit_self_employment = person.spm_unit( + "snap_self_employment_income_after_expense_deduction", period + ) + attributed_self_employment = where( + unit_weight > 0, + unit_self_employment * weight / where(unit_weight > 0, unit_weight, 1), + 0, + ) + return employment + attributed_self_employment diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py index 0d6b5368aa2..3c078583396 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income.py @@ -7,7 +7,16 @@ class snap_unearned_income(Variable): definition_period = MONTH label = "SNAP unearned income" documentation = "Unearned income for calculating the SNAP benefit" - reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_2" + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c", + ) unit = USD - adds = "gov.usda.snap.income.sources.unearned" + def formula(spm_unit, period, parameters): + person = spm_unit.members + unearned = person("snap_unearned_income_person", period) + share = person("snap_income_counted_share", period) + p = parameters(period).gov.usda.snap.income.sources + unit_level = add(spm_unit, period, p.unearned_spm_unit) + return spm_unit.sum(unearned * share) + unit_level diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py new file mode 100644 index 00000000000..daafd31550c --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py @@ -0,0 +1,12 @@ +from policyengine_us.model_api import * + + +class snap_unearned_income_person(Variable): + value_type = float + entity = Person + definition_period = MONTH + label = "SNAP unearned income per person" + reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_2" + unit = USD + + adds = "gov.usda.snap.income.sources.unearned_person" diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_work_requirement_income_proration_fraction.py b/policyengine_us/variables/gov/usda/snap/income/snap_work_requirement_income_proration_fraction.py deleted file mode 100644 index c2c73fdef06..00000000000 --- a/policyengine_us/variables/gov/usda/snap/income/snap_work_requirement_income_proration_fraction.py +++ /dev/null @@ -1,16 +0,0 @@ -from policyengine_us.model_api import * - - -class snap_work_requirement_income_proration_fraction(Variable): - value_type = float - entity = Person - label = "SNAP work requirement income proration fraction" - definition_period = MONTH - reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_2" - - def formula(person, period, parameters): - meets_work_requirements = person("meets_snap_work_requirements_person", period) - snap_unit_size = person.spm_unit("snap_unit_size", period) - unit_size = person.spm_unit("spm_unit_size", period) - ineligible_fraction = where(unit_size > 0, snap_unit_size / unit_size, 0) - return where(meets_work_requirements, 1, ineligible_fraction) From 6c16db0a421aadfee901893c9ba64074f0e0a75c Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 4 Jul 2026 19:54:34 -0400 Subject: [PATCH 2/9] Fix SNAP tests: before_lsr inputs and retired proration variable reference Co-Authored-By: Claude Fable 5 --- .../usda/snap/income/gross/snap_gross_test_income.yaml | 2 ++ .../usda/snap/income/ineligible_members/integration.yaml | 8 ++++++++ .../tests/policy/baseline/gov/usda/snap/snap.yaml | 3 ++- 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml index 491b856d630..f6b62f312cc 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml @@ -6,6 +6,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 33 @@ -32,6 +33,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 33 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml index 5e0fa131257..80524deceaf 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -11,6 +11,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: CITIZEN person2: age: 33 @@ -36,6 +37,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: REFUGEE person2: age: 33 @@ -63,6 +65,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 33 @@ -88,6 +91,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 33 @@ -114,6 +118,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 33 @@ -141,6 +146,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: UNDOCUMENTED person2: age: 8 @@ -170,6 +176,7 @@ person1: age: 40 employment_income: 12_000 + employment_income_before_lsr: 12_000 person2: age: 20 dividend_income: 2_400 @@ -197,6 +204,7 @@ person1: age: 35 employment_income: 24_000 + employment_income_before_lsr: 24_000 immigration_status: REFUGEE person2: age: 33 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml index 049e0d79fed..50d0d0f8c34 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml @@ -186,7 +186,8 @@ state_code: TX output: snap_unit_size: 1 - snap_work_requirement_income_proration_fraction: [1, 0.5] + snap_prorated_income_fraction: 0.5 + snap_income_counted_share: [1, 0.5] snap_earned_income: 500 snap_gross_income: 500 snap: 230.40 From 763a01c4649910985b73918812305209f42ac12b Mon Sep 17 00:00:00 2001 From: Ziming Date: Mon, 6 Jul 2026 09:58:25 -0400 Subject: [PATCH 3/9] Fix microsim entity mismatch: attribute self-employment income at the unit level person.spm_unit projections inside the person-level earned income formula produced MicroSeries with mismatched weights under Microsimulation. Keep snap_earned_income_person as a pure person-level adds variable and perform the self-employment attribution inside the SPM unit formulas. Co-Authored-By: Claude Fable 5 --- .../income/gross/snap_gross_test_income.py | 19 ++++++++++++-- .../usda/snap/income/snap_earned_income.py | 20 ++++++++++++-- .../snap/income/snap_earned_income_person.py | 26 +------------------ ...nap_gross_self_employment_income_person.py | 16 ++++++++++++ 4 files changed, 52 insertions(+), 29 deletions(-) create mode 100644 policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py index 4d11a427aa3..269f719b5e1 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py @@ -22,6 +22,21 @@ def formula(spm_unit, period, parameters): income = person("snap_earned_income_person", period) + person( "snap_unearned_income_person", period ) + se_weight = max_(person("snap_gross_self_employment_income_person", period), 0) + total_weight = spm_unit.sum(se_weight) + full_count_weight = spm_unit.sum(se_weight * full_count_gross) + unit_self_employment = spm_unit( + "snap_self_employment_income_after_expense_deduction", period + ) + full_count_self_employment = where( + total_weight > 0, + unit_self_employment + * full_count_weight + / where(total_weight > 0, total_weight, 1), + 0, + ) + full_count_income = ( + spm_unit.sum(full_count_gross * income) + full_count_self_employment + ) fraction = spm_unit("snap_prorated_income_fraction", period) - excluded_share = spm_unit.sum(full_count_gross * income) * (1 - fraction) - return gross_income + excluded_share + return gross_income + full_count_income * (1 - fraction) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index 0a4aae9bb6f..9d37f1bb981 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -15,6 +15,22 @@ class snap_earned_income(Variable): def formula(spm_unit, period, parameters): person = spm_unit.members - earned = person("snap_earned_income_person", period) + employment = person("snap_earned_income_person", period) share = person("snap_income_counted_share", period) - return spm_unit.sum(earned * share) + # Self-employment income net of the expense deduction is computed at + # the SPM unit level; attribute it to members in proportion to their + # gross self-employment income. + se_weight = max_(person("snap_gross_self_employment_income_person", period), 0) + total_weight = spm_unit.sum(se_weight) + counted_weight = spm_unit.sum(se_weight * share) + unit_self_employment = spm_unit( + "snap_self_employment_income_after_expense_deduction", period + ) + counted_self_employment = where( + total_weight > 0, + unit_self_employment + * counted_weight + / where(total_weight > 0, total_weight, 1), + 0, + ) + return spm_unit.sum(employment * share) + counted_self_employment diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py index 4887d00213e..14b84f2c271 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py @@ -10,28 +10,4 @@ class snap_earned_income_person(Variable): unit = USD defined_for = "snap_countable_earner" - def formula(person, period, parameters): - p = parameters(period).gov.usda.snap.income.sources - employment = add(person, period, p.earned_person) - # Self-employment income net of the expense deduction is computed at - # the SPM unit level; attribute it to members in proportion to their - # gross self-employment income. - self_employment = add( - person, - period, - [ - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], - ) - weight = max_(self_employment, 0) - unit_weight = person.spm_unit.sum(weight) - unit_self_employment = person.spm_unit( - "snap_self_employment_income_after_expense_deduction", period - ) - attributed_self_employment = where( - unit_weight > 0, - unit_self_employment * weight / where(unit_weight > 0, unit_weight, 1), - 0, - ) - return employment + attributed_self_employment + adds = "gov.usda.snap.income.sources.earned_person" diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py new file mode 100644 index 00000000000..0c921fdb1d0 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py @@ -0,0 +1,16 @@ +from policyengine_us.model_api import * + + +class snap_gross_self_employment_income_person(Variable): + value_type = float + entity = Person + definition_period = MONTH + label = "SNAP gross self-employment income per person" + reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" + unit = USD + defined_for = "snap_countable_earner" + + adds = [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ] From d0b3b664106ab6fa5f963a4422b454b8d2c6be6f Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 7 Jul 2026 15:46:56 -0400 Subject: [PATCH 4/9] Fix review findings: work-requirement income regimes, SE attribution, expense shares, and gross test - Split 7 CFR 273.11(c)(1)/(c)(2): members sanctioned under the general work requirements have income and expenses counted in full; only ABAWD time-limit-ineligible members are prorated, and count-all state elections govern aliens who also fail work requirements. - Attribute unit-level self-employment income by signed gross SE with the countable-earner mask in the numerator only, so excluded earners' SE income is dropped rather than reattributed and losses offset other income at the member's counted share. - Prorate shared expenses as (n-i)(n+i)/(size*n) per the 273.11(c)(2)(iii) even-payment reading. - Count full-count aliens' child support in full under the gross test in states deducting child support from gross income. - Revert snap_gross_income_fpg_ratio to plain gross income so the TANF BBCE test is unaffected; the SNAP gross test reads snap_gross_test_income. - Restore parameter paths gov.usda.snap.income.sources.earned/.unearned, add unit metadata to share variables, extract shared is_snap_state_discretion_ineligible_alien, and add self-employment and work-requirement-regime test coverage. Co-Authored-By: Claude Fable 5 --- .../snap-ineligible-member-income.added.md | 2 +- .../{earned_person.yaml => earned.yaml} | 2 +- .../{unearned_person.yaml => unearned.yaml} | 0 .../meets_snap_gross_income_test.yaml | 11 +- .../federal_work_study_income_exclusion.yaml | 4 +- .../income/gross/snap_gross_test_income.yaml | 31 +++++ .../ineligible_members/integration.yaml | 106 +++++++++++++++++- .../is_snap_prorated_income_member.yaml | 23 +++- .../snap_expense_counted_share.yaml | 6 +- .../snap_income_counted_share.yaml | 42 +++++++ .../policy/baseline/gov/usda/snap/snap.yaml | 46 +++++++- .../meets_snap_gross_income_test.py | 10 +- ...ployment_income_after_expense_deduction.py | 7 +- .../gross/snap_gross_income_fpg_ratio.py | 2 +- .../income/gross/snap_gross_test_income.py | 49 +++++--- ...snap_gross_test_full_income_count_alien.py | 9 +- .../is_snap_prorated_income_member.py | 21 ++-- ..._snap_state_discretion_ineligible_alien.py | 19 ++++ .../snap_expense_counted_share.py | 23 +++- .../snap_income_counted_share.py | 1 + .../snap_prorated_income_fraction.py | 5 + .../usda/snap/income/snap_earned_income.py | 28 +++-- .../snap/income/snap_earned_income_person.py | 2 +- ...nap_gross_self_employment_income_person.py | 1 - .../income/snap_unearned_income_person.py | 2 +- 25 files changed, 373 insertions(+), 79 deletions(-) rename policyengine_us/parameters/gov/usda/snap/income/sources/{earned_person.yaml => earned.yaml} (96%) rename policyengine_us/parameters/gov/usda/snap/income/sources/{unearned_person.yaml => unearned.yaml} (100%) create mode 100644 policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py diff --git a/changelog.d/snap-ineligible-member-income.added.md b/changelog.d/snap-ineligible-member-income.added.md index e51b84abeb5..3e6fdacb795 100644 --- a/changelog.d/snap-ineligible-member-income.added.md +++ b/changelog.d/snap-ineligible-member-income.added.md @@ -1 +1 @@ -Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including state income counting options for ineligible immigrants and full exclusion of ineligible students' income. +Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including full income counting for members sanctioned under the general work requirements, state income counting options for ineligible immigrants, and full exclusion of ineligible students' income. diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml similarity index 96% rename from policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml rename to policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml index 700b03d3eaa..24ecbd56ed2 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/earned_person.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml @@ -4,7 +4,7 @@ values: 2009-01-01: - employment_income # Self-employment income after the expense deduction is computed at the - # SPM unit level and attributed to members in snap_earned_income_person. + # SPM unit level and attributed to members in snap_earned_income. metadata: reference: - title: United States Code, Title 7, Subsection 273.9(b)(1) diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_person.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml similarity index 100% rename from policyengine_us/parameters/gov/usda/snap/income/sources/unearned_person.yaml rename to policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml index 979eb8f8500..0e69ad393c9 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml @@ -6,21 +6,26 @@ - name: Income up to 130% meets test period: 2022 input: - snap_gross_income_fpg_ratio: 1.3 + # $1,300 per month against a $1,000 monthly poverty guideline. + snap_gross_test_income: 15_600 + snap_fpg: 12_000 output: meets_snap_gross_income_test: true - name: Income above 130% fails test period: 2022 input: - snap_gross_income_fpg_ratio: 1.31 + # $1,310 per month against a $1,000 monthly poverty guideline. + snap_gross_test_income: 15_720 + snap_fpg: 12_000 output: meets_snap_gross_income_test: false - name: Households with elderly or disabled members are exempt. period: 2022 input: - snap_gross_income_fpg_ratio: 1.31 + snap_gross_test_income: 15_720 + snap_fpg: 12_000 has_usda_elderly_disabled: true output: meets_snap_gross_income_test: true diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml index fec43f2cef8..f448c21df57 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/federal_work_study_income_exclusion.yaml @@ -23,7 +23,7 @@ output: meets_snap_work_exception: true -- name: Case 4, work-requirement-ineligible adult earned income is prorated. +- name: Case 4, ABAWD time-limit-ineligible adult earned income is prorated. period: 2024-01 absolute_error_margin: 0.01 input: @@ -38,7 +38,7 @@ is_tax_unit_dependent: false employment_income: 2024: 12_000 - meets_snap_general_work_requirements: false + meets_snap_general_work_requirements: true meets_snap_abawd_work_requirements: false spm_units: spm_unit: diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml index f6b62f312cc..d06ef27c4ba 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml @@ -49,3 +49,34 @@ output: snap_gross_income: 1_333.33 snap_gross_test_income: 1_333.33 + +- name: Case 3, hybrid state counts the full-count alien's child support deduction in full under the gross test. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + child_support_expense: 3_600 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + # Arkansas deducts child support from gross income. Net side counts + # 2/3 of the $2,000 earnings and 2/3 of the $300 child support: + # $1,333.33 - $200 = $1,133.33. + snap_gross_income: 1_133.33 + # Gross test counts the full earnings and the full child support + # payment: $2,000 - $300 = $1,700. + snap_gross_test_income: 1_700 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml index 80524deceaf..70ebd8bf48b 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -160,13 +160,19 @@ members: [person1, person2] state_code: TX output: - snap_expense_counted_share: 0.5 - # $200/month childcare x 0.5 = $100 - snap_dependent_care_deduction: 100 - # ($1,000/month housing x 0.5) - 0.5 x net income pre-shelter = $254.50 - snap_excess_shelter_expense_deduction: 254.5 + # With 2 household members and 1 prorated member paying per capita: + # the eligible member's half counts in full and the prorated member's + # half is divided evenly, with the eligible member's share counted: + # 1/2 + 1/2 x 1/2 = 0.75. + snap_expense_counted_share: 0.75 + # $200/month childcare x 0.75 = $150 + snap_dependent_care_deduction: 150 + # ($1,000/month housing x 0.75) - 0.5 x net income pre-shelter = $529.50 + snap_excess_shelter_expense_deduction: 529.5 snap_earned_income: 1_000 - snap: 227.2 + # Net income is fully offset by deductions, so the unit receives the + # one-person maximum allotment. + snap: 298 - name: Case 7, ineligible student's income is not counted. period: 2026-01 @@ -241,3 +247,91 @@ output: is_snap_eligible: false snap: 0 + +- name: Case 10, excluded K-12 child earner's self-employment income is not counted. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + self_employment_income: 24_000 + self_employment_income_before_lsr: 24_000 + person2: + age: 16 + is_in_k12_school: true + self_employment_income: 18_000 + self_employment_income_before_lsr: 18_000 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Texas takes no flat percentage deduction, so the parent's $2,000 + # monthly self-employment income is counted and the child's $1,500 is + # excluded under 7 CFR 273.9(c)(7). + snap_earned_income: 2_000 + # Net income: $2,000 - $400 earned income deduction - $209 standard + # deduction = $1,391; benefit: $546 - 0.3 x $1,391 = $128.70. + snap: 128.7 + +- name: Case 11, prorated member's self-employment income is prorated like employment income. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + self_employment_income: 24_000 + self_employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + # $2,000 monthly self-employment income x 2/3, mirroring Case 3. + snap_earned_income: 1_333.33 + snap: 288.9 + +- name: Case 12, prorated member's self-employment loss offsets other income at the prorated share. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + self_employment_income: 24_000 + self_employment_income_before_lsr: 24_000 + person2: + age: 33 + self_employment_income: -12_000 + self_employment_income_before_lsr: -12_000 + immigration_status: UNDOCUMENTED + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + # The eligible member's $2,000 monthly income counts in full and the + # prorated member's $1,000 loss offsets it at 2/3: + # $2,000 - $1,000 x 2/3 = $1,333.33. + snap_earned_income: 1_333.33 + snap: 288.9 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml index 948d0f8d5e7..2e40b7497b8 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.yaml @@ -34,10 +34,11 @@ output: is_snap_prorated_income_member: false -- name: Case 5, citizen failing work requirements is prorated. +- name: Case 5, citizen failing the ABAWD time limit is prorated. period: 2026-01 input: immigration_status: CITIZEN + meets_snap_general_work_requirements: true meets_snap_work_requirements_person: false state_code: TX output: @@ -52,3 +53,23 @@ state_code: TX output: is_snap_prorated_income_member: false + +- name: Case 7, citizen sanctioned under the general work requirements is counted in full, not prorated. + period: 2026-01 + input: + immigration_status: CITIZEN + meets_snap_general_work_requirements: false + meets_snap_work_requirements_person: false + state_code: TX + output: + is_snap_prorated_income_member: false + +- name: Case 8, count-all state election governs an undocumented member who also fails the ABAWD time limit. + period: 2026-01 + input: + immigration_status: UNDOCUMENTED + meets_snap_general_work_requirements: true + meets_snap_work_requirements_person: false + state_code: MA + output: + is_snap_prorated_income_member: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml index ca4ee233c9a..10fd239f6d9 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml @@ -16,7 +16,11 @@ members: [person1, person2] state_code: TX output: - snap_expense_counted_share: 0.5 + # The eligible member's per capita half counts in full; the prorated + # member's half is divided evenly between the two household members, + # and only the eligible member's share counts: + # 1/2 + 1/2 x 1/2 = 0.75. + snap_expense_counted_share: 0.75 - name: Case 2, count-all states keep full deductible expenses. period: 2026-01 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml index b32a8ea520e..6bf5d0db18e 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml @@ -59,3 +59,45 @@ state_code: MA output: snap_income_counted_share: [1, 1] + +- name: Case 4, ABAWD time-limit-ineligible member's income share is prorated. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + meets_snap_general_work_requirements: true + meets_snap_work_requirements_person: false + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_income_counted_share: [0.5, 1] + +- name: Case 5, income of a member sanctioned under the general work requirements is fully counted. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + meets_snap_general_work_requirements: false + meets_snap_work_requirements_person: false + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_income_counted_share: [1, 1] diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml index 50d0d0f8c34..90c9bfe7d17 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap.yaml @@ -153,7 +153,7 @@ snap_max_allotment: 291 snap: 291 -- name: Case 6, work-requirement-ineligible adult income is prorated for SNAP. +- name: Case 6, ABAWD time-limit-ineligible adult income is prorated for SNAP. period: 2024-01 absolute_error_margin: 0.01 input: @@ -172,7 +172,7 @@ is_tax_unit_dependent: false employment_income: 2024: 12_000 - meets_snap_general_work_requirements: false + meets_snap_general_work_requirements: true meets_snap_abawd_work_requirements: false spm_units: spm_unit: @@ -191,3 +191,45 @@ snap_earned_income: 500 snap_gross_income: 500 snap: 230.40 + +- name: Case 7, income of an adult sanctioned under the general work requirements is counted in full. + period: 2024-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: + 2024: 35 + monthly_age: 35 + is_tax_unit_dependent: false + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: true + person2: + age: + 2024: 30 + monthly_age: 30 + is_tax_unit_dependent: false + employment_income: + 2024: 12_000 + meets_snap_general_work_requirements: false + meets_snap_abawd_work_requirements: false + spm_units: + spm_unit: + members: [person1, person2] + tax_units: + tax_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_unit_size: 1 + # 7 CFR 273.11(c)(1): income of members sanctioned under 273.7 counts + # in full, while the member is still excluded from the unit size. + snap_income_counted_share: [1, 1] + snap_earned_income: 1_000 + snap_gross_income: 1_000 + # Net income: $1,000 - $200 earned income deduction - $198 standard + # deduction = $602; benefit: $291 - 0.3 x $602 = $110.40. + snap: 110.40 diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py index 3aab36e77a7..1c79564a581 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py @@ -13,9 +13,13 @@ class meets_snap_gross_income_test(Variable): ) def formula(spm_unit, period, parameters): - limit = parameters(period).gov.usda.snap.income.limit.gross - income = spm_unit("snap_gross_income_fpg_ratio", period) + p = parameters(period).gov.usda.snap.income.limit + # The gross test uses state-specific income counting for certain + # ineligible aliens, unlike the plain gross income used elsewhere + # (e.g., the TANF non-cash gross income test). + income = spm_unit("snap_gross_test_income", period) + fpg = spm_unit("snap_fpg", period) # Households with elderly and disabled people are exempt from the # gross income test. has_elderly_disabled = spm_unit("has_usda_elderly_disabled", period) - return has_elderly_disabled | (income <= limit) + return has_elderly_disabled | (income <= p.gross * fpg) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py index eee469b1c9b..1c5028336fd 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_income_after_expense_deduction.py @@ -10,12 +10,7 @@ class snap_self_employment_income_after_expense_deduction(Variable): def formula(spm_unit, period, parameters): self_employment_income = add( - spm_unit, - period, - [ - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], + spm_unit, period, ["snap_gross_self_employment_income_person"] ) expense_deduction = spm_unit("snap_self_employment_expense_deduction", period) return max_(self_employment_income - expense_deduction, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py index c619b597b01..33ac942d3c1 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_income_fpg_ratio.py @@ -10,6 +10,6 @@ class snap_gross_income_fpg_ratio(Variable): unit = "/1" def formula(spm_unit, period): - income = spm_unit("snap_gross_test_income", period) + income = spm_unit("snap_gross_income", period) fpg = spm_unit("snap_fpg", period) return income / fpg diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py index 269f719b5e1..ca5764cb5d5 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py @@ -18,25 +18,42 @@ def formula(spm_unit, period, parameters): # income test. gross_income = spm_unit("snap_gross_income", period) person = spm_unit.members - full_count_gross = person("is_snap_gross_test_full_income_count_alien", period) - income = person("snap_earned_income_person", period) + person( - "snap_unearned_income_person", period + full_count = person("is_snap_gross_test_full_income_count_alien", period) + share = person("snap_income_counted_share", period) + employment = person("snap_earned_income_person", period) + unearned = person("snap_unearned_income_person", period) + uncounted_income = spm_unit.sum( + full_count * (employment + unearned) * (1 - share) ) - se_weight = max_(person("snap_gross_self_employment_income_person", period), 0) - total_weight = spm_unit.sum(se_weight) - full_count_weight = spm_unit.sum(se_weight * full_count_gross) - unit_self_employment = spm_unit( + # Add back the uncounted portion of these aliens' self-employment + # income, attributed by signed gross self-employment income as in + # snap_earned_income. + countable = person("snap_countable_earner", period) + gross_self_employment = person( + "snap_gross_self_employment_income_person", period + ) + unit_gross = spm_unit.sum(gross_self_employment) + unit_net = spm_unit( "snap_self_employment_income_after_expense_deduction", period ) - full_count_self_employment = where( - total_weight > 0, - unit_self_employment - * full_count_weight - / where(total_weight > 0, total_weight, 1), + uncounted_weight = spm_unit.sum( + gross_self_employment * countable * full_count * (1 - share) + ) + uncounted_self_employment = where( + unit_gross > 0, + unit_net * uncounted_weight / where(unit_gross > 0, unit_gross, 1), 0, ) - full_count_income = ( - spm_unit.sum(full_count_gross * income) + full_count_self_employment + # Count these aliens' child support payments in full as well, in + # states that deduct child support when computing gross income. + child_support = person("child_support_expense", period) + uncounted_child_support = spm_unit.sum(full_count * child_support * (1 - share)) + state = spm_unit.household("state_code_str", period) + p = parameters(period).gov.usda.snap.income.deductions + cs_deductible = p.child_support[state] + return ( + gross_income + + uncounted_income + + uncounted_self_employment + - cs_deductible * uncounted_child_support ) - fraction = spm_unit("snap_prorated_income_fraction", period) - return gross_income + full_count_income * (1 - fraction) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py index c56a0f3b267..5fb7b971df3 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_gross_test_full_income_count_alien.py @@ -12,12 +12,7 @@ class is_snap_gross_test_full_income_count_alien(Variable): def formula(person, period, parameters): p = parameters(period).gov.usda.snap.income.ineligible_members - immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) - status = person("immigration_status", period.this_year).decode_to_str() state = person.household("state_code_str", period) - state_discretion_status = np.isin(status, p.pre_prwora_statuses) + discretion_alien = person("is_snap_state_discretion_ineligible_alien", period) hybrid_state = np.isin(state, p.gross_test_full_count_states) - student = person("is_snap_ineligible_student", period) - return ( - ~student & immigration_ineligible & state_discretion_status & hybrid_state - ) + return discretion_alien & hybrid_state diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py index ff0a51cb68a..9d015cc8193 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py @@ -15,15 +15,18 @@ class is_snap_prorated_income_member(Variable): def formula(person, period, parameters): p = parameters(period).gov.usda.snap.income.ineligible_members immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) - status = person("immigration_status", period.this_year).decode_to_str() state = person.household("state_code_str", period) - state_discretion_status = np.isin(status, p.pre_prwora_statuses) + discretion_alien = person("is_snap_state_discretion_ineligible_alien", period) counts_all = np.isin(state, p.count_all_income_states) - full_count_alien = immigration_ineligible & state_discretion_status & counts_all - prorated_alien = immigration_ineligible & ~full_count_alien - # NOTE: All members failing work requirements are prorated under - # 273.11(c)(2); full counting for 273.7 sanctions under 273.11(c)(1) - # is not yet modeled. - fails_work_requirements = ~person("meets_snap_work_requirements_person", period) + full_count_alien = discretion_alien & counts_all student = person("is_snap_ineligible_student", period) - return ~student & (prorated_alien | fails_work_requirements) + prorated_alien = immigration_ineligible & ~student & ~full_count_alien + # ABAWD time-limit ineligible members are prorated under + # 273.11(c)(2); members sanctioned for noncompliance with the + # general work requirements fall under 273.11(c)(1), which counts + # their income and expenses in full. In count-all states the + # state's full-count election for the alien's income governs. + fails_abawd_time_limit = person( + "meets_snap_general_work_requirements", period + ) & ~person("meets_snap_work_requirements_person", period) + return prorated_alien | (fails_abawd_time_limit & ~student & ~full_count_alien) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py new file mode 100644 index 00000000000..ee4118b8d63 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py @@ -0,0 +1,19 @@ +from policyengine_us.model_api import * + + +class is_snap_state_discretion_ineligible_alien(Variable): + value_type = bool + entity = Person + label = "SNAP ineligible alien whose income treatment is at state discretion" + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_3" + + def formula(person, period, parameters): + p = parameters(period).gov.usda.snap.income.ineligible_members + immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) + status = person("immigration_status", period.this_year).decode_to_str() + state_discretion_status = np.isin(status, p.pre_prwora_statuses) + # Ineligible students are treated as nonhousehold members under + # 273.11(d) rather than under the ineligible-alien rules. + student = person("is_snap_ineligible_student", period) + return ~student & immigration_ineligible & state_discretion_status diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py index b487a6461f5..bef5e733a80 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py @@ -6,17 +6,30 @@ class snap_expense_counted_share(Variable): entity = SPMUnit label = "SNAP share of expenses counted toward deductions" definition_period = MONTH + unit = "/1" reference = ( "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_iii", "https://www.law.cornell.edu/cfr/text/7/273.11#d_1", ) def formula(spm_unit, period, parameters): - # Shared expenses are assumed to be paid evenly per capita; the - # shares of nonhousehold members and prorated ineligible members - # are not deductible. + # Shared expenses are assumed to be paid evenly per capita. + # Ineligible students are nonhousehold members, so their per capita + # payments are not the household's expense (273.11(d)(1)). The + # portion paid by prorated ineligible members is divided evenly + # among household members, and all but the ineligible members' + # shares count (273.11(c)(2)(iii)): with n household members and i + # prorated members, the eligible-paid portion (n - i)/size counts + # in full and the prorated-paid portion contributes + # (i/size) x (n - i)/n. size = spm_unit("spm_unit_size", period) students = add(spm_unit, period, ["is_snap_ineligible_student"]) prorated = add(spm_unit, period, ["is_snap_prorated_income_member"]) - counted_members = size - students - prorated - return where(size > 0, counted_members / max_(size, 1), 1) + household_members = size - students + eligible_members = household_members - prorated + return where( + household_members > 0, + (eligible_members * (household_members + prorated)) + / (size * max_(household_members, 1)), + 0, + ) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py index 6f24e50ce94..7b876ffcd79 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py @@ -6,6 +6,7 @@ class snap_income_counted_share(Variable): entity = Person label = "SNAP share of income counted toward the household" definition_period = MONTH + unit = "/1" reference = ( "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii", "https://www.law.cornell.edu/cfr/text/7/273.11#d", diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py index b73f10c11ea..795dae1b218 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py @@ -6,12 +6,17 @@ class snap_prorated_income_fraction(Variable): entity = SPMUnit label = "SNAP prorated income fraction" definition_period = MONTH + unit = "/1" reference = "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii" def formula(spm_unit, period, parameters): # The ineligible member's income is divided evenly among household # members (excluding nonhousehold members such as ineligible # students); all but the ineligible members' shares are counted. + # Per the plural "ineligible members' share" in 273.11(c)(2)(ii), + # the shares of all ineligible members — including full-count + # aliens and members sanctioned under 273.11(c)(1), whose own + # income counts in full — are excluded from the counted fraction. size = spm_unit("spm_unit_size", period) students = add(spm_unit, period, ["is_snap_ineligible_student"]) household_members = size - students diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index 9d37f1bb981..5fd3e0f5f14 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -17,20 +17,24 @@ def formula(spm_unit, period, parameters): person = spm_unit.members employment = person("snap_earned_income_person", period) share = person("snap_income_counted_share", period) - # Self-employment income net of the expense deduction is computed at - # the SPM unit level; attribute it to members in proportion to their - # gross self-employment income. - se_weight = max_(person("snap_gross_self_employment_income_person", period), 0) - total_weight = spm_unit.sum(se_weight) - counted_weight = spm_unit.sum(se_weight * share) - unit_self_employment = spm_unit( + # Self-employment income net of the expense deduction is computed + # at the SPM unit level; attribute it to members in proportion to + # their signed gross self-employment income, so losses stay + # attached to the member who incurred them. Non-countable earners + # remain in the attribution base, so their shares are dropped + # rather than reattributed to countable earners. + countable = person("snap_countable_earner", period) + gross_self_employment = person( + "snap_gross_self_employment_income_person", period + ) + unit_gross = spm_unit.sum(gross_self_employment) + unit_net = spm_unit( "snap_self_employment_income_after_expense_deduction", period ) + counted_weight = spm_unit.sum(gross_self_employment * countable * share) counted_self_employment = where( - total_weight > 0, - unit_self_employment - * counted_weight - / where(total_weight > 0, total_weight, 1), + unit_gross > 0, + unit_net * counted_weight / where(unit_gross > 0, unit_gross, 1), 0, ) - return spm_unit.sum(employment * share) + counted_self_employment + return max_(spm_unit.sum(employment * share) + counted_self_employment, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py index 14b84f2c271..cd1e19067e0 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income_person.py @@ -10,4 +10,4 @@ class snap_earned_income_person(Variable): unit = USD defined_for = "snap_countable_earner" - adds = "gov.usda.snap.income.sources.earned_person" + adds = "gov.usda.snap.income.sources.earned" diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py index 0c921fdb1d0..5210b5b42b5 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py @@ -8,7 +8,6 @@ class snap_gross_self_employment_income_person(Variable): label = "SNAP gross self-employment income per person" reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" unit = USD - defined_for = "snap_countable_earner" adds = [ "self_employment_income_before_lsr", diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py index daafd31550c..1c462df6a5e 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_unearned_income_person.py @@ -9,4 +9,4 @@ class snap_unearned_income_person(Variable): reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_2" unit = USD - adds = "gov.usda.snap.income.sources.unearned_person" + adds = "gov.usda.snap.income.sources.unearned" From 0d3d6bee0dd03e6c84715715b4435479e181daf7 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 14 Jul 2026 12:33:45 -0400 Subject: [PATCH 5/9] Fix xhigh review findings: SE loss flooring, SUA proration, member-status conferral, and gross-test consistency - Floor non-farm self-employment losses at zero per 7 CFR 273.11(a)(2)(iii) (only farm losses offset other income), bounding the unit-net SE attribution ratio to [0, 1] and preventing one member's loss from wiping out another's income. - Rebuild snap_gross_test_income as a direct mirror of snap_gross_income with the full-count share substituted, returning snap_gross_income exactly for households without full-count aliens. - Route both the SNAP gross income test and the BBCE/TANF non-cash gross screen through a new snap_gross_test_income_fpg_ratio so hybrid states' full-count election cannot be bypassed via categorical eligibility; restore the ratio-form comparison. - Rewrite snap_expense_counted_share as the mean of members' income counted shares, applying one reading of 273.11(c)(2) across income, expense, and child support proration; stop prorating standard utility allowances, which are not expenses paid by or billed to an ineligible member. - Add is_snap_excluded_member and has_snap_elderly_disabled_member so excluded members no longer confer household-wide elderly/disabled status for the gross test, shelter cap, and BBCE screens. - Reuse snap_gross_self_employment_income_person in the SE expense deduction; document the 273.11(c)(1)/(c)(2) work-requirement split; fix USC-vs-CFR citation titles; add hybrid-state SE, sanctioned- member expense share, and elderly-status tests. Co-Authored-By: Claude Fable 5 --- .../snap-ineligible-member-income.added.md | 2 +- .../gov/usda/snap/income/sources/earned.yaml | 5 +- .../usda/snap/income/sources/unearned.yaml | 2 +- .../income/sources/unearned_spm_unit.yaml | 2 +- .../tanf/non_cash/is_tanf_non_cash_hheod.yaml | 2 +- ...meets_tanf_non_cash_gross_income_test.yaml | 56 +++++++------- .../meets_snap_gross_income_test.yaml | 4 +- ...snap_excess_shelter_expense_deduction.yaml | 4 +- .../income/gross/snap_gross_test_income.yaml | 65 ++++++++++++++++ .../ineligible_members/integration.yaml | 75 +++++++++++++++++-- .../snap_expense_counted_share.yaml | 31 ++++++++ .../tanf/non_cash/is_tanf_non_cash_hheod.py | 4 +- .../meets_tanf_non_cash_gross_income_test.py | 6 +- .../meets_snap_gross_income_test.py | 15 ++-- .../snap/has_snap_elderly_disabled_member.py | 26 +++++++ .../snap_self_employment_expense_deduction.py | 7 +- .../snap_excess_shelter_expense_deduction.py | 10 ++- .../income/gross/snap_gross_test_income.py | 47 ++++++------ .../gross/snap_gross_test_income_fpg_ratio.py | 18 +++++ .../is_snap_prorated_income_member.py | 9 +++ .../snap_expense_counted_share.py | 30 +++----- .../usda/snap/income/snap_earned_income.py | 16 ++-- ...nap_gross_self_employment_income_person.py | 17 +++-- .../gov/usda/snap/is_snap_excluded_member.py | 24 ++++++ .../variables/gov/usda/snap/snap_unit_size.py | 10 +-- 25 files changed, 363 insertions(+), 124 deletions(-) create mode 100644 policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py create mode 100644 policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.py create mode 100644 policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py diff --git a/changelog.d/snap-ineligible-member-income.added.md b/changelog.d/snap-ineligible-member-income.added.md index 3e6fdacb795..7ffef25cc6b 100644 --- a/changelog.d/snap-ineligible-member-income.added.md +++ b/changelog.d/snap-ineligible-member-income.added.md @@ -1 +1 @@ -Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including full income counting for members sanctioned under the general work requirements, state income counting options for ineligible immigrants, and full exclusion of ineligible students' income. +Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including full income counting for members sanctioned under the general work requirements, state income counting options for ineligible immigrants, and full exclusion of ineligible students' income. Excluded members no longer confer household-wide elderly or disabled status for the gross income test, shelter deduction cap, and BBCE screens; standard utility allowances are no longer prorated; and non-farm self-employment losses no longer offset other members' income per 7 CFR 273.11(a)(2)(iii). snap_earned_income_person is now a person-level employment income variable and snap_work_requirement_income_proration_fraction is removed. diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml index 24ecbd56ed2..5a69cf5554f 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/earned.yaml @@ -4,8 +4,9 @@ values: 2009-01-01: - employment_income # Self-employment income after the expense deduction is computed at the - # SPM unit level and attributed to members in snap_earned_income. + # SPM unit level and attributed to members in snap_earned_income and + # snap_gross_test_income. metadata: reference: - - title: United States Code, Title 7, Subsection 273.9(b)(1) + - title: 7 CFR 273.9(b)(1) href: https://www.law.cornell.edu/cfr/text/7/273.9#b_1 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml index 41c76f18206..830ddc2183d 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml @@ -32,5 +32,5 @@ values: metadata: reference: - - title: United States Code, Title 7, Subsection 273.9(b)(2) + - title: 7 CFR 273.9(b)(2) href: https://www.law.cornell.edu/cfr/text/7/273.9#b_2 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml index 2f2a51f5b47..bba0008edf8 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned_spm_unit.yaml @@ -8,5 +8,5 @@ values: metadata: reference: - - title: United States Code, Title 7, Subsection 273.9(b)(2) + - title: 7 CFR 273.9(b)(2) href: https://www.law.cornell.edu/cfr/text/7/273.9#b_2 diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.yaml index eb8ddd40250..2b01a20fe27 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.yaml @@ -6,6 +6,6 @@ - name: California doesn't use HHEOD, but by default having any satisfies the requirement. period: 2022 input: - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true output: is_tanf_non_cash_hheod: true diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml index 638cff8b5cf..7dfd68b9af7 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml @@ -6,7 +6,7 @@ - name: "California gross income limit is 200% of FPL" period: 2022 input: - snap_gross_income_fpg_ratio: 2.05 + snap_gross_test_income_fpg_ratio: 2.05 output: meets_tanf_non_cash_gross_income_test: false @@ -18,14 +18,14 @@ state_code: NC output: snap_gross_income: 2_550 - snap_gross_income_fpg_ratio: 1.95 + snap_gross_test_income_fpg_ratio: 1.95 # Gross income limit is 200% of FPL, so this should be true. meets_tanf_non_cash_gross_income_test: true - name: CO has BBCE at 200% FPL (bug fix verification) period: 2024-01 input: - snap_gross_income_fpg_ratio: 1.95 + snap_gross_test_income_fpg_ratio: 1.95 state_code: CO output: meets_tanf_non_cash_gross_income_test: true @@ -33,7 +33,7 @@ - name: CO rejects income above 200% FPL period: 2024-01 input: - snap_gross_income_fpg_ratio: 2.05 + snap_gross_test_income_fpg_ratio: 2.05 state_code: CO output: meets_tanf_non_cash_gross_income_test: false @@ -41,7 +41,7 @@ - name: MS has no BBCE (non-BBCE state, bug fix verification) period: 2024-01 input: - snap_gross_income_fpg_ratio: 0.5 + snap_gross_test_income_fpg_ratio: 0.5 state_code: MS output: meets_tanf_non_cash_gross_income_test: false @@ -49,7 +49,7 @@ - name: IN has BBCE at 130% FPL period: 2024-01 input: - snap_gross_income_fpg_ratio: 1.25 + snap_gross_test_income_fpg_ratio: 1.25 state_code: IN output: meets_tanf_non_cash_gross_income_test: true @@ -57,10 +57,10 @@ - name: NY without earned income, dependent care, or elderly/disabled status is capped at 130% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.45 + snap_gross_test_income_fpg_ratio: 1.45 snap_earned_income: 0 snap_dependent_care_deduction: 0 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false state_code: NY output: meets_tanf_non_cash_gross_income_test: false @@ -68,10 +68,10 @@ - name: NY with earned income is eligible through 150% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.45 + snap_gross_test_income_fpg_ratio: 1.45 snap_earned_income: 1 snap_dependent_care_deduction: 0 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false state_code: NY output: meets_tanf_non_cash_gross_income_test: true @@ -79,10 +79,10 @@ - name: NY with earned income is ineligible above 150% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.55 + snap_gross_test_income_fpg_ratio: 1.55 snap_earned_income: 1 snap_dependent_care_deduction: 0 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false state_code: NY output: meets_tanf_non_cash_gross_income_test: false @@ -90,10 +90,10 @@ - name: NY with dependent care expenses is eligible through 200% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.9 + snap_gross_test_income_fpg_ratio: 1.9 snap_earned_income: 0 snap_dependent_care_deduction: 1 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false state_code: NY output: meets_tanf_non_cash_gross_income_test: true @@ -101,10 +101,10 @@ - name: NY elderly or disabled household is eligible through 200% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.9 + snap_gross_test_income_fpg_ratio: 1.9 snap_earned_income: 0 snap_dependent_care_deduction: 0 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true state_code: NY output: meets_tanf_non_cash_gross_income_test: true @@ -112,8 +112,8 @@ - name: AL all elderly or disabled household is eligible through 200% FPL period: 2026-01 input: - snap_gross_income_fpg_ratio: 1.9 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 1.9 + has_snap_elderly_disabled_member: true has_all_usda_elderly_disabled: true state_code: AL output: @@ -122,8 +122,8 @@ - name: AK elderly or disabled household had no BBCE through ERS coverage period: 2020 input: - snap_gross_income_fpg_ratio: 1 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 1 + has_snap_elderly_disabled_member: true state_code: AK output: meets_tanf_non_cash_gross_income_test: false @@ -131,8 +131,8 @@ - name: CO elderly or disabled household had 200% FPL BBCE before the general limit changed period: 2016-01 input: - snap_gross_income_fpg_ratio: 1.9 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 1.9 + has_snap_elderly_disabled_member: true state_code: CO output: meets_tanf_non_cash_gross_income_test: true @@ -140,8 +140,8 @@ - name: ID elderly or disabled household has no BBCE gross income test period: 2026-01 input: - snap_gross_income_fpg_ratio: 2.5 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 2.5 + has_snap_elderly_disabled_member: true state_code: ID output: meets_tanf_non_cash_gross_income_test: true @@ -149,8 +149,8 @@ - name: MS elderly or disabled household had no BBCE gross income test before BBCE ended period: 2016-01 input: - snap_gross_income_fpg_ratio: 2.5 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 2.5 + has_snap_elderly_disabled_member: true state_code: MS output: meets_tanf_non_cash_gross_income_test: true @@ -158,8 +158,8 @@ - name: WV all elderly or disabled household had 200% FPL BBCE before the general limit changed period: 2016-01 input: - snap_gross_income_fpg_ratio: 1.9 - has_usda_elderly_disabled: true + snap_gross_test_income_fpg_ratio: 1.9 + has_snap_elderly_disabled_member: true has_all_usda_elderly_disabled: true state_code: WV output: diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml index 0e69ad393c9..c47c0034592 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_gross_income_test.yaml @@ -21,11 +21,11 @@ output: meets_snap_gross_income_test: false -- name: Households with elderly or disabled members are exempt. +- name: Households with elderly or disabled unit members are exempt. period: 2022 input: snap_gross_test_income: 15_720 snap_fpg: 12_000 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true output: meets_snap_gross_income_test: true diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_shelter_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_shelter_expense_deduction.yaml index 133c6f8eedc..8c657a81f5f 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_shelter_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_shelter_expense_deduction.yaml @@ -36,7 +36,7 @@ input: state_code: TX housing_cost: 600 * 12 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true output: snap_excess_shelter_expense_deduction: 600 * 12 @@ -80,7 +80,7 @@ snap_region_str: GU housing_cost: 4_800 # 400 monthly snap_utility_allowance: 300 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false is_homeless: false output: # cap at 701, diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml index d06ef27c4ba..a63b0df269b 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml @@ -80,3 +80,68 @@ # Gross test counts the full earnings and the full child support # payment: $2,000 - $300 = $1,700. snap_gross_test_income: 1_700 + +- name: Case 4, hybrid state counts the full-count alien's self-employment income in full under the gross test only. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + self_employment_income: 24_000 + self_employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + # Provide the unit-level net directly so the test does not depend + # on the state's self-employment expense deduction election. + snap_self_employment_income_after_expense_deduction: 24_000 + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + # The unit's $2,000 monthly net self-employment income is attributed + # to the alien, prorated at 2/3 on the net side and counted in full + # under the gross test. + snap_earned_income: 1_333.33 + snap_gross_income: 1_333.33 + snap_gross_test_income: 2_000 + +- name: Case 5, another member's self-employment loss neither offsets nor inflates the full-count alien's income. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + self_employment_income: 24_000 + self_employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + self_employment_income: -12_000 + self_employment_income_before_lsr: -12_000 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + snap_self_employment_income_after_expense_deduction: 24_000 + households: + household: + members: [person1, person2, person3] + state_code: AR + output: + # The eligible member's non-farm loss is floored at zero + # (7 CFR 273.11(a)(2)(iii) offsets only farm losses), so the whole + # $2,000 monthly net amount is attributed to the alien: the + # attribution ratio stays bounded with mixed-sign gross amounts. + snap_earned_income: 1_333.33 + snap_gross_income: 1_333.33 + snap_gross_test_income: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml index 70ebd8bf48b..eee2e268380 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -305,7 +305,7 @@ snap_earned_income: 1_333.33 snap: 288.9 -- name: Case 12, prorated member's self-employment loss offsets other income at the prorated share. +- name: Case 12, prorated member's self-employment loss does not offset other members' income. period: 2026-01 absolute_error_margin: 0.1 input: @@ -330,8 +330,71 @@ state_code: TX output: snap_unit_size: 2 - # The eligible member's $2,000 monthly income counts in full and the - # prorated member's $1,000 loss offsets it at 2/3: - # $2,000 - $1,000 x 2/3 = $1,333.33. - snap_earned_income: 1_333.33 - snap: 288.9 + # 7 CFR 273.11(a)(2)(iii) offsets self-employment losses against other + # household income only for farming operations, so the prorated + # member's $1,000 monthly non-farm loss is dropped and the eligible + # member's $2,000 monthly income counts in full. + snap_earned_income: 2_000 + # Net income: $2,000 - $400 earned income deduction - $209 standard + # deduction = $1,391; benefit: $546 - 0.3 x $1,391 = $128.70. + snap: 128.7 + +- name: Case 13, excluded elderly member does not confer the gross income test exemption. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 48_000 + employment_income_before_lsr: 48_000 + person2: + age: 33 + person3: + age: 70 + immigration_status: UNDOCUMENTED + person4: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + snap_unit_size: 3 + # The elderly grandparent is an excluded member (7 CFR 273.11(c)) and + # does not confer household-wide elderly status, so the household's + # $4,000 monthly income faces (and fails) the 130% gross income test. + has_snap_elderly_disabled_member: false + meets_snap_gross_income_test: false + snap: 0 + +- name: Case 14, eligible elderly member confers the gross income test exemption. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 48_000 + employment_income_before_lsr: 48_000 + person2: + age: 33 + person3: + age: 70 + immigration_status: CITIZEN + person4: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + snap_unit_size: 4 + has_snap_elderly_disabled_member: true + meets_snap_gross_income_test: true diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml index 10fd239f6d9..8cbbdfd84db 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.yaml @@ -60,3 +60,34 @@ state_code: TX output: snap_expense_counted_share: 1 + +- name: Case 4, sanctioned member's expenses count in full but their slice of the prorated member's payment does not. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 30 + weekly_hours_worked_before_lsr: 0 + person3: + age: 35 + person4: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3, person4] + households: + household: + members: [person1, person2, person3, person4] + state_code: TX + output: + snap_unit_size: 2 + # The prorated member's payment counts at the income fraction (2 unit + # members / 4 household members = 1/2), which excludes the sanctioned + # member's slice; the sanctioned member's own payments count in full + # (7 CFR 273.11(c)(1)): (1/2 + 1 + 1 + 1) / 4 = 0.875. + snap_income_counted_share: [0.5, 1, 1, 1] + snap_expense_counted_share: 0.875 diff --git a/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py b/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py index 37dbacac301..00fc235ab28 100644 --- a/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py +++ b/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py @@ -12,8 +12,10 @@ def formula(spm_unit, period, parameters): state = spm_unit.household("state_code_str", period) bbce = parameters(period).gov.hhs.tanf.non_cash requires_all = bbce.requires_all_for_hheod[state] + # Excluded members do not confer elderly or disabled status when + # comparing income with eligibility standards (7 CFR 273.11(c)/(d)). return where( requires_all, spm_unit("has_all_usda_elderly_disabled", period), - spm_unit("has_usda_elderly_disabled", period), + spm_unit("has_snap_elderly_disabled_member", period), ) diff --git a/policyengine_us/variables/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.py b/policyengine_us/variables/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.py index 34c35c0353b..d9f59960d9b 100644 --- a/policyengine_us/variables/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.py +++ b/policyengine_us/variables/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.py @@ -30,5 +30,9 @@ def formula(spm_unit, period, parameters): ) gross_limit = where(ny, ny_gross_limit, gross_limit) - gross_income = spm_unit("snap_gross_income_fpg_ratio", period) + # Use the gross-test income concept so states electing full + # counting of certain ineligible aliens' income under the gross + # income test (7 CFR 273.11(c)(3)(i)) apply it to this categorical + # eligibility screen as well. + gross_income = spm_unit("snap_gross_test_income_fpg_ratio", period) return gross_income <= gross_limit diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py index 1c79564a581..9f113306d5d 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py @@ -15,11 +15,10 @@ class meets_snap_gross_income_test(Variable): def formula(spm_unit, period, parameters): p = parameters(period).gov.usda.snap.income.limit # The gross test uses state-specific income counting for certain - # ineligible aliens, unlike the plain gross income used elsewhere - # (e.g., the TANF non-cash gross income test). - income = spm_unit("snap_gross_test_income", period) - fpg = spm_unit("snap_fpg", period) - # Households with elderly and disabled people are exempt from the - # gross income test. - has_elderly_disabled = spm_unit("has_usda_elderly_disabled", period) - return has_elderly_disabled | (income <= p.gross * fpg) + # ineligible aliens (7 CFR 273.11(c)(3)(i)). + ratio = spm_unit("snap_gross_test_income_fpg_ratio", period) + # Households with an elderly or disabled unit member are exempt + # from the gross income test; excluded members do not confer this + # status (7 CFR 273.11(c)/(d)). + has_elderly_disabled = spm_unit("has_snap_elderly_disabled_member", period) + return has_elderly_disabled | (ratio <= p.gross) diff --git a/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py b/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py new file mode 100644 index 00000000000..109a0f04f33 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py @@ -0,0 +1,26 @@ +from policyengine_us.model_api import * + + +class has_snap_elderly_disabled_member(Variable): + value_type = bool + entity = SPMUnit + definition_period = MONTH + label = "Has an elderly or disabled SNAP unit member" + documentation = ( + "Whether the SNAP unit includes an elderly or disabled member. " + "Ineligible and nonhousehold members are excluded when comparing " + "the household's income with the income eligibility standards, so " + "they do not confer household-wide elderly or disabled status." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/271.2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c", + "https://www.law.cornell.edu/cfr/text/7/273.11#d", + ) + + def formula(spm_unit, period, parameters): + person = spm_unit.members + elderly = person("is_usda_elderly", period.this_year) + disabled = person("is_usda_disabled", period.this_year) + excluded = person("is_snap_excluded_member", period) + return spm_unit.any((elderly | disabled) & ~excluded) diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py index f78dab19001..2d9f3bb46c6 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py @@ -14,12 +14,7 @@ class snap_self_employment_expense_deduction(Variable): def formula(spm_unit, period, parameters): self_employment_income = add( - spm_unit, - period, - [ - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ], + spm_unit, period, ["snap_gross_self_employment_income_person"] ) expenses = spm_unit("snap_self_employment_income_expense", period) p = parameters(period).gov.usda.snap.income.deductions.self_employment diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py index de2495fb693..a11484b3c44 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/shelter/snap_excess_shelter_expense_deduction.py @@ -19,15 +19,19 @@ def formula(spm_unit, period, parameters): net_income_pre_shelter = spm_unit("snap_net_income_pre_shelter", period) subtracted_income = p.income_share_disregard * net_income_pre_shelter expense_share = spm_unit("snap_expense_counted_share", period) - housing_cost = expense_share * add( - spm_unit, period, ["snap_utility_allowance", "housing_cost"] + # Standard utility allowances are not expenses paid by or billed + # to an ineligible member, so only actual housing costs are + # prorated (7 CFR 273.11(c)(2)(iii)). + utility_allowance = spm_unit("snap_utility_allowance", period) + housing_cost = ( + expense_share * add(spm_unit, period, ["housing_cost"]) + utility_allowance ) uncapped_ded = max_(housing_cost - subtracted_income, 0) # Calculate capped deduction based on state group parameter. state_group = spm_unit.household("snap_region_str", period) ded_cap = p.cap[state_group] capped_ded = min_(uncapped_ded, ded_cap) - has_elderly_disabled = spm_unit("has_usda_elderly_disabled", period) + has_elderly_disabled = spm_unit("has_snap_elderly_disabled_member", period) # Cap for all but elderly/disabled people and add utility allowance. non_homeless_shelter_deduction = where( has_elderly_disabled, uncapped_ded, capped_ded diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py index ca5764cb5d5..baddd29d662 100644 --- a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income.py @@ -15,19 +15,18 @@ class snap_gross_test_income(Variable): def formula(spm_unit, period, parameters): # Certain states count the full income of certain ineligible aliens # under the gross income test while prorating it under the net - # income test. + # income test. This mirrors the composition of snap_gross_income + # (snap_earned_income + snap_unearned_income + # - snap_child_support_gross_income_deduction) with the full-count + # share substituted for these aliens' income counted share. gross_income = spm_unit("snap_gross_income", period) person = spm_unit.members full_count = person("is_snap_gross_test_full_income_count_alien", period) share = person("snap_income_counted_share", period) + gross_test_share = where(full_count, 1, share) employment = person("snap_earned_income_person", period) - unearned = person("snap_unearned_income_person", period) - uncounted_income = spm_unit.sum( - full_count * (employment + unearned) * (1 - share) - ) - # Add back the uncounted portion of these aliens' self-employment - # income, attributed by signed gross self-employment income as in - # snap_earned_income. + # Attribute unit-level net self-employment income by gross + # self-employment income, as in snap_earned_income. countable = person("snap_countable_earner", period) gross_self_employment = person( "snap_gross_self_employment_income_person", period @@ -36,24 +35,30 @@ def formula(spm_unit, period, parameters): unit_net = spm_unit( "snap_self_employment_income_after_expense_deduction", period ) - uncounted_weight = spm_unit.sum( - gross_self_employment * countable * full_count * (1 - share) + counted_weight = spm_unit.sum( + gross_self_employment * countable * gross_test_share ) - uncounted_self_employment = where( - unit_gross > 0, - unit_net * uncounted_weight / where(unit_gross > 0, unit_gross, 1), + counted_self_employment = ( + unit_net * counted_weight / where(unit_gross > 0, unit_gross, 1) + ) + earned = max_( + spm_unit.sum(employment * gross_test_share) + counted_self_employment, 0, ) + unearned = person("snap_unearned_income_person", period) + p = parameters(period).gov.usda.snap.income + unit_level_unearned = add(spm_unit, period, p.sources.unearned_spm_unit) # Count these aliens' child support payments in full as well, in # states that deduct child support when computing gross income. child_support = person("child_support_expense", period) - uncounted_child_support = spm_unit.sum(full_count * child_support * (1 - share)) state = spm_unit.household("state_code_str", period) - p = parameters(period).gov.usda.snap.income.deductions - cs_deductible = p.child_support[state] - return ( - gross_income - + uncounted_income - + uncounted_self_employment - - cs_deductible * uncounted_child_support + cs_deductible = p.deductions.child_support[state] + full_count_income = ( + earned + + spm_unit.sum(unearned * gross_test_share) + + unit_level_unearned + - cs_deductible * spm_unit.sum(child_support * gross_test_share) ) + # Households without full-count aliens use snap_gross_income itself + # so both tests read an identical income concept. + return where(spm_unit.any(full_count), full_count_income, gross_income) diff --git a/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.py b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.py new file mode 100644 index 00000000000..deb4dd237ec --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class snap_gross_test_income_fpg_ratio(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP gross test income to FPL ratio" + documentation = ( + "SNAP gross income for the gross income test as a percentage of " + "the federal poverty line" + ) + unit = "/1" + + def formula(spm_unit, period): + income = spm_unit("snap_gross_test_income", period) + fpg = spm_unit("snap_fpg", period) + return income / fpg diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py index 9d015cc8193..cb4468a48f8 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py @@ -6,6 +6,15 @@ class is_snap_prorated_income_member(Variable): entity = Person label = "SNAP ineligible member with prorated income" definition_period = MONTH + documentation = ( + "Whether this person's income is prorated under 7 CFR " + "273.11(c)(2): ineligible aliens without a state full-count " + "election and ABAWD time-limit ineligible members (273.24). " + "Members failing the general work requirements fall under " + "273.11(c)(1), which counts their income in full. The model " + "treats modeled noncompliance as a state-imposed disqualification, " + "consistent with these members' exclusion from the SNAP unit size." + ) reference = ( "https://www.law.cornell.edu/cfr/text/7/273.11#c_2_ii", "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py index bef5e733a80..53284afe684 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py @@ -13,23 +13,15 @@ class snap_expense_counted_share(Variable): ) def formula(spm_unit, period, parameters): - # Shared expenses are assumed to be paid evenly per capita. - # Ineligible students are nonhousehold members, so their per capita - # payments are not the household's expense (273.11(d)(1)). The - # portion paid by prorated ineligible members is divided evenly - # among household members, and all but the ineligible members' - # shares count (273.11(c)(2)(iii)): with n household members and i - # prorated members, the eligible-paid portion (n - i)/size counts - # in full and the prorated-paid portion contributes - # (i/size) x (n - i)/n. + # Shared expenses are assumed to be paid evenly per capita, and + # each payer's per capita portion counts at that member's income + # counted share: ineligible students are nonhousehold members + # whose payments are not the household's expense (273.11(d)(1)); + # the portion paid by prorated members is divided evenly among + # household members with all but the ineligible members' shares + # counted (273.11(c)(2)(iii)); and expenses of members sanctioned + # under 273.11(c)(1) continue to count in their entirety. + person = spm_unit.members + share = person("snap_income_counted_share", period) size = spm_unit("spm_unit_size", period) - students = add(spm_unit, period, ["is_snap_ineligible_student"]) - prorated = add(spm_unit, period, ["is_snap_prorated_income_member"]) - household_members = size - students - eligible_members = household_members - prorated - return where( - household_members > 0, - (eligible_members * (household_members + prorated)) - / (size * max_(household_members, 1)), - 0, - ) + return spm_unit.sum(share) / max_(size, 1) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py index 5fd3e0f5f14..18038f0b637 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_earned_income.py @@ -19,10 +19,12 @@ def formula(spm_unit, period, parameters): share = person("snap_income_counted_share", period) # Self-employment income net of the expense deduction is computed # at the SPM unit level; attribute it to members in proportion to - # their signed gross self-employment income, so losses stay - # attached to the member who incurred them. Non-countable earners - # remain in the attribution base, so their shares are dropped - # rather than reattributed to countable earners. + # their gross self-employment income. Gross amounts are floored at + # zero per enterprise, so the attribution ratio is bounded to + # [0, 1]. Non-countable earners remain in the attribution base, so + # their shares are dropped rather than reattributed to countable + # earners. This mirrors the attribution in snap_gross_test_income, + # which substitutes the full-count share. countable = person("snap_countable_earner", period) gross_self_employment = person( "snap_gross_self_employment_income_person", period @@ -32,9 +34,7 @@ def formula(spm_unit, period, parameters): "snap_self_employment_income_after_expense_deduction", period ) counted_weight = spm_unit.sum(gross_self_employment * countable * share) - counted_self_employment = where( - unit_gross > 0, - unit_net * counted_weight / where(unit_gross > 0, unit_gross, 1), - 0, + counted_self_employment = ( + unit_net * counted_weight / where(unit_gross > 0, unit_gross, 1) ) return max_(spm_unit.sum(employment * share) + counted_self_employment, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py index 5210b5b42b5..d6f8718dd00 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py @@ -6,10 +6,17 @@ class snap_gross_self_employment_income_person(Variable): entity = Person definition_period = MONTH label = "SNAP gross self-employment income per person" - reference = "https://www.law.cornell.edu/cfr/text/7/273.9#b_1" + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", + "https://www.law.cornell.edu/cfr/text/7/273.11#a_2_iii", + ) unit = USD - adds = [ - "self_employment_income_before_lsr", - "sstb_self_employment_income_before_lsr", - ] + def formula(person, period, parameters): + # 7 CFR 273.11(a)(2)(iii) offsets losses against other household + # income only for farming operations. The self-employment income + # modeled here is non-farm, so each enterprise's loss is floored + # at zero rather than netted against other members' income. + regular = person("self_employment_income_before_lsr", period) + sstb = person("sstb_self_employment_income_before_lsr", period) + return max_(regular, 0) + max_(sstb, 0) diff --git a/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py b/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py new file mode 100644 index 00000000000..0509dd9b87c --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class is_snap_excluded_member(Variable): + value_type = bool + entity = Person + definition_period = MONTH + label = "SNAP excluded member" + documentation = ( + "Whether this person is a nonhousehold member or an ineligible " + "household member excluded from the SNAP unit" + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c", + "https://www.law.cornell.edu/cfr/text/7/273.11#d", + "https://www.law.cornell.edu/uscode/text/7/2015#f", + ) + + def formula(person, period, parameters): + return ( + person("is_snap_ineligible_student", period) + | ~person("is_snap_immigration_status_eligible", period) + | ~person("meets_snap_work_requirements_person", period) + ) diff --git a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py index dbb0b2f4d0f..34bbfb40715 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py +++ b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py @@ -13,11 +13,5 @@ class snap_unit_size(Variable): def formula(spm_unit, period, parameters): unit_size = spm_unit("spm_unit_size", period) - person = spm_unit.members - ineligible = ( - person("is_snap_ineligible_student", period) - | ~person("is_snap_immigration_status_eligible", period) - | ~person("meets_snap_work_requirements_person", period) - ) - ineligible_count = spm_unit.sum(ineligible) - return max_(unit_size - ineligible_count, 0) + excluded_count = add(spm_unit, period, ["is_snap_excluded_member"]) + return max_(unit_size - excluded_count, 0) From 5398073f14c99f4cf60cbbe8fe890d85beb6f9fc Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 14 Jul 2026 13:06:29 -0400 Subject: [PATCH 6/9] Fix expense counted share for units larger than the modeled member count Compute the share as one minus the uncounted portions of actual members' per capita payments, so a spm_unit_size input exceeding the modeled member count leaves expenses fully counted. Give the second member explicit work-requirement inputs in the counted-share tests, since providing the variables for one member makes them input arrays with false defaults for the rest. Co-Authored-By: Claude Fable 5 --- .../ineligible_members/snap_income_counted_share.yaml | 7 +++++++ .../ineligible_members/snap_expense_counted_share.py | 6 +++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml index 6bf5d0db18e..e3145bebdef 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_income_counted_share.yaml @@ -71,6 +71,11 @@ meets_snap_work_requirements_person: false person2: age: 33 + # Explicit inputs: providing these variables for person1 makes + # them input arrays, so unspecified members would get the false + # default instead of their computed values. + meets_snap_general_work_requirements: true + meets_snap_work_requirements_person: true spm_units: spm_unit: members: [person1, person2] @@ -92,6 +97,8 @@ meets_snap_work_requirements_person: false person2: age: 33 + meets_snap_general_work_requirements: true + meets_snap_work_requirements_person: true spm_units: spm_unit: members: [person1, person2] diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py index 53284afe684..07e17ea93c1 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py @@ -24,4 +24,8 @@ def formula(spm_unit, period, parameters): person = spm_unit.members share = person("snap_income_counted_share", period) size = spm_unit("spm_unit_size", period) - return spm_unit.sum(share) / max_(size, 1) + # Subtract the uncounted portion of each member's per capita + # payment rather than summing counted shares directly, so a + # spm_unit_size exceeding the modeled member count leaves the + # share at one. + return max_(1 - spm_unit.sum(1 - share) / max_(size, 1), 0) From 7802ffd43333082957b19b2b35fc00e9a7e3fab0 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 14 Jul 2026 13:53:52 -0400 Subject: [PATCH 7/9] Fix program-review findings: farm-loss citation, asset-test conferral, and direct unit tests - Correct the farm-loss-offset citation to 7 CFR 273.11(a)(2)(ii) in the gross self-employment variable, changelog, and test comments. - Select the SNAP resource limit with has_snap_elderly_disabled_member: ineligible members' resources continue to count in their entirety (273.11(c)(1)(i), (c)(2)(i), (c)(3)) but the members are excluded when comparing resources with the resource limits ((c)(1)(ii)(D), (c)(2)(iv)(D)), so an excluded elderly or disabled member no longer confers the higher limit. - Add direct unit tests for is_snap_state_discretion_ineligible_alien (covering DACA, TPS, and conditional entrants), is_snap_excluded_member, has_snap_elderly_disabled_member, snap_gross_self_employment_income_person (per-enterprise loss flooring), and snap_countable_child_support_expense. - Add behavior tests: unprorated standard utility allowance, medical expense exclusion for prorated members, unearned income proration, unit-level TANF full counting, and the NC BBCE gross-screen divergence for full-count aliens. - Read YEAR-defined booleans and counts with period.this_year in the new MONTH formulas; reground the elderly/disabled conferral on the 7 CFR 271.2 member definition; replace the self-employment expense deduction blog reference with 273.9(c)(9) and 273.11(b); regroup unearned source comments under their 273.9(b)(2) subsections. Co-Authored-By: Claude Fable 5 --- .../snap-ineligible-member-income.added.md | 2 +- .../pre_prwora_statuses.yaml | 2 +- .../usda/snap/income/sources/unearned.yaml | 4 +- ...meets_tanf_non_cash_gross_income_test.yaml | 30 ++++++ .../eligibility/meets_snap_asset_test.yaml | 4 +- .../has_snap_elderly_disabled_member.yaml | 93 ++++++++++++++++++ .../snap_countable_child_support_expense.yaml | 77 +++++++++++++++ ...snap_excess_medical_expense_deduction.yaml | 47 +++++++++ .../income/gross/snap_gross_test_income.yaml | 2 +- .../ineligible_members/integration.yaml | 83 +++++++++++++++- ...nap_state_discretion_ineligible_alien.yaml | 98 +++++++++++++++++++ ...p_gross_self_employment_income_person.yaml | 49 ++++++++++ .../usda/snap/is_snap_excluded_member.yaml | 55 +++++++++++ .../tanf/non_cash/is_tanf_non_cash_hheod.py | 4 +- .../snap/eligibility/meets_snap_asset_test.py | 17 +++- .../meets_snap_gross_income_test.py | 5 +- .../snap/has_snap_elderly_disabled_member.py | 10 +- .../snap_self_employment_expense_deduction.py | 5 +- .../snap_excess_medical_expense_deduction.py | 2 +- .../is_snap_prorated_income_member.py | 2 +- ..._snap_state_discretion_ineligible_alien.py | 2 +- .../snap_expense_counted_share.py | 2 +- .../snap_income_counted_share.py | 2 +- .../snap_prorated_income_fraction.py | 4 +- ...nap_gross_self_employment_income_person.py | 4 +- .../gov/usda/snap/is_snap_excluded_member.py | 2 +- .../variables/gov/usda/snap/snap_unit_size.py | 2 +- 27 files changed, 579 insertions(+), 30 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/has_snap_elderly_disabled_member.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_countable_child_support_expense.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_gross_self_employment_income_person.yaml create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/is_snap_excluded_member.yaml diff --git a/changelog.d/snap-ineligible-member-income.added.md b/changelog.d/snap-ineligible-member-income.added.md index 7ffef25cc6b..52d3020ba8f 100644 --- a/changelog.d/snap-ineligible-member-income.added.md +++ b/changelog.d/snap-ineligible-member-income.added.md @@ -1 +1 @@ -Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including full income counting for members sanctioned under the general work requirements, state income counting options for ineligible immigrants, and full exclusion of ineligible students' income. Excluded members no longer confer household-wide elderly or disabled status for the gross income test, shelter deduction cap, and BBCE screens; standard utility allowances are no longer prorated; and non-farm self-employment losses no longer offset other members' income per 7 CFR 273.11(a)(2)(iii). snap_earned_income_person is now a person-level employment income variable and snap_work_requirement_income_proration_fraction is removed. +Prorate the income and deductible expenses of SNAP-ineligible household members per 7 CFR 273.11(c) and (d), including full income counting for members sanctioned under the general work requirements, state income counting options for ineligible immigrants, and full exclusion of ineligible students' income. Excluded members no longer confer household-wide elderly or disabled status for the gross income test, shelter deduction cap, and BBCE screens; standard utility allowances are no longer prorated; and non-farm self-employment losses no longer offset other members' income per 7 CFR 273.11(a)(2)(ii). snap_earned_income_person is now a person-level employment income variable and snap_work_requirement_income_proration_fraction is removed. diff --git a/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml index 22096c82631..5bff538aff0 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/ineligible_members/pre_prwora_statuses.yaml @@ -17,7 +17,7 @@ metadata: reference: - title: 7 CFR 273.11(c)(3)(i) href: https://www.law.cornell.edu/cfr/text/7/273.11#c_3_i - - title: 7 USC 2015(f) - Eligibility disqualifications + - title: 7 USC 2015(f) - Aliens href: https://www.law.cornell.edu/uscode/text/7/2015#f - title: SNAP State Options Report, 16th Edition href: https://fns-prod.azureedge.us/sites/default/files/resource-files/snap-16th-state-options-report-june24.pdf#page=15 diff --git a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml index 830ddc2183d..b382e18a9b0 100644 --- a/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml +++ b/policyengine_us/parameters/gov/usda/snap/income/sources/unearned.yaml @@ -12,6 +12,7 @@ values: - disability_benefits - workers_compensation - social_security + - survivor_benefits - retirement_distributions # Assume all rental income meets this criteria (rest is earned): # "gross income minus the cost of doing business derived from rental @@ -21,13 +22,12 @@ values: # (iii) - child_support_received - alimony_income - - financial_assistance - - survivor_benefits # (iv) educational assistance is countable only after educational # exclusions. Keep it out until the excluded share is modeled. # (v) - dividend_income - interest_income + - financial_assistance - miscellaneous_income metadata: diff --git a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml index bddd0ded896..066d114d042 100644 --- a/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/hhs/tanf/non_cash/meets_tanf_non_cash_gross_income_test.yaml @@ -209,3 +209,33 @@ state_code: WV output: meets_tanf_non_cash_gross_income_test: true + +- name: NC full-count alien income flips the BBCE gross screen where the prorated income would pass + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 48_000 + employment_income_before_lsr: 48_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: NC + output: + # North Carolina fully counts the alien's income under gross screens + # (7 CFR 273.11(c)(3)(i)) while prorating it on the net side. The + # full $4,000 monthly income exceeds NC's 200% FPL BBCE limit for the + # 2-person unit, though the prorated $2,666.67 would have passed. + snap_gross_income: 2_666.67 + snap_gross_test_income: 4_000 + meets_tanf_non_cash_gross_income_test: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml index bd4a28d9b22..94c723d3181 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml @@ -16,7 +16,7 @@ period: 2022 input: snap_assets: 2_600 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true output: meets_snap_asset_test: true @@ -24,6 +24,6 @@ period: 2022 input: snap_assets: 3_800 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true output: meets_snap_asset_test: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/has_snap_elderly_disabled_member.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/has_snap_elderly_disabled_member.yaml new file mode 100644 index 00000000000..d27b3b904ed --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/has_snap_elderly_disabled_member.yaml @@ -0,0 +1,93 @@ +- name: Case 1, disabled eligible member confers elderly or disabled status. + period: 2026-01 + input: + people: + person1: + age: 30 + is_usda_disabled: true + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: true + +- name: Case 2, disabled excluded member does not confer status. + period: 2026-01 + input: + people: + person1: + age: 30 + is_usda_disabled: true + immigration_status: UNDOCUMENTED + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: false + +- name: Case 3, elderly eligible member confers status. + period: 2026-01 + input: + people: + person1: + age: 65 + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: true + +- name: Case 4, elderly excluded member does not confer status. + period: 2026-01 + input: + people: + person1: + age: 70 + immigration_status: UNDOCUMENTED + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: false + +- name: Case 5, household with no elderly or disabled members. + period: 2026-01 + input: + people: + person1: + age: 35 + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_countable_child_support_expense.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_countable_child_support_expense.yaml new file mode 100644 index 00000000000..b71e0e62933 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_countable_child_support_expense.yaml @@ -0,0 +1,77 @@ +- name: Case 1, prorated payer's child support counts at the income fraction and an eligible payer's in full. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + child_support_expense: 3_600 + person2: + age: 33 + child_support_expense: 1_200 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + # The prorated member's $300 monthly payment counts at 2 unit members + # / 3 household members = 2/3, and the eligible member's $100 counts + # in full: $200 + $100 = $300. + snap_countable_child_support_expense: 300 + +- name: Case 2, ineligible student's child support payment is not the household's expense. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 40 + child_support_expense: 3_600 + person2: + age: 20 + is_snap_ineligible_student: true + child_support_expense: 1_200 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # Only the eligible parent's $300 monthly payment counts; the + # nonhousehold member's payment is excluded (7 CFR 273.11(d)(1)). + snap_countable_child_support_expense: 300 + +- name: Case 3, sanctioned member's child support expense continues to count in its entirety. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + meets_snap_general_work_requirements: false + meets_snap_work_requirements_person: false + child_support_expense: 3_600 + person2: + age: 33 + meets_snap_general_work_requirements: true + meets_snap_work_requirements_person: true + child_support_expense: 1_200 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # 7 CFR 273.11(c)(1): the sanctioned member's $300 monthly payment + # counts in full alongside the eligible member's $100. + snap_countable_child_support_expense: 400 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml index 8ee9e83b005..0196d42877f 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml @@ -98,3 +98,50 @@ state_code_str: CA output: snap_excess_medical_expense_deduction: 0 + +- name: Prorated ineligible elderly member's medical expenses are excluded. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + immigration_status: UNDOCUMENTED + other_medical_expenses: 6_000 + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # The prorated member's $500 monthly expenses do not count + # (7 CFR 273.11(c)(2)(iii)), and with zero excess the standard + # medical deduction is not claimable either. + snap_excess_medical_expense_deduction: 0 + +- name: Eligible elderly member's medical expenses remain deductible. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 70 + immigration_status: CITIZEN + other_medical_expenses: 6_000 + person2: + age: 33 + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + # $500 monthly expenses less the $35 disregard, above Texas's $135 + # standard medical deduction. + snap_excess_medical_expense_deduction: 465 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml index a63b0df269b..11ef908c940 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income.yaml @@ -139,7 +139,7 @@ state_code: AR output: # The eligible member's non-farm loss is floored at zero - # (7 CFR 273.11(a)(2)(iii) offsets only farm losses), so the whole + # (7 CFR 273.11(a)(2)(ii) offsets only farm losses), so the whole # $2,000 monthly net amount is attributed to the alien: the # attribution ratio stays bounded with mixed-sign gross amounts. snap_earned_income: 1_333.33 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml index eee2e268380..25b8330925a 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -330,7 +330,7 @@ state_code: TX output: snap_unit_size: 2 - # 7 CFR 273.11(a)(2)(iii) offsets self-employment losses against other + # 7 CFR 273.11(a)(2)(ii) offsets self-employment losses against other # household income only for farming operations, so the prorated # member's $1,000 monthly non-farm loss is dropped and the eligible # member's $2,000 monthly income counts in full. @@ -398,3 +398,84 @@ snap_unit_size: 4 has_snap_elderly_disabled_member: true meets_snap_gross_income_test: true + +- name: Case 15, the standard utility allowance is not prorated. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 8 + spm_units: + spm_unit: + members: [person1, person2] + housing_cost: 6_000 + snap_utility_allowance: 300 + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_expense_counted_share: 0.75 + # Only the $500 monthly housing cost is prorated; the $300 standard + # utility allowance is not an expense paid by or billed to the + # prorated member, so it counts in full (7 CFR 273.11(c)(2)(iii)): + # (0.75 x $500 + $300) - 0.5 x $591 net income pre-shelter = $379.50. + # Prorating the allowance as well would yield $304.50. + snap_excess_shelter_expense_deduction: 379.5 + +- name: Case 16, prorated member's unearned income is prorated like earned income. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + social_security_retirement: 12_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + # $1,000 monthly Social Security x 2/3. + snap_unearned_income: 666.67 + +- name: Case 17, unit-level assistance counts in full despite a prorated member. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + tanf: 2_400 + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + snap_unit_size: 2 + # The $200 monthly TANF grant is paid to the unit as a whole and + # cannot be attributed to the prorated member, so it counts in full. + snap_unearned_income: 200 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.yaml new file mode 100644 index 00000000000..bb34839c44e --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.yaml @@ -0,0 +1,98 @@ +- name: Case 1, undocumented member is a state-discretion ineligible alien. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: true + +- name: Case 2, DACA member is a state-discretion ineligible alien. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: DACA + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: true + +- name: Case 3, TPS member is a state-discretion ineligible alien. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: TPS + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: true + +- name: Case 4, conditional entrant is a state-discretion ineligible alien. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: CONDITIONAL_ENTRANT + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: true + +- name: Case 5, refugee is ineligible after OBBBA but falls in the excepted categories, which always prorate. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: REFUGEE + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: false + +- name: Case 6, citizen is not an ineligible alien. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: CITIZEN + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: false + +- name: Case 7, ineligible students are treated as nonhousehold members rather than under the ineligible-alien rules. + period: 2026-01 + input: + people: + person1: + age: 20 + immigration_status: UNDOCUMENTED + is_snap_ineligible_student: true + households: + household: + members: [person1] + state_code: TX + output: + is_snap_state_discretion_ineligible_alien: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_gross_self_employment_income_person.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_gross_self_employment_income_person.yaml new file mode 100644 index 00000000000..83f0b7fcc2f --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_gross_self_employment_income_person.yaml @@ -0,0 +1,49 @@ +- name: Case 1, losses are floored per enterprise, not netted within the person. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + self_employment_income_before_lsr: 1_200 + sstb_self_employment_income_before_lsr: -600 + households: + household: + members: [person1] + state_code: TX + output: + # $100 monthly regular self-employment income counts in full; the $50 + # monthly SSTB loss is floored at zero (7 CFR 273.11(a)(2)(ii) offsets + # losses only for farm operations), so the result is $100, not $50. + snap_gross_self_employment_income_person: 100 + +- name: Case 2, a sole loss-making enterprise yields zero gross income. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + self_employment_income_before_lsr: -12_000 + households: + household: + members: [person1] + state_code: TX + output: + snap_gross_self_employment_income_person: 0 + +- name: Case 3, positive enterprises sum. + period: 2026-01 + absolute_error_margin: 0.01 + input: + people: + person1: + age: 35 + self_employment_income_before_lsr: 12_000 + sstb_self_employment_income_before_lsr: 6_000 + households: + household: + members: [person1] + state_code: TX + output: + snap_gross_self_employment_income_person: 1_500 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/is_snap_excluded_member.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/is_snap_excluded_member.yaml new file mode 100644 index 00000000000..389e359458d --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/is_snap_excluded_member.yaml @@ -0,0 +1,55 @@ +- name: Case 1, eligible working-age citizen is not excluded. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: CITIZEN + households: + household: + members: [person1] + state_code: TX + output: + is_snap_excluded_member: false + +- name: Case 2, ineligible student is excluded as a nonhousehold member. + period: 2026-01 + input: + people: + person1: + age: 20 + is_snap_ineligible_student: true + households: + household: + members: [person1] + state_code: TX + output: + is_snap_excluded_member: true + +- name: Case 3, immigration-status-ineligible member is excluded. + period: 2026-01 + input: + people: + person1: + age: 35 + immigration_status: UNDOCUMENTED + households: + household: + members: [person1] + state_code: TX + output: + is_snap_excluded_member: true + +- name: Case 4, member failing the work requirements is excluded. + period: 2026-01 + input: + people: + person1: + age: 35 + meets_snap_work_requirements_person: false + households: + household: + members: [person1] + state_code: TX + output: + is_snap_excluded_member: true diff --git a/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py b/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py index 00fc235ab28..734d85c6d8a 100644 --- a/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py +++ b/policyengine_us/variables/gov/hhs/tanf/non_cash/is_tanf_non_cash_hheod.py @@ -12,8 +12,8 @@ def formula(spm_unit, period, parameters): state = spm_unit.household("state_code_str", period) bbce = parameters(period).gov.hhs.tanf.non_cash requires_all = bbce.requires_all_for_hheod[state] - # Excluded members do not confer elderly or disabled status when - # comparing income with eligibility standards (7 CFR 273.11(c)/(d)). + # Excluded members are not household members under the 7 CFR 271.2 + # definition, so they do not confer elderly or disabled status. return where( requires_all, spm_unit("has_all_usda_elderly_disabled", period), diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_asset_test.py b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_asset_test.py index 8911b6f0b60..5aec563cf15 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_asset_test.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_asset_test.py @@ -9,9 +9,24 @@ class meets_snap_asset_test(Variable): "Whether the SPM unit's financial resources are within SNAP's allowable limit" ) definition_period = YEAR + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#g", + "https://www.law.cornell.edu/cfr/text/7/273.11#c", + ) def formula(spm_unit, period, parameters): - has_elderly_or_disabled = spm_unit("has_usda_elderly_disabled", period) + # Ineligible members' resources continue to count in their + # entirety (7 CFR 273.11(c)(1)(i), (c)(2)(i), (c)(3)), but the + # members themselves are excluded when comparing resources with + # the resource limits (273.11(c)(1)(ii)(D), (c)(2)(iv)(D)), so an + # excluded elderly or disabled member does not confer the higher + # limit. Nonhousehold members' (ineligible students') resources + # are not considered available to the household (273.11(d)), but + # assets are modeled at the unit level, so we do not carve out + # their share at the moment. + has_elderly_or_disabled = spm_unit( + "has_snap_elderly_disabled_member", period.first_month + ) asset_test = parameters(period).gov.usda.snap.asset_test assets = spm_unit("snap_assets", period) asset_limit = where( diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py index 9f113306d5d..c42cbafd470 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/meets_snap_gross_income_test.py @@ -18,7 +18,8 @@ def formula(spm_unit, period, parameters): # ineligible aliens (7 CFR 273.11(c)(3)(i)). ratio = spm_unit("snap_gross_test_income_fpg_ratio", period) # Households with an elderly or disabled unit member are exempt - # from the gross income test; excluded members do not confer this - # status (7 CFR 273.11(c)/(d)). + # from the gross income test; excluded members are not household + # members under the 7 CFR 271.2 definition, so they do not confer + # this status. has_elderly_disabled = spm_unit("has_snap_elderly_disabled_member", period) return has_elderly_disabled | (ratio <= p.gross) diff --git a/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py b/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py index 109a0f04f33..756fade4882 100644 --- a/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py +++ b/policyengine_us/variables/gov/usda/snap/has_snap_elderly_disabled_member.py @@ -8,14 +8,14 @@ class has_snap_elderly_disabled_member(Variable): label = "Has an elderly or disabled SNAP unit member" documentation = ( "Whether the SNAP unit includes an elderly or disabled member. " - "Ineligible and nonhousehold members are excluded when comparing " - "the household's income with the income eligibility standards, so " - "they do not confer household-wide elderly or disabled status." + "7 CFR 271.2 defines an elderly or disabled member as a member of " + "a household; ineligible and nonhousehold members are excluded " + "from the household, so they do not confer household-wide elderly " + "or disabled status." ) reference = ( "https://www.law.cornell.edu/cfr/text/7/271.2", - "https://www.law.cornell.edu/cfr/text/7/273.11#c", - "https://www.law.cornell.edu/cfr/text/7/273.11#d", + "https://www.law.cornell.edu/cfr/text/7/273.1", ) def formula(spm_unit, period, parameters): diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py index 2d9f3bb46c6..07e8c244346 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/self_employment/snap_self_employment_expense_deduction.py @@ -10,7 +10,10 @@ class snap_self_employment_expense_deduction(Variable): ) label = "SNAP self-employment expense deduction" unit = USD - reference = "https://www.snapscreener.com/blog/self-employment" + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#c_9", + "https://www.law.cornell.edu/cfr/text/7/273.11#b", + ) def formula(spm_unit, period, parameters): self_employment_income = add( diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py index c276932d130..ad9d0e67e97 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py @@ -20,7 +20,7 @@ def formula(spm_unit, period, parameters): person = spm_unit.members elderly = person("is_usda_elderly", period) disabled = person("is_usda_disabled", period) - student = person("is_snap_ineligible_student", period) + student = person("is_snap_ineligible_student", period.this_year) prorated = person("is_snap_prorated_income_member", period) moop = person("snap_allowable_medical_expenses", period) * ~(student | prorated) elderly_disabled_moop = spm_unit.sum(moop * (elderly | disabled)) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py index cb4468a48f8..600d890a8b8 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_prorated_income_member.py @@ -28,7 +28,7 @@ def formula(person, period, parameters): discretion_alien = person("is_snap_state_discretion_ineligible_alien", period) counts_all = np.isin(state, p.count_all_income_states) full_count_alien = discretion_alien & counts_all - student = person("is_snap_ineligible_student", period) + student = person("is_snap_ineligible_student", period.this_year) prorated_alien = immigration_ineligible & ~student & ~full_count_alien # ABAWD time-limit ineligible members are prorated under # 273.11(c)(2); members sanctioned for noncompliance with the diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py index ee4118b8d63..771c95e949b 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/is_snap_state_discretion_ineligible_alien.py @@ -15,5 +15,5 @@ def formula(person, period, parameters): state_discretion_status = np.isin(status, p.pre_prwora_statuses) # Ineligible students are treated as nonhousehold members under # 273.11(d) rather than under the ineligible-alien rules. - student = person("is_snap_ineligible_student", period) + student = person("is_snap_ineligible_student", period.this_year) return ~student & immigration_ineligible & state_discretion_status diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py index 07e17ea93c1..fb56c57fe61 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_expense_counted_share.py @@ -23,7 +23,7 @@ def formula(spm_unit, period, parameters): # under 273.11(c)(1) continue to count in their entirety. person = spm_unit.members share = person("snap_income_counted_share", period) - size = spm_unit("spm_unit_size", period) + size = spm_unit("spm_unit_size", period.this_year) # Subtract the uncounted portion of each member's per capita # payment rather than summing counted shares directly, so a # spm_unit_size exceeding the modeled member count leaves the diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py index 7b876ffcd79..458002c5cf0 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_income_counted_share.py @@ -13,7 +13,7 @@ class snap_income_counted_share(Variable): ) def formula(person, period, parameters): - student = person("is_snap_ineligible_student", period) + student = person("is_snap_ineligible_student", period.this_year) prorated = person("is_snap_prorated_income_member", period) fraction = person.spm_unit("snap_prorated_income_fraction", period) return select([student, prorated], [0, fraction], default=1) diff --git a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py index 795dae1b218..857d3d864b7 100644 --- a/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py +++ b/policyengine_us/variables/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.py @@ -17,8 +17,8 @@ def formula(spm_unit, period, parameters): # the shares of all ineligible members — including full-count # aliens and members sanctioned under 273.11(c)(1), whose own # income counts in full — are excluded from the counted fraction. - size = spm_unit("spm_unit_size", period) - students = add(spm_unit, period, ["is_snap_ineligible_student"]) + size = spm_unit("spm_unit_size", period.this_year) + students = add(spm_unit, period.this_year, ["is_snap_ineligible_student"]) household_members = size - students eligible_members = spm_unit("snap_unit_size", period) return where( diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py index d6f8718dd00..51c7a477112 100644 --- a/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py +++ b/policyengine_us/variables/gov/usda/snap/income/snap_gross_self_employment_income_person.py @@ -8,12 +8,12 @@ class snap_gross_self_employment_income_person(Variable): label = "SNAP gross self-employment income per person" reference = ( "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", - "https://www.law.cornell.edu/cfr/text/7/273.11#a_2_iii", + "https://www.law.cornell.edu/cfr/text/7/273.11#a_2_ii", ) unit = USD def formula(person, period, parameters): - # 7 CFR 273.11(a)(2)(iii) offsets losses against other household + # 7 CFR 273.11(a)(2)(ii) offsets losses against other household # income only for farming operations. The self-employment income # modeled here is non-farm, so each enterprise's loss is floored # at zero rather than netted against other members' income. diff --git a/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py b/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py index 0509dd9b87c..e0d4726078f 100644 --- a/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py +++ b/policyengine_us/variables/gov/usda/snap/is_snap_excluded_member.py @@ -18,7 +18,7 @@ class is_snap_excluded_member(Variable): def formula(person, period, parameters): return ( - person("is_snap_ineligible_student", period) + person("is_snap_ineligible_student", period.this_year) | ~person("is_snap_immigration_status_eligible", period) | ~person("meets_snap_work_requirements_person", period) ) diff --git a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py index 34bbfb40715..1b7478fb39a 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py +++ b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py @@ -12,6 +12,6 @@ class snap_unit_size(Variable): ) def formula(spm_unit, period, parameters): - unit_size = spm_unit("spm_unit_size", period) + unit_size = spm_unit("spm_unit_size", period.this_year) excluded_count = add(spm_unit, period, ["is_snap_excluded_member"]) return max_(unit_size - excluded_count, 0) From b031fe89e5f558b001e4d285027d51d732f2952e Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 14 Jul 2026 14:34:17 -0400 Subject: [PATCH 8/9] Address review: boundary tests, partner contract updates, and medical citation - Add end-to-end tests using real excluded members: shelter deduction cap with an excluded elderly member, asset limits on both sides of the elderly/disabled threshold, post-delay California refugee proration (2027) and pre-OBBBA baseline (2025), a household combining a student, prorated alien, and sanctioned member, a multi-member all-ineligible unit, and the student-only zero-denominator branch. - Add a direct unit test for snap_gross_test_income_fpg_ratio; rename dead has_usda_elderly_disabled inputs in the net income and expected contribution tests. - Reground the medical expense exclusion on 7 CFR 273.9(d)(3) and 271.2 and read elderly/disabled status with period.this_year. - Update partner contract expectations for 273.11(c) proration: TX refugee/undocumented cases rise to 4,147.92, CA CFAP and CalWORKs cases to 2,162.22 and 3,002.22, and the amplifi household flips from ineligible to a 2,114.10 (2025) / 2,360.52 (2026) benefit. Add count-all (MA), hybrid gross-test (NC), and student-income edge cases. Co-Authored-By: Claude Fable 5 --- .../eligibility/meets_snap_asset_test.yaml | 45 ++++++ ...snap_excess_medical_expense_deduction.yaml | 6 +- .../snap_gross_test_income_fpg_ratio.yaml | 9 ++ .../ineligible_members/integration.yaml | 148 ++++++++++++++++++ .../snap_prorated_income_fraction.yaml | 18 +++ .../usda/snap/snap_expected_contribution.yaml | 4 +- .../gov/usda/snap/snap_net_income.yaml | 4 +- .../baseline/partners/amplifi/2025.yaml | 4 +- .../baseline/partners/amplifi/2026.yaml | 4 +- .../edge_cases/federal/nutrition/snap/ca.yaml | 24 ++- .../federal/nutrition/snap/federal.yaml | 63 +++++++- .../edge_cases/federal/nutrition/snap/ma.yaml | 66 ++++++++ .../edge_cases/federal/nutrition/snap/nc.yaml | 135 ++++++++++++++++ .../snap_excess_medical_expense_deduction.py | 11 +- 14 files changed, 509 insertions(+), 32 deletions(-) create mode 100644 policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.yaml diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml index 94c723d3181..461d967429d 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/meets_snap_asset_test.yaml @@ -27,3 +27,48 @@ has_snap_elderly_disabled_member: true output: meets_snap_asset_test: false + +- name: Excluded elderly member does not confer the elderly/disabled resource limit. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 70 + immigration_status: UNDOCUMENTED + spm_units: + spm_unit: + members: [person1, person2] + snap_assets: 4_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # $4,000 exceeds the $3,000 standard limit, and the excluded elderly + # member does not confer the $4,500 elderly/disabled limit + # (7 CFR 273.11(c)(2)(iv)(D)). + meets_snap_asset_test: false + +- name: Eligible elderly member confers the elderly/disabled resource limit. + period: 2026 + input: + people: + person1: + age: 35 + person2: + age: 70 + immigration_status: CITIZEN + spm_units: + spm_unit: + members: [person1, person2] + snap_assets: 4_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + # $4,000 is between the $3,000 standard and $4,500 elderly/disabled + # limits, and the eligible elderly member confers the higher limit. + meets_snap_asset_test: true diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml index 0196d42877f..b1f8b36b652 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.yaml @@ -118,9 +118,9 @@ members: [person1, person2] state_code: TX output: - # The prorated member's $500 monthly expenses do not count - # (7 CFR 273.11(c)(2)(iii)), and with zero excess the standard - # medical deduction is not claimable either. + # The prorated member is not a household member (7 CFR 273.9(d)(3), + # 271.2), so their $500 monthly expenses do not count, and with zero + # excess the standard medical deduction is not claimable either. snap_excess_medical_expense_deduction: 0 - name: Eligible elderly member's medical expenses remain deductible. diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.yaml new file mode 100644 index 00000000000..53519afbf91 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/gross/snap_gross_test_income_fpg_ratio.yaml @@ -0,0 +1,9 @@ +- name: Gross test income FPG ratio + period: 2020 + input: + snap_gross_test_income: 15_000 + snap_fpg: + 2020-01: 10_000 / 12 + output: + snap_gross_test_income_fpg_ratio: + 2020-01: 1.5 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml index 25b8330925a..935c2fb577e 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/integration.yaml @@ -479,3 +479,151 @@ # The $200 monthly TANF grant is paid to the unit as a whole and # cannot be attributed to the prorated member, so it counts in full. snap_unearned_income: 200 + +- name: Case 18, excluded elderly member does not waive the shelter deduction cap. + period: 2026-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + person2: + age: 70 + immigration_status: UNDOCUMENTED + spm_units: + spm_unit: + members: [person1, person2] + housing_cost: 24_000 + households: + household: + members: [person1, person2] + state_code: TX + output: + has_snap_elderly_disabled_member: false + # Counted shelter: 0.75 x $2,000 = $1,500; uncapped deduction + # $1,500 - 0.5 x $1,391 = $804.50 exceeds the $744 cap, which applies + # because the excluded elderly member does not waive it. + snap_excess_shelter_expense_deduction: 744 + +- name: Case 19, California refugee is prorated once the OBBBA delay lapses. + period: 2027-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + immigration_status: REFUGEE + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: CA + output: + # California's ACL 25-92 delay ends 2026-04-01, so by 2027 the + # refugee is an excluded member with prorated income, matching the + # treatment in non-delaying states. + snap_unit_size: 2 + snap_income_counted_share: [0.6667, 1, 1] + snap_earned_income: 1_333.33 + +- name: Case 20, student, prorated alien, and sanctioned member in one household. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 20 + is_snap_ineligible_student: true + person2: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person3: + age: 30 + weekly_hours_worked_before_lsr: 0 + person4: + age: 33 + person5: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3, person4, person5] + households: + household: + members: [person1, person2, person3, person4, person5] + state_code: TX + output: + # Household members exclude the student: 4; unit members exclude the + # student, the prorated alien, and the sanctioned member: 2. The + # proration fraction is 2/4 and the sanctioned member's share is 1. + snap_unit_size: 2 + snap_prorated_income_fraction: 0.5 + snap_income_counted_share: [0, 0.5, 1, 1, 1] + # Expense share: 1 - (1 + 0.5) / 5 = 0.7. + snap_expense_counted_share: 0.7 + snap_earned_income: 1_000 + +- name: Case 21, all-ineligible multi-member household receives nothing. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + immigration_status: UNDOCUMENTED + person2: + age: 33 + immigration_status: UNDOCUMENTED + spm_units: + spm_unit: + members: [person1, person2] + households: + household: + members: [person1, person2] + state_code: TX + output: + snap_unit_size: 0 + snap_prorated_income_fraction: 0 + is_snap_eligible: false + snap: 0 + +- name: Case 22, refugee earner counts fully before OBBBA. + period: 2025-01 + absolute_error_margin: 0.1 + input: + people: + person1: + age: 35 + employment_income: 24_000 + employment_income_before_lsr: 24_000 + immigration_status: REFUGEE + person2: + age: 33 + person3: + age: 8 + spm_units: + spm_unit: + members: [person1, person2, person3] + households: + household: + members: [person1, person2, person3] + state_code: TX + output: + # Refugees remain SNAP-eligible until OBBBA takes effect in November + # 2025, so the earner is a unit member with fully counted income. + snap_unit_size: 3 + snap_income_counted_share: [1, 1, 1] + snap_earned_income: 2_000 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml index 238d03a3dff..bfef3580d5c 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/ineligible_members/snap_prorated_income_fraction.yaml @@ -60,3 +60,21 @@ state_code: TX output: snap_prorated_income_fraction: 1 + +- name: Case 4, student-only household has no members among whom to divide income. + period: 2026-01 + absolute_error_margin: 0.001 + input: + people: + person1: + age: 20 + is_snap_ineligible_student: true + spm_units: + spm_unit: + members: [person1] + households: + household: + members: [person1] + state_code: TX + output: + snap_prorated_income_fraction: 0 diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_expected_contribution.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_expected_contribution.yaml index 7c1d89d7215..458208461d5 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_expected_contribution.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_expected_contribution.yaml @@ -19,7 +19,7 @@ snap_net_income_pre_shelter: (2000 - (2000 * 0.2) - 181) * 12 snap_excess_shelter_expense_deduction: 586 * 12 housing_cost: 1700 * 12 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false members: [person_1, person_2, person_3, person_4] snap_net_income: 833 * 12 households: @@ -50,7 +50,7 @@ snap_net_income_pre_shelter: (2000 - (2000 * 0.2) - 181) * 12 snap_excess_shelter_expense_deduction: (1700 - (1419 * 0.5)) * 12 housing_cost: 1700 * 12 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true members: [person_1, person_2, person_3, person_4] snap_net_income: 428.5 * 12 households: diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_net_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_net_income.yaml index 2686209123c..6ef1596499d 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_net_income.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/snap_net_income.yaml @@ -18,7 +18,7 @@ snap_net_income_pre_shelter: (2000 - (2000 * 0.2) - 181) * 12 snap_excess_shelter_expense_deduction: 586 * 12 housing_cost: 1700 * 12 - has_usda_elderly_disabled: false + has_snap_elderly_disabled_member: false members: [person_1, person_2, person_3, person_4] households: household: @@ -45,7 +45,7 @@ snap_earned_income_deduction: (2000 * 0.2) * 12 snap_standard_deduction: 181 * 12 housing_cost: 1700 * 12 - has_usda_elderly_disabled: true + has_snap_elderly_disabled_member: true snap_net_income_pre_shelter: (2000 - (2000 * 0.2) - 181) * 12 snap_excess_shelter_expense_deduction: (1700 - (1419 * 0.5)) * 12 members: [person_1, person_2, person_3, person_4] diff --git a/policyengine_us/tests/policy/baseline/partners/amplifi/2025.yaml b/policyengine_us/tests/policy/baseline/partners/amplifi/2025.yaml index 39752d71782..b2f0b861f06 100644 --- a/policyengine_us/tests/policy/baseline/partners/amplifi/2025.yaml +++ b/policyengine_us/tests/policy/baseline/partners/amplifi/2025.yaml @@ -110,8 +110,8 @@ spm_unit: ca_tanf: 0.0 ca_tanf_eligible: false - snap: 0.0 - is_snap_eligible: false + snap: 2114.10 + is_snap_eligible: true lifeline: 228.0 is_lifeline_eligible: true la_general_relief: 0.0 diff --git a/policyengine_us/tests/policy/baseline/partners/amplifi/2026.yaml b/policyengine_us/tests/policy/baseline/partners/amplifi/2026.yaml index ea7545bfd23..04b5b298e12 100644 --- a/policyengine_us/tests/policy/baseline/partners/amplifi/2026.yaml +++ b/policyengine_us/tests/policy/baseline/partners/amplifi/2026.yaml @@ -110,8 +110,8 @@ spm_unit: ca_tanf: 0.0 ca_tanf_eligible: false - snap: 0.0 - is_snap_eligible: false + snap: 2360.52 + is_snap_eligible: true lifeline: 228.0 is_lifeline_eligible: true la_general_relief: 0.0 diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ca.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ca.yaml index 79cf34e4c04..ae4d8111604 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ca.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ca.yaml @@ -275,14 +275,12 @@ spm_units: spm_unit: # CA delays the OBBB refugee exclusion to 2026-04-01 (ACL 25-92), and - # CalWORKs ($7,700/year here) counts as unearned income ($641.67/mo): - # Jan-Mar (refugee still in unit, size 3): net = 1,333.33 (earned after - # 20% deduction) + 641.67 - 209 = 1,766.00 -> EC = floor(1,766) x 0.3 = - # 529.50 -> 785 - 529.50 = 255.50/month. Apr-Sep (head excluded, size 2): 546 - 529.50 - # = 16.50 < minimum allotment 23.84 -> 23.84/month. Oct-Dec: 558.24 - - # 528.30 = 29.94/month. Annual = 3 x 255.50 + 6 x 23.84 + 3 x 29.94 = - # 999.36. - snap: 999.36 + # CalWORKs counts as unearned income at the unit level (in full). + # Jan-Mar the refugee is still in the unit (size 3) with earnings + # fully counted; from April the head is excluded (size 2) and their + # earnings count at the 2/3 prorated share under 7 CFR + # 273.11(c)(2)-(3), raising the benefit relative to full counting. + snap: 2_162.22 is_snap_eligible: true - name: snap_ca_head_immigration_undocumented_calworks_interaction (ca) period: 2026 @@ -345,10 +343,8 @@ spm_units: spm_unit: # Undocumented head is excluded all year (not a CFAP status): unit size - # 2, and the CalWORKs grant is smaller ($5,133.33/year -> $427.78/mo - # unearned) because the head is also excluded from the CalWORKs AU: - # net = 1,333.33 + 427.78 - 209 = 1,552.11 -> EC 465.60 -> 546 - 465.60 - # = 80.40/month Jan-Sep; Oct-Dec: 558.24 - 464.10 = 94.14. - # Annual = 9 x 80.40 + 3 x 94.14 = 1,006.02. - snap: 1_006.02 + # 2, the CalWORKs grant excludes the head from the AU but counts in + # full as unit-level unearned income, and the head's earnings count + # at the 2/3 prorated share under 7 CFR 273.11(c)(2)-(3). + snap: 3_002.22 is_snap_eligible: true diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml index ba3456dc010..69278863e23 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml @@ -807,7 +807,7 @@ # allotment 546) but their earnings still count in full (they meet work # requirements, so no income proration). Same math as the childless- # couple 20k case: annual = 9 x 208.80 + 3 x 222.54 = 2,546.82. - snap: 2_546.82 + snap: 4_147.92 is_snap_eligible: true - name: snap_head_immigration_undocumented_excluded (federal) period: 2026 @@ -872,7 +872,7 @@ # Undocumented head is excluded from the unit (size 2) with earnings # fully counted; identical arithmetic to the refugee case: # annual = 9 x 208.80 + 3 x 222.54 = 2,546.82. - snap: 2_546.82 + snap: 4_147.92 is_snap_eligible: true - name: snap_abawd_zero_hours_fails_work_requirements (federal) period: 2026 @@ -1425,3 +1425,62 @@ # 802.60 - 201.00 = 601.60. Annual = 9 x 579.20 + 3 x 601.60 = 7,017.60. snap: 7_017.59 is_snap_eligible: true + +- name: snap_ineligible_student_income_excluded_mixed_household (federal) + period: 2026 + absolute_error_margin: 0.1 + input: + people: + head: + age: 40 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 20_000 + student: + age: 20 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: CITIZEN + is_full_time_college_student: true + weekly_hours_worked_before_lsr: 0 + employment_income_before_lsr: 10_000 + households: + household: + members: &id903 + - head + - student + state_code: TX + zip_code: '75201' + county_str: DALLAS_COUNTY_TX + spm_units: + spm_unit: + members: *id903 + snap_assets: 500 + tax_units: + tax_unit: + members: *id903 + tax_unit_is_joint: false + families: + family: + members: *id903 + marital_units: + mu_head: + members: + - head + mu_student: + members: + - student + output: + spm_units: + spm_unit: + # The ineligible student is a nonhousehold member (7 CFR 273.11(d)): + # their $10,000 earnings are excluded entirely and the unit size is 1; + # the 130%-FPG gross test passes only because of the exclusion, and + # the unit receives the 1-2 person minimum allotment. + snap: 287.68 + is_snap_eligible: true diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ma.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ma.yaml index 9a4f18af776..942dfad08dc 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ma.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/ma.yaml @@ -141,3 +141,69 @@ # ineligible all year. snap: 0.0 is_snap_eligible: false + +# MA is one of the nine 7 CFR 273.11(c)(3)(i) count-all states: the +# undocumented head is excluded from the unit size but their income counts +# in full rather than at the prorated share used in the TX baseline. +- name: snap_ma_head_immigration_undocumented_count_all_state (ma) + period: 2026 + absolute_error_margin: 0.1 + input: + people: + head: + age: 35 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: UNDOCUMENTED + employment_income_before_lsr: 20_000 + spouse: + age: 33 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + child1: + age: 5 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + households: + household: + members: &id801 + - head + - spouse + - child1 + state_code: MA + zip_code: '02118' + county_str: SUFFOLK_COUNTY_MA + spm_units: + spm_unit: + members: *id801 + snap_assets: 500 + tax_units: + tax_unit: + members: *id801 + tax_unit_is_joint: true + families: + family: + members: *id801 + marital_units: + mu_couple: + members: + - head + - spouse + mu_child1: + members: + - child1 + output: + spm_units: + spm_unit: + snap: 3_815.82 + is_snap_eligible: true diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/nc.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/nc.yaml index 631dc7c7570..0156d3b238a 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/nc.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/nc.yaml @@ -142,3 +142,138 @@ # ineligible all year. snap: 0.0 is_snap_eligible: false + +# NC is one of the four 7 CFR 273.11(c)(3)(i) hybrid states: an undocumented +# member's income counts in full under the gross income test (and the BBCE +# gross screen) while counting at the prorated share on the net side. +- name: snap_nc_head_immigration_undocumented_gross_test_full_count_denies (nc) + period: 2026 + absolute_error_margin: 0.1 + input: + people: + head: + age: 35 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: UNDOCUMENTED + employment_income_before_lsr: 48_000 + spouse: + age: 33 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + child1: + age: 5 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + households: + household: + members: &id901 + - head + - spouse + - child1 + state_code: NC + zip_code: '27601' + county_str: WAKE_COUNTY_NC + spm_units: + spm_unit: + members: *id901 + snap_assets: 500 + tax_units: + tax_unit: + members: *id901 + tax_unit_is_joint: true + families: + family: + members: *id901 + marital_units: + mu_couple: + members: + - head + - spouse + mu_child1: + members: + - child1 + output: + spm_units: + spm_unit: + # The full $4,000/month exceeds both the 130% gross limit and NC's + # 200% BBCE screen for the 2-person unit, though the prorated + # $2,666.67 would have passed the BBCE screen. + snap: 0 + is_snap_eligible: false + +- name: snap_nc_head_immigration_undocumented_prorated_net_benefit (nc) + period: 2026 + absolute_error_margin: 0.1 + input: + people: + head: + age: 35 + is_tax_unit_head: true + is_tax_unit_spouse: false + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: UNDOCUMENTED + employment_income_before_lsr: 20_000 + spouse: + age: 33 + is_tax_unit_head: false + is_tax_unit_spouse: true + is_tax_unit_dependent: false + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + child1: + age: 5 + is_tax_unit_head: false + is_tax_unit_spouse: false + is_tax_unit_dependent: true + is_disabled: false + immigration_status_str: CITIZEN + employment_income_before_lsr: 0 + households: + household: + members: &id902 + - head + - spouse + - child1 + state_code: NC + zip_code: '27601' + county_str: WAKE_COUNTY_NC + spm_units: + spm_unit: + members: *id902 + snap_assets: 500 + tax_units: + tax_unit: + members: *id902 + tax_unit_is_joint: true + families: + family: + members: *id902 + marital_units: + mu_couple: + members: + - head + - spouse + mu_child1: + members: + - child1 + output: + spm_units: + spm_unit: + # The full $1,666.67/month passes the gross screens and the benefit + # is computed from the 2/3-prorated income, matching the TX + # prorate-state baseline. + snap: 4_147.92 + is_snap_eligible: true diff --git a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py index ad9d0e67e97..ecad0f4e226 100644 --- a/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py +++ b/policyengine_us/variables/gov/usda/snap/income/deductions/snap_excess_medical_expense_deduction.py @@ -14,12 +14,13 @@ class snap_excess_medical_expense_deduction(Variable): ] def formula(spm_unit, period, parameters): - # Deduction applies to medical expenses incurred by elderly or disabled - # household members only; expenses of nonhousehold members and - # prorated ineligible members do not count. + # 7 CFR 273.9(d)(3) allows the deduction only for elderly or + # disabled household members (as defined in 271.2); nonhousehold + # members and prorated ineligible members are not household + # members, so their expenses do not count. person = spm_unit.members - elderly = person("is_usda_elderly", period) - disabled = person("is_usda_disabled", period) + elderly = person("is_usda_elderly", period.this_year) + disabled = person("is_usda_disabled", period.this_year) student = person("is_snap_ineligible_student", period.this_year) prorated = person("is_snap_prorated_income_member", period) moop = person("snap_allowable_medical_expenses", period) * ~(student | prorated) From 6fd1470b79423f130b7fafb198bc13a34d146089 Mon Sep 17 00:00:00 2001 From: Ziming Date: Tue, 14 Jul 2026 14:53:25 -0400 Subject: [PATCH 9/9] Update stale proration walkthroughs in partner contract comments The TX refugee and undocumented cases' calculation comments still described full income counting with the pre-proration 2,546.82 math; rewrite them to walk through the 2/3 prorated share arithmetic that produces the committed 4,147.92 values. Co-Authored-By: Claude Fable 5 --- .../edge_cases/federal/nutrition/snap/federal.yaml | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml index 69278863e23..04ab43fe95b 100644 --- a/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml +++ b/policyengine_us/tests/policy/baseline/partners/analytics_coverage/edge_cases/federal/nutrition/snap/federal.yaml @@ -804,9 +804,11 @@ spm_unit: # OBBB (PL 119-21, eff. 2025-07-04) removed refugees from SNAP-eligible # immigration statuses: the refugee head is excluded (unit size 2, max - # allotment 546) but their earnings still count in full (they meet work - # requirements, so no income proration). Same math as the childless- - # couple 20k case: annual = 9 x 208.80 + 3 x 222.54 = 2,546.82. + # allotment 546) and their earnings count at the 2/3 prorated share + # under 7 CFR 273.11(c)(2)-(3) (TX prorates): counted earnings + # 1,111.11/mo, net income 679.89 -> EC = floor(679.89) x 0.3 = 203.70 + # -> 546 - 203.70 = 342.30/month Jan-Sep; Oct-Dec (FY2027): 558.24 - + # 202.50 = 355.74. Annual = 9 x 342.30 + 3 x 355.74 = 4,147.92. snap: 4_147.92 is_snap_eligible: true - name: snap_head_immigration_undocumented_excluded (federal) @@ -870,8 +872,8 @@ spm_units: spm_unit: # Undocumented head is excluded from the unit (size 2) with earnings - # fully counted; identical arithmetic to the refugee case: - # annual = 9 x 208.80 + 3 x 222.54 = 2,546.82. + # counted at the 2/3 prorated share; identical arithmetic to the + # refugee case: annual = 9 x 342.30 + 3 x 355.74 = 4,147.92. snap: 4_147.92 is_snap_eligible: true - name: snap_abawd_zero_hours_fails_work_requirements (federal)