Plan: SNAP Enrollment and EBT Issuance
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Database schema: snap_enrollments, snap_benefit_issuances tables |
Done (2026-04-06) |
2 |
EbtAdapter trait with NoopEbtAdapter (deterministic test data) |
Done (2026-04-06) |
3 |
Benefit issuance logic: proration, allotment calculation, issuance pipeline |
Done (2026-04-06) |
4 |
12-month stale benefit expungement job |
Done (2026-04-06) |
5 |
API endpoints for enrollment management and issuance |
Done (2026-04-06) |
6 |
Integration tests |
Done (2026-04-06) — 9 devstack-gated integration tests in |
Known Gaps
-
Event-driven enrollment creation:
determination.completed.snapsubscriber now creates a pending enrollment record with zero allotment (MR !56). Allotment is set by the caseworker at issuance time via POST /enrollments/{id}/issue. Enhancement: pull allotment from determination event payload to pre-populate. Tracked in #295. -
appeal.continued_benefits_grantedhandling: Should pause scheduled terminations. Store functionupdate_enrollment_statusexists; event subscriber wiring needed. Tracked in #229.
Epic: &42
Branch: feature/snap-enrollment-ebt
Context
7 USC §2016(i) requires EBT as the mandatory delivery mechanism for SNAP benefits in all states. 7 CFR Part 274 governs EBT system requirements. 7 CFR 274.2(b) requires initial benefit issuance within 30 days of application (7 days for expedited service households). 7 USC §2016(h)(9) requires states to expunge benefits unused for 12 months and notify households 30 days before expungement.
Georgia’s EBT vendor is Conduent (formerly Xerox/ACS), operating under a state contract.
The Conduent API is the live production integration target.
For UAT, a NoopEbtAdapter provides deterministic responses without live API access.
The enrollment lifecycle:
1. Determination approved → determination.completed event published by canopy-snap
2. canopy-enrollment receives event, creates enrollment record
3. Benefit issuance: calculate allotment, prorate for first month, issue to EBT account
4. Monthly: issue ongoing benefits on benefit effective date
5. Monthly: expungement job scans for stale benefits; 30-day notice period; expunge on expiry
Scope
In scope:
-
snap_enrollmentstable — enrollment record per household/certification period -
snap_benefit_issuancestable — issuance ledger per benefit month -
EbtAdaptertrait withcreate_account,issue_benefits,get_balance,suspend_account,expunge_benefits -
NoopEbtAdapterwith deterministic responses for UAT -
ConduentEbtAdapterstub (interface defined, real API calls not yet wired) -
Benefit proration: first month allotment = max_allotment × (days_remaining_in_month / days_in_month)
-
Initial issuance: 30 days from application; 7 days for expedited households
-
12-month stale benefit expungement job with
enrollment.expungement_pendingevent at 30-day mark -
Event handling:
determination.completed(approved) → create enrollment + initial issuance -
Event handling:
appeal.continued_benefits_granted→ pause any scheduled termination -
Event handling:
appeal.decision_reversed→ re-evaluate enrollment; restart if terminated -
API: enrollment status, issuance history, issuance trigger (worker-initiated)
Out of scope:
-
Live Conduent API integration (requires executed state contract credentials and security review)
-
EBT card management (card issuance, card replacement, PIN change) — Conduent self-service portal handles this
-
TANF EBT issuance (canopy-tanf plan; same adapter interface, different program rules)
-
WIC eWIC issuance (separate federal vendor program)
-
Benefit reconciliation with state EBT host (post-UAT)
-
Automated overpayment collection from future benefits (canopy-appeals plan)
Design
Database schema
CREATE TABLE snap_enrollments (
id UUID PRIMARY KEY,
household_id UUID NOT NULL,
determination_id UUID NOT NULL, -- the approved determination that triggered enrollment
application_id UUID NOT NULL,
certification_start_date DATE NOT NULL,
certification_end_date DATE NOT NULL,
max_monthly_allotment NUMERIC(10,2) NOT NULL,
ebt_account_id TEXT, -- EBT host account identifier (set after account provisioned)
expedited BOOLEAN NOT NULL DEFAULT false,
initial_issuance_due_date DATE NOT NULL, -- application_date + 7 if expedited, else + 30
initial_issuance_date DATE, -- actual issuance date
status TEXT NOT NULL DEFAULT 'pending_issuance',
-- 'pending_issuance', 'active', 'suspended', 'terminated', 'expired'
suspended_reason TEXT,
terminated_reason TEXT,
terminated_date DATE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
active BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX snap_enrollments_household_idx ON snap_enrollments (household_id);
CREATE INDEX snap_enrollments_status_idx ON snap_enrollments (status, initial_issuance_due_date)
WHERE status = 'pending_issuance';
CREATE TABLE snap_benefit_issuances (
id UUID PRIMARY KEY,
enrollment_id UUID NOT NULL REFERENCES snap_enrollments(id),
household_id UUID NOT NULL,
benefit_month DATE NOT NULL, -- first day of the benefit month
allotment_amount NUMERIC(10,2) NOT NULL,
prorated BOOLEAN NOT NULL DEFAULT false,
proration_days_remaining INTEGER, -- days remaining in month at application date
proration_days_total INTEGER, -- total days in the benefit month
ebt_transaction_id TEXT, -- EBT host transaction reference
issued_at TIMESTAMPTZ,
issuance_status TEXT NOT NULL DEFAULT 'pending',
-- 'pending', 'issued', 'failed', 'reversed'
issuance_error TEXT,
expiry_date DATE NOT NULL, -- issued_date + 365 (12-month stale benefit rule)
expungement_notice_sent_at TIMESTAMPTZ, -- set when 30-day expungement notice generated
expunged_at TIMESTAMPTZ,
expunged_amount NUMERIC(10,2), -- may be less than allotment_amount if partially used
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX snap_issuances_enrollment_month ON snap_benefit_issuances (enrollment_id, benefit_month);
CREATE INDEX snap_issuances_expiry_idx ON snap_benefit_issuances (expiry_date)
WHERE expunged_at IS NULL AND issuance_status = 'issued';
EbtAdapter trait
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::NaiveDate;
use rust_decimal::Decimal;
use uuid::Uuid;
use anyhow::Result;
pub struct EbtAccountRequest {
pub household_id: Uuid,
pub enrollment_id: Uuid,
pub head_of_household_name: String, // fetched from canopy-persons
}
pub struct EbtAccountResult {
pub account_id: String,
pub provisioned_at: chrono::DateTime<chrono::Utc>,
}
pub struct EbtIssuanceRequest {
pub account_id: String,
pub enrollment_id: Uuid,
pub benefit_month: NaiveDate,
pub amount: Decimal,
}
pub struct EbtIssuanceResult {
pub transaction_id: String,
pub issued_at: chrono::DateTime<chrono::Utc>,
}
pub struct EbtBalanceResult {
pub available_balance: Decimal,
pub as_of: chrono::DateTime<chrono::Utc>,
}
pub struct EbtExpungeRequest {
pub account_id: String,
pub transaction_id: String, // the issuance transaction to expunge
pub amount: Decimal,
}
pub trait EbtAdapter: Send + Sync {
/// Provision a new EBT account for an enrolled household.
async fn create_account(&self, req: &EbtAccountRequest) -> Result<EbtAccountResult>;
/// Issue benefit allotment for a benefit month.
async fn issue_benefits(&self, req: &EbtIssuanceRequest) -> Result<EbtIssuanceResult>;
/// Query available balance (used for expungement calculation).
async fn get_balance(&self, account_id: &str) -> Result<EbtBalanceResult>;
/// Suspend EBT account (adverse action pending hearing).
async fn suspend_account(&self, account_id: &str, reason: &str) -> Result<()>;
/// Expunge stale benefits after 12-month window expires.
async fn expunge_benefits(&self, req: &EbtExpungeRequest) -> Result<()>;
}
NoopEbtAdapter
The Noop adapter returns deterministic success responses for UAT:
- create_account: returns account_id = "NOOP-{enrollment_id}", provisioned_at = now()
- issue_benefits: returns transaction_id = "NOOP-{enrollment_id}-{benefit_month}", issued_at = now()
- get_balance: returns the full allotment amount (simulates no spending)
- suspend_account: no-op, returns Ok
- expunge_benefits: no-op, returns Ok
Configure via environment variable: CANOPY_EBT_ADAPTER=noop (default) or =conduent.
ConduentEbtAdapter stub
pub struct ConduentEbtAdapter {
base_url: String,
api_key: String,
client: reqwest::Client,
}
// Methods unimplemented!() — real Conduent API endpoints TBD from state contract documentation.
// Do NOT implement real Conduent calls until contract credentials and security review are complete.
Benefit proration logic
For the first benefit month, SNAP regulations require proration based on the date the household becomes certified. Proration = max_monthly_allotment × (days_remaining_in_month / days_in_month).
/// Calculate prorated allotment for the initial benefit month.
/// Per 7 CFR 273.10(a)(1)(ii): prorate from date of application.
pub fn prorated_allotment(
max_allotment: Decimal,
application_date: NaiveDate,
) -> (Decimal, i32, i32) {
let days_in_month = days_in_month(application_date.year(), application_date.month());
let days_remaining = days_in_month - application_date.day() as i32 + 1;
let prorated = (max_allotment * Decimal::from(days_remaining)
/ Decimal::from(days_in_month))
.round_dp(2);
(prorated, days_remaining, days_in_month)
}
If application_date is the first of the month, days_remaining == days_in_month and the full allotment is issued without proration flag.
Issuance pipeline
On receipt of determination.completed (status=approved) event:
-
Look up enrollment record for household; create if not exists
-
Provision EBT account via
EbtAdapter::create_accountifebt_account_id IS NULL -
Calculate initial benefit month:
-
If expedited: current month if today ≤ 7th of month; else next month
-
Otherwise: month in which day 30 falls from application_date
-
-
Calculate allotment: prorate if first partial month
-
Call
EbtAdapter::issue_benefits -
Store issuance record with
expiry_date = issued_at.date() + 365 days -
Update enrollment:
status = 'active',initial_issuance_date = today -
Publish
enrollment.snap_issuedevent:{ enrollment_id, household_id, benefit_month }
For subsequent months: a monthly scheduler triggers issuance on the benefit effective date (state-configurable; default = 1st of month).
12-month stale benefit expungement
Background job (daily):
-
Find all issuances where
expiry_date = today + 30ANDexpungement_notice_sent_at IS NULL -
Publish
enrollment.expungement_pendingevent → canopy-notices generates ExpungementNotice -
Set
expungement_notice_sent_at = now()
On expiry date:
1. Find all issuances where expiry_date = today AND expunged_at IS NULL
2. Call EbtAdapter::get_balance to determine remaining balance
3. Call EbtAdapter::expunge_benefits for the remaining amount
4. Set expunged_at = now(), expunged_amount = balance_at_expiry
5. Publish enrollment.benefits_expunged event: { enrollment_id, household_id, benefit_month }
Both jobs can also be triggered via POST /internal/v1/enrollment/run-expungement for testing.
Events subscribed
| Event | Action |
|---|---|
|
Create enrollment, provision EBT account, issue initial benefits |
|
Set enrollment status = |
|
Cancel any scheduled termination for the enrollment period; resume issuance |
|
Re-activate enrollment if terminated; re-evaluate allotment if determination changed |
|
Record overpayment claim_id on enrollment (collection handled post-UAT) |
Events published
// enrollment.snap_issued
{ "enrollment_id": "uuid", "household_id": "uuid", "benefit_month": "2026-07-01" }
// enrollment.expungement_pending
{ "enrollment_id": "uuid", "household_id": "uuid",
"issuance_id": "uuid", "expiry_date": "2026-07-31" }
// enrollment.benefits_expunged
{ "enrollment_id": "uuid", "household_id": "uuid",
"issuance_id": "uuid", "benefit_month": "2025-07-01" }
No benefit amounts, EBT account IDs, or personal data in any event payload.
API endpoints
| Method + Path | Description | Auth |
|---|---|---|
|
Get current SNAP enrollment for household |
canopy-worker |
|
Get issuance history for enrollment |
canopy-worker |
|
Worker-initiated issuance (remediation only; system auto-issues monthly) |
canopy-snap-supervisor |
|
Suspend enrollment (adverse action) |
canopy-snap-supervisor |
|
Terminate enrollment with reason |
canopy-snap-supervisor |
|
Pending initial issuances past due date (supervisor dashboard) |
canopy-snap-supervisor |
CLI Commands (ADR-007)
Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:
-
canopy enrollment snap get --household-id <id>— get current SNAP enrollment for a household -
canopy enrollment snap issuances <id>— get issuance history for an enrollment -
canopy enrollment snap issue <id>— worker-initiated benefit issuance (remediation) -
canopy enrollment snap suspend <id>— suspend enrollment (adverse action) -
canopy enrollment snap terminate <id>— terminate enrollment with reason -
canopy enrollment snap queue— list pending initial issuances past due date
Steps
Step 1: Database migrations
Files: services/canopy-enrollment/migrations/20260401000000_create_enrollment_tables.sql, services/canopy-enrollment/src/main.rs
Create snap_enrollments and snap_benefit_issuances tables in canopy-enrollment’s database.
Note: canopy-enrollment uses the shared enrollment database (not an isolated program database). EBT account IDs, issuance amounts, and expiry dates are enrollment-level data, not legally-restricted program data. Only canopy-snap holds legally-restricted SNAP eligibility and IEVS data (ADR-004).
Step 2: EbtAdapter trait and NoopEbtAdapter
Files: crates/canopy-enrollment/src/ebt.rs (new)
Create the EbtAdapter trait, NoopEbtAdapter, and ConduentEbtAdapter stub.
Wire NoopEbtAdapter as the default via CANOPY_EBT_ADAPTER=noop.
Step 3: Issuance pipeline
Files: services/canopy-enrollment/src/issuance.rs (new)
Implement IssuancePipeline struct:
- handle_determination_approved(event) → enrollment creation + initial issuance
- issue_monthly_benefits(enrollment_id, benefit_month) → ongoing monthly issuance
- prorate_initial_month(max_allotment, application_date) → proration calculation
Step 4: Expungement job
Files: services/canopy-enrollment/src/expungement.rs (new), services/canopy-enrollment/src/events.rs (update), services/canopy-enrollment/src/main.rs (update)
Create services/canopy-enrollment/src/expungement.rs implementing the 12-month stale benefit expungement job per 7 USC section 2016(h)(9):
// SPDX-License-Identifier: AGPL-3.0-or-later
use canopy_mq::publisher::EventPublisher;
use chrono::{NaiveDate, Utc};
use sqlx::PgPool;
use uuid::Uuid;
pub struct ExpungementJob {
pool: PgPool,
ebt: Arc<dyn EbtAdapter>,
publisher: EventPublisher,
}
impl ExpungementJob {
pub fn new(pool: PgPool, ebt: Arc<dyn EbtAdapter>, publisher: EventPublisher) -> Self {
Self { pool, ebt, publisher }
}
/// Send 30-day expungement notices for issuances approaching expiry.
/// Finds issuances where expiry_date = today + 30 AND expungement_notice_sent_at IS NULL.
pub async fn send_expungement_notices(&self) -> Result<u64> {
let today = Utc::now().date_naive();
let target_date = today + chrono::Duration::days(30);
let issuances = sqlx::query_as::<_, SnapBenefitIssuance>(
r#"SELECT * FROM snap_benefit_issuances
WHERE expiry_date = $1
AND expungement_notice_sent_at IS NULL
AND expunged_at IS NULL
AND issuance_status = 'issued'"#,
)
.bind(target_date)
.fetch_all(&self.pool)
.await?;
for issuance in &issuances {
self.publisher.publish(
"enrollment.expungement_pending",
&serde_json::json!({
"enrollment_id": issuance.enrollment_id,
"household_id": issuance.household_id,
"issuance_id": issuance.id,
"expiry_date": issuance.expiry_date
}),
).await?;
// Update expungement_notice_sent_at
sqlx::query("UPDATE snap_benefit_issuances SET expungement_notice_sent_at = now() WHERE id = $1")
.bind(issuance.id)
.execute(&self.pool)
.await?;
}
Ok(issuances.len() as u64)
}
/// Process today's expired issuances: get remaining balance, expunge via EBT adapter.
/// Finds issuances where expiry_date = today AND expunged_at IS NULL AND issuance_status = 'issued'.
pub async fn run_expungements(&self) -> Result<u64> {
let today = Utc::now().date_naive();
let issuances = sqlx::query_as::<_, SnapBenefitIssuance>(
r#"SELECT * FROM snap_benefit_issuances
WHERE expiry_date = $1
AND expunged_at IS NULL
AND issuance_status = 'issued'"#,
)
.bind(today)
.fetch_all(&self.pool)
.await?;
for issuance in &issuances {
// 1. Get remaining balance from EBT host
// 2. Call ebt.expunge_benefits if balance > 0
// 3. Update expunged_at and expunged_amount
// 4. Publish enrollment.benefits_expunged event
}
Ok(issuances.len() as u64)
}
}
Error handling:
- If EbtAdapter::get_balance fails for an issuance, log at ERROR level and skip (do not halt the entire batch).
Record the error in issuance_error column and set issuance_status = 'failed'.
- If EbtAdapter::expunge_benefits fails, retry up to 3 times with exponential backoff.
After 3 failures, skip and log for manual intervention.
Update services/canopy-enrollment/src/events.rs to add publishing functions for enrollment.expungement_pending and enrollment.benefits_expunged.
Per ADR-004: no benefit amounts, EBT account IDs, or personal data in event payloads.
Update services/canopy-enrollment/src/main.rs to:
-
Wire the
ExpungementJobwith the configuredEbtAdapter -
Spawn a Tokio background task:
tokio::spawn(async move { loop { tokio::time::sleep(Duration::from_secs(86400)).await; job.send_expungement_notices().await; job.run_expungements().await; } }) -
Add internal endpoint
POST /internal/v1/enrollment/run-expungementthat runs both methods on demand (for testing)
Step 5: Event subscription and API routes
Files: services/canopy-enrollment/src/api/mod.rs (update), services/canopy-enrollment/src/api/enrollment.rs (new), services/canopy-enrollment/src/events.rs (update), services/canopy-enrollment/src/store/mod.rs (new), services/canopy-enrollment/src/store/models.rs (new), services/canopy-enrollment/src/main.rs (update)
Create services/canopy-enrollment/src/store/models.rs with sqlx model structs:
// SPDX-License-Identifier: AGPL-3.0-or-later
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapEnrollment {
pub id: Uuid,
pub household_id: Uuid,
pub determination_id: Uuid,
pub application_id: Uuid,
pub certification_start_date: NaiveDate,
pub certification_end_date: NaiveDate,
pub max_monthly_allotment: Decimal,
pub ebt_account_id: Option<String>,
pub expedited: bool,
pub initial_issuance_due_date: NaiveDate,
pub initial_issuance_date: Option<NaiveDate>,
pub status: String,
pub suspended_reason: Option<String>,
pub terminated_reason: Option<String>,
pub terminated_date: Option<NaiveDate>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub active: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapBenefitIssuance { /* all columns from snap_benefit_issuances table */ }
Create services/canopy-enrollment/src/store/mod.rs with query functions:
-
get_enrollment_by_household(pool, household_id) → Option<SnapEnrollment> -
create_enrollment(pool, enrollment) → SnapEnrollment -
update_enrollment_status(pool, id, status, reason) → SnapEnrollment -
create_issuance(pool, issuance) → SnapBenefitIssuance -
list_issuances(pool, enrollment_id) → Vec<SnapBenefitIssuance> -
list_pending_initial_issuances(pool) → Vec<SnapEnrollment>— whereinitial_issuance_date IS NULL AND initial_issuance_due_date < today
Create services/canopy-enrollment/src/api/enrollment.rs with route handlers:
// SPDX-License-Identifier: AGPL-3.0-or-later
use axum::{Router, routing::{get, post, put}, extract::{Path, Query, State}, Json};
use canopy_api::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
.route("/v1/enrollments/snap", get(get_snap_enrollment)) // Query: household_id
.route("/v1/enrollments/snap/:id/issuances", get(list_issuances))
.route("/v1/enrollments/snap/:id/issue", post(worker_issue)) // canopy-snap-supervisor
.route("/v1/enrollments/snap/:id/suspend", put(suspend_enrollment)) // canopy-snap-supervisor
.route("/v1/enrollments/snap/:id/terminate", put(terminate_enrollment)) // canopy-snap-supervisor
.route("/v1/enrollments/snap/queue", get(pending_issuance_queue)) // canopy-snap-supervisor
}
Auth: GET endpoints require canopy-worker role. POST/PUT mutation endpoints and /queue require canopy-snap-supervisor.
Error handling:
- 404 if enrollment not found
- 409 if worker_issue called on enrollment with status != 'active'
- 422 if terminate_enrollment called without a reason in the request body
Update services/canopy-enrollment/src/events.rs to implement event handlers for subscribed events:
pub async fn handle_determination_completed(event: DeterminationCompletedEvent, pipeline: &IssuancePipeline) -> Result<()> {
match event.status.as_str() {
"approved" => pipeline.handle_determination_approved(event).await,
"terminated" | "denied" => pipeline.handle_enrollment_termination(event).await,
_ => Ok(()),
}
}
pub async fn handle_continued_benefits_granted(event: ContinuedBenefitsEvent, pool: &PgPool) -> Result<()> {
// Cancel any scheduled termination for the enrollment; set status back to 'active'
}
pub async fn handle_decision_reversed(event: DecisionReversedEvent, pipeline: &IssuancePipeline) -> Result<()> {
// Re-activate enrollment if terminated; recalculate allotment
}
Update services/canopy-enrollment/src/main.rs to:
-
Wire RabbitMQ subscriber with queue
canopy-enrollment.events -
Bind to
canopy.eventswith routing keys:determination.completed,appeal.continued_benefits_granted,appeal.decision_reversed,appeal.overpayment_assessed -
Spawn subscriber as a background Tokio task
-
Merge API routes and internal expungement endpoint
Update services/canopy-enrollment/src/api/mod.rs to merge enrollment routes.
Step 6: Integration tests
Files: services/canopy-enrollment/tests/snap_enrollment_test.rs (new)
Use testcontainers-rs with PostgreSQL and RabbitMQ containers.
Use canopy_test_lib for test harness setup.
// SPDX-License-Identifier: AGPL-3.0-or-later
use canopy_test_lib::{setup_test_db, setup_test_mq, mock_event_publisher};
use crate::ebt::NoopEbtAdapter;
use crate::issuance::IssuancePipeline;
#[tokio::test]
async fn test_determination_approved_creates_enrollment() {
// Publish determination.completed event with status="approved", expedited=false
// Assert: snap_enrollment record created with status="active"
// Assert: ebt_account_id set to "NOOP-{enrollment_id}" (NoopEbtAdapter)
// Assert: initial_issuance_date set
// Assert: snap_benefit_issuance record created for first benefit month
// Assert: enrollment.snap_issued event published
}
#[tokio::test]
async fn test_initial_issuance_prorated() {
// Setup: application_date = 15th of a 30-day month, max_allotment = $300
// Assert: first month allotment = $300 * (16/30) = $160.00
// Assert: prorated = true, proration_days_remaining = 16, proration_days_total = 30
}
#[tokio::test]
async fn test_initial_issuance_first_of_month_no_proration() {
// Setup: application_date = 1st of month
// Assert: allotment = full max_allotment
// Assert: prorated = false
}
#[tokio::test]
async fn test_expedited_issuance_within_7_days() {
// Setup: expedited = true, application_date = today
// Assert: initial_issuance_due_date = today + 7
// Assert: issuance created and issued immediately
}
#[tokio::test]
async fn test_continued_benefits_cancels_termination() {
// Setup: enrollment with status="active", scheduled termination
// Publish appeal.continued_benefits_granted event
// Assert: enrollment status remains "active" (termination cancelled)
}
#[tokio::test]
async fn test_decision_reversed_reactivates_enrollment() {
// Setup: enrollment with status="terminated"
// Publish appeal.decision_reversed event
// Assert: enrollment status set back to "active"
}
#[tokio::test]
async fn test_expungement_30_day_notice() {
// Setup: issuance with expiry_date = today + 30, expungement_notice_sent_at = NULL
// Run ExpungementJob::send_expungement_notices()
// Assert: enrollment.expungement_pending event published
// Assert: expungement_notice_sent_at set on issuance record
}
#[tokio::test]
async fn test_expungement_on_expiry_date() {
// Setup: issuance with expiry_date = today, expunged_at = NULL
// Run ExpungementJob::run_expungements()
// Assert: EbtAdapter::get_balance called
// Assert: EbtAdapter::expunge_benefits called
// Assert: expunged_at set, expunged_amount recorded
// Assert: enrollment.benefits_expunged event published
}
#[tokio::test]
async fn test_issuance_history_api() {
// Insert enrollment with 3 issuances (1 prorated, 2 full)
// GET /v1/enrollments/snap/{id}/issuances
// Assert: 3 issuances returned with correct proration details
}
#[tokio::test]
async fn test_pending_issuance_queue() {
// Insert enrollment with initial_issuance_date = NULL, initial_issuance_due_date = yesterday
// GET /v1/enrollments/snap/queue
// Assert: enrollment appears in queue
}
Each test must:
-
Run migrations via
sqlx::migrate!()on the test container -
Use
NoopEbtAdapterfor all EBT interactions -
Use
mock_event_publisherto capture and assert published events -
Verify no benefit amounts, EBT account IDs, or personal data in published events (ADR-004)
Files Touched
| File | Change |
|---|---|
|
New: snap_enrollments, snap_benefit_issuances tables |
|
New: EbtAdapter trait, NoopEbtAdapter, ConduentEbtAdapter stub |
|
New: IssuancePipeline with proration and event-driven issuance |
|
New: ExpungementJob with notice and expunge logic |
|
Replace empty Router::new() with full route set |
|
Enable migrations; wire event subscriber; wire expungement job; wire EBT adapter |
Verification
-
cargo nextest run -p canopy-enrollment— all tests pass -
Expedited household: determination approved → issuance within 7 days,
initial_issuance_dateset -
Non-expedited household, application date = 15th of month: first issuance is prorated (~50% allotment)
-
appeal.continued_benefits_grantedevent → enrollment not terminated despite adverse action effective date passing -
Expungement job: 30-day notice →
enrollment.expungement_pendingevent published -
Expungement job: expiry date reached →
expunged_atset,enrollment.benefits_expungedpublished -
GET /v1/enrollments/snap/queue→ lists enrollments withinitial_issuance_date IS NULLANDinitial_issuance_due_date < today
Documentation Updates
-
.claude/docs/services.md— add snap_enrollments, snap_benefit_issuances tables; EbtAdapter; events -
CHANGELOG.adoc— entry under== Unreleased