(ns auto-ap.ssr.invoices (:require [auto-ap.client-routes :as client-routes] [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-3 audit-transact audit-transact-batch conn merge-query observable-query pull-many]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.datomic.bank-accounts :as d-bank-accounts] [auto-ap.datomic.clients :as d-clients] [auto-ap.datomic.invoices :as d-invoices] [auto-ap.graphql.checks :as gq-checks :refer [base-payment invoice-payments print-checks-internal validate-belonging]] [auto-ap.graphql.utils :refer [assert-can-see-client assert-not-locked assert-not-locked-ssr exception->4xx exception->notification extract-client-ids notify-if-locked]] [auto-ap.logging :as alog] [auto-ap.permissions :refer [can? wrap-must]] [auto-ap.query-params :refer [wrap-copy-qp-pqp]] [auto-ap.routes.invoice :as route] [auto-ap.routes.payments :as payment-route] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.rule-matching :as rm] [auto-ap.solr :as solr] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.common-handlers :refer [add-new-entity-handler]] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.components.multi-modal :as mm] [auto-ap.ssr.form-cursor :as fc] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hiccup-helper :as hh] [auto-ap.ssr.hx :as hx] [auto-ap.ssr.invoice.common :refer [default-read]] [auto-ap.ssr.invoice.import :as invoice-import] [auto-ap.ssr.invoice.new-invoice-wizard :as new-invoice-wizard :refer [location-select*]] [auto-ap.ssr.components.date-range :as dr] [auto-ap.ssr.svg :as svg] [auto-ap.ssr.utils :refer [apply-middleware-to-all-handlers assert-schema clj-date-schema dissoc-nil-transformer entity-id form-validation-error html-response main-transformer many-entity modal-response money percentage ref->enum-schema round-money strip wrap-entity wrap-implied-route-param wrap-merge-prior-hx wrap-schema-enforce]] [auto-ap.time :as atime] [auto-ap.utils :refer [by dollars-0? dollars=]] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clj-time.coerce :as c] [clj-time.core :as time] [clojure.string :as str] [datomic.api :as dc] [hiccup.util :as hu] [iol-ion.utils :refer [random-tempid]] [malli.core :as mc] [malli.transform :as mt] [malli.util :as mut] [slingshot.slingshot :refer [try+]])) (defn exact-match-id* [request] (if (nat-int? (:exact-match-id (:query-params request))) [:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"} (com/hidden {:name "exact-match-id" "x-model" "exact_match"}) (com/pill {:color :primary} [:span.inline-flex.space-x-2.items-center [:div "exact match"] [:div.w-3.h-3 (com/link {"@click" "exact_match=null; $nextTick(() => $dispatch('change'))"} svg/x)]])] [:div {:id "exact-match-id-tag"}])) (defn filters [request] [:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms" "hx-get" (bidi/path-for ssr-routes/only-routes ::route/table) "hx-target" "#entity-table" "hx-indicator" "#entity-table"} (com/hidden {:name "status" :value (some-> (:status (:query-params request)) name)}) [:fieldset.space-y-6 (com/field {:label "Vendor"} (com/typeahead {:name "vendor" :id "vendor" :url (bidi/path-for ssr-routes/only-routes :vendor-search) :value (:vendor (:query-params request)) :value-fn :db/id :content-fn :vendor/name :class "filter-trigger"})) (com/field {:label "Account"} (com/typeahead {:name "account" :id "account" :url (bidi/path-for ssr-routes/only-routes :account-search) :value (:account (:query-params request)) :value-fn :db/id :content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %)) (:db/id (:client request)))) :class "filter-trigger"})) (dr/date-range-field {:value {:start (:start-date (:query-params request)) :end (:end-date (:query-params request))} :id "date-range" :apply-button? true}) (com/field {:label "Check #"} (com/text-input {:name "check-number" :id "check-number" :class "hot-filter" :value (:check-number (:query-params request)) :placeholder "e.g., 10001" :size :small})) (com/field {:label "Invoice #"} (com/text-input {:name "invoice-number" :id "invoice-number" :class "hot-filter" :value (:invoice-number (:query-params request)) :placeholder "e.g., ABC-456" :size :small})) (com/field {:label "Amount"} [:div.flex.space-x-4.items-baseline (com/money-input {:name "amount-gte" :id "amount-gte" :hx-preserve "true" :class "hot-filter w-20" :value (:amount-gte (:query-params request)) :placeholder "0.01" :size :small}) [:div.align-baseline "to"] (com/money-input {:name "amount-lte" :hx-preserve "true" :id "amount-lte" :class "hot-filter w-20" :value (:amount-lte (:query-params request)) :placeholder "9999.34" :size :small})]) (exact-match-id* request)]]) (defn fetch-ids [db {:keys [query-params route-params] :as request}] (let [valid-clients (extract-client-ids (:clients request) (:client-id request) (when (:client-code request) [:client/code (:client-code request)])) query (if (:exact-match-id query-params) {:query {:find '[?e] :in '[$ ?e [?c ...]] :where '[[?e :invoice/client ?c]]} :args [db (:exact-match-id query-params) valid-clients]} (cond-> {:query {:find [] :in '[$ [?clients ?start ?end]] :where '[[(iol-ion.query/scan-invoices $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]} :args [db [valid-clients (some-> (:start-date query-params) coerce/to-date) (some-> (:end-date query-params) coerce/to-date)]]} (:client-id query-params) (merge-query {:query {:in ['?client-id] :where ['[?e :invoice/client ?client-id]]} :args [(:client-id query-params)]}) (:client-code query-params) (merge-query {:query {:in ['?client-code] :where ['[?e :invoice/client ?client-id] '[?client-id :client/code ?client-code]]} :args [(:client-code query-params)]}) (:start (:due-range query-params)) (merge-query {:query {:in '[?start-due] :where ['[?e :invoice/due ?due] '[(>= ?due ?start-due)]]} :args [(coerce/to-date (:start (:due-range query-params)))]}) (:end (:due-range query-params)) (merge-query {:query {:in '[?end-due] :where ['[?e :invoice/due ?due] '[(<= ?due ?end-due)]]} :args [(coerce/to-date (:end (:due-range query-params)))]}) (:import-status query-params) (merge-query {:query {:in ['?import-status] :where ['[?e :invoice/import-status ?import-status]]} :args [(:import-status query-params)]}) (not (:import-status query-params)) (merge-query {:query { :where ['[?e :invoice/import-status :import-status/imported]]} }) (:status route-params) (merge-query {:query {:in ['?status] :where ['[?e :invoice/status ?status]]} :args [(:status route-params)]}) (:vendor query-params) (merge-query {:query {:in ['?vendor-id] :where ['[?e :invoice/vendor ?vendor-id]]} :args [(:db/id (:vendor query-params))]}) (:account query-params) (merge-query {:query {:in ['?account-id] :where ['[?e :invoice/expense-accounts ?iea ?] '[?iea :invoice-expense-account/account ?account-id]]} :args [(:db/id (:account query-params))]}) (:amount-gte query-params) (merge-query {:query {:in ['?amount-gte] :where ['[?e :invoice/total ?total-filter] '[(>= ?total-filter ?amount-gte)]]} :args [(:amount-gte query-params)]}) (:amount-lte query-params) (merge-query {:query {:in ['?amount-lte] :where ['[?e :invoice/total ?total-filter] '[(<= ?total-filter ?amount-lte)]]} :args [(:amount-lte query-params)]}) (not-empty (:invoice-number query-params)) (merge-query {:query {:in ['?invoice-number-like] :where ['[?e :invoice/invoice-number ?invoice-number] '[(.contains ^String ?invoice-number ?invoice-number-like)]]} :args [(:invoice-number query-params)]}) (:scheduled-payments query-params) (merge-query {:query {:in [] :where ['[?e :invoice/scheduled-payment]]} :args []}) (:unresolved query-params) (merge-query {:query {:in [] :where ['(or-join [?e] (not [?e :invoice/expense-accounts]) (and [?e :invoice/expense-accounts ?ea] (not [?ea :invoice-expense-account/account])))]} :args []}) (seq (:location query-params)) (merge-query {:query {:in ['?location] :where ['[?e :invoice/expense-accounts ?eas] '[?eas :invoice-expense-account/location ?location]]} :args [(:location query-params)]}) (:sort query-params) (add-sorter-fields {"client" ['[?e :invoice/client ?c] '[?c :client/name ?sort-client]] "vendor" ['[?e :invoice/vendor ?v] '[?v :vendor/name ?sort-vendor]] "description-original" ['[?e :transaction/description-original ?sort-description-original]] "location" ['[?e :invoice/expense-accounts ?iea] '[?iea :invoice-expense-account/location ?sort-location]] "date" ['[?e :invoice/date ?sort-date]] "due" ['[(get-else $ ?e :invoice/due #inst "2050-01-01") ?sort-due]] "invoice-number" ['[?e :invoice/invoice-number ?sort-invoice-number]] "total" ['[?e :invoice/total ?sort-total]] "outstanding-balance" ['[?e :invoice/outstanding-balance ?sort-outstanding-balance]]} query-params) true (merge-query {:query {:find ['?sort-default '?e]}})))] (->> (observable-query query) (apply-sort-3 (assoc query-params :default-asc? false)) (apply-pagination query-params)))) (defn hydrate-results [ids db _] (let [results (->> (pull-many db default-read ids) (group-by :db/id)) refunds (->> ids (map results) (map first))] refunds)) (defn sum-outstanding [ids] (->> (dc/q {:find ['?id '?o] :in ['$ '[?id ...]] :where ['[?id :invoice/outstanding-balance ?o]]} (dc/db conn) ids) (map last) (reduce + 0.0))) (defn sum-total-amount [ids] (->> (dc/q {:find ['?id '?o] :in ['$ '[?id ...]] :where ['[?id :invoice/total ?o]] } (dc/db conn) ids) (map last) (reduce + 0.0))) (defn fetch-page [request] (let [db (dc/db conn) {ids-to-retrieve :ids matching-count :count all-ids :all-ids} (fetch-ids db request)] [(->> (hydrate-results ids-to-retrieve db request)) matching-count (sum-outstanding all-ids) (sum-total-amount all-ids)])) (def query-schema (mc/schema [:maybe [:map {:date-range [:date-range :start-date :end-date]} [:sort {:optional true} [:maybe [:any]]] [:per-page {:optional true :default 25} [:maybe :int]] [:start {:optional true :default 0} [:maybe :int]] [:amount-gte {:optional true} [:maybe :double]] [:amount-lte {:optional true} [:maybe :double]] [:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]] [:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]] [:check-number {:optional true} [:maybe [:string {:decode/string strip}]]] [:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]] [:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]] [:exact-match-id {:optional true} [:maybe entity-id]] [:all-selected {:optional true :default nil} [:maybe :boolean]] [:selected {:optional true :default nil} [:maybe [:vector {:coerce? true} entity-id]]] [:start-date {:optional true} [:maybe clj-date-schema]] [:end-date {:optional true} [:maybe clj-date-schema]]]])) (comment (mc/decode query-schema {:start " "} main-transformer)) (defn selected->ids [request params] (let [all-selected (:all-selected params) selected (:selected params) ids (cond all-selected (:ids (fetch-ids (dc/db conn) (-> request (assoc :query-params params) (assoc-in [:query-params :start] 0) (assoc-in [:query-params :per-page] 250)))) :else selected)] ids)) (defn assert-can-undo-autopayment [invoice] (assert-not-locked-ssr (:db/id (:invoice/client invoice)) (:invoice/date invoice)) (when (not= :invoice-status/paid (:invoice/status invoice)) (throw (ex-info "This invoice isn't paid." {:type :warning}))) (when-not (:invoice/scheduled-payment invoice) (throw (ex-info "This invoice didn't have a scheduled payment" {:type :warning}))) (when (:invoice/payments invoice) (throw (ex-info "This invoice has a linked payment. Void the payment to undo the payment." {:type :warning})))) (defn can-undo-autopayment [invoice] (try+ (assert-can-undo-autopayment invoice) true (catch [:type :warning] {} false))) (defn pay-button* [params] (let [ids (:ids params) ids (if (seq ids) (map first (dc/q '[:find ?i :in $ [?i ...] :where (not [?i :invoice/scheduled-payment])] (dc/db conn) ids)) ids) selected-client-count (if (seq ids) (ffirst (dc/q '[:find (count ?c) :in $ [?i ...] :where [?i :invoice/client ?c]] (dc/db conn) ids)) 0) outstanding-balances (if (seq ids) (->> (dc/q '[:find ?i ?v ?ob :in $ [?i ...] :where [?i :invoice/vendor ?v] [?i :invoice/outstanding-balance ?ob]] (dc/db conn) ids))) vendor-totals (if (seq ids) (->> outstanding-balances (reduce (fn [acc [_ v ob]] (update acc v (fnil + 0) ob)) {}) (vals))) all-credits-or-debits (or (every? #(<= % 0.0) vendor-totals) (every? #(>= % 0.0) vendor-totals)) at-least-one-positive-payment (some (fn [[_ _ ob]] (> ob 0.001)) outstanding-balances) total (reduce + 0.0 vendor-totals) paying-credit? (and (> (count ids) 1) (= 1 (count vendor-totals)) at-least-one-positive-payment (dollars-0? total))] [:div (cond-> {:hx-target "this" :hx-trigger "click from:#pay-button" :x-tooltip "{allowHTML: true, content: () => $refs.template.innerHTML, appendTo: $root}"} paying-credit? (assoc :hx-post (bidi/path-for ssr-routes/only-routes ::route/pay-using-credit)) (not paying-credit? ) (assoc :hx-get (bidi/path-for ssr-routes/only-routes ::route/pay-wizard))) (com/button {:color :primary :id "pay-button" :disabled (or (= (count (:ids params)) 0) (not= 1 selected-client-count) (not all-credits-or-debits)) "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" "hx-include" "#invoice-filters" :hx-get (bidi/path-for ssr-routes/only-routes ::route/pay-button) :hx-swap "outerHTML" :hx-trigger "selectedChanged from:body, htmx:afterSwap from:#entity-table" :x-ref "source" :minimal-loading? true :class "relative"} (cond paying-credit? "Pay invoices using credit" (> (count ids) 0) (format "Pay %d invoices ($%,.2f)" (count ids) (or total 0.0)) (or (= 0 (count ids)) (> selected-client-count 1)) (list "Pay " (com/badge {} "!")) :else "Pay")) [:template {:x-ref "template"} (cond (not all-credits-or-debits) [:div "All vendor totals must be either positive or negative."] (and (= 0 (count ids)) (not= (count (:ids params)) 0)) [:div "No " [:span.font-bold "payable"] " invoices selected."] (= 0 (count ids)) [:div "Please select some invoices to pay"] (> selected-client-count 1) [:div "Can only pay for one client at a time"] :else [:div "Click to choose a bank account"])]])) (defn pay-button [request] (html-response (pay-button* {:ids (selected->ids request (:query-params request))}))) ;; TODO test as a real user (def grid-page (helper/build {:id "entity-table" :nav com/main-aside-nav :check-boxes? true :check-box-warning? (fn [e] (some? (:invoice/scheduled-payment e))) :page-specific-nav filters :fetch-page fetch-page :oob-render (fn [request] [(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request)) :end (:end-date (:query-params request))} :id "date-range" :apply-button? true}) [1 :hx-swap-oob] true) (assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) :query-schema query-schema :parse-query-params (fn [p] (alog/peek ::PARSE (mc/decode query-schema p main-transformer))) :action-buttons (fn [request] (let [[_ _ outstanding total] (:page-results request)] [(com/pill {:color :primary} "Outstanding: " (format "$%,.2f" outstanding)) (com/pill {:color :secondary} "Total: " (format "$%,.2f" total)) (when (can? (:identity request) {:subject :invoice :activity :bulk-delete}) (com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)) "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" "hx-include" "#invoice-filters" :color :red} "Void selected")) (when (can? (:identity request) {:subject :invoice :activity :bulk-edit}) (com/button {:hx-get (str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit)) "x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})" "hx-include" "#invoice-filters" :color :secondary} "Bulk Edit")) (when (can? (:identity request) {:subject :invoice :activity :pay}) (pay-button* {:ids (selected->ids request (:query-params request))})) (when (can? (:identity request) {:subject :invoice :activity :create}) (com/button {:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-wizard)} "New invoice"))])) :row-buttons (fn [request entity] [(when (and (= :invoice-status/unpaid (:invoice/status entity)) (can? (:identity request) {:subject :invoice :activity :delete})) (com/icon-button {:hx-delete (bidi/path-for ssr-routes/only-routes ::route/delete :db/id (:db/id entity)) :hx-confirm "Are you sure you want to void this invoice?"} svg/trash)) (when (and (can? (:identity request) {:subject :invoice :activity :edit}) (#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity))) (com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes ::route/edit-wizard :db/id (:db/id entity))} svg/pencil)) (when (and (can? (:identity request) {:subject :invoice :activity :edit}) (#{:invoice-status/voided} (:invoice/status entity))) (com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes ::route/unvoid :db/id (:db/id entity))} svg/undo)) (when (and (can? (:identity request) {:subject :invoice :activity :edit}) (can-undo-autopayment entity) ) (com/button {:hx-put (bidi/path-for ssr-routes/only-routes ::route/undo-autopay :db/id (:db/id entity))} "Undo autopay"))]) :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Invoices"]] :title (fn [r] (str (some-> r :route-params :status name str/capitalize (str " ")) "Invoices")) :entity-name "invoices" :route ::route/table :break-table (fn [request entity] (when (= (-> request :query-params :sort first :name) "Vendor") (-> entity :invoice/vendor :vendor/name))) :headers [{:key "client" :name "Client" :sort-key "client" :hide? (fn [args] (and (= (count (:clients args)) 1) (= 1 (count (:client/locations (:client args)))))) :render (fn [x] [:div.flex.items-center.gap-2 (-> x :invoice/client :client/name) (map #(com/pill {:color :primary} (-> % :invoice-expense-account/location)) (:invoice/expense-accounts x)) ])} {:key "vendor" :name "Vendor" :sort-key "vendor" :render #(-> % :invoice/vendor :vendor/name)} {:key "invoice-number" :name "Invoice number" :sort-key "invoice-number" :render :invoice/invoice-number} {:key "date" :sort-key "date" :name "Date" :show-starting "lg" :render (fn [{:invoice/keys [date]}] (some-> date (atime/unparse-local atime/normal-date)))} {:key "due" :sort-key "due" :name "Due" :show-starting "xl" ;; xl:table-cell :render (fn [{:invoice/keys [due]}] (if-let [due-date (some-> due (atime/unparse-local atime/normal-date)) ] (let [ today (time/now) [start end] (if (time/before? due today) [due today] [today due]) i (time/interval start end ) days (if (time/before? due today) (- (time/in-days i)) (time/in-days i))] (cond (= 0 days) [:div.text-primary-700 "today"] (> days 0) [:div.text-primary-700 (format "in %d days", days)] :else [:div.text-red-700 (format "%d days ago", (- days))]))))} {:key "status" :name "Status" :render (fn [{:invoice/keys [status scheduled-payment]}] (cond (= status :invoice-status/paid) (com/pill {:color :primary} "Paid") (= status :invoice-status/voided) (com/pill {:color :red} "Voided") scheduled-payment (com/pill {:color :yellow} "Scheduled") (= status :invoice-status/unpaid) (com/pill {:color :secondary} "Unpaid") :else ""))} {:key "accounts" :name "Account" :show-starting "lg" :render (fn [{:invoice/keys [expense-accounts client]}] [:div.flex.flex-col.gap-y-2 (when (first expense-accounts) [:div.flex-initial (com/pill {:color :primary} (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id (:invoice-expense-account/account (first expense-accounts)))) (:db/id client))))]) (when (> (count expense-accounts) 1) [:div.flex-initial (com/pill {:color :secondary} "+ " (dec (count expense-accounts)) " more")])])} {:key "outstanding" :name "Outstanding" :sort-key "outstanding-balance" :class "text-right" :render (fn [{:invoice/keys [outstanding-balance total]}] [:div (some->> outstanding-balance (format "$%,.2f")) (when-not (dollars= outstanding-balance total) [:div.text-xs.text-gray-400 (format "of $%,.2f" total)])])} {:key "links" :name "Links" :show-starting "lg" :class "w-8" :render (fn [i] (link-dropdown (into [] (concat (->> i :invoice/payments (map :invoice-payment/payment) (filter (fn [p] (not= :payment-status/voided (:payment/status p)))) (mapcat (fn [p] (cond-> [{:link (hu/url (bidi/path-for ssr-routes/only-routes ::payment-route/all-page) {:exact-match-id (:db/id p)}) :content (str (format "$%,.2f" (:payment/amount p)) (some-> (:payment/date p) coerce/to-date-time (atime/unparse-local atime/normal-date) (#(str " payment on " %))))}] (:payment/transaction p) (conj {:link (hu/url (bidi/path-for client-routes/routes :transactions) {:exact-match-id (:db/id (first (:payment/transaction p)))}) :color :secondary :content "Transaction"}))))) (when (:invoice/journal-entry i) [{:link (hu/url (bidi/path-for client-routes/routes :ledger) {:exact-match-id (:db/id (first (:invoice/journal-entry i)))}) :color :yellow :content "Ledger entry"}]) (when (:invoice/source-url i) [{:link (:invoice/source-url i) :color :secondary :content "File"}])))))}]})) (def row* (partial helper/row* grid-page)) (defn unvoid-invoice [{:as request :keys [identity entity]}] (let [invoice entity id (:db/id entity) _ (assert-can-see-client identity (:db/id (:invoice/client invoice))) _ (assert-not-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) history (dc/history (dc/db conn)) txs (dc/q {:find ['?tx '?e '?original-status '?original-outstanding '?total '?ea '?ea-amount] :where ['[?e :invoice/status :invoice-status/voided ?tx true] '[?e :invoice/status ?original-status ?tx false] '[?e :invoice/outstanding-balance ?original-outstanding ?tx false] '[?e :invoice/total ?total ?tx false] '[?ea :invoice-expense-account/amount ?ea-amount ?tx false]] :in ['$ '?e]} history id) [last-transaction] (->> txs (sort-by first) (last)) tx [[:upsert-invoice (->> txs (filter (fn [[tx]] (= tx last-transaction))) (reduce (fn [new-transaction [_ entity original-status original-outstanding total expense-account expense-account-amount]] (-> new-transaction (assoc :db/id entity :invoice/total total :invoice/status original-status :invoice/outstanding-balance original-outstanding) (update :invoice/expense-accounts (fnil conj []) {:db/id expense-account :invoice-expense-account/amount expense-account-amount}))) {}))]] _ (audit-transact tx identity)] (alog/info ::unvoiding-invoice :transaction :tx) (html-response (row* identity (dc/pull (dc/db conn) default-read id) {:flash? true :request request}) :headers (cond-> {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" id) "hx-reswap" "outerHTML"})))) (defn undo-autopay [{:as request :keys [identity entity]}] (let [invoice entity id (:db/id entity) _ (assert-can-see-client identity (:db/id (:invoice/client invoice))) ] (alog/info ::undoing-autopay :transaction :tx) (assert-can-undo-autopayment invoice) (audit-transact [[:upsert-invoice {:db/id id :invoice/status :invoice-status/unpaid :invoice/outstanding-balance (:invoice/total entity) :invoice/scheduled-payment nil}]] identity) (html-response (row* identity (dc/pull (dc/db conn) default-read id) {:flash? true :request request}) :headers (cond-> {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" id) "hx-reswap" "outerHTML"})))) (defn delete [{invoice :entity :as request identity :identity}] (exception->notification #(when-not (= :invoice-status/unpaid (:invoice/status invoice)) (throw (ex-info "Cannot void an invoice if it is paid. First void the payment." {})))) (when (->> invoice :invoice/payments (filter (fn [p] (not= :payment-status/voided (:payment/status p)))) seq) (throw (ex-info "This invoice has linked payments. Void the payments first." {:type :notification}))) (exception->notification #(assert-can-see-client identity (:db/id (:invoice/client invoice)))) (notify-if-locked (:db/id (:invoice/client invoice)) (:invoice/date invoice)) (audit-transact [[:upsert-invoice {:db/id (:db/id invoice) :invoice/total 0.0 :invoice/outstanding-balance 0.0 :invoice/status :invoice-status/voided :invoice/expense-accounts (map (fn [ea] {:db/id (:db/id ea) :invoice-expense-account/amount 0.0}) (:invoice/expense-accounts invoice))}]] identity) (html-response (row* (:identity request) (dc/pull (dc/db conn) default-read (:db/id invoice)) {:class "live-removed"}) :headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id invoice))})) (defn bulk-delete-dialog [request] (let [all-selected (:all-selected (:query-params request)) selected (:selected (:query-params request)) ids (cond all-selected (:ids (fetch-ids (dc/db conn) (-> request (assoc-in [:query-params :start] 0) (assoc-in [:query-params :per-page] 250)))) :else selected)] (modal-response (com/modal {} (com/modal-card-advanced {} (com/modal-body {} [:div.flex.flex-col.mt-4.space-y-4.items-center [:div.w-24.h-24.bg-red-50.rounded-full.p-4.text-red-300 svg/alert] [:div "You are about to void " (count ids) " invoices. Are you sure you want to do this?"]]) (com/modal-footer {} [:div.flex.justify-end (com/button {:color :primary :hx-vals (hx/json (mc/encode query-schema (dissoc (:query-params request) :sort) (mt/transformer main-transformer dissoc-nil-transformer mt/strip-extra-keys-transformer))) :hx-delete (hu/url (bidi/path-for ssr-routes/only-routes ::route/bulk-delete-confirm))} "Void invoices")]))) :headers (-> {} (assoc "hx-retarget" ".modal-stack") (assoc "hx-reswap" "beforeend"))))) (defn void-invoices-internal [all-ids id] (let [all-ids (->> all-ids (dc/q '[:find (pull ?i [:db/id :invoice/date {:invoice/expense-accounts [:db/id]}]) :in $ [?i ...] :where (not [_ :invoice-payment/invoice ?i]) [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn))) voidable-cash-payments (->> (dc/q '[:find ?p :in $ [?i ...] :where [?ip :invoice-payment/invoice ?i] [?ip :invoice-payment/payment ?p] [?p :payment/type :payment-type/cash] [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn) (map :db/id all-ids)) (map first))] (alog/info ::void-payments :count (count voidable-cash-payments)) (gq-checks/void-payments-internal voidable-cash-payments id) (alog/info ::voiding-invoices :count (count all-ids)) (audit-transact (->> all-ids (map (fn [[i]] [:upsert-invoice {:db/id (:db/id i) :invoice/total 0.0 :invoice/outstanding-balance 0.0 :invoice/status :invoice-status/voided :invoice/expense-accounts (mapv (fn [iea] {:db/id (:db/id iea) :invoice-expense-account/amount 0.0}) (:invoice/expense-accounts i))}]))) id) (count all-ids))) (defn bulk-delete-dialog-confirm [request] (alog/peek (:form-params request)) (let [ids (selected->ids request (:form-params request)) updated-count (void-invoices-internal ids (:identity request))] (html-response [:div] :headers {"hx-trigger" (hx/json {:modalclose "" :notification (format "Successfully voided %d of %d invoices." updated-count (count ids))})}))) #_(defn pay-invoices-from-balance [context {invoices :invoices client-id :client_id} _] ) (defn pay-using-credit [request] (alog/peek (:form-params request)) (let [invoices (selected->ids request (:form-params request)) _ (alog/peek invoices) invoices (d-invoices/get-multi invoices) client->invoices (group-by (comp :db/id :invoice/client) invoices) client-id (first (keys client->invoices)) _ (when (> (count (keys client->invoices)) 1) (throw (ex-info "Can only pay from one customer's balance at a time" {:type :form-validation}))) _ (when-not (can? (:identity request) {:activity :pay :subject :invoice :client client-id}) (throw (ex-info "You can't pay these invoices" {:type :form-validation}))) client (d-clients/get-by-id client-id) _ (when (> (count (set (map :invoice/vendor invoices))) 1) (throw (ex-info "Balance payments can only be for one vendor at a time." {:type :form-validation}))) _ (when (> (reduce + 0 (map :invoice/outstanding-balance invoices)) 0.001) (throw (ex-info "There isn't a positive balance to pay from" {:type :form-validation}))) invoices-to-be-paid (filter (fn [i] (> (:invoice/outstanding-balance i) 0.001)) invoices) credit-invoices (filter (fn [i] (< (:invoice/outstanding-balance i) 0.001)) invoices) total-to-pay (reduce + 0 (map :invoice/outstanding-balance invoices-to-be-paid)) _ (when (<= total-to-pay 0.001) (throw (ex-info "Select some invoices that need to be paid" {:type :form-validation}))) invoice-amounts (->> invoices-to-be-paid (map (fn [i] [(:db/id i) (:invoice/outstanding-balance i)])) (concat (->> credit-invoices (reduce (fn [[remaining-to-pay invoice-amounts] invoice] (cond (dollars-0? (+ remaining-to-pay (:invoice/outstanding-balance invoice))) (reduced (conj invoice-amounts [(:db/id invoice) (:invoice/outstanding-balance invoice)])) (< (+ remaining-to-pay (:invoice/outstanding-balance invoice)) 0.0) (reduced (conj invoice-amounts [(:db/id invoice) (- remaining-to-pay)])) :else [(+ remaining-to-pay (:invoice/outstanding-balance invoice)) (conj invoice-amounts [(:db/id invoice) (:invoice/outstanding-balance invoice)])])) [total-to-pay []]))) (into {})) vendor-id (:db/id (:invoice/vendor (first invoices))) payment {:db/id (str vendor-id) :payment/amount total-to-pay :payment/vendor vendor-id :payment/client (:db/id client) :payment/date (c/to-date (time/now)) :payment/invoices (map :db/id invoices) :payment/type :payment-type/balance-credit :payment/status :payment-status/cleared} result (audit-transact (-> [] (conj payment) (into (invoice-payments invoices invoice-amounts))) (:identity request))] (doseq [[_ n] (:tempids result)] (solr/touch-with-ledger n)) (html-response [:div] :headers {"hx-trigger" (hx/json {:modalclose "" :invalidated "" :notification (format "Successfully paid %d invoices." (count invoices))})}))) (defn does-amount-exceed-outstanding? [amount outstanding-balance] (let [outstanding-balance (round-money outstanding-balance) amount (round-money amount)] (or (and (> outstanding-balance 0) (> amount outstanding-balance)) (and (> outstanding-balance 0) (<= amount 0)) (and (< outstanding-balance 0) (< amount outstanding-balance)) (and (< outstanding-balance 0) (>= amount 0)) (and (= outstanding-balance 0.0) (not= amount 0.0))))) (def payment-form-schema (mc/schema [:map [:client entity-id] [:invoices [:and [:vector {:coerce? true} [:map [:invoice-id entity-id] [:amount money]]] [:fn {:error/message "All payments must not exceed their outstanding balance."} (fn [invoices] (let [outstanding-balances (->> (dc/q '[:find ?i ?ob :in $ [?i ...] :where [?i :invoice/outstanding-balance ?ob]] (dc/db conn) (map :invoice-id invoices)) (into {}))] (every? (fn [%] (not (does-amount-exceed-outstanding? (:amount %) (outstanding-balances (:invoice-id %))))) invoices)))]]] [:has-warning? :boolean] [:bank-account entity-id] [:check-number {:optional true} :int] [:handwritten-date {:optional true} [:maybe clj-date-schema]] [:mode [:enum :simple :advanced]] [:method [:enum :debit :print-check :cash :handwrite-check :credit]]])) (defn bank-account-card-base [{:keys [bg-color text-color icon bank-account can-handwrite? credit-only?]}] [:div {:class "w-[30em]"} (com/card {:class "w-full"} [:div.flex.items-stretch {} (com/hidden {:name "item" :value (:db/id bank-account)}) [:div.grow-0.flex.flex-col.justify-center [:div.p-1.m-2.rounded-full {:class bg-color} [:div {:class (hh/add-class "p-1.5 w-8 h-8" text-color)} icon]]] [:div.flex.flex-col.grow.m-2 [:div.font-medium.text-gray-700 (:bank-account/name bank-account)] [:div.font-light.text-gray-600 (:bank-account/bank-name bank-account)]] [:div.grow-0.m-2.self-center {:x-data (hx/json {})} (if credit-only? (com/button {:color :primary :minimal-loading? true :hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "step-params[method]" "credit"}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) {:from (mm/encode-step-key :choose-method) :to (mm/encode-step-key :payment-details)})} "Credit") (com/button {:x-ref "button" "@click.prevent.capture" "$tooltip($refs.tooltip.innerHTML, {allowHTML: true, onMount(i) {htmx.process(i.popper)}, interactive:true, theme:\"light\", timeout:5000})" } "Pay")) [:template { :x-ref "tooltip"} [:div.flex.flex-col.gap-2 { :data-key "vis" :class "p-4 w-max" } (when (= :bank-account-type/check (:bank-account/type bank-account)) (com/button {:color :primary :minimal-loading? true :hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "step-params[method]" "print-check"}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) {:from (mm/encode-step-key :choose-method) :to (mm/encode-step-key :payment-details)})} "Print check")) (when (= :bank-account-type/cash (:bank-account/type bank-account)) (com/button {:minimal-loading? true :hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "step-params[method]" "cash"}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) {:from (mm/encode-step-key :choose-method) :to (mm/encode-step-key :payment-details)})} "With cash")) (when (not= :bank-account-type/cash (:bank-account/type bank-account)) (com/button {:color (when (= :bank-account-type/credit (:bank-account/type bank-account)) :primary) :minimal-loading? true :hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "step-params[method]" "debit"}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) {:from (mm/encode-step-key :choose-method) :to (mm/encode-step-key :payment-details)})} "Debit")) (when (and (= :bank-account-type/check (:bank-account/type bank-account)) can-handwrite?) (com/button {:minimal-loading? true :hx-vals (hx/json {"step-params[bank-account]" (:db/id bank-account) "step-params[method]" "handwrite-check"}) :hx-put (hu/url (bidi/path-for ssr-routes/only-routes ::route/pay-wizard-navigate) {:from (mm/encode-step-key :choose-method) :to (mm/encode-step-key :payment-details)})} "Handwrite check"))]]]])]) (defmulti bank-account-card (fn [ba _ _] (:bank-account/type ba))) (defmethod bank-account-card :bank-account-type/cash [bank-account can-handwrite? credit-only?] (bank-account-card-base {:bg-color "bg-green-50" :text-color "text-green-600" :icon svg/dollar :bank-account bank-account :can-handwrite? can-handwrite? :credit-only? credit-only?})) (defmethod bank-account-card :bank-account-type/credit [bank-account can-handwrite? credit-only?] (bank-account-card-base {:bg-color "bg-purple-50" :text-color "text-purple-600" :icon svg/credit-card :bank-account bank-account :can-handwrite? can-handwrite? :credit-only? credit-only?})) (defmethod bank-account-card :bank-account-type/check [bank-account can-handwrite? credit-only?] (bank-account-card-base {:bg-color "bg-blue-50" :text-color "text-blue-600" :icon svg/check :bank-account bank-account :can-handwrite? can-handwrite? :credit-only? credit-only?})) (defn can-handwrite? [invoices] (let [selected-vendors (set (map (comp :db/id :invoice/vendor) invoices))] (and (= 1 (count selected-vendors)) (> (reduce + 0 (map :invoice/outstanding-balance invoices)) 0.0)))) (defn credit-only? [invoices] (->> invoices (group-by :invoice/vendor) vals (map (fn [is] (alog/peek ::invoices is) (reduce + 0.0 (map :invoice/outstanding-balance is)))) (every? #(<= % 0.0)))) (defrecord ChoosePaymentMethodModal [linear-wizard] mm/ModalWizardStep (step-name [_] "Payment method") (step-key [_] :choose-method) (edit-path [_ _] []) (step-schema [_] (mut/select-keys (mm/form-schema linear-wizard) #{:bank-account :method})) (render-step [this request] (let [invoices (:invoices (:snapshot (:multi-form-state request))) can-handwrite? (can-handwrite? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices)) credit-only? (credit-only? (map (comp (:invoice-by-id linear-wizard) :invoice-id) invoices))] (mm/default-render-step linear-wizard this :head [:div.p-2.inline-flex.gap-2.items-center "Pay " (count invoices) " invoices" (when (:has-warning? (:snapshot (:multi-form-state request))) (com/pill {:color :yellow} "Some of the selected invoices may be locked or paid."))] :body (mm/default-step-body {} [:div.flex.flex-col.space-y-2 (for [ba (:bank-accounts linear-wizard) :when (:bank-account/visible ba)] (bank-account-card ba can-handwrite? credit-only?))]) :footer nil :validation-route ::route/pay-wizard-navigate)))) (defrecord PaymentDetailsStep [linear-wizard] mm/ModalWizardStep (step-name [_] "Details") (step-key [_] :payment-details) (edit-path [_ _] []) (step-schema [_] (mut/select-keys (mm/form-schema linear-wizard) #{:invoices :check-number :handwritten-date :mode})) (render-step [this request] (mm/default-render-step linear-wizard this :head [:div.p-2 "Pay " (count (:invoices (:snapshot (:multi-form-state request)))) " invoices"] :body (mm/default-step-body {} [:div {} (when (= :handwrite-check (:method (:snapshot (:multi-form-state request)))) (fc/with-field :check-number (com/validated-field {:errors (fc/field-errors) :label "Check number"} (com/int-input {:value (fc/field-value) :name (fc/field-name) :error? (fc/field-errors) :placeholder "10001"})))) (when (#{:handwrite-check :print-check} (:method (:snapshot (:multi-form-state request)))) (fc/with-field :handwritten-date (com/validated-field {:errors (fc/field-errors) :label "Date"} (com/date-input {:value (-> (fc/field-value) (atime/unparse-local atime/normal-date)) :name (fc/field-name) :error? (fc/field-errors) :placeholder "1/1/2020"})))) (com/radio-list {:x-model "mode" :name "step-params[mode]" :options [{:value "simple" :content (let [total (reduce + 0.0 (map (comp :invoice/outstanding-balance (:invoice-by-id linear-wizard) :invoice-id) (:invoices (:snapshot (:multi-form-state request)))))] (if (< total 0) (format "Credit in full ($%,.2f)" total) (format "Pay in full ($%,.2f)" total)))} {:value "advanced" :content "Customize payments"}]}) [:div.space-y-4 (fc/with-field :invoices (com/validated-field {:errors (fc/field-errors)} (com/data-grid (hx/alpine-appear {:headers [(com/data-grid-header {} "Vendor") (com/data-grid-header {} "Invoice Number") (com/data-grid-header {:class "text-right"} "Total") (com/data-grid-header {:class "text-right"} "Pay")] :x-show "mode==\"advanced\""}) (fc/cursor-map (fn [i] (com/data-grid-row {} (com/data-grid-cell {} (-> (fc/field-value) :invoice :invoice/vendor :vendor/name)) (com/data-grid-cell {} (fc/with-field :invoice-id (com/hidden {:name (fc/field-name) :value (fc/field-value)})) (-> (fc/field-value) :invoice :invoice/invoice-number)) (com/data-grid-cell {:class "text-right"} [:span.inline-flex.gap-2 (format "$%,.2f" (-> (fc/field-value) :invoice :invoice/outstanding-balance))]) (com/data-grid-cell {:class "w-20"} (fc/with-field :amount (com/validated-field {:errors (fc/field-errors)} (com/money-input {:value (format "%.2f" (fc/field-value)) :class "w-20" :name (fc/field-name) :error? (fc/error?)}))))))))))]]) :footer (mm/default-step-footer linear-wizard this :validation-route ::route/pay-wizard-navigate :next-button-content "Pay") :validation-route ::route/pay-wizard-navigate))) (defn add-handwritten-check [request wizard snapshot] (let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot))) bank-account-id (:bank-account snapshot) bank-account (d-bank-accounts/get-by-id bank-account-id) _ (when-not (= 1 (count (set (map (comp :db/id :invoice/vendor) invoices)))) (throw (ex-info "Can only write a handwritten check for a single vendor." {:type :form-validation}))) _ (doseq [invoice invoices] (assert-can-see-client (:identity request) (:invoice/client invoice))) client-id (:db/id (:invoice/client (first invoices))) _ (validate-belonging (:db/id (:client/_bank-accounts bank-account)) invoices bank-account) _ (assert-not-locked client-id (:handwritten-date snapshot)) invoice-payment-lookup (by :invoice-id :amount (:invoices snapshot)) base-payment (base-payment invoices (:invoice/vendor (first invoices)) (:invoice/client (first invoices)) bank-account :payment-type/check 0 invoice-payment-lookup (:handwritten-date snapshot))] (let [result (audit-transact (into [(assoc base-payment :payment/type :payment-type/check :payment/status :payment-status/pending :payment/check-number (:check-number snapshot))] (invoice-payments invoices invoice-payment-lookup)) (:identity request))] (try (doseq [[_ i] (:tempids result)] (solr/touch-with-ledger i)) (catch Exception e (alog/error ::cant-save-solr :error e)))))) ;; TODO support crediting from balance (defrecord PayWizard [form-params current-step invoice-by-id] mm/LinearModalWizard (hydrate-from-request [this request] (let [invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id] :invoice/client [:db/id]} :invoice/outstanding-balance :invoice/invoice-number :db/id]) :in $ [?i ...]] (dc/db conn) (map :invoice-id (get-in request [:multi-form-state :snapshot :invoices]))) (map first) (sort-by (juxt (comp :invoice/vendor :vendor/name) :invoice/invoice-number)))] (assoc this :invoice-by-id (by :db/id invoices) :bank-accounts (->> (dc/q '[:find (pull ?ba [:bank-account/name :bank-account/sort-order :bank-account/visible :bank-account/bank-name :db/id {[:bank-account/type :xform iol-ion.query/ident] [:db/ident]}]) :in $ ?c :where [?c :client/bank-accounts ?ba]] (dc/db conn) (:client (:snapshot (:multi-form-state request)))) (map first) (sort-by :bank-account/sort-order))))) (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 :choose-method))) (render-wizard [this {:keys [multi-form-state] :as request}] (let [request (update-in request [:multi-form-state :step-params :invoices] (fn [form-invoices] (->> form-invoices (map (fn [form-invoice] (assoc form-invoice :invoice ((:invoice-by-id this) (:invoice-id form-invoice))))) (sort-by (juxt (comp :vendor/name :invoice/vendor :invoice) (comp :invoice/invoice-number :invoice))) (into []))))] (mm/default-render-wizard this request :form-params (-> mm/default-form-props (assoc :hx-post (str (bidi/path-for ssr-routes/only-routes ::route/pay-submit))) (assoc :x-data (hx/json {:mode (some-> multi-form-state :step-params :mode name)})))))) (steps [_] [:choose-method :payment-details]) (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] (if (= :step step-key-type) (get {:choose-method (->ChoosePaymentMethodModal this) :payment-details (->PaymentDetailsStep this)} step-key) (get {:bank-account (->ChoosePaymentMethodModal this)} (first step-key))))) (form-schema [_] payment-form-schema) (submit [this {:keys [multi-form-state request-method identity] :as request}] (let [snapshot (mc/decode payment-form-schema (:snapshot multi-form-state) mt/strip-extra-keys-transformer) _ (assert-schema payment-form-schema snapshot) _ (exception->4xx #(if (= :handwrite-check (:method snapshot)) (when (or (not (some? (:check-number snapshot))) (= "" (:check-number snapshot))) (throw (Exception. "Check number is required"))) true)) result (exception->4xx #(do (when (:handwritten-date snapshot) (let [invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot)))] (assert-not-locked (:db/id (:invoice/client (first invoices))) (:handwritten-date snapshot)))) (if (= :handwrite-check (:method snapshot)) (add-handwritten-check request this snapshot) (try (print-checks-internal (map (fn [i] {:invoice-id (:invoice-id i) :amount (:amount i)}) (:invoices snapshot)) (:client snapshot) (:bank-account snapshot) (cond (= :print-check (:method snapshot)) :payment-type/check (= :debit (:method snapshot)) :payment-type/debit (= :cash (:method snapshot)) :payment-type/cash (= :credit (:method snapshot)) :payment-type/credit :else :payment-type/debit) identity (:handwritten-date snapshot)) (catch Exception e (println e))))))] (modal-response (com/modal {} (com/modal-card-advanced {:class "transition duration-300 ease-in-out htmx-swapping:-translate-x-2/3 htmx-swapping:opacity-0 htmx-swapping:scale-0 htmx-added:translate-x-2/3 htmx-added:opacity-0 htmx-added:scale-0 scale-100 translate-x-0 opacity-100"} (com/modal-body {} [:div.flex.flex-col.mt-4.space-y-4.items-center [:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300.animate-gg svg/thumbs-up] (when-not (:pdf-url result) [:div "That's a wrap. Your payment is complete."]) (when (:pdf-url result) [:div "Your checks are ready. Click " (com/link {:href (:pdf-url result) :target "_new"} "here") " to download and print."]) (when (:pdf-url result) [:div.text-xs.italic [:em "Remember to turn off all scaling and margins."]])]))) :headers {"hx-trigger" "invalidated"})))) (def pay-wizard (->PayWizard nil nil nil)) (defn wrap-status-from-source [handler] (fn [{:keys [matched-current-page-route] :as request}] (let [request (cond-> request (= ::route/paid-page matched-current-page-route) (assoc-in [:route-params :status] :invoice-status/paid) (= ::route/unpaid-page matched-current-page-route) (assoc-in [:route-params :status] :invoice-status/unpaid) (= ::route/voided-page matched-current-page-route) (assoc-in [:route-params :status] :invoice-status/voided) (= ::route/all-page matched-current-page-route) (assoc-in [:route-params :status] nil))] (handler request)))) (defn payable-ids [ids] (->> (dc/q '[:find ?i :in $ [?i ...] :where [?i :invoice/status :invoice-status/unpaid] [?i :invoice/client ?c] (not [?i :invoice/scheduled-payment])] (dc/db conn) ids) (map first))) (defn initial-pay-wizard-state [request] (exception->notification #(let [selected-ids (selected->ids request (:query-params request)) selected-ids (payable-ids selected-ids) _ (when (= 0 (count selected-ids)) (throw (ex-info "No selected invoices are applicable for payment" {:type :notification}))) has-warning? (and (:selected (:query-params request)) (not= (count selected-ids) (count (:selected (:query-params request))))) invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id] :invoice/client [:db/id]} :invoice/outstanding-balance :invoice/invoice-number :db/id]) :in $ [?i ...]] (dc/db conn) selected-ids) (map first) (sort-by (juxt (comp :invoice/vendor :vendor/name) :invoice/invoice-number)))] (mm/->MultiStepFormState {:invoices (mapv (fn [i] {:invoice-id (:db/id i) :amount (:invoice/outstanding-balance i)}) invoices) :mode :simple :client (-> invoices first :invoice/client :db/id) :has-warning? (boolean has-warning?) :handwritten-date (time/now)} [] {:mode :simple :has-warning? (boolean has-warning?)})))) (defn redirect-handler [target-route] (fn handle [request] {:status 302 :headers {"Location" (str (hu/url (bidi.bidi/path-for ssr-routes/only-routes target-route) (:query-params request)))}})) (defn initial-bulk-edit-state [request] (mm/->MultiStepFormState {:search-params (:query-params request) :expense-accounts [{:db/id "123" :location "Shared" :account nil :percentage 1.0}]} [] {:search-params (:query-params request) :expense-accounts [{:db/id "123" :location "Shared" :account nil :percentage 1.0}]})) (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) { :purpose "invoice"}) :id name :x-model x-model :value value :content-fn (fn [value] (:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read value) client-id)))})]) ;; TODO clientize (defn all-ids-not-locked [all-ids] (->> all-ids (dc/q '[:find ?i :in $ [?i ...] :where [?i :invoice/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?i :invoice/date ?d] [(>= ?d ?lu)]] (dc/db conn)) (map first))) (defn- bulk-edit-account-row* [{:keys [value client-id]}] (com/data-grid-row (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? value)))) :accountId (fc/field-value (: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 :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 :location (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors) :x-hx-val:account-id "accountId" :hx-vals (hx/json {:name (fc/field-name) }) :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->> (:account @value) (nat-int? (:account @value)) (dc/pull (dc/db conn) '[:account/location]))) :value (fc/field-value)})))) (fc/with-field :percentage (com/data-grid-cell {} (com/validated-field {:errors (fc/field-errors)} (com/money-input {:name (fc/field-name) :class "w-16 amount-field" :value (some-> (fc/field-value) (* 100) (long))})))) (com/data-grid-cell {:class "align-top"} (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))) (defrecord AccountsStep [linear-wizard] mm/ModalWizardStep (step-name [_] "Expense Accounts") (step-key [_] :accounts) (edit-path [_ _] []) (step-schema [_] (mut/select-keys (mm/form-schema linear-wizard) #{:expense-accounts})) (render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}] (let [selected-ids (selected->ids (assoc request :query-params (:search-params snapshot)) (:search-params snapshot)) all-ids (all-ids-not-locked selected-ids)] (mm/default-render-step linear-wizard this :head [:div.p-2 "Bulk editing " (count all-ids) " invoices"] :body (mm/default-step-body {} [:div {} (fc/with-field :expense-accounts (com/validated-field {:errors (fc/field-errors)} (com/data-grid {:headers [(com/data-grid-header {} "Account") (com/data-grid-header {:class "w-32"} "Location") (com/data-grid-header {:class "w-16"} "%") (com/data-grid-header {:class "w-16"})]} (fc/cursor-map #(bulk-edit-account-row* {:value % :client-id (:invoice/client snapshot)})) (com/data-grid-new-row {:colspan 4 :hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-new-account) :row-offset 0 :index (count (fc/field-value)) } "New account") (com/data-grid-row {} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "TOTAL"]) (com/data-grid-cell {:id "total" :class "text-right" :hx-trigger "change from:closest form target:.amount-field" :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-total) :hx-target "this" :hx-swap "innerHTML"} #_(invoice-expense-account-total* request)) (com/data-grid-cell {})) (com/data-grid-row {} (com/data-grid-cell {}) (com/data-grid-cell {:class "text-right"} [:span.font-bold.text-right "BALANCE"]) (com/data-grid-cell {:id "total" :class "text-right" :hx-trigger "change from:closest form target:.amount-field" :hx-put (bidi.bidi/path-for ssr-routes/only-routes ::route/bulk-edit-balance) :hx-target "this" :hx-swap "innerHTML"} #_(invoice-expense-account-balance* request)) (com/data-grid-cell {})))))]) :footer (mm/default-step-footer linear-wizard this :validation-route ::route/new-wizard-navigate :next-button (com/button {:color :primary :x-ref "next" :class "w-32"} "Save")) :validation-route ::route/new-wizard-navigate)))) (defn maybe-code-accounts [invoice account-rules valid-locations] (with-precision 2 (let [accounts (vec (mapcat (fn [ar] (let [cents-to-distribute (int (Math/round (Math/abs (* (:percentage ar) (:invoice/total invoice) 100))))] (if (= "Shared" (:location ar)) (do (->> valid-locations (map (fn [cents location] {:db/id (random-tempid) :invoice-expense-account/account (:account ar) :invoice-expense-account/amount (* 0.01 cents) :invoice-expense-account/location location}) (rm/spread-cents cents-to-distribute (count valid-locations))))) [(cond-> {:db/id (random-tempid) :invoice-expense-account/account (:account ar) :invoice-expense-account/amount (* 0.01 cents-to-distribute)} (:location ar) (assoc :invoice-expense-account/location (:location ar)))]))) account-rules)) accounts (mapv (fn [a] (update a :invoice-expense-account/amount #(with-precision 2 (double (.setScale (bigdec %) 2 java.math.RoundingMode/HALF_UP))))) accounts) leftover (with-precision 2 (.round (bigdec (- (Math/abs (:invoice/total invoice)) (Math/abs (reduce + 0.0 (map #(:invoice-expense-account/amount %) accounts))))) *math-context*)) accounts (if (seq accounts) (update-in accounts [(dec (count accounts)) :invoice-expense-account/amount] #(+ % (double leftover))) [])] accounts))) (defn assert-percentages-add-up [{:keys [expense-accounts]}] (let [expense-account-total (reduce + 0 (map (fn [x] (:percentage x)) expense-accounts))] (when-not (dollars= 1.0 expense-account-total) (form-validation-error (str "Expense account total (" expense-account-total ") does not equal 100%"))))) (defrecord BulkEditWizard [_ 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 :accounts))) (render-wizard [this {:keys [multi-form-state] :as request}] (mm/default-render-wizard this request :form-params (-> mm/default-form-props (assoc :hx-put (str (bidi/path-for ssr-routes/only-routes ::route/bulk-edit-submit)))) :render-timeline? false)) (steps [_] [:accounts]) (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 {:accounts (->AccountsStep this) } step-key))) (form-schema [_] (mc/schema [:map [:expense-accounts (many-entity {:min 1} [:account entity-id] [:location [:string {:min 1 :error/message "required"}]] [:percentage percentage])]])) (submit [this {:keys [multi-form-state request-method identity] :as request}] (let [selected-ids (selected->ids (assoc request :query-params (:search-params (:snapshot multi-form-state))) (:search-params (:snapshot multi-form-state))) all-ids (all-ids-not-locked selected-ids) invoices (pull-many (dc/db conn) '[:db/id :invoice/total {:invoice/client [:client/locations]}] (vec all-ids)) ] (assert-percentages-add-up (:snapshot multi-form-state)) (doseq [a (-> multi-form-state :snapshot :expense-accounts) :let [{:keys [:account/location :account/name]} (dc/pull (dc/db conn) [:account/location :account/name] (:account 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}))))) (alog/info ::bulk-code :count (count all-ids)) (audit-transact-batch (map (fn [i] [:upsert-invoice {:db/id (:db/id i) :invoice/expense-accounts (maybe-code-accounts i (-> multi-form-state :snapshot :expense-accounts) (-> i :invoice/client :client/locations))}]) invoices) (:identity request)) (html-response [:div] :headers (cond-> {"hx-trigger" (hx/json { "modalclose" "" "invalidated" "" "notification" (str "Successfully coded " (count all-ids) " invoices.")}) "hx-reswap" "outerHTML"}))))) (def bulk-edit-wizard (->BulkEditWizard nil nil)) (defn bulk-edit-total* [request] (let [total (->> (-> request :multi-form-state :step-params :expense-accounts) (map (fnil :percentage 0.0)) (filter number?) (reduce + 0.0))] (format "%.1f%%" (* 100.0 total)))) (defn bulk-edit-balance* [request] (let [total (->> (-> request :multi-form-state :step-params :expense-accounts) (map (fnil :percentage 0.0)) (filter number?) (reduce + 0.0)) balance (- 100.0 (* 100.0 total))] [:span {:class (when-not (dollars= 0.0 balance) "text-red-300")} (format "%.1f%%" balance)])) (defn bulk-edit-total [request] (html-response (bulk-edit-total* request))) (defn bulk-edit-balance [request] (html-response (bulk-edit-balance* request))) (def key->handler (apply-middleware-to-all-handlers (-> {::route/all-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status nil)) ::route/paid-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :invoice-status/paid)) ::route/unpaid-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :invoice-status/unpaid)) ::route/voided-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :invoice-status/voided)) ::route/legacy-invoices (redirect-handler ::route/all-page) ::route/legacy-import-invoices (redirect-handler ::route/import-page) ::route/legacy-unpaid-invoices (redirect-handler ::route/unpaid-page) ::route/legacy-paid-invoices (redirect-handler ::route/paid-page) ::route/legacy-voided-invoices (redirect-handler ::route/voided-page) ::route/legacy-new-invoice (redirect-handler ::route/new-wizard) ::route/bulk-edit (-> mm/open-wizard-handler (mm/wrap-wizard bulk-edit-wizard) (mm/wrap-init-multi-form-state initial-bulk-edit-state)) ::route/bulk-edit-submit (-> mm/submit-handler (mm/wrap-wizard bulk-edit-wizard) (mm/wrap-decode-multi-form-state) (wrap-must {:subject :invoice :activity :bulk-edit})) ::route/bulk-edit-total (-> bulk-edit-total (mm/wrap-wizard bulk-edit-wizard) (mm/wrap-decode-multi-form-state) (wrap-must {:subject :invoice :activity :bulk-edit})) ::route/bulk-edit-balance (-> bulk-edit-balance (mm/wrap-wizard bulk-edit-wizard) (mm/wrap-decode-multi-form-state) (wrap-must {:subject :invoice :activity :bulk-edit})) ::route/bulk-edit-new-account (-> (add-new-entity-handler [:step-params :expense-accounts] (fn render [cursor request] (bulk-edit-account-row* {:value cursor })) (fn build-new-row [base _] (assoc base :invoice-expense-account/location "Shared"))) (wrap-schema-enforce :query-schema [:map [:client-id {:optional true} [:maybe entity-id]]])) ::route/undo-autopay (-> undo-autopay (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) ::route/unvoid (-> unvoid-invoice (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) ::route/pay-button (-> pay-button (wrap-schema-enforce :query-schema query-schema)) ::route/delete (-> delete (wrap-entity [:route-params :db/id] default-read) (wrap-schema-enforce :route-params [:map [:db/id entity-id]])) ::route/bulk-delete-confirm (-> bulk-delete-dialog-confirm (wrap-schema-enforce :form-schema query-schema) (wrap-admin)) ::route/bulk-delete (-> bulk-delete-dialog (wrap-admin)) ::route/pay-using-credit (-> pay-using-credit (wrap-schema-enforce :form-schema query-schema)) ::route/pay-wizard (-> mm/open-wizard-handler (mm/wrap-wizard pay-wizard) (mm/wrap-init-multi-form-state initial-pay-wizard-state)) ::route/pay-submit (-> mm/submit-handler (mm/wrap-wizard pay-wizard) (mm/wrap-decode-multi-form-state)) ::route/pay-wizard-navigate (-> mm/next-handler (mm/wrap-wizard pay-wizard) (mm/wrap-decode-multi-form-state)) ::route/table (helper/table-route grid-page :parse-query-params? false)} (merge new-invoice-wizard/key->handler) (merge invoice-import/key->handler)) (fn [h] (-> h (wrap-copy-qp-pqp) (wrap-status-from-source) (wrap-apply-sort grid-page) (wrap-merge-prior-hx) (wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :hx-schema query-schema) (wrap-client-redirect-unauthenticated)))))