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:
@@ -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.)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +972,21 @@
|
||||
[: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?]}]
|
||||
(let [ba-id (:db/id bank-account)]
|
||||
[: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
|
||||
@@ -991,13 +999,7 @@
|
||||
[: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)})}
|
||||
(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})"}
|
||||
@@ -1007,44 +1009,23 @@
|
||||
: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)})}
|
||||
(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 {: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)})}
|
||||
(com/button (method-control-attrs ba-id "cash")
|
||||
"With cash"))
|
||||
(when (not= :bank-account-type/cash
|
||||
(: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))
|
||||
: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)})}
|
||||
:primary)}
|
||||
(method-control-attrs ba-id "debit"))
|
||||
"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"))]]]])])
|
||||
(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)
|
||||
"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? credit-only?))])
|
||||
:footer
|
||||
nil
|
||||
:validation-route ::route/pay-wizard-navigate))))
|
||||
(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
|
||||
(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 (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
|
||||
{: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 (fc/field-errors)
|
||||
:label "Date"}
|
||||
(com/date-input {:value (-> (fc/field-value)
|
||||
{: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 (fc/field-name)
|
||||
:error? (fc/field-errors)
|
||||
:placeholder "1/1/2020"}))))
|
||||
: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)
|
||||
:content (if (< total 0)
|
||||
(format "Credit in full ($%,.2f)" total)
|
||||
(format "Pay 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]
|
||||
(for [[i ci] (map-indexed vector invoices)
|
||||
:let [inv (:invoice ci)]]
|
||||
(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"}
|
||||
(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" (-> (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)))
|
||||
(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,79 +1229,24 @@
|
||||
(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])
|
||||
|
||||
(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)
|
||||
|
||||
(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)
|
||||
(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)
|
||||
|
||||
_ (assert-schema payment-form-schema snapshot)
|
||||
@@ -1339,7 +1264,7 @@
|
||||
(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)
|
||||
(add-handwritten-check request nil snapshot)
|
||||
(try
|
||||
(print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i)
|
||||
:amount (:amount i)})
|
||||
@@ -1375,10 +1300,7 @@
|
||||
" 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))
|
||||
: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))
|
||||
(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})))
|
||||
|
||||
has-warning? (and (:selected (:query-params request))
|
||||
(not= (count selected-ids)
|
||||
(count (:selected (:query-params request)))))
|
||||
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)
|
||||
(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)
|
||||
(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?)
|
||||
:handwritten-date (time/now)}
|
||||
[]
|
||||
{:mode :simple
|
||||
: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)}}))
|
||||
|
||||
(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)
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user