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

@@ -189,3 +189,43 @@ entity*, not a true multi-data-step flow — so it exercises the engine's render
preview path (`:all-data` feeds the test table) but not the cross-step *merge*. The merge
(`get-all` combining independent steps) gets its real workout in Phase 7+ (Invoice Pay,
New Invoice, Vendor, Client), where steps collect genuinely different fields.
## Conditional `:next` + dual-purpose (new+edit) — New Invoice (Phase 8)
A step's `:next` is just `(fn [data] -> next-step-key | :done)`, so **branching the flow is a
one-liner** — no `CustomNext` protocol, no 308-redirect-to-submit hack:
```clojure
{:key :basic-details
:next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))}
```
`:default` skips the expense-accounts step entirely (the done-fn uses the vendor's default
account); `:customize` routes through the grid. The old wizard expressed this with
`mm/CustomNext` returning either `navigate-handler{:to :accounts}` or a 308 to the submit
route — and the 308 path was broken (see `gotchas.md`, the `{}`→nil 500). The engine's
conditional `:next` is both simpler and correct.
**Dual-purpose (create *and* edit) = one config, one `:init-fn` that branches on a route id:**
```clojure
(defn new-init-fn [request]
(if-let [id (->db-id (get-in request [:route-params :db/id]))]
{:init-data {:basic-details (… entity prefilled, :customize-accounts :customize)
:accounts {:invoice/expense-accounts (… existing rows)}}} ; edit
{:init-data {:basic-details {:invoice/date (coerce/to-date (time/now)) ; new
:customize-accounts :default}}}))
```
`create-wizard!` stores `:init-data` **as the per-step `:step-data` map directly**, so seeding
`{:basic-details … :accounts …}` opens both steps populated — the edit case repopulates the
grid without a separate hydrate. Two open routes (`new-wizard`, `edit-wizard`) both reduce to
`(partial wizard2/open-wizard config)`; the done-fn branches on `(:db/id all-data)` to return
the next-steps modal (create) vs the swapped table row (edit).
**Async step fragments read the posted form, not multi-form-state.** The basic-details
fragments (account-prediction radio, due-date / scheduled-payment suggestions) and the
accounts totals all post the whole `#wizard-form`; in the engine that form carries the flat
`invoice/*` fields + the opaque `wizard-id`, so a fragment decodes what it needs straight from
`form-params` (and, for a cross-step value like the invoice total on the accounts step, reads
`ws/get-all` via the posted `wizard-id`). No `mm/wrap-decode-multi-form-state` stack survives.

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.

View File

@@ -220,3 +220,43 @@ Each migration appends one row (after-numbers), referencing the before in the di
> 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.
---
## Phase 8 — New / Edit Invoice (the conditional-`:next`, dual-purpose wizard)
`auto-ap.ssr.invoice.new-invoice-wizard` — 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 accounts is skipped on the default-accounts path), Solr
typeaheads for client+vendor, an async account-prediction fragment, and live expense-account
totals.
**Finding: the OLD basic-details "Save" was broken.** It `hx-put`s `/invoice/new/navigate`,
whose `[:to {:optional true} …]` query-schema 500s on empty query-params (the `{}`→nil
`main-transformer` quirk — see `gotchas.md`). Production uses the identical `wrap-params`, so
it was broken there too; the underlying create only worked when POSTed straight to
`new-invoice-submit`. So the Phase 8 gate (`e2e/invoice-new.spec.ts`) is an **acceptance**
gate, not a green→green characterization: red on the old code, green on the engine (whose
submit is a POST with no query-schema). The migration *fixes* a latent bug. The create
*semantics* (default → vendor default account, location-spread; customize → the posted grid;
edit → prefilled + updated row) were pinned via REPL before/around the migration.
**Conditional `:next` is a one-liner** (`(if (= :customize …) :accounts :done)`) replacing the
`mm/CustomNext` protocol + the broken 308-to-submit. **Dual-purpose = one `:init-fn` branching
on a route `:db/id`**; `create-wizard!` seeds `:init-data` as per-step step-data so edit opens
both steps populated. See `form-vs-wizard.md`.
**Coupling outcome (the review's lens).** The whole wizard collapses to *config + render +
fragments*: `defrecord` **4 → 0** (NewWizard2 / BasicDetailsStep / AccountsStep / NextSteps all
gone), `mm/` **0**, `fc/` cursor refs **0**, `step-params[…]` field names **0**. The broken
`new-wizard-navigate` route is **deleted** (3 wizard-nav routes → the engine's open + submit);
the genuine async helpers (account-prediction, due-date, scheduled-payment-date,
location-select, expense-account total/balance, add-row) remain but were **de-coupled from
multi-form-state** — each now reads the posted flat form (+ `ws/get-all` for the one cross-step
value). `next-steps` stops being a wizard step and becomes the done-fn's returned modal (Pay
now / Add another / Close), matching the Phase 7 pay-success shape.
**Verification:** full e2e suite **61/61** (58 prior + 3 new: basic-details renders;
default-path create → next-steps; customize-path → accounts grid → create → next-steps); the
`maybe-spread-locations` unit test still 6/6; create semantics + edit prefill confirmed at the
REPL; dates ride as `#inst` so step-data is EDN-safe across the non-terminal step.

101
e2e/invoice-new.spec.ts Normal file
View File

@@ -0,0 +1,101 @@
import { test, expect } from '@playwright/test';
// Acceptance spec for the New Invoice wizard (basic-details -> [accounts] -> next-steps),
// the dual-purpose new+edit wizard with a CONDITIONAL middle step: basic-details creates
// straight away when customize-accounts = :default (the vendor's default expense account),
// and routes through the expense-accounts grid when :customize.
//
// NOTE: the pre-migration `mm` flow's basic-details "Save" was broken in this harness (and
// prod): the button PUT /invoice/new/navigate, whose `:to` query-schema 500s on empty
// query-params (the {}->nil main-transformer quirk). So this is an ACCEPTANCE gate -- red on
// the old code, green on the engine (whose submit is a POST with no query-schema). The seed
// exposes client TEST + vendor "Test Vendor" (default account "Test Account") via /test-info.
test.beforeEach(async ({ request }) => { await request.post('/test-reset'); });
async function seedIds(page: any): Promise<{ client: number; vendor: number }> {
const info = await (await page.request.get('/test-info')).json();
return { client: info.clientIds.test, vendor: info.accounts.vendor };
}
// Open the wizard from the invoice list (so htmx/alpine are present -- opening the modal
// fragment directly would submit natively).
async function openNewWizard(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto('/invoice');
await page.waitForSelector('#entity-table tbody tr');
await page.locator('button:has-text("New invoice")').first().click();
await page.waitForSelector('#wizard-form');
await page.waitForTimeout(400);
}
// The vendor field is a typeahead whose hidden input posts invoice/vendor; set it the way a
// dropdown pick would land (the value the form submits).
async function setVendor(page: any, vendorId: number) {
await page.evaluate((id: number) => {
const hidden = document.querySelector('input[name="invoice/vendor"]') as HTMLInputElement;
hidden.value = String(id);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
}, vendorId);
}
// The customize-accounts radio lives in an async fragment loaded on the "bryce" event (fired
// when the Alpine vendorId changes). Trigger that htmx load explicitly after setting vendor.
async function loadPrediction(page: any) {
await page.evaluate(() => {
const el = document.querySelector('#expense-account-prediction [hx-put], #expense-account-prediction[hx-put]');
// @ts-ignore
if (el && window.htmx) window.htmx.trigger(el, 'bryce');
});
await page.waitForTimeout(600);
}
const save = (page: any) => page.locator('#wizard-form button:has-text("Save")').first().click();
test.describe.configure({ mode: 'serial' });
test.describe('New Invoice wizard (acceptance)', () => {
test('basic-details renders the invoice fields', async ({ page }) => {
await openNewWizard(page);
const form = page.locator('#wizard-form');
await expect(form).toContainText('New invoice');
await expect(form).toContainText('Vendor');
await expect(form).toContainText('Date');
await expect(form).toContainText('Invoice Number');
await expect(form).toContainText('Total');
await expect(form.locator('input[name="invoice/total"]')).toBeVisible();
});
test('default-accounts path creates the invoice and offers to pay it now', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1001');
await page.locator('input[name="invoice/total"]').fill('212.44');
await page.waitForTimeout(200);
await save(page);
await page.waitForTimeout(1200);
// the next-steps modal (done-fn output) -- no accounts step on the default path
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
test('customize-accounts path routes through the expense-accounts grid then creates', async ({ page }) => {
const { vendor } = await seedIds(page);
await openNewWizard(page);
await setVendor(page, vendor);
await page.locator('input[name="invoice/invoice-number"]').fill('NEW-1002');
await page.locator('input[name="invoice/total"]').fill('300.00');
await loadPrediction(page);
// pick "Customize accounts" (the radio in the async fragment)
await page.locator('input[name="customize-accounts"][value="customize"]').first().check();
await page.waitForTimeout(150);
await save(page);
await page.waitForTimeout(1000);
// the expense-accounts step: a grid prefilled with the vendor's default account + total
const form = page.locator('#wizard-form');
await expect(form).toContainText('Invoice accounts');
await expect(form).toContainText('INVOICE TOTAL');
await save(page); // accounts -> done
await page.waitForTimeout(1200);
await expect(page.locator('body')).toContainText('Would you like to pay this invoice now?');
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,6 @@
:put ::new-invoice-submit
"/due-date" ::due-date
"/scheduled-payment-date" ::scheduled-payment-date
"/navigate" ::new-wizard-navigate
"/account/new" ::new-wizard-new-account
"/account/location-select" ::location-select
"/account/prediction" ::account-prediction