Plan: canopy-persons Income Mutation Endpoints (Issue #446)

On this page

Status

Step Description Status

1

Store-layer mutations. Add update(pool, person_id, income_id, &UpdateIncome) → sqlx::Result<Income> and soft_delete(pool, person_id, income_id) → sqlx::Result<Income> to services/canopy-persons/src/store/income.rs (currently 31 LOC, only add + list_by_person). update uses partial-update SQL: UPDATE income SET … WHERE id = $N AND person_id = $M AND active = true; returns the updated row or sqlx::Error::RowNotFound. soft_delete sets active = false, end_date = CURRENT_DATE, updated_at = now(); same AND active = true predicate keeps the call idempotent (re-deleting returns RowNotFound).

Done (2026-05-11) — update uses COALESCE($N, col) for partial-update semantics. soft_delete matches the design.

2

Request type. Add UpdateIncome to services/canopy-persons/src/store/models.rs alongside CreateIncome (line 219). All fields Option<T> with [serde(default)] + the same validator::Validate constraints applied only when present ([validate(length(…​))] on Option works via the nested attribute or per-field guards — use the same pattern other models in this file use; CreatePerson / UpdatePerson at lines 106 / 135 are the precedent). Fields: income_type, amount, frequency, employer_name, effective_date, end_date, verified, verification_source. person_id and id come from path params, not body.

Done (2026-05-11) — UpdateIncome derives Default so missing fields don’t need explicit nulls in the request body. Validator length constraints fire on Option<String> via direct attributes (no nested).

3

Handlers. Add update_income and delete_income to services/canopy-persons/src/api/handlers.rs (or wherever existing add_income / list_income handlers live; verify location). Path: /persons/{id}/income/{income_id}. update_income validates the request body, calls store::income::update, returns 200 with the updated Income or 404 if RowNotFound. delete_income calls store::income::soft_delete, returns 204 (no body) or 404. Both use claims.require_service_caller()? per ADR-019 (canopy-web is service-class post-#424; direct human callers go through canopy-web).

Done (2026-05-11) — handlers live in services/canopy-persons/src/api/mod.rs (canopy-persons has no separate handlers.rs). The shared From<sqlx::Error> impl maps RowNotFound to 500, so each handler explicitly maps RowNotFound → ApiError::NotFound. Both gated by claims.require_service_caller()? per ADR-019.

4

Router wiring. Update services/canopy-persons/src/api/mod.rs:44 to add .put(update_income).delete(delete_income) on the income route. Final shape: .route("/persons/{id}/income/{income_id}", put(update_income).delete(delete_income)) as a NEW route (the existing /persons/{id}/income is for the collection — POST + GET only).

Done (2026-05-11) — new route registered at the collection-route’s sibling line.

5

OpenAPI + tests. Re-generate docs/modules/ROOT/openapi/persons.json via cargo xtask api-docs --update. Add 2 utoipa #[utoipa::path] decorators on the new handlers. Add 2 integration tests in services/canopy-persons/tests/persons_test.rs (or income_test.rs if a separate file is more natural): (a) update happy path — POST income, PUT change, GET reflects, (b) delete happy path — POST income, DELETE, GET excludes (verifies soft-delete). One unit test on the RowNotFound → 404 mapping.

Done (2026-05-11) — utoipa decorators on both handlers; openapi_doc_generates test path-count assertion bumped 11→12 (PUT and DELETE share one path entry). 5 integration tests (PUT happy path + partial update; PUT missing row 404; DELETE soft-delete + list exclusion; double-DELETE 404; PUT on row owned by other person 404). 31/31 canopy-persons tests pass.

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_person only).

  • services/canopy-persons/src/store/models.rs:201-216Income struct (the row shape; note active: bool, end_date: Option<NaiveDate> already exist — soft-delete uses these existing columns, no migration).

  • services/canopy-persons/src/store/models.rs:219-229CreateIncome (UpdateIncome mirrors this with all-Option<T> fields).

  • services/canopy-persons/src/store/models.rs:106-135CreatePerson / 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}.

  • UpdateIncome request struct.

  • store::income::update + store::income::soft_delete.

  • OpenAPI snapshot + 2 integration tests.

Out of scope:

  • Schema migration. The Income row already has active: bool and end_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

services/canopy-persons/src/store/income.rs

Add update + soft_delete functions

services/canopy-persons/src/store/models.rs

Add UpdateIncome request struct

services/canopy-persons/src/api/handlers.rs (or wherever add_income lives — verify)

Add update_income + delete_income handlers with utoipa decorators

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

Register /persons/{id}/income/{income_id} route

services/canopy-persons/tests/persons_test.rs (or new income_test.rs)

2 integration tests (update + delete) + 1 unit test (404 mapping)

docs/modules/ROOT/openapi/persons.json

Regenerated via cargo xtask api-docs --update

CHANGELOG.adoc

=== Added entry covering the two new endpoints

.claude/docs/services.md

canopy-persons route count: 17 → 19 (or whatever the post-#446 actual count is)

Verification

  1. cargo build -p canopy-persons clean.

  2. cargo clippy -p canopy-persons --all-targets — -D warnings clean.

  3. cargo nextest run -p canopy-persons — all tests pass including 3 new ones.

  4. cargo xtask api-docs --updatepersons.json snapshot updated with 2 new operations.

  5. cargo xtask validate --skip-docker passes.

  6. Manual: with devstack up, glab an auth token then curl -X PUT …​/persons/{id}/income/{income_id} against a real row; confirm 200 + updated row. Then curl -X DELETE …​ and confirm 204 + GET excludes the row.

Documentation Updates

  • CHANGELOG.adoc=== Added entry

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

Edit this page · default