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,78 +972,60 @@
[: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?]}]
[:div {:class "w-[30em]"} (let [ba-id (:db/id bank-account)]
(com/card {:class "w-full"} [:div {:class "w-[30em]"}
[:div.flex.items-stretch {} (com/card {:class "w-full"}
(com/hidden {:name "item" [:div.flex.items-stretch {}
: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 bg-color}
bg-color} [:div {:class
[:div {:class (hh/add-class "p-1.5 w-8 h-8" text-color)}
(hh/add-class "p-1.5 w-8 h-8" text-color)} icon]]]
icon]]] [:div.flex.flex-col.grow.m-2
[:div.flex.flex-col.grow.m-2 [:div.font-medium.text-gray-700 (:bank-account/name bank-account)]
[:div.font-medium.text-gray-700 (:bank-account/name bank-account)] [: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 (merge {:color :primary} (method-control-attrs ba-id "credit"))
(com/button {:color :primary "Credit")
:minimal-loading? true (com/button {:x-ref "button"
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "@click.prevent.capture" "$tooltip($refs.tooltip.innerHTML, {allowHTML: true, onMount(i) {htmx.process(i.popper)}, interactive:true, theme:\"light\", timeout:5000})"}
"step-params[method]" "credit"}) "Pay"))
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) [:template {:x-ref "tooltip"}
{:from (mm/encode-step-key :choose-method) [:div.flex.flex-col.gap-2 {:data-key "vis"
:to (mm/encode-step-key :payment-details)})} :class "p-4 w-max"}
"Credit") (when (= :bank-account-type/check
(com/button {:x-ref "button" (:bank-account/type bank-account))
"@click.prevent.capture" "$tooltip($refs.tooltip.innerHTML, {allowHTML: true, onMount(i) {htmx.process(i.popper)}, interactive:true, theme:\"light\", timeout:5000})"} (com/button (merge {:color :primary} (method-control-attrs ba-id "print-check"))
"Pay")) "Print check"))
[:template {:x-ref "tooltip"} (when (= :bank-account-type/cash
[:div.flex.flex-col.gap-2 {:data-key "vis" (:bank-account/type bank-account))
:class "p-4 w-max"} (com/button (method-control-attrs ba-id "cash")
(when (= :bank-account-type/check "With cash"))
(:bank-account/type bank-account)) (when (not= :bank-account-type/cash
(com/button {:color :primary (:bank-account/type bank-account))
:minimal-loading? true (com/button (merge {:color (when (= :bank-account-type/credit
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) (:bank-account/type bank-account))
"step-params[method]" "print-check"}) :primary)}
:hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) (method-control-attrs ba-id "debit"))
{:from (mm/encode-step-key :choose-method) "Debit"))
:to (mm/encode-step-key :payment-details)})} (when (and (= :bank-account-type/check (:bank-account/type bank-account))
"Print check")) can-handwrite?)
(when (= :bank-account-type/cash (com/button (method-control-attrs ba-id "handwrite-check")
(:bank-account/type bank-account)) "Handwrite check"))]]]])]))
(com/button {:minimal-loading? true
: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"))
(when (not= :bank-account-type/cash
(:bank-account/type bank-account))
(com/button {:color (when (= :bank-account-type/credit
(:bank-account/type bank-account))
:primary)
:minimal-loading? true
: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"))
(when (and (= :bank-account-type/check (:bank-account/type bank-account))
can-handwrite?)
(com/button {:minimal-loading? true
:hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account)
"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 :when (:bank-account/visible ba)]
(for [ba (:bank-accounts linear-wizard) (bank-account-card ba (can-handwrite? full) (credit-only? full)))]})))
:when (:bank-account/visible ba)]
(bank-account-card ba can-handwrite? credit-only?))])
: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] (com/validated-field
(mm/default-render-step {:errors (pay-ferr :check-number) :label "Check number"}
linear-wizard this (com/int-input {:value (:check-number step-data)
:head [:div.p-2 "Pay " (count (:invoices (:snapshot (:multi-form-state request)))) " invoices"] :name (path->name2 :check-number)
:body (mm/default-step-body :error? (pay-ferr :check-number)
{} :placeholder "10001"})))
[:div {} (when (#{:handwrite-check :print-check} method)
(when (= :handwrite-check (:method (:snapshot (:multi-form-state request)))) (com/validated-field
(fc/with-field :check-number {:errors (pay-ferr :handwritten-date) :label "Date"}
(com/validated-field (com/date-input {:value (some-> (or (:handwritten-date step-data)
{:errors (fc/field-errors) (time/now))
:label "Check number"} (atime/unparse-local atime/normal-date))
(com/int-input {:value (fc/field-value) :name (path->name2 :handwritten-date)
:name (fc/field-name) :error? (pay-ferr :handwritten-date)
:error? (fc/field-errors) :placeholder "1/1/2020"})))
:placeholder "10001"}))))
(when (#{:handwrite-check :print-check} (:method (:snapshot (:multi-form-state request))))
(fc/with-field :handwritten-date
(com/validated-field
{:errors (fc/field-errors)
:label "Date"}
(com/date-input {:value (-> (fc/field-value)
(atime/unparse-local atime/normal-date))
:name (fc/field-name)
:error? (fc/field-errors)
: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) (format "Credit in full ($%,.2f)" total)
(:invoices (:snapshot (:multi-form-state request)))))] (format "Pay in full ($%,.2f)" total))}
(if (< total 0)
(format "Credit 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/data-grid
(com/validated-field (hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor")
{:errors (fc/field-errors)} (com/data-grid-header {} "Invoice Number")
(com/data-grid (com/data-grid-header {:class "text-right"} "Total")
(hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor") (com/data-grid-header {:class "text-right"} "Pay")]
(com/data-grid-header {} "Invoice Number") :x-show "mode==\"advanced\""})
(com/data-grid-header {:class "text-right"} "Total") (for [[i ci] (map-indexed vector invoices)
(com/data-grid-header {:class "text-right"} "Pay")] :let [inv (:invoice ci)]]
:x-show "mode==\"advanced\""}) (com/data-grid-row
(fc/cursor-map {}
(fn [i] (com/data-grid-cell {} (-> inv :invoice/vendor :vendor/name))
(com/data-grid-row (com/data-grid-cell {}
{} (com/hidden {:name (path->name2 :invoices i :invoice-id)
(com/data-grid-cell :value (:invoice-id ci)})
{} (:invoice/invoice-number inv))
(com/data-grid-cell {:class "text-right"}
(-> (fc/field-value) :invoice :invoice/vendor :vendor/name)) [:span.inline-flex.gap-2
(com/data-grid-cell (format "$%,.2f" (:invoice/outstanding-balance inv))])
{} (com/data-grid-cell {:class "w-20"}
(fc/with-field :invoice-id (com/money-input {:value (format "%.2f" (or (get-in step-data [:invoices i :amount])
(com/hidden {:name (fc/field-name) (:amount ci)))
:value (fc/field-value)})) :class "w-20"
(-> (fc/field-value) :invoice :invoice/invoice-number)) :name (path->name2 :invoices i :amount)})))))]]
(com/data-grid-cell :footer (wizard2/nav-footer {:back? true :save? true :save-label "Pay"})})))
{:class "text-right"}
[:span.inline-flex.gap-2
(format "$%,.2f" (-> (fc/field-value) :invoice :invoice/outstanding-balance))])
(com/data-grid-cell
{:class "w-20"}
(fc/with-field :amount
(com/validated-field {:errors (fc/field-errors)}
(com/money-input {:value (format "%.2f" (fc/field-value)) :class "w-20"
:name (fc/field-name)
: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,136 +1229,78 @@
(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
re-derived here. Returns the completion modal."
[all-data {:keys [identity] :as request}]
(let [client (pay-invoice-client (map :invoice-id (:invoices all-data)))
snapshot (mc/decode payment-form-schema
(assoc all-data :client client :has-warning? false)
mt/strip-extra-keys-transformer)
(get-step [this step-key] _ (assert-schema payment-form-schema snapshot)
(let [step-key-result (mc/parse mm/step-key-schema step-key)
[step-key-type step-key] step-key-result]
(if (= :step step-key-type)
(get {:choose-method (->ChoosePaymentMethodModal this)
:payment-details (->PaymentDetailsStep this)}
step-key)
(get {:bank-account (->ChoosePaymentMethodModal this)} _ (exception->4xx
(first step-key))))) #(if (= :handwrite-check (:method snapshot))
(form-schema [_] payment-form-schema) (when (or (not (some? (:check-number snapshot)))
(submit [this {:keys [multi-form-state request-method identity] :as request}] (= "" (:check-number snapshot)))
(let [snapshot (mc/decode (throw (Exception. "Check number is required")))
payment-form-schema true))
(:snapshot multi-form-state)
mt/strip-extra-keys-transformer)
_ (assert-schema payment-form-schema snapshot) result (exception->4xx
#(do
_ (exception->4xx (when (:handwritten-date snapshot)
#(if (= :handwrite-check (:method snapshot)) (let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))]
(when (or (not (some? (:check-number snapshot))) (assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot))))
(= "" (:check-number snapshot))) (if (= :handwrite-check (:method snapshot))
(throw (Exception. "Check number is required"))) (add-handwritten-check request nil snapshot)
true)) (try
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i)
result (exception->4xx :amount (:amount i)})
#(do (:invoices snapshot))
(when (:handwritten-date snapshot) (:client snapshot)
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))] (:bank-account snapshot)
(assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot)))) (cond (= :print-check (:method snapshot))
(if (= :handwrite-check (:method snapshot)) :payment-type/check
(add-handwritten-check request this snapshot) (= :debit (:method snapshot))
(try :payment-type/debit
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i) (= :cash (:method snapshot))
:amount (:amount i)}) :payment-type/cash
(:invoices snapshot)) (= :credit (:method snapshot))
(:client snapshot) :payment-type/credit
(:bank-account snapshot) :else :payment-type/debit)
(cond (= :print-check (:method snapshot)) identity
:payment-type/check (:handwritten-date snapshot))
(= :debit (:method snapshot)) (catch Exception e
:payment-type/debit (println e))))))]
(= :cash (:method snapshot)) (modal-response
:payment-type/cash (com/modal {}
(= :credit (:method snapshot)) (com/modal-card-advanced
:payment-type/credit {:class "transition duration-300 ease-in-out htmx-swapping:-translate-x-2/3 htmx-swapping:opacity-0 htmx-swapping:scale-0 htmx-added:translate-x-2/3 htmx-added:opacity-0 htmx-added:scale-0 scale-100 translate-x-0 opacity-100"}
:else :payment-type/debit) (com/modal-body {}
identity [:div.flex.flex-col.mt-4.space-y-4.items-center
(:handwritten-date snapshot)) [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300.animate-gg
(catch Exception e svg/thumbs-up]
(println e))))))] (when-not (:pdf-url result)
(modal-response [:div "That's a wrap. Your payment is complete."])
(com/modal {} (when (:pdf-url result)
(com/modal-card-advanced [:div "Your checks are ready. Click "
{:class "transition duration-300 ease-in-out htmx-swapping:-translate-x-2/3 htmx-swapping:opacity-0 htmx-swapping:scale-0 htmx-added:translate-x-2/3 htmx-added:opacity-0 htmx-added:scale-0 scale-100 translate-x-0 opacity-100"} (com/link {:href (:pdf-url result) :target "_new"} "here")
(com/modal-body {} " to download and print."])
[:div.flex.flex-col.mt-4.space-y-4.items-center (when (:pdf-url result)
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300.animate-gg [:div.text-xs.italic [:em "Remember to turn off all scaling and margins."]])])))
svg/thumbs-up] :headers {"hx-trigger" "invalidated"})))
(when-not (:pdf-url result)
[:div "That's a wrap. Your payment is complete."])
(when (:pdf-url result)
[:div "Your checks are ready. Click "
(com/link {:href (:pdf-url result) :target "_new"} "here")
" to download and print."])
(when (:pdf-url result)
[:div.text-xs.italic [:em "Remember to turn off all scaling and margins."]])])))
: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]
(throw (ex-info "No selected invoices are applicable for payment" {:type :notification}))) (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})))
sel (:selected (:query-params request))
has-warning? (and sel (not= (count selected-ids) (count sel)))
invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id]
:invoice/client [:db/id]}
:invoice/outstanding-balance
:invoice/invoice-number
:db/id])
:in $ [?i ...]]
(dc/db conn) selected-ids)
(map first)
(sort-by (juxt (comp :vendor/name :invoice/vendor)
:invoice/invoice-number)))
client (-> invoices first :invoice/client :db/id)
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)
(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?)
:bank-accounts bank-accounts
:invoices (mapv (fn [i] {:invoice-id (:db/id i)
:amount (:invoice/outstanding-balance i)
:invoice i})
invoices)}}))
has-warning? (and (:selected (:query-params request)) (def pay-wizard-config
(not= (count selected-ids) {:name :pay
(count (:selected (:query-params request))))) :form-id "wizard-form"
invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id] :submit-route (bidi/path-for ssr-routes/only-routes ::route/pay-submit)
:invoice/client [:db/id]} :form-attrs {:hx-ext "response-targets"
:invoice/outstanding-balance :hx-target-400 "#form-errors"}
:invoice/invoice-number :open-response (fn [form] (modal-response [:div#transitioner.flex-1 form]))
:db/id]) :init-fn pay-init-fn
:in $ [?i ...]] :steps [{:key :choose-method
(dc/db conn) :decode decode-choose-method
selected-ids) :render render-choose-method
(map first) :next (fn [_] :payment-details)}
(sort-by (juxt (comp :invoice/vendor :vendor/name) {:key :payment-details
:invoice/invoice-number)))] :decode decode-payment-details
(mm/->MultiStepFormState {:invoices (mapv (fn [i] {:invoice-id (:db/id i) :render render-payment-details
:amount (:invoice/outstanding-balance i)}) :next (fn [_] :done)}]
invoices) :done-fn pay!})
:mode :simple
:client (-> invoices first :invoice/client :db/id) (defn open-pay-wizard
:has-warning? (boolean has-warning?) "GET open handler: build the wizard in its modal shell (notification banner if nothing
:handwritten-date (time/now)} is payable)."
[] [request]
{:mode :simple (exception->notification
:has-warning? (boolean has-warning?)})))) #(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}