958 lines
52 KiB
Clojure
958 lines
52 KiB
Clojure
(ns auto-ap.ssr.admin.transaction-rules
|
|
(:require
|
|
[auto-ap.datomic
|
|
:refer [add-sorter-fields
|
|
apply-pagination
|
|
apply-sort-3
|
|
audit-transact
|
|
conn
|
|
merge-query
|
|
pull-attr
|
|
pull-many
|
|
query2
|
|
remove-nils]]
|
|
[auto-ap.datomic.accounts :as d-accounts]
|
|
[auto-ap.datomic.transactions :as d-transactions]
|
|
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
|
[auto-ap.query-params :as query-params]
|
|
[auto-ap.routes.admin.transaction-rules :as route]
|
|
[auto-ap.routes.utils
|
|
:refer [wrap-admin 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.company :refer [bank-account-typeahead*]]
|
|
[auto-ap.ssr.components :as com]
|
|
[auto-ap.ssr.form-cursor :as fc]
|
|
[auto-ap.ssr.grid-page-helper :as helper]
|
|
[auto-ap.ssr.hx :as hx]
|
|
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.utils
|
|
:refer [apply-middleware-to-all-handlers
|
|
entity-id
|
|
field-validation-error
|
|
form-validation-error
|
|
html-response
|
|
main-transformer
|
|
many-entity
|
|
modal-response
|
|
money
|
|
percentage
|
|
ref->enum-schema
|
|
ref->radio-options
|
|
regex
|
|
temp-id
|
|
wrap-entity
|
|
wrap-form-4xx-2
|
|
wrap-schema-enforce]]
|
|
[auto-ap.time :as atime]
|
|
[auto-ap.utils :refer [dollars=]]
|
|
[bidi.bidi :as bidi]
|
|
[clj-time.coerce :as coerce]
|
|
[clojure.set :as set]
|
|
[clojure.string :as str]
|
|
[datomic.api :as dc]
|
|
[malli.core :as mc]
|
|
[auto-ap.logging :as alog]))
|
|
|
|
(defn filters [request]
|
|
[:form {"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"}
|
|
|
|
[:fieldset.space-y-6
|
|
(com/field {:label "Vendor"}
|
|
(com/typeahead {:name "vendor"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes
|
|
:vendor-search)
|
|
:id (str "vendor-search")
|
|
:value (:vendor (:parsed-query-params request))
|
|
:value-fn :db/id
|
|
:content-fn :vendor/name}))
|
|
(com/field {:label "Note"}
|
|
(com/text-input {:name "note"
|
|
:id "note"
|
|
:class "hot-filter"
|
|
:value (:note (:parsed-query-params request))
|
|
:placeholder "HOME DEPOT lte 250.0"
|
|
:size :small}))
|
|
|
|
(com/field {:label "Description"}
|
|
(com/text-input {:name "description"
|
|
:id "description"
|
|
:class "hot-filter"
|
|
:value (:description (:parsed-query-params request))
|
|
:placeholder "LOWES"
|
|
:size :small}))]])
|
|
|
|
(def default-read '[:db/id
|
|
:transaction-rule/description
|
|
:transaction-rule/note
|
|
:transaction-rule/amount-lte
|
|
:transaction-rule/amount-gte
|
|
:transaction-rule/dom-lte
|
|
:transaction-rule/dom-gte
|
|
{:transaction-rule/client [:client/name :db/id :client/code :client/locations]}
|
|
{:transaction-rule/bank-account [:db/id :bank-account/name]}
|
|
{:transaction-rule/yodlee-merchant [:db/id :yodlee-merchant/name :yodlee-merchant/yodlee-id]}
|
|
{[:transaction-rule/transaction-approval-status :xform iol-ion.query/ident] [:db/id :db/ident]}
|
|
{:transaction-rule/vendor [:vendor/name :db/id :vendor/default-account]}
|
|
{:transaction-rule/accounts [:transaction-rule-account/percentage
|
|
:transaction-rule-account/location
|
|
{:transaction-rule-account/account [:account/name :db/id :account/numeric-code :account/location
|
|
{:account/client-overrides [:db/id
|
|
:account-client-override/name
|
|
{:account-client-override/client [:db/id :client/name]}]}]}
|
|
:db/id]}])
|
|
|
|
(defn fetch-ids [db request]
|
|
(let [query-params (:parsed-query-params request)
|
|
valid-clients (extract-client-ids (:clients request)
|
|
(:client request)
|
|
(:client-id query-params)
|
|
(when (:client-code query-params)
|
|
[:client/code (:client-code query-params)]))
|
|
query (cond-> {:query {:find []
|
|
:in ['$]
|
|
:where []}
|
|
:args [db]}
|
|
(:sort query-params) (add-sorter-fields {"client" ['[?e :transaction-rule/client ?c]
|
|
'[?c :client/name ?sort-client]]
|
|
|
|
"yodlee-merchant" ['[?e :transaction-rule/yodlee-merchant ?ym]
|
|
'[?ym :yodlee-merchant/name ?sort-yodlee-merchant]]
|
|
"bank-account" ['[?e :transaction-rule/bank-account ?ba]
|
|
'[?ba :bank-account/name ?sort-bank-account]]
|
|
"description" ['[?e :transaction-rule/description ?sort-description]]
|
|
"note" ['[?e :transaction-rule/note ?sort-note]]
|
|
"amount-lte" ['[?e :transaction-rule/amount-lte ?sort-amount-lte]]
|
|
"amount-gte" ['[?e :transaction-rule/amount-gte ?sort-amount-gte]]}
|
|
query-params)
|
|
|
|
(= 1 (count valid-clients))
|
|
(merge-query {:query {:in '[?x]
|
|
:where '[[?e :transaction-rule/client ?x]]}
|
|
:args [(first valid-clients)]})
|
|
|
|
(-> query-params :vendor :db/id)
|
|
(merge-query {:query {:in ['?vendor-id]
|
|
:where ['[?e :transaction-rule/vendor ?vendor-id]]}
|
|
:args [(-> query-params :vendor :db/id)]})
|
|
|
|
(not (str/blank? (:note query-params)))
|
|
(merge-query {:query {:in ['?note-pattern]
|
|
:where ['[?e :transaction-rule/note ?n]
|
|
'[(re-find ?note-pattern ?n)]]}
|
|
:args [(re-pattern (str "(?i)" (:note query-params)))]})
|
|
|
|
(not (str/blank? (:description query-params)))
|
|
(merge-query {:query {:in ['?description]
|
|
:where ['[?e :transaction-rule/description ?d]
|
|
'[(clojure.string/lower-case ?d) ?d2]
|
|
'[(clojure.string/includes? ?d2 ?description)]]}
|
|
:args [(clojure.string/lower-case (:description query-params))]})
|
|
|
|
true
|
|
(merge-query {:query {:find ['?e]
|
|
:where ['[?e :transaction-rule/transaction-approval-status]]}}))]
|
|
|
|
(cond->> (query2 query)
|
|
true (apply-sort-3 query-params)
|
|
true (apply-pagination query-params))))
|
|
|
|
(defn hydrate-results [ids db _]
|
|
(let [results (->> (pull-many db default-read ids)
|
|
(group-by :db/id))
|
|
refunds (->> ids
|
|
(map results)
|
|
(map first))]
|
|
refunds))
|
|
|
|
(defn fetch-page [request]
|
|
(let [db (dc/db conn)
|
|
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
|
|
|
[(->> (hydrate-results ids-to-retrieve db request))
|
|
matching-count]))
|
|
|
|
(def grid-page
|
|
(helper/build {:id "entity-table"
|
|
:nav (com/admin-aside-nav)
|
|
:page-specific-nav filters
|
|
:fetch-page fetch-page
|
|
:parse-query-params (comp
|
|
(query-params/parse-key :vendor #(dc/pull (dc/db conn) '[:vendor/name :db/id] (Long/parseLong %)))
|
|
(helper/default-parse-query-params grid-page))
|
|
:action-buttons (fn [request]
|
|
[(com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/new-dialog))
|
|
:color :primary}
|
|
"New Transaction Rule")])
|
|
:row-buttons (fn [request entity]
|
|
[(com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes
|
|
::route/delete
|
|
:db/id (:db/id entity))
|
|
:hx-confirm "Are you sure you want to delete?"}
|
|
svg/trash)
|
|
(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
|
::route/execute-dialog
|
|
:db/id (:db/id entity))}
|
|
svg/play)
|
|
(com/icon-button {:hx-get (bidi/path-for ssr-routes/only-routes
|
|
::route/edit-dialog
|
|
:db/id (:db/id entity))}
|
|
svg/pencil)])
|
|
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes :admin)}
|
|
"Admin"]
|
|
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
|
|
"Transaction Rules"]]
|
|
:title "Rules"
|
|
:entity-name "Rule"
|
|
:route ::route/table
|
|
:headers [{:key "client"
|
|
:name "Client"
|
|
:sort-key "client"
|
|
:render #(-> % :transaction-rule/client :client/name)}
|
|
{:key "bank-account"
|
|
:name "Bank account"
|
|
:sort-key "bank-account"
|
|
:render #(-> % :transaction-rule/bank-account :bank-account/name)
|
|
:show-starting "lg"}
|
|
{:key "description"
|
|
:name "Description"
|
|
:sort-key "description"
|
|
:render :transaction-rule/description}
|
|
{:key "amount"
|
|
:name "Amount"
|
|
:sort-key "amount"
|
|
:render (fn [{:transaction-rule/keys [amount-gte amount-lte]}]
|
|
[:div.flex.gap-2 (when amount-gte
|
|
(com/pill {:color :red} (format "more than $%.2f" amount-gte)))
|
|
|
|
(when amount-lte
|
|
(com/pill {:color :primary} (format "less than $%.2f" amount-lte)))])
|
|
:show-starting "md"}
|
|
{:key "note"
|
|
:name "Note"
|
|
:sort-key "note"
|
|
:render :transaction-rule/note}
|
|
]}))
|
|
|
|
(def row* (partial helper/row* grid-page))
|
|
(def table* (partial helper/table* grid-page))
|
|
|
|
(defn entity->note [{:transaction-rule/keys [amount-lte amount-gte description client dom-lte dom-gte]}]
|
|
(str/join " - " (filter (complement str/blank?)
|
|
[(when client (pull-attr (dc/db conn) :client/code client))
|
|
description
|
|
(when (or amount-lte amount-gte)
|
|
(str (when amount-gte
|
|
(str amount-gte "<"))
|
|
"amt"
|
|
(when amount-lte
|
|
(str "<" amount-lte))))
|
|
|
|
(when (or dom-lte dom-gte)
|
|
(str (when dom-gte
|
|
(str dom-gte "<"))
|
|
"dom"
|
|
(when dom-lte
|
|
(str "<" dom-lte))))])))
|
|
|
|
(defn bank-account-belongs-to-client? [bank-account-id client-id]
|
|
(get (->> (dc/pull (dc/db conn) [{:client/bank-accounts [:db/id]}] client-id)
|
|
:client/bank-accounts
|
|
(map :db/id)
|
|
(set))
|
|
bank-account-id))
|
|
|
|
(defn validate-transaction-rule [form-params]
|
|
(doseq [[{:transaction-rule-account/keys [account location]} i] (map vector (:transaction-rule/accounts form-params) (range))
|
|
:let [account-location (pull-attr (dc/db conn) :account/location account)]
|
|
:when (and account-location (not= account-location location))]
|
|
(field-validation-error (str "must be " account-location)
|
|
[:transaction-rule/accounts i :transaction-rule-account/location]
|
|
:form form-params))
|
|
|
|
(let [total (reduce + 0.0 (map :transaction-rule-account/percentage (:transaction-rule/accounts form-params)))]
|
|
(when-not (dollars= 1.0 total)
|
|
(form-validation-error (format "Expense accounts total (%d%%) must add to 100%%" (int (* 100.0 total)))
|
|
:form form-params)))
|
|
|
|
(when (and (:transaction-rule/bank-account form-params)
|
|
(not (bank-account-belongs-to-client? (:transaction-rule/bank-account form-params)
|
|
(:transaction-rule/client form-params))))
|
|
(field-validation-error "does not belong to client"
|
|
[:transaction-rule/bank-account]
|
|
:form form-params)))
|
|
|
|
(defn save [{:keys [form-params request-method identity] :as request}]
|
|
(validate-transaction-rule form-params)
|
|
(let [entity (cond-> form-params
|
|
(= :post request-method) (assoc :db/id "new")
|
|
true (assoc :transaction-rule/note (entity->note form-params)))
|
|
{:keys [tempids]} (audit-transact [[:upsert-entity entity]]
|
|
(:identity request))
|
|
updated-rule (dc/pull (dc/db conn)
|
|
default-read
|
|
(or (get tempids (:db/id entity)) (:db/id entity)))]
|
|
(html-response
|
|
(row* identity updated-rule {:flash? true})
|
|
:headers (cond-> {"hx-trigger" "modalclose"}
|
|
(= :post request-method) (assoc "hx-retarget" "#entity-table tbody"
|
|
"hx-reswap" "afterbegin")
|
|
(= :put request-method) (assoc "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id updated-rule))
|
|
"hx-reswap" "outerHTML")))))
|
|
|
|
|
|
|
|
(def transaction-read '[{:transaction/client [:client/name]
|
|
:transaction/bank-account [:bank-account/name]}
|
|
:transaction/description-original
|
|
:db/id
|
|
[:transaction/date :xform clj-time.coerce/from-date]])
|
|
|
|
(defn transactions-matching-rule [{{:transaction-rule/keys [description client bank-account amount-lte amount-gte dom-lte dom-gte]}
|
|
:entity
|
|
clients :clients
|
|
only-uncoded? :only-uncoded?}]
|
|
(let [valid-clients (extract-client-ids clients
|
|
client)
|
|
bank-account (or (:db/id bank-account) bank-account)
|
|
|
|
query (cond-> {:query {:find ['(pull ?e read)]
|
|
:in ['$ 'read]
|
|
:where []}
|
|
:args [(dc/db conn) transaction-read]}
|
|
description
|
|
(merge-query {:query {:in ['?descr]
|
|
:where ['[(iol-ion.query/->pattern ?descr) ?description-regex]]}
|
|
:args [description]})
|
|
|
|
valid-clients
|
|
(merge-query {:query {:in ['[?xx ...]]
|
|
:where ['[?e :transaction/client ?xx]]}
|
|
:args [(set valid-clients)]})
|
|
|
|
bank-account
|
|
(merge-query {:query {:in ['?bank-account-id]
|
|
:where ['[?e :transaction/bank-account ?bank-account-id]]}
|
|
:args [bank-account]})
|
|
|
|
description
|
|
(merge-query {:query {:where ['[?e :transaction/description-original ?do]
|
|
'[(re-find ?description-regex ?do)]]}})
|
|
|
|
amount-gte
|
|
(merge-query {:query {:in ['?amount-gte]
|
|
:where ['[?e :transaction/amount ?ta]
|
|
'[(>= ?ta ?amount-gte)]]}
|
|
:args [amount-gte]})
|
|
|
|
amount-lte
|
|
(merge-query {:query {:in ['?amount-lte]
|
|
:where ['[?e :transaction/amount ?ta]
|
|
'[(<= ?ta ?amount-lte)]]}
|
|
:args [amount-lte]})
|
|
|
|
dom-lte
|
|
(merge-query {:query {:in ['?dom-lte]
|
|
:where ['[?e :transaction/date ?transaction-date]
|
|
'[(iol-ion.query/dom ?transaction-date) ?dom]
|
|
'[(<= ?dom ?dom-lte)]]}
|
|
:args [dom-lte]})
|
|
|
|
dom-gte
|
|
(merge-query {:query {:in ['?dom-gte]
|
|
:where ['[?e :transaction/date ?transaction-date]
|
|
'[(iol-ion.query/dom ?transaction-date) ?dom]
|
|
'[(>= ?dom ?dom-gte)]]}
|
|
:args [dom-gte]})
|
|
|
|
only-uncoded?
|
|
(merge-query {:query {:where ['[or [?e :transaction/approval-status :transaction-approval-status/unapproved]
|
|
[(missing? $ ?e :transaction/approval-status)]]]}})
|
|
|
|
true
|
|
(merge-query {:query {:where ['[?e :transaction/id]]}}))
|
|
results (->>
|
|
(query2 query)
|
|
(map first))]
|
|
results))
|
|
|
|
(defn transaction-rule-test-table* [{:keys [entity clients checkboxes? only-uncoded?]}]
|
|
(let [results (transactions-matching-rule
|
|
{:entity entity
|
|
:clients clients
|
|
:only-uncoded? only-uncoded?})]
|
|
|
|
[:div#transaction-test-results
|
|
[:h2.my-4.text-lg.flex {:x-data (hx/json {:resultCount (count results)})} "Matching transactions"
|
|
[:div.ml-4.relative (com/badge {:class "text-[0.6rem]"} (let [cnt (count results)]
|
|
(if (>= cnt 99)
|
|
"99+"
|
|
cnt)))]
|
|
[:div.flex.justify-end.flex-1 [:div.gutter]]]
|
|
(com/data-grid
|
|
{:headers [(when checkboxes?
|
|
(com/data-grid-checkbox-header {:name "all"}))
|
|
(com/data-grid-header {} "Client")
|
|
(com/data-grid-header {} "Bank")
|
|
(com/data-grid-header {} "Date")
|
|
(com/data-grid-header {} "Description")]}
|
|
(for [r (take 15 results)]
|
|
(com/data-grid-row
|
|
{}
|
|
(when checkboxes?
|
|
(com/data-grid-cell {} (com/checkbox {:name "transaction-id" :value (:db/id r)})))
|
|
(com/data-grid-cell {} (-> r :transaction/client :client/name))
|
|
(com/data-grid-cell {} (-> r :transaction/bank-account :bank-account/name))
|
|
(com/data-grid-cell {} (some-> r :transaction/date (atime/unparse-local atime/normal-date)))
|
|
(com/data-grid-cell {} (some-> r :transaction/description-original )))))]))
|
|
|
|
(defn test [{:keys [form-params request-method identity] :as request
|
|
entity :form-params}]
|
|
(validate-transaction-rule form-params)
|
|
(html-response
|
|
(com/stacked-modal-card
|
|
1
|
|
{}
|
|
[:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"] ]
|
|
(transaction-rule-test-table* {:entity entity
|
|
:clients (:clients request)})
|
|
[:div.flex.justify-between
|
|
|
|
(com/button {"@click" "$dispatch('modalpop')"
|
|
:class "w-32"}
|
|
"Back")
|
|
(com/button (cond-> {:color :primary
|
|
:hx-include "#my-form"
|
|
:class "w-32"
|
|
}
|
|
(:db/id form-params) (assoc :hx-put (bidi/path-for ssr-routes/only-routes ::route/save))
|
|
(not (:db/id form-params)) (assoc :hx-post (bidi/path-for ssr-routes/only-routes ::route/save)))
|
|
"Save rule")])
|
|
:headers (-> {}
|
|
(assoc "hx-trigger-after-settle" "modalnext")
|
|
(assoc "hx-retarget" ".modal-stack")
|
|
(assoc "hx-reswap" "beforeend"))))
|
|
|
|
|
|
;; TODO only uncoded
|
|
|
|
|
|
(defn- location-select*
|
|
[{:keys [ name account-location client-locations value]}]
|
|
(com/select {:options (into [["" ""]]
|
|
(cond account-location
|
|
[[account-location account-location]]
|
|
|
|
(seq client-locations)
|
|
(into [["Shared" "Shared"]]
|
|
(for [cl client-locations]
|
|
[cl cl]))
|
|
:else
|
|
[["Shared" "Shared"]]))
|
|
:name name
|
|
:value value
|
|
:class "w-full"}))
|
|
|
|
(defn- account-typeahead*
|
|
[{:keys [name value client-id x-model]}]
|
|
[:div.flex.flex-col
|
|
(com/typeahead {:name name
|
|
:placeholder "Search..."
|
|
:url (str (bidi/path-for ssr-routes/only-routes :account-search) "?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)))})])
|
|
|
|
(defn- transaction-rule-account-row*
|
|
[account client-id client-locations]
|
|
(com/data-grid-row
|
|
(-> {:x-data (hx/json {:accountId (or (:db/id (fc/field-value (:transaction-rule-account/account account)))
|
|
(fc/field-value (:transaction-rule-account/account account)))
|
|
:location (fc/field-value (:transaction-rule-account/location account))
|
|
:show (boolean (not (fc/field-value (:new? account))))})
|
|
:data-key "show"
|
|
:x-ref "p"}
|
|
hx/alpine-mount-then-appear)
|
|
(let [account-name (fc/field-name (:transaction-rule-account/account account))]
|
|
(list
|
|
|
|
(fc/with-field :db/id
|
|
(com/hidden {:name (fc/field-name)
|
|
:value (fc/field-value)}))
|
|
(fc/with-field :transaction-rule-account/account
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
[:div {:hx-trigger "changed"
|
|
:hx-target "next div"
|
|
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', value: event.detail.accountId || ''}" account-name)
|
|
:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/account-typeahead))
|
|
:x-init "$watch('clientId', cid => $dispatch('changed', $data));"}]
|
|
(account-typeahead* {:value (fc/field-value)
|
|
:client-id client-id
|
|
:name (fc/field-name)
|
|
:x-model "accountId"}))))
|
|
(fc/with-field :transaction-rule-account/location
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)
|
|
:x-data (hx/json {:location (fc/field-value)})}
|
|
[:div {:hx-trigger "changed"
|
|
:hx-target "next *"
|
|
:hx-swap "outerHTML"
|
|
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId || '', 'account-id': event.detail.accountId || '', value: event.detail.location || ''}" (fc/field-name) )
|
|
:hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select)
|
|
:x-init "$watch('clientId', cid => $dispatch('changed', $data)); $watch('accountId', cid => $dispatch('changed', $data) )"}]
|
|
(location-select* {:name (fc/field-name)
|
|
:account-location (:account/location (cond->> (:transaction-rule-account/account @account)
|
|
(nat-int? (:transaction-rule-account/account @account)) (dc/pull (dc/db conn)
|
|
'[:account/location])))
|
|
:client-locations client-locations
|
|
:x-model "location"
|
|
:value (fc/field-value)}))))
|
|
(fc/with-field :transaction-rule-account/percentage
|
|
(com/data-grid-cell
|
|
{}
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
(com/money-input {:name (fc/field-name)
|
|
:class "w-16"
|
|
:value (some-> (fc/field-value)
|
|
(* 100 )
|
|
(long ))}))))))
|
|
(com/data-grid-cell {:class "align-top"}
|
|
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
|
|
|
|
(defn dialog* [{:keys [entity form-params form-errors]}]
|
|
(fc/start-form form-params form-errors
|
|
(com/modal
|
|
{:modal-class "max-w-2xl"
|
|
:hx-target "this"
|
|
}
|
|
|
|
(com/stacked-modal-card
|
|
0
|
|
{}
|
|
[:div.flex [:div.p-2 "Transaction Rule"]]
|
|
[:form#my-form {:hx-ext "response-targets"
|
|
:hx-target-400 "#form-errors .error-content"
|
|
:hx-indicator "#submit"
|
|
:x-trap "true"
|
|
(if (:db/id entity)
|
|
:hx-put
|
|
:hx-post) (str (bidi/path-for ssr-routes/only-routes ::route/save))}
|
|
[:fieldset {:class "hx-disable"
|
|
:x-data (hx/json {:clientId (or (:db/id (:transaction-rule/client form-params))
|
|
(:transaction-rule/client form-params)
|
|
(:db/id (:transaction-rule/client entity)))})}
|
|
|
|
[:div.space-y-1
|
|
(when-let [id (:db/id entity)]
|
|
(com/hidden {:name "db/id"
|
|
:value id}))
|
|
(fc/with-field :transaction-rule/description
|
|
(com/validated-field {:label "Description"
|
|
:errors (fc/field-errors)}
|
|
(com/text-input {:name (fc/field-name)
|
|
:error? (fc/error?)
|
|
:x-init "$el.focus()"
|
|
:placeholder "HOME DEPOT"
|
|
:class "w-96"
|
|
:value (fc/field-value)})))
|
|
[:div.filters {:x-data (hx/json {:clientFilter (boolean (fc/field-value (:transaction-rule/client fc/*current*)))
|
|
:bankAccountFilter (boolean (fc/field-value (:transaction-rule/bank-account fc/*current*)))
|
|
:amountFilter (boolean (or (fc/field-value (:transaction-rule/amount-gte fc/*current*))
|
|
(fc/field-value (:transaction-rule/amount-lte fc/*current*))))
|
|
:domFilter (boolean (or (fc/field-value (:transaction-rule/dom-gte fc/*current*))
|
|
(fc/field-value (:transaction-rule/dom-lte fc/*current*))))})}
|
|
|
|
[:div.flex.gap-2.mb-2
|
|
(com/a-button {"@click" "clientFilter=true"
|
|
"x-show" "!clientFilter"} "Filter client")
|
|
(com/a-button {"@click" "bankAccountFilter=true"
|
|
"x-show" "clientFilter && !bankAccountFilter"} "Filter bank account")
|
|
(com/a-button {"@click" "amountFilter=true"
|
|
"x-show" "!amountFilter"} "Filter amount")
|
|
(com/a-button {"@click" "domFilter=true"
|
|
"x-show" "!domFilter"} "Filter day of month")]
|
|
(fc/with-field :transaction-rule/client
|
|
|
|
(com/validated-field
|
|
(-> {:label "Client"
|
|
:errors (fc/field-errors)
|
|
:x-show "clientFilter"}
|
|
(hx/alpine-appear))
|
|
[:div.w-96
|
|
(com/typeahead {:name (fc/field-name)
|
|
:error? (fc/error?)
|
|
:class "w-96"
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :company-search)
|
|
:x-model "clientId"
|
|
:value (fc/field-value)
|
|
:content-fn (fn [c] (pull-attr (dc/db conn) :client/name c))})]))
|
|
|
|
(fc/with-field :transaction-rule/bank-account
|
|
(com/validated-field
|
|
(-> {:label "Bank Account"
|
|
:errors (fc/field-errors)
|
|
:x-show "bankAccountFilter"}
|
|
hx/alpine-appear)
|
|
[:div.w-96
|
|
[:div#bank-account-changer {:hx-get (bidi/path-for ssr-routes/only-routes :bank-account-typeahead)
|
|
:hx-trigger "changed"
|
|
:hx-target "next *"
|
|
:hx-include "#bank-account-changer"
|
|
:hx-swap "innerHTML"
|
|
|
|
:hx-vals (format "js:{name: '%s', 'client-id': event.detail.clientId}" (fc/field-name))
|
|
:x-init "$watch('clientId', cid => $dispatch('changed', $data))"}]
|
|
|
|
(bank-account-typeahead* {:client-id (:transaction-rule/client form-params)
|
|
:name (fc/field-name)
|
|
:value (fc/field-value)})]))
|
|
|
|
(com/field (-> {:label "Amount"
|
|
:x-show "amountFilter"}
|
|
hx/alpine-appear)
|
|
[:div.flex.gap-2
|
|
(fc/with-field :transaction-rule/amount-gte
|
|
[:div.flex.flex-col
|
|
(com/money-input {:name (fc/field-name)
|
|
:placeholder ">="
|
|
:class "w-24"
|
|
:value (fc/field-value)})
|
|
(com/errors {:errors (fc/field-errors)})])
|
|
(fc/with-field :transaction-rule/amount-lte
|
|
[:div.flex.flex-col
|
|
(com/money-input {:name (fc/field-name)
|
|
:placeholder "<="
|
|
:class "w-24"
|
|
:value (fc/field-value)})
|
|
(com/errors {:errors (fc/field-errors)})])])
|
|
|
|
(com/field (-> {:label "Day of month"
|
|
:x-show "domFilter"}
|
|
hx/alpine-appear)
|
|
[:div.flex.gap-2
|
|
(fc/with-field :transaction-rule/dom-gte
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
(com/int-input {:name (fc/field-name)
|
|
:placeholder ">="
|
|
:class "w-24"
|
|
:value (fc/field-value)})))
|
|
(fc/with-field :transaction-rule/dom-lte
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
(com/int-input {:name (fc/field-name)
|
|
:placeholder ">="
|
|
:class "w-24"
|
|
:value (fc/field-value)})))])]
|
|
|
|
[:h2.text-lg "Outcomes"]
|
|
(fc/with-field :transaction-rule/vendor
|
|
(com/validated-field {:label "Assign Vendor"
|
|
:errors (fc/field-errors)}
|
|
[:div.w-96
|
|
(com/typeahead {:name (fc/field-name)
|
|
:placeholder "Search..."
|
|
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
|
|
:class "w-96"
|
|
:value (fc/field-value)
|
|
:content-fn #(pull-attr (dc/db conn) :vendor/name %)})]))
|
|
|
|
(fc/with-field :transaction-rule/accounts
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
(let [client-locations (some->> form-params :transaction-rule/client (pull-attr (dc/db conn) :client/locations))]
|
|
(com/data-grid {:headers [(com/data-grid-header {} "Account")
|
|
(com/data-grid-header {:class "w-32"} "Location")
|
|
(com/data-grid-header {:class "w-16"} "%")
|
|
(com/data-grid-header {:class "w-16"})]}
|
|
(fc/cursor-map #(transaction-rule-account-row* % (:transaction-rule/client form-params) client-locations))
|
|
(com/data-grid-new-row {:colspan 4
|
|
:hx-get (bidi/path-for ssr-routes/only-routes
|
|
::route/new-account)
|
|
:index (count (fc/field-value))
|
|
:tr-params (hx/bind-alpine-vals {} {"client-id" "clientId"})}
|
|
"New account")))))
|
|
|
|
(fc/with-field :transaction-rule/transaction-approval-status
|
|
(com/validated-field {:label "Approval status"
|
|
:errors (fc/field-errors)}
|
|
(com/radio {:options (ref->radio-options "transaction-approval-status")
|
|
:value (fc/field-value)
|
|
:name (fc/field-name)
|
|
:size :small
|
|
:orientation :horizontal})))]]]
|
|
[:div
|
|
(com/form-errors {:errors (:errors fc/*form-errors*)})
|
|
[:div.flex.justify-end
|
|
|
|
(com/validated-save-button {:errors form-errors :color :secondary
|
|
:hx-post (bidi/path-for ssr-routes/only-routes ::route/test)
|
|
:hx-include "#my-form"}
|
|
|
|
"Test rule")
|
|
(com/validated-save-button {:errors form-errors
|
|
:id "submit"
|
|
:form "my-form"} "Save rule")]]))))
|
|
|
|
|
|
(defn new-account [{{:keys [client-id index]} :query-params}]
|
|
(html-response
|
|
(fc/start-form-with-prefix
|
|
[:transaction-rule/accounts (or index 0)]
|
|
{:db/id (str (java.util.UUID/randomUUID))
|
|
:transaction-rule-account/location "Shared"
|
|
:new? true}
|
|
[]
|
|
(transaction-rule-account-row*
|
|
fc/*current*
|
|
client-id
|
|
(some->> client-id (pull-attr (dc/db conn) :client/locations))))))
|
|
|
|
(defn all-ids-not-locked [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 execute [{:keys [form-params clients entity identity]}]
|
|
(let [all-results (->> (transactions-matching-rule {:entity entity
|
|
:clients clients
|
|
:only-uncoded? true})
|
|
(map :db/id)
|
|
(into #{}))
|
|
|
|
ids (if (not-empty (:all form-params))
|
|
all-results
|
|
(set/intersection (into #{} (:transaction-id form-params))
|
|
all-results))
|
|
|
|
ids (all-ids-not-locked ids)
|
|
transactions (transduce
|
|
(comp
|
|
(map d-transactions/get-by-id)
|
|
(map #(update % :transaction/date coerce/to-date)))
|
|
conj
|
|
[]
|
|
ids)
|
|
entity (update entity :transaction-rule/description #(some-> % iol-ion.query/->pattern))
|
|
|
|
;; TODO
|
|
#_#_x (doseq [transaction transactions]
|
|
(when (not (rm/rule-applies? transaction entity))
|
|
(throw (ex-info "Transaction rule does not apply" {:validation-error "Transaction rule does not apply"
|
|
:transaction-rule entity
|
|
:transaction transaction})))
|
|
|
|
(when (:transaction/payment transaction)
|
|
|
|
(throw (ex-info "Transaction already associated with a payment" {:validation-error "Transaction already associated with a payment"}))))
|
|
]
|
|
|
|
(audit-transact (mapv (fn [t]
|
|
[:upsert-transaction
|
|
(remove-nils (rm/apply-rule {:db/id (:db/id t)
|
|
:transaction/amount (:transaction/amount t)}
|
|
entity
|
|
|
|
(or (-> t :transaction/bank-account :bank-account/locations)
|
|
(-> t :transaction/client :client/locations))))])
|
|
transactions)
|
|
identity)
|
|
(doseq [n transactions]
|
|
(solr/touch-with-ledger (:db/id n)))
|
|
|
|
(html-response [:div]
|
|
:headers {"hx-trigger" (hx/json {:modalclose ""
|
|
:notification (format "Successfully coded %d of %d transactions!"
|
|
(count ids)
|
|
(count all-results))})})))
|
|
|
|
(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-typeahead [{{:keys [name value client-id] :as qp} :query-params}]
|
|
(html-response (account-typeahead* {:name name
|
|
:value value
|
|
:client-id client-id
|
|
:x-model "accountId"})))
|
|
|
|
(def form-schema (mc/schema
|
|
[:map
|
|
[:db/id {:optional true} [:maybe entity-id]]
|
|
[:transaction-rule/client {:optional true} [:maybe entity-id]]
|
|
[:transaction-rule/description [:and regex
|
|
[:string {:min 3}]]]
|
|
[:transaction-rule/bank-account [:maybe entity-id]]
|
|
[:transaction-rule/amount-gte {:optional true} [:maybe money]]
|
|
[:transaction-rule/amount-lte {:optional true} [:maybe money]]
|
|
[:transaction-rule/dom-gte {:optional true} [:maybe :int]]
|
|
[:transaction-rule/dom-lte {:optional true} [:maybe :int]]
|
|
[:transaction-rule/vendor {:optional true} [:maybe entity-id]]
|
|
[:transaction-rule/transaction-approval-status (ref->enum-schema "transaction-approval-status")]
|
|
[:transaction-rule/accounts
|
|
(many-entity {:min 1}
|
|
[:db/id [:or entity-id temp-id]]
|
|
[:transaction-rule-account/account entity-id]
|
|
[:transaction-rule-account/location [:string {:min 1 :error/message "required"}]]
|
|
[:transaction-rule-account/percentage percentage])]]))
|
|
|
|
(defn edit-dialog [{:keys [entity form-params form-errors]}]
|
|
(modal-response (dialog* {:entity entity
|
|
:form-params (or (when (seq form-params)
|
|
form-params)
|
|
(when entity
|
|
(mc/decode form-schema entity main-transformer))
|
|
{})
|
|
:form-errors form-errors})))
|
|
|
|
|
|
(defn check-badges [{query-params :query-params}]
|
|
(html-response
|
|
[:div (if (not-empty (:all query-params))
|
|
(com/pill {:color :secondary}
|
|
|
|
[:span "All " [:span {:x-text "resultCount" :x-data "{}"}] " transactions"])
|
|
(com/pill {:color :primary}
|
|
(str (count (:transaction-id query-params)) " transactions")))]))
|
|
|
|
(defn execute-dialog [{:keys [entity clients]}]
|
|
(modal-response
|
|
(com/modal{}
|
|
(com/stacked-modal-card
|
|
0
|
|
{}
|
|
[:div.p-2.flex.space-x-4 [:div "Transaction Rule"] [:div ">"] [:div "Results"]]
|
|
[:form#my-form
|
|
{:hx-post (bidi/path-for ssr-routes/only-routes ::route/execute
|
|
:db/id (:db/id entity))
|
|
:hx-indicator "#code"}
|
|
[:div
|
|
{:hx-get (bidi/path-for ssr-routes/only-routes ::route/check-badges)
|
|
:hx-trigger "change"
|
|
:hx-target "#transaction-test-results .gutter"
|
|
:hx-include "this"}
|
|
(transaction-rule-test-table* {:entity entity
|
|
:clients clients
|
|
:checkboxes? true
|
|
:only-uncoded? true})]]
|
|
[:div.flex.justify-end (com/validated-save-button {:form "my-form" :id "code"} "Code transactions")]))
|
|
:headers (-> {}
|
|
(assoc "hx-trigger-after-settle" "modalnext")
|
|
(assoc "hx-retarget" ".modal-stack")
|
|
(assoc "hx-reswap" "beforeend"))))
|
|
|
|
(defn delete [{:keys [entity] :as request}]
|
|
@(dc/transact conn [[:db/retractEntity (:db/id entity)]])
|
|
|
|
(html-response (row* (:identity request) entity {:delete-after-settle? true :class "live-removed"})
|
|
:headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id entity))}))
|
|
|
|
|
|
(def key->handler
|
|
(apply-middleware-to-all-handlers
|
|
(->>
|
|
{::route/page (helper/page-route grid-page)
|
|
::route/table (helper/table-route grid-page)
|
|
::route/delete (-> delete
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-params [:map [:db/id entity-id]]))
|
|
::route/new-account (-> new-account
|
|
(wrap-schema-enforce :query-schema [:map
|
|
[:client-id {:optional true}
|
|
[:maybe entity-id]]
|
|
[:index {:optional true
|
|
:default 0} [nat-int? {:default 0}]]])
|
|
wrap-admin wrap-client-redirect-unauthenticated)
|
|
::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/account-typeahead (-> account-typeahead
|
|
(wrap-schema-enforce :query-schema [:map
|
|
[:name :string]
|
|
[:client-id {:optional true}
|
|
[:maybe entity-id]]
|
|
[:value {:optional true}
|
|
[:maybe entity-id]]]))
|
|
::route/save (-> save
|
|
(wrap-entity [:form-params :db/id] default-read)
|
|
(wrap-schema-enforce :form-schema form-schema)
|
|
(wrap-nested-form-params)
|
|
(wrap-form-4xx-2 (-> edit-dialog
|
|
(wrap-entity [:form-params :db/id] default-read))))
|
|
|
|
::route/execute (-> execute
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]])
|
|
(wrap-schema-enforce :form-schema
|
|
[:map
|
|
[:transaction-id {:optional true}
|
|
[:maybe [:vector {:decode/arbitrary (fn [x] ;; TODO make this easier
|
|
(if (sequential? x)
|
|
x
|
|
[x]))}
|
|
entity-id]]]
|
|
[:all {:optional true} [:maybe :string]]])
|
|
#_(wrap-form-4xx-2 (-> edit-dialog ;; TODO for example not having a single one checked
|
|
(wrap-entity [:form-params :db/id] default-read))))
|
|
|
|
::route/test (-> test
|
|
(wrap-entity [:form-params :db/id] default-read)
|
|
(wrap-schema-enforce :form-schema form-schema)
|
|
(wrap-nested-form-params)
|
|
(wrap-form-4xx-2 (-> edit-dialog
|
|
(wrap-entity [:form-params :db/id] default-read))))
|
|
|
|
::route/check-badges (-> check-badges
|
|
(wrap-schema-enforce :query-schema [:map
|
|
[:transaction-id {:optional true}
|
|
[:maybe [:vector {:decode/arbitrary (fn [x]
|
|
(if (sequential? x)
|
|
x
|
|
[x]))}
|
|
entity-id]]]
|
|
[:all {:optional true} [:maybe :string]]]))
|
|
::route/execute-dialog (-> execute-dialog
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
|
|
|
::route/edit-dialog (-> edit-dialog
|
|
(wrap-entity [:route-params :db/id] default-read)
|
|
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
|
|
::route/new-dialog edit-dialog})
|
|
(fn [h]
|
|
(-> h
|
|
(wrap-admin)
|
|
(wrap-client-redirect-unauthenticated)))))
|