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, 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 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. `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 > (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 -> > a mechanical follow-up. The Alpine cross-field dispatch wiring (clientId -> accountId ->
> location) was preserved verbatim — de-cursoring touched only the data plumbing. > 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.

View File

@@ -64,8 +64,8 @@ test.describe('Invoice Pay wizard (characterization)', () => {
// scope to the wizard form (the background grid filters also have a check-number input) // scope to the wizard form (the background grid filters also have a check-number input)
await page.locator('#wizard-form input[name*="check-number"]').first().fill('10001'); await page.locator('#wizard-form input[name*="check-number"]').first().fill('10001');
await page.waitForTimeout(150); await page.waitForTimeout(150);
// the footer submit button (x-ref="next"), not the background #pay-button // the footer Pay submit button, scoped to the form (not the background #pay-button)
await page.locator('[x-ref="next"]').first().click(); await page.locator('#wizard-form button:has-text("Pay")').first().click();
await page.waitForTimeout(1500); await page.waitForTimeout(1500);
// the submit transacts a pending check payment and swaps in the completion modal // the submit transacts a pending check payment and swaps in the completion modal
await expect(page.locator('body')).toContainText('payment is complete'); await expect(page.locator('body')).toContainText('payment is complete');

View File

@@ -67,9 +67,10 @@
field the engine branches on; the advance/save button is marked `data-primary` so the field the engine branches on; the advance/save button is marked `data-primary` so the
form's Enter guard triggers it. Also renders the `#form-errors` slot. form's Enter guard triggers it. Also renders the `#form-errors` slot.
(nav-footer {:next \"Test\"}) ; an intermediate step: Next (nav-footer {:next \"Test\"}) ; intermediate step: Next
(nav-footer {:back? true :save? true}) ; the last step: Back + Save" (nav-footer {:back? true :save? true}) ; last step: Back + Save
[{:keys [next back? save?]}] (nav-footer {:back? true :save? true :save-label \"Pay\"}) ; last step, custom label"
[{:keys [next back? save? save-label]}]
[:div.flex.justify-end.items-baseline.gap-x-4 [:div.flex.justify-end.items-baseline.gap-x-4
[:div#form-errors] [:div#form-errors]
(when back? (when back?
@@ -77,7 +78,7 @@
(when next (when next
(com/button {:type "submit" :name "direction" :value "next" :data-primary "" :color :primary :class "w-24"} next)) (com/button {:type "submit" :name "direction" :value "next" :data-primary "" :color :primary :class "w-24"} next))
(when save? (when save?
(com/button {:type "submit" :name "direction" :value "submit" :data-primary "" :color :primary :class "w-24"} "Save"))]) (com/button {:type "submit" :name "direction" :value "submit" :data-primary "" :color :primary :class "w-24"} (or save-label "Save")))])
(defn blank-row (defn blank-row
"A fresh repeated-row map for an 'add row' interaction, with a temp `:db/id` (so a row "A fresh repeated-row map for an 'add row' interaction, with a temp `:db/id` (so a row

View File

@@ -30,11 +30,11 @@
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com] [auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.components.selmer :as sc] [auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.components.wizard2 :as wizard2]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]] [auto-ap.ssr.nested-form-params :as nfp :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel] [auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.transaction.edit :as tx-edit] [auto-ap.ssr.transaction.edit :as tx-edit]
[auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hiccup-helper :as hh]
@@ -64,7 +64,6 @@
[iol-ion.utils :refer [random-tempid]] [iol-ion.utils :refer [random-tempid]]
[malli.core :as mc] [malli.core :as mc]
[malli.transform :as mt] [malli.transform :as mt]
[malli.util :as mut]
[slingshot.slingshot :refer [try+]])) [slingshot.slingshot :refer [try+]]))
(defn exact-match-id* [request] (defn exact-match-id* [request]
@@ -973,12 +972,21 @@
[:mode [:enum :simple :advanced]] [:mode [:enum :simple :advanced]]
[:method [:enum :debit :print-check :cash :handwrite-check :credit]]])) [:method [:enum :debit :print-check :cash :handwrite-check :credit]]]))
(defn- method-control-attrs
"Attrs for a payment-method control: post the chosen {bank-account, method} to the engine
submit-route with direction=next, swapping the whole wizard form to the details step."
[bank-account-id method]
{:minimal-loading? true
:hx-post (bidi/path-for ssr-routes/only-routes ::route/pay-submit)
:hx-vals (hx/json {"bank-account" bank-account-id "method" method "direction" "next"})
:hx-target "#wizard-form"
:hx-swap "outerHTML"})
(defn bank-account-card-base [{:keys [bg-color text-color icon bank-account can-handwrite? credit-only?]}] (defn bank-account-card-base [{:keys [bg-color text-color icon bank-account can-handwrite? credit-only?]}]
(let [ba-id (:db/id bank-account)]
[:div {:class "w-[30em]"} [:div {:class "w-[30em]"}
(com/card {:class "w-full"} (com/card {:class "w-full"}
[:div.flex.items-stretch {} [:div.flex.items-stretch {}
(com/hidden {:name "item"
:value (:db/id bank-account)})
[:div.grow-0.flex.flex-col.justify-center [:div.grow-0.flex.flex-col.justify-center
[:div.p-1.m-2.rounded-full [:div.p-1.m-2.rounded-full
{:class {:class
@@ -991,13 +999,7 @@
[:div.font-light.text-gray-600 (:bank-account/bank-name bank-account)]] [:div.font-light.text-gray-600 (:bank-account/bank-name bank-account)]]
[:div.grow-0.m-2.self-center {:x-data (hx/json {})} [:div.grow-0.m-2.self-center {:x-data (hx/json {})}
(if credit-only? (if credit-only?
(com/button {:color :primary (com/button (merge {:color :primary} (method-control-attrs ba-id "credit"))
:minimal-loading? true
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account)
"step-params[method]" "credit"})
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate)
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"Credit") "Credit")
(com/button {:x-ref "button" (com/button {:x-ref "button"
"@click.prevent.capture" "$tooltip($refs.tooltip.innerHTML, {allowHTML: true, onMount(i) {htmx.process(i.popper)}, interactive:true, theme:\"light\", timeout:5000})"} "@click.prevent.capture" "$tooltip($refs.tooltip.innerHTML, {allowHTML: true, onMount(i) {htmx.process(i.popper)}, interactive:true, theme:\"light\", timeout:5000})"}
@@ -1007,44 +1009,23 @@
:class "p-4 w-max"} :class "p-4 w-max"}
(when (= :bank-account-type/check (when (= :bank-account-type/check
(:bank-account/type bank-account)) (:bank-account/type bank-account))
(com/button {:color :primary (com/button (merge {:color :primary} (method-control-attrs ba-id "print-check"))
:minimal-loading? true
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account)
"step-params[method]" "print-check"})
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate)
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"Print check")) "Print check"))
(when (= :bank-account-type/cash (when (= :bank-account-type/cash
(:bank-account/type bank-account)) (:bank-account/type bank-account))
(com/button {:minimal-loading? true (com/button (method-control-attrs ba-id "cash")
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account)
"step-params[method]" "cash"})
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate)
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"With cash")) "With cash"))
(when (not= :bank-account-type/cash (when (not= :bank-account-type/cash
(:bank-account/type bank-account)) (:bank-account/type bank-account))
(com/button {:color (when (= :bank-account-type/credit (com/button (merge {:color (when (= :bank-account-type/credit
(:bank-account/type bank-account)) (:bank-account/type bank-account))
:primary) :primary)}
:minimal-loading? true (method-control-attrs ba-id "debit"))
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account)
"step-params[method]" "debit"})
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate)
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"Debit")) "Debit"))
(when (and (= :bank-account-type/check (:bank-account/type bank-account)) (when (and (= :bank-account-type/check (:bank-account/type bank-account))
can-handwrite?) can-handwrite?)
(com/button {:minimal-loading? true (com/button (method-control-attrs ba-id "handwrite-check")
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "Handwrite check"))]]]])]))
"step-params[method]" "handwrite-check"})
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate)
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"Handwrite check"))]]]])])
(defmulti bank-account-card (fn [ba _ _] (defmulti bank-account-card (fn [ba _ _]
(:bank-account/type ba))) (:bank-account/type ba)))
@@ -1090,130 +1071,129 @@
(reduce + 0.0 (map :invoice/outstanding-balance is)))) (reduce + 0.0 (map :invoice/outstanding-balance is))))
(every? #(<= % 0.0)))) (every? #(<= % 0.0))))
(defrecord ChoosePaymentMethodModal [linear-wizard] ;; ---------------------------------------------------------------------------
mm/ModalWizardStep ;; Invoice Pay -- migrated onto the session-backed wizard engine (wizard2).
(step-name [_] ;; The first genuine multi-data-step wizard: choose-method collects
"Payment method") ;; {:bank-account :method}; payment-details collects {:invoices :check-number
(step-key [_] ;; :handwritten-date :mode}; the engine's get-all merges them for `pay!`.
:choose-method) ;; Read-only setup (enriched invoices, client, bank accounts) rides in :context.
;; ---------------------------------------------------------------------------
(edit-path [_ _] (def ^:dynamic *pay-errors* {})
[])
(step-schema [_] (defn- pay-ferr [& path] (get-in *pay-errors* (vec path)))
(mut/select-keys (mm/form-schema linear-wizard) #{:bank-account :method}))
(render-step (def ^:private choose-method-schema
[this request] (mc/schema [:map
(let [invoices (:invoices (:snapshot (:multi-form-state request))) [:bank-account entity-id]
can-handwrite? (can-handwrite? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices)) [:method [:enum :debit :print-check :cash :handwrite-check :credit]]]))
credit-only? (credit-only? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices))]
(mm/default-render-step (def ^:private payment-details-schema
linear-wizard this (mc/schema [:map
:head [:div.p-2.inline-flex.gap-2.items-center "Pay " (count invoices) " invoices" [:check-number {:optional true} [:maybe :int]]
(when (:has-warning? (:snapshot (:multi-form-state request))) [:handwritten-date {:optional true} [:maybe clj-date-schema]]
[:mode [:enum :simple :advanced]]
[:invoices [:vector {:coerce? true}
[:map [:invoice-id entity-id] [:amount money]]]]]))
(defn- decode-choose-method
"Step 1 posts a flat {bank-account, method} (the engine already stripped its nav fields)."
[request]
(mc/decode choose-method-schema (:form-params request) main-transformer))
(defn- decode-payment-details
"Step 2: nested invoices[i][amount] + check-number / handwritten-date / mode."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))]
(mc/decode payment-details-schema nested main-transformer)))
(defn- pay-modal-card [{:keys [head body footer]}]
(com/modal-card-advanced
{:class "w-[50em]"}
(com/modal-header {} head)
(com/modal-body {} body)
(when footer (com/modal-footer {} footer))))
(defn render-choose-method
"Step 1 body: a card per visible client bank account; each method control posts
{bank-account, method, direction:next} to the engine."
[{:keys [context]}]
(let [{:keys [invoices bank-accounts has-warning?]} context
full (map :invoice invoices)]
(pay-modal-card
{:head [:div.p-2
[:div.font-medium.text-gray-700 "Payment method"]
[:div.inline-flex.gap-2.items-center
(str "Pay " (count invoices) " invoices")
(when has-warning?
(com/pill {:color :yellow} (com/pill {:color :yellow}
"Some of the selected invoices may be locked or paid."))] "Some of the selected invoices may be locked or paid."))]]
:body (mm/default-step-body :body [:div.flex.flex-col.space-y-2
{} (for [ba bank-accounts
[:div.flex.flex-col.space-y-2
(for [ba (:bank-accounts linear-wizard)
:when (:bank-account/visible ba)] :when (:bank-account/visible ba)]
(bank-account-card ba can-handwrite? credit-only?))]) (bank-account-card ba (can-handwrite? full) (credit-only? full)))]})))
:footer
nil
:validation-route ::route/pay-wizard-navigate))))
(defrecord PaymentDetailsStep [linear-wizard] (defn render-payment-details
mm/ModalWizardStep "Step 2 body: method-conditional fields (check number / date) + a simple/advanced
(step-name [_] amounts grid. De-cursored -- rows iterate :context :invoices with explicit names; the
"Details") chosen :method comes from :all-data (the merged choose-method step)."
(step-key [_] [{:keys [context all-data step-data]}]
:payment-details) (let [method (:method all-data)
invoices (:invoices context)
(edit-path [_ _] mode (name (or (:mode step-data) :simple))
[]) total (reduce + 0.0 (map (comp :invoice/outstanding-balance :invoice) invoices))]
(pay-modal-card
(step-schema [_] {:head [:div.p-2 (str "Pay " (count invoices) " invoices")]
(mut/select-keys (mm/form-schema linear-wizard) #{:invoices :check-number :handwritten-date :mode})) :body [:div {:x-data (hx/json {:mode mode})}
(when (= :handwrite-check method)
(render-step [this request]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Pay " (count (:invoices (:snapshot (:multi-form-state request)))) " invoices"]
:body (mm/default-step-body
{}
[:div {}
(when (= :handwrite-check (:method (:snapshot (:multi-form-state request))))
(fc/with-field :check-number
(com/validated-field (com/validated-field
{:errors (fc/field-errors) {:errors (pay-ferr :check-number) :label "Check number"}
:label "Check number"} (com/int-input {:value (:check-number step-data)
(com/int-input {:value (fc/field-value) :name (path->name2 :check-number)
:name (fc/field-name) :error? (pay-ferr :check-number)
:error? (fc/field-errors) :placeholder "10001"})))
:placeholder "10001"})))) (when (#{:handwrite-check :print-check} method)
(when (#{:handwrite-check :print-check} (:method (:snapshot (:multi-form-state request))))
(fc/with-field :handwritten-date
(com/validated-field (com/validated-field
{:errors (fc/field-errors) {:errors (pay-ferr :handwritten-date) :label "Date"}
:label "Date"} (com/date-input {:value (some-> (or (:handwritten-date step-data)
(com/date-input {:value (-> (fc/field-value) (time/now))
(atime/unparse-local atime/normal-date)) (atime/unparse-local atime/normal-date))
:name (fc/field-name) :name (path->name2 :handwritten-date)
:error? (fc/field-errors) :error? (pay-ferr :handwritten-date)
:placeholder "1/1/2020"})))) :placeholder "1/1/2020"})))
(com/radio-list {:x-model "mode" (com/radio-list {:x-model "mode"
:name "step-params[mode]" :name (path->name2 :mode)
:options [{:value "simple" :options [{:value "simple"
:content (let [total (reduce + 0.0 :content (if (< total 0)
(map (comp :invoice/outstanding-balance (:invoice-by-id linear-wizard) :invoice-id)
(:invoices (:snapshot (:multi-form-state request)))))]
(if (< total 0)
(format "Credit in full ($%,.2f)" total) (format "Credit in full ($%,.2f)" total)
(format "Pay in full ($%,.2f)" total)))} (format "Pay in full ($%,.2f)" total))}
{:value "advanced" {:value "advanced"
:content "Customize payments"}]}) :content "Customize payments"}]})
[:div.space-y-4 [:div.space-y-4
(fc/with-field :invoices
(com/validated-field
{:errors (fc/field-errors)}
(com/data-grid (com/data-grid
(hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor") (hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor")
(com/data-grid-header {} "Invoice Number") (com/data-grid-header {} "Invoice Number")
(com/data-grid-header {:class "text-right"} "Total") (com/data-grid-header {:class "text-right"} "Total")
(com/data-grid-header {:class "text-right"} "Pay")] (com/data-grid-header {:class "text-right"} "Pay")]
:x-show "mode==\"advanced\""}) :x-show "mode==\"advanced\""})
(fc/cursor-map (for [[i ci] (map-indexed vector invoices)
(fn [i] :let [inv (:invoice ci)]]
(com/data-grid-row (com/data-grid-row
{} {}
(com/data-grid-cell (com/data-grid-cell {} (-> inv :invoice/vendor :vendor/name))
{} (com/data-grid-cell {}
(com/hidden {:name (path->name2 :invoices i :invoice-id)
(-> (fc/field-value) :invoice :invoice/vendor :vendor/name)) :value (:invoice-id ci)})
(com/data-grid-cell (:invoice/invoice-number inv))
{} (com/data-grid-cell {:class "text-right"}
(fc/with-field :invoice-id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
(-> (fc/field-value) :invoice :invoice/invoice-number))
(com/data-grid-cell
{:class "text-right"}
[:span.inline-flex.gap-2 [:span.inline-flex.gap-2
(format "$%,.2f" (-> (fc/field-value) :invoice :invoice/outstanding-balance))]) (format "$%,.2f" (:invoice/outstanding-balance inv))])
(com/data-grid-cell (com/data-grid-cell {:class "w-20"}
{:class "w-20"} (com/money-input {:value (format "%.2f" (or (get-in step-data [:invoices i :amount])
(fc/with-field :amount (:amount ci)))
(com/validated-field {:errors (fc/field-errors)} :class "w-20"
(com/money-input {:value (format "%.2f" (fc/field-value)) :class "w-20" :name (path->name2 :invoices i :amount)})))))]]
:name (fc/field-name) :footer (wizard2/nav-footer {:back? true :save? true :save-label "Pay"})})))
:error? (fc/error?)}))))))))))]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/pay-wizard-navigate
:next-button-content "Pay")
:validation-route ::route/pay-wizard-navigate)))
(defn add-handwritten-check [request wizard snapshot] (defn add-handwritten-check [request wizard snapshot]
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot))) (let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))
@@ -1249,79 +1229,24 @@
(alog/error ::cant-save-solr (alog/error ::cant-save-solr
:error e)))))) :error e))))))
;; TODO support crediting from balance (defn- pay-invoice-client
(defrecord PayWizard [form-params current-step invoice-by-id] "The single client owning the invoices being paid (single-client is enforced upstream by
mm/LinearModalWizard the pay button)."
(hydrate-from-request [invoice-ids]
[this request] (dc/q '[:find ?c .
(let [invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id] :in $ [?i ...]
:invoice/client [:db/id]} :where [?i :invoice/client ?c]]
:invoice/outstanding-balance (dc/db conn) invoice-ids))
:invoice/invoice-number
:db/id])
:in $ [?i ...]]
(dc/db conn)
(map :invoice-id (get-in request [:multi-form-state :snapshot :invoices])))
(map first)
(sort-by (juxt (comp :invoice/vendor :vendor/name)
:invoice/invoice-number)))]
(assoc this :invoice-by-id (by :db/id invoices)
:bank-accounts (->> (dc/q '[:find (pull ?ba [:bank-account/name :bank-account/sort-order :bank-account/visible
:bank-account/bank-name
:db/id
{[:bank-account/type :xform iol-ion.query/ident] [:db/ident]}])
:in $ ?c
:where [?c :client/bank-accounts ?ba]]
(dc/db conn)
(:client (:snapshot (:multi-form-state request))))
(map first)
(sort-by :bank-account/sort-order)))))
(navigate [this step-key]
(assoc this :current-step step-key))
(get-current-step [this]
(if current-step
(mm/get-step this current-step)
(mm/get-step this :choose-method)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(let [request (update-in request [:multi-form-state :step-params :invoices]
(fn [form-invoices]
(->> form-invoices
(map (fn [form-invoice]
(assoc form-invoice :invoice ((:invoice-by-id this) (:invoice-id form-invoice)))))
(sort-by
(juxt (comp :vendor/name :invoice/vendor :invoice)
(comp :invoice/invoice-number :invoice)))
(into []))))]
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-post
(str (bidi/path-for ssr-routes/only-routes ::route/pay-submit)))
(assoc :x-data (hx/json {:mode (some-> multi-form-state
:step-params
:mode
name)}))))))
(steps [_] (defn pay!
[:choose-method "Engine done-fn: merge the two steps' data into a payment snapshot, validate, and run the
:payment-details]) per-method payment (handwrite-check transacts a pending check directly; the others go
through print-checks-internal). :client / :has-warning? aren't posted, so they're
(get-step [this step-key] re-derived here. Returns the completion modal."
(let [step-key-result (mc/parse mm/step-key-schema step-key) [all-data {:keys [identity] :as request}]
[step-key-type step-key] step-key-result] (let [client (pay-invoice-client (map :invoice-id (:invoices all-data)))
(if (= :step step-key-type) snapshot (mc/decode payment-form-schema
(get {:choose-method (->ChoosePaymentMethodModal this) (assoc all-data :client client :has-warning? false)
:payment-details (->PaymentDetailsStep this)}
step-key)
(get {:bank-account (->ChoosePaymentMethodModal this)}
(first step-key)))))
(form-schema [_] payment-form-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [snapshot (mc/decode
payment-form-schema
(:snapshot multi-form-state)
mt/strip-extra-keys-transformer) mt/strip-extra-keys-transformer)
_ (assert-schema payment-form-schema snapshot) _ (assert-schema payment-form-schema snapshot)
@@ -1339,7 +1264,7 @@
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))] (let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))]
(assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot)))) (assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot))))
(if (= :handwrite-check (:method snapshot)) (if (= :handwrite-check (:method snapshot))
(add-handwritten-check request this snapshot) (add-handwritten-check request nil snapshot)
(try (try
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i) (print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i)
:amount (:amount i)}) :amount (:amount i)})
@@ -1375,10 +1300,7 @@
" to download and print."]) " to download and print."])
(when (:pdf-url result) (when (:pdf-url result)
[:div.text-xs.italic [:em "Remember to turn off all scaling and margins."]])]))) [:div.text-xs.italic [:em "Remember to turn off all scaling and margins."]])])))
:headers {"hx-trigger" "invalidated"})))) :headers {"hx-trigger" "invalidated"})))
(def pay-wizard
(->PayWizard nil nil nil))
(defn wrap-status-from-source [handler] (defn wrap-status-from-source [handler]
(fn [{:keys [matched-current-page-route] :as request}] (fn [{:keys [matched-current-page-route] :as request}]
@@ -1399,37 +1321,84 @@
ids) ids)
(map first))) (map first)))
(defn initial-pay-wizard-state [request] (defn pay-init-fn
(exception->notification "Engine :init-fn -- resolve the selected payable invoices into read-only :context
#(let [selected-ids (selected->ids request (:query-params request)) (enriched invoices, client, bank accounts, warning flag). Folds together the old
selected-ids (payable-ids selected-ids) initial-pay-wizard-state + PayWizard hydrate. Throws a :notification if nothing payable."
_ (when (= 0 (count selected-ids)) [request]
(let [selected-ids (payable-ids (selected->ids request (:query-params request)))
_ (when (zero? (count selected-ids))
(throw (ex-info "No selected invoices are applicable for payment" {:type :notification}))) (throw (ex-info "No selected invoices are applicable for payment" {:type :notification})))
sel (:selected (:query-params request))
has-warning? (and (:selected (:query-params request)) has-warning? (and sel (not= (count selected-ids) (count sel)))
(not= (count selected-ids)
(count (:selected (:query-params request)))))
invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id] invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id]
:invoice/client [:db/id]} :invoice/client [:db/id]}
:invoice/outstanding-balance :invoice/outstanding-balance
:invoice/invoice-number :invoice/invoice-number
:db/id]) :db/id])
:in $ [?i ...]] :in $ [?i ...]]
(dc/db conn) (dc/db conn) selected-ids)
selected-ids)
(map first) (map first)
(sort-by (juxt (comp :invoice/vendor :vendor/name) (sort-by (juxt (comp :vendor/name :invoice/vendor)
:invoice/invoice-number)))] :invoice/invoice-number)))
(mm/->MultiStepFormState {:invoices (mapv (fn [i] {:invoice-id (:db/id i) client (-> invoices first :invoice/client :db/id)
:amount (:invoice/outstanding-balance i)}) bank-accounts (->> (dc/q '[:find (pull ?ba [:bank-account/name :bank-account/sort-order
invoices) :bank-account/visible :bank-account/bank-name :db/id
:mode :simple {[:bank-account/type :xform iol-ion.query/ident] [:db/ident]}])
:client (-> invoices first :invoice/client :db/id) :in $ ?c
:where [?c :client/bank-accounts ?ba]]
(dc/db conn) client)
(map first)
(sort-by :bank-account/sort-order))]
;; Context is EDN-serialized into the (cookie) session, so keep it EDN-safe -- no
;; clj-time values (the date default is computed in render instead).
{:context {:client client
:has-warning? (boolean has-warning?) :has-warning? (boolean has-warning?)
:handwritten-date (time/now)} :bank-accounts bank-accounts
[] :invoices (mapv (fn [i] {:invoice-id (:db/id i)
{:mode :simple :amount (:invoice/outstanding-balance i)
:has-warning? (boolean has-warning?)})))) :invoice i})
invoices)}}))
(def pay-wizard-config
{:name :pay
:form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/pay-submit)
:form-attrs {:hx-ext "response-targets"
:hx-target-400 "#form-errors"}
:open-response (fn [form] (modal-response [:div#transitioner.flex-1 form]))
:init-fn pay-init-fn
:steps [{:key :choose-method
:decode decode-choose-method
:render render-choose-method
:next (fn [_] :payment-details)}
{:key :payment-details
:decode decode-payment-details
:render render-payment-details
:next (fn [_] :done)}]
:done-fn pay!})
(defn open-pay-wizard
"GET open handler: build the wizard in its modal shell (notification banner if nothing
is payable)."
[request]
(exception->notification
#(wizard2/open-wizard pay-wizard-config request)))
(defn pay-step
"POST handler for every pay-wizard transition: the cards' next, the footer Pay/Back.
The per-method payment validation (exception->4xx etc.) throws slingshot errors; surface
them as a 4xx into #form-errors (hx-target-400) so the modal stays open for a retry."
[request]
(try+
(wizard2/handle-step-submit pay-wizard-config request)
(catch #(#{:form-validation :schema-validation :field-validation :notification} (:type %)) e
(html-response
[:span.error-content.text-red-500
(or (some->> (:form-validation-errors e) (str/join " "))
(:message e)
"Could not complete the payment.")]
:status 400))))
(defn redirect-handler [target-route] (defn redirect-handler [target-route]
(fn handle [request] (fn handle [request]
{:status 302 {:status 302
@@ -1859,20 +1828,11 @@
::route/pay-using-credit (-> pay-using-credit ::route/pay-using-credit (-> pay-using-credit
(wrap-schema-enforce :form-schema query-schema)) (wrap-schema-enforce :form-schema query-schema))
::route/pay-wizard (-> mm/open-wizard-handler ;; Engine-driven: open builds the wizard in its modal; pay-submit handles every
;; transition (the cards' next, the footer Pay/Back). wrap-form-4xx-2 turns the
(mm/wrap-wizard pay-wizard) ;; per-method validation (check-number required, etc.) into a 4xx -> #form-errors.
(mm/wrap-init-multi-form-state initial-pay-wizard-state)) ::route/pay-wizard open-pay-wizard
::route/pay-submit pay-step
::route/pay-submit (-> mm/submit-handler
(mm/wrap-wizard pay-wizard)
(mm/wrap-decode-multi-form-state))
::route/pay-wizard-navigate
(-> mm/next-handler
(mm/wrap-wizard pay-wizard)
(mm/wrap-decode-multi-form-state))
::route/table (helper/table-route grid-page :parse-query-params? false)} ::route/table (helper/table-route grid-page :parse-query-params? false)}
(merge new-invoice-wizard/key->handler) (merge new-invoice-wizard/key->handler)

View File

@@ -26,8 +26,6 @@
"/pay" {:get ::pay-wizard "/pay" {:get ::pay-wizard
"/using-credit" ::pay-using-credit "/using-credit" ::pay-using-credit
"/navigate" ::pay-wizard-navigate
:post ::pay-submit} :post ::pay-submit}
"/bulk-delete" {:get ::bulk-delete "/bulk-delete" {:get ::bulk-delete
:delete ::bulk-delete-confirm} :delete ::bulk-delete-confirm}