1173 lines
64 KiB
Clojure
1173 lines
64 KiB
Clojure
(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 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.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 exception->4xx
|
|
exception->notification
|
|
extract-client-ids notify-if-locked]]
|
|
[auto-ap.logging :as alog]
|
|
[auto-ap.permissions :refer [can?]]
|
|
[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.solr :as solr]
|
|
[auto-ap.ssr-routes :as ssr-routes]
|
|
[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.new-invoice-wizard :as new-invoice-wizard]
|
|
[auto-ap.ssr.pos.common :refer [date-range-field*]]
|
|
[auto-ap.ssr.svg :as svg]
|
|
[auto-ap.ssr.utils
|
|
:refer [apply-middleware-to-all-handlers clj-date-schema
|
|
dissoc-nil-transformer entity-id html-response
|
|
main-transformer modal-response money 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=]]
|
|
[bidi.bidi :as bidi]
|
|
[clj-time.coerce :as coerce]
|
|
[clj-time.core :as time]
|
|
[clojure.string :as str]
|
|
[datomic.api :as dc]
|
|
[hiccup.util :as hu]
|
|
[malli.core :as mc]
|
|
[malli.transform :as mt]
|
|
[malli.util :as mut]))
|
|
|
|
|
|
(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" "change delay:500ms, 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}))
|
|
(date-range-field* request)
|
|
(com/field {:label "Check #"}
|
|
(com/text-input {:name "check-number"
|
|
:id "check-number"
|
|
:class "hot-filter"
|
|
:value (:check-number (:query-params request))
|
|
:placeholder "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 "10001"
|
|
: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)]})
|
|
(: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-id query-params)
|
|
(merge-query {:query {:in ['?account-id]
|
|
:where ['[?e :invoice/expense-accounts ?iea ?]
|
|
'[?iea :invoice-expense-account/account ?account-id]]}
|
|
:args [(:account-id 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 fetch-page [request]
|
|
(let [db (dc/db conn)
|
|
{ids-to-retrieve :ids matching-count :count} (fetch-ids db request)]
|
|
|
|
[(->> (hydrate-results ids-to-retrieve db request))
|
|
matching-count]))
|
|
|
|
(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]}]]]
|
|
[: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 pay-button* [params]
|
|
(let [ids (:ids params)
|
|
selected-client-count (if (seq ids)
|
|
(ffirst
|
|
(dc/q '[:find (count ?c)
|
|
:in $ [?i ...]
|
|
:where [?i :invoice/client ?c]]
|
|
(dc/db conn)
|
|
ids))
|
|
|
|
0)
|
|
vendor-totals (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)
|
|
(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))]
|
|
|
|
|
|
[:div {:hx-target "this"
|
|
:hx-get (bidi/path-for ssr-routes/only-routes
|
|
::route/pay-wizard)
|
|
:hx-trigger "click from:#pay-button"
|
|
:x-data (hx/json {:popper nil
|
|
:hovering false})
|
|
"x-init" "popper = Popper.createPopper($refs.button, $refs.tooltip, {placement: 'bottom', strategy: 'fixed', modifiers: [{name: 'preventOverflow'}, {name: 'offset', options: {offset: [0, 10]}}]});"}
|
|
(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"
|
|
"@mouseover" "hovering=true; $nextTick(() => popper.update())"
|
|
"@mouseout" "hovering=false;"
|
|
:x-ref "button"
|
|
:minimal-loading? true
|
|
:class "relative"}
|
|
(if (> (count (:ids params)) 0)
|
|
|
|
(str "Pay " (count (:ids params)) " invoices")
|
|
"Pay")
|
|
(when (or (= 0 (count ids))
|
|
(> selected-client-count 1))
|
|
(com/badge {} "!")))
|
|
[:div (hx/alpine-appear {:x-ref "tooltip"
|
|
|
|
:x-show "hovering"
|
|
:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 p-4"})
|
|
(cond
|
|
(not all-credits-or-debits)
|
|
[:div "All vendor totals must be either positive or negative."]
|
|
(= 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
|
|
:page-specific-nav filters
|
|
:fetch-page fetch-page
|
|
:oob-render
|
|
(fn [request]
|
|
[(assoc-in (date-range-field* request) [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]
|
|
(mc/decode query-schema p main-transformer))
|
|
:action-buttons (fn [request]
|
|
[(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 :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 [_ entity]
|
|
[(when (= :invoice-status/unpaid (:invoice/status entity))
|
|
(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))])
|
|
: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
|
|
:headers [{:key "client"
|
|
:name "Client"
|
|
:sort-key "client"
|
|
:hide? (fn [args]
|
|
(= (count (:clients args)) 1))
|
|
:render #(-> % :invoice/client :client/name)}
|
|
{: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 "status"
|
|
:name "Status"
|
|
:render (fn [{:invoice/keys [status]}]
|
|
(condp = status
|
|
:invoice-status/paid
|
|
(com/pill {:color :primary} "Paid")
|
|
|
|
:invoice-status/unpaid
|
|
(com/pill {:color :secondary} "Unpaid")
|
|
:invoice-status/voided
|
|
(com/pill {:color :red} "Voided")
|
|
nil
|
|
""))}
|
|
{: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
|
|
(concat (->> i
|
|
:invoice/payments
|
|
(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 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))})})))
|
|
|
|
;; TODO
|
|
;; Allow for paying balances from set of invoices for one vendor
|
|
|
|
(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)))))
|
|
|
|
|
|
(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 {:x-data (hx/json {:chosen false
|
|
:popper nil})
|
|
"x-init" "popper = Popper.createPopper($refs.button, $refs.tooltip, {placement: 'bottom', strategy: 'fixed', modifiers: [{name: 'preventOverflow'}, {name: 'offset', options: {offset: [0, 10]}}]});"}
|
|
(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
|
|
(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" "chosen=true; $nextTick(() => popper.update())"}
|
|
"Pay"))
|
|
[:div.flex.flex-col.gap-2 (hx/alpine-appear {:x-show "chosen" :x-ref "tooltip"
|
|
:data-key "vis"
|
|
:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 p-4"
|
|
"@click.outside" "chosen=false"})
|
|
(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)]
|
|
(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}))
|
|
|
|
(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 (: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 (hx/alpine-appear {:x-show "mode==\"advanced\""})
|
|
(fc/with-field :invoices
|
|
(com/validated-field
|
|
{:errors (fc/field-errors)}
|
|
(com/data-grid
|
|
{: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")]}
|
|
(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)
|
|
: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)]
|
|
(let [result (audit-transact
|
|
(into [(assoc base-payment
|
|
:payment/type :payment-type/check
|
|
:payment/status :payment-status/pending
|
|
:payment/check-number (:check-number snapshot)
|
|
:payment/date (coerce/to-date (:handwritten-date snapshot)))]
|
|
(invoice-payments invoices invoice-payment-lookup))
|
|
(:identity request))]
|
|
(doseq [[_ i] (:tempids result)]
|
|
(solr/touch-with-ledger i)))))
|
|
|
|
;; TODO Payment validations
|
|
;; 1. ensure that filtering for selected ids happens again
|
|
;; at the end of the modal to prevent race conditions
|
|
;; add validation to prevent overpaying
|
|
;; Paying a completed invoice is allowed presently
|
|
;; Thought is to put it into the pay function itself
|
|
;; balance should only go to negative if total was negative
|
|
;; balance should stay positive if total was positive
|
|
;; NOTE: payable-ids function could be used.
|
|
|
|
;; 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)
|
|
_ (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
|
|
#(if (= :handwrite-check (:method snapshot))
|
|
(add-handwritten-check request this snapshot)
|
|
(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)))]
|
|
(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]
|
|
[(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu]
|
|
[?i :invoice/date ?d]
|
|
[(>= ?d ?lu)]]
|
|
(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?)}))))
|
|
|
|
(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/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-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)}
|
|
(merge new-invoice-wizard/key->handler))
|
|
(fn [h]
|
|
(-> h
|
|
(wrap-status-from-source)
|
|
(wrap-apply-sort grid-page)
|
|
(wrap-schema-enforce :query-schema query-schema)
|
|
(wrap-merge-prior-hx)
|
|
(wrap-client-redirect-unauthenticated))))) |