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

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

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

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

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

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

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

View File

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

View File

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

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)
await page.locator('#wizard-form input[name*="check-number"]').first().fill('10001');
await page.waitForTimeout(150);
// the footer submit button (x-ref="next"), not the background #pay-button
await page.locator('[x-ref="next"]').first().click();
// the footer Pay submit button, scoped to the form (not the background #pay-button)
await page.locator('#wizard-form button:has-text("Pay")').first().click();
await page.waitForTimeout(1500);
// the submit transacts a pending check payment and swaps in the completion modal
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
form's Enter guard triggers it. Also renders the `#form-errors` slot.
(nav-footer {:next \"Test\"}) ; an intermediate step: Next
(nav-footer {:back? true :save? true}) ; the last step: Back + Save"
[{:keys [next back? save?]}]
(nav-footer {:next \"Test\"}) ; intermediate step: Next
(nav-footer {:back? true :save? true}) ; last step: 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#form-errors]
(when back?
@@ -77,7 +78,7 @@
(when next
(com/button {:type "submit" :name "direction" :value "next" :data-primary "" :color :primary :class "w-24"} next))
(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
"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.components :as com]
[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.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.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.transaction.edit :as tx-edit]
[auto-ap.ssr.hiccup-helper :as hh]
@@ -64,7 +64,6 @@
[iol-ion.utils :refer [random-tempid]]
[malli.core :as mc]
[malli.transform :as mt]
[malli.util :as mut]
[slingshot.slingshot :refer [try+]]))
(defn exact-match-id* [request]
@@ -973,78 +972,60 @@
[:mode [:enum :simple :advanced]]
[: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?]}]
[:div {:class "w-[30em]"}
(com/card {:class "w-full"}
[:div.flex.items-stretch {}
(com/hidden {:name "item"
:value (:db/id bank-account)})
[:div.grow-0.flex.flex-col.justify-center
[:div.p-1.m-2.rounded-full
{:class
bg-color}
[:div {:class
(hh/add-class "p-1.5 w-8 h-8" text-color)}
icon]]]
[:div.flex.flex-col.grow.m-2
[:div.font-medium.text-gray-700 (:bank-account/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 {})}
(if credit-only?
(com/button {:color :primary
: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")
(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})"}
"Pay"))
[:template {:x-ref "tooltip"}
[:div.flex.flex-col.gap-2 {:data-key "vis"
:class "p-4 w-max"}
(when (= :bank-account-type/check
(:bank-account/type bank-account))
(com/button {:color :primary
: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"))
(when (= :bank-account-type/cash
(:bank-account/type bank-account))
(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"))]]]])])
(let [ba-id (:db/id bank-account)]
[:div {:class "w-[30em]"}
(com/card {:class "w-full"}
[:div.flex.items-stretch {}
[:div.grow-0.flex.flex-col.justify-center
[:div.p-1.m-2.rounded-full
{:class
bg-color}
[:div {:class
(hh/add-class "p-1.5 w-8 h-8" text-color)}
icon]]]
[:div.flex.flex-col.grow.m-2
[:div.font-medium.text-gray-700 (:bank-account/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 {})}
(if credit-only?
(com/button (merge {:color :primary} (method-control-attrs ba-id "credit"))
"Credit")
(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})"}
"Pay"))
[:template {:x-ref "tooltip"}
[:div.flex.flex-col.gap-2 {:data-key "vis"
:class "p-4 w-max"}
(when (= :bank-account-type/check
(:bank-account/type bank-account))
(com/button (merge {:color :primary} (method-control-attrs ba-id "print-check"))
"Print check"))
(when (= :bank-account-type/cash
(:bank-account/type bank-account))
(com/button (method-control-attrs ba-id "cash")
"With cash"))
(when (not= :bank-account-type/cash
(:bank-account/type bank-account))
(com/button (merge {:color (when (= :bank-account-type/credit
(:bank-account/type bank-account))
:primary)}
(method-control-attrs ba-id "debit"))
"Debit"))
(when (and (= :bank-account-type/check (:bank-account/type bank-account))
can-handwrite?)
(com/button (method-control-attrs ba-id "handwrite-check")
"Handwrite check"))]]]])]))
(defmulti bank-account-card (fn [ba _ _]
(:bank-account/type ba)))
@@ -1090,130 +1071,129 @@
(reduce + 0.0 (map :invoice/outstanding-balance is))))
(every? #(<= % 0.0))))
(defrecord ChoosePaymentMethodModal [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Payment method")
(step-key [_]
:choose-method)
;; ---------------------------------------------------------------------------
;; Invoice Pay -- migrated onto the session-backed wizard engine (wizard2).
;; The first genuine multi-data-step wizard: choose-method collects
;; {:bank-account :method}; payment-details collects {:invoices :check-number
;; :handwritten-date :mode}; the engine's get-all merges them for `pay!`.
;; Read-only setup (enriched invoices, client, bank accounts) rides in :context.
;; ---------------------------------------------------------------------------
(edit-path [_ _]
[])
(def ^:dynamic *pay-errors* {})
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:bank-account :method}))
(defn- pay-ferr [& path] (get-in *pay-errors* (vec path)))
(render-step
[this request]
(let [invoices (:invoices (:snapshot (:multi-form-state request)))
can-handwrite? (can-handwrite? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices))
credit-only? (credit-only? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices))]
(mm/default-render-step
linear-wizard this
:head [:div.p-2.inline-flex.gap-2.items-center "Pay " (count invoices) " invoices"
(when (:has-warning? (:snapshot (:multi-form-state request)))
(def ^:private choose-method-schema
(mc/schema [:map
[:bank-account entity-id]
[:method [:enum :debit :print-check :cash :handwrite-check :credit]]]))
(def ^:private payment-details-schema
(mc/schema [:map
[:check-number {:optional true} [:maybe :int]]
[: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}
"Some of the selected invoices may be locked or paid."))]
:body (mm/default-step-body
{}
[:div.flex.flex-col.space-y-2
(for [ba (:bank-accounts linear-wizard)
:when (:bank-account/visible ba)]
(bank-account-card ba can-handwrite? credit-only?))])
:footer
nil
:validation-route ::route/pay-wizard-navigate))))
"Some of the selected invoices may be locked or paid."))]]
:body [:div.flex.flex-col.space-y-2
(for [ba bank-accounts
:when (:bank-account/visible ba)]
(bank-account-card ba (can-handwrite? full) (credit-only? full)))]})))
(defrecord PaymentDetailsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Details")
(step-key [_]
:payment-details)
(edit-path [_ _]
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:invoices :check-number :handwritten-date :mode}))
(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
{:errors (fc/field-errors)
:label "Check number"}
(com/int-input {:value (fc/field-value)
:name (fc/field-name)
:error? (fc/field-errors)
: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"}))))
(defn render-payment-details
"Step 2 body: method-conditional fields (check number / date) + a simple/advanced
amounts grid. De-cursored -- rows iterate :context :invoices with explicit names; the
chosen :method comes from :all-data (the merged choose-method step)."
[{:keys [context all-data step-data]}]
(let [method (:method all-data)
invoices (:invoices context)
mode (name (or (:mode step-data) :simple))
total (reduce + 0.0 (map (comp :invoice/outstanding-balance :invoice) invoices))]
(pay-modal-card
{:head [:div.p-2 (str "Pay " (count invoices) " invoices")]
:body [:div {:x-data (hx/json {:mode mode})}
(when (= :handwrite-check method)
(com/validated-field
{:errors (pay-ferr :check-number) :label "Check number"}
(com/int-input {:value (:check-number step-data)
:name (path->name2 :check-number)
:error? (pay-ferr :check-number)
:placeholder "10001"})))
(when (#{:handwrite-check :print-check} method)
(com/validated-field
{:errors (pay-ferr :handwritten-date) :label "Date"}
(com/date-input {:value (some-> (or (:handwritten-date step-data)
(time/now))
(atime/unparse-local atime/normal-date))
:name (path->name2 :handwritten-date)
:error? (pay-ferr :handwritten-date)
:placeholder "1/1/2020"})))
(com/radio-list {:x-model "mode"
:name "step-params[mode]"
:name (path->name2 :mode)
:options [{:value "simple"
:content (let [total (reduce + 0.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 "Pay in full ($%,.2f)" total)))}
:content (if (< total 0)
(format "Credit in full ($%,.2f)" total)
(format "Pay in full ($%,.2f)" total))}
{:value "advanced"
:content "Customize payments"}]})
[:div.space-y-4
(fc/with-field :invoices
(com/validated-field
{:errors (fc/field-errors)}
(com/data-grid
(hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor")
(com/data-grid-header {} "Invoice Number")
(com/data-grid-header {:class "text-right"} "Total")
(com/data-grid-header {:class "text-right"} "Pay")]
:x-show "mode==\"advanced\""})
(fc/cursor-map
(fn [i]
(com/data-grid-row
{}
(com/data-grid-cell
{}
(-> (fc/field-value) :invoice :invoice/vendor :vendor/name))
(com/data-grid-cell
{}
(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
(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)))
(com/data-grid
(hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor")
(com/data-grid-header {} "Invoice Number")
(com/data-grid-header {:class "text-right"} "Total")
(com/data-grid-header {:class "text-right"} "Pay")]
:x-show "mode==\"advanced\""})
(for [[i ci] (map-indexed vector invoices)
:let [inv (:invoice ci)]]
(com/data-grid-row
{}
(com/data-grid-cell {} (-> inv :invoice/vendor :vendor/name))
(com/data-grid-cell {}
(com/hidden {:name (path->name2 :invoices i :invoice-id)
:value (:invoice-id ci)})
(:invoice/invoice-number inv))
(com/data-grid-cell {:class "text-right"}
[:span.inline-flex.gap-2
(format "$%,.2f" (:invoice/outstanding-balance inv))])
(com/data-grid-cell {:class "w-20"}
(com/money-input {:value (format "%.2f" (or (get-in step-data [:invoices i :amount])
(:amount ci)))
:class "w-20"
:name (path->name2 :invoices i :amount)})))))]]
:footer (wizard2/nav-footer {:back? true :save? true :save-label "Pay"})})))
(defn add-handwritten-check [request wizard snapshot]
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))
@@ -1249,136 +1229,78 @@
(alog/error ::cant-save-solr
:error e))))))
;; TODO support crediting from balance
(defrecord PayWizard [form-params current-step invoice-by-id]
mm/LinearModalWizard
(hydrate-from-request
[this request]
(let [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)
(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)}))))))
(defn- pay-invoice-client
"The single client owning the invoices being paid (single-client is enforced upstream by
the pay button)."
[invoice-ids]
(dc/q '[:find ?c .
:in $ [?i ...]
:where [?i :invoice/client ?c]]
(dc/db conn) invoice-ids))
(steps [_]
[:choose-method
:payment-details])
(defn pay!
"Engine done-fn: merge the two steps' data into a payment snapshot, validate, and run the
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]
(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)
_ (assert-schema payment-form-schema snapshot)
(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)
_ (exception->4xx
#(if (= :handwrite-check (:method snapshot))
(when (or (not (some? (:check-number snapshot)))
(= "" (:check-number snapshot)))
(throw (Exception. "Check number is required")))
true))
_ (assert-schema payment-form-schema snapshot)
_ (exception->4xx
#(if (= :handwrite-check (:method snapshot))
(when (or (not (some? (:check-number snapshot)))
(= "" (:check-number snapshot)))
(throw (Exception. "Check number is required")))
true))
result (exception->4xx
#(do
(when (:handwritten-date snapshot)
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))]
(assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot))))
(if (= :handwrite-check (:method snapshot))
(add-handwritten-check request this snapshot)
(try
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i)
:amount (:amount i)})
(:invoices snapshot))
(:client snapshot)
(:bank-account snapshot)
(cond (= :print-check (:method snapshot))
:payment-type/check
(= :debit (:method snapshot))
:payment-type/debit
(= :cash (:method snapshot))
:payment-type/cash
(= :credit (:method snapshot))
:payment-type/credit
:else :payment-type/debit)
identity
(:handwritten-date snapshot))
(catch Exception e
(println e))))))]
(modal-response
(com/modal {}
(com/modal-card-advanced
{: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/modal-body {}
[:div.flex.flex-col.mt-4.space-y-4.items-center
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300.animate-gg
svg/thumbs-up]
(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))
result (exception->4xx
#(do
(when (:handwritten-date snapshot)
(let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))]
(assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot))))
(if (= :handwrite-check (:method snapshot))
(add-handwritten-check request nil snapshot)
(try
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i)
:amount (:amount i)})
(:invoices snapshot))
(:client snapshot)
(:bank-account snapshot)
(cond (= :print-check (:method snapshot))
:payment-type/check
(= :debit (:method snapshot))
:payment-type/debit
(= :cash (:method snapshot))
:payment-type/cash
(= :credit (:method snapshot))
:payment-type/credit
:else :payment-type/debit)
identity
(:handwritten-date snapshot))
(catch Exception e
(println e))))))]
(modal-response
(com/modal {}
(com/modal-card-advanced
{: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/modal-body {}
[:div.flex.flex-col.mt-4.space-y-4.items-center
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300.animate-gg
svg/thumbs-up]
(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"})))
(defn wrap-status-from-source [handler]
(fn [{:keys [matched-current-page-route] :as request}]
@@ -1399,37 +1321,84 @@
ids)
(map first)))
(defn initial-pay-wizard-state [request]
(exception->notification
#(let [selected-ids (selected->ids request (:query-params request))
selected-ids (payable-ids selected-ids)
_ (when (= 0 (count selected-ids))
(throw (ex-info "No selected invoices are applicable for payment" {:type :notification})))
(defn pay-init-fn
"Engine :init-fn -- resolve the selected payable invoices into read-only :context
(enriched invoices, client, bank accounts, warning flag). Folds together the old
initial-pay-wizard-state + PayWizard hydrate. Throws a :notification if nothing payable."
[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})))
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))
(not= (count selected-ids)
(count (:selected (:query-params request)))))
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 :invoice/vendor :vendor/name)
:invoice/invoice-number)))]
(mm/->MultiStepFormState {:invoices (mapv (fn [i] {:invoice-id (:db/id i)
:amount (:invoice/outstanding-balance i)})
invoices)
:mode :simple
:client (-> invoices first :invoice/client :db/id)
:has-warning? (boolean has-warning?)
:handwritten-date (time/now)}
[]
{:mode :simple
:has-warning? (boolean has-warning?)}))))
(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]
(fn handle [request]
{:status 302
@@ -1859,20 +1828,11 @@
::route/pay-using-credit (-> pay-using-credit
(wrap-schema-enforce :form-schema query-schema))
::route/pay-wizard (-> mm/open-wizard-handler
(mm/wrap-wizard pay-wizard)
(mm/wrap-init-multi-form-state initial-pay-wizard-state))
::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))
;; 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
;; per-method validation (check-number required, etc.) into a 4xx -> #form-errors.
::route/pay-wizard open-pay-wizard
::route/pay-submit pay-step
::route/table (helper/table-route grid-page :parse-query-params? false)}
(merge new-invoice-wizard/key->handler)

View File

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