(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]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.graphql.utils :refer [extract-client-ids]] [auto-ap.query-params :as query-params] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [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-decode]] [auto-ap.utils :refer [dollars=]] [bidi.bidi :as bidi] [clojure.string :as str] [datomic.api :as dc] [malli.core :as mc])) (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 :admin-transaction-rule-table) "hx-target" "#entity-table" "hx-indicator" "#entity-table"} [:fieldset.space-y-6 (com/field {:label "Vendor"} (com/typeahead-2 {: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) (seq valid-clients) (merge-query {:query {:in ['[?xx ...]] :where ['(or-join [?e] (and [?e :transaction-rule/client ?xx]) (and (not [?e :transaction-rule/client]) [?e :transaction-rule/note]))]} :args [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 :admin-transaction-rule-new-dialog)) :color :primary} "New Transaction Rule")]) :row-buttons (fn [request entity] [(com/icon-button {:hx-get (str (bidi/path-for ssr-routes/only-routes :admin-transaction-rule-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 :admin-transaction-rules)} "Transaction Rules"]] :title "Rules" :entity-name "Rule" :route :admin-transaction-rule-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 transaction-rule-save [{:keys [form-params request-method identity] :as request}] (let [entity (cond-> form-params (= :post request-method) (assoc :db/id "new") true (assoc :transaction-rule/note (entity->note form-params))) _ (doseq [[{:transaction-rule-account/keys [account location]} i] (map vector (:transaction-rule/accounts entity) (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)) total (reduce + 0.0 (map :transaction-rule-account/percentage (:transaction-rule/accounts entity))) _ (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 entity) (not (bank-account-belongs-to-client? (:transaction-rule/bank-account entity) (:transaction-rule/client entity)))) (field-validation-error "does not belong to client" [:transaction-rule/bank-account] :form 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))))))) (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-2 {: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 :admin-transaction-rule-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-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 :admin-transaction-rule-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)))) ;; TODO background jobs and company 1099 (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"} [:form {:hx-ext "response-targets" :hx-swap "outerHTML swap:300ms" :hx-target-400 "#form-errors .error-content" :x-trap "true" :class "group/form" (if (:db/id entity) :hx-put :hx-post) (str (bidi/path-for ssr-routes/only-routes :admin-transaction-rule-edit-save))} (com/modal-card {} [:div.flex [:div.p-2 "Transaction Rule"]] [: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-2 {: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-2 {: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 :admin-transaction-rule-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*)}) (com/validated-save-button {:errors (:errors form-errors)} "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) client-id))))) ;; TODO check to see if it should be called "Shared" or "shared" for the value ;; TODO hydrate nested types more easily. make it easy to hydrate so you don't do weird sometimes pulls (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 transaction-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}))) (def key->handler (apply-middleware-to-all-handlers (->> {:admin-transaction-rules (helper/page-route grid-page) :admin-transaction-rule-table (helper/table-route grid-page) :admin-transaction-rule-new-account (-> new-account (wrap-schema-decode :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) :admin-transaction-rule-location-select (-> location-select (wrap-schema-decode :query-schema [:map [:name :string] [:client-id {:optional true} [:maybe entity-id]] [:account-id {:optional true} [:maybe entity-id]]])) :admin-transaction-rule-account-typeahead (-> account-typeahead (wrap-schema-decode :query-schema [:map [:name :string] [:client-id {:optional true} [:maybe entity-id]] [:value {:optional true} [:maybe entity-id]]])) :admin-transaction-rule-save (-> transaction-rule-save (wrap-entity [:form-params :db/id] default-read) (wrap-schema-decode :form-schema form-schema) (wrap-nested-form-params) (wrap-form-4xx-2 (-> transaction-dialog (wrap-entity [:form-params :db/id] default-read)))) :admin-transaction-rule-edit-dialog (-> transaction-dialog (wrap-entity [:route-params :db/id] default-read) (wrap-schema-decode :route-schema [:map [:db/id entity-id]])) :admin-transaction-rule-new-dialog transaction-dialog}) (fn [h] (-> h (wrap-admin) (wrap-client-redirect-unauthenticated)))))