(ns auto-ap.ssr.payments (: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.graphql.utils :refer [assert-can-see-client exception->notification extract-client-ids notify-if-locked]] [auto-ap.logging :as alog] [auto-ap.permissions :refer [can?]] [auto-ap.routes.invoice :as invoice-route] [auto-ap.routes.payments :as route] [auto-ap.routes.utils :refer [wrap-admin wrap-client-redirect-unauthenticated]] [auto-ap.ssr-routes :as ssr-routes] [auto-ap.ssr.components :as com] [auto-ap.ssr.components.bank-account-icon :as bank-account-icon] [auto-ap.ssr.components.link-dropdown :refer [link-dropdown]] [auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]] [auto-ap.ssr.hx :as hx] [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 ref->enum-schema strip wrap-entity wrap-implied-route-param wrap-merge-prior-hx wrap-schema-enforce]] [auto-ap.time :as atime] [bidi.bidi :as bidi] [clj-time.coerce :as coerce] [clojure.string :as str] [datomic.api :as dc] [hiccup.util :as hu] [iol-ion.query :refer [dollars-0?]] [malli.core :as mc] [malli.transform :as mt])) (defn exact-match-id* [request] (if (nat-int? (:exact-match-id (:parsed-query-params request))) [:div {:x-data (hx/json {:exact_match (:exact-match-id (:parsed-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"}])) ;; TODO use query-params instead of parsed-query-params (defn filters [request] [:form#payment-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})]) (com/field {:label "Payment Type"} (com/radio-card {:size :small :name "payment-type" :value (:payment-type (:query-params request)) :options [{:value "" :content "All"} {:value "cash" :content "Cash"} {:value "check" :content "Check"} {:value "debit" :content "Debit"}]})) (exact-match-id* request)]]) (def default-read '[* [:payment/date :xform clj-time.coerce/from-date] {:invoice-payment/_payment [* {:invoice-payment/invoice [*]}]} {:payment/client [:client/name :db/id :client/code]} {:payment/bank-account [* {[:bank-account/type :xform iol-ion.query/ident] [:db/ident]}]} {:payment/invoices [:db/id :invoice/invoice-number]} {:payment/vendor [:vendor/name {:vendor/default-account [:account/name :account/numeric-code :db/id]} :db/id {:vendor/primary-contact [*]} {:vendor/address [*]}]} {[:payment/status :xform iol-ion.query/ident] [:db/ident]} {[:payment/type :xform iol-ion.query/ident] [:db/ident]} {:transaction/_payment [:db/id :transaction/date]}]) (defn fetch-ids [db {:keys [query-params route-params] :as request}] (let [ valid-clients (extract-client-ids (:clients request) (:client request) (:client-id query-params) (when (:client-code query-params) [:client/code (:client-code query-params)])) check-number-like (try (Long/parseLong (:check-number query-params)) (catch Exception _ nil)) query (if (:exact-match-id query-params) {:query {:find '[?e] :in '[$ ?e [?c ...]] :where '[[?e :payment/client ?c]]} :args [db (:exact-match-id query-params) valid-clients]} (cond-> {:query {:find [] :in '[$ [?clients ?start ?end]] :where '[[(iol-ion.query/scan-payments $ ?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)]]} (:sort query-params) (add-sorter-fields {"client" ['[?e :payment/client ?c] '[?c :client/name ?sort-client]] "vendor" ['[?e :payment/vendor ?v] '[?v :vendor/name ?sort-vendor]] "bank-account" ['[?e :payment/bank-account ?ba] '[?ba :bank-account/name ?sort-bank-account]] "check-number" ['[(get-else $ ?e :payment/check-number 0) ?sort-check-number]] "date" ['[?e :payment/date ?sort-date]] "amount" ['[?e :payment/amount ?sort-amount]] "status" ['[?e :payment/status ?sort-status]]} query-params) (:exact-match-id query-params) (merge-query {:query {:in ['?e] :where []} :args [(:exact-match-id query-params)]}) (:vendor query-params) (merge-query {:query {:in ['?vendor-id] :where ['[?e :payment/vendor ?vendor-id]]} :args [(:db/id (:vendor query-params))]}) (:original-id query-params) (merge-query {:query {:in ['?original-id] :where ['[?e :payment/client ?c] '[?c :client/original-id ?original-id]]} :args [(:original-id query-params)]}) (:check-number-like query-params) (merge-query {:query {:in ['?check-number] :where ['[?e :payment/check-number ?check-number]]} :args [(:check-number-like query-params)]}) (not-empty (:invoice-number query-params)) (merge-query {:query {:in ['?invoice-number] :where ['[?e :payment/invoices ?i] '[?i :invoice/invoice-number ?invoice-number]]} :args [(:invoice-number query-params)]}) (:bank-account-id query-params) (merge-query {:query {:in ['?bank-account-id] :where ['[?e :payment/bank-account ?bank-account-id]]} :args [(:bank-account-id query-params)]}) (:amount-gte query-params) (merge-query {:query {:in ['?amount-gte] :where ['[?e :payment/amount ?a] '[(>= ?a ?amount-gte)]]} :args [(:amount-gte query-params)]}) (:amount-lte query-params) (merge-query {:query {:in ['?amount-lte] :where ['[?e :payment/amount ?a] '[(<= ?a ?amount-lte)]]} :args [(:amount-lte query-params)]}) (:amount query-params) (merge-query {:query {:in ['?amount] :where ['[?e :payment/amount ?transaction-amount] '[(iol-ion.query/dollars= ?transaction-amount ?amount)]]} :args [(:amount query-params)]}) (:status route-params) (merge-query {:query {:in ['?status] :where ['[?e :payment/status ?status]]} :args [(:status route-params)]}) (:payment-type query-params) (merge-query {:query {:in '[?payment-type] :where ['[?e :payment/type ?payment-type]]} :args [(:payment-type query-params)]}) check-number-like (merge-query {:query {:in '[?check-number-like] :where ['[?e :payment/check-number ?check-number-like]]} :args [check-number-like]}) true (merge-query {:query {:find ['?sort-default '?e]}})))] (cond->> (observable-query query) true (apply-sort-3 query-params) true (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]] [:payment-type {:optional true} [:maybe (ref->enum-schema "payment-type")]] [: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 "payment-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)) ;; TODO fix parsing of query params (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 :payment :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" "#payment-filters" :color :red} "Void selected"))]) :row-buttons (fn [_ entity] [(when (not= :payment-status/voided (:payment/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 payment?"} svg/trash))]) :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Payments"]] :title (fn [r] (str (some-> r :rout-params :status name str/capitalize (str " ")) "Payments")) :entity-name "payments" :route ::route/table :headers [{:key "client" :name "Client" :sort-key "client" :hide? (fn [args] (= (count (:clients args)) 1)) :render #(-> % :payment/client :client/code)} {:key "vendor" :name "Vendor" :sort-key "vendor" :render #(-> % :payment/vendor :vendor/name)} {:key "bank-account" :name "Bank account" :sort-key "bank-account" :show-starting "xl" :render (fn [p] [:div.flex.items-center (when (:payment/bank-account p) (bank-account-icon/icon (:payment/bank-account p))) [:div (-> p :payment/bank-account :bank-account/name)]])} {:key "check-number" :name "Check #" :sort-key "check-number" :render (fn [{:payment/keys [s3-url check-number]}] (if s3-url (com/link {:href s3-url :target "_new"} [:div.flex.items-center.gap-x-2 check-number [:div.w-4.h-4 svg/external-link]]) check-number))} {:key "status" :name "Status" :render (fn [{:payment/keys [status]}] (condp = status :payment-status/cleared (com/pill {:color :primary} "cleared") :payment-status/pending (com/pill {:color :secondary} "pending") :payment-status/voided (com/pill {:color :red} "voided") nil ""))} {:key "date" :name "Date" :show-starting "lg" :render (fn [{:payment/keys [date]}] (some-> date (atime/unparse-local atime/normal-date)))} {:key "amount" :name "Amount" :render (fn [{:payment/keys [amount]}] (some->> amount (format "$%.2f")))} {:key "links" :name "Links" :class "w-8" :render (fn [p] (link-dropdown (concat (->> p :payment/invoices (map (fn [invoice] {:link (hu/url (bidi/path-for ssr-routes/only-routes ::invoice-route/all-page) {:exact-match-id (:db/id invoice)}) :content (str "Inv. " (:invoice/invoice-number invoice))}))) (some-> p :transaction/_payment ((fn [t] [{:link (hu/url (bidi/path-for client-routes/routes :transactions) {:exact-match-id (:db/id (first t))}) :color :secondary :content "Transaction"}]))))))}]})) (def row* (partial helper/row* grid-page)) (comment (mc/decode query-schema {"exact-match-id" "123"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"exact-match-id" nil} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"exact-match-id" ""} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"start-date" "12/21/2023"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"payment-type" "food"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"vendor" "87"} (mt/transformer main-transformer mt/strip-extra-keys-transformer)) (mc/decode query-schema {"start-date" #inst "2023-12-21T08:00:00.000-00:00"} (mt/transformer main-transformer mt/strip-extra-keys-transformer))) (defn delete [{check :entity :as request identity :identity}] (alog/peek ::check-type check) (exception->notification #(when-not (or (= :payment-status/pending (:payment/status check)) (#{:payment-type/cash :payment-type/debit :payment-type/balance-credit} (:payment/type check))) (throw (ex-info "Payment must be pending." {})))) (exception->notification #(assert-can-see-client identity (:db/id (:payment/client check)))) (notify-if-locked (:db/id (:payment/client check)) (:payment/date check)) (let [removing-payments (mapcat (fn [x] (let [invoice (:invoice-payment/invoice x) new-balance (+ (:invoice/outstanding-balance invoice) (:invoice-payment/amount x))] [[:db/retractEntity (:db/id x)] [:upsert-invoice {:db/id (:db/id invoice) :invoice/outstanding-balance new-balance :invoice/status (if (dollars-0? new-balance) (:invoice/status invoice) :invoice-status/unpaid)}]])) (:invoice-payment/_payment check)) updated-payment {:db/id (:db/id check) :payment/amount 0.0 :payment/status :payment-status/voided}] (audit-transact (conj removing-payments updated-payment) identity) (html-response (row* (:identity request) updated-payment {:delete-after-settle? true :class "live-removed" :request request}) :headers {"hx-retarget" (format "#entity-table tr[data-id=\"%d\"]" (:db/id check))}))) ;; TODO use decoding here (defn bulk-delete-dialog [request] (alog/peek :selected (pr-str (:selected (:query-params 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) " payments. 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 payments")]))) :headers (-> {} (assoc "hx-retarget" ".modal-stack") (assoc "hx-reswap" "beforeend"))))) (defn void-payments-internal [all-ids id] (let [payments-to-update (->> all-ids (dc/q '[:find (pull ?p [:db/id {:invoice-payment/_payment [:invoice-payment/amount :db/id {:invoice-payment/invoice [:db/id :invoice/outstanding-balance]}]}]) :in $ [?p ...] :where (not [_ :transaction/payment ?p]) (not [?p :payment/status :payment-status/voided]) [?p :payment/client ?c] [(get-else $ ?c :client/locked-until #inst "2000-01-01") ?lu] [?p :payment/date ?d] [(>= ?d ?lu)]] (dc/db conn)) (map first))] (audit-transact (->> payments-to-update (mapcat (fn [{:keys [:db/id] invoices :invoice-payment/_payment}] (into [{:db/id id :payment/amount 0.0 :payment/status :payment-status/voided}] (->> invoices (mapcat (fn [{:keys [:invoice-payment/invoice :db/id :invoice-payment/amount]}] (let [new-balance (+ (:invoice/outstanding-balance invoice) amount)] [[:db.fn/retractEntity id] [:upsert-invoice {:db/id (:db/id invoice) :invoice/outstanding-balance new-balance :invoice/status (if (dollars-0? new-balance) (:invoice/status invoice) :invoice-status/unpaid)}]])))))))) id) (count payments-to-update))) (defn bulk-delete-dialog-confirm [request] (alog/peek (:form-params request)) (let [all-selected (:all-selected (:form-params request)) selected (:selected (:form-params request)) ids (cond all-selected (:ids (fetch-ids (dc/db conn) (-> request (assoc :query-params (:form-params request)) (assoc-in [:query-params :start] 0) (assoc-in [:query-params :per-page] 250)))) :else selected) updated-count (void-payments-internal ids (:identity request))] (html-response [:div] :headers {"hx-trigger" (hx/json {:modalclose "" :notification (format "Successfully voided %d of %d payments." updated-count (count ids))})}))) (defn wrap-status-from-source [handler] (fn [{:keys [matched-current-page-route] :as request}] (let [ request (cond-> request (= ::route/cleared-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/cleared) (= ::route/pending-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/pending) (= ::route/voided-page matched-current-page-route) (assoc-in [:route-params :status] :payment-status/voided) (= ::route/all-page matched-current-page-route) (assoc-in [:route-params :status] nil))] (handler request)))) (def key->handler (apply-middleware-to-all-handlers {::route/cleared-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :payment-status/cleared)) ::route/pending-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :payment-status/pending)) ::route/voided-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status :payment-status/voided)) ::route/all-page (-> (helper/page-route grid-page) (wrap-implied-route-param :status nil)) ::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/table (helper/table-route grid-page)} (fn [h] (-> h (wrap-apply-sort grid-page) (wrap-merge-prior-hx) (wrap-status-from-source) (wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :hx-schema query-schema) (wrap-client-redirect-unauthenticated)))))