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 (snap_standard_deductions, etc.) but implementation uses JSON files (rulesets/federal/snap-deductions-2026.json, snap-allotments-2026.json) loaded by params.rs at startup. Same data, different storage mechanism. See errata below.

2

Implement deduction calculation pipeline in canopy-snap (6 deductions in mandatory order)

Done (2026-04-12) — deductions.rs with inline unit tests (plan specified separate deduction_tests.rs file; tests live in #[cfg(test)] instead)

3

Implement SUA/LUA election logic

Done (2026-04-12) — sua.rs

4

Implement net income test and benefit allotment calculation

Done (2026-04-12) — implemented in snap-eligibility.json JDM ruleset (expr-deductions + expr-benefit nodes)

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 snap_test.rs (plan specified deduction_tests.rs which does not exist as a separate file)

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):

  1. Earned income deduction — 20% of gross earned income (7 CFR 273.9(d)(2))

  2. Standard deduction — varies by household size, indexed annually by FNS (7 CFR 273.9(d)(1))

  3. 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))

  4. Medical expense deduction — elderly (60+) or disabled members only, excess over $35/month (7 CFR 273.9(d)(3))

  5. 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))

  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-eligibility plan

  • Asset test — covered in snap-eligibility plan

  • Categorical eligibility bypass of income/asset tests — covered in snap-categorical-eligibility plan

  • Income type classification — covered in reference-extensions plan (IncomeType enum)

  • Expense data collection — covered in persons-household-model plan (expenses table)

  • Proration of first month benefit — covered in snap-enrollment-ebt plan

Dependencies

This plan depends on:

  • reference-extensions (must be complete): IncomeType enum with SelfEmploymentNet, ChildSupportPaid; ExpenseType additions if needed

  • persons-household-model (must be complete): person_income and person_expenses tables with expense_type, amount, frequency columns; is_elderly (age >= 60) and is_disabled computed 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:

  1. Household claims heating or cooling costs → eligible for SUA

  2. Household claims non-heating utility costs (electric, water, sewer, trash, phone) but NOT heating/cooling → eligible for LUA

  3. Household claims only telephone expense → eligible for Telephone Allowance

  4. Household claims no utility costs (utilities included in rent) → no SUA/LUA

  5. If jurisdiction.toml allow_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:

  1. Assemble DeductionInput from canopy-persons income/expense data and household composition

  2. Load DeductionParams from snap_standard_deductions, snap_max_allotments, snap_sua_amounts, snap_shelter_deduction_caps, and FPL thresholds (all cached at startup)

  3. Call calculate_deductions(input, params)DeductionResult

  4. If net_income_test_passed == false → Deny (unless categorically eligible, which bypasses net income test for benefit calculation only)

  5. If passed, call calculate_allotment(result.net_income, household_size, max_allotment, …​) → monthly benefit

  6. Populate Determination struct with benefit_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.

API endpoints

No new public API endpoints. The deduction calculation is internal to canopy-snap. Results are visible via the existing GET /v1/determinations/{id} endpoint on canopy-eligibility, which returns the signed determination object (including benefit amount).

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 with available_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:

  1. Fetch household expenses from canopy-persons via internal HTTP call

  2. Build DeductionInput from income data + expenses + household composition

  3. Call calculate_deductions()DeductionResult

  4. Use result.net_income for the net income test and allotment calculation

  5. 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.

Step 6: Integration tests

Files:

  • services/canopy-snap/tests/deduction_tests.rs (new)

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

services/canopy-snap/migrations/YYYYMMDD_snap_deduction_params.sql

New: snap_standard_deductions, snap_max_allotments, snap_sua_amounts, snap_shelter_deduction_caps tables with FY2026 seed

services/canopy-snap/src/deductions.rs

New: DeductionInput, DeductionResult, DeductionParams, calculate_deductions(), calculate_allotment()

services/canopy-snap/src/sua.rs

New: HouseholdExpenses, SuaConfig, UtilityElection, determine_utility_election()

services/canopy-snap/src/params.rs

New: parameter loader from database + jurisdiction.toml cache

services/canopy-snap/src/determine.rs

Modify: replace gross_income TODO with full deduction pipeline

rulesets/federal/snap-deductions.json

New: federal deduction decision table

rulesets/georgia/snap-deductions.json

New: Georgia SUA/LUA election overrides

rulesets/georgia/jurisdiction.toml

Modify: add [snap.sua], [snap.deductions], [snap.benefit] sections

services/canopy-snap/tests/deduction_tests.rs

New: 14+ integration test scenarios with boundary cases

Verification

  1. cargo nextest run -p canopy-snap — all deduction tests pass

  2. Hand-calculate a 4-person household with all 6 deductions active; verify Canopy produces the identical net income and benefit amount

  3. Verify elderly/disabled household has uncapped shelter deduction (vs. non-elderly capped)

  4. Verify SUA election correctly picks the higher of SUA and actual utility costs in Georgia

  5. Verify minimum benefit floor applies to 1-2 person households only

  6. 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

Edit this page · default