Improvements for transaction page

This commit is contained in:
2025-03-22 23:21:21 -07:00
parent f3ca8afcc9
commit 0bae8f3d1b
8 changed files with 158 additions and 101 deletions

View File

@@ -25,6 +25,5 @@
(alog/info ::closed :count (count invoices-to-close))))
(defn -main [& _]
(execute "close-auto-invoices" close-auto-invoices))

View File

@@ -135,7 +135,7 @@
:fetch-page fetch-page
:page-specific-nav filters
:row-buttons (fn [_ entity]
[(com/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/all-page)
[(com/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/page)
{:import-batch-id (:db/id entity)})
:hx-boost true}
svg/external-link)])

View File

@@ -57,7 +57,7 @@
(atime/unparse-local atime/standard-time)
(#(str "Synced " %)))]
(when-let [n (cond (-> b :bank-account/intuit-bank-account)
#_(when-let [n (cond (-> b :bank-account/intuit-bank-account)
"Intuit"
(-> b :bank-account/yodlee-account)
"Yodlee"

View File

@@ -171,6 +171,10 @@
(merge-query {:query {:in ['?vendor-id]
:where ['[?e :transaction/vendor ?vendor-id]]}
:args [(:db/id (:vendor 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)]})
(:status route-params)
(merge-query {:query {:in ['?status]
@@ -251,6 +255,7 @@
[: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]]
[: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]}]]]

View File

@@ -10,6 +10,7 @@
exception->4xx]]
[auto-ap.import.transactions :as i-transactions]
[auto-ap.logging :as alog]
[auto-ap.permissions :refer [wrap-must]]
[auto-ap.routes.payments :as payment-route]
[auto-ap.routes.transactions :as route]
[auto-ap.routes.utils
@@ -26,11 +27,12 @@
[auto-ap.ssr.utils
:refer [->db-id apply-middleware-to-all-handlers check-allowance
check-location-belongs entity-id form-validation-error
html-response modal-response ref->enum-schema strip wrap-entity
wrap-schema-enforce]]
html-response modal-response ref->enum-schema strip temp-id
wrap-entity wrap-schema-enforce]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clojure.edn :as edn]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars=]]
@@ -72,7 +74,7 @@
[:vector {:coerce? true}
[:and
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:db/id {:optional true} [:maybe [:or temp-id entity-id]]]
[:transaction-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/default-allowance)]]]
@@ -90,10 +92,11 @@
[:transaction-id entity-id]]]
[:link-unpaid-invoices [:map
[:unpaid-invoice-ids [:vector {:coerce? true} entity-id]]]]
[:unpaid-invoice-ids {:decode/string (fn [x] (edn/read-string x))}
[:vector {:coerce? true} entity-id]]]]
[:link-autopay-invoices [:map
[:autopay-invoice-ids [:vector {:coerce? true} entity-id]]]]
[:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]]
[:link-payment [:map
[:payment-id entity-id]]]
[:manual [:map
@@ -378,9 +381,6 @@
;; Editable fields section
[:div.mt-6
[:h3.text-lg.font-semibold.mb-4 "Editable Fields"]]
;; Vendor field
)
:footer
@@ -416,7 +416,6 @@
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (-> request :entity :transaction/client :db/id)
_ (println "TRANSACTION" client-id)
matches-set (when (and tx client-id)
(i-transactions/match-transaction-to-unfulfilled-autopayments
(:transaction/amount tx)
@@ -432,28 +431,20 @@
(if (seq invoice-matches)
[:div
[:h3.text-lg.font-bold.mb-4 "Available Autopay Invoices"]
[:div {:hx-post (bidi/path-for ssr-routes/only-routes ::route/link-autopay-invoices)
:hx-include "this"
:hx-trigger "linkAutopayInvoices"
:hx-target "#modal-holder"
:hx-swap "outerHTML"}
(com/hidden {:name "action"
:value "link-autopay-invoices"})
(com/hidden {:name "transaction-id"
:value (get-in request [:multi-form-state :snapshot :db/id])
:form ""})
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select an autopay invoice to apply:"]
(doall (for [match-group invoice-matches]
(doall (for [invoice match-group]
[:div.flex.items-center
[:input {:type :radio :value (:db/id invoice) :name "autopay-invoice-ids"}]
[:div.ml-3
[:span.block.text-sm.font-medium (:invoice/invoice-number invoice)]
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/total invoice))]]]))))]
(com/a-button {"@click" "$dispatch('linkAutopayInvoices')"} "Match")
]]
(com/hidden {:name "action"
:value "link-autopay-invoices"})
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select an autopay invoice to apply:"]
(com/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group))
:content (doall (for [invoice match-group]
[:div.ml-3
[:span.block.text-sm.font-medium (:invoice/invoice-number invoice)]
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))})
:name (fc/with-field :autopay-invoice-ids (fc/field-name ))
:width "w-full"})]
]
[:div.text-center.py-4.text-gray-500 "No matching autopay invoices available for this transaction."])]))
(defn get-available-unpaid-invoices [request]
@@ -477,30 +468,31 @@
(if (seq invoice-matches)
[:div
[:h3.text-lg.font-bold.mb-4 "Available Unpaid Invoices"]
[:div {:hx-post (bidi/path-for ssr-routes/only-routes ::route/link-unpaid-invoices)
[:div #_{:hx-post (bidi/path-for ssr-routes/only-routes ::route/link-unpaid-invoices)
:hx-include "this"
:hx-params "transaction-id, action, unpaid-invoice-ids"
:hx-trigger "linkUnpaidInvoices"
:hx-target "#modal-holder"
:hx-swap "outerHTML"}
(com/hidden {:name "action"
:hx-trigger "linkUnpaidInvoices"
:hx-target "#modal-holder"
:hx-swap "outerHTML"}
(com/hidden {:name "action"
:value "link-unpaid-invoices"
:form ""})
(com/hidden {:name "transaction-id"
#_(com/hidden {:name "transaction-id"
:value (get-in request [:multi-form-state :snapshot :db/id])
:form ""})
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select an unpaid invoice to apply:"]
(doall (for [match-group invoice-matches]
(doall (for [invoice match-group]
[:div.flex.items-center
[:input {:type :radio :value (:db/id invoice) :name "unpaid-invoice-ids"}]
[:div.ml-3
[:span.block.text-sm.font-medium (:invoice/invoice-number invoice)]
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]]))))]
(com/a-button {:color :primary "@click" "$dispatch('linkUnpaidInvoices')"} "Link")
]]
(com/radio-card {:options (for [match-group invoice-matches]
{:value (pr-str (map :db/id match-group))
:content (doall (for [invoice match-group]
[:div.ml-3
[:span.block.text-sm.font-medium (:invoice/invoice-number invoice)]
[:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)]
[:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))})
:name (fc/with-field :unpaid-invoice-ids (fc/field-name ))
:width "w-full"})
]
#_(com/a-button {:color :primary "@click" "$dispatch('linkUnpaidInvoices')"} "Link")]]
[:div.text-center.py-4.text-gray-500 "No matching unpaid invoices available for this transaction."])]))
(defn get-available-rules [request]
@@ -536,24 +528,20 @@
(if (seq matching-rules)
[:div
[:h3.text-lg.font-bold.mb-4 "Matching Transaction Rules"]
[:div {:hx-post (bidi/path-for ssr-routes/only-routes ::route/apply-rule)
:hx-trigger "applyRule"
:hx-include "this"
:hx-target "#modal-holder"
:hx-swap "outerHTML"}
(fc/with-field :action
(com/hidden {:name (fc/field-name)
:value "apply-rule"
:form ""}))
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select a rule to apply:"]
(doall (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules]
[:div.flex.items-center
[:input {:type :radio :value id :name (fc/with-field :rule-id (fc/field-name))}]
[:div.ml-3
[:span.block.text-sm.font-medium note]
[:span.block.text-sm.text-gray-500 description]]]))]
(com/a-button {"@click" "$dispatch('applyRule')"} "Apply")]]
(fc/with-field :action
(com/hidden {:name (fc/field-name)
:value "apply-rule"
:form ""}))
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select a rule to apply:"]
(com/radio-card {:options (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules]
{:value id
:content [:div.ml-3
[:span.block.text-sm.font-medium note]
[:span.block.text-sm.text-gray-500 description]]})
:name (fc/with-field :rule-id (fc/field-name ))
:width "w-full"}) ]
#_(com/a-button {"@click" "$dispatch('applyRule')"} "Apply")]
[:div.text-center.py-4.text-gray-500 "No matching rules found for this transaction."])]))
(defn payment-matches-view [request]
@@ -619,8 +607,7 @@
" - Amount: $" (format "%.2f" (:payment/amount payment))
" • Date: " (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date)))})
:name payment-id-field
:width "w-full"})))
(com/a-button {"@click" "$dispatch('matchPayment')"} "Match" #_[:button.mt-4.w-full.py-2.bg-blue-500.text-white.rounded.hover:bg-blue-600 "Match"])]]
:width "w-full"}))) ]]
[:div.text-center.py-4.text-gray-500 "No matching payments available for this transaction."]))]))
(defn count-payment-matches [request]
@@ -667,7 +654,8 @@
:placeholder "Optional note"})]))
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
"link-payment"
(fc/with-field :action (fc/field-value)))
(or (fc/with-field :action (fc/field-value))
"manual"))
:canChange (boolean (not (:transaction/payment (:entity request))))})
"@unlinked" "canChange=true"}
[:div {:class "flex space-x-2 mb-4"}
@@ -863,7 +851,7 @@
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-autopay-invoices
[{{:keys [autopay-invoice-ids]} :form-params :as request transaction :entity}]
[{{ {:keys [autopay-invoice-ids] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity} ]
(let [db (dc/db conn)
invoice-clients (set (map #(pull-ref db :invoice/client %) autopay-invoice-ids))
invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/total %) autopay-invoice-ids))]
@@ -894,7 +882,8 @@
autopay-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact payment-tx (:identity request)))
(audit-transact (conj payment-tx
[:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
@@ -905,7 +894,7 @@
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-unpaid-invoices
[{{:keys [unpaid-invoice-ids]} :form-params :as request transaction :entity}]
[{{ {:keys [unpaid-invoice-ids] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity} ]
(let [ db (dc/db conn)
invoice-clients (set (map #(pull-ref db :invoice/client %) unpaid-invoice-ids))
invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/outstanding-balance %) unpaid-invoice-ids))]
@@ -938,7 +927,8 @@
unpaid-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact payment-tx (:identity request)))
(audit-transact (conj payment-tx
[:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
@@ -958,7 +948,7 @@
(defmethod save-handler
:apply-rule
[{{{:keys [rule-id]} :snapshot} :multi-form-state :as request transaction :entity}]
[{{{:keys [rule-id] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [transaction-rule (dc/pull (dc/db conn)
[:transaction-rule/description
:transaction-rule/vendor
@@ -982,7 +972,8 @@
updated-tx (rm/apply-rule {:db/id (:db/id transaction)
:transaction/amount (:transaction/amount transaction)}
transaction-rule
locations)]
locations)
updated-tx (default-update-tx snapshot updated-tx)]
(alog/info ::applying-rule-tx :tx-data updated-tx
:transaction transaction
:transaction-rule transaction-rule)
@@ -996,16 +987,73 @@
[:p.text-gray-600.mt-2 "The selected rule has been applied to this transaction."])
:headers {"hx-trigger" "invalidated"})))
(defn- calculate-spread
"Helper function to calculate the amount to be assigned to each location"
[shared-amount total-locations]
(let [base-amount (int (/ shared-amount total-locations))
remainder (- shared-amount (* base-amount total-locations))]
{:base-amount base-amount
:remainder remainder}))
(defn- spread-account
"Spreads the expense account amount across the given locations"
[locations account]
(if (= "Shared" (:transaction-account/location account))
(let [{:keys [base-amount remainder]} (calculate-spread (:transaction-account/amount account) (count locations))]
(map-indexed (fn [idx _]
(assoc account
:transaction-account/amount (+ base-amount (if (< idx remainder) 1 0))
:transaction-account/location (nth locations idx)))
locations))
[account]))
(defn- apply-total-delta-to-account [invoice-total eas]
(when (seq eas)
(let [leftover (- invoice-total (reduce + 0 (map :transaction-account/amount eas)))
leftover-beyond-a-single-cent? (or (< leftover -1)
(> leftover 1))
leftover (if leftover-beyond-a-single-cent?
0
leftover)
[first-eas & rest] eas]
(cons
(update first-eas :transaction-account/amount #(+ % leftover))
rest))))
(defn $->cents [x]
(int
(let [result (* 100M (bigdec x))]
(.setScale result 0 java.math.BigDecimal/ROUND_HALF_UP))))
(defn cents->$ [x]
(double
(let [result (* 0.01M (bigdec x))]
(.setScale result 2 java.math.BigDecimal/ROUND_HALF_UP))))
(defn maybe-spread-locations
"Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client"
([transaction]
(maybe-spread-locations transaction (pull-attr (dc/db conn) :client/locations (:transaction/client transaction))))
([transaction locations]
(clojure.pprint/pprint transaction)
(update-in transaction
[:transaction/accounts]
(fn [accounts]
(->> accounts
(map (fn [ea] (update ea :transaction-account/amount $->cents)))
(mapcat (partial spread-account locations))
(apply-total-delta-to-account ($->cents (:transaction/amount transaction)))
(map (fn [ea] (update ea :transaction-account/amount cents->$))))))))
(defmethod save-handler :manual
[{:as request
transaction :entity
:keys [multi-form-state]}]
(let [tx-data (-> multi-form-state :snapshot (dissoc :action))
_ (clojure.pprint/pprint tx-data)
tx-id (:db/id tx-data)
client-id (->db-id (:transaction/client tx-data))
existing-tx (d-transactions/get-by-id tx-id)
transaction [:upsert-transaction (assoc tx-data :db/id tx-id)]]
transaction [:upsert-transaction (maybe-spread-locations (assoc tx-data :db/id tx-id))]]
(alog/info ::transaction transaction :entity transaction)
(exception->4xx #(assert-can-see-client (:identity request) client-id))
@@ -1102,10 +1150,6 @@
(html-response (fc/with-field :step-params (payment-matches-view request))
:headers {"hx-trigger" "unlinked"}))))
#_(def save-schema
(mc/schema
))
(defrecord EditWizard [_ current-step]
mm/LinearModalWizard
@@ -1120,6 +1164,7 @@
(mm/get-step this :basic-details)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(println "HERE XYZ" (:form-errors request))
(clojure.pprint/pprint (:snapshot multi-form-state) )
(mm/default-render-wizard
this request
:form-params
@@ -1168,15 +1213,18 @@
(def key->handler
(apply-middleware-to-all-handlers
{::route/edit-wizard (-> mm/open-wizard-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
(mm/wrap-wizard edit-wizard)
(mm/wrap-init-multi-form-state initial-edit-wizard-state)
(wrap-entity [:route-params :db/id] d-transactions/default-read)
(wrap-schema-enforce :route-schema [:map [:db/id entity-id]]))
::route/edit-wizard-navigate (-> mm/next-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-submit (-> mm/submit-handler
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
@@ -1204,13 +1252,14 @@
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/unlink-payment (-> unlink-payment
(wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client)))
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)
#_(wrap-schema-enforce :form-schema
save-schema))}
save-schema))}
(fn [h]
(-> h
(wrap-client-redirect-unauthenticated)))))