1585 lines
88 KiB
Clojure
1585 lines
88 KiB
Clojure
(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)))))
|
|
|
|
|
|
|