Plan: SNAP Income Deduction Calculation and Benefit Amount
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Seed federal parameter tables (standard deduction, max allotment, SUA/LUA) in canopy-snap |
Done (2026-04-12) — implementation note: plan designed DB tables ( |
2 |
Implement deduction calculation pipeline in canopy-snap (6 deductions in mandatory order) |
Done (2026-04-12) — |
3 |
Implement SUA/LUA election logic |
Done (2026-04-12) — |
4 |
Implement net income test and benefit allotment calculation |
Done (2026-04-12) — implemented in |
5 |
Wire deduction pipeline into snap-eligibility ruleset evaluation |
Done (2026-04-12) — deductions computed inside the JDM ruleset via expressionNode, not as a separate Rust pipeline |
6 |
Add jurisdiction.toml parameters for Georgia-specific SUA/LUA amounts |
Done (2026-04-12) |
7 |
Integration tests |
Done (2026-04-12) — tests in |
MR: !13
Epic: &33, &39
Branch: feature/snap-deduction-calculation
Labels: type::feature, priority::critical, program::snap, service::rules, service::shared-crates, workflow::ready, federal-partner::fns
Context
7 CFR 273.9(d) defines six mandatory deductions that must be subtracted from gross income to compute net income.
7 CFR 273.10(e) mandates the calculation order.
Without this pipeline, the current code has let net_income = gross_income; // TODO: subtract allowable deductions, making every approved determination produce an incorrect benefit amount.
The six mandatory deductions (in required order):
-
Earned income deduction — 20% of gross earned income (7 CFR 273.9(d)(2))
-
Standard deduction — varies by household size, indexed annually by FNS (7 CFR 273.9(d)(1))
-
Dependent care deduction — actual costs when needed for work/training/education, capped at $200/child under 2 or $175/other dependent (7 CFR 273.9(d)(4))
-
Medical expense deduction — elderly (60+) or disabled members only, excess over $35/month (7 CFR 273.9(d)(3))
-
Excess shelter/utility deduction — shelter costs exceeding 50% of income after other deductions; capped at max shelter deduction unless household contains elderly/disabled member (7 CFR 273.9(d)(6))
-
Child support paid deduction — legally obligated child support paid to non-household member (7 CFR 273.9(d)(7))
The Standard Utility Allowance (SUA) is a state-set amount used in lieu of actual utility costs for the shelter deduction. Georgia offers three tiers: SUA (heating/cooling), LUA (limited, non-heating utilities), and Telephone Allowance. States must allow households to use the SUA if they claim heating/cooling expenses. 7 CFR 273.9(d)(6)(iii) governs the SUA election.
The net income test (100% FPL) determines final eligibility after deductions. The benefit allotment = max allotment for household size − 30% of net income (rounded down to nearest dollar). Minimum benefit: $23/month for 1-2 person households (FY2026 — confirm with FNS memo).
Scope
In scope:
-
Federal parameter seed tables:
snap_standard_deductions,snap_max_allotments,snap_sua_amounts -
Deduction calculation pipeline with mandatory order enforcement (7 CFR 273.10(e))
-
SUA/LUA/Telephone Allowance election logic with Georgia amounts in
jurisdiction.toml -
Dependent care deduction with per-child caps
-
Medical expense deduction with elderly/disabled member check and $35 threshold
-
Excess shelter deduction with 50% income test and elderly/disabled uncapping
-
Child support paid deduction
-
Net income test (100% FPL)
-
Benefit allotment calculation (max allotment − 30% of net income)
-
Minimum benefit floor ($23 for 1-2 person households FY2026)
-
JDM ruleset for deduction calculation (
rulesets/federal/snap-deductions.json) -
Georgia-specific shelter deduction parameters in
rulesets/georgia/snap-deductions.json -
Integration tests with boundary conditions
Out of scope:
-
Gross income test (130% FPL) — covered in
snap-eligibilityplan -
Asset test — covered in
snap-eligibilityplan -
Categorical eligibility bypass of income/asset tests — covered in
snap-categorical-eligibilityplan -
Income type classification — covered in
reference-extensionsplan (IncomeTypeenum) -
Expense data collection — covered in
persons-household-modelplan (expenses table) -
Proration of first month benefit — covered in
snap-enrollment-ebtplan
Dependencies
This plan depends on:
-
reference-extensions (must be complete):
IncomeTypeenum withSelfEmploymentNet,ChildSupportPaid;ExpenseTypeadditions if needed -
persons-household-model (must be complete):
person_incomeandperson_expensestables withexpense_type,amount,frequencycolumns;is_elderly(age >= 60) andis_disabledcomputed or stored fields on Person -
snap-eligibility (parallel): this plan provides the deduction pipeline that snap-eligibility calls between gross income test and net income test
Design
Federal parameter tables (canopy-snap database)
These tables hold annually-updated federal parameters. They are seeded via migration with current FY2026 values. Annual updates are applied via a new migration each fiscal year (October 1).
-- SPDX-License-Identifier: AGPL-3.0-or-later
-- Standard deduction by household size (7 CFR 273.9(d)(1))
-- FNS publishes annually in the Federal Register
CREATE TABLE snap_standard_deductions (
id UUID PRIMARY KEY,
fiscal_year INTEGER NOT NULL,
household_size_min INTEGER NOT NULL, -- 1
household_size_max INTEGER NOT NULL, -- 3, or 99 for "4+"
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX snap_std_deduction_fy_size
ON snap_standard_deductions (fiscal_year, household_size_min, household_size_max);
-- FY2026 seed values (confirm with FNS COLA memo before implementation)
INSERT INTO snap_standard_deductions (id, fiscal_year, household_size_min, household_size_max, amount) VALUES
(gen_random_uuid(), 2026, 1, 3, 198.00),
(gen_random_uuid(), 2026, 4, 99, 208.00);
-- Maximum monthly allotment by household size (7 CFR 273.10)
-- Used for benefit calculation: benefit = max_allotment - 30% of net_income
CREATE TABLE snap_max_allotments (
id UUID PRIMARY KEY,
fiscal_year INTEGER NOT NULL,
household_size INTEGER NOT NULL, -- 1 through 8; 9+ uses per_additional_member
max_allotment NUMERIC(10,2) NOT NULL,
per_additional_member NUMERIC(10,2), -- only populated for household_size = 8 row
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX snap_max_allotment_fy_size
ON snap_max_allotments (fiscal_year, household_size);
-- FY2026 seed values (48 contiguous states; confirm with FNS COLA memo)
INSERT INTO snap_max_allotments (id, fiscal_year, household_size, max_allotment, per_additional_member) VALUES
(gen_random_uuid(), 2026, 1, 292.00, NULL),
(gen_random_uuid(), 2026, 2, 536.00, NULL),
(gen_random_uuid(), 2026, 3, 768.00, NULL),
(gen_random_uuid(), 2026, 4, 975.00, NULL),
(gen_random_uuid(), 2026, 5, 1158.00, NULL),
(gen_random_uuid(), 2026, 6, 1390.00, NULL),
(gen_random_uuid(), 2026, 7, 1536.00, NULL),
(gen_random_uuid(), 2026, 8, 1756.00, 220.00);
-- Standard Utility Allowance amounts by jurisdiction and tier
-- Georgia has three tiers: SUA (heating/cooling), LUA (non-heating), Telephone
-- Updated annually by state; requires FNS approval
CREATE TABLE snap_sua_amounts (
id UUID PRIMARY KEY,
jurisdiction TEXT NOT NULL, -- e.g., 'georgia'
fiscal_year INTEGER NOT NULL,
tier TEXT NOT NULL, -- 'sua', 'lua', 'telephone'
amount NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX snap_sua_jurisdiction_fy_tier
ON snap_sua_amounts (jurisdiction, fiscal_year, tier);
-- FY2026 Georgia SUA values (confirm with Georgia DFCS before implementation)
INSERT INTO snap_sua_amounts (id, jurisdiction, fiscal_year, tier, amount) VALUES
(gen_random_uuid(), 'georgia', 2026, 'sua', 399.00),
(gen_random_uuid(), 'georgia', 2026, 'lua', 268.00),
(gen_random_uuid(), 'georgia', 2026, 'telephone', 49.00);
-- Maximum excess shelter deduction cap (applies to non-elderly/non-disabled households)
-- FNS publishes annually; elderly/disabled households have no cap
CREATE TABLE snap_shelter_deduction_caps (
id UUID PRIMARY KEY,
fiscal_year INTEGER NOT NULL,
max_excess_shelter NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX snap_shelter_cap_fy
ON snap_shelter_deduction_caps (fiscal_year);
INSERT INTO snap_shelter_deduction_caps (id, fiscal_year, max_excess_shelter) VALUES
(gen_random_uuid(), 2026, 672.00);
jurisdiction.toml additions
[snap.sua]
# Which SUA tiers are offered in this jurisdiction
# Values: "sua", "lua", "telephone"
available_tiers = ["sua", "lua", "telephone"]
# Whether households may use actual utility costs instead of SUA
# Georgia: yes, household chooses higher of SUA or actual
allow_actual_utility_costs = true
[snap.deductions]
# Dependent care monthly cap: under age 2
dependent_care_cap_under_2 = 200.00
# Dependent care monthly cap: age 2 and older
dependent_care_cap_2_and_over = 175.00
# Medical expense threshold for elderly/disabled
medical_expense_threshold = 35.00
[snap.benefit]
# Minimum monthly benefit for 1-2 person households
minimum_benefit_household_max_size = 2
minimum_benefit_amount = 23.00
Deduction calculation pipeline
The pipeline processes deductions in the federally mandated order per 7 CFR 273.10(e). Each step receives the running income total and returns the deduction amount.
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
/// Input to the deduction pipeline — assembled by snap-eligibility from
/// canopy-persons income/expense data and the household composition.
pub struct DeductionInput {
/// Total gross earned income (all IncomeType variants classified as earned)
pub gross_earned_income: Decimal,
/// Total gross unearned income
pub gross_unearned_income: Decimal,
/// Household size (adjusted for ineligible students per snap-categorical-eligibility)
pub household_size: u32,
/// Whether any household member is elderly (age >= 60) or disabled
pub has_elderly_or_disabled: bool,
/// Dependent care expenses: Vec of (child_age_under_2: bool, monthly_cost: Decimal)
pub dependent_care_expenses: Vec<DependentCareExpense>,
/// Medical expenses for elderly/disabled members only (monthly total)
pub medical_expenses: Decimal,
/// Shelter expenses: rent/mortgage + property tax + insurance (monthly)
pub shelter_costs: Decimal,
/// Utility expense election (SUA tier, actual costs, or none)
pub utility_election: UtilityElection,
/// Legally obligated child support paid to non-household member (monthly)
pub child_support_paid: Decimal,
/// Fiscal year for parameter lookups
pub fiscal_year: i32,
}
pub struct DependentCareExpense {
pub under_age_2: bool,
pub monthly_cost: Decimal,
}
pub enum UtilityElection {
/// Household claims SUA (heating/cooling costs)
Sua,
/// Household claims LUA (non-heating utilities only)
Lua,
/// Household claims telephone allowance only
Telephone,
/// Household uses actual documented utility costs
ActualCosts(Decimal),
/// Household has no utility costs (included in rent)
None,
}
/// Output of the deduction pipeline — every intermediate value is preserved
/// for notice generation and QC review.
pub struct DeductionResult {
pub gross_income: Decimal,
pub earned_income_deduction: Decimal,
pub standard_deduction: Decimal,
pub dependent_care_deduction: Decimal,
pub medical_deduction: Decimal,
pub child_support_deduction: Decimal,
pub total_shelter_costs: Decimal, // rent + utilities (SUA or actual)
pub shelter_half_income: Decimal, // 50% of income after other deductions
pub excess_shelter_raw: Decimal, // shelter - 50% threshold (before cap)
pub excess_shelter_deduction: Decimal, // after cap (uncapped if elderly/disabled)
pub net_income: Decimal,
pub net_income_test_passed: bool, // net_income <= 100% FPL for household_size
}
/// Calculate all deductions in mandatory federal order (7 CFR 273.10(e)).
///
/// Order:
/// 1. Earned income deduction (20%)
/// 2. Standard deduction (by household size)
/// 3. Dependent care deduction (capped per dependent)
/// 4. Child support paid deduction
/// 5. Sum deductions 1-4, subtract from gross → adjusted income
/// 6. Medical expense deduction (elderly/disabled only, excess over $35)
/// 7. Compute 50% of adjusted income after medical deduction
/// 8. Excess shelter = total shelter costs − 50% adjusted income
/// 9. Cap excess shelter unless elderly/disabled
/// 10. Net income = gross − all deductions
pub fn calculate_deductions(
input: &DeductionInput,
params: &DeductionParams,
) -> DeductionResult {
let gross_income = input.gross_earned_income + input.gross_unearned_income;
// Step 1: Earned income deduction — 20% of gross earned income
let earned_income_deduction = (input.gross_earned_income * dec!(0.20)).round_dp(2);
// Step 2: Standard deduction — looked up by household_size and fiscal_year
let standard_deduction = params.standard_deduction;
// Step 3: Dependent care deduction — actual costs capped per dependent
let dependent_care_deduction = input.dependent_care_expenses.iter()
.map(|dep| {
let cap = if dep.under_age_2 {
params.dependent_care_cap_under_2
} else {
params.dependent_care_cap_2_and_over
};
dep.monthly_cost.min(cap)
})
.sum::<Decimal>();
// Step 4: Child support paid deduction — full amount, no cap
let child_support_deduction = input.child_support_paid;
// Step 5: Compute adjusted income after deductions 1-4
let adjusted_after_non_shelter = gross_income
- earned_income_deduction
- standard_deduction
- dependent_care_deduction
- child_support_deduction;
// Step 6: Medical expense deduction — elderly/disabled only, excess over threshold
let medical_deduction = if input.has_elderly_or_disabled && input.medical_expenses > params.medical_threshold {
input.medical_expenses - params.medical_threshold
} else {
Decimal::ZERO
};
let adjusted_after_medical = adjusted_after_non_shelter - medical_deduction;
// Step 7: Compute 50% of adjusted income (for shelter test)
let shelter_half_income = (adjusted_after_medical * dec!(0.50)).round_dp(2);
// Step 8: Total shelter costs = shelter + utility (SUA or actual)
let utility_amount = match &input.utility_election {
UtilityElection::Sua => params.sua_amount,
UtilityElection::Lua => params.lua_amount,
UtilityElection::Telephone => params.telephone_amount,
UtilityElection::ActualCosts(actual) => *actual,
UtilityElection::None => Decimal::ZERO,
};
let total_shelter_costs = input.shelter_costs + utility_amount;
// Step 9: Excess shelter = total shelter − 50% adjusted income
let excess_shelter_raw = (total_shelter_costs - shelter_half_income).max(Decimal::ZERO);
// Step 10: Cap excess shelter unless household has elderly/disabled member
let excess_shelter_deduction = if input.has_elderly_or_disabled {
excess_shelter_raw // No cap for elderly/disabled households
} else {
excess_shelter_raw.min(params.max_excess_shelter)
};
// Final: Net income = gross − all deductions
let net_income = gross_income
- earned_income_deduction
- standard_deduction
- dependent_care_deduction
- child_support_deduction
- medical_deduction
- excess_shelter_deduction;
let net_income = net_income.max(Decimal::ZERO);
DeductionResult {
gross_income,
earned_income_deduction,
standard_deduction,
dependent_care_deduction,
medical_deduction,
child_support_deduction,
total_shelter_costs,
shelter_half_income,
excess_shelter_raw,
excess_shelter_deduction,
net_income,
net_income_test_passed: net_income <= params.net_income_limit,
}
}
/// Parameters loaded from database tables and jurisdiction.toml at startup.
pub struct DeductionParams {
pub standard_deduction: Decimal,
pub dependent_care_cap_under_2: Decimal,
pub dependent_care_cap_2_and_over: Decimal,
pub medical_threshold: Decimal,
pub sua_amount: Decimal,
pub lua_amount: Decimal,
pub telephone_amount: Decimal,
pub max_excess_shelter: Decimal,
pub net_income_limit: Decimal, // 100% FPL for household_size
}
Benefit allotment calculation
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
use rust_decimal_macros::dec;
/// Calculate the monthly SNAP benefit allotment.
///
/// Formula (7 CFR 273.10(e)(2)(ii)):
/// benefit = max_allotment_for_household_size − (30% × net_income)
/// Round the 30% product DOWN to the nearest cent.
/// Round the benefit DOWN to the nearest dollar.
///
/// Minimum benefit: $23/month for 1-2 person households (FY2026).
/// Households larger than 2 have no minimum benefit — if the formula
/// yields $0, the benefit is $0 (household may still be eligible but
/// receives zero allotment).
pub fn calculate_allotment(
net_income: Decimal,
household_size: u32,
max_allotment: Decimal,
minimum_benefit: Decimal,
minimum_benefit_max_household_size: u32,
) -> Decimal {
let thirty_pct = (net_income * dec!(0.30)).round_dp(2);
let raw_benefit = (max_allotment - thirty_pct).max(Decimal::ZERO);
let benefit = raw_benefit.round_dp(0); // Round down to nearest dollar
// Apply minimum benefit floor for small households
if household_size <= minimum_benefit_max_household_size && benefit < minimum_benefit {
minimum_benefit
} else {
benefit
}
}
SUA election logic
The household’s utility election determines which SUA tier (or actual costs) is used in the shelter deduction.
Decision rules:
-
Household claims heating or cooling costs → eligible for SUA
-
Household claims non-heating utility costs (electric, water, sewer, trash, phone) but NOT heating/cooling → eligible for LUA
-
Household claims only telephone expense → eligible for Telephone Allowance
-
Household claims no utility costs (utilities included in rent) → no SUA/LUA
-
If
jurisdiction.tomlallow_actual_utility_costs = true, household may use actual costs instead of SUA; system uses the higher of SUA or actual (per Georgia policy — other jurisdictions may not offer this choice)
The election is captured during application intake or change report via the expenses table in canopy-persons.
canopy-snap reads the household expenses, determines the highest applicable tier, and passes the UtilityElection to the deduction pipeline.
// SPDX-License-Identifier: AGPL-3.0-or-later
use rust_decimal::Decimal;
pub struct HouseholdExpenses {
pub has_heating_cooling: bool,
pub has_non_heating_utility: bool,
pub has_telephone_only: bool,
pub actual_utility_costs: Decimal,
pub utilities_included_in_rent: bool,
}
pub struct SuaConfig {
pub available_tiers: Vec<String>,
pub allow_actual_utility_costs: bool,
pub sua_amount: Decimal,
pub lua_amount: Decimal,
pub telephone_amount: Decimal,
}
/// Determine the utility election for the shelter deduction.
pub fn determine_utility_election(
expenses: &HouseholdExpenses,
config: &SuaConfig,
) -> UtilityElection {
if expenses.utilities_included_in_rent {
return UtilityElection::None;
}
if expenses.has_heating_cooling && config.available_tiers.contains(&"sua".to_string()) {
if config.allow_actual_utility_costs
&& expenses.actual_utility_costs > config.sua_amount
{
UtilityElection::ActualCosts(expenses.actual_utility_costs)
} else {
UtilityElection::Sua
}
} else if expenses.has_non_heating_utility
&& config.available_tiers.contains(&"lua".to_string())
{
UtilityElection::Lua
} else if expenses.has_telephone_only
&& config.available_tiers.contains(&"telephone".to_string())
{
UtilityElection::Telephone
} else {
UtilityElection::None
}
}
Wiring into snap-eligibility
The deduction pipeline replaces the let net_income = gross_income; // TODO line in the snap-eligibility determination flow.
The evaluation order in canopy-snap/src/determine.rs becomes:
-
Assemble
DeductionInputfrom canopy-persons income/expense data and household composition -
Load
DeductionParamsfromsnap_standard_deductions,snap_max_allotments,snap_sua_amounts,snap_shelter_deduction_caps, and FPL thresholds (all cached at startup) -
Call
calculate_deductions(input, params)→DeductionResult -
If
net_income_test_passed == false→ Deny (unless categorically eligible, which bypasses net income test for benefit calculation only) -
If passed, call
calculate_allotment(result.net_income, household_size, max_allotment, …)→ monthly benefit -
Populate
Determinationstruct withbenefit_amount,denial_reason_codes(if denied), and all deduction intermediate values for the signed JWS payload
Events
No new events are published by this plan.
The deduction calculation is an internal computational step within the snap-eligibility determination flow.
The determination result (including benefit amount) is published as part of the existing determination.completed event, which contains only IDs and status — no income amounts or deduction details.
Steps
Step 1: Federal parameter seed tables
Files:
-
services/canopy-snap/migrations/YYYYMMDD_snap_deduction_params.sql(new)
Create snap_standard_deductions, snap_max_allotments, snap_sua_amounts, snap_shelter_deduction_caps tables.
Seed with FY2026 values (confirm exact amounts with FNS COLA memo before merging).
Add to jurisdiction.toml:
-
[snap.sua]section withavailable_tiers,allow_actual_utility_costs -
[snap.deductions]section with dependent care caps, medical threshold -
[snap.benefit]section with minimum benefit parameters
Step 2: Deduction calculation pipeline
Files:
-
services/canopy-snap/src/deductions.rs(new) —DeductionInput,DeductionResult,DeductionParams,UtilityElection,calculate_deductions(),calculate_allotment() -
services/canopy-snap/src/sua.rs(new) —HouseholdExpenses,SuaConfig,determine_utility_election()
Implement the full deduction pipeline as shown in the Design section.
All monetary values use rust_decimal::Decimal with NUMERIC(10,2) storage.
No unwrap() in any code path.
All functions return Result<T> using anyhow::Context.
Step 3: Parameter loader
Files:
-
services/canopy-snap/src/params.rs(new)
Load all deduction parameters from the database at service startup.
Cache in Arc<DeductionParams> (or Arc<RwLock<…>> if hot-reload is needed).
The loader accepts fiscal_year and jurisdiction from CANOPY_JURISDICTION env var.
If a parameter is missing for the given fiscal year, the service fails to start with a clear error message (not a silent fallback).
Step 4: Wire into determination flow
Files:
-
services/canopy-snap/src/determine.rs(modify)
Replace let net_income = gross_income; // TODO: subtract allowable deductions with:
-
Fetch household expenses from canopy-persons via internal HTTP call
-
Build
DeductionInputfrom income data + expenses + household composition -
Call
calculate_deductions()→DeductionResult -
Use
result.net_incomefor the net income test and allotment calculation -
Populate all deduction fields in the determination payload
Step 5: JDM rulesets
Files:
-
rulesets/federal/snap-deductions.json(new) — federal deduction logic as a JDM decision table -
rulesets/georgia/snap-deductions.json(new) — Georgia-specific SUA amounts and election rules
The JDM rulesets encode the same logic as the Rust functions but as data-driven decision tables evaluated by zen-engine. This enables jurisdiction customization without code changes. The Rust implementation serves as the reference; the JDM rulesets must produce identical results.
Integration Tests
All tests use testcontainers-rs for PostgreSQL.
All tests use cargo nextest run -p canopy-snap.
Test scenarios
| # | Scenario | Expected result |
|---|---|---|
1 |
Single person, $1,000 earned income, no expenses, no elderly/disabled |
Earned deduction = $200; standard = $198; no shelter; net income = $602; benefit = max(1) - 30% of $602 |
2 |
Family of 4, $2,000 earned + $500 unearned, $1,200 rent, SUA elected, 1 child under 2 ($150 dependent care), no elderly/disabled |
All 6 deductions applied in order; excess shelter capped at $672 |
3 |
Elderly household (2 persons, one age 65), $800 SSI, $300 medical expenses, $900 rent, SUA elected |
Medical deduction = $300 - $35 = $265; excess shelter UNCAPPED (elderly household) |
4 |
Zero earned income household — earned income deduction = $0, standard deduction still applies |
Verify earned_income_deduction = 0; standard_deduction = $198 |
5 |
Household with actual utility costs ($450) exceeding SUA ($399) in Georgia with allow_actual_utility_costs=true |
Utility election = ActualCosts($450), not SUA($399) |
6 |
Household with actual utility costs ($350) below SUA ($399) in Georgia |
Utility election = SUA($399), not ActualCosts($350) |
7 |
LUA-only household (non-heating utilities, no heating/cooling) |
Utility election = LUA($268) |
8 |
Net income test boundary: net income = exactly 100% FPL for household size |
net_income_test_passed = true (boundary is inclusive: ⇐) |
9 |
Net income test failure: net income = 100% FPL + $1 |
net_income_test_passed = false |
10 |
Minimum benefit: 1-person household, calculated benefit = $15 |
Benefit = $23 (minimum benefit floor) |
11 |
Minimum benefit does NOT apply: 3-person household, calculated benefit = $15 |
Benefit = $15 (no minimum for 3+ person households) |
12 |
Household size > 8: 10-person household |
max_allotment = size-8 allotment + 2 × per_additional_member |
13 |
Child support paid: $200/month to non-household member |
child_support_deduction = $200; subtracted before shelter calculation |
14 |
Dependent care: 2 children, one under 2 ($250 actual) and one age 4 ($200 actual) |
Capped: $200 (under 2 cap) + $175 (2+ cap) = $375 total |
Boundary tests (required by QC standards)
-
Deduction order matters: verify that changing the order of deduction steps produces a different (wrong) result
-
Rounding: verify that 30% of net income is rounded to nearest cent, final benefit rounded down to nearest dollar
-
Zero gross income: all deductions = 0, net income = 0, benefit = max_allotment
-
All deductions present simultaneously: verify each intermediate value matches hand calculation
Files Touched
| File | Change |
|---|---|
|
New: snap_standard_deductions, snap_max_allotments, snap_sua_amounts, snap_shelter_deduction_caps tables with FY2026 seed |
|
New: DeductionInput, DeductionResult, DeductionParams, calculate_deductions(), calculate_allotment() |
|
New: HouseholdExpenses, SuaConfig, UtilityElection, determine_utility_election() |
|
New: parameter loader from database + jurisdiction.toml cache |
|
Modify: replace gross_income TODO with full deduction pipeline |
|
New: federal deduction decision table |
|
New: Georgia SUA/LUA election overrides |
|
Modify: add [snap.sua], [snap.deductions], [snap.benefit] sections |
|
New: 14+ integration test scenarios with boundary cases |
Verification
-
cargo nextest run -p canopy-snap— all deduction tests pass -
Hand-calculate a 4-person household with all 6 deductions active; verify Canopy produces the identical net income and benefit amount
-
Verify elderly/disabled household has uncapped shelter deduction (vs. non-elderly capped)
-
Verify SUA election correctly picks the higher of SUA and actual utility costs in Georgia
-
Verify minimum benefit floor applies to 1-2 person households only
-
Verify deduction order: earned income deduction is subtracted before standard deduction, which is subtracted before dependent care, etc.
Documentation Updates
-
.claude/docs/services.md— add snap_standard_deductions, snap_max_allotments, snap_sua_amounts, snap_shelter_deduction_caps tables; add deduction pipeline description -
.claude/CLAUDE.md— update canopy-snap feature status: "Deduction pipeline implemented; 6 mandatory deductions; SUA/LUA election" -
CHANGELOG.adoc— entry under== Unreleased -
docs/modules/ROOT/pages/plans/snap-deduction-calculation.adoc— update status table steps to COMPLETE