(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}]]] [: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] :where ['[?e :transaction/description-original ?do] '[(clojure.string/lower-case ?do) ?do2] '[(.contains ?do2 ?description)]]} :args [(str/lower-case (:description 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? true)) (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" "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 "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 (is-admin? (:identity 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))