Plan: canopy-persons Income Mutation Endpoints (Issue #446)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Store-layer mutations. Add |
Done (2026-05-11) — |
2 |
Request type. Add |
Done (2026-05-11) — |
3 |
Handlers. Add |
Done (2026-05-11) — handlers live in |
4 |
Router wiring. Update |
Done (2026-05-11) — new route registered at the collection-route’s sibling line. |
5 |
OpenAPI + tests. Re-generate |
Done (2026-05-11) — utoipa decorators on both handlers; |
Issue: #446
Branch: feat/canopy-persons-income-mutations
Labels: type::feature, priority::low, service::persons, program::cross-program, workflow::needs-spec
Context
Per the architectural decision locked 2026-05-05 (income mutates in place; no income_versions layer; determinations carry their own income snapshot in the JWS via SignableDetermination.program_extension), the canopy-persons income surface needs PUT + DELETE to let caseworkers mutate income rows after intake.
The Tier B plan-refresh pass (2026-05-11) for #409 (canopy-web income editing UI) surfaced that the #409 plan assumed these endpoints already exist. They do not. canopy-persons has only POST /v1/persons/{id}/income (add) and GET /v1/persons/{id}/income (list).
#409 (BFF wiring) waits on this plan landing.
Code references
-
services/canopy-persons/src/api/mod.rs:44— current income route registration (POST + GET only). -
services/canopy-persons/src/store/income.rs:1-31— current store module (add+list_by_persononly). -
services/canopy-persons/src/store/models.rs:201-216—Incomestruct (the row shape; noteactive: bool,end_date: Option<NaiveDate>already exist — soft-delete uses these existing columns, no migration). -
services/canopy-persons/src/store/models.rs:219-229—CreateIncome(UpdateIncomemirrors this with all-Option<T>fields). -
services/canopy-persons/src/store/models.rs:106-135—CreatePerson/UpdatePerson(precedent for the create-vs-update pattern with optional validator constraints). -
canopy-web-income-editing-ui (#409) — downstream consumer.
Scope
In scope:
-
PUT /v1/persons/{id}/income/{income_id}+DELETE /v1/persons/{id}/income/{income_id}. -
UpdateIncomerequest struct. -
store::income::update+store::income::soft_delete. -
OpenAPI snapshot + 2 integration tests.
Out of scope:
-
Schema migration. The
Incomerow already hasactive: boolandend_date: Option<NaiveDate>columns; soft-delete uses these. No new columns, no migration. -
Income versioning. Per architectural decision, income mutates in place.
-
Bulk operations. One income at a time.
-
Cross-program audit propagation. canopy-security captures these via the existing wildcard event subscriber; no per-handler audit code.
-
BFF integration. That’s #409.
Dependencies
-
None blocking. ADR-019 service-class JWT pattern is already standard; ADR-002 signed determinations are unaffected (snapshot is in the JWS, not the live row).
-
#409 canopy-web Income Editing UI depends on THIS plan; the BFF cannot wire without these endpoints.
Design
UpdateIncome struct (mirror of CreateIncome with everything optional):
#[derive(Debug, Deserialize, Validate, utoipa::ToSchema)]
pub struct UpdateIncome {
#[validate(length(min = 1, max = 50))]
pub income_type: Option<String>,
#[schema(value_type = String)]
pub amount: Option<Decimal>,
#[validate(length(min = 1, max = 20))]
pub frequency: Option<String>,
#[validate(length(max = 200))]
pub employer_name: Option<String>,
pub effective_date: Option<NaiveDate>,
pub end_date: Option<NaiveDate>,
pub verified: Option<bool>,
pub verification_source: Option<String>,
}
Store-layer update uses COALESCE to keep unchanged fields:
pub async fn update(
pool: &PgPool,
person_id: PersonId,
income_id: IncomeId,
req: &UpdateIncome,
) -> sqlx::Result<Income> {
sqlx::query_as::<_, Income>(
"UPDATE income
SET income_type = COALESCE($3, income_type),
amount = COALESCE($4, amount),
frequency = COALESCE($5, frequency),
employer_name = COALESCE($6, employer_name),
effective_date = COALESCE($7, effective_date),
end_date = COALESCE($8, end_date),
verified = COALESCE($9, verified),
verification_source = COALESCE($10, verification_source),
updated_at = now()
WHERE id = $1 AND person_id = $2 AND active = true
RETURNING *",
)
.bind(income_id)
.bind(person_id)
.bind(req.income_type.as_deref())
.bind(req.amount)
.bind(req.frequency.as_deref())
.bind(req.employer_name.as_deref())
.bind(req.effective_date)
.bind(req.end_date)
.bind(req.verified)
.bind(req.verification_source.as_deref())
.fetch_one(pool)
.await
}
pub async fn soft_delete(
pool: &PgPool,
person_id: PersonId,
income_id: IncomeId,
) -> sqlx::Result<Income> {
sqlx::query_as::<_, Income>(
"UPDATE income
SET active = false, end_date = CURRENT_DATE, updated_at = now()
WHERE id = $1 AND person_id = $2 AND active = true
RETURNING *",
)
.bind(income_id)
.bind(person_id)
.fetch_one(pool)
.await
}
Both functions return sqlx::Error::RowNotFound when the predicate fails (wrong person, deleted, etc.) — handler maps to 404 via the existing ApiError conversion.
Files Touched
| File | Change |
|---|---|
|
Add |
|
Add |
|
Add |
|
Register |
|
2 integration tests (update + delete) + 1 unit test (404 mapping) |
|
Regenerated via |
|
|
|
canopy-persons route count: 17 → 19 (or whatever the post-#446 actual count is) |
Verification
-
cargo build -p canopy-personsclean. -
cargo clippy -p canopy-persons --all-targets — -D warningsclean. -
cargo nextest run -p canopy-persons— all tests pass including 3 new ones. -
cargo xtask api-docs --update—persons.jsonsnapshot updated with 2 new operations. -
cargo xtask validate --skip-dockerpasses. -
Manual: with devstack up,
glaban auth token thencurl -X PUT …/persons/{id}/income/{income_id}against a real row; confirm 200 + updated row. Thencurl -X DELETE …and confirm 204 +GETexcludes the row.
Documentation Updates
-
CHANGELOG.adoc—=== Addedentry -
.claude/docs/services.md— canopy-persons route-count + endpoint table refresh -
docs/modules/ROOT/pages/api/canopy-persons.adoc— add the two endpoints to the income section -
OpenAPI snapshot regenerated (verifies the surface change auto-flows to consumers)
-
Plan archive: move to
plans/archive/post-merge