refactor(ssr): Phase 8 — migrate New/Edit Invoice onto the engine (conditional :next)

The hardest modal in the app: one wizard that both creates and edits invoices,
with a conditional middle step (basic-details → [accounts] → next-steps, where
the expense-accounts step is skipped on the default-accounts path). Migrated off
mm/* + form-cursor + the EDN snapshot onto the session-backed engine (wizard2).

Finding: the OLD basic-details "Save" was broken. It hx-puts /invoice/new/navigate,
whose `[:to {:optional true} …]` query-schema 500s on empty query-params — Ring's
wrap-params yields {} for a no-query PUT, and main-transformer's parse-empty-as-nil
decodes {} → nil, which the bare [:map] rejects. Production uses the identical
wrap-params, so it was broken there too. So e2e/invoice-new.spec.ts is an ACCEPTANCE
gate (red on the old code, green on the engine, whose submit is a POST with no
query-schema): the migration fixes a latent bug. Create semantics (default → vendor
default account, location-spread; customize → posted grid; edit → prefill + updated
row) were pinned at the REPL.

What changed:
- defrecord 4 → 0 (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps), mm/ 0,
  fc/ cursor refs 0, step-params[…] field names 0.
- Conditional `:next` `(if (= :customize …) :accounts :done)` replaces mm/CustomNext +
  the broken 308-to-submit. Dual-purpose new+edit = one :init-fn branching on a route
  :db/id; create-wizard! seeds :init-data as per-step step-data so edit opens populated.
- The broken new-wizard-navigate route is deleted; the genuine async helpers
  (account-prediction, due/scheduled-payment-date, location-select, expense total/balance,
  add-row) remain but read the posted flat form (+ ws/get-all for the cross-step total).
- next-steps becomes the done-fn's returned modal (Pay now / Add another / Close).
- Dates ride as java.util.Date (#inst) in step-data so it's EDN-safe across the
  non-terminal step (clj-time DateTimes break the cookie store).

Verification: full e2e suite 61/61 (58 prior + 3 new); maybe-spread-locations unit
test 6/6; create semantics + edit prefill confirmed at the REPL. Skill fed
(scorecard Phase 8, gotchas {}→nil 500 + #inst dates, form-vs-wizard conditional
:next + dual-purpose).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 22:05:01 -07:00
parent 7a53441ec7
commit 5502f4c4a2
6 changed files with 820 additions and 625 deletions

View File

@@ -333,3 +333,35 @@ Rules of thumb:
`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.)
## A bare `[:map …]` query-schema 500s on empty query-params (the `{}`→nil trap)
`auto-ap.ssr.utils/main-transformer` includes `parse-empty-as-nil`, whose **`:map` decoder
turns any map with no truthy values into `nil`** (`(if (seq (filter identity (vals m))) m nil)`).
So `(mc/coerce [:map [:k {:optional true} …]] {} main-transformer)` decodes `{}` → `nil`,
then validates `nil` against `[:map …]` → `:malli.core/invalid-type` → **500**.
Ring's `wrap-params` sets `:query-params` to `{}` (not nil) for a request with no query
string. So **any handler wrapped with `wrap-schema-enforce :query-schema [:map …]` 500s on a
PUT/POST that carries no `?query`** — `(and query-schema query-params)` is truthy for `{}`,
so the coercion runs and blows up. This is exactly why the pre-migration New Invoice
basic-details "Save" was broken: its button `hx-put`s `/invoice/new/navigate` (no `?to`), and
`mm/next-handler`'s `[:to {:optional true} …]` query-schema 500d every time (the
`CustomNext`/308-to-submit logic never even ran).
- A `[:maybe [:map …]]` query-schema survives (`nil` is valid) — that's why the *grid*
query-schema, hit by the same empty POST, doesn't throw.
- **The engine sidesteps this entirely**: `handle-step-submit` is a POST with **no**
query-schema, so empty query-params never reach a `[:map]` coercion. Migrating a wizard
off the `mm` navigate route *removes* the bug; you don't need to fix the old route.
## Keep wizard dates as `#inst`, not clj-time, in step-data
Reinforcing the EDN-safety rule above: a new+edit wizard that stores dates across a
non-terminal step (New Invoice: `basic-details` holds `:invoice/date` while you visit
`accounts`) must keep them **EDN-safe**. Decode them to `java.util.Date` (`coerce/to-date`)
before they land in step-data, and coerce back to clj-time only for display
(`coerce/from-date` → `atime/unparse-local`). A helper that maps over the date keys
(`->edn-safe-dates`) right after `mc/decode` is the clean seam — both the step `:decode` and
the edit `:init-fn` run the posted/persisted map through it. Datomic's upsert wants
`java.util.Date` anyway, so the done-fn needs no extra conversion.