Files
integreat/src/clj/auto_ap/ssr/transaction/edit.clj
Bryce a01dfc197e refactor(ssr): full Selmer migration of Transaction Edit; remove the wizard
Migrate every part of the Transaction Edit modal's HTML to Selmer templates
(zero Hiccup in the render path) and delete the mm multi-modal "wizard"
abstraction entirely -- there was only ever one step.

- New auto-ap.ssr.components.selmer (sc) + ~22 shared component partials under
  resources/templates/components/ (typeahead, button-group, radio-card,
  data-grid, validated-field, modal, buttons, inputs, SVGs). Each wrapper renders
  its own partial; dynamic HTMX/Alpine attrs bridge via attrs->str -> {{attrs|safe}}.
- 15 modal templates under resources/templates/transaction-edit/.
- Delete EditWizard/LinksStep records + all mm/* usage. Plain handlers: flat
  wrap-decode-edit (fields renamed off step-params[...], stray keys stripped),
  flat wrap-derive-state, *errors*-based field errors, generic wrap-form-4xx-2.
- Drop the edit-wizard-navigate route (routes ~12 -> 5).
- Fix: stray `method` (tab button-group hidden) leaked into the upsert -> 500;
  strip decoded map to schema keys.
- e2e selectors updated (#wizard-form->#edit-form, #wizardmodal->#editmodal,
  step-params[...] field names). Parity: swap 6/6, edit 8/8, suite 38/1
  (1 pre-existing unrelated nav test).
- ssr-form-migration skill updated with the learnings (composition mechanics,
  sc/* library, drop-the-wizard recipe, scorecard row, 3 new gotchas).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 07:47:47 -07:00

1557 lines
82 KiB
Clojure
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(ns auto-ap.ssr.transaction.edit
(:require
[auto-ap.datomic
:refer [audit-transact conn pull-attr pull-ref]]
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.datomic.checks :as d-checks]
[auto-ap.datomic.invoices :as d-invoices]
[auto-ap.datomic.transactions :as d-transactions]
[auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked
exception->4xx]]
[auto-ap.import.transactions :as i-transactions]
[auto-ap.logging :as alog]
[auto-ap.permissions :refer [wrap-must]]
[auto-ap.routes.payments :as payment-route]
[auto-ap.routes.transactions :as route]
[auto-ap.routes.utils
:refer [wrap-client-redirect-unauthenticated]]
[auto-ap.rule-matching :as rm]
[auto-ap.solr :as solr]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.inputs :as inputs]
[auto-ap.ssr.components.selmer :as sc]
[auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.transaction.common :refer [grid-page]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.selmer :as sel]
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers assert-schema
check-allowance check-location-belongs entity-id
form-validation-error html-response main-transformer
modal-response path->name2 ref->enum-schema strip temp-id
wrap-form-4xx-2 wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clojure.edn :as edn]
[clojure.string :as str]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars=]]
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(declare render-full-form wrap-div)
(def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved"
:transaction-approval-status/approved "Approved"
:transaction-approval-status/suppressed "Client Review"})
(def row* (partial helper/row* grid-page))
(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))))
(defn require-approval [s]
[:and s
[:fn {:error/message "Approved transactions must have accounts assigned."}
(fn [{:transaction/keys [accounts approval-status]}]
(or (not= approval-status :transaction-approval-status/approved)
(seq accounts)))]])
(def edit-form-schema
(mc/schema
[:and
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:action [:enum :apply-rule :unlink-payment :link-unpaid-invoices :link-autopay-invoices :link-payment :manual]]
[:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:amount-mode {:optional true} [:maybe [:enum "$" "%"]]]
[:mode {:optional true} [:maybe [:enum "simple" "advanced"]]]
[:transaction/accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:and
[:map
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
[:transaction-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/default-allowance)]]]
[:transaction-account/location :string]
[:transaction-account/amount :double]]
[:fn {:error/fn (fn [r x] (:type r))
:error/path [:transaction-account/location]}
(fn [iea]
(check-location-belongs (:transaction-account/location iea)
(:transaction-account/account iea)))]]]]]]
[:multi {:dispatch :action}
[:apply-rule [:map
[:rule-id {:optional true} [:maybe entity-id]]]]
[:unlink-payment [:map
[:transaction-id entity-id]]]
[:link-unpaid-invoices [:map
[:unpaid-invoice-ids {:decode/string (fn [x] (edn/read-string x))}
[:vector {:coerce? true} entity-id]]]]
[:link-autopay-invoices [:map
[:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]]
[:link-payment [:map
[:payment-id entity-id]]]
[:manual (require-approval [:map])]]]))
(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 vendor-default-account [vendor-id client-id]
(when vendor-id
(let [vendor (get-vendor vendor-id)
clientized (clientize-vendor vendor client-id)]
(:vendor/default-account clientized))))
(defn location-select*
"The location <select> for an account row, rendered from a Selmer template
(templates/components/location-select.html) -- the first interactive modal component
migrated off Hiccup. Same options/selection/styling as the old com/select, emitted as
plain HTML and embedded back into the Hiccup row via the interop bridge."
[{:keys [name account-location client-locations value]}]
(let [options (cond account-location
[[account-location account-location]]
(seq client-locations)
(into [["Shared" "Shared"]]
(for [cl client-locations]
[cl cl]))
:else
[["Shared" "Shared"]])
selected (or value (ffirst options))
classes (str/join " " (conj (vec inputs/default-input-classes) "w-full"))]
(sel/render->hiccup
"templates/components/location-select.html"
{:name name
:classes classes
:options (for [[v label] options]
{:value v :label label :selected (= v selected)})})))
(defn account-typeahead*
[{:keys [name value client-id x-model]}]
(wrap-div
"flex flex-col"
(sc/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
(cond-> {:purpose "transaction"}
client-id (assoc :client-id client-id)))
: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)))})))
(def ^:dynamic *errors*
"Humanized form errors for the current render, keyed by edit-form-schema paths (e.g.
{:transaction/accounts {0 {:transaction-account/account [\"...\"]}}}). Bound by
render-form from the request's :form-errors. Plain map -- no wizard, no cursor."
{})
(defn fname
"Form-field name for a schema path, e.g. (fname :transaction/accounts 0
:transaction-account/account) => \"transaction/accounts[0][transaction-account/account]\".
No step-params prefix: posted fields decode straight into edit-form-schema."
[& path]
(apply path->name2 path))
(defn ferr
"Field errors at a schema path, read from *errors* (no step-params prefix)."
[& path]
(get-in *errors* (vec path)))
(defn- account-field-name [index field]
(fname :transaction/accounts index field))
(defn- account-field-errors [index field]
(ferr :transaction/accounts index field))
(defn wrap-div
"Trivial structural wrapper <div class=...> around already-rendered HTML fragments.
Plain-string composition (not Hiccup) -- the substantive markup lives in Selmer
component templates; this just nests their output."
[class & body]
(sel/raw (str "<div class=\"" class "\">"
(apply str (map str (remove nil? body)))
"</div>")))
(defn simple-mode-fields*
"Renders the simple-mode account + location row and the toggle-to-advanced link.
Must be called within a fc/start-form + fc/with-field :step-params context.
Caller must establish Alpine x-data with simpleAccountId in scope.
The single account row is rendered from explicit data with explicit field names
(account-field-name 0 ...) rather than faking a synthetic MapCursor rooted at
accounts[0] -- the row always lives at index 0 in simple mode."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
client-id (or (-> request :entity :transaction/client :db/id)
(:transaction/client snapshot))
existing-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))
account-val (let [av (:transaction-account/account existing-row)]
(if (map? av) (:db/id av) av))
location-val (or (:transaction-account/location existing-row) "Shared")
account-id (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val))
row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID)))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
location-attrs {:x-hx-val:account-id "simpleAccountId"
:hx-vals (hx/json (cond-> {:name (account-field-name 0 :transaction-account/location)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "simpleAccountId"
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#simple-account-location"
:hx-select "#simple-account-location"
:hx-swap "outerHTML"
:hx-include "closest form"}]
(sel/render->hiccup
"templates/transaction-edit/simple-mode.html"
{:row_id_hidden (str (sc/hidden {:name (account-field-name 0 :db/id) :value row-id}))
;; Selecting the account only affects the valid Location options, so the change
;; swaps just the #simple-account-location cell -- nothing else re-renders.
:account_field (str (sc/validated-field
{:label "Account"
:errors (account-field-errors 0 :transaction-account/account)}
(wrap-div "w-72"
(account-typeahead* {:value account-val
:client-id client-id
:name (account-field-name 0 :transaction-account/account)
:x-model "simpleAccountId"}))))
:location_field (str (sc/validated-field
(merge {:label "Location"
:errors (account-field-errors 0 :transaction-account/location)}
location-attrs)
(location-select*
{:name (account-field-name 0 :transaction-account/location)
:account-location (:account/location account-id)
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value location-val})))
:amount_hidden (str (sc/hidden {:name (account-field-name 0 :transaction-account/amount)
:value total}))
:toggle_attrs (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "toggle-mode"})
:hx-include "closest form"
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"})})))
(defn- manual-mode-initial
"Returns :simple or :advanced based on existing account row count."
[snapshot]
(let [rows (seq (:transaction/accounts snapshot))]
(if (and rows (> (count rows) 1))
:advanced
:simple)))
(defn transaction-account-row*
"One row of the advanced account grid, from a plain account map (no cursor). The
location cell swaps just itself (#account-location-<index>, Rule 2); the amount cell
swaps only #account-totals (Rule 4); remove swaps the whole #edit-form (Rule 3)."
[{:keys [value client-id amount-mode index]}]
(let [account-val (let [av (:transaction-account/account value)]
(if (map? av) (:db/id av) av))
location-attrs {:x-hx-val:account-id "accountId"
:hx-vals (hx/json (cond-> {:name (account-field-name index :transaction-account/location)}
client-id (assoc :client-id client-id)))
:x-dispatch:changed "accountId"
:hx-trigger "changed"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target (str "#account-location-" index)
:hx-select (str "#account-location-" index)
:hx-swap "outerHTML"
:hx-include "closest form"}
amount-attrs {:name (account-field-name index :transaction-account/amount)
:id (str "account-amount-" index)
:class "w-16 account-amount-field"
:value (:transaction-account/amount value)
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#account-totals"
:hx-select "#account-totals"
:hx-swap "outerHTML"
:hx-trigger "keyup changed delay:300ms"
:hx-include "closest form"}]
(sc/data-grid-row
(-> {:class "account-row"
:id (str "account-row-" index)
:x-data (hx/json {:show (boolean (not (:new? value)))
:accountId account-val})
:data-key "show"
:x-ref "p"}
hx/alpine-mount-then-appear)
(sc/hidden {:name (account-field-name index :db/id)
:value (:db/id value)})
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :transaction-account/account)}
(account-typeahead* {:value account-val
:client-id client-id
:name (account-field-name index :transaction-account/account)
:x-model "accountId"})))
(sc/data-grid-cell
{:id (str "account-location-" index)}
(sc/validated-field
(merge {:errors (account-field-errors index :transaction-account/location)}
location-attrs)
(location-select* {:name (account-field-name index :transaction-account/location)
:account-location (:account/location (when (nat-int? account-val)
(dc/pull (dc/db conn) '[:account/location] account-val)))
:client-locations (pull-attr (dc/db conn) :client/locations client-id)
:value (:transaction-account/location value)})))
(sc/data-grid-cell
{}
(sc/validated-field
{:errors (account-field-errors index :transaction-account/amount)}
(if (= "%" amount-mode)
(sc/text-input (assoc amount-attrs :type "number" :step "0.01"))
(sc/money-input amount-attrs))))
(sc/data-grid-cell
{:class "align-top"}
(sc/a-icon-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "remove-account" :row-index (or index 0)})
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:class "account-remove-action"}
(sc/render "templates/components/svg-x.html" {}))))))
(defn location-select [{{:keys [name account-id client-id value] :as qp} :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 account-total* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:transaction/accounts)
(map (fnil :transaction-account/amount 0.0))
(filter number?)
(reduce + 0.0))]
(format "$%,.2f" total)))
(defn account-balance* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:transaction/accounts)
(map (fnil :transaction-account/amount 0.0))
(filter number?)
(reduce + 0.0))
balance (-
(Math/abs (or (-> request :entity :transaction/amount)
(-> request :multi-form-state :snapshot :transaction/amount)
0.0))
total)]
(sel/raw (str "<span"
(when-not (dollars= 0.0 balance) " class=\"text-red-300\"")
">" (format "$%,.2f" balance) "</span>"))))
(defn ->percentage [amount total]
(when (and amount total (not= total 0))
(* 100.0 (/ amount total))))
(defn percentages->dollars [percentages total]
(let [total-cents (int (* 100 (Math/abs total)))
pct-sum (reduce + 0 percentages)
normalized-pcts (if (zero? pct-sum)
(repeat (count percentages) 0)
(map #(* (/ % pct-sum) 100) percentages))
individual-cents (map #(int (* total-cents (/ % 100))) normalized-pcts)
short-by (- total-cents (reduce + 0 individual-cents))
adjustments (concat (take short-by (repeat 1)) (repeat 0))
final-cents (map + individual-cents adjustments)]
(map #(* 0.01 %) final-cents)))
(defn convert-accounts-mode [accounts old-mode new-mode total]
(if (= old-mode new-mode)
accounts
(let [amounts (map :transaction-account/amount accounts)]
(map #(assoc %1 :transaction-account/amount %2)
accounts
(case [old-mode new-mode]
["$" "%"] (map #(->percentage % total) amounts)
["%" "$"] (percentages->dollars amounts total)
amounts)))))
(defn- bold-right [label]
(sel/raw (str "<span class=\"font-bold text-right\">" label "</span>")))
(defn account-totals-tbody*
"The separately-swappable totals <tbody> (Rule 4 target #account-totals)."
[request total]
(sel/render->hiccup
"templates/transaction-edit/account-totals.html"
{:rows (str
(sc/data-grid-row {:class "account-total-row"}
(sc/data-grid-cell {})
(sc/data-grid-cell {:class "text-right"} (bold-right "TOTAL"))
(sc/data-grid-cell {:id "total" :class "text-right"} (account-total* request))
(sc/data-grid-cell {}))
(sc/data-grid-row {:class "account-balance-row"}
(sc/data-grid-cell {})
(sc/data-grid-cell {:class "text-right"} (bold-right "BALANCE"))
(sc/data-grid-cell {:id "balance" :class "text-right"} (account-balance* request))
(sc/data-grid-cell {}))
(sc/data-grid-row {:class "account-grand-total-row"}
(sc/data-grid-cell {})
(sc/data-grid-cell {:class "text-right"} (bold-right "TRANSACTION TOTAL"))
(sc/data-grid-cell {:class "text-right"} (format "$%,.2f" total))
(sc/data-grid-cell {})))}))
(defn account-grid-body* [request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
amount-mode (or (:amount-mode snapshot) "$")
client-id (-> request :entity :transaction/client :db/id)
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
accounts (vec (or (seq (:transaction/accounts step-params))
(:transaction/accounts snapshot)
[]))]
(apply
sc/data-grid
{:headers [(sc/data-grid-header {} "Account")
(sc/data-grid-header {:class "w-32"} "Location")
(sc/data-grid-header {:class "w-16"}
(sc/radio-card {:options [{:value "$" :content "$"}
{:value "%" :content "%"}]
:value amount-mode
:name "amount-mode"
:orientation :horizontal
:hx-vals (hx/json {:op "toggle-amount-mode"})
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"}))
(sc/data-grid-header {:class "w-16"})]
:footer-tbody (account-totals-tbody* request total)}
(concat
(map-indexed
(fn [index account]
(transaction-account-row* {:value account
:client-id client-id
:amount-mode amount-mode
:index index}))
accounts)
[(sc/data-grid-row
{:class "new-row"}
(sc/data-grid-cell {:colspan 4}
(sc/a-button {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "new-account"})
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"
:hx-include "closest form"
:color :secondary}
"New account")))]))))
(defn manual-coding-section*
"Renders the vendor field + account/location section for the manual tab.
mode is :simple or :advanced.
In simple mode, establishes Alpine x-data with simpleAccountId in scope.
Must be called within a fc/start-form + fc/with-field :step-params context."
[mode request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
all-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
row-count (count all-accounts)
vendor-val (:transaction/vendor step-params)
toggle-attrs (fn [] (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "toggle-mode"})
:hx-include "closest form"
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"}))]
(sel/render->hiccup
"templates/transaction-edit/manual-coding.html"
{:mode_hidden (str (sc/hidden {:name "mode" :value (name mode)}))
:vendor_changed_attrs (sc/attrs->str {:hx-trigger "change"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-form-changed)
:hx-vals (hx/json {:op "vendor-changed"})
:hx-target "#edit-form"
:hx-select "#edit-form"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"})
:vendor_field (str (sc/validated-field
{:label "Vendor" :errors (ferr :transaction/vendor)}
(wrap-div "w-96"
(sc/typeahead {:name (fname :transaction/vendor)
:error? (boolean (seq (ferr :transaction/vendor)))
:class "w-96"
:placeholder "Search..."
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value vendor-val
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))}))))
:is_simple (= mode :simple)
:simple_xdata (when (= mode :simple)
(hx/json {:simpleAccountId (let [av (-> (first all-accounts) :transaction-account/account)]
(if (map? av) (:db/id av) av))}))
:simple_mode (when (= mode :simple) (str (simple-mode-fields* request)))
:toggle_link (when (and (not= mode :simple) (<= row-count 1))
(str (wrap-div "mb-2"
(sel/raw (str "<a class=\"text-sm text-blue-600 hover:underline cursor-pointer\""
(toggle-attrs)
">Switch to simple mode</a>")))))
:accounts_field (when (not= mode :simple)
(str (sc/validated-field
{:errors (ferr :transaction/accounts)}
(sel/raw (str "<div id=\"account-grid-body\">"
(str (account-grid-body* request))
"</div>")))))})))
(defn apply-toggle-amount-mode
"edit-form-changed op: convert account amounts between $ and % and record the new mode."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
old-mode (or (:amount-mode snapshot) "$")
new-mode (or (:amount-mode step-params) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))
;; Convert the LIVE rows (step-params), not the stale snapshot, so amounts the
;; user typed before toggling survive. step-params is already schema-decoded.
accounts (convert-accounts-mode (or (seq (:transaction/accounts step-params))
(:transaction/accounts snapshot))
old-mode new-mode total)]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts)
(assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))))
(defn transaction-details-panel [tx]
(sel/render->hiccup
"templates/transaction-edit/details-panel.html"
{:amount (format "$%,.2f" (Math/abs (or (:transaction/amount tx) 0.0)))
:date (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))
:bank_account (or (-> tx :transaction/bank-account :bank-account/name) "-")
:post_date (some-> tx :transaction/post-date coerce/to-date-time (atime/unparse-local atime/normal-date))
:description_original (or (:transaction/description-original tx) "No original description")
:description_simple (or (:transaction/description-simple tx) "-")
:check_number (or (:transaction/check-number tx) "-")
:status (or (some-> tx :transaction/status) "-")
:type (or (some-> tx :transaction/type) "-")}))
(defn get-available-payments [request]
(let [tx-id (or (get-in request [:form-params :transaction-id])
(-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (-> tx :transaction/client :db/id)
payments (when client-id
(dc/q '[:find [(pull ?p [:db/id :payment/invoice-number :payment/amount :payment/date
{:payment/vendor [:db/id :vendor/name]}]) ...]
:in $ ?client
:where
[?p :payment/client ?client]
[?p :payment/status :payment-status/pending]]
(dc/db conn)
client-id))]
(filter #(dollars= (Math/abs (:transaction/amount tx)) (:payment/amount %)) payments)))
(defn get-available-autopay-invoices [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (-> request :entity :transaction/client :db/id)
matches-set (when (and tx client-id)
(i-transactions/match-transaction-to-unfulfilled-autopayments
(:transaction/amount tx)
client-id))]
(when matches-set
(for [matches matches-set]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn- panel-wrap [inner]
(sel/raw (str "<div>" (str inner) "</div>")))
(defn- panel-empty* [message]
(sel/render->hiccup "templates/transaction-edit/panel-empty.html" {:message message}))
(defn- panel-list* [{:keys [heading action-hidden prompt radio]}]
(sel/render->hiccup "templates/transaction-edit/panel-list.html"
{:heading heading
:action_hidden (str action-hidden)
:prompt prompt
:radio (str radio)}))
(defn- invoice-group-content [match-group]
(sel/raw (apply str (for [invoice match-group]
(sel/render "templates/transaction-edit/invoice-option.html"
{:number (:invoice/invoice-number invoice)
:vendor (-> invoice :invoice/vendor :vendor/name)
:date (some-> invoice :invoice/date coerce/to-date-time (atime/unparse-local atime/normal-date))
:amount (format "$%.2f" (:invoice/outstanding-balance invoice))})))))
(defn autopay-invoices-view [request]
(let [invoice-matches (get-available-autopay-invoices request)]
(panel-wrap
(if (seq invoice-matches)
(panel-list* {:heading "Available Autopay Invoices"
:action-hidden (sc/hidden {:name "action" :value "link-autopay-invoices"})
:prompt "Select an autopay invoice to apply:"
:radio (sc/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group))
:content (invoice-group-content match-group)})
:name (fname :autopay-invoice-ids)
:width "w-full"})})
(panel-empty* "No matching autopay invoices available for this transaction.")))))
(defn get-available-unpaid-invoices [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (or (get-in request [:multi-form-state :snapshot :transaction/client])
(get-in request [:client :db/id]))
matches-set (when (and tx client-id)
(i-transactions/match-transaction-to-unpaid-invoices
(:transaction/amount tx)
client-id))]
(when matches-set
(for [matches matches-set]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn unpaid-invoices-view [request]
(let [invoice-matches (get-available-unpaid-invoices request)]
(panel-wrap
(if (seq invoice-matches)
(panel-list* {:heading "Available Unpaid Invoices"
:action-hidden (sc/hidden {:name "action" :value "link-unpaid-invoices" :form ""})
:prompt "Select an unpaid invoice to apply:"
:radio (sc/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group))
:content (invoice-group-content match-group)})
:name (fname :unpaid-invoice-ids)
:width "w-full"})})
(panel-empty* "No matching unpaid invoices available for this transaction.")))))
(defn get-available-rules [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
patterns (dc/q '[:find (pull ?r
[:db/id
:transaction-rule/description
:transaction-rule/note
:transaction-rule/client-group
:transaction-rule/dom-gte :transaction-rule/dom-lte
:transaction-rule/amount-gte :transaction-rule/amount-lte
:transaction-rule/client :transaction-rule/bank-account
:transaction-rule/yodlee-merchant])
:where
[?r :transaction-rule/description]]
(dc/db conn))]
(when tx
(->> patterns
(map first)
(filter (fn [rule]
(rm/rule-applies? (-> tx
(update :transaction/date coerce/to-date))
(-> rule
(update :transaction-rule/description #(some-> % iol-ion.query/->pattern))))))))))
(defn transaction-rules-view [request]
(let [matching-rules (get-available-rules request)]
(panel-wrap
(if (seq matching-rules)
(panel-list* {:heading "Matching Transaction Rules"
:action-hidden (sc/hidden {:name (fname :action) :value "apply-rule" :form ""})
:prompt "Select a rule to apply:"
:radio (sc/radio-card {:options (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules]
{:value id
:content (sel/render->hiccup "templates/transaction-edit/rule-option.html"
{:note note :description description})})
:name (fname :rule-id)
:width "w-full"})})
(panel-empty* "No matching rules found for this transaction.")))))
(defn payment-matches-view [request]
(let [payments (get-available-payments request)
tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
payment (dc/pull
(dc/db conn)
'[:payment/amount
:db/id
[:payment/date :xform clj-time.coerce/from-date]
{[:payment/status :xform iol-ion.query/ident] [:db/ident]
:payment/vendor [:vendor/name]}]
(-> tx :transaction/payment :db/id))]
(sel/render->hiccup
"templates/transaction-edit/payment-matches.html"
{:inner
(str
(if (and payment (:db/id payment))
(sel/render->hiccup
"templates/transaction-edit/linked-payment.html"
{:external_link (str (sc/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id (:db/id payment)})}
(sc/render "templates/components/svg-external-link.html" {})))
:number (:payment/invoice-number payment)
:vendor (-> payment :payment/vendor :vendor/name)
:amount (some->> (:payment/amount payment) (format "$%.2f"))
:status (some-> payment :payment/status name)
:date (some-> payment :payment/date (atime/unparse-local atime/normal-date))
:payment_id_hidden (str (sc/hidden {:name (fname :payment-id) :value (:db/id payment)}))
:unlink_attrs (sc/attrs->str {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
:hx-trigger "unlinkPayment"
:hx-target "#payment-matches"
:hx-include "closest form"
:hx-swap "outerHTML"
:hx-confirm "Are you sure you want to unlink this payment?"})
:unlink_button (str (sc/a-button {:color :red :size :small "@click" "$dispatch('unlinkPayment')"}
"Unlink Payment"))})
(if (seq payments)
(panel-list* {:heading "Available Payments"
:action-hidden ""
:prompt "Select a payment to match:"
:radio (sc/radio-card {:options (for [payment payments]
{:value (:db/id payment)
:content (str (:payment/invoice-number payment) " - "
(-> payment :payment/vendor :vendor/name)
" - Amount: $" (format "%.2f" (:payment/amount payment))
" • Date: " (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date)))})
:name (fname :payment-id)
:width "w-full"})})
(panel-empty* "No matching payments available for this transaction."))))})))
(defn count-payment-matches [request]
(count (get-available-payments request)))
(defn count-autopay-invoice-matches [request]
(count (get-available-autopay-invoices request)))
(defn count-unpaid-invoice-matches [request]
(count (get-available-unpaid-invoices request)))
(defn count-rule-matches [request]
(count (get-available-rules request)))
(defn- tab-button [{:keys [active value badge-count disabled? relative?] :or {relative? true}} label]
(sc/button-group-button
(cond-> {"@click" (str "activeForm = '" active "'")
:value value
":class" (str "{ '!bg-primary-200 text-primary-800': activeForm === '" active "'}")}
relative? (assoc :class "relative")
disabled? (assoc ":disabled" "!canChange"))
(when (and badge-count (> badge-count 0))
(sc/badge {:color "green"} (str badge-count)))
label))
(defn- tabs* [request]
(sc/button-group
{:name "method"}
(tab-button {:active "link-payment" :value "payment"
:badge-count (count-payment-matches request)} "Link to payment")
(tab-button {:active "link-unpaid-invoices" :value "unpaid" :disabled? true
:badge-count (count-unpaid-invoice-matches request)} "Link to unpaid invoices")
(tab-button {:active "link-autopay-invoices" :value "autopay" :disabled? true
:badge-count (count-autopay-invoice-matches request)} "Link to autopay invoices")
(tab-button {:active "apply-rule" :value "rule" :disabled? true
:badge-count (count-rule-matches request)} "Apply rule")
(tab-button {:active "manual" :value "manual" :disabled? true :relative? false} "Manual")))
(defn- approval-status* [request]
(let [step-params (-> request :multi-form-state :step-params)
v (:transaction/approval-status step-params)
current-value (name (or (if (map? v) (:db/ident v) v)
:transaction-approval-status/unapproved))]
(sc/validated-field
{:label "Status" :errors (ferr :transaction/approval-status)}
(sel/render->hiccup
"templates/transaction-edit/approval-status.html"
{:x_data (hx/json {:approvalStatus current-value})
:status_hidden (str (sc/hidden {:name (fname :transaction/approval-status)
:value current-value ":value" "approvalStatus"}))
:buttons (str
(sc/button-group-button {"@click" "approvalStatus = 'approved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }" :class "rounded-l-lg"} "Approved")
(sc/button-group-button {"@click" "approvalStatus = 'unapproved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }" :class "rounded-r-lg"} "Unapproved")
(sc/button-group-button {"@click" "approvalStatus = 'suppressed'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }" :class "rounded-r-lg"} "Client Review"))}))))
(defn- links-body* [request mode]
(let [step-params (-> request :multi-form-state :step-params)
payment? (:transaction/payment (:entity request))
action-str (some-> (:action step-params) name)]
(sel/render->hiccup
"templates/transaction-edit/links-body.html"
{:memo_field (str (sc/validated-field
{:label "Memo" :errors (ferr :transaction/memo)}
(wrap-div "w-96"
(sc/text-input {:value (:transaction/memo step-params)
:name (fname :transaction/memo)
:id "edit-memo"
:error? (ferr :transaction/memo)
:placeholder "Optional note"}))))
:x_data (hx/json {:activeForm (if payment? "link-payment" (or action-str "manual"))
:canChange (boolean (not payment?))})
:action_hidden (str (sc/hidden {:name (fname :action) :value action-str ":value" "activeForm"}))
:tabs (str (tabs* request))
:panel_payment (str (payment-matches-view request))
:panel_unpaid (str (unpaid-invoices-view request))
:panel_autopay (str (autopay-invoices-view request))
:panel_rule (str (transaction-rules-view request))
:panel_manual (str (manual-coding-section* mode request)
(approval-status* request))})))
(defn- form-errors-html [errors]
(str "<div id=\"form-errors\">"
(when (seq errors)
(str "<span class=\"error-content\"><p class=\"mt-2 text-xs text-red-600 dark:text-red-500 h-4\">"
(str/join ", " (filter string? errors))
"</p></span>"))
"</div>"))
(defn- footer* [request]
(sel/raw
(str "<div class=\"flex justify-end\"><div class=\"flex items-baseline gap-x-4\">"
(form-errors-html (:errors (:form-errors request)))
(str (sc/button {:color :primary :x-ref "next" :class "w-32 wizard-save-action"} "Done"))
"</div></div>")))
(defn render-form
"Renders the whole plain edit form (no wizard). Binds *errors* from the request's
:form-errors so the field-level error lookups (ferr) resolve."
[request]
(binding [*errors* (or (:form-errors request) {})]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
tx-id (or (:db/id snapshot) (-> request :route-params :db/id))
tx (d-transactions/get-by-id tx-id)
;; Preserve an explicit mode choice; fall back to the row-count heuristic only
;; on initial open.
mode (keyword (or (:mode step-params) (name (manual-mode-initial snapshot))))
modal-card (sel/render "templates/transaction-edit/edit-modal.html"
{:head "<div class=\"p-2\">Edit Transaction</div>"
:side_panel (str (transaction-details-panel tx))
:body (str (links-body* request mode))
:footer (str (footer* request))})]
(sel/render->hiccup
"templates/transaction-edit/edit-form.html"
{:db_id (:db/id snapshot)
:form_attrs (sc/attrs->str {:hx-ext "response-targets"
:hx-swap "outerHTML"
:hx-target-400 "#form-errors .error-content"
:hx-trigger "submit"
:hx-target "this"
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-submit)})
:modal (str (sc/modal {:id "editmodal"} (sel/raw modal-card)))}))))
(defmulti save-handler (fn [request]
(-> request :multi-form-state :snapshot :action)))
(defn- default-update-tx [snapshot transaction]
(merge {:transaction/memo (:transaction/memo snapshot)}
transaction))
(defn- save-linked-transaction [{{snapshot :snapshot} :multi-form-state :as request transaction :entity} payment]
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(audit-transact (into
[{:db/id (:db/id payment)
:payment/status :payment-status/cleared
:payment/date (coerce/to-date (first (sort [(:payment/date payment)
(coerce/to-date-time (:transaction/date transaction))])))}
[:upsert-transaction
(default-update-tx
snapshot
{:db/id (:db/id transaction)
:transaction/payment (:db/id payment)
:transaction/vendor (-> payment :payment/vendor :db/id)
:transaction/approval-status :transaction-approval-status/approved
:transaction/accounts [{:db/id (random-tempid)
:transaction-account/account (:db/id (d-accounts/get-account-by-numeric-code-and-sets 21000 ["default"]))
:transaction-account/location "A"
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]})]])
(:identity request)))
(defn- save-memo-only [{{snapshot :snapshot} :multi-form-state :as request}]
(audit-transact [[:upsert-transaction (default-update-tx snapshot {})]]
(:identity request)))
(defn- is-already-linked-to-this-payment? [transaction payment-id]
(= (pull-attr (dc/db conn)
:transaction/payment
(:db/id transaction))
payment-id))
(defmethod save-handler
:link-payment [{{{:keys [transaction-id payment-id] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [payment (d-checks/get-by-id payment-id)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-can-see-client (:identity request) (-> payment :payment/client :db/id)))
(when (not= (-> transaction :transaction/client :db/id)
(-> payment :payment/client :db/id))
(form-validation-error "Clients don't match."
:payment-client-id (:payment/client payment)
:transaction-client-id (:transaction/client transaction))
#_(throw (ex-info "Clients don't match" {:validation-error "Payment and client do not match."})))
(when-not (dollars= (- (:transaction/amount transaction))
(:payment/amount payment))
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(if (is-already-linked-to-this-payment? transaction payment-id)
(save-memo-only request)
(save-linked-transaction request payment))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."]
[:p.text-gray-600.mt-2 "To view the new payment, click "
(com/link {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id payment-id})
:hx-boost true}
"here")
" to view it."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-autopay-invoices
[{{{:keys [autopay-invoice-ids] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [db (dc/db conn)
invoice-clients (set (map #(pull-ref db :invoice/client %) autopay-invoice-ids))
invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/total %) autopay-invoice-ids))]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
(when (or (> (count invoice-clients) 1)
(not= (-> transaction :transaction/client :db/id)
(first invoice-clients)))
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."})))
(when-not (dollars= (- (:transaction/amount transaction))
invoice-amount)
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(let [payment-tx (i-transactions/add-new-payment
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
(map (fn [id]
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
[(or (-> entity :invoice/vendor :db/id)
(-> entity :invoice/vendor))
(-> entity :db/id)
(-> entity :invoice/total)]))
autopay-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact (conj payment-tx
[:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-unpaid-invoices
[{{{:keys [unpaid-invoice-ids] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [db (dc/db conn)
invoice-clients (set (map #(pull-ref db :invoice/client %) unpaid-invoice-ids))
invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/outstanding-balance %) unpaid-invoice-ids))]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (or (> (count invoice-clients) 1)
(not= (-> transaction :transaction/client :db/id)
(first invoice-clients)))
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."
:transaction-client (-> transaction :transaction/client :db/id)
:invoice-clients invoice-clients})))
(when-not (dollars= (- (:transaction/amount transaction))
invoice-amount)
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
(let [payment-tx (i-transactions/add-new-payment
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
(map (fn [id]
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
[(or (-> entity :invoice/vendor :db/id)
(-> entity :invoice/vendor))
(-> entity :db/id)
(-> entity :invoice/total)]))
unpaid-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact (conj payment-tx
[:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."]
[:p.text-gray-600.mt-2 "To view the new payment, click "
(com/link {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id (:db/id (pull-attr (dc/db conn)
:transaction/payment
(:db/id transaction)))})
:hx-boost true}
"here")
" to view it."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler
:apply-rule
[{{{:keys [rule-id] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [transaction-rule (dc/pull (dc/db conn)
[:transaction-rule/description
:transaction-rule/vendor
:transaction-rule/accounts
:transaction-rule/approval-status]
rule-id)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(let [description-pattern (some-> transaction-rule :transaction-rule/description iol-ion.query/->pattern)]
(when (not (rm/rule-applies? transaction {:transaction-rule/description description-pattern}))
(throw (ex-info "Transaction rule does not apply"
{:validation-error "Transaction rule does not apply"}))))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already associated with a payment"
{:validation-error "Transaction already associated with a payment"})))
(let [locations (-> transaction :transaction/client :client/locations)
updated-tx (rm/apply-rule {:db/id (:db/id transaction)
:transaction/amount (:transaction/amount transaction)}
transaction-rule
locations)
updated-tx (default-update-tx snapshot updated-tx)]
(alog/info ::applying-rule-tx :tx-data updated-tx
:transaction transaction
:transaction-rule transaction-rule)
(audit-transact [[:upsert-transaction updated-tx]] (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Rule applied successfully"}
[:p.text-gray-600.mt-2 "The selected rule has been applied to this transaction."])
:headers {"hx-trigger" "invalidated"})))
(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-account
"Spreads the expense account amount across the given locations"
[locations account]
(if (= "Shared" (:transaction-account/location account))
(let [{:keys [base-amount remainder]} (calculate-spread (:transaction-account/amount account) (count locations))]
(map-indexed (fn [idx _]
(assoc account
:db/id (if (= idx 0)
(:db/id account)
(random-tempid))
:transaction-account/amount (+ base-amount (if (< idx remainder) 1 0))
:transaction-account/location (nth locations idx)))
locations))
[account]))
(defn- apply-total-delta-to-account [invoice-total eas]
(when (seq eas)
(let [leftover (- invoice-total (reduce + 0 (map :transaction-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 :transaction-account/amount #(+ % leftover))
rest))))
(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 maybe-spread-locations
"Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client"
([transaction]
(maybe-spread-locations transaction (pull-attr (dc/db conn) :client/locations (:transaction/client transaction))))
([transaction locations]
(clojure.pprint/pprint transaction)
(update-in transaction
[:transaction/accounts]
(fn [accounts]
(->> accounts
(map (fn [ea] (update ea :transaction-account/amount $->cents)))
(mapcat (partial spread-account locations))
(apply-total-delta-to-account ($->cents (:transaction/amount transaction)))
(map (fn [ea] (update ea :transaction-account/amount cents->$))))))))
(defmethod save-handler :manual
[{:as request
transaction :entity
:keys [multi-form-state]}]
(let [;; :mode is a UI-only field (simple/advanced); :action/:amount-mode are control
;; fields. None are Datomic attributes, so strip them before building the upsert
;; (otherwise :upsert-transaction fails with :db.error/not-an-entity :mode).
tx-data (-> multi-form-state :snapshot (dissoc :action :mode))
tx-id (:db/id tx-data)
client-id (->db-id (:transaction/client tx-data))
existing-tx (d-transactions/get-by-id tx-id)
amount-mode (or (:amount-mode tx-data) "$")
total (Math/abs (or (:transaction/amount existing-tx) 0.0))
tx-data (if (= "%" amount-mode)
(update tx-data :transaction/accounts
#(map (fn [account dollar-amount]
(assoc account :transaction-account/amount dollar-amount))
%
(percentages->dollars (map :transaction-account/amount %) total)))
tx-data)
tx-data (dissoc tx-data :amount-mode)
transaction [:upsert-transaction (maybe-spread-locations (assoc tx-data :db/id tx-id))]]
(alog/info ::transaction transaction :entity transaction)
(exception->4xx #(assert-can-see-client (:identity request) client-id))
(exception->4xx #(assert-not-locked client-id (:transaction/date existing-tx)))
(when (and (= :transaction-approval-status/approved (keyword (:transaction/approval-status tx-data)))
(not (seq (:transaction/accounts tx-data))))
(throw (ex-info "Approved transactions must have accounts assigned."
{:type :form-validation
:form-validation-errors ["Approved transactions must have accounts assigned."]})))
(when (seq (:transaction/accounts tx-data))
(let [account-total (reduce + 0 (map :transaction-account/amount (:transaction/accounts tx-data)))
tx-amount (Math/abs (:transaction/amount existing-tx))]
(when (not (dollars= tx-amount account-total))
(throw (ex-info (format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-amount)
{:type :form-validation
:form-validation-errors [(format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-amount)]})))))
(let [transaction-result (audit-transact [transaction] (:identity request))]
(try
(solr/touch-with-ledger tx-id)
(catch Exception e
(alog/error ::cant-save-solr :error e)))
(html-response
(row* (:identity request) (d-transactions/get-by-id tx-id) {:flash? true})
:headers {"hx-trigger" "modalclose"
"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id)
"hx-reswap" "outerHTML"}))))
(defn unlink-payment [{{{transaction-id :db/id} :snapshot} :multi-form-state :as request}]
(let [transaction (dc/pull (dc/db conn)
'[:transaction/approval-status
:transaction/date
:transaction/location
:transaction/vendor
:transaction/accounts
:transaction/status
:transaction/client [:db/id]
{:transaction/payment [:payment/date
{[:payment/status :xform iol-ion.query/ident] [:db/ident]} :db/id]}]
transaction-id)
payment (-> transaction :transaction/payment)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (not= :payment-status/cleared (-> payment :payment/status))
(throw (ex-info "Payment can't be undone because it isn't cleared."
{:validation-error "Payment can't be undone because it isn't cleared."})))
(let [is-autopay-payment? (some->> (dc/q {:find ['?sp]
:in ['$ '?payment]
:where ['[?ip :invoice-payment/payment ?payment]
'[?ip :invoice-payment/invoice ?i]
'[(get-else $ ?i :invoice/scheduled-payment "N/A") ?sp]]}
(dc/db conn) (:db/id payment))
seq
(map first)
(every? #(instance? java.util.Date %)))]
(if is-autopay-payment?
(audit-transact
(-> [{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id (:db/id transaction)
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]
[:db/retractEntity (:db/id payment)]]
(into (map (fn [[invoice-payment]]
[:db/retractEntity invoice-payment])
(dc/q {:find ['?ip]
:in ['$ '?p]
:where ['[?ip :invoice-payment/payment ?p]]}
(dc/db conn)
(:db/id payment)))))
(:identity request))
(audit-transact
[{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id (:db/id transaction)
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]]
(:identity request))))
(solr/touch-with-ledger (:db/id transaction))
(html-response (payment-matches-view request)
:headers {"hx-trigger" "unlinked"})))
(defn entity->base
"The persisted transaction, shaped like the form's base state (what the old snapshot was
seeded with). The plain form derives its state fresh from this + the live posted form,
instead of round-tripping an EDN snapshot hidden field."
[tx-id]
(-> (dc/pull (dc/db conn)
'[:db/id
:transaction/vendor
:transaction/client
:transaction/description-original
:transaction/status
:transaction/type
:transaction/memo
{[:transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
:transaction/amount
:transaction/accounts]
tx-id)
(update :transaction/vendor :db/id)
(update :transaction/client :db/id)))
(defn wrap-derive-state
"Plain-form replacement for the EDN-snapshot round-trip. Builds :multi-form-state from
the entity (loaded by the db/id hidden field, or the route on initial open) overlaid
with the live posted step-params -- no serialized snapshot. Runs after wrap-decode /
wrap-wizard, which provide nested + schema-typed step-params. The 30-odd `:snapshot`
reads keep working: snapshot is now `entity step-params`, derived per request."
[handler]
(fn [request]
(let [tx-id (->db-id (or (some-> request :form-params (get "db/id"))
(-> request :route-params :db/id)))
base (entity->base tx-id)
posted (-> request :multi-form-state :step-params)
;; Fields the form does NOT edit always come from the entity. Everything else is
;; the live posted form, which is authoritative even when ABSENT -- an absent
;; field means the user cleared it (e.g. removed all account rows), not "fall
;; back to the entity's persisted value". Merging base's editable fields back in
;; would resurrect persisted accounts after a remove-all.
entity-only (select-keys base [:db/id :transaction/client :transaction/amount
:transaction/description-original
:transaction/status :transaction/type])
;; On initial open there is no posted form -> render the entity. On every post
;; the form is authoritative for the editable fields.
step-params (if (seq posted) posted base)
snapshot (if (seq posted) (merge entity-only posted) base)]
(handler (-> request
(assoc :entity (d-transactions/get-by-id tx-id))
(assoc :multi-form-state {:snapshot snapshot :edit-path [] :step-params step-params}))))))
(def ^:private edit-form-keys
"Top-level keys edit-form-schema recognises (the [:map] fields + every :multi branch
field). Posted fields outside this set -- e.g. the tab button-group's `method` hidden --
are dropped so they can't leak into the saved entity (the old step-params[...] prefix
excluded them implicitly)."
[:db/id :action :transaction/memo :transaction/vendor :transaction/approval-status
:amount-mode :mode :transaction/accounts
:rule-id :transaction-id :unpaid-invoice-ids :autopay-invoice-ids :payment-id])
(defn wrap-decode-edit
"Replaces the wizard's two-key snapshot/step-params decode. Parses the posted (nested)
form params and decodes them straight into edit-form-schema -- the field names have no
step-params[...] prefix, so they map directly onto the schema keys. Strips stray
non-schema fields. wrap-derive-state then fills :snapshot from the entity."
[handler]
(-> (fn [request]
(let [decoded (mc/decode edit-form-schema (:form-params request) main-transformer)
decoded (if (map? decoded) (select-keys decoded edit-form-keys) {})]
(handler (assoc request :multi-form-state {:step-params decoded}))))
(wrap-nested-form-params)))
(defn render-full-form
"Renders the complete edit form for whole-form re-rendering."
[request]
(render-form request))
(defn open-handler
"Initial modal open (GET). Wraps the rendered form in the #transitioner shell expected
by the modal stack."
[request]
(modal-response
(sel/render->hiccup "templates/transaction-edit/transitioner.html"
{:body (str (render-form request))})))
(defn submit-edit
"Validates the merged record against edit-form-schema (field-level errors surface via
wrap-form-4xx-2), then dispatches to the save-handler for the chosen action."
[request]
(assert-schema edit-form-schema (-> request :multi-form-state :snapshot))
(save-handler request))
(defn- render-form-response
"wrap-form-4xx-2 form-handler: re-render the whole form with field/form errors."
[request]
(html-response (render-form request)
:headers {"HX-reswap" "outerHTML"}))
(defn apply-vendor-changed [request]
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params)
(get (:form-params request) "mode")
"simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (->db-id (:transaction/vendor step-params))
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
0.0))
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
;; The form always submits an account row (even when empty with account=nil),
;; so we check if any row has a meaningful account ID.
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
existing-accounts)
;; Simple mode: always populate vendor default (overwrite existing).
;; Advanced mode: populate only when 0 rows OR 1 empty row.
should-populate? (case mode
:simple true
:advanced (or (empty? existing-accounts)
(and (= 1 (count existing-accounts))
(not has-meaningful-accounts?))))
default-account (when (and should-populate? vendor-id client-id)
(vendor-default-account vendor-id client-id))
render-request
(-> (if (and should-populate? vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
render-request))
(defn apply-toggle-mode [request]
(let [step-params (-> request :multi-form-state :step-params)
snapshot (-> request :multi-form-state :snapshot)
current-mode (keyword (or (:mode step-params) "simple"))
target-mode (if (= current-mode :simple) :advanced :simple)
;; When switching simple→advanced, promote simple-mode values into accounts
render-request
(if (and (= target-mode :advanced)
(= current-mode :simple))
;; carry the simple-mode single row into snapshot so the table shows it
(let [accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(vec accounts))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode))))
;; advanced→simple: take first row only
(let [first-row (first (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot))))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :transaction/accounts]
(if first-row [first-row] []))
(assoc-in [:multi-form-state :step-params :mode]
(name target-mode)))))]
render-request))
(defn apply-new-account
"edit-form-changed op: append a fresh account row."
[request]
(let [snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
amount-mode (or (:amount-mode step-params) (:amount-mode snapshot) "$")
total (Math/abs (or (:transaction/amount snapshot) 0.0))
new-account {:db/id (str (java.util.UUID/randomUUID))
:new? true
:transaction-account/location "Shared"
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
;; Append to the LIVE rows (step-params) so values typed before clicking
;; "New account" are not reverted to the stale snapshot.
accounts (vec (or (seq (:transaction/accounts step-params))
(:transaction/accounts snapshot) []))
updated-accounts (conj accounts new-account)
updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
updated-request))
(defn apply-remove-account
"edit-form-changed op: remove the account row at form-param row-index."
[request]
(let [row-index (some-> request :form-params (get "row-index") Integer/parseInt)
snapshot (-> request :multi-form-state :snapshot)
step-params (-> request :multi-form-state :step-params)
;; Remove from the LIVE rows (step-params) so the surviving rows keep the values
;; the user typed, rather than reverting to the stale snapshot.
accounts (vec (or (seq (:transaction/accounts step-params))
(:transaction/accounts snapshot) []))
updated-accounts (if (and row-index (< row-index (count accounts)))
(vec (concat (subvec accounts 0 row-index)
(subvec accounts (inc row-index))))
accounts)
updated-request (-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] updated-accounts)
(assoc-in [:multi-form-state :step-params :transaction/accounts] updated-accounts))]
updated-request))
(defn edit-form-changed-handler
"Single whole-form re-render endpoint. Dispatches on the `op` form-param to apply the
relevant state mutation (vendor change, mode toggle, add/remove row, $/% toggle), then
re-renders the whole form. A missing/unknown op (a plain dependent-field change) just
re-renders. Replaces the per-operation edit-wizard-* / toggle-amount-mode routes."
[request]
(let [op (get-in request [:form-params "op"])
request' (case op
"vendor-changed" (apply-vendor-changed request)
"toggle-mode" (apply-toggle-mode request)
"new-account" (apply-new-account request)
"remove-account" (apply-remove-account request)
"toggle-amount-mode" (apply-toggle-amount-mode request)
request)]
(html-response
(render-full-form request'))))
(def ^:private get-client (fn [request] (-> request :entity :transaction/client)))
(def key->handler
(apply-middleware-to-all-handlers
{::route/edit-wizard (-> open-handler
(wrap-must {:activity :edit :subject :transaction} get-client)
(wrap-derive-state)
(wrap-decode-edit)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-submit (-> submit-edit
(wrap-form-4xx-2 render-form-response)
(wrap-must {:activity :edit :subject :transaction} get-client)
(wrap-derive-state)
(wrap-decode-edit))
::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/edit-form-changed (-> edit-form-changed-handler
(wrap-derive-state)
(wrap-decode-edit))
::route/unlink-payment (-> unlink-payment
(wrap-must {:activity :edit :subject :transaction} get-client)
(wrap-derive-state)
(wrap-decode-edit))}
(fn [h]
(-> h
(wrap-client-redirect-unauthenticated)))))