Plan: canopy-web Income Editing UI (Issue #409)
On this page
Status
| Step | Description | Status |
|---|---|---|
1 |
Action handlers. New |
Done (2026-05-11) — deviation: simpler route shape |
2 |
Form templates. New |
Done (2026-05-11) — deviation: forms embedded directly in |
3 |
Tab integration. Update |
Done (2026-05-11) — table gains an "Actions" column with per-row Edit/Remove |
4 |
Router wiring. Register the 3 new routes in |
Done (2026-05-11) — 3 routes registered: |
5 |
Tests. 4 Playwright specs at |
Done (2026-05-11) — 3 specs added to existing |
Issue: #409
Branch: feat/canopy-web-income-editing-ui
Labels: type::feature, priority::low, service::web, program::cross-program, workflow::ready
PUT /v1/persons/{id}/income/{income_id} and DELETE endpoints exist; this plan’s BFF wiring can ship without prerequisite work.
Context
services/canopy-web/templates/cases/tab_income.html renders a read-only income table with per-member rows and a "rule pointer" indicator showing which program-specific rule applies to each income source. Caseworkers cannot add, edit, or remove income from the worker portal — they have to go to the canopy-persons API directly or wait for the applicant to file a change report. Both are operationally awkward.
Per the architectural decision locked 2026-05-05, income mutates in place; no versioning to plan for. Determinations carry their own income snapshot in the signed JWS (SignableDetermination.program_extension) at the time of determination, so historical reproducibility is preserved without an income_versions layer.
The Tier B plan-refresh pass (2026-05-11) surfaced that this plan’s original assumption — that PUT /v1/income/{id} and DELETE /v1/income/{id} "already exist on canopy-persons" — was false. canopy-persons currently has only POST /v1/persons/{id}/income (add) and GET /v1/persons/{id}/income (list). The mutation endpoints are tracked under #446 and land first; this plan picks up when those endpoints exist.
Code references
-
services/canopy-web/templates/cases/tab_income.html— read-only table to extend. -
services/canopy-web/src/api/case_detail.rs:204-220—PersonIncomestruct used by the tab. -
services/canopy-web/src/api/actions.rs:27-63— handler-shape precedent (post-#424ServiceTokenSourcepattern). -
services/canopy-web/src/clients.rs:229-240—with_service_identitydefinition. -
services/canopy-web/src/csrf.rs:46— router-levelcsrf_middleware(NOT a per-handler extractor). -
canopy-persons-income-mutations (#446) — prereq.
-
service-identity-and-on-behalf-of (#424 / ADR-019) — current outbound-auth model.
-
Archived: canopy-web-persons-wiring.adoc — predecessor.
Scope
In scope:
-
3 BFF action handlers (add / edit / remove).
-
htmx form templates.
-
Tab integration with htmx swap regions.
-
4 Playwright specs.
Out of scope:
-
canopy-persons-side endpoint changes. Tracked under #446 and must land before this plan starts.
-
canopy-persons-side validation changes. The BFF surfaces upstream validation errors as htmx-error fragments verbatim.
-
Cross-program rule-pointer recalculation when income changes. The existing tab already pulls fresh rule pointers on each load; immediate post-edit redraw is sufficient.
-
Audit-log entries beyond what canopy-persons + canopy-security already emit. Income changes get captured at both the persons layer (direct write) and the wildcard event subscriber.
-
Bulk import / CSV upload. One income at a time.
-
Form validation beyond what canopy-persons enforces upstream.
Dependencies
-
Archived: canopy-web-persons-wiring.adoc — predecessor.
-
service-identity-and-on-behalf-of (#424 / ADR-019) — outbound-auth model used by every BFF action handler.
Design
Handler shape (mirrors the post-#424 pattern in actions.rs:27-63):
#[derive(Deserialize)]
pub struct AddIncomeForm {
pub household_id: String,
pub person_id: String,
pub income_type: String,
pub amount: String, // parses to Decimal in the handler — Form decoding is string-shaped
pub frequency: String,
pub employer_name: Option<String>,
pub effective_date: String, // parses to NaiveDate
}
pub async fn add_income(
AuthenticatedWorker(worker): AuthenticatedWorker,
_write: WritePermission,
Extension(svc_token): Extension<canopy_auth::ServiceTokenSource>,
Extension(clients): Extension<Arc<ServiceClients>>,
Form(form): Form<AddIncomeForm>,
) -> Result<Redirect, Html<String>> {
let persons = clients.with_service_identity(&svc_token).await
.map_err(|e| Html(format!("<div class=\"hx-error\">auth: {e}</div>")))?
.persons;
persons.add_income(&form.person_id, form.into_request())
.await
.map_err(|e| Html(format!("<div class=\"hx-error\">{e}</div>")))?;
Ok(Redirect::to(&format!("/cases/{}/income", form.household_id)))
}
CSRF is enforced at the router layer (csrf_middleware). Templates use htmx hx-post + hx-target + hx-swap="outerHTML". Cancel buttons trigger hx-get back to the read-only row.
Form-input parsing: HTML form submissions arrive as strings; the handler parses amount → Decimal and effective_date → NaiveDate before calling persons.update_income(…), surfacing parse errors as htmx error fragments. canopy-persons applies its own validator::Validate constraints on top.
The edit handler uses the UpdateIncome request type from canopy-persons (#446) — Option<T> on every field, partial-update friendly.
Files Touched
| File | Change |
|---|---|
|
New module: 3 handlers (add / edit / remove) |
|
Register the 3 routes |
|
Add |
|
Add buttons + htmx swap regions |
|
New shared form partial (add + edit) |
|
New confirmation template |
|
4 new Playwright specs |
|
|
|
canopy-web actions count refresh (currently 7; this adds 3 → 10) |
Verification
-
cargo nextest run -p canopy-web— handler unit tests pass. -
cargo xtask dev start && cargo xtask e2e — worker-portal-income-editing.spec.ts— 4 specs pass. -
Manual smoke: log in as caseworker, open a case detail’s income tab, add a new income row, edit it, remove it. Confirm canopy-persons reflects the changes (
GET /v1/persons/{id}/incomeshould show the new row after add, the updated row after edit, and exclude the row after remove). -
CSP smoke: open browser dev tools, confirm no
unsafe-inlineviolations during form interaction. -
cargo xtask validate— full battery green.
Documentation Updates
-
CHANGELOG.adoc— entry under== Unreleased/=== Added -
.claude/docs/services.md— canopy-web actions count update (7 → 10) -
docs/modules/ROOT/pages/api/canopy-web.adoc— case detail income-tab editing flow -
Plan archive: move to
plans/archive/post-merge