(ns auto-ap.ssr.transaction.edit (:require [auto-ap.cursor :as cursor] [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.permissions :refer [wrap-must]] [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.grid-page-helper :as helper] [auto-ap.ssr.transaction.common :refer [grid-page]] [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 temp-id wrap-entity wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clojure.edn :as edn] [datomic.api :as dc] [hiccup.util :as hu] [iol-ion.query :refer [dollars=]] [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 "Client Review"}) (def row* (partial helper/row* grid-page)) (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)))) (defn require-approval [s] [:and s [:fn {:error/message "Approved transactions must have accounts assigned."} (fn [{:transaction/keys [accounts approval-status]}] (or (not= approval-status :transaction-approval-status/approved) (seq accounts)))]]) (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")]] [:amount-mode {:optional true} [:maybe [:enum "$" "%"]]] [:transaction/accounts {:optional true} [:maybe [:vector {:coerce? true} [:and [:map [:db/id {:optional true} [:maybe [:or temp-id entity-id]]] [:transaction-account/account [:and entity-id [:fn {:error/message "Not an allowed account."} #(check-allowance % :account/default-allowance)]]] [: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 {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]] [:link-autopay-invoices [:map [:autopay-invoice-ids {:decode/string (fn [x] (edn/read-string x))} [:vector {:coerce? true} entity-id]]]] [:link-payment [:map [:payment-id entity-id]]] [:manual (require-approval [: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 vendor-default-account [vendor-id client-id] (when vendor-id (let [vendor (get-vendor vendor-id) clientized (clientize-vendor vendor client-id)] (:vendor/default-account clientized)))) (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 (or 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) (cond-> {:purpose "transaction"} client-id (assoc :client-id client-id))) :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 simple-mode-fields* "Renders the simple-mode account + location row and the toggle-to-advanced link. Must be called within a fc/start-form + fc/with-field :step-params context. Caller must establish Alpine x-data with simpleAccountId in scope." [request] (let [snapshot (-> request :multi-form-state :snapshot) step-params (-> request :multi-form-state :step-params) client-id (or (-> request :entity :transaction/client :db/id) (:transaction/client snapshot)) existing-row (first (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot)))) account-val (let [av (:transaction-account/account existing-row)] (if (map? av) (:db/id av) av)) location-val (or (:transaction-account/location existing-row) "Shared") account-id (when (nat-int? account-val) (dc/pull (dc/db conn) '[:account/location] account-val)) row-id (or (:db/id existing-row) (str (java.util.UUID/randomUUID))) total (Math/abs (or (-> request :entity :transaction/amount) (:transaction/amount snapshot) 0.0))] [:div (fc/with-field :transaction/accounts (fc/with-cursor (let [cur fc/*current*] (if (sequential? @cur) (nth cur 0 nil) (auto_ap.cursor.MapCursor. {} (cursor/state cur) (conj (cursor/path cur) 0)))) [:span (fc/with-field :db/id (com/hidden {:name (fc/field-name) :value row-id})) [:div.flex.gap-2.mt-2 (fc/with-field :transaction-account/account (com/validated-field {:label "Account" :errors (fc/field-errors)} [:div.w-72 (account-typeahead* {:value account-val :client-id client-id :name (fc/field-name) :x-model "simpleAccountId"})])) (fc/with-field :transaction-account/location (com/validated-field {:label "Location" :errors (fc/field-errors) :x-hx-val:account-id "simpleAccountId" :hx-vals (hx/json (cond-> {:name (fc/field-name)} client-id (assoc :client-id client-id))) :x-dispatch:changed "simpleAccountId" :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 account-id) :client-locations (pull-attr (dc/db conn) :client/locations client-id) :value location-val}))) (fc/with-field :transaction-account/amount (com/hidden {:name (fc/field-name) :value total}))]])) [:div.mt-1 [:a.text-sm.text-blue-600.hover:underline.cursor-pointer {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode) :hx-include "closest form" :hx-target "#manual-coding-section" :hx-swap "outerHTML"} "Switch to advanced mode"]]])) (defn- manual-mode-initial "Returns :simple or :advanced based on existing account row count." [snapshot] (let [rows (seq (:transaction/accounts snapshot))] (if (and rows (> (count rows) 1)) :advanced :simple))) (defn transaction-account-row* [{:keys [value client-id amount-mode total]}] (com/data-grid-row (-> {:class "account-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 (cond-> {:name (fc/field-name)} client-id (assoc :client-id client-id))) :x-dispatch:changed "accountId" :hx-trigger "changed" :hx-get (bidi/path-for ssr-routes/only-routes ::route/location-select) :hx-target "find *" :hx-swap "outerHTML"} (location-select* {:name (fc/field-name) :account-location (:account/location (cond->> (: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)} (if (= "%" amount-mode) (com/text-input {:name (fc/field-name) :class "w-16 account-amount-field" :value (fc/field-value) :type "number" :step "0.01"}) (com/money-input {:name (fc/field-name) :class "w-16 account-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)" :class "account-remove-action"} svg/x)))) (defn- account-field-name [index field] (str "step-params[transaction/accounts][" index "][" (if (keyword? field) (str (when (namespace field) (str (namespace field) "/")) (name field)) field) "]")) (defn transaction-account-row-no-cursor* [{:keys [account index client-id amount-mode total]}] (com/data-grid-row (-> {:class "account-row" :x-data (hx/json {:show true :accountId (:transaction-account/account account)}) :data-key "show" :x-ref "p"} hx/alpine-mount-then-appear) (com/hidden {:name (account-field-name index :db/id) :value (or (:db/id account) "")}) (com/data-grid-cell {} (com/validated-field {} (account-typeahead* {:value (:transaction-account/account account) :client-id client-id :name (account-field-name index :transaction-account/account) :x-model "accountId"}))) (com/data-grid-cell {} (com/validated-field {} (location-select* {:name (account-field-name index :transaction-account/location) :account-location (:account/location (cond->> (:transaction-account/account account) (nat-int? (:transaction-account/account account)) (dc/pull (dc/db conn) '[:account/location]))) :client-locations (pull-attr (dc/db conn) :client/locations client-id) :value (:transaction-account/location account)}))) (com/data-grid-cell {} (com/validated-field {} (if (= "%" amount-mode) (com/text-input {:name (account-field-name index :transaction-account/amount) :class "w-16 account-amount-field" :value (:transaction-account/amount account) :type "number" :step "0.01"}) (com/money-input {:name (account-field-name index :transaction-account/amount) :class "w-16 account-amount-field" :value (:transaction-account/amount account)})))) (com/data-grid-cell {:class "align-top"} (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)" :class "account-remove-action"} 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 (or (-> request :entity :transaction/amount) (-> request :multi-form-state :snapshot :transaction/amount) 0.0)) 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 ->percentage [amount total] (when (and amount total (not= total 0)) (* 100.0 (/ amount total)))) (defn percentages->dollars [percentages total] (let [total-cents (int (* 100 (Math/abs total))) pct-sum (reduce + 0 percentages) normalized-pcts (if (zero? pct-sum) (repeat (count percentages) 0) (map #(* (/ % pct-sum) 100) percentages)) individual-cents (map #(int (* total-cents (/ % 100))) normalized-pcts) short-by (- total-cents (reduce + 0 individual-cents)) adjustments (concat (take short-by (repeat 1)) (repeat 0)) final-cents (map + individual-cents adjustments)] (map #(* 0.01 %) final-cents))) (defn convert-accounts-mode [accounts old-mode new-mode total] (if (= old-mode new-mode) accounts (let [amounts (map :transaction-account/amount accounts)] (map #(assoc %1 :transaction-account/amount %2) accounts (case [old-mode new-mode] ["$" "%"] (map #(->percentage % total) amounts) ["%" "$"] (percentages->dollars amounts total) amounts))))) (defn account-grid-body* [request] (let [snapshot (-> request :multi-form-state :snapshot) amount-mode (or (:amount-mode snapshot) "$") total (Math/abs (or (-> request :entity :transaction/amount) (:transaction/amount snapshot) 0.0))] (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/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}] :value amount-mode :name "step-params[amount-mode]" :orientation :horizontal :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) :hx-target "#account-grid-body" :hx-swap "outerHTML" :hx-include "closest form"})) (com/data-grid-header {:class "w-16"})]} (fc/cursor-map #(transaction-account-row* {:value % :client-id (-> request :entity :transaction/client :db/id) :amount-mode amount-mode :total total})) (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 (:transaction/accounts snapshot)) :tr-params {:hx-vals (hx/json {:client-id (:transaction/client snapshot)})}} "New account") (com/data-grid-row {:class "account-total-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 {:class "account-balance-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 {:class "account-grand-total-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" total)) (com/data-grid-cell {}))))) (defn manual-coding-section* "Renders the vendor field + account/location section for the manual tab. mode is :simple or :advanced. In simple mode, establishes Alpine x-data with simpleAccountId in scope. Must be called within a fc/start-form + fc/with-field :step-params context." [mode request] (let [snapshot (-> request :multi-form-state :snapshot) step-params (-> request :multi-form-state :step-params) all-accounts (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot))) row-count (count all-accounts)] [:div#manual-coding-section (com/hidden {:name "mode" :value (name mode)}) [:div {:hx-trigger "change" :hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed) :hx-target "#manual-coding-section" :hx-swap "outerHTML" :hx-sync "this:replace" :hx-include "closest form"} (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))})]))] (if (= mode :simple) [:div {:x-data (hx/json {:simpleAccountId (let [av (-> (first all-accounts) :transaction-account/account)] (if (map? av) (:db/id av) av))})} (simple-mode-fields* request)] [:div (when (<= row-count 1) [:div.mb-2 [:a.text-sm.text-blue-600.hover:underline.cursor-pointer {:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-wizard-toggle-mode) :hx-include "closest form" :hx-target "#manual-coding-section" :hx-swap "outerHTML"} "Switch to simple mode"]]) (fc/with-field :transaction/accounts (com/validated-field {:errors (fc/field-errors)} [:div#account-grid-body (account-grid-body* request)]))])])) (defn toggle-amount-mode [request] (let [snapshot (-> request :multi-form-state :snapshot) old-mode (or (:amount-mode snapshot) "$") new-mode (or (get-in request [:multi-form-state :step-params :amount-mode]) "$") total (Math/abs (or (:transaction/amount snapshot) 0.0)) accounts (convert-accounts-mode (:transaction/accounts snapshot) old-mode new-mode total) updated-request (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] accounts) (assoc-in [:multi-form-state :snapshot :amount-mode] new-mode))] (html-response [:div#account-grid-body (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/radio-card {:options [{:value "$" :content "$"} {:value "%" :content "%"}] :value new-mode :name "step-params[amount-mode]" :orientation :horizontal :hx-post (bidi/path-for ssr-routes/only-routes ::route/toggle-amount-mode) :hx-target "#account-grid-body" :hx-swap "outerHTML" :hx-include "closest form"})) (com/data-grid-header {:class "w-16"})]} (map-indexed (fn [idx account] (transaction-account-row-no-cursor* {:account account :index idx :client-id (-> updated-request :entity :transaction/client :db/id) :amount-mode new-mode :total total})) accounts) (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 accounts) :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"} (format "$%,.2f" (double (reduce + 0.0 (map :transaction-account/amount accounts))))) (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"} (let [account-total (double (reduce + 0.0 (map :transaction-account/amount accounts))) balance (- total account-total)] [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")} (format "$%,.2f" (double balance))])) (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" total)) (com/data-grid-cell {})))]))) (defn transaction-details-panel [tx] [:div.p-4.space-y-4 [:h3.text-sm.font-semibold.text-gray-900.uppercase.tracking-wider "Details"] [:div.space-y-3 [:div [:div.text-xs.font-medium.text-gray-500 "Amount"] [:div.text-sm.font-medium.text-gray-900 (format "$%,.2f" (Math/abs (:transaction/amount tx)))]] [:div [:div.text-xs.font-medium.text-gray-500 "Date"] [:div.text-sm.text-gray-900 (some-> tx :transaction/date coerce/to-date-time (atime/unparse-local atime/normal-date))]] [:div [:div.text-xs.font-medium.text-gray-500 "Bank Account"] [:div.text-sm.text-gray-900 (or (-> tx :transaction/bank-account :bank-account/name) "-")]] [:div [:div.text-xs.font-medium.text-gray-500 "Post Date"] [:div.text-sm.text-gray-900 (some-> tx :transaction/post-date coerce/to-date-time (atime/unparse-local atime/normal-date))]] [:div [:div.text-xs.font-medium.text-gray-500 "Description"] [:div.text-sm.text-gray-900.truncate.cursor-help {:title (or (:transaction/description-original tx) "No original description")} (or (:transaction/description-simple tx) "-")]] [:div [:div.text-xs.font-medium.text-gray-500 "Check Number"] [:div.text-sm.text-gray-900 (or (:transaction/check-number tx) "-")]] [:div [:div.text-xs.font-medium.text-gray-500 "Status"] [:div.text-sm.text-gray-900 (or (some-> tx :transaction/status) "-")]] [:div [:div.text-xs.font-medium.text-gray-500 "Transaction Type"] [:div.text-sm.text-gray-900 (or (some-> tx :transaction/type) "-")]]]]) (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) 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"] (com/hidden {:name "action" :value "link-autopay-invoices"}) [:div.space-y-2 [:label.block.text-sm.font-medium.mb-1 "Select an autopay invoice to apply:"] (com/radio-card {:options (for [match-group invoice-matches] {:value (pr-str (map :db/id match-group)) :content (doall (for [invoice match-group] [:div.ml-3 [:span.block.text-sm.font-medium (:invoice/invoice-number invoice)] [:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)] [:span.block.text-sm.text-gray-500 (some-> invoice :invoice/date coerce/to-date-time (atime/unparse-local atime/normal-date))] [:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))}) :name (fc/with-field :autopay-invoice-ids (fc/field-name)) :width "w-full"})]] [:div.text-center.py-4.text-gray-500 "No matching autopay invoices available for this transaction."])])) (defn get-available-unpaid-invoices [request] (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:"] (com/radio-card {:options (for [match-group invoice-matches] {:value (pr-str (map :db/id match-group)) :content (doall (for [invoice match-group] [:div.ml-3 [:span.block.text-sm.font-medium (:invoice/invoice-number invoice)] [:span.block.text-sm.text-gray-500 (-> invoice :invoice/vendor :vendor/name)] [:span.block.text-sm.text-gray-500 (some-> invoice :invoice/date coerce/to-date-time (atime/unparse-local atime/normal-date))] [:span.block.text-sm.font-medium (format "$%.2f" (:invoice/outstanding-balance invoice))]]))}) :name (fc/with-field :unpaid-invoice-ids (fc/field-name)) :width "w-full"})] #_(com/a-button {:color :primary "@click" "$dispatch('linkUnpaidInvoices')"} "Link")]] [:div.text-center.py-4.text-gray-500 "No matching unpaid invoices available for this transaction."])])) (defn get-available-rules [request] (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"] (fc/with-field :action (com/hidden {:name (fc/field-name) :value "apply-rule" :form ""})) [:div.space-y-2 [:label.block.text-sm.font-medium.mb-1 "Select a rule to apply:"] (com/radio-card {:options (for [{:keys [:db/id :transaction-rule/note :transaction-rule/description]} matching-rules] {:value id :content [:div.ml-3 [:span.block.text-sm.font-medium note] [:span.block.text-sm.text-gray-500 description]]}) :name (fc/with-field :rule-id (fc/field-name)) :width "w-full"})] #_(com/a-button {"@click" "$dispatch('applyRule')"} "Apply")] [:div.text-center.py-4.text-gray-500 "No matching rules found for this transaction."])])) (defn payment-matches-view [request] (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"})))]] [: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 step-params] :as multi-form-state} :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) ;; Preserve explicit mode choice from step-params; only fall back to ;; row-count heuristic on initial load when no mode has been chosen. mode (keyword (or (:mode step-params) (name (manual-mode-initial snapshot))))] (mm/default-render-step linear-wizard this :head [:div.p-2 "Edit Transaction"] :width-height-class " md:w-[950px] md:h-[650px] " :side-panel (transaction-details-panel tx) :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" (or (fc/with-field :action (fc/field-value)) "manual")) :canChange (boolean (not (:transaction/payment (:entity request))))}) "@unlinked" "canChange=true"} [:div {:class "flex space-x-2 mb-4"} (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 {} (manual-coding-section* mode request) (fc/with-field :transaction/approval-status (com/validated-field {:label "Status" :errors (fc/field-errors)} (let [current-value (name (or (fc/field-value) :transaction-approval-status/unapproved))] [:div {:x-data (hx/json {:approvalStatus current-value})} (com/hidden {:name (fc/field-name) :value current-value ":value" "approvalStatus"}) [:div {:class "inline-flex rounded-md shadow-sm", :role "group"} (com/button-group-button {"@click" "approvalStatus = 'approved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'approved' }" :class "rounded-l-lg"} "Approved") (com/button-group-button {"@click" "approvalStatus = 'unapproved'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'unapproved' }" :class "rounded-r-lg"} "Unapproved") (com/button-group-button {"@click" "approvalStatus = 'suppressed'" ":class" "{ '!bg-primary-200 text-primary-800': approvalStatus === 'suppressed' }" :class "rounded-r-lg"} "Client Review")]])))]]]]) :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 wizard-save-action"} "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] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}] (let [db (dc/db conn) invoice-clients (set (map #(pull-ref db :invoice/client %) autopay-invoice-ids)) invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/total %) autopay-invoice-ids))] (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 (conj payment-tx [:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (: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] :as snapshot} :snapshot} :multi-form-state :as request transaction :entity}] (let [db (dc/db conn) invoice-clients (set (map #(pull-ref db :invoice/client %) unpaid-invoice-ids)) invoice-amount (reduce + 0.0 (map #(pull-attr db :invoice/outstanding-balance %) unpaid-invoice-ids))] (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 (conj payment-tx [:upsert-transaction (default-update-tx snapshot {:db/id (:db/id transaction)})]) (: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] :as snapshot} :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) updated-tx (default-update-tx snapshot updated-tx)] (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"}))) (defn- calculate-spread "Helper function to calculate the amount to be assigned to each location" [shared-amount total-locations] (let [base-amount (int (/ shared-amount total-locations)) remainder (- shared-amount (* base-amount total-locations))] {:base-amount base-amount :remainder remainder})) (defn- spread-account "Spreads the expense account amount across the given locations" [locations account] (if (= "Shared" (:transaction-account/location account)) (let [{:keys [base-amount remainder]} (calculate-spread (:transaction-account/amount account) (count locations))] (map-indexed (fn [idx _] (assoc account :db/id (if (= idx 0) (:db/id account) (random-tempid)) :transaction-account/amount (+ base-amount (if (< idx remainder) 1 0)) :transaction-account/location (nth locations idx))) locations)) [account])) (defn- apply-total-delta-to-account [invoice-total eas] (when (seq eas) (let [leftover (- invoice-total (reduce + 0 (map :transaction-account/amount eas))) leftover-beyond-a-single-cent? (or (< leftover -1) (> leftover 1)) leftover (if leftover-beyond-a-single-cent? 0 leftover) [first-eas & rest] eas] (cons (update first-eas :transaction-account/amount #(+ % leftover)) rest)))) (defn $->cents [x] (int (let [result (* 100M (bigdec x))] (.setScale result 0 java.math.BigDecimal/ROUND_HALF_UP)))) (defn cents->$ [x] (double (let [result (* 0.01M (bigdec x))] (.setScale result 2 java.math.BigDecimal/ROUND_HALF_UP)))) (defn maybe-spread-locations "Converts any expense account for a \"Shared\" location into a separate expense account for all valid locations for that client" ([transaction] (maybe-spread-locations transaction (pull-attr (dc/db conn) :client/locations (:transaction/client transaction)))) ([transaction locations] (clojure.pprint/pprint transaction) (update-in transaction [:transaction/accounts] (fn [accounts] (->> accounts (map (fn [ea] (update ea :transaction-account/amount $->cents))) (mapcat (partial spread-account locations)) (apply-total-delta-to-account ($->cents (:transaction/amount transaction))) (map (fn [ea] (update ea :transaction-account/amount cents->$)))))))) (defmethod save-handler :manual [{:as request transaction :entity :keys [multi-form-state]}] (let [tx-data (-> multi-form-state :snapshot (dissoc :action)) tx-id (:db/id tx-data) client-id (->db-id (:transaction/client tx-data)) existing-tx (d-transactions/get-by-id tx-id) amount-mode (or (:amount-mode tx-data) "$") total (Math/abs (or (:transaction/amount existing-tx) 0.0)) tx-data (if (= "%" amount-mode) (update tx-data :transaction/accounts #(map (fn [account dollar-amount] (assoc account :transaction-account/amount dollar-amount)) % (percentages->dollars (map :transaction-account/amount %) total))) tx-data) tx-data (dissoc tx-data :amount-mode) transaction [:upsert-transaction (maybe-spread-locations (assoc tx-data :db/id tx-id))]] (alog/info ::transaction transaction :entity transaction) (exception->4xx #(assert-can-see-client (:identity request) client-id)) (exception->4xx #(assert-not-locked client-id (:transaction/date existing-tx))) (when (and (= :transaction-approval-status/approved (keyword (:transaction/approval-status tx-data))) (not (seq (:transaction/accounts tx-data)))) (throw (ex-info "Approved transactions must have accounts assigned." {:type :form-validation :form-validation-errors ["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))) tx-amount (Math/abs (:transaction/amount existing-tx))] (when (not (dollars= tx-amount account-total)) (throw (ex-info (format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-amount) {:type :form-validation :form-validation-errors [(format "The total of your expense accounts ($%,.2f) must equal the transaction amount ($%,.2f)." account-total tx-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 (row* (:identity request) (d-transactions/get-by-id tx-id) {:flash? true}) :headers {"hx-trigger" "modalclose" "hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" tx-id) "hx-reswap" "outerHTML"})))) (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"})))) (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 :links))) (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? false)) (steps [_] [: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 {: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))) (defn- render-account-grid-body [request] (fc/start-form (:multi-form-state request) nil (fc/with-field :step-params (fc/with-field :transaction/accounts (account-grid-body* request))))) (defn edit-vendor-changed-handler [request] (let [multi-form-state (:multi-form-state request) snapshot (:snapshot multi-form-state) step-params (:step-params multi-form-state) mode (keyword (or (:mode step-params) (get (:form-params request) "mode") "simple")) client-id (or (:transaction/client snapshot) (-> request :entity :transaction/client :db/id)) vendor-id (or (:transaction/vendor step-params) (->db-id (get step-params "transaction/vendor")) (:transaction/vendor snapshot)) total (Math/abs (or (-> request :entity :transaction/amount) (:transaction/amount snapshot) 0.0)) amount-mode (or (:amount-mode snapshot) "$") existing-accounts (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot))) ;; The form always submits an account row (even when empty with account=nil), ;; so we check if any row has a meaningful account ID. has-meaningful-accounts? (some #(some? (:transaction-account/account %)) existing-accounts) ;; Simple mode: always populate vendor default (overwrite existing). ;; Advanced mode: populate only when 0 rows OR 1 empty row. should-populate? (case mode :simple true :advanced (or (empty? existing-accounts) (and (= 1 (count existing-accounts)) (not has-meaningful-accounts?)))) default-account (when (and should-populate? vendor-id client-id) (vendor-default-account vendor-id client-id)) render-request (-> (if (and should-populate? vendor-id client-id) (let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID)) :transaction-account/location (or (:account/location default-account) "Shared") :transaction-account/amount (if (= amount-mode "%") 100.0 total)} default-account (assoc :transaction-account/account (:db/id default-account)))] (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account]) (assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account]))) request) (assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))] (html-response (fc/start-form (:multi-form-state render-request) nil (fc/with-field :step-params (manual-coding-section* mode render-request)))))) (defn edit-wizard-toggle-mode-handler [request] (let [step-params (-> request :multi-form-state :step-params) snapshot (-> request :multi-form-state :snapshot) current-mode (keyword (or (:mode step-params) "simple")) target-mode (if (= current-mode :simple) :advanced :simple) ;; When switching simple→advanced, promote simple-mode values into accounts render-request (if (and (= target-mode :advanced) (= current-mode :simple)) ;; carry the simple-mode single row into snapshot so the table shows it (let [accounts (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot)))] (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] (vec accounts)) (assoc-in [:multi-form-state :step-params :transaction/accounts] (vec accounts)))) ;; advanced→simple: take first row only (let [first-row (first (or (seq (:transaction/accounts step-params)) (seq (:transaction/accounts snapshot))))] (-> request (assoc-in [:multi-form-state :snapshot :transaction/accounts] (if first-row [first-row] [])) (assoc-in [:multi-form-state :step-params :transaction/accounts] (if first-row [first-row] [])))))] (html-response (fc/start-form (:multi-form-state render-request) nil (fc/with-field :step-params (manual-coding-section* target-mode render-request)))))) (def key->handler (apply-middleware-to-all-handlers {::route/edit-wizard (-> mm/open-wizard-handler (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (mm/wrap-wizard edit-wizard) (mm/wrap-init-multi-form-state initial-edit-wizard-state) (wrap-entity [:route-params :db/id] d-transactions/default-read) (wrap-schema-enforce :route-schema [:map [:db/id entity-id]])) ::route/edit-wizard-navigate (-> mm/next-handler (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state)) ::route/edit-submit (-> mm/submit-handler (wrap-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state)) ::route/edit-vendor-changed (-> edit-vendor-changed-handler (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (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) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-decode-multi-form-state)) ::route/account-balance (-> account-balance (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-decode-multi-form-state)) ::route/toggle-amount-mode (-> toggle-amount-mode (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-decode-multi-form-state)) ::route/edit-wizard-toggle-mode (-> edit-wizard-toggle-mode-handler (mm/wrap-wizard edit-wizard) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-decode-multi-form-state)) ::route/edit-wizard-new-account (-> (add-new-entity-handler [:step-params :transaction/accounts] (fn render [cursor request] (let [snapshot (-> request :multi-form-state :snapshot) amount-mode (or (:amount-mode snapshot) "$") total (Math/abs (or (:transaction/amount snapshot) 0.0))] (transaction-account-row* {:value cursor :client-id (:client-id (:query-params request)) :amount-mode amount-mode :total total}))) (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-must {:activity :edit :subject :transaction} (fn get-client [request] (-> request :entity :transaction/client))) (wrap-entity [:multi-form-state :snapshot :db/id] d-transactions/default-read) (mm/wrap-wizard edit-wizard) (mm/wrap-decode-multi-form-state) #_(wrap-schema-enforce :form-schema save-schema))} (fn [h] (-> h (wrap-client-redirect-unauthenticated)))))