(ns auto-ap.ssr.transaction.edit (:require [auto-ap.client-routes :as client-routes] [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.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 html-response ref->enum-schema strip 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] [malli.util :as mut])) (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 [:map [:db/id {:optional true} [:maybe entity-id]] [:transaction/client {:optional true} [:maybe entity-id]] [:transaction/vendor {:optional true} [:maybe entity-id]] [:transaction/memo {:optional true} [:maybe [:string {:decode/string strip}]]] [:transaction/approval-status {:optional true} [:maybe (ref->enum-schema "transaction-approval-status")]] [:transaction/accounts [:vector {:coerce? true} [:and [:map [:transaction-account/account [:and entity-id [:fn {:error/message "Not an allowed account."} #(check-allowance % :account/invoice-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)))]]]] ]) (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 wrap-schema [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 [_] "Basic Details") (step-key [_] :basic-details) (edit-path [_ _] []) (step-schema [_] (wrap-schema (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))) (render-step [this {:keys [multi-form-state] :as request}] (let [extant? (mm/get-mfs-field multi-form-state :db/id) tx (d-transactions/get-by-id extant?)] (alog/info ::TRANSACTION :i multi-form-state) (mm/default-render-step linear-wizard this :head [:div.p-2 (if extant? "Edit transaction" "New transaction")] :body (mm/default-step-body {} [:div {:x-data (hx/json {:clientId (or (fc/field-value (:transaction/client fc/*current*)) (:db/id (:client request))) :vendorId (fc/field-value (:transaction/vendor fc/*current*))})} (fc/with-field :db/id (when extant? (com/hidden {:name (fc/field-name) :value (fc/field-value)}))) (fc/with-field :transaction/client (if (or (:client request) extant?) (com/hidden {:name (fc/field-name) :value (or (mm/get-mfs-field multi-form-state :transaction/client) (:db/id (:client request)))}) (com/validated-field {:label "Client" :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 :company-search) :value (fc/field-value) :content-fn (fn [c] (pull-attr (dc/db conn) :client/name c)) :x-model "clientId"})]))) (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)) :x-model "vendorId"})])) [:div.mb-4 [:span.text-sm.text-gray-500 "Can't find the vendor? " (com/link {:href (bidi.bidi/path-for client-routes/routes :new-vendor) :target "new"} "Add new vendor") " in a new window, then return here."]] (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"})])) (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)})))]) :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" :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/edit-wizard-navigate)} "Next")) :validation-route ::route/edit-wizard-navigate))) mm/Initializable (init-step-params [_ current request] (:step-params current))) (defrecord AccountsStep [linear-wizard] mm/ModalWizardStep (step-name [_] "Expense Accounts") (step-key [_] :accounts) (edit-path [_ _] []) (step-schema [_] (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/accounts})) (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 accounts "] :body (mm/default-step-body {} [:div {} (pull-attr (dc/db conn) :client/name (:transaction/client snapshot)) (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"})]} (fc/cursor-map #(transaction-account-row* {:value % :client-id (:transaction/client snapshot)})) (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"} "Save")) :validation-route ::route/edit-wizard-navigate)) mm/Initializable (init-step-params [_ current request] (if (not (seq (:transaction/accounts (:step-params current)))) (let [tx (d-transactions/get-by-id (->db-id (:db/id (:snapshot current)))) client-id (-> tx :transaction/client :db/id) vendor (:transaction/vendor tx) vendor-id (when vendor (:db/id vendor))] (assoc (:step-params current) :transaction/accounts (if (seq (:transaction/accounts tx)) ;; If the transaction already has accounts, use them (mapv (fn [account] {:db/id (random-tempid) :transaction-account/account (-> account :transaction-account/account :db/id) :transaction-account/location (:transaction-account/location account) :transaction-account/amount (:transaction-account/amount account)}) (:transaction/accounts tx)) ;; Otherwise, if we have a vendor, use its default account (if vendor-id [{:db/id (random-tempid) :transaction-account/location "Shared" :transaction-account/account (:db/id (:vendor/default-account (clientize-vendor (get-vendor vendor-id) client-id))) :transaction-account/amount (Math/abs (:transaction/amount tx))}] ;; If no vendor and no accounts, start with an empty list [])))) (:step-params current)))) (defn get-available-payments [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])) 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 payment-matches-view [request] (let [payments (get-available-payments request)] [:div (if (seq payments) [:div [:h3.text-lg.font-bold.mb-4 "Available Payments"] [:div.space-y-2 (for [payment payments] [:form.py-2.border-b.border-gray-200 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/match-payment) :hx-target "#modal-holder" :hx-swap "outerHTML"} (com/hidden {:name "transaction-id" :value (get-in request [:multi-form-state :snapshot :db/id])}) (com/hidden {:name "payment-id" :value (:db/id payment)}) [:div.flex.justify-between.items-center [:div.space-y-1 [:div.font-medium (str (:payment/invoice-number payment) " - " (-> payment :payment/vendor :vendor/name))] [:div.text-sm.text-gray-600 (str "Amount: $" (format "%.2f" (:payment/amount payment))) " • " (str "Date: " (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date)))]] (com/button {:color :primary :size :small} "Match")]])]] [:div.text-center.py-4.text-gray-500 "No matching payments available for this transaction."])])) (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 (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-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.space-y-4 (for [match-group invoice-matches] [:form.border.border-gray-200.rounded.p-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/match-autopay-invoices) :hx-target "#modal-holder" :hx-swap "outerHTML"} (com/hidden {:name "transaction-id" :value (get-in request [:multi-form-state :snapshot :db/id])}) [:div.space-y-3 [:div.text-sm.font-medium "Match with these invoices:"] (for [invoice match-group] [:div.flex.justify-between.items-center.py-2.border-b.border-gray-100 [:div [:div.font-medium (:invoice/invoice-number invoice)] [:div.text-sm.text-gray-600 (-> invoice :invoice/vendor :vendor/name)]] [:div.text-right [:div.font-medium (format "$%.2f" (:invoice/total invoice))] (com/hidden {:name "autopay-invoice-ids[]" :value (:db/id invoice)})]]) [:div.flex.justify-end.mt-4 (com/button {:color :primary :size :small} "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.space-y-4 (for [match-group invoice-matches] [:form.border.border-gray-200.rounded.p-4 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/match-unpaid-invoices) :hx-target "#modal-holder" :hx-swap "outerHTML"} (com/hidden {:name "transaction-id" :value (get-in request [:multi-form-state :snapshot :db/id])}) [:div.space-y-3 [:div.text-sm.font-medium "Match with these invoices:"] (for [invoice match-group] [:div.flex.justify-between.items-center.py-2.border-b.border-gray-100 [:div [:div.font-medium (:invoice/invoice-number invoice)] [:div.text-sm.text-gray-600 (-> invoice :invoice/vendor :vendor/name)]] [:div.text-right [:div.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))] (com/hidden {:name "unpaid-invoice-ids[]" :value (:db/id invoice)})]]) [:div.flex.justify-end.mt-4 (com/button {:color :primary :size :small} "Match")]]])]] [: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 ?r ?name ?pattern :where [?r :transaction-rule/description ?pattern] [?r :transaction-rule/name ?name]] (dc/db conn))] (when tx patterns #_(filter (fn [[rule-id rule-name pattern]] (rm/pattern-matches? pattern (:transaction/description-original tx))) patterns)))) (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.space-y-2 (for [[rule-id rule-name pattern] matching-rules] [:form.py-2.border-b.border-gray-200 {:hx-post (bidi/path-for ssr-routes/only-routes ::route/apply-rule) :hx-target "#modal-holder" :hx-swap "outerHTML"} (com/hidden {:name "transaction-id" :value (get-in request [:multi-form-state :snapshot :db/id])}) (com/hidden {:name "rule-id" :value rule-id}) [:div.flex.justify-between.items-center [:div.space-y-1 [:div.font-medium rule-name] [:div.text-sm.text-gray-600 (str "Pattern: " pattern)]] (com/button {:color :primary :size :small} "Apply")]])]] [:div.text-center.py-4.text-gray-500 "No matching rules found for this transaction."])])) (defn payment-info-view [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)) payment (:transaction/payment tx)] (when payment [:div.my-4.p-4.bg-blue-50.rounded [:h3.text-lg.font-bold.mb-2 "Linked Payment"] [: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 (format "$%.2f" (:payment/amount payment))]] [:div.flex.justify-between [:div.font-medium "Status"] [:div (-> payment :payment/status :db/ident name)]] [:div.flex.justify-between [:div.font-medium "Date"] [:div (some-> payment :payment/date coerce/to-date-time (atime/unparse-local atime/normal-date))]] [:div.mt-4 [:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/unlink-payment) :hx-target "#modal-holder" :hx-swap "outerHTML" :hx-confirm "Are you sure you want to unlink this payment?"} (com/hidden {:name "transaction-id" :value tx-id}) (com/button {:color :danger :size :small} "Unlink Payment")]]]]))) (defrecord LinksStep [linear-wizard] mm/ModalWizardStep (step-name [_] "Links") (step-key [_] :links) (edit-path [_ _] []) (step-schema [_] [:map]) (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 Links"] :body (mm/default-step-body {} [:div (payment-info-view request) [:div.grid.grid-cols-2.gap-6 [:div [:h2.text-xl.font-bold.mb-4 "Link to Payment"] (payment-matches-view request)] [:div [:h2.text-xl.font-bold.mb-4 "Link to Invoices"] (autopay-invoices-view request) (unpaid-invoices-view request)]] [:div.mt-8 [:h2.text-xl.font-bold.mb-4 "Apply Transaction Rules"] (transaction-rules-view request)]]) :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))) (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}] (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? true)) (steps [_] [:basic-details :accounts :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) :accounts (->AccountsStep this) :links (->LinksStep this)} step-key))) (form-schema [_] edit-form-schema) (submit [this {:keys [multi-form-state request-method identity] :as request}] (let [tx-data (-> multi-form-state :snapshot) tx-id (:db/id tx-data) client-id (->db-id (:transaction/client tx-data)) existing-tx (when tx-id (d-transactions/get-by-id tx-id)) transaction [:upsert-transaction (cond-> tx-data true (dissoc :db/id) tx-id (assoc :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 (or tx-id (get-in transaction-result [:tempids "invoice"]))) (catch Exception e (alog/error ::cant-save-solr :error e))) (html-response [:div] :headers {"hx-trigger" "modalclose"}))))) (def edit-wizard (->EditWizard nil nil)) (defn initial-edit-wizard-state [request] (let [tx-id (-> request :route-params :db/id) entity (when tx-id (dc/pull (dc/db conn) '[:db/id :transaction/vendor :transaction/client :transaction/memo { [ :transaction/approval-status :xform iol-ion.query/ident] [:db/ident]} :transaction/amount :transaction/accounts] tx-id)) entity (if entity (-> entity (update :transaction/vendor :db/id) (update :transaction/client :db/id)) {})] (mm/->MultiStepFormState entity [] entity))) (defn match-payment [{{:strs [transaction-id payment-id]} :form-params :as request}] (let [transaction-id (Long/parseLong transaction-id) payment-id (Long/parseLong payment-id) transaction (d-transactions/get-by-id transaction-id) 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))) (exception->4xx #(assert-not-locked (-> transaction :transaction/client :db/id) (:transaction/date transaction))) (when (not= (-> transaction :transaction/client :db/id) (-> payment :payment/client :db/id)) (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"}))) (audit-transact (into [{:db/id (:db/id payment) :payment/status :payment-status/cleared :payment/date (coerce/to-date (first (sort [(:payment/date payment) (:transaction/date transaction)])))} [:upsert-transaction {: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)) (solr/touch-with-ledger transaction-id) (html-response [:div.p-4 [:div.text-2xl.text-center.text-green-600 svg/check] [:div.text-center.mt-4 [:h3.text-xl.font-bold "Transaction linked successfully"] [:p.text-gray-600.mt-2 "The transaction has been linked to the selected payment."] [:div.mt-6 (com/button {:color :primary "@click" "$dispatch('modalclose') $dispatch('refreshTable')"} "Close")]]]))) (defn match-autopay-invoices [{{:strs [transaction-id autopay-invoice-ids]} :form-params :as request}] (let [transaction-id (Long/parseLong transaction-id) autopay-invoice-ids (if (string? autopay-invoice-ids) [(Long/parseLong autopay-invoice-ids)] (mapv #(Long/parseLong %) autopay-invoice-ids)) transaction (d-transactions/get-by-id transaction-id) 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] transaction-id) (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 transaction-id) (html-response [:div.p-4 [:div.text-2xl.text-center.text-green-600 svg/check] [:div.text-center.mt-4 [:h3.text-xl.font-bold "Transaction linked successfully"] [:p.text-gray-600.mt-2 "The transaction has been linked to the selected autopay invoices."] [:div.mt-6 (com/button {:color :primary "@click" "$dispatch('modalclose') $dispatch('refreshTable')"} "Close")]]]))) (defn match-unpaid-invoices [{{:strs [transaction-id unpaid-invoice-ids]} :form-params :as request}] (let [transaction-id (Long/parseLong transaction-id) unpaid-invoice-ids (if (string? unpaid-invoice-ids) [(Long/parseLong unpaid-invoice-ids)] (mapv #(Long/parseLong %) unpaid-invoice-ids)) transaction (d-transactions/get-by-id transaction-id) 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."}))) (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] transaction-id) (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 transaction-id) (html-response [:div.p-4 [:div.text-2xl.text-center.text-green-600 svg/check] [:div.text-center.mt-4 [:h3.text-xl.font-bold "Transaction linked successfully"] [:p.text-gray-600.mt-2 "The transaction has been linked to the selected unpaid invoices."] [:div.mt-6 (com/button {:color :primary "@click" "$dispatch('modalclose') $dispatch('refreshTable')"} "Close")]]]))) (defn apply-rule [{{:strs [transaction-id rule-id]} :form-params :as request}] (let [transaction-id (Long/parseLong transaction-id) rule-id (Long/parseLong rule-id) transaction (d-transactions/get-by-id transaction-id) 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)] (audit-transact [[:upsert-transaction updated-tx]] (:identity request))) (solr/touch-with-ledger transaction-id) (html-response [:div.p-4 [:div.text-2xl.text-center.text-green-600 svg/check] [:div.text-center.mt-4 [:h3.text-xl.font-bold "Rule applied successfully"] [:p.text-gray-600.mt-2 "The selected rule has been applied to this transaction."] [:div.mt-6 (com/button {:color :primary "@click" "$dispatch('modalclose') $dispatch('refreshTable')"} "Close")]]]))) (defn unlink-payment [{{:strs [transaction-id]} :form-params :as request}] (let [transaction-id (Long/parseLong transaction-id) transaction (dc/pull (dc/db conn) [:transaction/approval-status :transaction/status :transaction/date :transaction/location :transaction/vendor :transaction/accounts :transaction/client [:db/id] {:transaction/payment [:payment/date {:payment/status [: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 :db/ident)) (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 transaction-id) (html-response [:div.p-4 [:div.text-2xl.text-center.text-green-600 svg/check] [:div.text-center.mt-4 [:h3.text-xl.font-bold "Transaction unlinked successfully"] [:p.text-gray-600.mt-2 "The transaction has been unlinked from its payment."] [:div.mt-6 (com/button {:color :primary "@click" "$dispatch('modalclose') $dispatch('refreshTable')"} "Close")]]]))) (defn edit-transaction [{:keys [route-params] :as request}] (mm/open-wizard-handler (-> request (mm/wrap-wizard edit-wizard) (mm/wrap-init-multi-form-state initial-edit-wizard-state)))) (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-schema-enforce :route-schema [:map [:db/id entity-id]])) ::route/edit-wizard-navigate (-> mm/next-handler (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state)) ::route/edit-submit (-> mm/submit-handler (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/match-payment match-payment ::route/match-autopay-invoices match-autopay-invoices ::route/match-unpaid-invoices match-unpaid-invoices ::route/apply-rule apply-rule ::route/unlink-payment unlink-payment} (fn [h] (-> h (wrap-client-redirect-unauthenticated)))))