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 services/canopy-enrollment/tests/enrollment_test.rs (create_enrollment_returns_201, list_enrollments_returns_array, plus issuance + expungement + idempotency tests) + 11 unit tests in lib code. All devstack-gated via infrastructure_available().

Known Gaps

  • Event-driven enrollment creation: determination.completed.snap subscriber 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_granted handling: Should pause scheduled terminations. Store function update_enrollment_status exists; 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_enrollments table — enrollment record per household/certification period

  • snap_benefit_issuances table — issuance ledger per benefit month

  • EbtAdapter trait with create_account, issue_benefits, get_balance, suspend_account, expunge_benefits

  • NoopEbtAdapter with deterministic responses for UAT

  • ConduentEbtAdapter stub (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_pending event 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:

  1. Look up enrollment record for household; create if not exists

  2. Provision EBT account via EbtAdapter::create_account if ebt_account_id IS NULL

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

  4. Calculate allotment: prorate if first partial month

  5. Call EbtAdapter::issue_benefits

  6. Store issuance record with expiry_date = issued_at.date() + 365 days

  7. Update enrollment: status = 'active', initial_issuance_date = today

  8. Publish enrollment.snap_issued event: { 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):

  1. Find all issuances where expiry_date = today + 30 AND expungement_notice_sent_at IS NULL

  2. Publish enrollment.expungement_pending event → canopy-notices generates ExpungementNotice

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

determination.completed (status=approved)

Create enrollment, provision EBT account, issue initial benefits

determination.completed (status=terminated / status=denied after active)

Set enrollment status = terminated; suspend EBT account

appeal.continued_benefits_granted

Cancel any scheduled termination for the enrollment period; resume issuance

appeal.decision_reversed

Re-activate enrollment if terminated; re-evaluate allotment if determination changed

appeal.overpayment_assessed

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 /v1/enrollments/snap?household_id={id}

Get current SNAP enrollment for household

canopy-worker

GET /v1/enrollments/snap/{id}/issuances

Get issuance history for enrollment

canopy-worker

POST /v1/enrollments/snap/{id}/issue

Worker-initiated issuance (remediation only; system auto-issues monthly)

canopy-snap-supervisor

PUT /v1/enrollments/snap/{id}/suspend

Suspend enrollment (adverse action)

canopy-snap-supervisor

PUT /v1/enrollments/snap/{id}/terminate

Terminate enrollment with reason

canopy-snap-supervisor

GET /v1/enrollments/snap/queue

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 ExpungementJob with the configured EbtAdapter

  • 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-expungement that 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> — where initial_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.events with 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:

  1. Run migrations via sqlx::migrate!() on the test container

  2. Use NoopEbtAdapter for all EBT interactions

  3. Use mock_event_publisher to capture and assert published events

  4. Verify no benefit amounts, EBT account IDs, or personal data in published events (ADR-004)

Files Touched

File Change

services/canopy-enrollment/migrations/YYYYMMDD_snap_enrollments.sql

New: snap_enrollments, snap_benefit_issuances tables

crates/canopy-enrollment/src/ebt.rs

New: EbtAdapter trait, NoopEbtAdapter, ConduentEbtAdapter stub

services/canopy-enrollment/src/issuance.rs

New: IssuancePipeline with proration and event-driven issuance

services/canopy-enrollment/src/expungement.rs

New: ExpungementJob with notice and expunge logic

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

Replace empty Router::new() with full route set

services/canopy-enrollment/src/main.rs

Enable migrations; wire event subscriber; wire expungement job; wire EBT adapter

Verification

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

  2. Expedited household: determination approved → issuance within 7 days, initial_issuance_date set

  3. Non-expedited household, application date = 15th of month: first issuance is prorated (~50% allotment)

  4. appeal.continued_benefits_granted event → enrollment not terminated despite adverse action effective date passing

  5. Expungement job: 30-day notice → enrollment.expungement_pending event published

  6. Expungement job: expiry date reached → expunged_at set, enrollment.benefits_expunged published

  7. GET /v1/enrollments/snap/queue → lists enrollments with initial_issuance_date IS NULL AND initial_issuance_due_date < today

Documentation Updates

  • .claude/docs/services.md — add snap_enrollments, snap_benefit_issuances tables; EbtAdapter; events

  • CHANGELOG.adoc — entry under == Unreleased

Edit this page · default