From badc6b61a33a4d50fe1c9461631d38c01c3b923d Mon Sep 17 00:00:00 2001 From: Ziming Date: Sat, 4 Jul 2026 19:28:23 -0400 Subject: [PATCH 1/4] 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/4] 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/4] 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/4] 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"