diff --git a/.claude/skills/ssr-form-migration/reference/gotchas.md b/.claude/skills/ssr-form-migration/reference/gotchas.md index ff1334f3..6312cd87 100644 --- a/.claude/skills/ssr-form-migration/reference/gotchas.md +++ b/.claude/skills/ssr-form-migration/reference/gotchas.md @@ -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.) diff --git a/.claude/skills/ssr-form-migration/reference/scorecard.md b/.claude/skills/ssr-form-migration/reference/scorecard.md index 4600feec..ef4d6dc1 100644 --- a/.claude/skills/ssr-form-migration/reference/scorecard.md +++ b/.claude/skills/ssr-form-migration/reference/scorecard.md @@ -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. diff --git a/e2e/invoice-pay.spec.ts b/e2e/invoice-pay.spec.ts index 01cfa139..b575fd29 100644 --- a/e2e/invoice-pay.spec.ts +++ b/e2e/invoice-pay.spec.ts @@ -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'); diff --git a/src/clj/auto_ap/ssr/components/wizard2.clj b/src/clj/auto_ap/ssr/components/wizard2.clj index f1858724..9d8b5f72 100644 --- a/src/clj/auto_ap/ssr/components/wizard2.clj +++ b/src/clj/auto_ap/ssr/components/wizard2.clj @@ -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 diff --git a/src/clj/auto_ap/ssr/invoices.clj b/src/clj/auto_ap/ssr/invoices.clj index 2293e6b3..e2126e49 100644 --- a/src/clj/auto_ap/ssr/invoices.clj +++ b/src/clj/auto_ap/ssr/invoices.clj @@ -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) diff --git a/src/cljc/auto_ap/routes/invoice.cljc b/src/cljc/auto_ap/routes/invoice.cljc index 462ed92c..c732782a 100644 --- a/src/cljc/auto_ap/routes/invoice.cljc +++ b/src/cljc/auto_ap/routes/invoice.cljc @@ -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}