Add vendor pre-population for bulk code and individual edit forms

- Add vendor-changed HTMX handlers for both bulk code and individual edit
- Pre-populate default account at 100% when vendor is selected and no accounts exist
- Fix render-accounts-section to render from step-params correctly
- Change bulk code vendor-changed from hx-get to hx-post to include form data
- Add routes for vendor-changed endpoints
- Update e2e tests to cover vendor pre-population
- Run lein cljfmt fix across codebase
This commit is contained in:
2026-05-21 14:45:19 -07:00
parent 8bd0cee1b1
commit ba87805d4c
210 changed files with 8694 additions and 9627 deletions

View File

@@ -66,14 +66,14 @@
(defn get-ids-matching-filters [args]
(alog/info ::getting-ids-matching-filters
:args args)
:args args)
(let [ids (some-> (:filters args)
(assoc :clients (:clients args))
(assoc :id (:id args))
(<-graphql)
(update :approval-status enum->keyword "transaction-approval-status")
(assoc :per-page Integer/MAX_VALUE)
(d-transactions/raw-graphql-ids )
(d-transactions/raw-graphql-ids)
:ids)
specific-ids (d-transactions/filter-ids (seq (:ids args)))]
(if (seq (:ids args))
@@ -83,13 +83,13 @@
(defn all-ids-not-locked [all-ids]
(->> all-ids
(dc/q '[:find ?t
:in $ [?t ...]
:where
[?t :transaction/client ?c]
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
[?t :transaction/date ?d]
[(>= ?d ?lu)]]
(dc/db conn))
:in $ [?t ...]
:where
[?t :transaction/client ?c]
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
[?t :transaction/date ?d]
[(>= ?d ?lu)]]
(dc/db conn))
(map first)))
(defn bulk-change-status [context args _]
(let [_ (assert-admin (:id context))
@@ -98,47 +98,46 @@
all-ids-not-locked)]
(alog/info ::bulk-change-status
:count (count all-ids)
:sample (take 3 all-ids)
:status (:status args)
)
:count (count all-ids)
:sample (take 3 all-ids)
:status (:status args))
(audit-transact-batch
(->> all-ids
(mapv (fn [t]
[:upsert-transaction {:db/id t
:transaction/approval-status (enum->keyword (:status args) "transaction-approval-status")}])))
(:id context))
{:message (str "Succesfully changed " (count all-ids) " transactions to be " (name (:status args) ) ".")}))
{:message (str "Succesfully changed " (count all-ids) " transactions to be " (name (:status args)) ".")}))
;; TODO very similar to rule-matching
(defn maybe-code-accounts [transaction account-rules valid-locations]
(with-precision 2
(let [accounts (vec (mapcat
(fn [ar]
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
(:transaction/amount transaction)
100))))]
(if (= "Shared" (:location ar))
(->> valid-locations
(map
(fn [cents location]
{:db/id (random-tempid)
:transaction-account/account (:account_id ar)
:transaction-account/amount (* 0.01 cents)
:transaction-account/location location})
(rm/spread-cents cents-to-distribute (count valid-locations))))
[(cond-> {:db/id (random-tempid)
:transaction-account/account (:account_id ar)
:transaction-account/amount (* 0.01 cents-to-distribute)}
(:location ar) (assoc :transaction-account/location (:location ar)))])))
account-rules))
(fn [ar]
(let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar)
(:transaction/amount transaction)
100))))]
(if (= "Shared" (:location ar))
(->> valid-locations
(map
(fn [cents location]
{:db/id (random-tempid)
:transaction-account/account (:account_id ar)
:transaction-account/amount (* 0.01 cents)
:transaction-account/location location})
(rm/spread-cents cents-to-distribute (count valid-locations))))
[(cond-> {:db/id (random-tempid)
:transaction-account/account (:account_id ar)
:transaction-account/amount (* 0.01 cents-to-distribute)}
(:location ar) (assoc :transaction-account/location (:location ar)))])))
account-rules))
accounts (mapv
(fn [a]
(update a :transaction-account/amount
#(with-precision 2
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
accounts)
(fn [a]
(update a :transaction-account/amount
#(with-precision 2
(double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP)))))
accounts)
leftover (with-precision 2 (.round (bigdec (- (Math/abs (:transaction/amount transaction))
(Math/abs (reduce + 0.0 (map #(:transaction-account/amount %) accounts)))))
*math-context*))
@@ -152,13 +151,13 @@
(when-not (seq (:clients context))
(throw (ex-info "Client is required"
{:validation-error "Client is required"})))
(let [args (assoc args :clients (:clients context) :id (:id context))
(let [args (assoc args :clients (:clients context) :id (:id context))
client->locations (->> (:clients context)
(map :db/id )
(map :db/id)
(dc/q
'[:find (pull ?e [:db/id :client/locations])
:in $ [?e ...]]
(dc/db conn))
'[:find (pull ?e [:db/id :client/locations])
:in $ [?e ...]]
(dc/db conn))
(map (fn [[client]]
[(:db/id client) (:client/locations client)]))
(into {}))
@@ -166,41 +165,40 @@
transactions (pull-many (dc/db conn) [:db/id :transaction/amount {:transaction/client [:db/id]}] (vec all-ids))
account-total (reduce + 0 (map (fn [x] (:percentage x)) (:accounts args)))]
(alog/info ::bulk-coding-transactions
:count (count transactions)
:sample (take 3 transactions))
:count (count transactions)
:sample (take 3 transactions))
(when
(and
(seq (:accounts args))
(not (dollars= 1.0 account-total)))
(let [error (str "Account total (" account-total ") does not reach 100%")]
(throw (ex-info error {:validation-error error}))))
(and
(seq (:accounts args))
(not (dollars= 1.0 account-total)))
(let [error (str "Account total (" account-total ") does not reach 100%")]
(throw (ex-info error {:validation-error error}))))
(doseq [a (:accounts args)
:let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn)
[:account/location :account/name]
(:account_id a))]]
(when (and location (not= location (:location a)))
(let [err (str "Account " name " uses location " (:location a) ", but is supposed to be " location)]
(throw (ex-info err {:validation-error err}) )))
(throw (ex-info err {:validation-error err}))))
(doseq [[_ locations] client->locations]
(when (and (not location)
(not (get (into #{"Shared"} locations)
(:location a))))
(let [err (str "Account " name " uses location " (:location a) ", but doesn't belong to the client.")]
(throw (ex-info err {:validation-error err}) )))))
(throw (ex-info err {:validation-error err}))))))
(audit-transact-batch
(map (fn [t]
(let [locations (client->locations (-> t :transaction/client :db/id))]
(doto
[:upsert-transaction (cond-> t
(:approval_status args) (assoc :transaction/approval-status (enum->keyword (:approval_status args) "transaction-approval-status"))
(:vendor args) (assoc :transaction/vendor (:vendor args))
(seq (:accounts args)) (assoc :transaction/accounts (maybe-code-accounts t (:accounts args) locations)))]
clojure.pprint/pprint)))
transactions)
(:id context))
(map (fn [t]
(let [locations (client->locations (-> t :transaction/client :db/id))]
(doto
[:upsert-transaction (cond-> t
(:approval_status args) (assoc :transaction/approval-status (enum->keyword (:approval_status args) "transaction-approval-status"))
(:vendor args) (assoc :transaction/vendor (:vendor args))
(seq (:accounts args)) (assoc :transaction/accounts (maybe-code-accounts t (:accounts args) locations)))]
clojure.pprint/pprint)))
transactions)
(:id context))
{:message (str "Successfully coded " (count all-ids) " transactions.")}))
(defn delete-transactions [context args _]
(let [_ (assert-admin (:id context))
args (assoc args :clients (:clients context))
@@ -208,24 +206,24 @@
db (dc/db conn)]
(alog/info ::bulk-delete-transactions
:count (count all-ids)
:sample (take 3 all-ids))
:count (count all-ids)
:sample (take 3 all-ids))
(audit-transact-batch
(mapcat (fn [i]
(let [transaction (dc/pull db [:transaction/payment
:transaction/expected-deposit
:db/id] i)
payment-id (-> transaction :transaction/payment :db/id)
expected-deposit-id (-> transaction :transaction/expected-deposit :db/id)]
(cond->> [[:db/retractEntity [:journal-entry/original-entity i]]]
payment-id (into [{:db/id payment-id
:payment/status :payment-status/pending}
[:db/retract (:db/id transaction) :transaction/payment payment-id]])
expected-deposit-id (into [{:db/id expected-deposit-id
:expected-deposit/status :expected-deposit-status/pending}
[:db/retract (:db/id transaction) :transaction/expected-deposit expected-deposit-id]]))))
all-ids)
(:id context))
(mapcat (fn [i]
(let [transaction (dc/pull db [:transaction/payment
:transaction/expected-deposit
:db/id] i)
payment-id (-> transaction :transaction/payment :db/id)
expected-deposit-id (-> transaction :transaction/expected-deposit :db/id)]
(cond->> [[:db/retractEntity [:journal-entry/original-entity i]]]
payment-id (into [{:db/id payment-id
:payment/status :payment-status/pending}
[:db/retract (:db/id transaction) :transaction/payment payment-id]])
expected-deposit-id (into [{:db/id expected-deposit-id
:expected-deposit/status :expected-deposit-status/pending}
[:db/retract (:db/id transaction) :transaction/expected-deposit expected-deposit-id]]))))
all-ids)
(:id context))
(audit-transact-batch
(mapcat (fn [i]
(let [transaction-tx (if (:suppress args)
@@ -242,21 +240,21 @@
(assert-power-user (:id context))
(let [transaction (d-transactions/get-by-id (:transaction_id args))
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
_ (assert-can-see-client (:id context) (:transaction/client transaction))
matches-set (i-transactions/match-transaction-to-unfulfilled-autopayments (:transaction/amount transaction)
(:db/id (:transaction/client transaction)))]
(->graphql (for [matches matches-set]
(for [[_ invoice-id ] matches]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn get-potential-unpaid-invoices-matches [context args _]
(assert-power-user (:id context))
(let [transaction (d-transactions/get-by-id (:transaction_id args))
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
_ (assert-can-see-client (:id context) (:transaction/client transaction))
matches-set (i-transactions/match-transaction-to-unpaid-invoices (:transaction/amount transaction)
(:db/id (:transaction/client transaction)))]
(->graphql (for [matches matches-set]
(for [[_ invoice-id ] matches]
(for [[_ invoice-id] matches]
(d-invoices/get-by-id invoice-id))))))
(defn unlink-transaction [context args _]
@@ -264,20 +262,20 @@
args (assoc args :id (:id context))
transaction-id (:transaction_id args)
transaction (dc/pull (dc/db conn)
[:transaction/approval-status
:transaction/status
:transaction/date
:transaction/location
:transaction/vendor
:transaction/accounts
:transaction/client [:db/id]
{:transaction/payment [:payment/date {:payment/status [:db/ident]} :db/id]} ]
transaction-id)
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
[:transaction/approval-status
:transaction/status
:transaction/date
:transaction/location
:transaction/vendor
:transaction/accounts
:transaction/client [:db/id]
{:transaction/payment [:payment/date {:payment/status [:db/ident]} :db/id]}]
transaction-id)
_ (assert-can-see-client (:id context) (:transaction/client transaction))
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))
_ (when (:transaction/payment transaction)
(assert-not-locked (:db/id (:transaction/client transaction)) (-> transaction :transaction/payment :payment/date)))
payment (-> transaction :transaction/payment )
payment (-> transaction :transaction/payment)
is-autopay-payment? (some->> (dc/q {:find ['?sp]
:in ['$ '?payment]
:where ['[?ip :invoice-payment/payment ?payment]
@@ -286,8 +284,7 @@
(dc/db conn) (:db/id payment))
seq
(map first)
(every? #(instance? java.util.Date %)))
]
(every? #(instance? java.util.Date %)))]
(alog/info ::unlinking :transaction (pr-str transaction) :autopay is-autopay-payment? :payment (pr-str payment))
@@ -295,49 +292,47 @@
(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."})))
(if is-autopay-payment?
(audit-transact
(-> [{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id transaction-id
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]
(-> [{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id transaction-id
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]
[:db/retractEntity (:db/id payment) ]]
[: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) ))))
(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)))))
(:id context))
(audit-transact
[{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id transaction-id
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]]
[{:db/id (:db/id payment)
:payment/status :payment-status/pending}
[:upsert-transaction
{:db/id transaction-id
:transaction/approval-status :transaction-approval-status/unapproved
:transaction/payment nil
:transaction/vendor nil
:transaction/location nil
:transaction/accounts nil}]]
(:id context)))
(-> (d-transactions/get-by-id transaction-id)
approval-status->graphql
->graphql)))
(defn transaction-account->entity [{:keys [id account_id amount location]}]
#:transaction-account {:amount amount
:db/id (or id (random-tempid))
:account account_id
:location location})
(defn assert-valid-expense-accounts [accounts]
(doseq [trans-account accounts
:let [account (dc/pull (dc/db conn)
@@ -351,7 +346,7 @@
(:account/location account)))
(let [err (str "Account uses location '" (:location trans-account) "' but expects '" (:account/location account) "'")]
(throw (ex-info err
{:validation-error err}))))
{:validation-error err}))))
(when (and (empty? (:account/location account))
(= "A" (:location trans-account)))
@@ -359,13 +354,12 @@
(throw (ex-info err
{:validation-error err}))))
(when (nil? (:account_id trans-account))
(throw (ex-info "Account is missing account" {:validation-error "Account is missing account"})))))
(defn edit-transaction [context {{:keys [id accounts vendor_id approval_status memo forecast_match]} :transaction} _]
(let [existing-transaction (d-transactions/get-by-id id)
_ (assert-can-see-client (:id context) (:transaction/client existing-transaction) )
_ (assert-can-see-client (:id context) (:transaction/client existing-transaction))
_ (assert-valid-expense-accounts accounts)
_ (assert-not-locked (:db/id (:transaction/client existing-transaction)) (:transaction/date existing-transaction))
account-total (reduce + 0 (map (fn [x] (:amount x)) accounts))
@@ -378,17 +372,17 @@
set
(conj "A")
(conj "HQ"))))]
(when (and (not (dollars= (Math/abs (:transaction/amount existing-transaction)) account-total))
(or
(and (= approval_status :unapproved)
(> (count accounts) 0))
(not= approval_status :unapproved)))
(not= approval_status :unapproved)))
(let [error (str "Expense account total (" account-total ") does not equal transaction total (" (Math/abs (:transaction/amount existing-transaction)) ")")]
(throw (ex-info error {:validation-error error}))))
(throw (ex-info error {:validation-error error}))))
(when missing-locations
(throw (ex-info (str "Location '" (str/join ", " missing-locations) "' not found on client.") {})) )
(throw (ex-info (str "Location '" (str/join ", " missing-locations) "' not found on client.") {})))
(audit-transact (cond-> [[:upsert-transaction {:db/id id
:transaction/vendor vendor_id
:transaction/memo memo
@@ -413,8 +407,8 @@
(defn match-transaction [context {:keys [transaction_id payment_id]} _]
(let [transaction (d-transactions/get-by-id transaction_id)
payment (d-checks/get-by-id payment_id)
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
_ (assert-can-see-client (:id context) (:payment/client payment) )
_ (assert-can-see-client (:id context) (:transaction/client transaction))
_ (assert-can-see-client (:id context) (:payment/client payment))
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))]
(when (not= (:db/id (:transaction/client transaction))
(:db/id (:payment/client payment)))
@@ -423,7 +417,7 @@
(when-not (dollars= (- (:transaction/amount transaction))
(:payment/amount payment))
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(audit-transact (into
(audit-transact (into
[{:db/id (:db/id payment)
:payment/status :payment-status/cleared
:payment/date (coerce/to-date (first (sort [(:payment/date payment)
@@ -431,14 +425,14 @@
[:upsert-transaction
{:db/id (:db/id transaction)
:transaction/payment (:db/id payment)
:transaction/vendor (:db/id (:payment/vendor payment))
:transaction/location "A"
:transaction/approval-status :transaction-approval-status/approved
:transaction/accounts [{:db/id (random-tempid)
:transaction-account/account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"]))
:transaction-account/location "A"
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]}]])
:transaction/payment (:db/id payment)
:transaction/vendor (:db/id (:payment/vendor payment))
:transaction/location "A"
:transaction/approval-status :transaction-approval-status/approved
:transaction/accounts [{:db/id (random-tempid)
:transaction-account/account (:db/id (a/get-account-by-numeric-code-and-sets 21000 ["default"]))
:transaction-account/location "A"
:transaction-account/amount (Math/abs (:transaction/amount transaction))}]}]])
(:id context)))
(solr/touch-with-ledger transaction_id)
(-> (d-transactions/get-by-id transaction_id)
@@ -448,7 +442,7 @@
(defn match-transaction-autopay-invoices [context {:keys [transaction_id autopay_invoice_ids]} _]
(let [_ (assert-power-user (:id context))
transaction (d-transactions/get-by-id transaction_id)
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
_ (assert-can-see-client (:id context) (:transaction/client transaction))
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))
@@ -474,9 +468,9 @@
(:db/id (:transaction/bank-account transaction))
(:db/id (:transaction/client transaction)))]
(alog/info ::adding-payment-from-autopay-invoice
:payment (pr-str payment-tx))
:payment (pr-str payment-tx))
(audit-transact payment-tx (:id context)))
(solr/touch-with-ledger transaction_id)
(solr/touch-with-ledger transaction_id)
(-> (d-transactions/get-by-id transaction_id)
approval-status->graphql
->graphql)))
@@ -485,8 +479,8 @@
(defn match-transaction-unpaid-invoices [context {:keys [transaction_id unpaid_invoice_ids]} _]
(let [_ (assert-power-user (:id context))
transaction (d-transactions/get-by-id transaction_id)
_ (assert-can-see-client (:id context) (:transaction/client transaction) )
_ (assert-can-see-client (:id context) (:transaction/client transaction))
_ (assert-not-locked (:db/id (:transaction/client transaction)) (:transaction/date transaction))
db (dc/db conn)
invoice-clients (set (map #(pull-ref db :invoice/client %) unpaid_invoice_ids))
@@ -502,17 +496,17 @@
(throw (ex-info "Amounts don't match" {:validation-error "Amounts don't match"})))
(when (:transaction/payment transaction)
(throw (ex-info "Transaction already linked" {:validation-error "Transaction already linked"})))
(let [payment-tx (i-transactions/add-new-payment (dc/pull db [:transaction/amount :transaction/date :db/id] transaction_id)
(map (fn [id]
(let [entity (dc/pull db [:invoice/vendor :db/id :invoice/total] id)]
[(or (-> entity :invoice/vendor :db/id)
(-> entity :invoice/vendor))
(-> entity :db/id)
(-> entity :invoice/total)]))
unpaid_invoice_ids)
(:db/id (:transaction/bank-account transaction))
(:db/id (:transaction/client 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)
(:db/id (:transaction/bank-account transaction))
(:db/id (:transaction/client transaction)))]
(audit-transact payment-tx (:id context)))
(solr/touch-with-ledger transaction_id)
@@ -527,9 +521,8 @@
:count Integer/MAX_VALUE} nil)
(filter #(not (:payment %)))
(map :id ))
(map :id))
transaction_ids)
_ (mu/log ::here :txids transaction_ids)
transaction_ids (all-ids-not-locked transaction_ids)
@@ -553,17 +546,16 @@
(audit-transact (mapv (fn [t]
[:upsert-transaction
(remove-nils (rm/apply-rule {:db/id (:db/id t)
:transaction/amount (:transaction/amount t)}
transaction-rule
:transaction/amount (:transaction/amount t)}
transaction-rule
(or (-> t :transaction/bank-account :bank-account/locations)
(-> t :transaction/client :client/locations))))])
(or (-> t :transaction/bank-account :bank-account/locations)
(-> t :transaction/client :client/locations))))])
transactions)
(:id context))
(doseq [n transactions]
(solr/touch-with-ledger (:db/id n)))
)
(solr/touch-with-ledger (:db/id n))))
(transduce
(comp
(map d-transactions/get-by-id)
@@ -571,12 +563,12 @@
(map ->graphql))
conj
[]
transaction_ids ))
transaction_ids))
(def objects
{:transaction {:fields {:id {:type :id}
:amount {:type 'String}
:memo {:type 'String}
:memo {:type 'String}
:is_locked {:type 'Boolean}
:description_original {:type 'String}
:description_simple {:type 'String}
@@ -628,8 +620,8 @@
:resolve :mutation/bulk-code-transactions}
:delete_transactions {:type :message
:args {:filters {:type :transaction_filters}
:ids {:type '(list :id)}
:suppress {:type 'Boolean}}
:ids {:type '(list :id)}
:suppress {:type 'Boolean}}
:resolve :mutation/delete-transactions}
:edit_transaction {:type :transaction
:args {:transaction {:type :edit_transaction}}
@@ -711,9 +703,8 @@
:mutation/match-transaction-unpaid-invoices match-transaction-unpaid-invoices
:mutation/match-transaction-rules match-transaction-rules})
(defn attach [schema]
(->
(->
(merge-with merge schema
{:objects objects
:queries queries