Plan: canopy-enrollment Household-Scoped Issuance Listing

On this page

Status

Step Description Status

1

Store: list_issuances_for_household(db, household_id, window) — JOINs snap_benefit_issuances against the household column (already present on the issuance row) and filters by benefit_month overlap with [from, to]

Done (2026-04-20)

2

API: GET /v1/households/{household_id}/issuances?from=YYYY-MM&to=YYYY-MM — returns Vec<SnapBenefitIssuance> with issuance_status = 'issued' filtered in (exclude failed/pending/reversed from the default, include with ?include_all=true). RBAC: require_caseworker_or_above. utoipa-annotated.

Done (2026-04-20)

3

canopy-appeals client: add EnrollmentClient::list_issuances_for_household(household_id, from, to) — thin reqwest wrapper matching the other service clients

Done (2026-04-20)

4

canopy-appeals overpayment calculation: replace the placeholder at services/canopy-appeals/src/continued_benefits.rs:29 with sum(issuance.allotment_amount for issuance in window where issued). Document the semantic decision (see Design) in the function’s doc comment.

Done (2026-04-20)

5

Integration tests (canopy-enrollment): seed an enrollment + 3 issuances (2 issued, 1 reversed) across a 4-month window, GET the list with various from/to, assert filtering + status behaviour

Done (2026-04-20)

6

Integration tests (canopy-appeals): seed an appeal with continued_benefits_granted = true, seed issuances covering the continued-benefits window, compute overpayment, assert the value equals the issuance sum (not the old formula)

Done (2026-04-20)

7

Plan sync: fair-hearings-appeals.adoc errata note explaining the continued-benefits calculation now queries enrollment; Tier 5.5 row → Done

Done (2026-04-20)

Branch: feature/canopy-enrollment-household-issuances
Labels: type::feature, priority::medium, program::snap, service::enrollment, service::appeals, workflow::ready

Context

services/canopy-appeals/src/continued_benefits.rs:29 computes overpayment for continued-benefits-granted appeals as monthly_benefit / 30 * days_of_continued_benefits. The inline comment says: "This is a simplified calculation: daily_benefit * days_of_continued_benefits. In production, this would query canopy-enrollment for actual issuances."

The shortcut is obvious at read time and wrong in two ways:

  • SNAP allotments aren’t issued daily; they’re monthly. Proration applies on the first month of certification only (7 CFR 274.2(b)). The 30-day divisor isn’t how Georgia actually disburses.

  • The calculation doesn’t consider whether issuances actually happened. If canopy-enrollment’s issuance_status marks a row failed or reversed, the household didn’t receive that money — it shouldn’t be counted in an overpayment.

canopy-enrollment already tracks per-issuance data (snap_benefit_issuances table at services/canopy-enrollment/migrations/20260401000000_create_enrollment_tables.sql:33-62). GET /v1/enrollments/{id}/issuances exists at services/canopy-enrollment/src/api/mod.rs:252-272. But canopy-appeals doesn’t know the enrollment ID — it knows the household ID, the appeal, and the continued-benefits window.

This plan adds the missing lookup path.

Scope

In scope:

  • One store helper + one endpoint on canopy-enrollment.

  • Client integration + calculation replacement on canopy-appeals.

  • Tests on both services.

Out of scope:

  • TANF issuances (canopy-tanf has no issuance ledger today; cash assistance issuance tracking is a separate plan under Tier 3/4).

  • CAPS / WIC benefit tracking. Those programs have different disbursement models (voucher / provider-pay).

  • Recoupment of the overpayment (IPV, voluntary repayment, offset against future benefits) — that’s an enrollment concern post-appeal.

Dependencies

  • services/canopy-enrollment/migrations/20260401000000_create_enrollment_tables.sql:33-62snap_benefit_issuances.household_id column already present.

  • services/canopy-enrollment/src/store/mod.rs — existing list_issuances_for_enrollment fn as template.

  • services/canopy-enrollment/src/domain.rs:34-56SnapBenefitIssuance struct.

  • services/canopy-appeals/src/continued_benefits.rs:29 — placeholder formula.

  • services/canopy-appeals/src/clients.rs (or equivalent) — where inter-service clients live.

Design

Endpoint shape

GET /v1/households/{household_id}/issuances?from=2026-03&to=2026-06&include_all=false

200 OK
[
  {
    "id": "…",
    "enrollment_id": "…",
    "household_id": "…",
    "benefit_month": "2026-03",
    "allotment_amount": "235.00",
    "prorated": true,
    "proration_days_remaining": 22,
    "proration_days_total": 30,
    "ebt_transaction_id": "EBT-…",
    "issued_at": "2026-03-10T14:22:11Z",
    "issuance_status": "issued",
    "expiry_date": "2026-10-10T00:00:00Z",
    ...
  },
  ...
]

Query params:

  • from — inclusive month in YYYY-MM format. Defaults to the earliest issuance if omitted.

  • to — inclusive month. Defaults to the latest issuance.

  • include_all — if true, include pending/failed/reversed issuances. Default false (i.e., the overpayment calculation sees only money that actually reached the household).

canopy-appeals overpayment calculation

// services/canopy-appeals/src/continued_benefits.rs

/// Compute the overpayment created by continued benefits during an appeal.
///
/// Returns the sum of `allotment_amount` for issuances to the household
/// within the continued-benefits window (`continued_benefits_start_date`
/// through `decision_date`) that have `issuance_status = 'issued'`.
///
/// Failed/reversed/pending issuances are excluded — the household didn't
/// receive that money, so it's not an overpayment.
pub async fn compute_overpayment(
    enrollment_client: &EnrollmentClient,
    household_id: HouseholdId,
    window: (NaiveDate, NaiveDate),
) -> Result<Decimal, OverpaymentError> {
    let (from_date, to_date) = window;
    let from_month = from_date.format("%Y-%m").to_string();
    let to_month   = to_date.format("%Y-%m").to_string();
    let issuances  = enrollment_client
        .list_issuances_for_household(household_id, &from_month, &to_month)
        .await?;
    Ok(issuances.iter().map(|i| i.allotment_amount).sum())
}

Semantic decision: whole-month vs partial-month

Continued-benefits windows are date-ranged; SNAP issuances are monthly (with proration on first month). Decision: if the continued-benefits window includes any part of a benefit month, that month’s entire allotment_amount counts toward the overpayment. This matches how the overpayment would be recovered in practice (whole issuances are recouped, not pro-rated).

When this is wrong (rare): the appeal decision falls mid-month and the household already received a full-month issuance; Georgia would typically allow the household to retain that month and recoup starting the following month. If the rules engine needs that nuance, it can be added via a post-processing step that subtracts the partial month. Deferred.

RBAC

Same role requirement as the existing GET /v1/enrollments/{id}/issuances: require_eligibility_specialist_or_above. Caseworkers can view issuances; non-caseworkers cannot.

Steps

Step 1: Store helper

Files: services/canopy-enrollment/src/store/mod.rs.

Add list_issuances_for_household with the filter logic. Pattern the function after the existing list_issuances_for_enrollment (same file).

Step 2: API endpoint

Files: services/canopy-enrollment/src/api/mod.rs.

utoipa-annotated handler. Parse from/to as chrono::NaiveDate via a YYYY-MM custom parser (treat as the first of the month) so query semantics are obvious.

Step 3: canopy-appeals client

Files: services/canopy-appeals/src/clients.rs (or wherever service clients live; if it doesn’t exist yet, create it following the canopy-web clients.rs pattern).

Step 4: Replace the placeholder

Files: services/canopy-appeals/src/continued_benefits.rs, services/canopy-appeals/src/api/mod.rs:388 (caller).

Step 5: canopy-enrollment integration tests

Files: services/canopy-enrollment/tests/ (extend existing enrollment tests).

Seed 3 issuances (issued / issued / reversed), GET with filter, assert result set.

Step 6: canopy-appeals integration tests

Files: services/canopy-appeals/tests/appeals_test.rs or new.

Seed an appeal + continued-benefits + 2 issued months; compute; assert value equals sum(allotment_amount).

Step 7: Plan sync

Files: fair-hearings-appeals.adoc, roadmap.adoc, CHANGELOG.adoc.

Files Touched

File Change

services/canopy-enrollment/src/store/mod.rs

+list_issuances_for_household

services/canopy-enrollment/src/api/mod.rs

+1 handler + route

services/canopy-appeals/src/clients.rs

+EnrollmentClient::list_issuances_for_household

services/canopy-appeals/src/continued_benefits.rs

Replace placeholder compute

services/canopy-appeals/src/api/mod.rs

Update caller at line 388

services/canopy-enrollment/tests/*

New integration tests

services/canopy-appeals/tests/appeals_test.rs

New integration test

docs/modules/ROOT/pages/plans/fair-hearings-appeals.adoc

Errata resolved

docs/modules/ROOT/pages/roadmap.adoc

Tier 5.5 row → Done

CHANGELOG.adoc

Unreleased entry

Verification

  1. cargo nextest run -p canopy-enrollment --test issuances_household_test — new tests pass.

  2. cargo nextest run -p canopy-appeals — overpayment test confirms real sum, not the old formula.

  3. Manual: seed a SNAP enrollment + 3 months of issuances, curl $ENROLLMENT_URL/v1/households/{id}/issuances?from=2026-03&to=2026-06 — returns the expected set.

  4. cargo xtask validate — full battery green.

Documentation Updates

  • .claude/CLAUDE.md — canopy-enrollment route count 6 → 7

  • CHANGELOG.adoc== Unreleased=== Added entry

  • fair-hearings-appeals.adoc — errata for placeholder overpayment formula resolved

  • roadmap.adoc — Tier 5.5 row for canopy-appeals/src/api/mod.rs:388 + continued_benefits.rs:29 → Done

  • docs/modules/ROOT/pages/api/canopy-enrollment.adoc / api/canopy-appeals.adoc — per-program API reference files don’t exist yet; deferred with the rest of the per-service reference pages

Potential Improvements

Orthogonal to the overpayment fix; follow-ups:

  • Direct DB pending/reversed seeding for include_all=true tests. The enrollment integration tests can’t exercise the include_all query param today because there is no way to create a non-'issued' row through the public API (EBT adapter is NoopEbtAdapter and always succeeds). A test-only helper on store:: that inserts a pending/reversed row directly would cover the branch end-to-end. Deferred — the pure-function unit tests in continued_benefits::tests::overpayment_excludes_reversed already verify the semantics, and the include_all query param is additionally covered by the store-level boolean filter.

  • Partial-month retention rule. Per PAMMS 2415, if the continued-benefits window ends mid-month and the household already received the full month’s issuance, Georgia may allow the household to retain that month. The current implementation counts the whole-month issuance. A post-processing step that subtracts the partial month when the decision falls before the 16th (or whatever the jurisdiction.toml threshold ends up being) would match that rule. Deferred pending a jurisdiction parameter.

  • Household-scope RBAC. The endpoint currently requires eligibility_specialist_or_above — same as enrollment-scoped listing. Household-centric views might eventually want a narrower "own caseload only" filter (visibility, not an RBAC role). Tracked under the worker-portal authorization plan.

  • Bulk / caching. The overpayment lookup is one HTTP call per decision, which is fine. If reporting pipelines start querying every household’s issuances monthly, a bulk endpoint + cache would help. Premature.


Tracked follow-ups (filed 2026-04-24 after audit of plan Errata + Potential Improvements sections across the repo):

  • #319 — Bulk query + caching for issuance listings (from Potential Improvements)

Tracked follow-ups (filed 2026-05-04 during PI sweep):

  • #407 — Partial-month retention rule per PAMMS 2415

  • #408 — Household-scope RBAC for issuance listing

Edit this page · default