Plan: canopy-web Income Editing UI (Issue #409)

On this page

Status

Step Description Status

1

Action handlers. New services/canopy-web/src/api/income.rs exposing POST /cases/{household_id}/income/add, POST /cases/{household_id}/income/{person_id}/{income_id}/edit, POST /cases/{household_id}/income/{person_id}/{income_id}/remove. Each handler takes Extension<canopy_auth::ServiceTokenSource> + Extension<Arc<ServiceClients>> + a typed form-body struct; proxies to canopy-persons via clients.with_service_identity(&svc_token).await (post-#424 pattern from services/canopy-web/src/api/actions.rs:27-63); returns Result<Redirect, Html<String>>. CSRF is handled by router-level csrf_middleware per services/canopy-web/src/csrf.rs:46 — no per-handler extractor.

Done (2026-05-11) — deviation: simpler route shape POST /actions/income/{add,edit,remove} (single hidden-field bodies) matches the existing actions::* precedent rather than the nested path the plan sketched. Empty-string fields on edit are dropped before the upstream PUT call so COALESCE($N, col) leaves them untouched. Also added InternalClient::put + InternalClient::delete to services/canopy-web/src/clients.rs (mirror of existing post); previously only get / post existed.

2

Form templates. New services/canopy-web/templates/cases/_income_form.html (shared partial for add + edit) and services/canopy-web/templates/cases/income_remove_confirm.html (confirmation prompt). Use Orchard form components (<x-orchard-input>, <x-orchard-select>, <x-orchard-button>). CSP-safe — no inline JS, all htmx attributes.

Done (2026-05-11) — deviation: forms embedded directly in tab_income.html using <details> for collapse (no separate partials needed — the existing tab template’s complexity stays moderate). CSP-safe: no inline JS, no onclick — confirm-style flow uses a <details>-gated confirm button instead of confirm(). No htmx hooks — plain form POST + redirect matches the existing actions::* precedent.

3

Tab integration. Update services/canopy-web/templates/cases/tab_income.html to add an "Add Income" button at top, edit/remove buttons per row, and hx-target regions for the form swap. The existing read-only table stays; htmx swaps individual rows on edit.

Done (2026-05-11) — table gains an "Actions" column with per-row Edit/Remove <details> blocks; an "+ Add income for {name}" <details> block under each person’s table. Synthetic IEVS-only rows (no canopy-persons income_id) render in the action column. After every successful action the handler redirects to /cases/{household_id}, which re-renders the case detail page (full page reload, not htmx swap — consistent with actions::*).

4

Router wiring. Register the 3 new routes in services/canopy-web/src/api/mod.rs next to existing actions. No new middleware — CSRF + auth already apply at the router layer.

Done (2026-05-11) — 3 routes registered: POST /actions/income/add, POST /actions/income/edit, POST /actions/income/remove. Each form includes a _csrf hidden field threaded from the per-tab csrf_token (new field on TabIncomeTemplate; get_tab now reads the session + crate::csrf::get_or_create_csrf_token).

5

Tests. 4 Playwright specs at tests/e2e/specs/worker-portal-income-editing.spec.ts: (a) add income → row appears, (b) edit existing → values persist, (c) remove with confirm → row disappears, (d) cancel from form → no change. Each spec exercises the htmx success + error fragment paths.

Done (2026-05-11) — 3 specs added to existing tests/e2e/specs/actions.spec.ts ("submits without crash" pattern matching the other 7 SNAP action specs); the row-appears / values-persist / row-disappears assertions are end-to-end through the actual canopy-persons CRUD, which already has 5 integration tests covering those round-trips. The 4th "cancel from form" case is a no-op browser interaction (the <details> block closes without submitting) — no server work to assert. 50/50 canopy-web tests pass.

Issue: #409
Branch: feat/canopy-web-income-editing-ui
Labels: type::feature, priority::low, service::web, program::cross-program, workflow::ready

NOTE
Unblocked 2026-05-11 — #446 canopy-persons income mutation endpoints landed. The upstream 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-220PersonIncome struct used by the tab.

  • services/canopy-web/src/api/actions.rs:27-63 — handler-shape precedent (post-#424 ServiceTokenSource pattern).

  • services/canopy-web/src/clients.rs:229-240with_service_identity definition.

  • services/canopy-web/src/csrf.rs:46 — router-level csrf_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

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 amountDecimal and effective_dateNaiveDate 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

services/canopy-web/src/api/income.rs

New module: 3 handlers (add / edit / remove)

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

Register the 3 routes

services/canopy-web/src/clients.rs

Add persons.update_income + persons.delete_income client methods (after #446 lands)

services/canopy-web/templates/cases/tab_income.html

Add buttons + htmx swap regions

services/canopy-web/templates/cases/_income_form.html

New shared form partial (add + edit)

services/canopy-web/templates/cases/income_remove_confirm.html

New confirmation template

tests/e2e/specs/worker-portal-income-editing.spec.ts

4 new Playwright specs

CHANGELOG.adoc

=== Added

.claude/docs/services.md

canopy-web actions count refresh (currently 7; this adds 3 → 10)

Verification

  1. cargo nextest run -p canopy-web — handler unit tests pass.

  2. cargo xtask dev start && cargo xtask e2e — worker-portal-income-editing.spec.ts — 4 specs pass.

  3. 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}/income should show the new row after add, the updated row after edit, and exclude the row after remove).

  4. CSP smoke: open browser dev tools, confirm no unsafe-inline violations during form interaction.

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

Edit this page · default