Files
integreat/src/clj/auto_ap/ssr/invoice/new_invoice_wizard.clj
Bryce 6c791efb06 feat(ssr): subtle fade-in on wizard step cards
§3 animations: the migrated wizard step cards had no transition, so step→step
swaps and modal open were flat. The old mm/* slide system was deleted in Phase
11 (and its classes purged from CSS), and the transaction-edit "reference" uses
an undefined `last-modal-step` no-op — so there was no clean slide to restore.

Apply the codebase's existing `fade-in transition-opacity duration-300`
primitive (`.htmx-added .fade-in` in input.css) to all three wizard step cards
(new-invoice basic-details + accounts, vendor step-card, client step-card). Each
card now fades in on open and on every step swap. Verified live: cards always
settle to opacity 1 (never stuck invisible) on both open and step navigation.

Richer directional (forward/back) slide transitions are left for a design pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 13:26:13 -07:00

839 lines
43 KiB
Clojure

(ns auto-ap.ssr.invoice.new-invoice-wizard
"New / Edit Invoice wizard, migrated onto the session-backed engine (wizard2).
A dual-purpose wizard (create + edit) with a CONDITIONAL middle step:
basic-details --(customize-accounts = :default)--> :done (default vendor account)
\\-(customize-accounts = :customize)-> accounts --> :done
Per-step data lives in the Ring session (wizard-state); the engine's get-all merges
them for the done-fn (`create-invoice!`). Only an opaque wizard-id + current-step ride
in the form -- no EDN snapshot.
The pre-migration `mm` flow routed basic-details \"Save\" through a PUT /navigate whose
`:to` query-schema 500s on empty query-params (the {}->nil main-transformer quirk); the
engine's submit is a POST with no query-schema, so that latent bug is gone.
Dates in step-data are kept as java.util.Date (#inst) -- EDN-safe for the cookie session
store, unlike clj-time DateTimes (which have no reader); they are coerced to clj-time only
for display."
(:require
[auto-ap.datomic :refer [audit-transact conn pull-attr]]
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.datomic.invoices :as d-invoices]
[auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked
exception->4xx exception->notification]]
[auto-ap.logging :as alog]
[auto-ap.routes.invoice :as route]
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.solr :as solr]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-state :as ws]
[auto-ap.ssr.components.wizard2 :as wizard2]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.invoice.common :refer [default-read]]
[auto-ap.ssr.nested-form-params :as nfp]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema check-allowance
check-location-belongs clj-date-schema entity-id form-validation-error
html-response main-transformer modal-response money path->name2 strip
wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars=]]
[malli.core :as mc]
[malli.util :as mut]
[slingshot.slingshot :refer [try+]]))
;; ---------------------------------------------------------------------------
;; Domain helpers (unchanged from the pre-migration wizard).
;; ---------------------------------------------------------------------------
(defn get-vendor [vendor-id]
(dc/pull
(dc/db conn)
[:vendor/terms
:vendor/automatically-paid-when-due
{:vendor/default-account d-accounts/default-read
:vendor/account-overrides
[:vendor-account-override/client
{:vendor-account-override/account d-accounts/default-read}]}
{:vendor/terms-overrides
[:vendor-terms-override/client :vendor-terms-override/terms]}]
vendor-id))
(defn check-vendor-default-account [vendor-id]
(some? (:vendor/default-account (get-vendor vendor-id))))
(def new-form-schema
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:customize-due-and-scheduled? {:optional true :default false :decode/arbitrary (fn [x] (if (= "" x)
false
x))} [:maybe :boolean]]
[:customize-accounts {:optional true :default :default} [:enum :default :customize]]
[:invoice/client {:optional true} [:maybe entity-id]]
[:invoice/date clj-date-schema]
[:invoice/due {:optional true} [:maybe clj-date-schema]]
[:invoice/scheduled-payment {:optional true} [:maybe clj-date-schema]]
[:invoice/vendor {:optional true}
[:and entity-id
[:fn {:error/message "Vendor is missing default expense account"}
check-vendor-default-account]]]
[:invoice/invoice-number {:optional true} [:string {:min 1 :decode/string strip}]]
[:invoice/total money]
[:invoice/expense-accounts
[:vector {:coerce? true}
[:and
[:map
[:invoice-expense-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/invoice-allowance)]]]
[:invoice-expense-account/location :string]
[:invoice-expense-account/amount :double]]
[:fn {:error/fn (fn [r x] (:type r))
:error/path [:invoice-expense-account/location]}
(fn [iea]
(check-location-belongs (:invoice-expense-account/location iea)
(:invoice-expense-account/account iea)))]]]]])
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
(if (nil? vendor)
nil
(let [terms-override (->> terms-overrides
(filter (fn [to]
(= (->db-id (:vendor-terms-override/client to))
client-id)))
(map :vendor-terms-override/terms)
first)
account (or (->> account-overrides
(filter (fn [to]
(= (->db-id (:vendor-account-override/client to))
client-id)))
(map :vendor-account-override/account)
first)
default-account)
account (d-accounts/clientize account client-id)
automatically-paid-when-due (->> automatically-paid-when-due
(filter (fn [to]
(= (->db-id to)
client-id)))
seq
boolean)
vendor (cond-> vendor
terms-override (assoc :vendor/terms terms-override)
true (assoc :vendor/automatically-paid-when-due automatically-paid-when-due
:vendor/default-account account)
true (dissoc :vendor/account-overrides :vendor/terms-overrides))]
vendor)))
(defn location-select*
[{:keys [name account-location client-locations value]}]
(let [options (into (cond account-location
[[account-location account-location]]
(seq client-locations)
(into [["Shared" "Shared"]]
(for [cl client-locations]
[cl cl]))
:else
[["Shared" "Shared"]]))]
(com/select {:options options
:name name
:value (or value (ffirst options))
:class "w-full"})))
(defn account-typeahead*
[{:keys [name value client-id x-model]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
:id name
:x-model x-model
:value value
:content-fn (fn [value]
(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value)
client-id)))})])
(defn assert-no-conflicting [{:invoice/keys [invoice-number client vendor] :db/keys [id]}]
(when (seq (d-invoices/find-conflicting {:invoice/invoice-number invoice-number
:invoice/vendor (->db-id vendor)
:invoice/client (->db-id client)
:db/id id}))
(form-validation-error (str "Invoice '" invoice-number "' already exists."))))
(defn assert-invoice-amounts-add-up [{:keys [:invoice/expense-accounts :invoice/total]}]
(let [expense-account-total (reduce + 0 (map (fn [x] (:invoice-expense-account/amount x)) expense-accounts))]
(when-not (dollars= total expense-account-total)
(form-validation-error (str "Expense account total (" expense-account-total ") does not equal invoice total (" total ")")))))
(defn- calculate-spread
"Helper function to calculate the amount to be assigned to each location"
[shared-amount total-locations]
(let [base-amount (int (/ shared-amount total-locations))
remainder (- shared-amount (* base-amount total-locations))]
{:base-amount base-amount
:remainder remainder}))
(defn- spread-expense-account
"Spreads the expense account amount across the given locations"
[locations expense-account]
(if (= "Shared" (:invoice-expense-account/location expense-account))
(let [{:keys [base-amount remainder]} (calculate-spread (:invoice-expense-account/amount expense-account) (count locations))]
(map-indexed (fn [idx _]
(assoc expense-account
:invoice-expense-account/amount (+ base-amount (if (< idx remainder) 1 0))
:invoice-expense-account/location (nth locations idx)))
locations))
[expense-account]))
(defn $->cents [x]
(int
(let [result (* 100M (bigdec x))]
(.setScale result 0 java.math.BigDecimal/ROUND_HALF_UP))))
(defn cents->$ [x]
(double
(let [result (* 0.01M (bigdec x))]
(.setScale result 2 java.math.BigDecimal/ROUND_HALF_UP))))
(defn- apply-total-delta-to-account [invoice-total eas]
(when (seq eas)
(let [leftover (- invoice-total (reduce + 0 (map :invoice-expense-account/amount eas)))
leftover-beyond-a-single-cent? (or (< leftover -1)
(> leftover 1))
leftover (if leftover-beyond-a-single-cent?
0
leftover)
[first-eas & rest] eas]
(cons
(update first-eas :invoice-expense-account/amount #(+ % leftover))
rest))))
(defn maybe-spread-locations
"Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client"
([invoice]
(maybe-spread-locations invoice (pull-attr (dc/db conn) :client/locations (:invoice/client invoice))))
([invoice locations]
(update-in invoice
[:invoice/expense-accounts]
(fn [expense-accounts]
(->> expense-accounts
(map (fn [ea] (update ea :invoice-expense-account/amount $->cents)))
(mapcat (partial spread-expense-account locations))
(apply-total-delta-to-account ($->cents (:invoice/total invoice)))
(map (fn [ea] (update ea :invoice-expense-account/amount cents->$))))))))
;; ---------------------------------------------------------------------------
;; De-cursored field names + per-render errors.
;; ---------------------------------------------------------------------------
(def ^:dynamic *errors*
"Humanized form errors for the current step render, keyed by schema paths.
Bound by each render fn from the engine ctx :errors."
{})
(defn- ferr [& path] (get-in *errors* (vec path)))
(defn- err? [& path] (boolean (seq (apply ferr path))))
(defn- ea-name [index field] (path->name2 :invoice/expense-accounts index field))
(defn- ea-errors [index field] (ferr :invoice/expense-accounts index field))
(defn- fmt-date
"java.util.Date (#inst, EDN-safe) -> the MM/DD/YYYY display string."
[d]
(some-> d coerce/from-date (atime/unparse-local atime/normal-date)))
;; ---------------------------------------------------------------------------
;; Schemas + decode (per step).
;; ---------------------------------------------------------------------------
(def ^:private basic-details-schema
(mc/schema
(mut/select-keys new-form-schema
#{:invoice/client :invoice/vendor :invoice/date :invoice/due
:invoice/scheduled-payment :invoice/total :invoice/invoice-number
:db/id :customize-due-and-scheduled? :customize-accounts})))
(def ^:private accounts-schema
(mc/schema (mut/select-keys new-form-schema #{:invoice/expense-accounts})))
(defn- ->edn-safe-dates
"clj-time DateTimes -> java.util.Date so step-data round-trips through the cookie store."
[m]
(reduce (fn [acc k] (cond-> acc (get acc k) (update k coerce/to-date)))
m
[:invoice/date :invoice/due :invoice/scheduled-payment]))
(defn- decode-basic-details
"Step 1 posts flat invoice/* fields (the engine already stripped its nav fields)."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))]
(-> (mc/decode basic-details-schema nested main-transformer)
->edn-safe-dates)))
(defn- decode-accounts
"Step 2: nested invoice/expense-accounts[i][...] rows."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))]
(mc/decode accounts-schema nested main-transformer)))
(defn- validate-basic-details
"Mirror the old wrap-schema :fn: a new invoice needs client + vendor + invoice-number;
date + total are always required; a chosen vendor must have a default expense account."
[data _request]
(let [new? (not (:db/id data))
errs (cond-> {}
(nil? (:invoice/date data)) (assoc :invoice/date ["required"])
(nil? (:invoice/total data)) (assoc :invoice/total ["required"])
(and new? (nil? (:invoice/client data))) (assoc :invoice/client ["required"])
(and new? (nil? (:invoice/vendor data))) (assoc :invoice/vendor ["required"])
(and new? (str/blank? (:invoice/invoice-number data))) (assoc :invoice/invoice-number ["required"])
(and (:invoice/vendor data)
(not (check-vendor-default-account (->db-id (:invoice/vendor data)))))
(assoc :invoice/vendor ["Vendor is missing default expense account"]))]
(when (seq errs) errs)))
;; ---------------------------------------------------------------------------
;; Renders (de-cursored: explicit data + path->name2 + *errors*).
;; ---------------------------------------------------------------------------
(defn account-prediction-radio
"The customize-accounts radio (default vendor account vs customize), computed from the
posted/known vendor + client. Lives in an async fragment so it appears once a vendor is
chosen."
[{:keys [vendor-id client-id value]}]
(let [vendor (clientize-vendor (get-vendor vendor-id) (->db-id client-id))
account-name (:account/name (:vendor/default-account vendor))]
(when vendor
(com/radio-list {:name "customize-accounts"
:value (name (or value :default))
:options (filter identity
[(when account-name {:value (name :default)
:content (com/pill {:color :primary} account-name)})
{:value (name :customize)
:content [:div "Customize accounts"]}])}))))
(defn render-basic-details
[{:keys [step-data request errors]}]
(binding [*errors* (or errors {})]
(let [data (or step-data {})
extant? (:db/id data)
client-from-req (:db/id (:client request))
client-val (or (:invoice/client data) client-from-req)]
(com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300"
"x-data" ""}
(com/modal-header {} [:div.p-2 (if extant? "Edit invoice" "New invoice")])
(com/modal-body
{}
[:div {:x-data (hx/json {:clientId client-val
:vendorId (:invoice/vendor data)
:date (fmt-date (:invoice/date data))
:due (fmt-date (:invoice/due data))
:scheduledPayment (fmt-date (:invoice/scheduled-payment data))
:customizeDueAndScheduled (boolean (:customize-due-and-scheduled? data))})}
(when extant?
(com/hidden {:name "db/id" :value extant?}))
(com/hidden {:name "customize-due-and-scheduled?"
:value (boolean (:customize-due-and-scheduled? data))
:x-model "customizeDueAndScheduled"})
(if (or (:client request) extant?)
(com/hidden {:name "invoice/client" :value client-val})
(com/validated-field
{:label "Client" :errors (ferr :invoice/client)}
[:div.w-96
(com/typeahead {:name "invoice/client"
:error? (err? :invoice/client)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :company-search)
:value (:invoice/client data)
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))
:x-model "clientId"})]))
(com/validated-field
{:label "Vendor" :errors (ferr :invoice/vendor)}
[:div.w-96
(com/typeahead {:name "invoice/vendor"
:error? (err? :invoice/vendor)
:disabled (boolean extant?)
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (:invoice/vendor data)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))
:x-model "vendorId"})])
[:div.flex.items-center.gap-2
(com/validated-field
{:label "Date" :errors (ferr :invoice/date)}
[:div {:class "w-24"}
(com/date-input {:value (fmt-date (:invoice/date data))
:name "invoice/date"
:error? (err? :invoice/date)
:x-model "date"
:placeholder "1/1/2024"})])
[:div {:x-show "!customizeDueAndScheduled"}
(com/link {"@click" "customizeDueAndScheduled=true"
:x-show "!due && !scheduledPayment"}
"Add due / scheduled payment date")
(com/link {"@click" "customizeDueAndScheduled=true"
:x-show "due || scheduledPayment"}
"Change due / scheduled payment date")]]
(com/validated-field
(hx/alpine-appear {:label "Due (optional)"
:errors (ferr :invoice/due)
:x-show "customizeDueAndScheduled"})
[:div {:class "w-24"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/due-date)
:x-dispatch:changed "[clientId, vendorId, date]"
:hx-trigger "changed"
:hx-target "this"
:hx-swap "innerHTML"}
(com/date-input {:value (fmt-date (:invoice/due data))
:name "invoice/due"
:x-model "due"
:error? (err? :invoice/due)
:placeholder "1/1/2024"})])
(com/validated-field
(hx/alpine-appear {:label "Scheduled payment (optional)"
:errors (ferr :invoice/scheduled-payment)
:x-show "customizeDueAndScheduled"})
[:div {:class "w-24"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/scheduled-payment-date)
:x-dispatch:changed "[clientId, vendorId, due]"
:hx-trigger "changed"
:hx-target "this"
:hx-swap "innerHTML"}
(com/date-input {:value (fmt-date (:invoice/scheduled-payment data))
:name "invoice/scheduled-payment"
:error? (err? :invoice/scheduled-payment)
:placeholder "1/1/2024"})])
(com/validated-field
{:label "Invoice Number" :errors (ferr :invoice/invoice-number)}
[:div {:class "w-24"}
(com/text-input {:value (:invoice/invoice-number data)
:name "invoice/invoice-number"
:error? (err? :invoice/invoice-number)
:placeholder "HA-123"})])
(com/validated-field
{:label "Total" :errors (ferr :invoice/total)}
[:div {:class "w-16"}
(com/money-input {:value (:invoice/total data)
:name "invoice/total"
:class "w-24"
:error? (err? :invoice/total)
:placeholder "212.44"})])
[:div#expense-account-prediction
(hx/alpine-appear
{:x-dispatch:bryce "[vendorId]"
:hx-trigger "bryce"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/account-prediction)
:hx-target "this"
:hx-swap "innerHTML"})
(account-prediction-radio {:vendor-id (->db-id (:invoice/vendor data))
:client-id client-val
:value (:customize-accounts data)})]])
(com/modal-footer {} (wizard2/nav-footer {:save? true :save-label "Save"}))))))
(defn- invoice-expense-account-row*
"One expense-account row, de-cursored: a plain account map + its index."
[{:keys [account index client-id client-locations]}]
(let [acct (:invoice-expense-account/account account)
acct-id (if (map? acct) (:db/id acct) acct)
aname (ea-name index :invoice-expense-account/account)
lname (ea-name index :invoice-expense-account/location)]
(com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (:new? account)))
:accountId acct-id})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(com/hidden {:name (ea-name index :db/id)
:value (:db/id account)})
(com/data-grid-cell
{}
(com/validated-field
{:errors (ea-errors index :invoice-expense-account/account)}
(account-typeahead* {:value acct-id
:client-id client-id
:name aname
:x-model "accountId"})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (ea-errors index :invoice-expense-account/location)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json {:name lname :client-id client-id})
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
:hx-target "find *"
:hx-swap "outerHTML"}
(location-select* {:name lname
:account-location (:account/location (when (nat-int? acct-id)
(dc/pull (dc/db conn) '[:account/location] acct-id)))
:client-locations client-locations
:value (:invoice-expense-account/location account)})))
(com/data-grid-cell
{}
(com/validated-field
{:errors (ea-errors index :invoice-expense-account/amount)}
(com/money-input {:name (ea-name index :invoice-expense-account/amount)
:class "w-16 amount-field"
:value (:invoice-expense-account/amount account)})))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn- expense-accounts-or-default
"The accounts grid's rows: the posted rows, or -- on first landing -- a single row
prefilled with the vendor's default account + the invoice total (the old Initializable)."
[{:keys [step-data all-data]}]
(let [rows (:invoice/expense-accounts step-data)]
(if (seq rows)
(vec rows)
[{:db/id "123"
:invoice-expense-account/location "Shared"
:invoice-expense-account/account
(:db/id (:vendor/default-account
(clientize-vendor (get-vendor (->db-id (:invoice/vendor all-data)))
(->db-id (:invoice/client all-data)))))
:invoice-expense-account/amount (:invoice/total all-data)}])))
(defn- expense-accounts-total* [rows]
(->> rows
(map (fnil :invoice-expense-account/amount 0.0))
(filter number?)
(reduce + 0.0)))
(defn render-accounts
[{:keys [all-data errors] :as ctx}]
(binding [*errors* (or errors {})]
(let [client-id (->db-id (:invoice/client all-data))
client-locations (pull-attr (dc/db conn) :client/locations client-id)
rows (expense-accounts-or-default ctx)
invoice-total (:invoice/total all-data)
total (expense-accounts-total* rows)]
(com/modal-card-advanced
{"@keydown.enter.prevent.stop" "if ($refs.next) {$refs.next.click()}"
:class " md:w-[750px] md:h-[600px] w-full h-full fade-in transition-opacity duration-300"
"x-data" ""}
(com/modal-header {} [:div.p-2 "Invoice accounts "])
(com/modal-body
{}
[:div {}
(pull-attr (dc/db conn) :client/name client-id)
(com/validated-field
{:errors (ferr :invoice/expense-accounts)}
(com/data-grid {:headers [(com/data-grid-header {} "Account")
(com/data-grid-header {:class "w-32"} "Location")
(com/data-grid-header {:class "w-16"} "$")
(com/data-grid-header {:class "w-16"})]}
(map-indexed (fn [i a] (invoice-expense-account-row* {:account a
:index i
:client-id client-id
:client-locations client-locations}))
rows)
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard-new-account)
:index (count rows)
:tr-params {:hx-vals (hx/json {:client-id client-id})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(format "$%,.2f" total))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "balance"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi/path-for ssr-routes/only-routes ::route/expense-account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
[:span {:class (when-not (dollars= 0.0 (- invoice-total total)) "text-red-300")}
(format "$%,.2f" (- invoice-total total))])
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "INVOICE TOTAL"])
(com/data-grid-cell {:class "text-right"} (format "$%,.2f" invoice-total))
(com/data-grid-cell {}))))])
(com/modal-footer {} (wizard2/nav-footer {:back? true :save? true :save-label "Save"}))))))
;; ---------------------------------------------------------------------------
;; Async step fragments (read from posted form / session -- no multi-form-state).
;; ---------------------------------------------------------------------------
(defn- form-vendor-client
"Decode just the vendor + client + dates a fragment needs from the posted basic-details
form (flat invoice/* fields)."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))]
(mc/decode basic-details-schema nested main-transformer)))
(defn account-prediction [request]
(let [{:invoice/keys [vendor client] :keys [customize-accounts]} (form-vendor-client request)]
(html-response (account-prediction-radio {:vendor-id (->db-id vendor)
:client-id client
:value customize-accounts}))))
(defn due-date [request]
(let [{:invoice/keys [vendor client date due]} (form-vendor-client request)
vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client))
good-date (or (when (and date (:vendor/terms vendor))
(time/plus date (time/days (:vendor/terms vendor))))
due)]
(html-response
(com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date))
:name "invoice/due"
:x-init (format "due='%s'" (or (some-> good-date (atime/unparse-local atime/normal-date)) ""))
:x-model "due"
:error? false
:placeholder "1/1/2024"}))))
(defn scheduled-payment-date [request]
(let [{:invoice/keys [vendor client due]} (form-vendor-client request)
vendor (clientize-vendor (get-vendor (->db-id vendor)) (->db-id client))
good-date (when (and due (:vendor/automatically-paid-when-due vendor))
due)]
(html-response
(com/date-input {:value (some-> good-date (atime/unparse-local atime/normal-date))
:name "invoice/scheduled-payment"
:error? false
:placeholder "1/1/2024"}))))
(defn location-select [{{:keys [name account-id client-id value]} :query-params}]
(html-response (location-select* {:name name
:value value
:account-location (some->> account-id
(pull-attr (dc/db conn) :account/location))
:client-locations (some->> client-id
(pull-attr (dc/db conn) :client/locations))})))
(defn- posted-amount-total
"Sum the amounts in the posted accounts form."
[request]
(let [nested (:form-params (nfp/nested-params-request request {}))
decoded (mc/decode accounts-schema nested main-transformer)]
(expense-accounts-total* (:invoice/expense-accounts decoded))))
(defn- session-invoice-total
"The invoice total stored in basic-details step-data (the accounts form doesn't carry it)."
[request]
(let [wid (get-in request [:form-params "wizard-id"])]
(or (:invoice/total (ws/get-all (:session request) wid)) 0.0)))
(defn expense-account-total [request]
(html-response (format "$%,.2f" (posted-amount-total request))))
(defn expense-account-balance [request]
(let [balance (- (session-invoice-total request) (posted-amount-total request))]
(html-response [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")}
(format "$%,.2f" balance)])))
(defn new-account
"Add-row: render one fresh expense-account row at the posted index."
[request]
(let [idx (-> request :query-params :index)
idx (if (string? idx) (Integer/parseInt idx) idx)
client-id (-> request :query-params :client-id)
client-locations (some->> client-id (pull-attr (dc/db conn) :client/locations))]
(html-response
(invoice-expense-account-row* {:account (wizard2/blank-row :invoice-expense-account/location "Shared")
:index idx
:client-id client-id
:client-locations client-locations}))))
;; ---------------------------------------------------------------------------
;; done-fn: create / update the invoice, then show next-steps (or the updated row).
;; ---------------------------------------------------------------------------
(defn- next-steps-modal
"After a NEW invoice is created: offer to pay it now, add another, or close."
[invoice-id]
(modal-response
(com/modal {}
(com/modal-card-advanced
{:class "transition duration-300 ease-in-out scale-100 translate-x-0 opacity-100"}
(com/modal-header {} [:div.p-2 "Invoice accounts "])
(com/modal-body
{}
[:p.text-lg "Would you like to pay this invoice now?"]
(com/navigation-button-list
{}
(com/navigation-button (-> {:class "w-48"
:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard)
{:selected invoice-id :replace-modal true})}
hx/trigger-click-or-enter) "Pay now")
(com/navigation-button (-> {:class "w-48"
:hx-get (hu/url (bidi/path-for ssr-routes/only-routes ::route/new-wizard)
{:replace-modal true})}
hx/trigger-click-or-enter) "Add another")
(com/navigation-button {:class "w-48" :next-arrow? false
"@click" "$dispatch('modalclose') "
"@keyup.enter.stop" "$dispatch('modalclose')"}
"Close")))))
:headers {"hx-trigger" "invalidated"}))
(defn- updated-row-response
"After an EDIT: swap the table row in place (resolve invoices/row* to avoid a cycle)."
[identity invoice-id request]
(html-response
(@(resolve 'auto-ap.ssr.invoices/row*) identity (dc/pull (dc/db conn) default-read invoice-id) {:flash? true
:request request})
:headers {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
"hx-reswap" "outerHTML"}))
(defn create-invoice!
"Engine done-fn: merge the steps into the invoice, validate, upsert, and respond. Default
accounts use the vendor's default account; :customize uses the posted grid."
[all-data {:keys [identity] :as request}]
(let [invoice all-data
extant? (:db/id invoice)
client-id (->db-id (:invoice/client invoice))
vendor-id (->db-id (:invoice/vendor invoice))
paid-amount (if-let [outstanding-balance
(and extant?
(- (pull-attr (dc/db conn) :invoice/total (:db/id invoice))
(pull-attr (dc/db conn) :invoice/outstanding-balance (:db/id invoice))))]
outstanding-balance
0.0)
outstanding-balance (- (:invoice/total invoice) paid-amount)
entity (-> invoice
(assoc :db/id (or (:db/id invoice) "invoice"))
(dissoc :customize-due-and-scheduled? :customize-accounts)
(assoc :invoice/expense-accounts
(if (= :customize (:customize-accounts invoice))
(:invoice/expense-accounts invoice)
[{:db/id "123"
:invoice-expense-account/location "Shared"
:invoice-expense-account/account
(:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) client-id)))
:invoice-expense-account/amount (:invoice/total invoice)}]))
(assoc :invoice/outstanding-balance outstanding-balance
:invoice/import-status :import-status/imported
:invoice/status (if (dollars= 0.0 outstanding-balance)
:invoice-status/paid
:invoice-status/unpaid))
(maybe-spread-locations)
(update :invoice/date coerce/to-date)
(update :invoice/due coerce/to-date)
(update :invoice/scheduled-payment coerce/to-date))]
(assert-invoice-amounts-add-up entity)
(assert-no-conflicting invoice)
(exception->4xx #(assert-can-see-client identity client-id))
(exception->4xx #(assert-not-locked client-id (:invoice/date invoice)))
(let [transaction-result (audit-transact [[:upsert-invoice entity]] identity)]
(try
(solr/touch-with-ledger (or (:db/id invoice) (get-in transaction-result [:tempids "invoice"])))
(catch Exception e
(alog/error ::cant-save-solr :error e)))
(if extant?
(updated-row-response identity (:db/id invoice) request)
(next-steps-modal (get-in transaction-result [:tempids "invoice"]))))))
;; ---------------------------------------------------------------------------
;; Engine config + open / submit handlers.
;; ---------------------------------------------------------------------------
(defn new-init-fn
"Engine :init-fn -- new vs edit branch on a route db/id. Dates ride as java.util.Date
(EDN-safe). For new, the date defaults to today; for edit, the persisted invoice is read
and customize-accounts is forced to :customize so its accounts are editable."
[request]
(if-let [id (->db-id (get-in request [:route-params :db/id]))]
(let [entity (-> (dc/pull (dc/db conn) default-read id)
(select-keys (mut/keys new-form-schema))
->edn-safe-dates
(assoc :customize-accounts :customize))]
{:init-data {:basic-details (dissoc entity :invoice/expense-accounts)
:accounts (select-keys entity [:invoice/expense-accounts])}})
{:init-data {:basic-details {:invoice/date (coerce/to-date (time/now))
:customize-accounts :default}}}))
(def new-invoice-config
{:name :new-invoice
:form-id "wizard-form"
:submit-route (bidi/path-for ssr-routes/only-routes ::route/new-invoice-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 new-init-fn
:steps [{:key :basic-details
:decode decode-basic-details
:validate validate-basic-details
:render render-basic-details
:next (fn [data] (if (= :customize (:customize-accounts data)) :accounts :done))}
{:key :accounts
:decode decode-accounts
:render render-accounts
:next (fn [_] :done)}]
:done-fn create-invoice!})
(defn open-wizard
"GET open (new or edit): build the wizard in its modal shell."
[request]
(exception->notification
#(wizard2/open-wizard new-invoice-config request)))
(defn new-invoice-step
"POST handler for every transition: basic-details Save, accounts Save/Back. Surface the
create-time validation (conflict, amounts, locks) as a 4xx into #form-errors."
[request]
(try+
(wizard2/handle-step-submit new-invoice-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 save the invoice.")]
:status 400))))
(def key->handler
(apply-middleware-to-all-handlers
{::route/new-wizard (partial open-wizard)
::route/edit-wizard (-> (partial open-wizard)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/new-invoice-submit new-invoice-step
::route/account-prediction account-prediction
::route/due-date due-date
::route/scheduled-payment-date scheduled-payment-date
::route/expense-account-total expense-account-total
::route/expense-account-balance expense-account-balance
::route/location-select (-> location-select
(wrap-schema-enforce :query-schema [:map
[:name :string]
[:client-id {:optional true} [:maybe entity-id]]
[:account-id {:optional true} [:maybe entity-id]]]))
::route/new-wizard-new-account (-> new-account
(wrap-schema-enforce :query-schema [:map
[:index {:optional true} [:maybe nat-int?]]
[:client-id {:optional true} [:maybe entity-id]]]))}
(fn [h]
(-> h
(wrap-client-redirect-unauthenticated)))))