Plan: Person and Household Data Model
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Database schema (persons, households, household_members, addresses, income, assets, expenses) |
Done (2026-03-28) |
2 |
Store layer (database query functions) |
Done (2026-03-28) |
3 |
CRUD API endpoints for persons and households |
Done (2026-03-28) |
4 |
Event publishing (person.created, household.updated, etc.) |
Done (2026-03-28) |
5 |
Integration tests with testcontainers |
Done (2026-03-28) |
Epic: &31, &38
Branch: feature/persons-household-model
MR: !6
Context
Every program service in Canopy needs person and household data to evaluate eligibility. A SNAP determination requires household composition, income, and assets. A Medicaid determination requires the same, plus demographic details for MAGI household rules. canopy-persons is the single source of truth for this data — program services receive application contexts assembled from canopy-persons data, but they never query canopy-persons directly (per ADR-001, cross-service communication is via HTTP API, not database).
canopy-persons is greenfield — there is no CRAIG equivalent. CRAIG’s person model is embedded in craig-cases; Canopy’s is intentionally separated because persons exist independently of any single program application.
This is the first service that must be implemented. Nothing else can produce real data without it.
Scope
In scope:
-
Person table: name, DOB, SSN (encrypted at rest), gender, race, ethnicity, citizenship status, language preference
-
Household table: household composition, effective dates
-
Household member table: person-to-household mapping with relationship types
-
Address table: residential, mailing, with effective date ranges
-
Income table: per ADR-003, income data feeds into rules engine — typed by
IncomeTypeenum from canopy-reference -
Asset table: typed by
AssetTypeenum from canopy-reference -
Expense table: shelter, dependent care, medical (SNAP deductions)
-
CRUD endpoints under
/v1/persons,/v1/households -
Event publishing for person/household lifecycle events
-
Soft-delete pattern (active column)
Out of scope:
-
Program-specific data (eligibility results, determination history) — belongs in program services
-
Application intake flow — belongs in canopy-applications
-
Identity verification — belongs in canopy-verification
-
Document/file uploads — handled by canopy-store integration in a later plan
Design
Data Model
All primary keys are UUID v7 (canopy_common::id::new_id()).
Monetary values use NUMERIC(10,2) / rust_decimal::Decimal.
Soft-delete via active BOOLEAN NOT NULL DEFAULT true.
Timestamps: created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now().
CREATE TABLE persons (
id UUID PRIMARY KEY,
first_name TEXT NOT NULL,
middle_name TEXT,
last_name TEXT NOT NULL,
suffix TEXT,
date_of_birth DATE NOT NULL,
ssn_encrypted BYTEA, -- encrypted at rest, never in API responses
gender TEXT,
race TEXT[],
ethnicity TEXT,
citizenship_status TEXT,
language_preference TEXT DEFAULT 'en',
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE households (
id UUID PRIMARY KEY,
name TEXT, -- optional label
effective_date DATE NOT NULL,
end_date DATE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE household_members (
id UUID PRIMARY KEY,
household_id UUID NOT NULL REFERENCES households(id),
person_id UUID NOT NULL REFERENCES persons(id),
relationship TEXT NOT NULL, -- head_of_household, spouse, child, other_adult, etc.
effective_date DATE NOT NULL,
end_date DATE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE addresses (
id UUID PRIMARY KEY,
person_id UUID NOT NULL REFERENCES persons(id),
address_type TEXT NOT NULL, -- residential, mailing
line_1 TEXT NOT NULL,
line_2 TEXT,
city TEXT NOT NULL,
state TEXT NOT NULL, -- FIPS abbreviation
zip TEXT NOT NULL,
county_fips TEXT,
effective_date DATE NOT NULL,
end_date DATE,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE income (
id UUID PRIMARY KEY,
person_id UUID NOT NULL REFERENCES persons(id),
income_type TEXT NOT NULL, -- maps to canopy_reference::IncomeType
amount NUMERIC(10,2) NOT NULL,
frequency TEXT NOT NULL, -- monthly, biweekly, weekly, annual
employer_name TEXT,
effective_date DATE NOT NULL,
end_date DATE,
verified BOOLEAN NOT NULL DEFAULT false,
verification_source TEXT, -- maps to canopy_reference::VerificationSource
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE assets (
id UUID PRIMARY KEY,
person_id UUID NOT NULL REFERENCES persons(id),
asset_type TEXT NOT NULL, -- maps to canopy_reference::AssetType
description TEXT,
value NUMERIC(10,2) NOT NULL,
verified BOOLEAN NOT NULL DEFAULT false,
verification_source TEXT,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE expenses (
id UUID PRIMARY KEY,
person_id UUID NOT NULL REFERENCES persons(id),
expense_type TEXT NOT NULL, -- shelter, dependent_care, medical, child_support
amount NUMERIC(10,2) NOT NULL,
frequency TEXT NOT NULL,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
API Endpoints
All endpoints require authentication (Bearer JWT via canopy-auth).
Create endpoints return 201 Created (Canopy convention, not 200).
All list endpoints support pagination via PageRequest from canopy-common.
| Method | Path | Description |
|---|---|---|
POST |
/v1/persons |
Create a person |
GET |
/v1/persons |
List persons (paginated, searchable) |
GET |
/v1/persons/{id} |
Get a person by ID |
PUT |
/v1/persons/{id} |
Update a person |
DELETE |
/v1/persons/{id} |
Soft-delete a person |
POST |
/v1/households |
Create a household |
GET |
/v1/households/{id} |
Get household with members |
POST |
/v1/households/{id}/members |
Add a member |
DELETE |
/v1/households/{id}/members/{member_id} |
Remove a member |
POST |
/v1/persons/{id}/income |
Add income record |
POST |
/v1/persons/{id}/assets |
Add asset record |
POST |
/v1/persons/{id}/expenses |
Add expense record |
POST |
/v1/persons/{id}/addresses |
Add address |
Events
Published to canopy.events exchange:
-
person.created— payload:{ person_id, created_by } -
person.updated— payload:{ person_id, updated_by, fields_changed[] } -
household.created— payload:{ household_id, created_by } -
household.member_added— payload:{ household_id, person_id, relationship } -
household.member_removed— payload:{ household_id, person_id }
No restricted data in payloads — IDs and metadata only per ADR-004 / coding conventions.
CLI Commands (ADR-007)
Per ADR-007, the following canopy CLI commands must be added to tools/canopy-cli/ when this plan ships:
-
canopy person create— create a person -
canopy person list— list persons (paginated, searchable) -
canopy person get <id>— get a person by ID -
canopy person update <id>— update a person -
canopy person delete <id>— soft-delete a person -
canopy household create— create a household -
canopy household get <id>— get household with members -
canopy household add-member <id>— add a member to a household -
canopy household remove-member <id> <member_id>— remove a member -
canopy person add-income <id>— add income record -
canopy person add-asset <id>— add asset record -
canopy person add-expense <id>— add expense record -
canopy person add-address <id>— add address
Steps
Step 1: Database Migration
Files: services/canopy-persons/migrations/20260326000000_create_persons_tables.sql
Migration file naming convention: <YYYYMMDD><6-digit-sequence>_<snake_case_description>.sql.
Migrations are additive only — never ALTER or DROP columns in the same migration that creates them.
If a later plan needs schema changes, it creates a new migration file with a later timestamp.
The migration file contains all seven CREATE TABLE statements exactly as written in the Design section above.
Copy them verbatim — the Design section is the single source of truth for the schema.
After creating the migration file, uncomment the migration runner on line 15 of services/canopy-persons/src/main.rs:
// Before (line 15):
// boot.db.run_migrations(&sqlx::migrate!()).await?;
// After (line 15):
boot.db.run_migrations(&sqlx::migrate!()).await?;
The sqlx::migrate!() macro reads from the migrations/ directory relative to Cargo.toml at compile time.
No further configuration is needed.
Step 2: Store Layer
Files:
-
services/canopy-persons/src/store/mod.rs -
services/canopy-persons/src/store/models.rs -
services/canopy-persons/src/store/persons.rs -
services/canopy-persons/src/store/households.rs -
services/canopy-persons/src/store/income.rs -
services/canopy-persons/src/store/assets.rs -
services/canopy-persons/src/store/expenses.rs -
services/canopy-persons/src/store/addresses.rs
This follows the store pattern from d:/code/craig/services/craig-cases/src/store/ — see cases.rs for the COALESCE update pattern and models.rs for struct derives.
Model Structs (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, utoipa::ToSchema)]
pub struct Person {
pub id: Uuid,
pub first_name: String,
pub middle_name: Option<String>,
pub last_name: String,
pub suffix: Option<String>,
pub date_of_birth: NaiveDate,
#[serde(skip_serializing)]
pub ssn_encrypted: Option<Vec<u8>>,
pub gender: Option<String>,
pub race: Option<Vec<String>>,
pub ethnicity: Option<String>,
pub citizenship_status: Option<String>,
pub language_preference: Option<String>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct Household {
pub id: Uuid,
pub name: Option<String>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct HouseholdMember {
pub id: Uuid,
pub household_id: Uuid,
pub person_id: Uuid,
pub relationship: String,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct Address {
pub id: Uuid,
pub person_id: Uuid,
pub address_type: String,
pub line_1: String,
pub line_2: Option<String>,
pub city: String,
pub state: String,
pub zip: String,
pub county_fips: Option<String>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct Income {
pub id: Uuid,
pub person_id: Uuid,
pub income_type: String,
pub amount: Decimal,
pub frequency: String,
pub employer_name: Option<String>,
pub effective_date: NaiveDate,
pub end_date: Option<NaiveDate>,
pub verified: bool,
pub verification_source: Option<String>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct Asset {
pub id: Uuid,
pub person_id: Uuid,
pub asset_type: String,
pub description: Option<String>,
pub value: Decimal,
pub verified: bool,
pub verification_source: Option<String>,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow, utoipa::ToSchema)]
pub struct Expense {
pub id: Uuid,
pub person_id: Uuid,
pub expense_type: String,
pub amount: Decimal,
pub frequency: String,
pub active: bool,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
Store Module (store/mod.rs)
pub mod models;
pub mod persons;
pub mod households;
pub mod income;
pub mod assets;
pub mod expenses;
pub mod addresses;
Person Queries (store/persons.rs)
All functions use sqlx::query_as::<_, Model>() with raw SQL strings — not the sqlx::query! macro (which requires a live database at compile time).
IDs are generated with canopy_common::id::new_id().
use canopy_common::error::ApiError;
use canopy_common::pagination::{PageRequest, PageResponse};
use canopy_db::DbPool;
use chrono::NaiveDate;
use uuid::Uuid;
use super::models::Person;
pub async fn create_person(
pool: &DbPool,
first_name: &str,
middle_name: Option<&str>,
last_name: &str,
suffix: Option<&str>,
date_of_birth: NaiveDate,
ssn_encrypted: Option<&[u8]>,
gender: Option<&str>,
race: Option<&[String]>,
ethnicity: Option<&str>,
citizenship_status: Option<&str>,
language_preference: Option<&str>,
) -> Result<Person, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Person>(
r#"INSERT INTO persons
(id, first_name, middle_name, last_name, suffix, date_of_birth,
ssn_encrypted, gender, race, ethnicity, citizenship_status, language_preference)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING *"#,
)
.bind(id)
.bind(first_name)
.bind(middle_name)
.bind(last_name)
.bind(suffix)
.bind(date_of_birth)
.bind(ssn_encrypted)
.bind(gender)
.bind(race)
.bind(ethnicity)
.bind(citizenship_status)
.bind(language_preference)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn get_person(pool: &DbPool, id: Uuid) -> Result<Person, ApiError> {
sqlx::query_as::<_, Person>(
"SELECT * FROM persons WHERE id = $1 AND active = true",
)
.bind(id)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("person {id} not found")))
}
pub async fn list_persons(
pool: &DbPool,
page: &PageRequest,
search: Option<&str>,
) -> Result<PageResponse<Person>, ApiError> {
let rows = sqlx::query_as::<_, Person>(
r#"SELECT * FROM persons
WHERE active = true
AND ($1::TEXT IS NULL
OR first_name ILIKE '%' || $1 || '%'
OR last_name ILIKE '%' || $1 || '%')
ORDER BY created_at DESC
LIMIT $2 OFFSET $3"#,
)
.bind(search)
.bind(page.limit())
.bind(page.offset())
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
let total: (i64,) = sqlx::query_as(
r#"SELECT COUNT(*) FROM persons
WHERE active = true
AND ($1::TEXT IS NULL
OR first_name ILIKE '%' || $1 || '%'
OR last_name ILIKE '%' || $1 || '%')"#,
)
.bind(search)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
Ok(PageResponse {
data: rows,
page: page.page,
per_page: page.per_page,
total: total.0,
})
}
pub async fn update_person(
pool: &DbPool,
id: Uuid,
first_name: Option<&str>,
middle_name: Option<&str>,
last_name: Option<&str>,
suffix: Option<&str>,
date_of_birth: Option<NaiveDate>,
gender: Option<&str>,
race: Option<&[String]>,
ethnicity: Option<&str>,
citizenship_status: Option<&str>,
language_preference: Option<&str>,
) -> Result<Person, ApiError> {
sqlx::query_as::<_, Person>(
r#"UPDATE persons SET
first_name = COALESCE($2, first_name),
middle_name = COALESCE($3, middle_name),
last_name = COALESCE($4, last_name),
suffix = COALESCE($5, suffix),
date_of_birth = COALESCE($6, date_of_birth),
gender = COALESCE($7, gender),
race = COALESCE($8, race),
ethnicity = COALESCE($9, ethnicity),
citizenship_status = COALESCE($10, citizenship_status),
language_preference = COALESCE($11, language_preference),
updated_at = now()
WHERE id = $1 AND active = true
RETURNING *"#,
)
.bind(id)
.bind(first_name)
.bind(middle_name)
.bind(last_name)
.bind(suffix)
.bind(date_of_birth)
.bind(gender)
.bind(race)
.bind(ethnicity)
.bind(citizenship_status)
.bind(language_preference)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("person {id} not found")))
}
pub async fn soft_delete_person(pool: &DbPool, id: Uuid) -> Result<(), ApiError> {
let result = sqlx::query(
"UPDATE persons SET active = false, updated_at = now() WHERE id = $1 AND active = true",
)
.bind(id)
.execute(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound(format!("person {id} not found")));
}
Ok(())
}
The COALESCE pattern allows partial updates — the caller sends only the fields they want to change, and NULL parameters leave the existing value untouched.
This matches the pattern in d:/code/craig/services/craig-cases/src/store/cases.rs (update_case function, lines 134-175).
The soft-delete pattern sets active = false instead of issuing a DELETE.
All SELECT queries filter on active = true so soft-deleted rows are invisible to the API.
Household Queries (store/households.rs)
use canopy_common::error::ApiError;
use canopy_db::DbPool;
use chrono::NaiveDate;
use uuid::Uuid;
use super::models::{Household, HouseholdMember};
pub async fn create_household(
pool: &DbPool,
name: Option<&str>,
effective_date: NaiveDate,
) -> Result<Household, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Household>(
r#"INSERT INTO households (id, name, effective_date)
VALUES ($1, $2, $3)
RETURNING *"#,
)
.bind(id)
.bind(name)
.bind(effective_date)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn get_household(pool: &DbPool, id: Uuid) -> Result<Household, ApiError> {
sqlx::query_as::<_, Household>(
"SELECT * FROM households WHERE id = $1 AND active = true",
)
.bind(id)
.fetch_optional(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?
.ok_or_else(|| ApiError::NotFound(format!("household {id} not found")))
}
pub async fn list_household_members(
pool: &DbPool,
household_id: Uuid,
) -> Result<Vec<HouseholdMember>, ApiError> {
sqlx::query_as::<_, HouseholdMember>(
r#"SELECT * FROM household_members
WHERE household_id = $1 AND active = true
ORDER BY created_at"#,
)
.bind(household_id)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn add_household_member(
pool: &DbPool,
household_id: Uuid,
person_id: Uuid,
relationship: &str,
effective_date: NaiveDate,
) -> Result<HouseholdMember, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, HouseholdMember>(
r#"INSERT INTO household_members
(id, household_id, person_id, relationship, effective_date)
VALUES ($1, $2, $3, $4, $5)
RETURNING *"#,
)
.bind(id)
.bind(household_id)
.bind(person_id)
.bind(relationship)
.bind(effective_date)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn remove_household_member(
pool: &DbPool,
household_id: Uuid,
member_id: Uuid,
) -> Result<(), ApiError> {
let result = sqlx::query(
r#"UPDATE household_members
SET active = false, updated_at = now()
WHERE id = $1 AND household_id = $2 AND active = true"#,
)
.bind(member_id)
.bind(household_id)
.execute(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))?;
if result.rows_affected() == 0 {
return Err(ApiError::NotFound(format!(
"member {member_id} not found in household {household_id}"
)));
}
Ok(())
}
Income Queries (store/income.rs)
use canopy_common::error::ApiError;
use canopy_db::DbPool;
use chrono::NaiveDate;
use rust_decimal::Decimal;
use uuid::Uuid;
use super::models::Income;
pub async fn create_income(
pool: &DbPool,
person_id: Uuid,
income_type: &str,
amount: Decimal,
frequency: &str,
employer_name: Option<&str>,
effective_date: NaiveDate,
) -> Result<Income, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Income>(
r#"INSERT INTO income
(id, person_id, income_type, amount, frequency, employer_name, effective_date)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *"#,
)
.bind(id)
.bind(person_id)
.bind(income_type)
.bind(amount)
.bind(frequency)
.bind(employer_name)
.bind(effective_date)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn list_income(
pool: &DbPool,
person_id: Uuid,
) -> Result<Vec<Income>, ApiError> {
sqlx::query_as::<_, Income>(
r#"SELECT * FROM income
WHERE person_id = $1 AND active = true
ORDER BY effective_date DESC"#,
)
.bind(person_id)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
Asset Queries (store/assets.rs)
use canopy_common::error::ApiError;
use canopy_db::DbPool;
use rust_decimal::Decimal;
use uuid::Uuid;
use super::models::Asset;
pub async fn create_asset(
pool: &DbPool,
person_id: Uuid,
asset_type: &str,
description: Option<&str>,
value: Decimal,
) -> Result<Asset, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Asset>(
r#"INSERT INTO assets
(id, person_id, asset_type, description, value)
VALUES ($1, $2, $3, $4, $5)
RETURNING *"#,
)
.bind(id)
.bind(person_id)
.bind(asset_type)
.bind(description)
.bind(value)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn list_assets(
pool: &DbPool,
person_id: Uuid,
) -> Result<Vec<Asset>, ApiError> {
sqlx::query_as::<_, Asset>(
r#"SELECT * FROM assets
WHERE person_id = $1 AND active = true
ORDER BY created_at DESC"#,
)
.bind(person_id)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
Expense Queries (store/expenses.rs)
use canopy_common::error::ApiError;
use canopy_db::DbPool;
use rust_decimal::Decimal;
use uuid::Uuid;
use super::models::Expense;
pub async fn create_expense(
pool: &DbPool,
person_id: Uuid,
expense_type: &str,
amount: Decimal,
frequency: &str,
) -> Result<Expense, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Expense>(
r#"INSERT INTO expenses
(id, person_id, expense_type, amount, frequency)
VALUES ($1, $2, $3, $4, $5)
RETURNING *"#,
)
.bind(id)
.bind(person_id)
.bind(expense_type)
.bind(amount)
.bind(frequency)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn list_expenses(
pool: &DbPool,
person_id: Uuid,
) -> Result<Vec<Expense>, ApiError> {
sqlx::query_as::<_, Expense>(
r#"SELECT * FROM expenses
WHERE person_id = $1 AND active = true
ORDER BY created_at DESC"#,
)
.bind(person_id)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
Address Queries (store/addresses.rs)
use canopy_common::error::ApiError;
use canopy_db::DbPool;
use chrono::NaiveDate;
use uuid::Uuid;
use super::models::Address;
pub async fn create_address(
pool: &DbPool,
person_id: Uuid,
address_type: &str,
line_1: &str,
line_2: Option<&str>,
city: &str,
state: &str,
zip: &str,
county_fips: Option<&str>,
effective_date: NaiveDate,
) -> Result<Address, ApiError> {
let id = canopy_common::id::new_id();
sqlx::query_as::<_, Address>(
r#"INSERT INTO addresses
(id, person_id, address_type, line_1, line_2, city, state, zip,
county_fips, effective_date)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING *"#,
)
.bind(id)
.bind(person_id)
.bind(address_type)
.bind(line_1)
.bind(line_2)
.bind(city)
.bind(state)
.bind(zip)
.bind(county_fips)
.bind(effective_date)
.fetch_one(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
pub async fn list_addresses(
pool: &DbPool,
person_id: Uuid,
) -> Result<Vec<Address>, ApiError> {
sqlx::query_as::<_, Address>(
r#"SELECT * FROM addresses
WHERE person_id = $1 AND active = true
ORDER BY effective_date DESC"#,
)
.bind(person_id)
.fetch_all(&**pool)
.await
.map_err(|e| ApiError::Internal(e.to_string()))
}
Step 3: API Routes
Files:
-
services/canopy-persons/src/api/mod.rs -
services/canopy-persons/src/api/persons.rs -
services/canopy-persons/src/api/households.rs
This follows the route wiring pattern from d:/code/craig/services/craig-cases/src/api/mod.rs.
Request/Response Types (api/persons.rs)
use chrono::NaiveDate;
use rust_decimal::Decimal;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreatePersonRequest {
pub first_name: String,
pub last_name: String,
pub middle_name: Option<String>,
pub suffix: Option<String>,
pub date_of_birth: NaiveDate,
pub gender: Option<String>,
pub race: Option<Vec<String>>,
pub ethnicity: Option<String>,
pub citizenship_status: Option<String>,
pub language_preference: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct UpdatePersonRequest {
pub first_name: Option<String>,
pub last_name: Option<String>,
pub middle_name: Option<String>,
pub suffix: Option<String>,
pub date_of_birth: Option<NaiveDate>,
pub gender: Option<String>,
pub race: Option<Vec<String>>,
pub ethnicity: Option<String>,
pub citizenship_status: Option<String>,
pub language_preference: Option<String>,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateHouseholdRequest {
pub name: Option<String>,
pub effective_date: NaiveDate,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct AddHouseholdMemberRequest {
pub person_id: Uuid,
pub relationship: String,
pub effective_date: NaiveDate,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateIncomeRequest {
pub income_type: String,
pub amount: Decimal,
pub frequency: String,
pub employer_name: Option<String>,
pub effective_date: NaiveDate,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateAssetRequest {
pub asset_type: String,
pub description: Option<String>,
pub value: Decimal,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateExpenseRequest {
pub expense_type: String,
pub amount: Decimal,
pub frequency: String,
}
#[derive(Debug, Deserialize, utoipa::ToSchema)]
pub struct CreateAddressRequest {
pub address_type: String,
pub line_1: String,
pub line_2: Option<String>,
pub city: String,
pub state: String,
pub zip: String,
pub county_fips: Option<String>,
pub effective_date: NaiveDate,
}
Handler Signatures (api/persons.rs)
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::Json;
use canopy_api::AppState;
use canopy_common::error::ApiError;
use canopy_common::pagination::{PageRequest, PageResponse};
use uuid::Uuid;
use crate::store;
/// POST /persons
#[utoipa::path(
post, path = "/persons",
tag = "Persons",
request_body = CreatePersonRequest,
responses(
(status = 201, description = "Person created", body = Person),
(status = 400, description = "Validation error", body = ProblemDetails),
),
security(("bearer" = []))
)]
pub async fn create_person(
State(state): State<AppState>,
Json(req): Json<CreatePersonRequest>,
) -> Result<(StatusCode, Json<store::models::Person>), ApiError> {
let person = store::persons::create_person(
&state.db,
&req.first_name,
req.middle_name.as_deref(),
&req.last_name,
req.suffix.as_deref(),
req.date_of_birth,
None, // ssn_encrypted — set via separate secure endpoint
req.gender.as_deref(),
req.race.as_deref(),
req.ethnicity.as_deref(),
req.citizenship_status.as_deref(),
req.language_preference.as_deref(),
)
.await?;
Ok((StatusCode::CREATED, Json(person)))
}
/// GET /persons
#[utoipa::path(
get, path = "/persons",
tag = "Persons",
params(PageRequest, ("search" = Option<String>, Query, description = "Name search")),
responses(
(status = 200, description = "Paginated list", body = PageResponse<Person>),
),
security(("bearer" = []))
)]
pub async fn list_persons(
State(state): State<AppState>,
Query(page): Query<PageRequest>,
Query(params): Query<ListPersonsParams>,
) -> Result<Json<PageResponse<store::models::Person>>, ApiError> {
let result = store::persons::list_persons(
&state.db,
&page,
params.search.as_deref(),
)
.await?;
Ok(Json(result))
}
#[derive(Debug, serde::Deserialize)]
pub struct ListPersonsParams {
pub search: Option<String>,
}
/// GET /persons/{id}
#[utoipa::path(
get, path = "/persons/{id}",
tag = "Persons",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 200, description = "Person found", body = Person),
(status = 404, description = "Not found", body = ProblemDetails),
),
security(("bearer" = []))
)]
pub async fn get_person(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<Json<store::models::Person>, ApiError> {
let person = store::persons::get_person(&state.db, id).await?;
Ok(Json(person))
}
/// PUT /persons/{id}
#[utoipa::path(
put, path = "/persons/{id}",
tag = "Persons",
params(("id" = Uuid, Path, description = "Person ID")),
request_body = UpdatePersonRequest,
responses(
(status = 200, description = "Person updated", body = Person),
(status = 404, description = "Not found", body = ProblemDetails),
),
security(("bearer" = []))
)]
pub async fn update_person(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(req): Json<UpdatePersonRequest>,
) -> Result<Json<store::models::Person>, ApiError> {
let person = store::persons::update_person(
&state.db,
id,
req.first_name.as_deref(),
req.middle_name.as_deref(),
req.last_name.as_deref(),
req.suffix.as_deref(),
req.date_of_birth,
req.gender.as_deref(),
req.race.as_deref(),
req.ethnicity.as_deref(),
req.citizenship_status.as_deref(),
req.language_preference.as_deref(),
)
.await?;
Ok(Json(person))
}
/// DELETE /persons/{id}
#[utoipa::path(
delete, path = "/persons/{id}",
tag = "Persons",
params(("id" = Uuid, Path, description = "Person ID")),
responses(
(status = 204, description = "Deleted"),
(status = 404, description = "Not found", body = ProblemDetails),
),
security(("bearer" = []))
)]
pub async fn delete_person(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<StatusCode, ApiError> {
store::persons::soft_delete_person(&state.db, id).await?;
Ok(StatusCode::NO_CONTENT)
}
Household handlers follow the same pattern in api/households.rs — create_household, get_household, add_household_member, remove_household_member.
Sub-resource handlers for income, assets, expenses, and addresses also follow the same pattern — create_income, create_asset, create_expense, create_address.
Router Wiring (api/mod.rs)
pub mod persons;
pub mod households;
use axum::routing::{delete, get, post, put};
use axum::Router;
use canopy_api::AppState;
pub fn routes() -> Router<AppState> {
Router::new()
// Persons
.route("/persons", get(persons::list_persons).post(persons::create_person))
.route(
"/persons/{id}",
get(persons::get_person)
.put(persons::update_person)
.delete(persons::delete_person),
)
// Households
.route("/households", post(households::create_household))
.route("/households/{id}", get(households::get_household))
.route(
"/households/{id}/members",
post(households::add_household_member),
)
.route(
"/households/{id}/members/{member_id}",
delete(households::remove_household_member),
)
// Person sub-resources
.route("/persons/{id}/income", post(persons::create_income))
.route("/persons/{id}/assets", post(persons::create_asset))
.route("/persons/{id}/expenses", post(persons::create_expense))
.route("/persons/{id}/addresses", post(persons::create_address))
}
The router is nested under /v1 by ApiServer::router() in canopy-api (see crates/canopy-api/src/lib.rs line 92: .nest("/v1", protected)).
Auth middleware and idempotency are applied automatically by the framework — handlers do not need to check tokens themselves.
Request/Response JSON Examples
POST /v1/persons request:
{
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1985-03-15",
"gender": "female",
"race": ["white"],
"ethnicity": "not_hispanic",
"citizenship_status": "us_citizen",
"language_preference": "en"
}
POST /v1/persons response (201 Created):
{
"id": "019513a2-7b3c-7def-8901-234567890abc",
"first_name": "Jane",
"middle_name": null,
"last_name": "Doe",
"suffix": null,
"date_of_birth": "1985-03-15",
"gender": "female",
"race": ["white"],
"ethnicity": "not_hispanic",
"citizenship_status": "us_citizen",
"language_preference": "en",
"active": true,
"created_at": "2026-03-26T14:30:00Z",
"updated_at": "2026-03-26T14:30:00Z"
}
Note: ssn_encrypted is excluded from JSON responses via #[serde(skip_serializing)] on the model.
GET /v1/persons?page=1&per_page=25&search=doe response:
{
"data": [
{
"id": "019513a2-7b3c-7def-8901-234567890abc",
"first_name": "Jane",
"last_name": "Doe",
"..."
}
],
"page": 1,
"per_page": 25,
"total": 1
}
Error response (404 Not Found, RFC 9457):
{
"type": "about:blank",
"title": "Not Found",
"status": 404,
"detail": "person 019513a2-7b3c-7def-8901-234567890abc not found"
}
Error response (500 Internal Server Error, RFC 9457 — detail is redacted):
{
"type": "about:blank",
"title": "Internal Server Error",
"status": 500,
"detail": "An unexpected error occurred"
}
Error Handling
Error mapping uses canopy_common::error::ApiError (defined in crates/canopy-common/src/error.rs):
| ApiError variant | HTTP status | When to use |
|---|---|---|
|
404 |
|
|
400 |
Deserialization failure (Axum handles automatically), or explicit validation (e.g. missing required fields) |
|
401 |
Auth middleware rejects the token (handled by framework, not handlers) |
|
403 |
Reserved for future role-based access control |
|
409 |
Duplicate key violations (e.g. adding a person already in a household) |
|
500 |
Any |
Step 4: Event Publishing
Files: services/canopy-persons/src/events.rs
Follows the event publishing pattern from d:/code/craig/services/craig-cases/src/events.rs.
Events are fire-and-forget with a tracing::warn on failure — a failed publish must never roll back the database transaction.
use canopy_mq::{EventEnvelope, Publisher};
use uuid::Uuid;
const SOURCE: &str = "canopy-persons";
pub async fn publish_person_created(
publisher: &Publisher,
person_id: Uuid,
created_by: &str,
) {
let envelope = EventEnvelope::new(
SOURCE,
"person.created",
serde_json::json!({ "person_id": person_id, "created_by": created_by }),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::warn!(error = %e, "failed to publish person.created");
}
}
pub async fn publish_person_updated(
publisher: &Publisher,
person_id: Uuid,
updated_by: &str,
fields_changed: &[&str],
) {
let envelope = EventEnvelope::new(
SOURCE,
"person.updated",
serde_json::json!({
"person_id": person_id,
"updated_by": updated_by,
"fields_changed": fields_changed,
}),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::warn!(error = %e, "failed to publish person.updated");
}
}
pub async fn publish_household_created(
publisher: &Publisher,
household_id: Uuid,
created_by: &str,
) {
let envelope = EventEnvelope::new(
SOURCE,
"household.created",
serde_json::json!({ "household_id": household_id, "created_by": created_by }),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::warn!(error = %e, "failed to publish household.created");
}
}
pub async fn publish_household_member_added(
publisher: &Publisher,
household_id: Uuid,
person_id: Uuid,
relationship: &str,
) {
let envelope = EventEnvelope::new(
SOURCE,
"household.member_added",
serde_json::json!({
"household_id": household_id,
"person_id": person_id,
"relationship": relationship,
}),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::warn!(error = %e, "failed to publish household.member_added");
}
}
pub async fn publish_household_member_removed(
publisher: &Publisher,
household_id: Uuid,
person_id: Uuid,
) {
let envelope = EventEnvelope::new(
SOURCE,
"household.member_removed",
serde_json::json!({
"household_id": household_id,
"person_id": person_id,
}),
);
if let Err(e) = publisher.publish(&envelope).await {
tracing::warn!(error = %e, "failed to publish household.member_removed");
}
}
Event functions are called from API handlers after the database write succeeds.
Example call site in create_person handler:
// After successful database insert:
if let Some(Extension(ref publisher)) = publisher {
crate::events::publish_person_created(publisher, person.id, "system").await;
}
The Publisher is injected as an axum::Extension on the router (same pattern as d:/code/craig/services/craig-cases/src/api/mod.rs line 279: .layer(Extension(publisher))).
The created_by / updated_by values will come from the authenticated JWT claims once the auth extraction is wired — use "system" as a placeholder for now.
Step 5: Tests
Files:
-
services/canopy-persons/tests/api/mod.rs -
services/canopy-persons/tests/api/persons.rs -
services/canopy-persons/tests/api/households.rs
All integration tests use the canopy_test_lib::infrastructure_available() guard.
Tests that need infrastructure skip silently (return early) when Docker is not available.
This means cargo nextest run never fails on a developer machine without Docker.
Test Functions and Assertions
// tests/api/persons.rs
use reqwest::StatusCode;
/// Verify the full create-then-get lifecycle for a person.
#[tokio::test]
async fn create_and_get_person() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
// POST /v1/persons with full payload
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Jane",
"last_name": "Doe",
"date_of_birth": "1985-03-15",
"gender": "female",
"race": ["white"],
"ethnicity": "not_hispanic",
"citizenship_status": "us_citizen",
"language_preference": "en"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
assert_eq!(person["first_name"], "Jane");
assert_eq!(person["last_name"], "Doe");
assert_eq!(person["date_of_birth"], "1985-03-15");
assert_eq!(person["active"], true);
// ssn_encrypted must NOT appear in the response
assert!(person.get("ssn_encrypted").is_none());
// GET /v1/persons/{id}
let resp = client.get(&format!("/v1/persons/{person_id}"))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let fetched: serde_json::Value = resp.json().await.unwrap();
assert_eq!(fetched["id"], person_id);
assert_eq!(fetched["first_name"], "Jane");
}
/// Verify paginated listing with search filter.
#[tokio::test]
async fn list_persons_pagination() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
// Create two persons
for name in &["Alice", "Bob"] {
client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": name,
"last_name": "Smith",
"date_of_birth": "1990-01-01"
}))
.send().await.unwrap();
}
// GET /v1/persons?page=1&per_page=10
let resp = client.get("/v1/persons?page=1&per_page=10")
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body: serde_json::Value = resp.json().await.unwrap();
assert!(body["total"].as_i64().unwrap() >= 2);
assert_eq!(body["page"], 1);
assert_eq!(body["per_page"], 10);
assert!(body["data"].as_array().unwrap().len() >= 2);
// GET /v1/persons?search=alice — should match only Alice
let resp = client.get("/v1/persons?search=alice")
.send().await.unwrap();
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["total"], 1);
assert_eq!(body["data"][0]["first_name"], "Alice");
}
/// Verify soft-delete sets active=false and hides from GET.
#[tokio::test]
async fn soft_delete_person() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
// Create a person
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "ToDelete",
"last_name": "Person",
"date_of_birth": "1980-06-01"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let id = person["id"].as_str().unwrap();
// DELETE /v1/persons/{id}
let resp = client.delete(&format!("/v1/persons/{id}"))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
// GET /v1/persons/{id} should now return 404
let resp = client.get(&format!("/v1/persons/{id}"))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
// Verify error body is RFC 9457 compliant
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["type"], "about:blank");
assert_eq!(body["status"], 404);
assert!(body["detail"].as_str().unwrap().contains(id));
}
/// Verify partial update via COALESCE — only supplied fields change.
#[tokio::test]
async fn update_person_partial() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Original",
"last_name": "Name",
"date_of_birth": "1990-01-01",
"gender": "male"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let id = person["id"].as_str().unwrap();
// PUT with only first_name — gender should remain "male"
let resp = client.put(&format!("/v1/persons/{id}"))
.json(&serde_json::json!({
"first_name": "Updated"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let updated: serde_json::Value = resp.json().await.unwrap();
assert_eq!(updated["first_name"], "Updated");
assert_eq!(updated["last_name"], "Name"); // unchanged
assert_eq!(updated["gender"], "male"); // unchanged
}
// tests/api/households.rs
/// Verify household creation and member addition.
#[tokio::test]
async fn create_household_and_add_member() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
// Create a person first
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Head",
"last_name": "OfHousehold",
"date_of_birth": "1975-01-01"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
// Create household
let resp = client.post("/v1/households")
.json(&serde_json::json!({
"name": "Doe Household",
"effective_date": "2026-01-01"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let household: serde_json::Value = resp.json().await.unwrap();
let household_id = household["id"].as_str().unwrap();
// Add member
let resp = client.post(&format!("/v1/households/{household_id}/members"))
.json(&serde_json::json!({
"person_id": person_id,
"relationship": "head_of_household",
"effective_date": "2026-01-01"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let member: serde_json::Value = resp.json().await.unwrap();
assert_eq!(member["person_id"], person_id);
assert_eq!(member["relationship"], "head_of_household");
}
/// Verify adding an income record to a person.
#[tokio::test]
async fn add_income_record() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Worker",
"last_name": "Bee",
"date_of_birth": "1988-07-20"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
let resp = client.post(&format!("/v1/persons/{person_id}/income"))
.json(&serde_json::json!({
"income_type": "employment",
"amount": "2500.00",
"frequency": "monthly",
"employer_name": "Acme Corp",
"effective_date": "2026-01-15"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let income: serde_json::Value = resp.json().await.unwrap();
assert_eq!(income["income_type"], "employment");
assert_eq!(income["amount"], "2500.00");
assert_eq!(income["frequency"], "monthly");
assert_eq!(income["person_id"], person_id);
assert_eq!(income["verified"], false);
}
/// Verify adding an asset record to a person.
#[tokio::test]
async fn add_asset_record() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Asset",
"last_name": "Owner",
"date_of_birth": "1970-01-01"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
let resp = client.post(&format!("/v1/persons/{person_id}/assets"))
.json(&serde_json::json!({
"asset_type": "bank_account",
"description": "Checking account",
"value": "1500.00"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let asset: serde_json::Value = resp.json().await.unwrap();
assert_eq!(asset["asset_type"], "bank_account");
assert_eq!(asset["value"], "1500.00");
}
/// Verify adding an expense record to a person.
#[tokio::test]
async fn add_expense_record() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Renter",
"last_name": "Jones",
"date_of_birth": "1992-05-10"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
let resp = client.post(&format!("/v1/persons/{person_id}/expenses"))
.json(&serde_json::json!({
"expense_type": "shelter",
"amount": "1200.00",
"frequency": "monthly"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let expense: serde_json::Value = resp.json().await.unwrap();
assert_eq!(expense["expense_type"], "shelter");
assert_eq!(expense["amount"], "1200.00");
}
/// Verify adding an address to a person.
#[tokio::test]
async fn add_address() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Home",
"last_name": "Owner",
"date_of_birth": "1985-12-25"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
let resp = client.post(&format!("/v1/persons/{person_id}/addresses"))
.json(&serde_json::json!({
"address_type": "residential",
"line_1": "123 Main St",
"line_2": "Apt 4B",
"city": "Springfield",
"state": "IL",
"zip": "62704",
"county_fips": "17167",
"effective_date": "2026-01-01"
}))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let addr: serde_json::Value = resp.json().await.unwrap();
assert_eq!(addr["address_type"], "residential");
assert_eq!(addr["line_1"], "123 Main St");
assert_eq!(addr["state"], "IL");
assert_eq!(addr["county_fips"], "17167");
}
/// Verify 404 for non-existent person.
#[tokio::test]
async fn get_nonexistent_person_returns_404() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
let fake_id = "01951111-1111-7111-8111-111111111111";
let resp = client.get(&format!("/v1/persons/{fake_id}"))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::NOT_FOUND);
let body: serde_json::Value = resp.json().await.unwrap();
assert_eq!(body["type"], "about:blank");
assert_eq!(body["title"], "Not Found");
assert_eq!(body["status"], 404);
}
/// Verify removing a household member soft-deletes the membership.
#[tokio::test]
async fn remove_household_member() {
if !canopy_test_lib::infrastructure_available().await { return; }
let client = test_client().await;
// Create person, household, add member (setup same as create_household_and_add_member)
let resp = client.post("/v1/persons")
.json(&serde_json::json!({
"first_name": "Temp",
"last_name": "Member",
"date_of_birth": "2000-01-01"
}))
.send().await.unwrap();
let person: serde_json::Value = resp.json().await.unwrap();
let person_id = person["id"].as_str().unwrap();
let resp = client.post("/v1/households")
.json(&serde_json::json!({
"effective_date": "2026-01-01"
}))
.send().await.unwrap();
let household: serde_json::Value = resp.json().await.unwrap();
let household_id = household["id"].as_str().unwrap();
let resp = client.post(&format!("/v1/households/{household_id}/members"))
.json(&serde_json::json!({
"person_id": person_id,
"relationship": "child",
"effective_date": "2026-01-01"
}))
.send().await.unwrap();
let member: serde_json::Value = resp.json().await.unwrap();
let member_id = member["id"].as_str().unwrap();
// DELETE /v1/households/{id}/members/{member_id}
let resp = client.delete(&format!("/v1/households/{household_id}/members/{member_id}"))
.send().await.unwrap();
assert_eq!(resp.status(), StatusCode::NO_CONTENT);
}
Files Touched
| File | Change |
|---|---|
|
New: all seven tables |
|
Uncomment migration runner (line 15) |
|
New: module declarations |
|
New: Person, Household, HouseholdMember, Address, Income, Asset, Expense structs |
|
New: create, get, list, update, soft_delete query functions |
|
New: create, get, list_members, add_member, remove_member query functions |
|
New: create, list query functions |
|
New: create, list query functions |
|
New: create, list query functions |
|
New: create, list query functions |
|
Rewrite: router with all 13 routes, module declarations |
|
New: request types, handler functions with utoipa attributes |
|
New: handler functions for household CRUD |
|
Rewrite: five event publishing functions |
|
Add chrono, rust_decimal if not present |
|
New: test module wiring |
|
New: 8 integration test functions |
|
New: 2 integration test functions |
Verification
-
cargo nextest run -p canopy-persons— unit tests pass -
cargo xtask dev restart— migration runs against devstack -
cargo nextest run -p canopy-persons --profile integration— integration tests pass -
Manual:
curl http://localhost:8002/v1/personsreturns empty paginated response -
Manual: POST a person, GET it back, verify fields
Documentation Updates
-
.claude/docs/services.md— add persons endpoint table, event list -
CHANGELOG.adoc— entry under== Unreleased -
.claude/CLAUDE.md— update canopy-persons feature status