Files
integreat/src/clj/auto_ap/ssr/transaction/common.clj
Bryce 3759258ebe fix(ssr): require Apply for all date-range filters
Most grid pages auto-submitted their date-range filter on every change
event, which fired mid-typing and re-rendered the date inputs, breaking
manual date entry. Invoices and ledgers already gated date submission
behind an explicit Apply button; this brings the other ten pages in line.

- date-range component: stop `change` from the date inputs bubbling to
  the form (@change.stop) and always render the Apply button, so typed or
  picked dates submit only via the Apply button's `datesApplied` event.
  The All/Week/Month/Year presets and all other filters are unaffected.
- payments, invoice import, transactions, import batches, sales
  summaries, expected deposits, cash drawer shifts, refunds, tenders,
  sales orders: add `datesApplied` to the form hx-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:42:17 -07:00

602 lines
35 KiB
Clojure

(ns auto-ap.ssr.transaction.common
(:require
[auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-4
conn merge-query observable-query pull-many]]
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.graphql.utils :refer [extract-client-ids is-admin?]]
[auto-ap.routes.invoice :as invoice-routes]
[auto-ap.routes.ledger :as ledger-routes]
[auto-ap.routes.payments :as payment-routes]
[auto-ap.routes.transactions :as route]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.grid-page-helper :as helper]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.pos.common :refer [date-range-field*]]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils :refer [clj-date-schema entity-id strip]]
[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]
[malli.core :as mc]))
(def query-schema (mc/schema
[:maybe [:map {:date-range [:date-range :start-date :end-date]}
[:sort {:optional true} [:maybe [:any]]]
[:per-page {:optional true :default 25} [:maybe :int]]
[:start {:optional true :default 0} [:maybe :int]]
[:amount-gte {:optional true} [:maybe :double]]
[:amount-lte {:optional true} [:maybe :double]]
[:client-id {:optional true} [:maybe entity-id]]
[:import-batch-id {:optional true} [:maybe entity-id]]
[:unresolved {:optional true}
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
(= % "") false
:else
(boolean %))}}]]]
[:description {:optional true} [:maybe [:string {:decode/string strip}]]]
[:memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/numeric-code]}]]]
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
[:linked-to {:optional true}
[:maybe [:enum {:decode/string {:enter #(if (seq %) % nil)}}
"payment" "expected-deposit" "invoice" "none"]]]
[:location {:optional true} [:maybe [:string {:decode/string strip}]]]
[:potential-duplicates {:optional true}
[:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true
(= % "") false
:else
(boolean %))}}]]]
#_[:status {:optional true} [:maybe (ref->enum-schema "transaction-status")]]
[:exact-match-id {:optional true} [:maybe entity-id]]
[:all-selected {:optional true :default nil} [:maybe :boolean]]
[:selected {:optional true :default nil} [:maybe [:vector {:coerce? true}
entity-id]]]
[:start-date {:optional true}
[:maybe clj-date-schema]]
[:end-date {:optional true}
[:maybe clj-date-schema]]]]))
(def default-read
'[:transaction/amount
:transaction/description-original
:transaction/description-simple
[:transaction/date :xform clj-time.coerce/from-date]
[:transaction/post-date :xform clj-time.coerce/from-date]
:transaction/type
:transaction/status
:transaction/client-overrides
:db/id
{:transaction/vendor [:vendor/name :db/id]
:transaction/client [:client/name :client/code :db/id [:client/locked-until :xform clj-time.coerce/from-date]]
:transaction/bank-account [:bank-account/numeric-code :bank-account/name]
:transaction/accounts [{:transaction-account/account [:account/name :db/id]}
:transaction-account/location
:transaction-account/amount]
:transaction/matched-rule [:matched-rule/name]
:transaction/payment [:db/id [:payment/date :xform clj-time.coerce/from-date]]}])
(defn hydrate-results [ids db _]
(let [results (->> (pull-many db default-read ids)
(group-by :db/id))
results (->> ids
(map results)
(map first))]
results))
(defn sum-amount [ids]
(->>
(dc/q {:find ['?id '?a]
:in ['$ '[?id ...]]
:where ['[?id :transaction/amount ?a]]}
(dc/db conn)
ids)
(map last)
(reduce + 0.0)))
(defn all-ids-not-locked
"Filters transaction IDs to only those that aren't locked (client locked date earlier than transaction date)"
[all-ids]
(->> all-ids
(dc/q '[:find ?t
:in $ [?t ...]
:where
[?t :transaction/client ?c]
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
[?t :transaction/date ?d]
[(>= ?d ?lu)]]
(dc/db conn))
(map first)))
(defn fetch-ids [db {:keys [query-params route-params] :as request}]
(let [valid-clients (extract-client-ids (:clients request)
(:client-id request)
(:client-id query-params)
(when (:client-code request)
[:client/code (:client-code request)]))
args query-params
query
(if (:exact-match-id args)
{:query {:find '[?e]
:in '[$ ?e [?c ...]]
:where '[[?e :transaction/client ?c]]}
:args [db
(:exact-match-id args)
valid-clients]}
(cond-> {:query {:find []
:in ['$ '[?clients ?start ?end]]
:where '[[(iol-ion.query/scan-transactions $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
:args [db
[valid-clients
(some-> (:start-date query-params) coerce/to-date)
(some-> (:end-date query-params) coerce/to-date)]]}
(seq (:description args))
(merge-query {:query {:in ['?description-regex]
:where ['[?e :transaction/description-original ?do]
'[(re-find ?description-regex ?do)]]}
:args [(re-pattern (str "(?i).*" (str/lower-case (:description args)) ".*"))]})
(seq (:memo args))
(merge-query {:query {:in ['?memo-regex]
:where ['[?e :transaction/memo ?memo]
'[(re-find ?memo-regex ?memo)]]}
:args [(re-pattern (str "(?i).*" (str/lower-case (:memo args)) ".*"))]})
(:amount-gte args)
(merge-query {:query {:in ['?amount-gte]
:where ['[?e :transaction/amount ?a]
'[(>= ?a ?amount-gte)]]}
:args [(:amount-gte args)]})
(:amount-lte args)
(merge-query {:query {:in ['?amount-lte]
:where ['[?e :transaction/amount ?a]
'[(<= ?a ?amount-lte)]]}
:args [(:amount-lte args)]})
(:db/id (:bank-account args))
(merge-query {:query {:in ['?ba]
:where ['[?e :transaction/bank-account ?ba]]}
:args [(:db/id (:bank-account args))]})
(:vendor args)
(merge-query {:query {:in ['?vendor-id]
:where ['[?e :transaction/vendor ?vendor-id]]}
:args [(:db/id (:vendor args))]})
(:db/id (:account args))
(merge-query {:query {:in ['?account-id]
:where ['[?e :transaction/accounts ?tas]
'[?tas :transaction-account/account ?account-id]]}
:args [(:db/id (:account args))]})
(:import-batch-id args)
(merge-query {:query {:in ['?import-batch-id]
:where ['[?import-batch-id :import-batch/entry ?e]]}
:args [(:import-batch-id args)]})
(:unresolved args)
(merge-query {:query {:where ['[?e :transaction/date]
'(or-join [?e]
(not [?e :transaction/accounts])
(and [?e :transaction/accounts ?tas]
(not [?tas :transaction-account/account])))]}})
(seq (:location args))
(merge-query {:query {:in ['?location]
:where ['[?e :transaction/accounts ?tas]
'[?tas :transaction-account/location ?location]]}
:args [(:location args)]})
(= (:linked-to args) "payment")
(merge-query {:query {:where ['[?e :transaction/payment]]}})
(= (:linked-to args) "expected-deposit")
(merge-query {:query {:where ['[?e :transaction/expected-deposit]]}})
(= (:linked-to args) "invoice")
(merge-query {:query {:where ['[?e :transaction/payment ?p]
'[_ :invoice-payment/payment ?p]]}})
(= (:linked-to args) "none")
(merge-query {:query {:where ['(not [?e :transaction/payment])
'(not [?e :transaction/expected-deposit])]}})
(:potential-duplicates args)
(merge-query (let [bank-account-id (:db/id (:bank-account args))
_ (when-not bank-account-id
(throw (ex-info "In order to select potential duplicates, you must choose a bank account."
{:validation-error "In order to select potential duplicates, you must choose a bank account."})))
duplicate-ids (->> (dc/q '[:find ?tx ?amount ?date
:in $ ?ba
:where
[?tx :transaction/bank-account ?ba]
[?tx :transaction/amount ?amount]
[?tx :transaction/date ?date]
(not [?tx :transaction/approval-status :transaction-approval-status/suppressed])]
db
bank-account-id)
(group-by (fn [[_ amount date]]
[amount date]))
(filter (fn [[_ txes]]
(> (count txes) 1)))
(vals)
(mapcat identity)
(map first)
set)]
{:query {:in '[[?e ...]]
:where []}
:args [duplicate-ids]}))
(:status route-params)
(merge-query {:query {:in ['?status]
:where ['[?e :transaction/approval-status ?status]]}
:args [(:status route-params)]})
(:sort args) (add-sorter-fields {"client" ['[?e :transaction/client ?c]
'[?c :client/name ?sort-client]]
"vendor" '[(or-join [?e ?sort-vendor]
(and
[?e :transaction/vendor ?v]
[?v :vendor/name ?sort-vendor])
(and [(missing? $ ?e :transaction/vendor)]
[(ground "") ?sort-vendor]))]
"date" ['[?e :transaction/date ?sort-date]]
"amount" ['[?e :transaction/amount ?sort-amount]]
"description" ['[?e :transaction/description-original ?sort-description]]}
args)
true
(merge-query {:query {:find ['?sort-default '?e]}})))]
(->> (observable-query query)
(apply-sort-4 (assoc query-params :default-asc? false))
(apply-pagination query-params))))
(defn fetch-page [request]
(let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count
all-ids :all-ids} (fetch-ids db request)]
[(->> (hydrate-results ids-to-retrieve db request))
matching-count
(sum-amount all-ids)]))
(defn exact-match-id* [request]
(if (nat-int? (:exact-match-id (:query-params request)))
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
(com/hidden {:name "exact-match-id"
"x-model" "exact_match"})
(com/pill {:color :primary}
[:span.inline-flex.space-x-2.items-center
[:div "exact match"]
[:div.w-3.h-3
(com/link {"@click" "exact_match=null; $nextTick(() => $dispatch('change'))"}
svg/x)]])]
[:div {:id "exact-match-id-tag"}]))
(defn import-batch-id* [request]
(when-let [import-batch-id (:import-batch-id (:query-params request))]
[:div {:x-data (hx/json {:import_batch_id import-batch-id}) :id "import-batch-id-tag"}
(com/hidden {:name "import-batch-id"
"x-model" "import_batch_id"})
(com/pill {:color :primary}
[:span.inline-flex.space-x-2.items-center
[:div (str "Batch " import-batch-id)]
[:div.w-3.h-3
(com/link {"@click" "import_batch_id=null; $nextTick(() => $dispatch('change'))"}
svg/x)]])]))
(defn bank-account-filter* [request]
[:div {:hx-trigger "clientSelected from:body"
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
:hx-target "this"
:hx-swap "outerHTML"}
(when (:client request)
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
(:db/id (:bank-account (:query-params request))))]
(com/field {:label "Bank Account"}
(com/radio-card {:size :small
:name "bank-account"
:value (or (when bank-account-belongs-to-client?
(:db/id (:bank-account (:query-params request))))
"")
:options
(into [{:value ""
:content "All"}]
(for [ba (:client/bank-accounts (:client request))]
{:value (:db/id ba)
:content (:bank-account/name ba)}))}))))])
(defn filters [request]
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
"hx-indicator" "#entity-table"}
(com/hidden {:name "status"
:value (some-> (:status (:query-params request)) name)})
[:fieldset.space-y-6
(com/field {:label "Vendor"}
(com/typeahead {:name "vendor"
:id "vendor"
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (:vendor (:query-params request))
:value-fn :db/id
:content-fn :vendor/name}))
(com/field {:label "Financial Account"}
(com/typeahead {:name "account"
:id "account"
:url (bidi/path-for ssr-routes/only-routes :account-search)
:value (:account (:query-params request))
:value-fn :db/id
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
(:db/id (:client request))))}))
(bank-account-filter* request)
(date-range-field* request)
(com/field {:label "Description"}
(com/text-input {:name "description"
:id "description"
:class "hot-filter"
:value (:description (:query-params request))
:placeholder "e.g., Groceries"
:size :small}))
(com/field {:label "Memo"}
(com/text-input {:name "memo"
:id "memo"
:class "hot-filter"
:value (:memo (:query-params request))
:placeholder "e.g., Rent"
:size :small}))
(com/field {:label "Location"}
(com/text-input {:name "location"
:id "location"
:class "hot-filter"
:value (:location (:query-params request))
:placeholder "SC"
:size :small}))
(com/field {:label "Amount"}
[:div.flex.space-x-4.items-baseline
(com/money-input {:name "amount-gte"
:id "amount-gte"
:hx-preserve "true"
:class "hot-filter w-20"
:value (:amount-gte (:query-params request))
:placeholder "0.01"
:size :small})
[:div.align-baseline
"to"]
(com/money-input {:name "amount-lte"
:hx-preserve "true"
:id "amount-lte"
:class "hot-filter w-20"
:value (:amount-lte (:query-params request))
:placeholder "9999.34"
:size :small})])
(com/field {:label "Linking"}
(com/radio-card {:size :small
:name "linked-to"
:value (or (:linked-to (:query-params request)) "")
:options [{:value ""
:content "All"}
{:value "none"
:content "None"}
{:value "invoice"
:content "Invoice"}
{:value "expected-deposit"
:content "Expected Deposit"}
{:value "payment"
:content "Payment"}]}))
(when (is-admin? (:identity request))
[:div.mt-4 {:x-data (hx/json {:unresolvedOnly (:unresolved (:query-params request))})}
(com/hidden {:name "unresolved"
":value" "unresolvedOnly ? 'on' : ''"})
(com/checkbox {:value (:unresolved (:query-params request))
:x-model "unresolvedOnly"}
"Unresolved only")])
(when (and (is-admin? (:identity request))
(:db/id (:bank-account (:query-params request))))
[:div.mt-4 {:x-data (hx/json {:potentialDuplicates (:potential-duplicates (:query-params request))})}
(com/hidden {:name "potential-duplicates"
":value" "potentialDuplicates ? 'on' : ''"})
(com/checkbox {:value (:potential-duplicates (:query-params request))
:x-model "potentialDuplicates"}
"Same Amount + Date")])
(import-batch-id* request)
(exact-match-id* request)]])
(def grid-page
(helper/build {:id "entity-table"
:nav com/main-aside-nav
:check-boxes? true
:page-specific-nav filters
:fetch-page fetch-page
:query-schema query-schema
:oob-render
(fn [request]
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)
(some-> (import-batch-id* request) (assoc-in [1 :hx-swap-oob] true))])
:action-buttons (fn [request]
[(com/button {:color :primary
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"
:hx-confirm "Are you sure you want to delete these transactions?"}
"Delete")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"
:hx-confirm "Are you sure you want to suppress these transactions?"}
"Suppress")])
:row-buttons (fn [request entity]
(let [client (:transaction/client entity)
locked-until (:client/locked-until client)
tx-date (:transaction/date entity)
is-locked (and locked-until tx-date (time/before? tx-date locked-until))]
(if is-locked
[[:div.p-3.rounded-full.bg-gray-50.text-gray-400.w-6.h-6.box-content
svg/lock]]
[(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard
:db/id (:db/id entity))}
svg/pencil)])))
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Transactions"]]
:title (fn [r]
"Transaction")
:entity-name "register"
:route ::route/table
:csv-route ::route/csv
:table-attributes (fn [_]
{:hx-trigger "refreshTable from:body"
:hx-get (bidi/path-for ssr-routes/only-routes ::route/table)
:hx-target "#entity-table"})
:break-table (fn [request entity]
(cond
(= (-> request :query-params :sort first :name) "Vendor")
(or (-> entity :transaction/vendor :vendor/name)
"No vendor")
:else nil))
:page->csv-entities (fn [[transactions]]
transactions)
:headers [{:key "id"
:name "Id"
:render-csv :db/id
:render-for #{:csv}}
{:key "client"
:name "Client"
:sort-key "client"
:hide? (fn [args]
(and (= (count (:clients args)) 1)
(= 1 (count (:client/locations (:client args))))))
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :transaction/client :client/name)])
:render-csv (fn [x] (-> x :transaction/client :client/name))}
{:key "vendor"
:name "Vendor"
:sort-key "vendor"
:render (fn [e]
#_(alog/peek :vend e)
(or
(-> e :transaction/vendor :vendor/name)
[:span.italic.text-gray-400 (-> e :transaction/description-simple)]))
:render-csv (fn [e] (or (-> e :transaction/vendor :vendor/name)
(-> e :transaction/description-simple)))}
{:key "description"
:name "Description"
:sort-key "description"
:render :transaction/description-original
:render-csv :transaction/description-original}
{:key "date"
:sort-key "date"
:name "Date"
:show-starting "lg"
:render (fn [{:transaction/keys [date]}]
(some-> date (atime/unparse-local atime/normal-date)))}
{:key "amount"
:name "Amount"
:sort-key "amount"
:class "text-right"
:render #(format "$%,.2f" (:transaction/amount %))
:render-csv :transaction/amount}
{:key "links"
:name "Links"
:show-starting "lg"
:class "w-8"
:render (fn [i]
(let [db (dc/db conn)
journal-entries (when (:db/id i)
(dc/q '[:find (pull ?je [:db/id :journal-entry/id])
:in $ ?t-id
:where
[?je :journal-entry/original-entity ?t-id]]
db
(:db/id i)))
linked-invoices (when (and (:db/id i) (:transaction/payment i))
(dc/q '[:find (pull ?inv [:db/id :invoice/invoice-number :invoice/total])
:in $ ?payment-id
:where
[?ip :invoice-payment/payment ?payment-id]
[?ip :invoice-payment/invoice ?inv]]
db
(:db/id (:transaction/payment i))))]
(link-dropdown
(cond-> []
;; Payment link
(:transaction/payment i)
(conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::payment-routes/all-page)
{:exact-match-id (:db/id (:transaction/payment i))})
:color :primary
:content (format "Payment '%s'" (-> i :transaction/payment :payment/date (atime/unparse-local atime/normal-date)))})
;; Journal entry links
(seq journal-entries)
(concat
(for [[je] journal-entries]
{:link (hu/url (bidi/path-for ssr-routes/only-routes ::ledger-routes/all-page)
{:exact-match-id (:db/id je)})
:color :yellow
:content "Ledger entry"}))
;; Invoice links
(seq linked-invoices)
(concat
(for [[inv] linked-invoices]
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::invoice-routes/all-page)
{:exact-match-id (:db/id inv)})
:color :secondary
:content (format "Invoice '%s'" (:invoice/invoice-number inv))}))))))
:render-for #{:html}}]}))
(defn wrap-status-from-source [handler]
(fn [{:keys [matched-current-page-route] :as request}]
(let [request (cond-> request
(= ::route/unapproved-page matched-current-page-route) (assoc-in [:route-params :status] :transaction-approval-status/unapproved)
(= ::route/approved-page matched-current-page-route) (assoc-in [:route-params :status] :transaction-approval-status/approved)
(= ::route/requires-feedback-page matched-current-page-route) (assoc-in [:route-params :status] :transaction-approval-status/requires-feedback)
(= ::route/page matched-current-page-route) (assoc-in [:route-params :status] nil))]
(handler request))))
(defn selected->ids [request params]
(let [all-selected (:all-selected params)
selected (:selected params)
ids (cond
all-selected
(:ids (fetch-ids (dc/db conn) (-> request
(assoc :query-params params)
(assoc-in [:query-params :start] 0)
(assoc-in [:query-params :per-page] 250))))
:else
selected)]
ids))