(ns auto-ap.ssr.transaction.bulk-code (:require [auto-ap.datomic :refer [audit-transact-batch conn pull-attr pull-many]] [auto-ap.logging :as alog] [auto-ap.permissions :refer [wrap-must]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.routes.transactions :as route] [auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]] [auto-ap.rule-matching :as rm] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.multi-modal :as mm :refer [wrap-wizard]] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.transaction.common :refer [grid-page query-schema selected->ids wrap-status-from-source]] [auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* location-select*]] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers entity-id form-validation-error html-response percentage ref->enum-schema wrap-merge-prior-hx wrap-schema-enforce]] [bidi.bidi :as bidi] [datomic.api :as dc] [iol-ion.query :refer [dollars=]] [iol-ion.tx :refer [random-tempid]] [malli.core :as mc])) (defn transaction-account-row* [{:keys [value client-id]}] (com/data-grid-row (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) :accountId (fc/field-value (:account value))}) :data-key "show" :x-ref "p"} hx/alpine-mount-then-appear) (fc/with-field :db/id (com/hidden {:name (fc/field-name) :value (fc/field-value)})) (fc/with-field :account (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} (account-typeahead* {:value (fc/field-value) :client-id client-id :name (fc/field-name) :x-model "accountId"})))) (fc/with-field :location (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors) :x-hx-val:account-id "accountId" :hx-vals (hx/json (cond-> {:name (fc/field-name) } client-id (assoc :client-id client-id))) :x-dispatch:changed "accountId" :hx-trigger "changed" :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :hx-target "find *" :hx-swap "outerHTML"} (location-select* {:name (fc/field-name) :account-location (:account/location (cond->> (:account @value) (nat-int? (:account @value)) (dc/pull (dc/db conn) '[:account/location]))) :client-locations (pull-attr (dc/db conn) :client/locations client-id) :value (fc/field-value)})))) (fc/with-field :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 initial-bulk-edit-state [request] (mm/->MultiStepFormState {:search-params (:query-params request) :accounts []} [] {:search-params (:query-params request) :accounts []})) (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))) (def bulk-code-schema (mc/schema [:map [:vendor {:optional true} [:maybe entity-id]] [:approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")] ] [:accounts {:optional true} [:maybe [:vector {:coerce? true} [:map [:account entity-id] [:location [:string {:min 1 :error/message "required"}]] [:percentage percentage]]]] ]])) (defn maybe-code-accounts [transaction account-rules valid-locations] (with-precision 2 (let [accounts (vec (mapcat (fn [ar] (let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar) (:transaction/amount transaction) 100))))] (if (= "Shared" (:location ar)) (->> valid-locations (map (fn [cents location] {:db/id (random-tempid) :transaction-account/account (:account ar) :transaction-account/amount (* 0.01 cents) :transaction-account/location location}) (rm/spread-cents cents-to-distribute (count valid-locations)))) [(cond-> {:db/id (random-tempid) :transaction-account/account (:account ar) :transaction-account/amount (* 0.01 cents-to-distribute)} (:location ar) (assoc :transaction-account/location (:location ar)))]))) account-rules)) accounts (mapv (fn [a] (update a :transaction-account/amount #(with-precision 2 (double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP))))) accounts) leftover (with-precision 2 (.round (bigdec (- (Math/abs (:transaction/amount transaction)) (Math/abs (reduce + 0.0 (map #(:transaction-account/amount %) accounts))))) *math-context*)) accounts (if (seq accounts) (update-in accounts [(dec (count accounts)) :transaction-account/amount] #(+ % (double leftover))) [])] accounts))) (defrecord AccountsStep [linear-wizard] mm/ModalWizardStep (step-name [_] "Bulk Code") (step-key [_] :accounts) (edit-path [_ _] []) (step-schema [_] (mm/form-schema linear-wizard)) (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] (let [_ (alog/peek ::SEARCH_PARAMS (:search-params snapshot)) selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot)) all-ids (all-ids-not-locked selected-ids)] (mm/default-render-step linear-wizard this :head [:div.p-2 "Bulk editing " (count all-ids) " transactions"] :body (mm/default-step-body {} [:div #_(com/hidden {:name "ids" :value (pr-str ids)}) [:div.space-y-4.p-4 [:div.grid.grid-cols-2.gap-4 ;; Vendor field [:div (fc/with-field :vendor (com/validated-field {:label "Vendor" :errors (fc/field-errors)} (com/typeahead {:name (fc/field-name) :placeholder "Search for vendor..." :url (bidi/path-for ssr-routes/only-routes :vendor-search) :content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})))] ;; Status field [:div (fc/with-field :approval-status (com/validated-field {:label "Status" :errors (fc/field-errors)} (com/select {:name (fc/field-name) :options [["" "No Change"] ["approved" "Approved"] ["unapproved" "Unapproved"] ["suppressed" "Suppressed"] ["requires_feedback" "Requires Feedback"]]})))] ;; Accounts section [:div.col-span-2.pt-4 [:h3.text-lg.font-medium.mb-3 "Expense Accounts"] [:div.space-y-3#account-entries (fc/with-field :accounts (com/validated-field {:errors (fc/field-errors)} (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-account-row* {:value %})) (com/data-grid-new-row {:colspan 4 :hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code-new-account) :row-offset 0 :index (count (fc/field-value))} "New account") ))) ;; Button to add more accounts ]]]]]) :footer (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save")) :validation-route ::route/new-wizard-navigate)))) (defn assert-percentages-add-up [{:keys [accounts]}] (let [account-total (reduce + 0 (map (fn [x] (:percentage x)) accounts))] (when-not (dollars= 1.0 account-total) (form-validation-error (str "Expense account total (" account-total ") does not equal 100%"))))) (defrecord BulkCodeWizard [_ current-step] mm/LinearModalWizard (hydrate-from-request [this request] this) (navigate [this step-key] (assoc this :current-step step-key)) (get-current-step [this] (if current-step (mm/get-step this current-step) (mm/get-step this :accounts))) (render-wizard [this {:keys [multi-form-state] :as request}] (mm/default-render-wizard this request :form-params (-> mm/default-form-props (assoc :hx-put (str (bidi/path-for ssr-routes/only-routes ::route/bulk-code-submit)))) :render-timeline? false)) (steps [_] [:accounts]) (get-step [this step-key] (let [step-key-result (mc/parse mm/step-key-schema step-key) [step-key-type step-key] step-key-result] (get {:accounts (->AccountsStep this)} step-key))) (form-schema [_] bulk-code-schema) (submit [this {:keys [multi-form-state request-method identity] :as request}] (let [ ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state))) all-ids (all-ids-not-locked ids) vendor (-> request :multi-form-state :snapshot :vendor) approval-status (-> request :multi-form-state :snapshot :approval-status) accounts (-> request :multi-form-state :snapshot :accounts) ] (when (seq accounts) (assert-percentages-add-up (:snapshot multi-form-state))) (alog/peek ::ACCOUNTS (-> request :multi-form-state :snapshot)) ;; Get transactions and filter for locked ones (let [db (dc/db conn) transactions (pull-many db [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids)) ;; Get client locations client->locations (->> (map (comp :db/id :transaction/client) transactions) (distinct) (dc/q '[:find (pull ?e [:db/id :client/locations]) :in $ [?e ...]] db) (map (fn [[client]] [(:db/id client) (:client/locations client)])) (into {}))] (audit-transact-batch (map (fn [t] (let [locations (client->locations (-> t :transaction/client :db/id))] [:upsert-transaction (cond-> t approval-status (assoc :transaction/approval-status approval-status) vendor (assoc :transaction/vendor vendor) (seq accounts) (assoc :transaction/accounts (maybe-code-accounts t accounts locations)))])) transactions) (:identity request)) ;; Return success modal (html-response (com/success-modal {:title "Transactions Coded"} [:p (str "Successfully coded " (count all-ids) " transactions.")]) :headers {"hx-trigger" "refreshTable"}))))) (def bulk-code-wizard (->BulkCodeWizard nil nil)) (def key->handler (apply-middleware-to-all-handlers {::route/bulk-code (-> mm/open-wizard-handler (mm/wrap-wizard bulk-code-wizard) (mm/wrap-init-multi-form-state initial-bulk-edit-state)) ::route/bulk-code-new-account (-> (add-new-entity-handler [:step-params :accounts] (fn render [cursor request] (transaction-account-row* {:value cursor})) (fn build-new-row [base _] (assoc base :location "Shared"))) (wrap-schema-enforce :query-schema [:map [:client-id {:optional true} [:maybe entity-id]]])) ::route/bulk-code-submit (-> mm/submit-handler (wrap-wizard bulk-code-wizard) (mm/wrap-decode-multi-form-state))} (fn [h] (-> h (wrap-copy-qp-pqp) (wrap-ensure-bank-account-belongs) (wrap-status-from-source) (wrap-apply-sort grid-page) (wrap-merge-prior-hx) (wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :hx-schema query-schema) (wrap-must {:activity :bulk-code :subject :transaction}) (wrap-client-redirect-unauthenticated)))))