Files
integreat/src/clj/auto_ap/ssr/transaction/edit.clj
2025-03-15 21:55:41 -07:00

1220 lines
65 KiB
Clojure

(ns auto-ap.ssr.transaction.edit
(:require
[auto-ap.datomic
:refer [audit-transact conn pull-attr pull-ref]]
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.datomic.checks :as d-checks]
[auto-ap.datomic.invoices :as d-invoices]
[auto-ap.datomic.transactions :as d-transactions]
[auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked
exception->4xx]]
[auto-ap.import.transactions :as i-transactions]
[auto-ap.logging :as alog]
[auto-ap.routes.payments :as payment-route]
[auto-ap.routes.transactions :as route]
[auto-ap.routes.utils
:refer [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.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.svg :as svg]
[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]]
[auto-ap.time :as atime]
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[datomic.api :as dc]
[hiccup.util :as hu]
[iol-ion.query :refer [dollars=]]
[iol-ion.tx :refer [random-tempid]]
[malli.core :as mc]))
(def transaction-approval-status
{:transaction-approval-status/unapproved "Unapproved"
:transaction-approval-status/approved "Approved"
:transaction-approval-status/suppressed "Suppressed"})
(defn get-vendor [vendor-id]
(dc/pull
(dc/db conn)
[:vendor/terms
:vendor/automatically-paid-when-due
{:vendor/default-account d-accounts/default-read
:vendor/account-overrides
[:vendor-account-override/client
{:vendor-account-override/account d-accounts/default-read}]}
{:vendor/terms-overrides
[:vendor-terms-override/client :vendor-terms-override/terms]}]
vendor-id))
(defn check-vendor-default-account [vendor-id]
(some? (:vendor/default-account (get-vendor vendor-id))))
(def edit-form-schema
(mc/schema
[:and
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:action [:enum :apply-rule :unlink-payment :link-unpaid-invoices :link-autopay-invoices :link-payment :manual]]
[:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]]
[:transaction/vendor {:optional true} [:maybe entity-id]]
[:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]]
[:transaction/accounts {:optional true}
[:maybe
[:vector {:coerce? true}
[:and
[:map
[:db/id {:optional true} [:maybe entity-id]]
[:transaction-account/account [:and entity-id
[:fn {:error/message "Not an allowed account."}
#(check-allowance % :account/default-allowance)]]]
[:transaction-account/location :string]
[:transaction-account/amount :double]]
[:fn {:error/fn (fn [r x] (:type r))
:error/path [:transaction-account/location]}
(fn [iea]
(check-location-belongs (:transaction-account/location iea)
(:transaction-account/account iea)))]]]]]]
[:multi {:dispatch :action}
[:apply-rule [:map
[:rule-id {:optional true} [:maybe entity-id]]]]
[:unlink-payment [:map
[:transaction-id entity-id]]]
[:link-unpaid-invoices [:map
[:unpaid-invoice-ids [:vector {:coerce? true} entity-id]]]]
[:link-autopay-invoices [:map
[:autopay-invoice-ids [:vector {:coerce? true} entity-id]]]]
[:link-payment [:map
[:payment-id entity-id]]]
[:manual [:map
]]]]))
(defn clientize-vendor [{:vendor/keys [terms-overrides automatically-paid-when-due default-account account-overrides] :as vendor} client-id]
(if (nil? vendor)
nil
(let [terms-override (->> terms-overrides
(filter (fn [to]
(= (->db-id (:vendor-terms-override/client to))
client-id)))
(map :vendor-terms-override/terms)
first)
account (or (->> account-overrides
(filter (fn [to]
(= (->db-id (:vendor-account-override/client to))
client-id)))
(map :vendor-account-override/account)
first)
default-account)
account (d-accounts/clientize account client-id)
automatically-paid-when-due (->> automatically-paid-when-due
(filter (fn [to]
(= (->db-id to)
client-id)))
seq
boolean)
vendor (cond-> vendor
terms-override (assoc :vendor/terms terms-override)
true (assoc :vendor/automatically-paid-when-due automatically-paid-when-due
:vendor/default-account account)
true (dissoc :vendor/account-overrides :vendor/terms-overrides))]
vendor)))
(defn location-select*
[{:keys [name account-location client-locations value]}]
(let [options (into (cond account-location
[[account-location account-location]]
(seq client-locations)
(into [["Shared" "Shared"]]
(for [cl client-locations]
[cl cl]))
:else
[["Shared" "Shared"]]))]
(com/select {:options options
:name name
:value (ffirst options)
:class "w-full"})))
(defn account-typeahead*
[{:keys [name value client-id x-model]}]
[:div.flex.flex-col
(com/typeahead {:name name
:placeholder "Search..."
:url (hu/url (bidi/path-for ssr-routes/only-routes :account-search)
{:client-id client-id
:purpose "invoice"})
: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-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 (:transaction-account/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 :transaction-account/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 :transaction-account/location
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)
:x-hx-val:account-id "accountId"
:hx-vals (hx/json {:name (fc/field-name)
: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->> (:transaction-account/account @value)
(nat-int? (:transaction-account/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 :transaction-account/amount
(com/data-grid-cell
{}
(com/validated-field
{:errors (fc/field-errors)}
(com/money-input {:name (fc/field-name)
:class "w-16 amount-field"
:value (fc/field-value)}))))
(com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))
(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-total* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:transaction/accounts)
(map (fnil :transaction-account/amount 0.0))
(filter number?)
(reduce + 0.0))]
(format "$%,.2f" total)))
(defn account-balance* [request]
(let [total (->> (-> request
:multi-form-state
:step-params
:transaction/accounts)
(map (fnil :transaction-account/amount 0.0))
(filter number?)
(reduce + 0.0))
balance (-
(Math/abs (-> request :multi-form-state :snapshot :transaction/amount))
total)]
[:span {:class (when-not (dollars= 0.0 balance)
"text-red-300")}
(format "$%,.2f" balance)]))
(defn account-total [request]
(html-response (account-total* request)))
(defn account-balance [request]
(html-response (account-balance* request)))
(defn require-approval [s]
[:and s
[:fn (fn [{:transaction/keys [accounts approval-status]}]
(or (not= approval-status :approved)
(seq accounts)))]])
(defrecord BasicDetailsStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Transaction Details")
(step-key [_]
:basic-details)
(edit-path [_ _]
[])
(step-schema [_]
(mc/schema [:map
[:db/id {:optional true} [:maybe entity-id]]]))
(render-step
[this {:keys [multi-form-state] :as request}]
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
tx (d-transactions/get-by-id tx-id)]
(alog/info ::TRANSACTION :i multi-form-state)
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit transaction"]
:body (mm/default-step-body
{}
[:div {:x-data (hx/json {:clientId (or (fc/field-value (:transaction/client fc/*current*))
(:db/id (:client request))) })}
;; Read-only transaction details
[:div.mb-6.border.rounded-lg.p-4.bg-gray-50
[:h3.text-lg.font-semibold.mb-2 "Transaction Details"]
[:div.grid.grid-cols-2.gap-4
[:div
[:div.text-sm.font-medium.text-gray-500 "Amount"]
[:div.text-base (format "$%,.2f" (Math/abs (:transaction/amount tx)))]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Date"]
[:div.text-base (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Bank Account"]
[:div.text-base (or (-> tx :transaction/bank-account :bank-account/name) "-")]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Post Date"]
[:div.text-base (some-> tx :transaction/post-date coerce/to-date-time (atime/unparse-local atime/normal-date))]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Original Description"]
[:div.text-base (or (:transaction/description-original tx) "-")]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Simplified Description"]
[:div.text-base (or (:transaction/description-simple tx) "-")]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Check Number"]
[:div.text-base (or (:transaction/check-number tx) "-")]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Status"]
[:div.text-base (or (some-> tx :transaction/status) "-")]]
[:div
[:div.text-sm.font-medium.text-gray-500 "Transaction Type"]
[:div.text-base (or (some-> tx :transaction/type) "-")]]]]
;; Transaction Links Section
#_[:div.mb-6.border.rounded-lg.p-4.bg-gray-50
[:h3.text-lg.font-semibold.mb-2 "Transaction Links"]
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
db-history (dc/history (dc/db conn))
;; Get current and historical payments linked to this transaction
current-payment (when-let [payment-id (-> (dc/pull (dc/db conn)
'[:transaction/payment]
tx-id)
:transaction/payment
:db/id)]
{:entity-id payment-id :active true})
historical-payments (when tx-id
(->> (dc/q '[:find ?payment ?inst ?added
:in $ ?e
:where
[?e :transaction/payment ?payment ?tx ?added]
[?tx :db/txInstant ?inst]]
db-history tx-id)
(map (fn [[id date op]] {:entity-id id
:date date
:op op}))))
all-payments historical-payments]
[:div
;; Payments section
[:div.mb-3
[:h4.font-medium.text-gray-700 "Linked Payments:"]
(if (seq all-payments)
[:ul.list-disc.pl-6.mt-1
(for [{:keys [entity-id date op]} all-payments
:let [payment (dc/pull (dc/db conn)
'[:db/id :payment/invoice-number
[ :payment/date :xform clj-time.coerce/from-date]
{:payment/vendor [:vendor/name]}]
entity-id)]]
[:li.text-sm.text-gray-600 {:class (when-not op "line-through")}
(if op [:span.text-green-600 "✓ "] "")
(str "Payment #" (:payment/invoice-number payment) " - "
(-> payment :payment/vendor :vendor/name))])]
[:p.text-sm.text-gray-500.italic "No payments linked to this transaction"])]])]]
;; Invoices section
;; Hidden ID field
(fc/with-field :db/id
(com/hidden {:name (fc/field-name)
:value (fc/field-value)}))
;; Editable fields section
[:div.mt-6
[:h3.text-lg.font-semibold.mb-4 "Editable Fields"]]
;; Vendor field
)
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate)
:validation-route ::route/edit-wizard-navigate)))
mm/Initializable
(init-step-params
[_ current request]
(:step-params current)))
(defn get-available-payments [request]
(let [tx-id (or (get-in request [:form-params :transaction-id])
(-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (-> tx :transaction/client :db/id)
payments (when client-id
(dc/q '[:find [(pull ?p [:db/id :payment/invoice-number :payment/amount :payment/date
{:payment/vendor [:db/id :vendor/name]}]) ...]
:in $ ?client
:where
[?p :payment/client ?client]
[?p :payment/status :payment-status/pending]]
(dc/db conn)
client-id))]
(filter #(dollars= (Math/abs (:transaction/amount tx)) (:payment/amount %)) payments)))
(defn get-available-autopay-invoices [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(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)
client-id))]
(when matches-set
(for [matches matches-set]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn autopay-invoices-view [request]
(let [invoice-matches (get-available-autopay-invoices request)]
[:div
(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")
]]
[:div.text-center.py-4.text-gray-500 "No matching autopay invoices available for this transaction."])]))
(defn get-available-unpaid-invoices [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
client-id (or (get-in request [:multi-form-state :snapshot :transaction/client])
(get-in request [:client :db/id]))
matches-set (when (and tx client-id)
(i-transactions/match-transaction-to-unpaid-invoices
(:transaction/amount tx)
client-id))]
(when matches-set
(for [matches matches-set]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn unpaid-invoices-view [request]
(let [invoice-matches (get-available-unpaid-invoices request)]
[:div
(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)
: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"
:value "link-unpaid-invoices"
:form ""})
(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")
]]
[:div.text-center.py-4.text-gray-500 "No matching unpaid invoices available for this transaction."])]))
(defn get-available-rules [request]
(let [tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
patterns (dc/q '[:find (pull ?r
[:db/id
:transaction-rule/description
:transaction-rule/note
:transaction-rule/client-group
:transaction-rule/dom-gte :transaction-rule/dom-lte
:transaction-rule/amount-gte :transaction-rule/amount-lte
:transaction-rule/client :transaction-rule/bank-account
:transaction-rule/yodlee-merchant])
:where
[?r :transaction-rule/description]]
(dc/db conn))]
(when tx
(->> patterns
(map first)
(filter (fn [rule]
(rm/rule-applies? (-> tx
(update :transaction/date coerce/to-date))
(-> rule
(update :transaction-rule/description #(some-> % iol-ion.query/->pattern))))))))))
(defn transaction-rules-view [request]
(let [matching-rules (get-available-rules request)]
[:div
(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")]]
[:div.text-center.py-4.text-gray-500 "No matching rules found for this transaction."])]))
(defn payment-matches-view [request]
(let [payments (get-available-payments request)
tx-id (or (-> request :multi-form-state :snapshot :db/id)
(get-in request [:route-params :db/id]))
tx (when tx-id (d-transactions/get-by-id tx-id))
payment (dc/pull
(dc/db conn)
'[:payment/amount
:db/id
[:payment/date :xform clj-time.coerce/from-date]
{ [ :payment/status :xform iol-ion.query/ident] [:db/ident]
:payment/vendor [:vendor/name]}]
(-> tx :transaction/payment :db/id))]
[:div#payment-matches
(if (and payment (:db/id payment))
[:div.my-4.p-4.bg-blue-50.rounded
[:h3.text-lg.font-bold.mb-2 "Linked Payment"
(com/a-icon-button {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id (:db/id payment)})}
svg/external-link)]
[:div.space-y-2
[:div.flex.justify-between
[:div.font-medium "Payment #"]
[:div (:payment/invoice-number payment)]]
[:div.flex.justify-between
[:div.font-medium "Vendor"]
[:div (-> payment :payment/vendor :vendor/name)]]
[:div.flex.justify-between
[:div.font-medium "Amount"]
[:div (some->> (:payment/amount payment) (format "$%.2f"))]]
[:div.flex.justify-between
[:div.font-medium "Status"]
[:div (some-> payment :payment/status name)]]
[:div.flex.justify-between
[:div.font-medium "Date"]
[:div (some-> payment :payment/date (atime/unparse-local atime/normal-date))]]
(fc/with-field :payment-id (com/hidden {:name (fc/field-name)
:value (:db/id payment)}))
[:div.mt-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment)
:hx-trigger "unlinkPayment"
:hx-target "#payment-matches"
:hx-include "this"
:hx-swap "outerHTML"
:hx-confirm "Are you sure you want to unlink this payment?"}
(com/a-button {:color :red :size :small
"@click" "$dispatch('unlinkPayment')"} "Unlink Payment")]]]
(if (seq payments)
[:div
[:h3.text-lg.font-bold.mb-4 "Available Payments"]
[:div.space-y-2
[:label.block.text-sm.font-medium.mb-1 "Select a payment to match:"]
(when payments
(let [payment-id-field (fc/with-field :payment-id (fc/field-name ))]
(com/radio-card {:options (for [payment payments]
{:value (:db/id payment)
:content (str (:payment/invoice-number payment) " - "
(-> payment :payment/vendor :vendor/name)
" - 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"])]]
[:div.text-center.py-4.text-gray-500 "No matching payments available for this transaction."]))]))
(defn count-payment-matches [request]
(count (get-available-payments request)))
(defn count-autopay-invoice-matches [request]
(count (get-available-autopay-invoices request)))
(defn count-unpaid-invoice-matches [request]
(count (get-available-unpaid-invoices request)))
(defn count-rule-matches [request]
(count (get-available-rules request)))
(defrecord LinksStep [linear-wizard]
mm/ModalWizardStep
(step-name [_]
"Transaction Actions")
(step-key [_]
:links)
(edit-path [_ _]
[])
(step-schema [_]
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
(mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Transaction Actions"]
:body (mm/default-step-body
{}
[:div
(fc/with-field :transaction/memo
(com/validated-field
{:label "Memo"
:errors (fc/field-errors)}
[:div.w-96
(com/text-input {:value (-> (fc/field-value))
:name (fc/field-name)
:error? (fc/field-errors)
:placeholder "Optional note"})]))
[:div {:x-data (hx/json {:activeForm (if (:transaction/payment (:entity request))
"link-payment"
(fc/with-field :action (fc/field-value)))
:canChange (boolean (not (:transaction/payment (:entity request))))})
"@unlinked" "canChange=true"}
[:div {:class "flex space-x-2 mb-4"}
(fc/with-field :action
(com/hidden {:name (fc/field-name)
:value (fc/field-value)
":value" "activeForm"}))
(com/button-group {:name "method"}
(com/button-group-button {"@click" "activeForm = 'link-payment'" :value "payment" ":class" "{ '!bg-primary-200 text-primary-800': activeForm === 'link-payment'}" :class "relative"}
(let [count (count-payment-matches request)]
(when (> count 0)
(com/badge {:color "green"} (str count))))
"Link to payment")
(com/button-group-button {"@click" "activeForm = 'link-unpaid-invoices'" :value "unpaid" ":class" "{ '!bg-primary-200 text-primary-800': activeForm === 'link-unpaid-invoices'}" :class "relative"
":disabled" "!canChange"}
(let [count (count-unpaid-invoice-matches request)]
(when (> count 0)
(com/badge {:color "green"} (str count))))
"Link to unpaid invoices")
(com/button-group-button {"@click" "activeForm = 'link-autopay-invoices'" :value "autopay" ":class" "{ '!bg-primary-200 text-primary-800': activeForm === 'link-autopay-invoices'}" :class "relative"
":disabled" "!canChange"}
(let [count (count-autopay-invoice-matches request)]
(when (> count 0)
(com/badge {:color "green"} (str count))))
"Link to autopay invoices")
(com/button-group-button {"@click" "activeForm = 'apply-rule'" :value "rule" ":class" "{ '!bg-primary-200 text-primary-800': activeForm === 'apply-rule'}" :class "relative"
":disabled" "!canChange"}
(let [count (count-rule-matches request)]
(when (> count 0)
(com/badge {:color "green"} (str count))))
"Apply rule")
(com/button-group-button {"@click" "activeForm = 'manual'" :value "manual" ":class" "{ '!bg-primary-200 text-primary-800': activeForm === 'manual'}"
":disabled" "!canChange"}
"Manual"))]
[:div {:x-show "activeForm === 'link-payment'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
(payment-matches-view request)]
[:div {:x-show "activeForm === 'link-unpaid-invoices'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
(unpaid-invoices-view request)]
[:div {:x-show "activeForm === 'link-autopay-invoices'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
(autopay-invoices-view request)]
[:div {:x-show "activeForm === 'apply-rule'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
(transaction-rules-view request)]
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
[:div {}
(fc/with-field :transaction/vendor
(com/validated-field
{:label "Vendor"
:errors (fc/field-errors)}
[: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 :vendor-search)
:value (fc/field-value)
:content-fn (fn [c] (pull-attr (dc/db conn) :vendor/name c))})]))
;; Memo field
;; Approval status field
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
:errors (fc/field-errors)}
(com/radio-card {:options (mapv (fn [[k v]] {:value (name k) :content v})
transaction-approval-status)
:value (name (or (fc/field-value) :transaction-approval-status/unapproved))
:name (fc/field-name)})))
(fc/with-field :transaction/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"})]}
(println "WE ARE NOW HERE" (fc/field-value))
(fc/cursor-map #(transaction-account-row* {:value %
:client-id (-> request :entity :transaction/client :db/id)}))
(com/data-grid-new-row {:colspan 4
:hx-get (bidi/path-for ssr-routes/only-routes
::route/edit-wizard-new-account)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}}
"New account")
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-total)
:hx-target "this"
:hx-swap "innerHTML"}
(account-total* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"])
(com/data-grid-cell {:id "total"
:class "text-right"
:hx-trigger "change from:closest form target:.amount-field"
:hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/account-balance)
:hx-target "this"
:hx-swap "innerHTML"}
(account-balance* request))
(com/data-grid-cell {}))
(com/data-grid-row {}
(com/data-grid-cell {})
(com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TRANSACTION TOTAL"])
(com/data-grid-cell {:class "text-right"}
(format "$%,.2f" (Math/abs (:transaction/amount snapshot))))
(com/data-grid-cell {})))))]]]])
:footer
(mm/default-step-footer linear-wizard this :validation-route ::route/edit-wizard-navigate
:next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Done"))
:validation-route ::route/edit-wizard-navigate)) )
(defmulti save-handler (fn [request]
(-> request :multi-form-state :snapshot :action)))
(defn- default-update-tx [snapshot transaction]
(merge {:transaction/memo (:transaction/memo snapshot) }
transaction))
(defn- save-linked-transaction [{{ snapshot :snapshot} :multi-form-state :as request transaction :entity} payment]
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(audit-transact (into
[{:db/id (:db/id payment)
:payment/status :payment-status/cleared
:payment/date (coerce/to-date (first (sort [(:payment/date payment)
(coerce/to-date-time (:transaction/date transaction))])))}
[:upsert-transaction
(default-update-tx
snapshot
{:db/id (:db/id transaction)
:transaction/payment (:db/id payment)
:transaction/vendor (-> payment :payment/vendor :db/id)
:transaction/approval-status :transaction-approval-status/approved
:transaction/accounts [{:db/id (random-tempid)
:transaction-account/account (:db/id (d-accounts/get-account-by-numeric-code-and-sets 21000 ["default"]))
:transaction-account/location "A"
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]})]])
(:identity request)))
(defn- save-memo-only [{{ snapshot :snapshot} :multi-form-state :as request}]
(audit-transact [[:upsert-transaction (default-update-tx snapshot {})]]
(:identity request)))
(defn- is-already-linked-to-this-payment? [transaction payment-id]
(= (pull-attr (dc/db conn)
:transaction/payment
(:db/id transaction))
payment-id))
(defmethod save-handler
:link-payment [{{ {:keys [transaction-id payment-id] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}]
(let [ payment (d-checks/get-by-id payment-id)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-can-see-client (:identity request) (-> payment :payment/client :db/id)))
(when (not= (-> transaction :transaction/client :db/id)
(-> payment :payment/client :db/id))
(form-validation-error "Clients don't match."
:payment-client-id (:payment/client payment)
:transaction-client-id (:transaction/client transaction))
#_(throw (ex-info "Clients don't match" {:validation-error "Payment and client do not match."})))
(when-not (dollars= (- (:transaction/amount transaction))
(:payment/amount payment))
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(if (is-already-linked-to-this-payment? transaction payment-id)
(save-memo-only request)
(save-linked-transaction request payment))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."]
[:p.text-gray-600.mt-2 "To view the new payment, click "
(com/link {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id payment-id})
:hx-boost true}
"here")
" to view it."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-autopay-invoices
[{{:keys [autopay-invoice-ids]} :form-params :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))]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
(when (or (> (count invoice-clients) 1)
(not= (-> transaction :transaction/client :db/id)
(first invoice-clients)))
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."})))
(when-not (dollars= (- (:transaction/amount transaction))
invoice-amount)
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(let [payment-tx (i-transactions/add-new-payment
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
(map (fn [id]
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
[(or (-> entity :invoice/vendor :db/id)
(-> entity :invoice/vendor))
(-> entity :db/id)
(-> entity :invoice/total)]))
autopay-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact payment-tx (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler :link-unpaid-invoices
[{{:keys [unpaid-invoice-ids]} :form-params :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))]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (or (> (count invoice-clients) 1)
(not= (-> transaction :transaction/client :db/id)
(first invoice-clients)))
(throw (ex-info "Clients don't match" {:validation-error "Invoice(s) and transaction client do not match."
:transaction-client (-> transaction :transaction/client :db/id)
:invoice-clients invoice-clients})))
(when-not (dollars= (- (:transaction/amount transaction))
invoice-amount)
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
(let [payment-tx (i-transactions/add-new-payment
(dc/pull db [:transaction/amount :transaction/date :db/id] (:db/id transaction))
(map (fn [id]
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
[(or (-> entity :invoice/vendor :db/id)
(-> entity :invoice/vendor))
(-> entity :db/id)
(-> entity :invoice/total)]))
unpaid-invoice-ids)
(-> transaction :transaction/bank-account :db/id)
(-> transaction :transaction/client :db/id))]
(audit-transact payment-tx (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Transaction linked successfully"}
[:p.text-gray-600.mt-2 "The transaction has been linked to the autopay invoices."]
[:p.text-gray-600.mt-2 "To view the new payment, click "
(com/link {:href (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page)
{:exact-match-id (:db/id (pull-attr (dc/db conn)
:transaction/payment
(:db/id transaction)))})
:hx-boost true}
"here")
" to view it."])
:headers {"hx-trigger" "invalidated"})))
(defmethod save-handler
:apply-rule
[{{{:keys [rule-id]} :snapshot} :multi-form-state :as request transaction :entity}]
(let [transaction-rule (dc/pull (dc/db conn)
[:transaction-rule/description
:transaction-rule/vendor
:transaction-rule/accounts
:transaction-rule/approval-status]
rule-id)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(let [description-pattern (some-> transaction-rule :transaction-rule/description iol-ion.query/->pattern)]
(when (not (rm/rule-applies? transaction {:transaction-rule/description description-pattern}))
(throw (ex-info "Transaction rule does not apply"
{:validation-error "Transaction rule does not apply"}))))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already associated with a payment"
{:validation-error "Transaction already associated with a payment"})))
(let [locations (-> transaction :transaction/client :client/locations)
updated-tx (rm/apply-rule {:db/id (:db/id transaction)
:transaction/amount (:transaction/amount transaction)}
transaction-rule
locations)]
(alog/info ::applying-rule-tx :tx-data updated-tx
:transaction transaction
:transaction-rule transaction-rule)
(audit-transact [[:upsert-transaction updated-tx]] (:identity request)))
(solr/touch-with-ledger (:db/id transaction))
(modal-response
(com/success-modal {:title "Rule applied successfully"}
[:p.text-gray-600.mt-2 "The selected rule has been applied to this transaction."])
:headers {"hx-trigger" "invalidated"})))
(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)]]
(alog/info ::transaction transaction :entity transaction)
(exception->4xx #(assert-can-see-client (:identity request) client-id))
(exception->4xx #(assert-not-locked client-id (:transaction/date existing-tx)))
(when (and (= :approved (keyword (:transaction/approval-status tx-data)))
(not (seq (:transaction/accounts tx-data))))
(throw (ex-info "Approved transactions must have accounts assigned."
{:validation-error "Approved transactions must have accounts assigned."})))
(when (seq (:transaction/accounts tx-data))
(let [account-total (reduce + 0 (map :transaction-account/amount (:transaction/accounts tx-data)))]
(when (not (dollars= (Math/abs (:transaction/amount existing-tx)) account-total))
(throw (ex-info (str "Account total (" account-total ") does not equal transaction amount ("
(Math/abs (:transaction/amount existing-tx)) ")")
{:validation-error "Account totals must match transaction amount."})))))
(let [transaction-result (audit-transact [transaction] (:identity request))]
(try
(solr/touch-with-ledger tx-id)
(catch Exception e
(alog/error ::cant-save-solr :error e)))
(html-response
[:div]
:headers {"hx-trigger" "modalclose"}))))
(defn unlink-payment [{{{transaction-id :db/id} :snapshot} :multi-form-state :as request}]
(fc/start-form (:multi-form-state request) (when (:form-errors request) {:step-params (:form-errors request)})
(let [transaction (dc/pull (dc/db conn)
'[:transaction/approval-status
:transaction/date
:transaction/location
:transaction/vendor
:transaction/accounts
:transaction/status
:transaction/client [:db/id]
{:transaction/payment [:payment/date
{[:payment/status :xform iol-ion.query/ident] [:db/ident]} :db/id]}]
transaction-id)
payment (-> transaction :transaction/payment)]
(exception->4xx #(assert-can-see-client (:identity request) (-> transaction :transaction/client :db/id)))
(exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction)))
(when (not= :payment-status/cleared (-> payment :payment/status))
(throw (ex-info "Payment can't be undone because it isn't cleared."
{:validation-error "Payment can't be undone because it isn't cleared."})))
(let [is-autopay-payment? (some->> (dc/q {:find ['?sp]
:in ['$ '?payment]
:where ['[?ip :invoice-payment/payment ?payment]
'[?ip :invoice-payment/invoice ?i]
'[(get-else $ ?i :invoice/scheduled-payment "N/A") ?sp]]}
(dc/db conn) (:db/id payment))
seq
(map first)
(every? #(instance? java.util.Date %)))]
(if is-autopay-payment?
(audit-transact
(-> [{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id (:db/id transaction)
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]
[:db/retractEntity (:db/id payment)]]
(into (map (fn [[invoice-payment]]
[:db/retractEntity invoice-payment])
(dc/q {:find ['?ip]
:in ['$ '?p]
:where ['[?ip :invoice-payment/payment ?p]]}
(dc/db conn)
(:db/id payment)))))
(:identity request))
(audit-transact
[{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id (:db/id transaction)
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]]
(:identity request))))
(solr/touch-with-ledger (:db/id transaction))
(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
(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 :basic-details)))
(render-wizard [this {:keys [multi-form-state] :as request}]
(println "HERE XYZ" (:form-errors request))
(mm/default-render-wizard
this request
:form-params
(-> mm/default-form-props
(assoc :hx-post
(str (bidi/path-for ssr-routes/only-routes ::route/edit-submit))))
:render-timeline? false))
(steps [_]
[:basic-details
:links ])
(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 {:basic-details (->BasicDetailsStep this)
:links (->LinksStep this)}
step-key)))
(form-schema [_]
edit-form-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(save-handler request)
))
(def edit-wizard (->EditWizard nil nil))
(defn initial-edit-wizard-state [request]
(let [tx-id (-> request :route-params :db/id)
entity (dc/pull (dc/db conn)
'[:db/id
:transaction/vendor
:transaction/client
:transaction/description-original
:transaction/status
:transaction/type
:transaction/memo
{ [ :transaction/approval-status :xform iol-ion.query/ident] [:db/ident]}
:transaction/amount
:transaction/accounts]
tx-id)
entity (-> entity
(update :transaction/vendor :db/id)
(update :transaction/client :db/id))]
(mm/->MultiStepFormState entity
[]
entity)))
(def key->handler
(apply-middleware-to-all-handlers
{::route/edit-wizard (-> mm/open-wizard-handler
(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-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-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::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-total (-> account-total
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/account-balance (-> account-balance
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state))
::route/edit-wizard-new-account (->
(add-new-entity-handler [:step-params :transaction/accounts]
(fn render [cursor request]
(transaction-account-row*
{:value cursor
:client-id (:client-id (:query-params request))}))
(fn build-new-row [base _]
(assoc base :transaction-account/location "Shared")))
(wrap-schema-enforce :query-schema [:map
[:client-id {:optional true}
[:maybe entity-id]]]))
::route/unlink-payment (-> unlink-payment
(wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read)
(mm/wrap-wizard edit-wizard)
(mm/wrap-decode-multi-form-state)
#_(wrap-schema-enforce :form-schema
save-schema))}
(fn [h]
(-> h
(wrap-client-redirect-unauthenticated)))))