handwriting checks.

This commit is contained in:
Bryce
2024-03-17 22:37:12 -07:00
parent a972df1d43
commit 8fb27d6c66
4 changed files with 269 additions and 151 deletions

View File

@@ -37,11 +37,10 @@
:bank-account/plaid-account [:plaid-account/name :db/id :plaid-account/number :plaid-account/balance]
:bank-account/intuit-bank-account [:intuit-bank-account/name :intuit-bank-account/external-id :db/id]
:bank-account/integration-status [:integration-status/message
:db/id
:integration-status/last-attempt
:integration-status/last-updated
{:integration-status/state [:db/ident]}]}
]}
:db/id
:integration-status/last-attempt
:integration-status/last-updated
{:integration-status/state [:db/ident]}]}]}
{:yodlee-provider-account/_client [*]}
{:plaid-item/_client [*]}
{:client/emails [:db/id :email-contact/email :email-contact/description]}])
@@ -61,7 +60,7 @@
(fn [bas]
(map (fn [i ba]
(-> ba
(update :bank-account/type :db/ident )
(update :bank-account/type :db/ident)
(update-in [:bank-account/integration-status :integration-status/state] :db/ident)
(update-in [:bank-account/integration-status :integration-status/last-attempt] #(some-> % coerce/to-date-time))
(update-in [:bank-account/integration-status :integration-status/last-updated] #(some-> % coerce/to-date-time))
@@ -71,9 +70,9 @@
(defn get-all []
(->> (dc/q '[:find (pull ?e r)
:in $ r
:where [?e :client/name]]
(dc/db conn)
full-read)
:where [?e :client/name]]
(dc/db conn)
full-read)
(map first)
(map cleanse)))
@@ -98,23 +97,23 @@
(map cleanse)))
(defn get-by-id [id]
(->>
(dc/pull (dc/db conn )
full-read
id)
(->>
(dc/pull (dc/db conn)
full-read
id)
(cleanse)))
(defn code->id [code]
(->>
(->>
(dc/q '[:find ?e
:in $ ?code
:where [?e :client/code ?code]]
:in $ ?code
:where [?e :client/code ?code]]
(dc/db conn) code)
(first)
(first)))
(defn best-match [identifier]
(when (and identifier (not-empty identifier))
(when (and identifier (not-empty identifier))
(some-> (solr/query solr/impl "clients"
{"query" (format "_text_:\"%s\"" (str/upper-case (solr/escape identifier)))
"fields" "id"})
@@ -126,7 +125,7 @@
(defn exact-match [identifier]
(when (and identifier (not-empty identifier))
(when (and identifier (not-empty identifier))
(some-> (solr/query solr/impl "clients"
{"query" (format "exact:\"%s\"" (str/upper-case (solr/escape identifier)))
"fields" "id"})
@@ -149,7 +148,6 @@
"code" (:client/code result)
"exact" (map str/upper-case matches)})))
(defn raw-graphql-ids [db args]
(let [name-like-ids (cond (not (str/blank? (:name-like args)))
(set (map (comp #(Long/parseLong %) :id)
@@ -172,24 +170,24 @@
matching-ids)
(set (map :db/id (:clients args))))
query (cond-> {:query {:find []
:in ['$ ]
:in ['$]
:where []}
:args [db]}
valid-ids
(merge-query {:query {:in ['[?e ...]]}
:args [(set valid-ids)]})
(:sort args) (add-sorter-fields {"name" ['[?e :client/name ?sort-name]]}
args)
true
(merge-query {:query {:find ['?sort-default '?e] :where ['[?e :client/name ?sort-default]]}}))]
(->> (query2 query)
(apply-sort-3 (update args :sort conj {:sort-key "default-2" :asc true}))
(apply-pagination args))))
(apply-sort-3 (update args :sort conj {:sort-key "default-2" :asc true}))
(apply-pagination args))))
(defn graphql-results [ids db args]
(let [results (->> (pull-many db full-read
@@ -201,6 +199,6 @@
(defn get-graphql-page [args]
(let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count} (raw-graphql-ids db args)]
[(->> (graphql-results ids-to-retrieve db args))
matching-count]))

View File

@@ -423,7 +423,6 @@
nil)}))
(defn get-payment-page [context args _]
(alog/info ::TEST)
(let [[payments checks-count] (d-checks/get-graphql (-> args
:filters
(<-graphql)

View File

@@ -4,9 +4,14 @@
:refer [add-sorter-fields apply-pagination apply-sort-3
audit-transact conn merge-query observable-query
pull-many]]
[auto-ap.graphql.checks :as gq-checks :refer [print-checks-internal]]
[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
exception->4xx
assert-not-locked exception->4xx
exception->notification
extract-client-ids notify-if-locked]]
[auto-ap.logging :as alog]
@@ -15,6 +20,7 @@
[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]]
@@ -29,10 +35,13 @@
:refer [apply-middleware-to-all-handlers clj-date-schema
dissoc-nil-transformer entity-id html-response
main-transformer modal-response money ref->enum-schema
strip wrap-entity wrap-merge-prior-hx wrap-schema-enforce]]
round-money strip wrap-entity 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]
@@ -112,6 +121,7 @@
(def default-read '[:db/id
:invoice/invoice-number
:invoice/total
:invoice/outstanding-balance
:invoice/source-url
[:invoice/date :xform clj-time.coerce/from-date]
@@ -446,12 +456,15 @@
(com/pill {:color :secondary}
"+ " (dec (count expense-accounts)) " more")])])}
{:key "total"
:name "Total"
:sort-key "total"
{:key "outstanding"
:name "Outstanding"
:sort-key "outstanding-balance"
:class "text-right"
:render (fn [{:invoice/keys [total]}]
(some->> total (format "$%,.2f")))}
: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"
@@ -594,40 +607,61 @@
updated-count
(count ids))})})))
;; TODO
;; voiding invoice - should you allow if there are ANY payments? i think no
;; Do filtering of invoices on the dialog. Show a little banner if the total count doesn't match
;; Allow for paying balances from set of invoices for one vendor
;; Allow for credits, just highlight it
;; Entering 0.00 payment should just remove it from the set
;; Better text to link to pdfs
;; include hint about margins from the dialog
;; refresh the window after printing
;; Handwritten date
;; filter only for unlocked
;; filter only for unpaid
(defn does-amount-exceed-outstanding? [amount outstanding-balance]
(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))))
(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? #(not (does-amount-exceed-outstanding? (:amount %) (outstanding-balances (:invoice-id %))))
invoices)))]]]
[:bank-account entity-id]
[:mode [:enum :simple :advanced]]
[:method [:enum :debit :print-check :cash :handwrite-check]]]))
(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]
[:mode [:enum :simple :advanced]]
[:method [:enum :debit :print-check :cash :handwrite-check]]]))
(defn bank-account-card-base [{:keys [bg-color text-color icon bank-account]}]
[:div {:class "w-[30em] cursor-move"}
(defn bank-account-card-base [{:keys [bg-color text-color icon bank-account can-handwrite?]}]
[:div {:class "w-[30em]"}
(com/card {:class "w-full"}
[:div.flex.items-stretch {:x-data (hx/json {:chosen false
:popper nil})
@@ -685,7 +719,8 @@
{:from (mm/encode-step-key :choose-method)
:to (mm/encode-step-key :payment-details)})}
"Debit"))
(when (= :bank-account-type/check (:bank-account/type bank-account))
(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"})
@@ -695,29 +730,39 @@
"Handwrite check"))]]])])
(defmulti bank-account-card (comp :bank-account/type))
(defmethod bank-account-card :bank-account-type/cash [bank-account]
(defmulti bank-account-card (fn [ba _]
(:bank-account/type ba)))
(defmethod bank-account-card :bank-account-type/cash [bank-account can-handwrite?]
(bank-account-card-base {:bg-color "bg-green-50"
:text-color "text-green-600"
:icon svg/dollar
:bank-account bank-account}))
:bank-account bank-account
:can-handwrite? can-handwrite?}))
(defmethod bank-account-card
:bank-account-type/credit
[bank-account]
[bank-account can-handwrite?]
(bank-account-card-base {:bg-color "bg-purple-50"
:text-color "text-purple-600"
:icon svg/credit-card
:bank-account bank-account}))
:bank-account bank-account
:can-handwrite? can-handwrite?}))
(defmethod bank-account-card
:bank-account-type/check [bank-account]
:bank-account-type/check [bank-account can-handwrite?]
(bank-account-card-base {:bg-color "bg-blue-50"
:text-color "text-blue-600"
:icon svg/check
:bank-account bank-account}))
:bank-account bank-account
:can-handwrite? can-handwrite?}))
(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))))
(defrecord ChoosePaymentMethodModal [linear-wizard]
mm/ModalWizardStep
@@ -732,29 +777,24 @@
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:bank-account :method}))
(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
{}
(let [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)))) ;; TODO
(map first)
(sort-by :bank-account/sort-order))]
(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))]
(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]
(bank-account-card ba))]))
:footer
nil
#_(mm/default-step-footer linear-wizard this :validation-route ::route/navigate)
:validation-route ::route/pay-wizard-navigate)))
(for [ba (:bank-accounts linear-wizard)]
(bank-account-card ba can-handwrite?))])
:footer
nil
:validation-route ::route/pay-wizard-navigate))))
(defrecord PaymentDetailsStep [linear-wizard]
mm/ModalWizardStep
@@ -767,7 +807,7 @@
[])
(step-schema [_]
(mut/select-keys (mm/form-schema linear-wizard) #{:invoices}))
(mut/select-keys (mm/form-schema linear-wizard) #{:invoices :check-number}))
(render-step [this request]
(mm/default-render-step
@@ -776,10 +816,21 @@
:body (mm/default-step-body
{}
[:div {}
(if (= :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"}))))
(com/radio-list {:x-model "mode"
:name "step-params[mode]"
:options [{:value "simple"
:content "Pay in full"}
:content (format "Pay in full ($%,.2f)" (reduce + 0.0
(map (comp :invoice/outstanding-balance (:invoice-by-id linear-wizard) :invoice-id)
(:invoices (:snapshot (:multi-form-state request))))))}
{:value "advanced"
:content "Customize payments"}]})
[:div.space-y-4 (hx/alpine-appear {:x-show "mode==\"advanced\""})
@@ -820,22 +871,39 @@
:validation-route ::route/pay-wizard-navigate)))
(defn add-handwritten-check [request wizard snapshot]
(let [snapshot (assoc snapshot :date (time/now))
invoices (d-invoices/get-multi (map :invoice-id (:invoices snapshot))) ;; TODO shouldn't need datomic
bank-account-id (:bank-account snapshot)
bank-account (d-bank-accounts/get-by-id bank-account-id)
_ (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 (: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 (:date snapshot)))]
(invoice-payments invoices invoice-payment-lookup))
(:identity request))]
(doseq [[_ i] (:tempids result)]
(solr/touch-with-ledger i)))))
(defrecord PayWizard [form-params current-step entity]
(defrecord PayWizard [form-params current-step invoice-by-id]
mm/LinearModalWizard
(hydrate-from-request
[this request]
this
#_(assoc this :entity (:entity request)))
(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}]
;; TODO should this be customized based off the selections they make?
(let [invoices (->> (dc/q '[:find (pull ?i [{:invoice/vendor [:vendor/name :db/id]
:invoice/client [:db/id]}
:invoice/outstanding-balance
@@ -843,14 +911,37 @@
:db/id])
:in $ [?i ...]]
(dc/db conn)
(map :invoice-id (get-in request [:multi-form-state :step-params :invoices])))
(map :invoice-id (get-in request [:multi-form-state :snapshot :invoices])))
(map first)
(sort-by (juxt (comp :invoice/vendor :vendor/name)
:invoice/invoice-number)))
request (update-in request [:multi-form-state :step-params :invoices]
: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]
(mapv (fn [form-invoice i]
(assoc form-invoice :invoice i)) form-invoices 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
@@ -858,9 +949,9 @@
(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)}))))))
:step-params
:mode
name)}))))))
(steps [_]
[:choose-method
@@ -877,31 +968,37 @@
(get {:bank-account (->ChoosePaymentMethodModal this)}
(first step-key)))))
(form-schema [_] payment-form-schema)
(submit [_ {:keys [multi-form-state request-method identity] :as request}]
(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
#(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
:else :payment-type/debit)
identity))]
(alog/info ::printed :result result)
#(if (= :handwrite-check (:method snapshot)) ;; TODO validation for single vendor again?
(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
:else :payment-type/debit) ;; TODO might not be right
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
@@ -931,26 +1028,45 @@
(mm/wrap-wizard pay-wizard)
(mm/wrap-init-multi-form-state (fn [request]
(exception->notification
;; TODO handle locked
(let [selected-ids (selected->ids request (: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)}
[]
{:mode :simple})))))
#(let [selected-ids (selected->ids request (:query-params request))
selected-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)
selected-ids)
(map first))
_ (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?)}
[]
{:mode :simple
:has-warning? (boolean has-warning?)}))))))
::route/pay-submit (-> mm/submit-handler
(mm/wrap-wizard pay-wizard)

View File

@@ -557,4 +557,9 @@
{:x 87}
main-transformer))
(defn round-money [d]
(with-precision 2
(double (.setScale (bigdec d) 2 java.math.RoundingMode/HALF_UP))))