(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)))))