refactor(ssr): Phase 7 — migrate Invoice Pay onto the engine; prove the cross-step merge

Invoice Pay is the first GENUINE multi-data-step wizard, and migrating it exercises
the engine's central abstraction for the first time: choose-method collects
{:bank-account :method}, payment-details collects {:invoices :check-number
:handwritten-date :mode}, and the engine's get-all MERGES the two independent step
payloads for the per-method pay (handwrite-check transacts a pending check; the
others go through print-checks-internal). This is exactly the mechanism the Phase-6
adversarial review flagged as unproven.

What changed
- Deleted the 3 wizard records (PayWizard / ChoosePaymentMethodModal /
  PaymentDetailsStep), MultiStepFormState, the EDN snapshot, and the step-params[...]
  prefix. Replaced with pay-wizard-config (init-fn builds read-only :context;
  two steps; done-fn = pay!) driven by wizard2.
- De-cursored the payment-details amounts grid (fc/cursor-map -> explicit
  (map-indexed) over :context :invoices with path->name2 names).
- The bank-account cards' method controls now post {bank-account, method,
  direction:next} straight to the engine submit-route (was a bespoke navigate route).
- Routes 3 -> 2: open-pay-wizard (GET), pay-step (every transition); the
  pay-wizard-navigate route is deleted.
- Used the post-review engine primitives: :open-response (modal wrap), nav-footer
  (with new :save-label "Pay"), auto nav-field stripping (flat decode, no allowlist),
  Enter guard.

invoices.clj falls fully off the framework: Invoice Pay was the last mm/fc user
(bulk-edit went in Phase 5), so fc/ 0, mm/ 0, defrecord 0, step-params 0 — and the
multi-modal / form-cursor / malli.util requires are removed.

Gotcha discovered + documented: wizard session data must be EDN-safe (the cookie
session store has no clj-time readers), so the date default is computed in render,
not stored in context.

Verification: invoice-pay spec 3/3 (the merge end-to-end); full suite 58/58; load-file
clean; cljfmt clean. Skill fed: scorecard row (merge proven; whole-file zeroing) +
the EDN-session-safety gotcha.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 19:59:24 -07:00
parent 01aca9d362
commit 4b2a3e53dd
6 changed files with 383 additions and 367 deletions

View File

@@ -309,3 +309,27 @@ the mm-coupling / snapshot / route counts are.
They are terminal responses (shown after the form closes), reuse a shared dialog component,
and sit outside the form's interactive render path. Migrating them means porting the shared
`success-modal` to Selmer — a Phase 11 cross-cutting task, not a single-modal one.
## Keep wizard session data EDN-safe (the cookie store has no custom readers)
The session-backed engine stores per-step data + context in the Ring session, and this app's
session store is a **cookie-store** (`ring.middleware.session.cookie`) that serializes with
`pr-str` and reads back with plain `clojure.edn/read-string` — **no custom tag readers**. So
anything you put in a wizard's `:context` or that a step `:decode` returns (which `put-step`
persists) must round-trip through bare EDN. A `clj-time` `DateTime` does not: it `pr-str`s as
`#clj-time/date-time "…"` and the read side 500s with **"No reader function for tag
clj-time/date-time"** on the *next* request that reads the cookie.
This first bit Invoice Pay (Phase 7), whose context defaulted `:handwritten-date (time/now)`.
Rules of thumb:
- **Context**: store only EDN-safe primitives (numbers, strings, keywords, vectors, maps,
`#inst`/`java.util.Date`). Compute clj-time defaults in the *render* fn, not in context.
- **Step data**: a `clj-time` value decoded by a step is fine *in memory* on the terminal
(`:done`) path — `get-all` reads it before `forget` clears the wizard, so it never reaches
the cookie. It only bites if a clj-time value survives in a step that gets re-persisted
(a non-terminal `put-step`). When in doubt, decode dates to `#inst` or keep them as strings
until the done-fn.
- The old `mm` wizard dodged this because it read its EDN snapshot with
`clojure.edn/read-string {:readers clj-time.coerce/data-readers}` (see `multi_modal.clj`) —
the cookie store has no such readers. (A durable/typed session backend would remove this
constraint; until then, EDN-safe is the rule. See `form-vs-wizard.md` open question.)

View File

@@ -187,3 +187,36 @@ Each migration appends one row (after-numbers), referencing the before in the di
> (heuristic 9) is therefore a documented partial here; the leaf-component `com/ -> sc/` swap is
> a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId ->
> location) was preserved verbatim — de-cursoring touched only the data plumbing.
> **Phase 7 — Invoice Pay: the engine's cross-step merge, finally proven.** The first
> *genuine* multi-data-step wizard (every prior one was single-data-step wearing wizard
> costume, or edit+preview of one entity). Step 1 `choose-method` collects
> `{:bank-account :method}`; step 2 `payment-details` collects
> `{:invoices :check-number :handwritten-date :mode}`; the engine's `get-all` **merges the two
> independent payloads** for the per-method `pay!` (handwrite-check transacts a pending check;
> the others go through `print-checks-internal`). This is the exact mechanism the Phase-6
> reviewer flagged as unproven — now exercised end-to-end (gate 3/3: choose-method renders the
> bank-account + methods → handwrite-check advances to details → check number + submit shows
> the completion modal). Conditional rendering by method (handwrite shows check-number, print
> shows date) lives in step 2's render, reading `:method` from `:all-data`.
>
> **The whole file falls off the framework.** Invoice Pay was the *last* `mm`/`fc` user in
> `invoices.clj` (bulk-edit went in Phase 5), so the migration zeroed the file: `fc/` cursor
> refs **0**, `mm/` **0**, `defrecord` **0** (PayWizard + ChoosePaymentMethodModal +
> PaymentDetailsStep all gone), `step-params` **0** — and the `multi-modal` / `form-cursor` /
> `malli.util` requires were deleted outright. The pay wizard's 3 routes (open / navigate /
> submit) collapse to **2** (open = `open-pay-wizard`, every transition = `pay-step`); the
> cards post `{bank-account, method, direction:next}` straight to the engine submit-route
> instead of a bespoke navigate route.
>
> **Engine dividends from the review follow-up paid off here.** This migration *used* the
> primitives the engine absorbed after Phase 6: `:open-response` (modal wrap, so open is one
> handler), `nav-footer` (with the new `:save-label "Pay"`), the auto nav-field stripping (the
> flat `{bank-account, method}` decode needs no allowlist), and the Enter guard — so the
> consumer is config + render + the per-method `pay!`, not framework plumbing.
>
> **New constraint discovered:** wizard session data must be EDN-safe (the cookie store has no
> clj-time readers) — see `gotchas.md`. The de-cursored amounts grid stores the enriched
> invoice list in `:context`, which is fine at gate scale (1 invoice) but is the session-bloat
> risk the reviewer named; a leaner context (ids + amounts, re-query in render) is the
> follow-up if real payments carry many invoices.