Plan: ACF-196 Expenditures Pipeline (Issue #378)

On this page

Status

Step Description Status

1

canopy-tanf schema. New migration services/canopy-tanf/migrations/20260506000001_create_tanf_expenditures.sql adding tanf_expenditures(id UUID PK, category TEXT NOT NULL, amount_cents BIGINT NOT NULL, fiscal_quarter TEXT NOT NULL, recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), recorded_by UUID, supporting_doc_url TEXT). Index on (fiscal_quarter, category). Forward-only per ADR-016.

Not started

2

canopy-tanf store + API. New services/canopy-tanf/src/store/expenditures.rs (record, list_by_quarter(fy_q), list_by_category, total_for_quarter). New services/canopy-tanf/src/api/expenditures.rs exposing POST /v1/tanf/expenditures (record), GET /v1/tanf/expenditures?fy=…&q=…, GET /v1/tanf/expenditures/totals?fy=…&q=…. Register routes in canopy-tanf’s API module.

Not started

3

canopy-reporting acf196_snapshot schema. New migration in canopy-reporting adding acf196_snapshot(id UUID PK, fiscal_quarter TEXT NOT NULL, generated_at TIMESTAMPTZ NOT NULL DEFAULT now(), category_totals JSONB NOT NULL, exported_csv_url TEXT, UNIQUE (fiscal_quarter)). Holds the 19 ACF-196 column totals per category snapshot at the time of generation.

Not started

4

canopy-reporting reader + generator. Extend services/canopy-reporting/src/reporting/tanf.rs:413 with generate_acf196_csv(client: &TanfClient, fy_q: &str) → Result<String>. Reads expenditures from canopy-tanf via existing client (mirror the generate_acf199_csv pattern at tanf.rs:22-26,413), aggregates per ACF-196 category, emits 19 columns per row.

Not started

5

canopy-reporting endpoint. New GET /v1/reporting/acf-196?fy=2026q3 returning the CSV (Content-Type text/csv). Persist a snapshot row at generation time so subsequent identical requests return the same byte stream. Add #[utoipa::path] decorator + register in canopy-reporting’s ApiDoc.

Not started

6

Tests + docs. 5 unit tests in services/canopy-reporting/src/reporting/tanf.rs covering column mapping (zero rows; one category populated; all 19 categories populated; quarter boundary; corrupt category). 1 devstack integration test at services/canopy-reporting/tests/acf196_test.rs. CHANGELOG entry under === Added. Update the Service Catalog canopy-reporting + canopy-tanf entries. Plan archives.

Not started

Issue: #378
Branch: feat/acf-196-expenditures-pipeline
Labels: type::feature, priority::medium, service::reporting, service::tanf, program::tanf, federal-partner::acf, workflow::ready

Context

services/canopy-reporting/src/reporting/tanf.rs ships ACF-199 enriched (work hours, sanctions, time limits) and a stub for ACF-196 (the financial expenditures report). ACF requires quarterly ACF-196 submission covering 19 expenditure categories — basic assistance, work activities, refundable EITC, etc. Today the ACF-196 path is empty.

The canonical TANF expenditure data does not yet exist anywhere in canopy: there is no tanf_expenditures table (verified during meta-plan authoring). This plan adds the table, ingest path, and the ACF-196 generator on top.

The ingest source is out of band: financial systems push expenditures to canopy-tanf via the POST /v1/tanf/expenditures endpoint as they’re recorded. The endpoint is the integration point; building the upstream financial-system bridge is out of scope.

Code references

  • services/canopy-reporting/src/reporting/tanf.rs:22-26 — ACF-199 fetches from canopy-tanf, the precedent for ACF-196.

  • services/canopy-reporting/src/reporting/tanf.rs:413generate_acf199_csv location to extend.

  • services/canopy-tanf/migrations/ — existing migrations directory.

  • ADR-016 — Forward-only migrations

Scope

In scope:

  • tanf_expenditures table + CRUD endpoints in canopy-tanf.

  • acf196_snapshot table in canopy-reporting.

  • CSV generator + endpoint.

  • Unit + integration tests.

Out of scope:

  • Upstream financial-system bridge (where the expenditure rows come from).

  • ACF-196 amendment / corrections workflow.

  • TANF MOE (Maintenance of Effort) reporting — separate ACF data set, separate plan.

  • Real-time expenditure tracking — ingest is push-based and quarterly.

  • CMS-64 cross-program shared infrastructure — see cms-64-expenditure-aggregation.adoc (#379).

Dependencies

  • Predecessor tanf-federal-reporting plan (already archived).

  • canopy-mq-persistent-outbox.adoc (#388) does not block; expenditure ingest does not currently publish events.

Design

tanf_expenditures schema:

CREATE TABLE tanf_expenditures (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    category TEXT NOT NULL,
    amount_cents BIGINT NOT NULL,
    fiscal_quarter TEXT NOT NULL,
    recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    recorded_by UUID,
    supporting_doc_url TEXT
);

CREATE INDEX tanf_expenditures_by_quarter
    ON tanf_expenditures (fiscal_quarter, category);

The 19 ACF-196 categories live as an enum in crates/canopy-reference/src/program/tanf.rs (or a new acf196.rs). Validation at POST time rejects any category outside the enum.

CSV generator:

pub async fn generate_acf196_csv(
    client: &TanfClient,
    fy_q: &str,
) -> Result<String> {
    let expenditures = client.list_expenditures(fy_q).await?;
    let mut totals = HashMap::<Acf196Category, i64>::new();
    for e in expenditures {
        *totals.entry(e.category).or_insert(0) += e.amount_cents;
    }
    let mut wtr = csv::Writer::from_writer(vec![]);
    wtr.write_record(&Acf196Category::headers())?;
    wtr.write_record(&Acf196Category::values(&totals))?;
    Ok(String::from_utf8(wtr.into_inner()?)?)
}

Files Touched

File Change

services/canopy-tanf/migrations/20260506000001_create_tanf_expenditures.sql

New migration

services/canopy-tanf/src/store/expenditures.rs

New store module

services/canopy-tanf/src/api/expenditures.rs

New API module

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

Register routes

services/canopy-reporting/migrations/20260506000020_create_acf196_snapshot.sql

New migration

services/canopy-reporting/src/reporting/tanf.rs

Extend at line 413 with generate_acf196_csv

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

Register /v1/reporting/acf-196 endpoint

crates/canopy-reference/src/program/tanf.rs (or new acf196.rs)

Add Acf196Category enum

services/canopy-reporting/tests/acf196_test.rs

New devstack integration test

Service Catalog

canopy-reporting + canopy-tanf entry updates

CHANGELOG.adoc

=== Added entry

docs/modules/ROOT/openapi/canopy-reporting.json

Regenerated snapshot

docs/modules/ROOT/openapi/canopy-tanf.json

Regenerated snapshot

Verification

  1. cargo nextest run -p canopy-tanf -p canopy-reporting --lib — unit tests pass.

  2. cargo xtask api-docs — OpenAPI snapshots regenerate clean.

  3. cargo xtask dev start && cargo nextest run -p canopy-reporting --test acf196_test --run-ignored only — integration test passes.

  4. Manual smoke: POST 5 expenditures across categories, GET /v1/reporting/acf-196?fy=2026q3, confirm CSV with totals matches the recorded sums.

  5. cargo xtask validate — full battery green.

Documentation Updates

  • Service Catalog — canopy-tanf + canopy-reporting entries (status / tables)

  • CHANGELOG.adoc — entry under == Unreleased / === Added

  • docs/modules/ROOT/pages/services/canopy-reporting.adoc — list ACF-196 path

  • docs/modules/ROOT/pages/federal-requirements.adoc — ACF-196 row update

  • Plan archive: move to plans/archive/ post-merge

Edit this page · default