Plan: SNAP Federal Reporting — FNS-388 and FNS-7176 QC Universe
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Database migration: snap_monthly_reports and snap_qc_universe tables |
Done (2026-04-09) |
2 |
Store layer: models and query functions |
Done (2026-04-09) |
3 |
Internal HTTP clients for upstream services (renewals, persons, applications, enrollment, snap) |
Done (2026-04-09) — |
4 |
FNS-388 report assembly — fetch certifications, issuances, aggregate counts |
Done (2026-04-09) — |
5 |
FNS-388 export |
Done (2026-04-09) — assembly results stored via |
6 |
QC universe snapshot assembly — iterate certifications, fetch per-household data from 5 services |
Done (2026-04-09) — |
7 |
FNS-7176 CSV export |
Done (2026-04-09) — (3 unit tests, correct column format) |
8 |
API endpoints (6 endpoints) |
Done (2026-04-27) — Status drift cleanup: all 6 handlers in |
9 |
Integration tests |
Done (2026-04-09) — 6 integration tests (list reports, generate fns-388, get report 404, generate QC snapshot, RBAC 401/403) |
Epic: &43
Branch: feature/snap-federal-reporting
Context
Federal regulations (7 CFR 272.11) require state SNAP agencies to submit monthly participation and issuance data to FNS using Form FNS-388/388A, due 45 days after the end of each reporting month. Failure to submit timely or accurate reports can trigger FNS corrective action and jeopardize federal funding.
The SNAP Quality Control program (7 CFR Part 275) adds a second reporting obligation: states must maintain a QC case universe from which FNS-selected reviewers draw random samples for in-depth case reviews. The Payment Error Rate (PER) calculated from these reviews determines whether the state faces financial liability under 7 USC §2025(c): states with a PER more than 3 percentage points above the national average are subject to payment error sanctions. FNS regional offices may request a universe pull at any time, so the system must be capable of producing it on demand.
canopy-reporting is the service responsible for all federal reports. It does not own any program data directly; it assembles reporting snapshots by querying the program services' internal APIs. This is by design: ADR-001 program service isolation means canopy-reporting must never query program service databases directly — it queries program service HTTP APIs. The cross-service assembly makes this a read-heavy, latency-tolerant operation that runs on scheduler or admin demand, not in the request path.
This plan depends on:
-
SNAP Eligibility — canopy-snap exposes IEVS match status and ABAWD tracking data
-
Person and Household Data Model — canopy-persons exposes household composition, income, and expense data
-
SNAP Renewals and Certification Period Management — canopy-renewals exposes certification period data
-
canopy-enrollment — exposes benefit issuance records (separate plan)
-
canopy-applications — exposes application metadata (separate plan)
Scope
In scope:
-
snap_monthly_reports and snap_qc_universe database schema (canopy_reporting database, postgres:5432)
-
Internal HTTP clients for all five upstream services
-
FNS-388 aggregate report assembly from upstream service data
-
FNS-388 export in structured format (JSON matching FNS-388 field layout; CSV as secondary format)
-
QC universe snapshot: assembles one row per active SNAP household from upstream APIs
-
FNS-7176 export as CSV matching the FNS column specification (50+ elements per case)
-
API endpoints for report generation, retrieval, and export
-
Row-level data validation: benefit amounts as NUMERIC(10,2)/Decimal, no null benefit amounts on active certifications
Out of scope:
-
Electronic submission to FNS ACM (FNS electronic submission gateway) — that integration is a separate plan requiring FNS credentials and the ACM API contract
-
TANF or Medicaid federal reporting — separate plans
-
Automated monthly scheduling — this plan delivers on-demand generation; scheduling via cron or GitLab pipeline is a follow-on
-
QC case review workflow — FNS selects cases from the universe; the review workflow itself is out of scope
-
FNS-388A (addendum for disaster SNAP) — out of scope until a disaster SNAP program is implemented
Design
Reporting Architecture
canopy-reporting assembles its reports by querying upstream program service APIs over the internal network.
It does not share a database with any program service.
The QC universe assembly is a bulk read operation: for each active SNAP certification in the reporting month, canopy-reporting fetches data from five services and assembles one snap_qc_universe row.
canopy-reporting
│
├─ GET /v1/renewals/snap/certifications (canopy-renewals)
│ → active certifications for the snapshot month
│
├─ GET /v1/persons/households/{id} (canopy-persons)
│ → household composition, income, expenses
│
├─ GET /v1/applications/{id} (canopy-applications)
│ → application metadata, categorical eligibility basis
│
├─ GET /v1/enrollments/snap/issuances (canopy-enrollment)
│ → benefit issuance amounts for the month
│
└─ GET /v1/snap/abawd/{household_id} (canopy-snap)
→ ABAWD tracking status, work registration, IEVS match status
All upstream calls use an internal reqwest::Client with a 30-second timeout.
The QC universe snapshot serializes household-by-household rather than loading all households into memory simultaneously; use a streaming cursor pattern over the certification list.
Data Isolation: Restricted Federal Data
canopy-reporting operates under the same federal data restrictions as all other services.
The snap_qc_universe table contains income and benefit data but does NOT contain:
-
Raw IEVS match results (stores only the boolean
ievs_match_completed) -
SSA SOLQ/BINDEX response data
-
FTI (Federal Tax Information) — SNAP does not use FTI; this constraint applies to TANF and Medicaid reports
The ievs_match_completed boolean is obtained from canopy-snap’s API response, which returns only a status indicator — never the underlying IEVS data (per ADR-004).
Event bus: canopy-reporting publishes no events. It is a pure read service for reporting purposes.
Database Schema
CREATE TABLE snap_monthly_reports (
id UUID PRIMARY KEY,
report_month DATE NOT NULL,
generated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
generated_by UUID,
total_households INTEGER NOT NULL DEFAULT 0,
total_individuals INTEGER NOT NULL DEFAULT 0,
total_benefits_issued NUMERIC(12,2) NOT NULL DEFAULT 0,
expedited_households INTEGER NOT NULL DEFAULT 0,
elderly_disabled_households INTEGER NOT NULL DEFAULT 0,
initial_certifications INTEGER NOT NULL DEFAULT 0,
recertifications INTEGER NOT NULL DEFAULT 0,
average_household_benefit NUMERIC(10,2),
submission_status TEXT NOT NULL DEFAULT 'draft',
submitted_at TIMESTAMPTZ,
fns_confirmation_number TEXT,
report_data JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX idx_snap_monthly_reports_month ON snap_monthly_reports(report_month);
CREATE TABLE snap_qc_universe (
id UUID PRIMARY KEY,
snapshot_date DATE NOT NULL,
household_id UUID NOT NULL,
certification_id UUID NOT NULL,
household_size INTEGER NOT NULL,
head_of_household_age INTEGER,
head_of_household_race TEXT,
head_of_household_ethnicity TEXT,
head_of_household_citizenship TEXT,
cert_start_date DATE NOT NULL,
cert_end_date DATE NOT NULL,
cert_type TEXT NOT NULL,
total_gross_income NUMERIC(10,2),
total_earned_income NUMERIC(10,2),
total_unearned_income NUMERIC(10,2),
earned_income_deduction NUMERIC(10,2),
standard_deduction NUMERIC(10,2),
dependent_care_deduction NUMERIC(10,2),
medical_deduction NUMERIC(10,2),
shelter_deduction NUMERIC(10,2),
child_support_deduction NUMERIC(10,2),
total_deductions NUMERIC(10,2),
net_income NUMERIC(10,2),
benefit_amount NUMERIC(10,2) NOT NULL,
categorical_eligibility TEXT,
expedited_service BOOLEAN NOT NULL DEFAULT false,
abawd_household BOOLEAN NOT NULL DEFAULT false,
work_registration_exempt_count INTEGER NOT NULL DEFAULT 0,
ievs_match_completed BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_snap_qc_universe_snapshot ON snap_qc_universe(snapshot_date);
CREATE INDEX idx_snap_qc_universe_household ON snap_qc_universe(household_id);
CREATE INDEX idx_snap_qc_universe_cert ON snap_qc_universe(certification_id);
CREATE UNIQUE INDEX idx_snap_qc_universe_household_snapshot
ON snap_qc_universe(household_id, snapshot_date);
FNS-388 Report Data Structure
The report_data JSONB column stores the full FNS-388 field layout.
This is the source of truth for the export.
The aggregate scalar columns (total_households, etc.) are denormalized from report_data for query convenience.
{
"state_code": "GA",
"report_month": "2026-03",
"reporting_period_start": "2026-03-01",
"reporting_period_end": "2026-03-31",
"households": {
"total": 142380,
"initial_certifications": 8421,
"recertifications": 7309,
"elderly_disabled": 31450,
"expedited": 3211,
"by_household_size": {
"1": 42100,
"2": 31500,
"3": 24300,
"4": 19800,
"5": 12400,
"6_or_more": 12280
}
},
"individuals": {
"total": 298741
},
"applications": {
"total_received": 12843,
"approved": 9210,
"denied": 2891,
"pending_end_of_month": 742,
"withdrawn": 312,
"denial_reasons": {
"gross_income_exceeded": 1102,
"net_income_exceeded": 421,
"asset_limit_exceeded": 89,
"failure_to_provide_verification": 634,
"failure_to_complete_interview": 287,
"drug_felony_disqualification": 12,
"fleeing_felony_disqualification": 8,
"abawd_time_limit": 201,
"other": 137
}
},
"negative_actions": {
"terminations": 4211,
"suspensions": 312,
"benefit_reductions": 1892,
"abawd_exhaustions": 421
},
"benefits": {
"total_issued_usd": "98432110.00",
"average_per_household_usd": "691.47",
"by_income_source": {
"earned_income_only": 28400,
"unearned_income_only": 71100,
"mixed": 29800,
"no_income": 13080
}
}
}
FNS-7176 QC Universe CSV Column Specification
The export produces a CSV with one header row and one data row per household in the universe. Column ordering must match the FNS specification exactly. Key columns (abbreviated from full 50+ column spec):
| Column # | FNS Field Name | Source |
|---|---|---|
1 |
CASE_ID |
household_id (UUID string) |
2 |
CERT_ID |
certification_id (UUID string) |
3 |
SNAPSHOT_DATE |
snapshot_date |
4 |
HH_SIZE |
household_size |
5 |
CERT_START |
cert_start_date |
6 |
CERT_END |
cert_end_date |
7 |
CERT_TYPE |
cert_type |
8 |
EXPEDITED |
expedited_service (Y/N) |
9 |
GROSS_INCOME |
total_gross_income |
10 |
EARNED_INCOME |
total_earned_income |
11 |
UNEARNED_INCOME |
total_unearned_income |
12 |
EI_DEDUCTION |
earned_income_deduction |
13 |
STD_DEDUCTION |
standard_deduction |
14 |
DEP_CARE_DED |
dependent_care_deduction |
15 |
MED_DED |
medical_deduction |
16 |
SHELTER_DED |
shelter_deduction |
17 |
CS_DED |
child_support_deduction |
18 |
TOTAL_DEDS |
total_deductions |
19 |
NET_INCOME |
net_income |
20 |
BENEFIT_AMT |
benefit_amount |
21 |
CAT_ELIG |
categorical_eligibility |
22 |
ABAWD_HH |
abawd_household (Y/N) |
23 |
WR_EXEMPT_CT |
work_registration_exempt_count |
24 |
IEVS_MATCH |
ievs_match_completed (Y/N) |
25 |
HOH_AGE |
head_of_household_age |
26 |
HOH_RACE |
head_of_household_race |
27 |
HOH_ETHNICITY |
head_of_household_ethnicity |
28 |
HOH_CITIZENSHIP |
head_of_household_citizenship |
API Endpoints
| Method | Path | Description |
|---|---|---|
POST |
/reporting/snap/fns-388 |
Generate FNS-388 for a month. Query param: |
GET |
/reporting/snap/fns-388 |
List FNS-388 reports. |
GET |
/reporting/snap/fns-388/{month} |
Get report by month. Returns 200 with snap_monthly_report object including report_data. |
POST |
/reporting/snap/qc-universe |
Trigger QC universe snapshot for a date. Body: |
GET |
/reporting/snap/qc-universe/{date} |
Get QC universe rows for a snapshot date. |
GET |
/reporting/snap/qc-universe/{date}/csv |
Export QC universe snapshot as CSV matching FNS-7176 spec. |
All error responses use RFC 9457 Problem Details. Create endpoints return HTTP 201; async job endpoints return 202.
CLI Commands (ADR-007)
Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:
-
canopy report snap fns388 generate --month <YYYY-MM>— generate FNS-388 report for a month -
canopy report snap fns388 get <id>— get report by ID -
canopy report snap fns388 export <id>— export FNS-388 as JSON or CSV -
canopy report snap qc-universe snapshot— trigger QC universe snapshot for a date -
canopy report snap qc-universe list --snapshot-date <YYYY-MM-DD>— get QC universe rows (paginated) -
canopy report snap qc-universe export <id>— export QC universe row or full snapshot
Steps
Step 1: Database Migration
Files: services/canopy-reporting/migrations/20260326000001_create_snap_reporting_tables.sql
Create snap_monthly_reports and snap_qc_universe tables using the SQL in the Design section.
All id columns use Uuid::now_v7() at the Rust layer (not gen_random_uuid() in SQL) to preserve sortability and consistent UUID v7 policy across the codebase.
The migration creates tables only; IDs are always generated in the service layer.
After applying the migration, verify the unique index on (report_month) prevents duplicate report generation for the same month:
-- Verify unique constraint
INSERT INTO snap_monthly_reports (id, report_month, report_data) VALUES (gen_random_uuid(), '2026-03-01', '{}');
INSERT INTO snap_monthly_reports (id, report_month, report_data) VALUES (gen_random_uuid(), '2026-03-01', '{}');
-- Second insert should fail with unique constraint violation.
Run with sqlx migrate run on the canopy_reporting database.
Step 2: Store Layer
Files: services/canopy-reporting/src/store/mod.rs, services/canopy-reporting/src/store/models.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/store/models.rs
use chrono::{DateTime, NaiveDate, Utc};
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapMonthlyReport {
pub id: Uuid,
pub report_month: NaiveDate,
pub generated_at: DateTime<Utc>,
pub generated_by: Option<Uuid>,
pub total_households: i32,
pub total_individuals: i32,
pub total_benefits_issued: Decimal,
pub expedited_households: i32,
pub elderly_disabled_households: i32,
pub initial_certifications: i32,
pub recertifications: i32,
pub average_household_benefit: Option<Decimal>,
pub submission_status: String,
pub submitted_at: Option<DateTime<Utc>>,
pub fns_confirmation_number: Option<String>,
pub report_data: serde_json::Value,
pub created_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
pub struct SnapQcUniverseRow {
pub id: Uuid,
pub snapshot_date: NaiveDate,
pub household_id: Uuid,
pub certification_id: Uuid,
pub household_size: i32,
pub head_of_household_age: Option<i32>,
pub head_of_household_race: Option<String>,
pub head_of_household_ethnicity: Option<String>,
pub head_of_household_citizenship: Option<String>,
pub cert_start_date: NaiveDate,
pub cert_end_date: NaiveDate,
pub cert_type: String,
pub total_gross_income: Option<Decimal>,
pub total_earned_income: Option<Decimal>,
pub total_unearned_income: Option<Decimal>,
pub earned_income_deduction: Option<Decimal>,
pub standard_deduction: Option<Decimal>,
pub dependent_care_deduction: Option<Decimal>,
pub medical_deduction: Option<Decimal>,
pub shelter_deduction: Option<Decimal>,
pub child_support_deduction: Option<Decimal>,
pub total_deductions: Option<Decimal>,
pub net_income: Option<Decimal>,
pub benefit_amount: Decimal,
pub categorical_eligibility: Option<String>,
pub expedited_service: bool,
pub abawd_household: bool,
pub work_registration_exempt_count: i32,
pub ievs_match_completed: bool,
pub created_at: DateTime<Utc>,
}
Query functions:
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/store/mod.rs
pub mod models;
use models::{SnapMonthlyReport, SnapQcUniverseRow};
use sqlx::PgPool;
use uuid::Uuid;
use chrono::NaiveDate;
pub struct PageRequest {
pub offset: i64,
pub limit: i64,
}
pub async fn create_monthly_report(
pool: &PgPool,
report: &SnapMonthlyReport,
) -> Result<SnapMonthlyReport, sqlx::Error> {
sqlx::query_as::<_, SnapMonthlyReport>(
r#"INSERT INTO snap_monthly_reports
(id, report_month, generated_by, total_households, total_individuals,
total_benefits_issued, expedited_households, elderly_disabled_households,
initial_certifications, recertifications, average_household_benefit,
submission_status, report_data)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *"#,
)
.bind(report.id)
.bind(report.report_month)
.bind(report.generated_by)
.bind(report.total_households)
.bind(report.total_individuals)
.bind(report.total_benefits_issued)
.bind(report.expedited_households)
.bind(report.elderly_disabled_households)
.bind(report.initial_certifications)
.bind(report.recertifications)
.bind(report.average_household_benefit)
.bind(&report.submission_status)
.bind(&report.report_data)
.fetch_one(pool)
.await
}
pub async fn get_monthly_report(
pool: &PgPool,
id: Uuid,
) -> Result<Option<SnapMonthlyReport>, sqlx::Error> {
sqlx::query_as::<_, SnapMonthlyReport>(
"SELECT * FROM snap_monthly_reports WHERE id = $1",
)
.bind(id)
.fetch_optional(pool)
.await
}
pub async fn get_monthly_report_by_month(
pool: &PgPool,
report_month: NaiveDate,
) -> Result<Option<SnapMonthlyReport>, sqlx::Error> {
sqlx::query_as::<_, SnapMonthlyReport>(
"SELECT * FROM snap_monthly_reports WHERE report_month = $1",
)
.bind(report_month)
.fetch_optional(pool)
.await
}
pub async fn insert_qc_universe_row(
pool: &PgPool,
row: &SnapQcUniverseRow,
) -> Result<SnapQcUniverseRow, sqlx::Error> {
sqlx::query_as::<_, SnapQcUniverseRow>(
r#"INSERT INTO snap_qc_universe
(id, snapshot_date, household_id, certification_id,
household_size, head_of_household_age, head_of_household_race,
head_of_household_ethnicity, head_of_household_citizenship,
cert_start_date, cert_end_date, cert_type,
total_gross_income, total_earned_income, total_unearned_income,
earned_income_deduction, standard_deduction, dependent_care_deduction,
medical_deduction, shelter_deduction, child_support_deduction,
total_deductions, net_income, benefit_amount,
categorical_eligibility, expedited_service,
abawd_household, work_registration_exempt_count, ievs_match_completed)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,
$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$29)
ON CONFLICT (household_id, snapshot_date) DO UPDATE
SET benefit_amount = EXCLUDED.benefit_amount,
total_gross_income = EXCLUDED.total_gross_income,
net_income = EXCLUDED.net_income,
ievs_match_completed = EXCLUDED.ievs_match_completed
RETURNING *"#,
)
// ... bind all 29 parameters in order
.fetch_one(pool)
.await
}
pub async fn list_qc_universe(
pool: &PgPool,
snapshot_date: NaiveDate,
page: &PageRequest,
) -> Result<Vec<SnapQcUniverseRow>, sqlx::Error> {
sqlx::query_as::<_, SnapQcUniverseRow>(
"SELECT * FROM snap_qc_universe WHERE snapshot_date = $1 ORDER BY household_id LIMIT $2 OFFSET $3",
)
.bind(snapshot_date)
.bind(page.limit)
.bind(page.offset)
.fetch_all(pool)
.await
}
Step 3: Internal HTTP Clients
Files: services/canopy-reporting/src/clients/mod.rs, and one file per upstream service: renewals_client.rs, persons_client.rs, applications_client.rs, enrollment_client.rs, snap_client.rs
Each client is a thin wrapper around a shared reqwest::Client with the service’s base URL configured from environment variables.
All clients return typed response structs; use #[derive(Deserialize)] on response types.
All clients have a 30-second timeout.
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/clients/mod.rs
pub mod applications_client;
pub mod enrollment_client;
pub mod persons_client;
pub mod renewals_client;
pub mod snap_client;
use reqwest::Client;
pub struct ServiceClients {
pub renewals: renewals_client::RenewalsClient,
pub persons: persons_client::PersonsClient,
pub applications: applications_client::ApplicationsClient,
pub enrollment: enrollment_client::EnrollmentClient,
pub snap: snap_client::SnapClient,
}
impl ServiceClients {
pub fn from_env() -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("failed to build HTTP client");
// Ports match docker-compose.yml. Env var names follow CANOPY_REPORTING__ prefix convention.
Self {
renewals: renewals_client::RenewalsClient::new(
client.clone(),
std::env::var("CANOPY_REPORTING__RENEWALS_URL").unwrap_or_else(|_| "http://localhost:8007".into()),
),
persons: persons_client::PersonsClient::new(
client.clone(),
std::env::var("CANOPY_REPORTING__PERSONS_URL").unwrap_or_else(|_| "http://localhost:8002".into()),
),
applications: applications_client::ApplicationsClient::new(
client.clone(),
std::env::var("CANOPY_REPORTING__APPLICATIONS_URL").unwrap_or_else(|_| "http://localhost:8003".into()),
),
enrollment: enrollment_client::EnrollmentClient::new(
client.clone(),
std::env::var("CANOPY_REPORTING__ENROLLMENT_URL").unwrap_or_else(|_| "http://localhost:8006".into()),
),
snap: snap_client::SnapClient::new(
client,
std::env::var("CANOPY_REPORTING__SNAP_URL").unwrap_or_else(|_| "http://localhost:8013".into()),
),
}
}
}
Each client method returns Result<T, ApiError> where ApiError::Upstream carries the service name and status code.
404 from an upstream service maps to ApiError::Upstream with a note that the record was not found (the assembly layer treats this as a missing record, not a fatal error).
Connection errors and 5xx responses are retried once after a 2-second delay.
Key methods required per client (response types match actual service domain structs):
-
renewals_client:-
list_certifications_due(days: i64) → Vec<SnapCertification>— callsGET /renewals/snap/due?days={days}. Returns certifications ending withindays. For a monthly report, passdays=0for current month or compute the lookahead. TheSnapCertificationstruct includeshousehold_id,application_id,certification_type,certification_start_date,certification_end_date,status. No direct month filter — the reporting assembly must filtercertification_end_datewithin the reporting month client-side.
-
-
persons_client:-
get_household(household_id: HouseholdId) → HouseholdWithMembers— callsGET /households/{id}. ReturnsHouseholdwith flattenedmembers: Vec<HouseholdMember>(each hasperson_id,relationship). Usemembers.len()for household size. -
get_person_income(person_id: PersonId) → Vec<Income>— callsGET /persons/{id}/income. Returns income records withsource,amount,frequency.
-
-
applications_client:-
get_application(application_id: ApplicationId) → Application— callsGET /applications/{id}. ReturnsApplicationwithexpedited_eligible: Option<bool>(notexpedited_service),programs_requested,status. Nois_initial_certificationfield — infer initial vs recertification from whetherApplicationProgram.statusis"initial"or"recertification". If the data is not available, count all as initial (acceptable for UAT).
-
-
enrollment_client:-
list_enrollments(household_id: HouseholdId) → Vec<SnapEnrollment>— callsGET /enrollments?household_id={id}. Returns enrollments withmax_monthly_allotment,expedited,status. -
list_issuances(enrollment_id: EnrollmentId) → Vec<SnapBenefitIssuance>— callsGET /enrollments/{id}/issuances. Returns issuances withbenefit_month: NaiveDate,allotment_amount: Decimal,issuance_status. Filter bybenefit_monthclient-side for the reporting month.
-
-
snap_client:-
get_abawd_tracking(household_id: HouseholdId) → Vec<AbawdTracking>— callsGET /abawd/tracking?household_id={id}. Returns tracking records withcurrent_status,months_used,exemption_type. Usecurrent_status == "tracking" || current_status == "exhausted"to determine ABAWD household flag.
-
Wiring into main.rs
In services/canopy-reporting/src/main.rs, after bootstrap:
let reporting_clients = std::sync::Arc::new(clients::ServiceClients::from_env());
// ... router setup ...
let router = router
.layer(axum::Extension(reporting_clients))
.layer(axum::Extension(boot.mq_health));
The generate_fns_388() and generate_qc_snapshot() API handlers extract Extension(clients): Extension<Arc<ServiceClients>> and pass to the assembly functions.
Step 4: FNS-388 Report Assembly
Files: services/canopy-reporting/src/reports/fns388.rs
The generate_fns388 function fetches data from all upstream services for a given month and assembles the aggregate counts:
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/reports/fns388.rs
use chrono::NaiveDate;
use rust_decimal::Decimal;
use uuid::Uuid;
use crate::clients::ServiceClients;
use crate::errors::ApiError;
use crate::store::models::SnapMonthlyReport;
pub struct Fns388Counts {
pub total_households: i32,
pub total_individuals: i32,
pub total_benefits_issued: Decimal,
pub expedited_households: i32,
pub elderly_disabled_households: i32,
pub initial_certifications: i32,
pub recertifications: i32,
}
pub async fn generate_fns388(
clients: &ServiceClients,
report_month: NaiveDate,
generated_by: Option<Uuid>,
) -> Result<SnapMonthlyReport, ApiError> {
// 1. Fetch all active certifications for the month from canopy-renewals.
let certifications = clients.renewals
.get_active_certifications_for_month(report_month)
.await?;
// 2. Fetch issuance totals for the month from canopy-enrollment.
let issuances = clients.enrollment
.get_snap_issuances_for_month(report_month)
.await?;
// Build a lookup map: household_id → issuance amount.
let issuance_map: std::collections::HashMap<Uuid, Decimal> = issuances
.into_iter()
.map(|i| (i.household_id, i.benefit_amount))
.collect();
// 3. Tally aggregate counts.
let mut counts = Fns388Counts {
total_households: 0,
total_individuals: 0,
total_benefits_issued: Decimal::ZERO,
expedited_households: 0,
elderly_disabled_households: 0,
initial_certifications: 0,
recertifications: 0,
};
for cert in &certifications {
counts.total_households += 1;
let household = clients.persons.get_household(cert.household_id).await?;
counts.total_individuals += household.members.len() as i32;
if let Some(&amount) = issuance_map.get(&cert.household_id) {
counts.total_benefits_issued += amount;
}
if cert.certification_type == "elderly_disabled" {
counts.elderly_disabled_households += 1;
}
// Expedited flag and initial/recertification come from application metadata.
let app = clients.applications.get_application(cert.application_id).await?;
if app.expedited_eligible.unwrap_or(false) {
counts.expedited_households += 1;
}
// Infer initial vs recertification: if cert_start_date matches the original
// application received_at month, it's initial; otherwise recertification.
// This is an approximation — a dedicated field would be more reliable.
if cert.certification_start_date.year() == app.received_at.year()
&& cert.certification_start_date.month() == app.received_at.month() {
counts.initial_certifications += 1;
} else {
counts.recertifications += 1;
}
}
let avg = if counts.total_households > 0 {
Some(counts.total_benefits_issued / Decimal::from(counts.total_households))
} else {
None
};
let report_data = build_report_data_json(report_month, &counts, avg);
Ok(SnapMonthlyReport {
id: Uuid::now_v7(),
report_month,
generated_at: chrono::Utc::now(),
generated_by,
total_households: counts.total_households,
total_individuals: counts.total_individuals,
total_benefits_issued: counts.total_benefits_issued,
expedited_households: counts.expedited_households,
elderly_disabled_households: counts.elderly_disabled_households,
initial_certifications: counts.initial_certifications,
recertifications: counts.recertifications,
average_household_benefit: avg,
submission_status: "draft".to_string(),
submitted_at: None,
fns_confirmation_number: None,
report_data,
created_at: chrono::Utc::now(),
})
}
The build_report_data_json function constructs the JSONB structure from the Design section.
Income source breakdown (earned only, unearned only, mixed, no income) requires fetching income records from canopy-persons for each household; this is done in the same certification loop to avoid a second pass.
Use rust_decimal::Decimal for all monetary arithmetic.
Never use f64 for benefit amounts.
Round averages to 2 decimal places using decimal.round_dp(2).
Step 5: FNS-388 Export
Files: services/canopy-reporting/src/export/fns388_export.rs
Two export formats:
-
JSON: serialize
report_dataJSONB value directly.Content-Type: application/json. -
CSV: flatten the FNS-388 aggregate fields into a two-column key/value CSV.
Content-Type: text/csv; charset=utf-8.Content-Disposition: attachment; filename="fns388-{YYYY-MM}.csv".
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/export/fns388_export.rs
use axum::response::Response;
use axum::http::{header, HeaderValue, StatusCode};
use axum::body::Body;
use crate::store::models::SnapMonthlyReport;
pub fn export_json(report: &SnapMonthlyReport) -> Response {
let body = serde_json::to_string_pretty(&report.report_data)
.unwrap_or_else(|_| "{}".to_string());
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(body))
.expect("failed to build response")
}
pub fn export_csv(report: &SnapMonthlyReport) -> Response {
let filename = format!(
"fns388-{}.csv",
report.report_month.format("%Y-%m")
);
let csv = build_fns388_csv(report);
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
.header(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
.expect("valid header value"),
)
.body(Body::from(csv))
.expect("failed to build response")
}
Step 6: QC Universe Snapshot Assembly
Files: services/canopy-reporting/src/reports/qc_universe.rs
The snapshot job assembles one snap_qc_universe row per active certified household for the given snapshot date.
It is invoked asynchronously (returns 202 Accepted) and runs in a tokio::task::spawn_blocking-wrapped async task.
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/reports/qc_universe.rs
pub async fn assemble_qc_snapshot(
pool: PgPool,
clients: Arc<ServiceClients>,
snapshot_date: NaiveDate,
) -> Result<u32, ApiError> {
// Get all active certifications as of snapshot_date.
let certifications = clients.renewals
.get_active_certifications_for_month(snapshot_date)
.await?;
let mut count = 0u32;
for cert in certifications {
match assemble_qc_row(&pool, &clients, snapshot_date, &cert).await {
Ok(()) => count += 1,
Err(e) => {
// Log and continue — a single missing household should not abort the whole snapshot.
tracing::warn!(
household_id = %cert.household_id,
error = %e,
"failed to assemble QC row; skipping"
);
}
}
}
tracing::info!(snapshot_date = %snapshot_date, rows_assembled = count, "QC universe snapshot complete");
Ok(count)
}
async fn assemble_qc_row(
pool: &PgPool,
clients: &ServiceClients,
snapshot_date: NaiveDate,
cert: &CertificationSummary,
) -> Result<(), ApiError> {
let household = clients.persons.get_household(cert.household_id).await?;
let app = clients.applications.get_application(cert.application_id).await?;
let issuance = clients.enrollment
.get_snap_issuance(cert.household_id, snapshot_date)
.await?;
let abawd = clients.snap.get_abawd_status(cert.household_id).await?;
// Compute deductions from expense records returned by canopy-persons.
let deductions = compute_deductions(&household);
let gross = sum_gross_income(&household);
let earned = sum_earned_income(&household);
let unearned = gross - earned;
let net = gross - deductions.total;
let hoh = household.members.iter().find(|m| m.is_head_of_household);
let row = SnapQcUniverseRow {
id: Uuid::now_v7(),
snapshot_date,
household_id: cert.household_id,
certification_id: cert.certification_id,
household_size: household.members.len() as i32,
head_of_household_age: hoh.map(|m| m.age),
head_of_household_race: hoh.and_then(|m| m.race.clone()),
head_of_household_ethnicity: hoh.and_then(|m| m.ethnicity.clone()),
head_of_household_citizenship: hoh.and_then(|m| m.citizenship_status.clone()),
cert_start_date: cert.cert_start_date,
cert_end_date: cert.cert_end_date,
cert_type: cert.cert_type.clone(),
total_gross_income: Some(gross),
total_earned_income: Some(earned),
total_unearned_income: Some(unearned),
earned_income_deduction: Some(deductions.earned_income),
standard_deduction: Some(deductions.standard),
dependent_care_deduction: Some(deductions.dependent_care),
medical_deduction: Some(deductions.medical),
shelter_deduction: Some(deductions.shelter),
child_support_deduction: Some(deductions.child_support),
total_deductions: Some(deductions.total),
net_income: Some(net),
benefit_amount: issuance.benefit_amount,
categorical_eligibility: app.categorical_eligibility_basis.clone(),
expedited_service: app.expedited_service,
abawd_household: abawd.is_abawd_household,
work_registration_exempt_count: abawd.work_registration_exempt_count,
ievs_match_completed: abawd.ievs_match_completed,
created_at: chrono::Utc::now(),
};
store::insert_qc_universe_row(pool, &row).await
.map_err(|e| ApiError::Internal(format!("store error: {e}")))?;
Ok(())
}
The compute_deductions function applies SNAP deduction rules to the expense records from canopy-persons:
-
Earned income deduction: 20% of gross earned income
-
Standard deduction: lookup by household size (loaded from a config table)
-
Dependent care deduction: from actual declared expenses, capped to earned income amount
-
Medical deduction: from declared medical expenses for elderly/disabled members, amount above $35/month
-
Shelter deduction: from declared shelter costs, amount above 50% of net income after other deductions; capped unless household has elderly/disabled member
-
Child support deduction: from declared child support payments
Use rust_decimal::Decimal for all deduction math.
Round all deduction amounts to 2 decimal places.
Step 7: FNS-7176 CSV Export
Files: services/canopy-reporting/src/export/fns7176_export.rs
Produce a CSV using the csv crate.
Column ordering must exactly match the FNS-7176 specification table in the Design section.
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/export/fns7176_export.rs
use crate::store::models::SnapQcUniverseRow;
use axum::response::Response;
use axum::http::{header, HeaderValue, StatusCode};
use axum::body::Body;
pub fn export_qc_universe_csv(
rows: Vec<SnapQcUniverseRow>,
snapshot_date: chrono::NaiveDate,
) -> Response {
let mut wtr = csv::Writer::from_writer(vec![]);
// Write header row — must match FNS-7176 column names exactly.
wtr.write_record(&[
"CASE_ID", "CERT_ID", "SNAPSHOT_DATE", "HH_SIZE",
"CERT_START", "CERT_END", "CERT_TYPE", "EXPEDITED",
"GROSS_INCOME", "EARNED_INCOME", "UNEARNED_INCOME",
"EI_DEDUCTION", "STD_DEDUCTION", "DEP_CARE_DED",
"MED_DED", "SHELTER_DED", "CS_DED", "TOTAL_DEDS",
"NET_INCOME", "BENEFIT_AMT", "CAT_ELIG",
"ABAWD_HH", "WR_EXEMPT_CT", "IEVS_MATCH",
"HOH_AGE", "HOH_RACE", "HOH_ETHNICITY", "HOH_CITIZENSHIP",
]).expect("csv write error");
for row in &rows {
wtr.write_record(&[
row.household_id.to_string(),
row.certification_id.to_string(),
row.snapshot_date.to_string(),
row.household_size.to_string(),
row.cert_start_date.to_string(),
row.cert_end_date.to_string(),
row.cert_type.clone(),
if row.expedited_service { "Y".into() } else { "N".into() },
decimal_or_empty(row.total_gross_income),
decimal_or_empty(row.total_earned_income),
decimal_or_empty(row.total_unearned_income),
decimal_or_empty(row.earned_income_deduction),
decimal_or_empty(row.standard_deduction),
decimal_or_empty(row.dependent_care_deduction),
decimal_or_empty(row.medical_deduction),
decimal_or_empty(row.shelter_deduction),
decimal_or_empty(row.child_support_deduction),
decimal_or_empty(row.total_deductions),
decimal_or_empty(row.net_income),
row.benefit_amount.to_string(),
row.categorical_eligibility.clone().unwrap_or_default(),
if row.abawd_household { "Y".into() } else { "N".into() },
row.work_registration_exempt_count.to_string(),
if row.ievs_match_completed { "Y".into() } else { "N".into() },
row.head_of_household_age.map(|a| a.to_string()).unwrap_or_default(),
row.head_of_household_race.clone().unwrap_or_default(),
row.head_of_household_ethnicity.clone().unwrap_or_default(),
row.head_of_household_citizenship.clone().unwrap_or_default(),
]).expect("csv write error");
}
let csv_bytes = wtr.into_inner().expect("csv flush error");
let filename = format!("fns7176-qc-universe-{snapshot_date}.csv");
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, "text/csv; charset=utf-8")
.header(
header::CONTENT_DISPOSITION,
HeaderValue::from_str(&format!("attachment; filename=\"{filename}\""))
.expect("valid header value"),
)
.body(Body::from(csv_bytes))
.expect("failed to build response")
}
fn decimal_or_empty(value: Option<rust_decimal::Decimal>) -> String {
value.map(|d| d.to_string()).unwrap_or_default()
}
Step 8: API Handlers
Files: services/canopy-reporting/src/handlers/snap_reporting.rs, services/canopy-reporting/src/router.rs
// SPDX-License-Identifier: AGPL-3.0-or-later
// services/canopy-reporting/src/handlers/snap_reporting.rs
/// POST /reporting/snap/fns-388?month=YYYY-MM
pub async fn generate_fns388(
State(state): State<AppState>,
Query(params): Query<MonthQuery>,
) -> Result<(StatusCode, Json<SnapMonthlyReport>), ApiError> {
let report_month = parse_report_month(¶ms.month)?;
// Idempotency: if a submitted/accepted report already exists, return 409.
if let Some(existing) = store::get_monthly_report_by_month(&state.pool, report_month).await
.map_err(|e| ApiError::Internal(format!("{e}")))?
{
if existing.submission_status != "draft" {
return Err(ApiError::Conflict(
"a submitted or accepted report already exists for this month".into()
));
}
// Draft reports may be regenerated — delete the old one first.
store::delete_monthly_report(&state.pool, existing.id).await
.map_err(|e| ApiError::Internal(format!("{e}")))?;
}
let report = fns388::generate_fns388(&state.clients, report_month, None).await?;
let saved = store::create_monthly_report(&state.pool, &report).await
.map_err(|e| ApiError::Internal(format!("{e}")))?;
Ok((StatusCode::CREATED, Json(saved)))
}
/// POST /reporting/snap/qc-universe
pub async fn trigger_qc_snapshot(
State(state): State<AppState>,
Json(body): Json<QcSnapshotRequest>,
) -> Result<(StatusCode, Json<QcSnapshotAccepted>), ApiError> {
let snapshot_date = body.snapshot_date;
// Spawn the assembly as a background task; return 202 Accepted.
let pool = state.pool.clone();
let clients = state.clients.clone();
tokio::spawn(async move {
if let Err(e) = qc_universe::assemble_qc_snapshot(pool, clients, snapshot_date).await {
tracing::error!(error = %e, snapshot_date = %snapshot_date, "QC snapshot failed");
}
});
Ok((StatusCode::ACCEPTED, Json(QcSnapshotAccepted {
snapshot_date,
message: "QC universe snapshot queued".to_string(),
})))
}
Wire routes in router.rs:
pub fn router(state: AppState) -> Router {
Router::new()
.route("/reporting/snap/fns-388", post(snap_reporting::generate_fns_388))
.route("/reporting/snap/fns-388", get(snap_reporting::list_reports))
.route("/reporting/snap/fns-388/{month}", get(snap_reporting::get_report))
.route("/reporting/snap/qc-universe", post(snap_reporting::generate_qc_snapshot))
.route("/reporting/snap/qc-universe/{date}", get(snap_reporting::get_qc_universe))
.route("/reporting/snap/qc-universe/{date}/csv", get(snap_reporting::export_qc_csv))
.route("/healthz", get(health::healthz))
.route("/metrics", get(metrics::metrics))
.with_state(state)
}
Step 9: Integration Tests
Files: services/canopy-reporting/tests/fns388.rs, services/canopy-reporting/tests/qc_universe.rs
Use testcontainers-rs for PostgreSQL.
Use wiremock to mock all five upstream service clients.
This avoids spinning up the full devstack for unit/integration tests.
// fns388.rs
#[tokio::test]
async fn test_generate_fns388_aggregates_correctly() {
// Mock canopy-renewals: return 3 certifications (2 standard, 1 elderly_disabled)
// Mock canopy-enrollment: return benefit amounts for each household
// Mock canopy-persons: return household sizes
// Mock canopy-applications: 2 initial, 1 recertification; 1 expedited
// Assert: generated report has total_households=3, elderly_disabled=1, initial=2, recerts=1, expedited=1
}
#[tokio::test]
async fn test_generate_fns388_duplicate_non_draft_returns_409() {
// Insert a report with submission_status='submitted' for the same month.
// Call generate_fns388 for the same month.
// Assert: returns 409 Conflict.
}
#[tokio::test]
async fn test_generate_fns388_duplicate_draft_regenerates() {
// Insert a draft report.
// Call generate again.
// Assert: old draft deleted, new report created.
}
#[tokio::test]
async fn test_fns388_csv_export_format() {
// Generate a report; call export with format=csv.
// Assert: Content-Type is text/csv, Content-Disposition has filename.
// Assert: CSV is parseable; header row matches expected columns.
}
// qc_universe.rs
#[tokio::test]
async fn test_qc_snapshot_assembles_all_active_certifications() {
// Mock all 5 upstream services with 10 households.
// Trigger snapshot.
// Assert: 10 rows in snap_qc_universe.
}
#[tokio::test]
async fn test_qc_snapshot_skips_and_logs_on_upstream_404() {
// Mock canopy-persons to return 404 for one household.
// Trigger snapshot.
// Assert: 9 rows assembled (not 10); no panic.
}
#[tokio::test]
async fn test_fns7176_csv_column_order_and_headers() {
// Insert a known snap_qc_universe row.
// Call export endpoint.
// Parse CSV; assert header columns match FNS-7176 spec order.
// Assert benefit_amount value matches inserted row.
}
#[tokio::test]
async fn test_qc_aggregate_matches_fns388_total() {
// Generate FNS-388 for March 2026.
// Generate QC universe for a date in March 2026.
// Assert: COUNT(*) in snap_qc_universe for snapshot_date = total_households in snap_monthly_reports.
}
The final test (test_qc_aggregate_matches_fns388_total) is the UAT validation condition: FNS-388 aggregate counts must match the QC universe row count for the same month.
Files Touched
| File | Change |
|---|---|
|
New: snap_monthly_reports, snap_qc_universe tables with indexes |
|
New: SnapMonthlyReport and SnapQcUniverseRow structs |
|
New: query functions for both tables |
|
New: ServiceClients aggregate and shared reqwest::Client setup |
|
New: RenewalsClient with get_active_certifications_for_month |
|
New: PersonsClient with get_household |
|
New: ApplicationsClient with get_application |
|
New: EnrollmentClient with get_snap_issuances_for_month and get_snap_issuance |
|
New: SnapClient with get_abawd_status |
|
New: generate_fns388, build_report_data_json, aggregate count logic |
|
New: assemble_qc_snapshot, assemble_qc_row, compute_deductions |
|
New: export_json, export_csv for FNS-388 |
|
New: export_qc_universe_csv with FNS-7176 column spec |
|
New: all API handlers |
|
Updated: wire all new reporting routes |
|
Updated: initialize ServiceClients, wire AppState |
|
Add: csv crate, wiremock (dev-dep) |
|
New: integration tests for FNS-388 generation and export |
|
New: integration tests for QC snapshot and FNS-7176 export |
Verification
-
cargo nextest run --workspace --lib— unit tests pass (deduction calculations, decimal arithmetic, CSV column ordering) -
cargo xtask dev start— devstack running -
Migrations applied:
snap_monthly_reportsandsnap_qc_universetables exist in canopy_reporting database -
POST /reporting/snap/fns-388?month=2026-03— returns 201 with report;submission_status = "draft" -
GET /reporting/snap/fns-388/{month}— returns valid JSON matching FNS-388 structure -
GET /reporting/snap/qc-universe/{date}/csv— response hasContent-Type: text/csvand valid CSV with key/value rows -
POST /reporting/snap/qc-universewith{ "snapshot_date": "2026-03-31" }— returns 202 Accepted -
After background task completes,
GET /reporting/snap/qc-universe/2026-03-31returns rows -
GET /reporting/snap/qc-universe/2026-03-31/csv— response is valid CSV with 28-column header matching FNS-7176 spec -
Cross-check: COUNT of QC universe rows for snapshot_date equals
total_householdsin the FNS-388 for the same month -
Attempt second
POST /reporting/snap/fns-388?month=2026-03after marking reportsubmitted— returns 409 Conflict with RFC 9457 Problem Details body -
cargo nextest run -p canopy-reporting— all integration tests pass including thetest_qc_aggregate_matches_fns388_totalUAT gate
Documentation Updates
-
.claude/docs/services.md— update canopy-reporting row: endpoint tables, upstream service dependencies, table list; add canopy-reporting’s cross-service query pattern note -
CHANGELOG.adoc— entry under== Unreleased: "Add SNAP FNS-388 monthly report assembly and FNS-7176 QC universe snapshot with CSV export" -
docs/modules/ROOT/pages/architecture.adoc— add canopy-reporting to architecture diagram with cross-service query arrows -
docs/modules/ROOT/pages/compliance.adoc— note FNS-388 and FNS-7176 undercompliance::fnsandfederal-partner::fns