diff --git a/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md b/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md new file mode 100644 index 00000000000..692e952fca3 --- /dev/null +++ b/changelog.d/fix-8139-snap-work-requirements-per-person.fixed.md @@ -0,0 +1 @@ +Apply SNAP work-requirement disqualifications at the individual level per 7 CFR 273.7(f)(1) and 273.24(b), instead of denying the entire SPM unit when any single member fails. diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml new file mode 100644 index 00000000000..18d9bc6a6fd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/is_snap_disqualified_prorated.yaml @@ -0,0 +1,25 @@ +- name: ABAWD time-limit failure receives prorated treatment. + period: 2024-01 + input: + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + is_snap_immigration_status_eligible: true + output: + is_snap_disqualified_prorated: true + +- name: Immigration-ineligible member receives prorated treatment. + period: 2024-01 + input: + meets_snap_abawd_work_requirements: true + is_snap_immigration_status_eligible: false + output: + is_snap_disqualified_prorated: true + +- name: Ineligible student does not receive prorated treatment. + period: 2024-01 + input: + is_snap_ineligible_student: true + meets_snap_abawd_work_requirements: true + is_snap_immigration_status_eligible: true + output: + is_snap_disqualified_prorated: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml new file mode 100644 index 00000000000..6b78af03cdd --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.yaml @@ -0,0 +1,15 @@ +- name: General work requirement failure is an individual disqualification. + period: 2024-01 + input: + meets_snap_general_work_requirements: false + meets_snap_abawd_work_requirements: true + output: + is_snap_work_requirements_disqualified: true + +- name: ABAWD time-limit failure is not a general work requirement disqualification. + period: 2024-01 + input: + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + output: + is_snap_work_requirements_disqualified: false diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml index f1664d91c34..5ab02d3251a 100644 --- a/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.yaml @@ -40,7 +40,6 @@ - name: Case 5, CA pre-HR1 age 55 exempt flows through SPM. period: 2026-01 - absolute_error_margin: 0.9 input: people: person1: @@ -63,7 +62,6 @@ - name: Case 6, non-CA post-HR1 age 55 not exempt. period: 2026-01 - absolute_error_margin: 0.9 input: people: person1: @@ -83,3 +81,74 @@ state_code: TX output: meets_snap_work_requirements: false + +- name: Case 7, two adults, one passes one fails, unit eligible (per-person disqualification). + # Per 7 CFR 273.7(f)(1), the non-compliant adult is individually + # disqualified and excluded; the remaining member continues. + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 40 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 0 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: true + +- name: Case 8, two adults, both fail, unit ineligible. + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 0 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 0 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: false + +- name: Case 9, two adults both pass, unit eligible (regression guard). + period: 2024-01 + input: + people: + adult_a: + age: 35 + weekly_hours_worked_before_lsr: 40 + adult_b: + age: 30 + weekly_hours_worked_before_lsr: 35 + spm_units: + spm_unit: + members: [adult_a, adult_b] + tax_units: + tax_unit: + members: [adult_a, adult_b] + households: + household: + members: [adult_a, adult_b] + state_code: CA + output: + meets_snap_work_requirements: true diff --git a/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml new file mode 100644 index 00000000000..8c57ca823a9 --- /dev/null +++ b/policyengine_us/tests/policy/baseline/gov/usda/snap/income/snap_prorated_income.yaml @@ -0,0 +1,274 @@ +# Tests for SNAP income and expense proration under 7 CFR 273.11(c)(2) / (c)(3) +# for members disqualified under the "prorated" treatment (ABAWDs who exceed +# the time limit, immigration-ineligible members). For each prorated member, income +# is divided evenly among all household members and only the portion that +# would go to eligible members is counted. + +- name: No prorated-disqualified members — no proration. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + b: + age: 38 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 12_000 + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_earned_income_reduction: 0 + snap_prorated_unearned_income_reduction: 0 + snap_earned_income: 3_000 # $36k annual / 12 + +- name: 4-person household with 1 immigration-ineligible member — 1/4 of their earned income excluded. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + b: + age: 38 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + c: + age: 20 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + d: + age: 19 + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b, c, d] + tax_units: + tax_unit: + members: [a, b, c, d] + households: + household: + members: [a, b, c, d] + state_code: CA + output: + snap_unit_size: 3 + snap_prorated_earned_income_reduction: 500 # $2k/mo × 1/4 + snap_earned_income: 7_500 # $8k raw − $500 + +- name: 2-person household with 1 immigration-ineligible member — 1/2 of their income excluded. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 12_000 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + employment_income: 12_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 + snap_prorated_earned_income_reduction: 500 # b's $1k/mo × 1/2 + snap_earned_income: 1_500 # $2k raw − $500 + +- name: ABAWD time-limit failure uses prorated treatment. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + employment_income: 12_000 + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: true + b: + age: 30 + is_snap_immigration_status_eligible: true + employment_income: 12_000 + meets_snap_general_work_requirements: true + meets_snap_abawd_work_requirements: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 + snap_prorated_earned_income_reduction: 500 + snap_earned_income: 1_500 + +- name: Self-employment expense deduction attributed before proration. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + self_employment_income_before_lsr: 12_000 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + self_employment_income_before_lsr: 12_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_earned_income_reduction: 300 + snap_earned_income: 900 + +- name: Unearned income prorated for immigration-ineligible member. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + unemployment_compensation: 12_000 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + unemployment_compensation: 12_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_unearned_income_reduction: 500 # b's $1k × 1/2 + snap_unearned_income: 1_500 # $2k raw − $500 + +- name: Previously omitted person-level unearned income is prorated. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + dividend_income: 12_000 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + dividend_income: 12_000 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_prorated_unearned_income_reduction: 500 + snap_unearned_income: 1_500 + +- name: Child support expense of immigration-ineligible member prorated. + period: 2024-01 + input: + people: + a: + age: 40 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + child_support_expense: 2_400 + b: + age: 19 + weekly_hours_worked_before_lsr: 40 + child_support_expense: 2_400 + is_snap_immigration_status_eligible: false + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + # a: $200/mo × share 1.0 = $200; b: $200/mo × share 0.5 = $100; total $300 + snap_child_support_expense: 300 + +- name: Work-requirement-disqualified member's income counts in full (entirety treatment under (c)(1)). + period: 2024-01 + input: + people: + a: + age: 35 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 40 + employment_income: 24_000 + b: + age: 30 + is_snap_immigration_status_eligible: true + weekly_hours_worked_before_lsr: 0 + employment_income: 24_000 + spm_units: + spm_unit: + members: [a, b] + tax_units: + tax_unit: + members: [a, b] + households: + household: + members: [a, b] + state_code: CA + output: + snap_unit_size: 1 # b excluded from size + snap_prorated_earned_income_reduction: 0 # (c)(1) entirety, no reduction + snap_earned_income: 4_000 # full $2k/mo each diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py new file mode 100644 index 00000000000..3895c705bc9 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/eligibility/is_snap_disqualified_prorated.py @@ -0,0 +1,41 @@ +from policyengine_us.model_api import * + + +class is_snap_disqualified_prorated(Variable): + value_type = bool + entity = Person + label = "SNAP disqualified with prorated treatment" + documentation = ( + "Whether this person is excluded from the SNAP unit under the " + "'prorated' treatment of 7 CFR 273.11(c)(2) or (c)(3): the " + "individual's income is divided evenly among all household " + "members and only the share that would have gone to eligible " + "members is counted, while resources continue to count in full. " + "Applies to members who fail the ABAWD time limit and members " + "who are not immigration-status eligible." + ) + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(person, period, parameters): + # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14). + hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) + p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold + p_pre = parameters( + "2025-06-01" + ).gov.usda.snap.work_requirements.abawd.age_threshold + dep_threshold = where(hr1_in_effect, p.dependent, p_pre.dependent) + age = person("monthly_age", period) + is_dependent = person("is_tax_unit_dependent", period) + is_child = age < dep_threshold + no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 + abawd_ineligible = ( + no_dependent_child + & person("meets_snap_general_work_requirements", period) + & ~person("meets_snap_abawd_work_requirements", period) + ) + immigration_ineligible = ~person("is_snap_immigration_status_eligible", period) + return abawd_ineligible | immigration_ineligible diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py new file mode 100644 index 00000000000..0a4cdb4ae76 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/is_snap_work_requirements_disqualified.py @@ -0,0 +1,18 @@ +from policyengine_us.model_api import * + + +class is_snap_work_requirements_disqualified(Variable): + value_type = bool + entity = Person + label = "SNAP work requirements disqualified" + documentation = ( + "Whether this person is individually disqualified from the SNAP " + "unit for failing the general work requirements. Per 7 CFR " + "273.7(f)(1), the disqualified member is excluded from the SNAP " + "unit; remaining members continue to receive SNAP." + ) + definition_period = MONTH + reference = "https://www.law.cornell.edu/cfr/text/7/273.7#f_1" + + def formula(person, period, parameters): + return ~person("meets_snap_general_work_requirements", period) diff --git a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py index 56aa32bc660..721094d1895 100644 --- a/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py +++ b/policyengine_us/variables/gov/usda/snap/eligibility/work_requirements/meets_snap_work_requirements.py @@ -6,18 +6,32 @@ class meets_snap_work_requirements(Variable): entity = SPMUnit label = "SPM Unit is eligible for SNAP benefits via work requirements" definition_period = MONTH - reference = "https://www.fns.usda.gov/snap/work-requirements" + reference = ( + "https://www.fns.usda.gov/snap/work-requirements", + # 7 CFR 273.7(f)(1) — general work requirement; individual + # disqualification is the default rule. + "https://www.law.cornell.edu/cfr/text/7/273.7#f_1", + # 7 CFR 273.24(b) — ABAWD time limit; always individual. + "https://www.law.cornell.edu/cfr/text/7/273.24#b", + ) def formula(spm_unit, period, parameters): + # Per 7 CFR 273.7(f)(1) and 273.24(b), a non-compliant member is + # individually disqualified and excluded from the SNAP unit. + # Remaining members continue to receive SNAP. The unit remains + # eligible on the work-requirement dimension so long as at least + # one member meets requirements or is exempt. + # + # The narrow 7 CFR 273.7(f)(5) state option — elected by 8 + # jurisdictions (AZ, FL, MA, MN, MS, TX, VA, VI) — permitting + # household-wide disqualification when the head of household + # fails the general work requirement, bounded to at most 180 + # days, is not yet parameterized here. person = spm_unit.members - general_work_requirements = person( - "meets_snap_general_work_requirements", period - ) - abawd_work_requirements = person("meets_snap_abawd_work_requirements", period) - # Dependent child threshold differs: pre-HR1 (18) vs post-HR1 (14) + # ABAWD time-limit failures apply only when the household has no + # dependent child under the applicable age threshold. hr1_in_effect = person("is_snap_abawd_hr1_in_effect", period) p = parameters(period).gov.usda.snap.work_requirements.abawd.age_threshold - # Snapshot pre-HR1 values (last month before 2025-07-04 effective date). p_pre = parameters( "2025-06-01" ).gov.usda.snap.work_requirements.abawd.age_threshold @@ -26,9 +40,11 @@ def formula(spm_unit, period, parameters): is_dependent = person("is_tax_unit_dependent", period) is_child = age < dep_threshold no_dependent_child = person.spm_unit.sum(is_dependent & is_child) == 0 - meets_work_requirements_person = where( - no_dependent_child, - abawd_work_requirements & general_work_requirements, - general_work_requirements, + abawd_disqualified = no_dependent_child & ~person( + "meets_snap_abawd_work_requirements", period + ) + disqualified = ( + person("is_snap_work_requirements_disqualified", period) + | abawd_disqualified ) - return spm_unit.sum(~meets_work_requirements_person) == 0 + return spm_unit.any(~disqualified) 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..f6079205664 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 @@ -8,12 +8,13 @@ class snap_child_support_deduction(Variable): unit = USD documentation = "Deduction from SNAP gross income for child support payments" definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_4" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_4", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) - # 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_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/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..39ed764ae28 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 @@ -7,13 +7,17 @@ class snap_child_support_gross_income_deduction(Variable): label = "SNAP child support payment deduction from gross income" unit = USD documentation = ( - "Deduction for child support payments when computing SNAP gross income" + "Deduction for child support payments when computing SNAP gross income, " + "using the prorated SNAP child support expense." ) definition_period = MONTH - reference = "https://www.law.cornell.edu/uscode/text/7/2014#e_4" + reference = ( + "https://www.law.cornell.edu/uscode/text/7/2014#e_4", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + ) def formula(spm_unit, period, parameters): - child_support = add(spm_unit, period, ["child_support_expense"]) + child_support = spm_unit("snap_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/snap_child_support_expense.py b/policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py new file mode 100644 index 00000000000..4f8a7c2b9c7 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_child_support_expense.py @@ -0,0 +1,24 @@ +from policyengine_us.model_api import * + + +class snap_child_support_expense(Variable): + value_type = float + entity = SPMUnit + label = "SNAP child support expense" + unit = USD + documentation = ( + "Child support payments treated as expenses for SNAP purposes, " + "with prorated-disqualified members' contributions reduced per " + "7 CFR 273.11(c)(2). For eligible and entirety-disqualified " + "members (c1), the full expense counts; for prorated-" + "disqualified members (c2/c3), only the eligible members' " + "share of their expense counts." + ) + definition_period = MONTH + reference = ("https://www.law.cornell.edu/cfr/text/7/273.11#c_2",) + + def formula(spm_unit, period, parameters): + person = spm_unit.members + expense = person("child_support_expense", period) + share = person("snap_income_share", period) + return spm_unit.sum(expense * share) 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 231f1e5356c..4aaf9077d4b 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 @@ -6,8 +6,19 @@ class snap_earned_income(Variable): entity = SPMUnit 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" + documentation = ( + "Earned income for calculating the SNAP benefit. Reduced by " + "the non-counted share of prorated-disqualified members' " + "earned income per 7 CFR 273.11(c)(2) or (c)(3)." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_1", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) unit = USD - adds = ["snap_earned_income_person"] + def formula(spm_unit, period, parameters): + raw = add(spm_unit, period, ["snap_earned_income_person"]) + reduction = spm_unit("snap_prorated_earned_income_reduction", period) + return max_(raw - reduction, 0) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py new file mode 100644 index 00000000000..09695be5a88 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_earned_income_reduction.py @@ -0,0 +1,62 @@ +from policyengine_us.model_api import * + + +class snap_prorated_earned_income_reduction(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP prorated earned income reduction" + unit = USD + documentation = ( + "The portion of prorated-disqualified members' earned income " + "that should not be counted per 7 CFR 273.11(c)(2) or (c)(3). " + "The regulation divides the ineligible member's income evenly " + "among all household members; only the portion that would go " + "to eligible members is counted. This variable returns the " + "amount to subtract from the raw SPM-unit earned income " + "aggregation. It covers Person-level employment_income and " + "self-employment income with the SPM-level expense deduction " + "attributed pro rata across self-employed members. Zero when " + "there are no prorated-disqualified members." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(spm_unit, period, parameters): + person = spm_unit.members + is_prorated = person("is_snap_disqualified_prorated", period) + countable = person("snap_countable_earner", period) + spm_size = np.asarray(spm_unit.project(spm_unit("spm_unit_size", period))) + prorated_count = np.asarray(spm_unit.project(spm_unit.sum(is_prorated))) + safe_size = where(spm_size > 0, spm_size, 1) + # Per 273.11(c)(2), divide income evenly across all members; + # count only the eligible share. The excluded share is + # prorated_count / spm_size of the prorated member's income. + exclusion_fraction = prorated_count / safe_size + employment = person("employment_income", period) + self_emp_gross = add( + person, + period, + [ + "self_employment_income_before_lsr", + "sstb_self_employment_income_before_lsr", + ], + ) + spm_self_emp_gross = np.asarray(spm_unit.project(spm_unit.sum(self_emp_gross))) + spm_expense = np.asarray( + spm_unit.project(spm_unit("snap_self_employment_expense_deduction", period)) + ) + # Attribute the SPM-level expense deduction proportionally to + # each member's share of total self-employment gross income. + safe_gross = where(spm_self_emp_gross > 0, spm_self_emp_gross, 1) + attributed_expense = where( + spm_self_emp_gross > 0, + self_emp_gross * spm_expense / safe_gross, + 0, + ) + self_emp_net_person = max_(self_emp_gross - attributed_expense, 0) + person_earned = (employment + self_emp_net_person) * countable + reduction_per_person = person_earned * is_prorated * exclusion_fraction + return spm_unit.sum(reduction_per_person) diff --git a/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py new file mode 100644 index 00000000000..b6bc41e85ce --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/income/snap_prorated_unearned_income_reduction.py @@ -0,0 +1,51 @@ +from policyengine_us.model_api import * + + +PERSON_LEVEL_UNEARNED_SOURCES = [ + "ssi", + "social_security", + "pension_income", + "veterans_benefits", + "unemployment_compensation", + "disability_benefits", + "workers_compensation", + "retirement_distributions", + "child_support_received", + "alimony_income", + "dividend_income", + "interest_income", + "miscellaneous_income", + "rental_income", + "general_assistance", +] + + +class snap_prorated_unearned_income_reduction(Variable): + value_type = float + entity = SPMUnit + definition_period = MONTH + label = "SNAP prorated unearned income reduction" + unit = USD + documentation = ( + "The portion of prorated-disqualified members' Person-level " + "unearned income that should not be counted per 7 CFR " + "273.11(c)(2) or (c)(3). SPM-level sources such as TANF " + "cannot be attributed to specific persons for proration and " + "are excluded." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(spm_unit, period, parameters): + sources = list(parameters(period).gov.usda.snap.income.sources.unearned) + applicable = [s for s in PERSON_LEVEL_UNEARNED_SOURCES if s in sources] + person = spm_unit.members + is_prorated = person("is_snap_disqualified_prorated", period) + spm_size = person.spm_unit("spm_unit_size", period) + prorated_count = person.spm_unit.sum(is_prorated) + safe_size = where(spm_size > 0, spm_size, 1) + exclusion_fraction = prorated_count / safe_size + person_unearned = add(person, period, applicable) if applicable else 0 + return spm_unit.sum(person_unearned * is_prorated * exclusion_fraction) 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..e787e9d6e20 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 @@ -6,8 +6,24 @@ class snap_unearned_income(Variable): entity = SPMUnit 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" + documentation = ( + "Unearned income for calculating the SNAP benefit. Reduced by " + "the non-counted share of prorated-disqualified members' " + "Person-level unearned income per 7 CFR 273.11(c)(2) or " + "(c)(3)." + ) + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.9#b_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) unit = USD - adds = "gov.usda.snap.income.sources.unearned" + def formula(spm_unit, period, parameters): + raw = add( + spm_unit, + period, + list(parameters(period).gov.usda.snap.income.sources.unearned), + ) + reduction = spm_unit("snap_prorated_unearned_income_reduction", period) + return max_(raw - reduction, 0) diff --git a/policyengine_us/variables/gov/usda/snap/snap_income_share.py b/policyengine_us/variables/gov/usda/snap/snap_income_share.py new file mode 100644 index 00000000000..8a13085fe98 --- /dev/null +++ b/policyengine_us/variables/gov/usda/snap/snap_income_share.py @@ -0,0 +1,35 @@ +from policyengine_us.model_api import * + + +class snap_income_share(Variable): + value_type = float + entity = Person + label = "SNAP income share" + documentation = ( + "The fraction of this person's income and prorated deductions " + "that count toward the SNAP unit's calculation, per 7 CFR " + "273.11(c). For prorated-disqualified members (ABAWD " + "time-limit failures or immigration-ineligible members), this " + "equals (total_size - " + "prorated_count) / total_size so that only the eligible " + "members' share of the member's income is counted. For all " + "other persons (eligible members and entirety-disqualified " + "members under c1), the share is 1.0: their income counts in " + "full." + ) + definition_period = MONTH + reference = ( + "https://www.law.cornell.edu/cfr/text/7/273.11#c_2", + "https://www.law.cornell.edu/cfr/text/7/273.11#c_3", + ) + + def formula(person, period, parameters): + is_prorated = person("is_snap_disqualified_prorated", period) + spm_size = person.spm_unit("spm_unit_size", period) + prorated_count = person.spm_unit.sum(is_prorated) + # Safe division: if spm_size is 0 there is no SPM unit to speak + # of; the share is irrelevant because income aggregations will + # also be zero. Guard to avoid NaN. + safe_size = where(spm_size > 0, spm_size, 1) + eligible_fraction = (safe_size - prorated_count) / safe_size + return where(is_prorated, eligible_fraction, 1.0) diff --git a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py index d4e9ab9194f..fd24218b9d2 100644 --- a/policyengine_us/variables/gov/usda/snap/snap_unit_size.py +++ b/policyengine_us/variables/gov/usda/snap/snap_unit_size.py @@ -9,13 +9,18 @@ class snap_unit_size(Variable): reference = ( "https://www.law.cornell.edu/uscode/text/7/2014#b", "https://www.law.cornell.edu/uscode/text/7/2015#f", + # 7 CFR 273.11(c)(1), (c)(2), (c)(3), and (d): ineligible + # members are excluded from SNAP unit size. + "https://www.law.cornell.edu/cfr/text/7/273.11", ) def formula(spm_unit, period, parameters): unit_size = spm_unit("spm_unit_size", period) person = spm_unit.members - ineligible = person("is_snap_ineligible_student", period) | ~person( - "is_snap_immigration_status_eligible", period + ineligible = ( + person("is_snap_ineligible_student", period) + | person("is_snap_disqualified_prorated", period) + | person("is_snap_work_requirements_disqualified", period) ) ineligible_count = spm_unit.sum(ineligible) return max_(unit_size - ineligible_count, 0)