Plan: canopy-enrollment Household-Scoped Issuance Listing
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Store: |
Done (2026-04-20) |
2 |
API: |
Done (2026-04-20) |
3 |
canopy-appeals client: add |
Done (2026-04-20) |
4 |
canopy-appeals overpayment calculation: replace the placeholder at |
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 |
Done (2026-04-20) |
6 |
Integration tests (canopy-appeals): seed an appeal with |
Done (2026-04-20) |
7 |
Plan sync: |
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_statusmarks a rowfailedorreversed, 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-62—snap_benefit_issuances.household_idcolumn already present. -
services/canopy-enrollment/src/store/mod.rs— existinglist_issuances_for_enrollmentfn as template. -
services/canopy-enrollment/src/domain.rs:34-56—SnapBenefitIssuancestruct. -
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 inYYYY-MMformat. Defaults to the earliest issuance if omitted. -
to— inclusive month. Defaults to the latest issuance. -
include_all— iftrue, includepending/failed/reversedissuances. Defaultfalse(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.
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.
Files Touched
| File | Change |
|---|---|
|
+list_issuances_for_household |
|
+1 handler + route |
|
+EnrollmentClient::list_issuances_for_household |
|
Replace placeholder compute |
|
Update caller at line 388 |
|
New integration tests |
|
New integration test |
|
Errata resolved |
|
Tier 5.5 row → Done |
|
Unreleased entry |
Verification
-
cargo nextest run -p canopy-enrollment --test issuances_household_test— new tests pass. -
cargo nextest run -p canopy-appeals— overpayment test confirms real sum, not the old formula. -
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. -
cargo xtask validate— full battery green.
Documentation Updates
-
.claude/CLAUDE.md— canopy-enrollment route count 6 → 7 -
CHANGELOG.adoc—== Unreleased→=== Addedentry -
fair-hearings-appeals.adoc— errata for placeholder overpayment formula resolved -
roadmap.adoc— Tier 5.5 row forcanopy-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=truetests. The enrollment integration tests can’t exercise theinclude_allquery param today because there is no way to create a non-'issued'row through the public API (EBT adapter isNoopEbtAdapterand always succeeds). A test-only helper onstore::that inserts a pending/reversed row directly would cover the branch end-to-end. Deferred — the pure-function unit tests incontinued_benefits::tests::overpayment_excludes_reversedalready verify the semantics, and theinclude_allquery 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):