(ns auto-ap.ssr.ledger.common (:require [auto-ap.datomic :refer [add-sorter-fields apply-pagination apply-sort-4 conn merge-query observable-query pull-many]] [auto-ap.datomic.accounts :as d-accounts] [auto-ap.graphql.utils :refer [extract-client-ids]] [auto-ap.permissions :refer [can?]] [auto-ap.routes.invoice :as invoice-route] [auto-ap.routes.ledger :as route] [auto-ap.routes.transactions :as transaction-routes] [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.grid-page-helper :as helper] [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 [clj-date-schema entity-id html-response ref->enum-schema strip]] [auto-ap.time :as atime] [auto-ap.utils :refer [dollars-0?]] [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=]] [malli.core :as mc])) (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 bank-account-filter* [request] [:div {:hx-trigger "clientSelected from:body" :hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter) :hx-target "this" :hx-swap "outerHTML"} (when (:client request) (let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request)))) (:db/id (:bank-account (:query-params request))))] (com/field {:label "Bank Account"} (com/radio-card {:size :small :name "bank-account" :value (or (when bank-account-belongs-to-client? (:db/id (:bank-account (:query-params request)))) "") :options (into [{:value "" :content "All"}] (for [ba (:client/bank-accounts (:client request))] {:value (:db/id ba) :content (:bank-account/name ba)}))}))))]) (defn bank-account-filter [request] (html-response (bank-account-filter* request))) (defn filters [request] [:form#ledger-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})) (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))))})) (bank-account-filter* request) (date-range-field* request) (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 "Account Code"} [:div.flex.space-x-4.items-baseline (com/int-input {:name "numeric-code-gte" :id "numeric-code-gte" :hx-preserve "true" :class "hot-filter w-20" :value (:numeric-code-gte (:query-params request)) :placeholder "40000" :size :small}) [:div.align-baseline "to"] (com/int-input {:name "numeric-code-lte" :hx-preserve "true" :id "numeric-code-lte" :class "hot-filter w-20" :value (:numeric-code-lte (:query-params request)) :placeholder "50000" :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})]) [:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})} (com/hidden {:name "only-unbalanced" ":value" "onlyUnbalanced ? 'on' : ''"}) (com/checkbox {:value (:only-unbalanced (:query-params request)) :x-model "onlyUnbalanced"} "Show unbalanced")] (exact-match-id* request)]]) (defn- apply-only-unbalanced [query-params results] (if (:only-unbalanced query-params) (let [unbalanced-ids (->> (dc/q '[:find (sum ?debit) (sum ?credit) ?je :in $ [?je ...] :with ?li :where [?je :journal-entry/line-items ?li] [(get-else $ ?li :journal-entry-line/credit 0.0) ?credit] [(get-else $ ?li :journal-entry-line/debit 0.0) ?debit]] (dc/db conn) (map last results)) (filter (fn [[debits credits]] (not (dollars= debits credits)))) (map last) (into #{}))] (for [result results :when (get unbalanced-ids (last result))] result)) results)) ;; TODO ;; 1. Sorting in investigate dialog ;; 2. actual date range filtering in investigate dialog ;; 3. CSVs ;; 4. better date range / advanced mode for dialog (defn fetch-ids [db {:keys [query-params route-params] :as request}] (let [valid-clients (extract-client-ids (:clients request) (:client-id request) (:client-id query-params) (when (:client-code request) [:client/code (:client-code request)])) args query-params query (if (:exact-match-id args) {:query {:find '[?e] :in '[$ ?e [?c ...]] :where '[[?e :journal-entry/client ?c]]} :args [db (:exact-match-id args) valid-clients]} (cond-> {:query {:find [] :in ['$ '[?clients ?start ?end]] :where '[[(iol-ion.query/scan-ledger $ ?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)]]} (:only-external args) (merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}}) (seq (:external-id-like args)) (merge-query {:query {:in ['?external-id-like] :where ['[?e :journal-entry/external-id ?external-id] '[(.contains ^String ?external-id ?external-id-like)]]} :args [(:external-id-like args)]}) (seq (:source args)) (merge-query {:query {:in ['?source] :where ['[?e :journal-entry/source ?source]]} :args [(:source args)]}) (:external? route-params) (merge-query {:query {:where ['[?e :journal-entry/external-id]]}}) (:vendor args) (merge-query {:query {:in ['?vendor-id] :where ['[?e :journal-entry/vendor ?vendor-id]]} :args [(:db/id (:vendor args))]}) (:invoice-number args) (merge-query {:query {:in ['?invoice-number] :where ['[?e :journal-entry/original-entity ?oe] '[?oe :invoice/invoice-number ?invoice-number]]} :args [(:invoice-number args)]}) (or (:numeric-code-lte args) (:numeric-code-gte args) (seq (:numeric-code args)) (:account args) (:db/id (:bank-account args)) (not-empty (:location args))) (merge-query {:query {:where ['[?e :journal-entry/line-items ?li]]}}) (or (:numeric-code-gte args) (:numeric-code-lte args)) (merge-query {:query {:in '[?from-numeric-code ?to-numeric-code] :where ['[?li :journal-entry-line/account ?a] '(or-join [?a ?c] [?a :account/numeric-code ?c] [?a :bank-account/numeric-code ?c]) '[(>= ?c ?from-numeric-code)] '[(<= ?c ?to-numeric-code)]]} :args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]}) (seq (:numeric-code args)) (merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]] :where ['[?li :journal-entry-line/account ?a] '(or-join [?a ?c] [?a :account/numeric-code ?c] [?a :bank-account/numeric-code ?c]) '[(>= ?c ?from-numeric-code)] '[(<= ?c ?to-numeric-code)]]} :args [(map (juxt :from :to) (:numeric-code args))]}) (seq (:account args)) (merge-query {:query {:in ['?a3] :where ['[?li :journal-entry-line/account ?a3]]} :args [(:db/id (:account args))]}) (:amount-gte args) (merge-query {:query {:in ['?amount-gte] :where ['[?e :journal-entry/amount ?a] '[(>= ?a ?amount-gte)]]} :args [(:amount-gte args)]}) (:amount-lte args) (merge-query {:query {:in ['?amount-lte] :where ['[?e :journal-entry/amount ?a] '[(<= ?a ?amount-lte)]]} :args [(:amount-lte args)]}) (:db/id (:bank-account args)) (merge-query {:query {:in ['?a] :where ['[?li :journal-entry-line/account ?a]]} :args [(:db/id (:bank-account args))]}) (:account-id args) (merge-query {:query {:in ['?a2] :where ['[?e :journal-entry/line-items ?li2] '[?li2 :journal-entry-line/account ?a2]]} :args [(:account-id args)]}) (not-empty (:location args)) (merge-query {:query {:in ['?location] :where ['[?li :journal-entry-line/location ?location]]} :args [(:location args)]}) (not-empty (:locations args)) (merge-query {:query {:in ['[?location ...]] :where ['[?li :journal-entry-line/location ?location]]} :args [(:locations args)]}) (:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c] '[?c :client/name ?sort-client]] "date" ['[?e :journal-entry/date ?sort-date]] "vendor" '[(or-join [?e ?sort-vendor] (and [?e :journal-entry/vendor ?v] [?v :vendor/name ?sort-vendor]) (and [(missing? $ ?e :journal-entry/vendor)] [(ground "") ?sort-vendor]))] "amount" ['[?e :journal-entry/amount ?sort-amount]] "external-id" ['[?e :journal-entry/external-id ?sort-external-id]] "source" '[(or-join [?e ?sort-source] [?e :journal-entry/source ?sort-source] (and [(missing? $ ?e :journal-entry/source)] [(ground "") ?sort-source]))]} args) true (merge-query {:query {:find ['?sort-default '?e]}})))] (->> (observable-query query) (apply-sort-4 (assoc query-params :default-asc? true)) (apply-only-unbalanced query-params) (apply-pagination query-params)))) #_(dc/q '{:find [?sort-vendor (count ?e)], :in [$ [?clients ?start ?end]], :where [[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]] #_(not [?e :journal-entry/vendor]) [(missing? $ ?e :journal-entry/vendor)] [(ground "ih") ?sort-vendor]]} (dc/db conn) args) (def default-read '[:journal-entry/amount :journal-entry/alternate-description :journal-entry/memo :journal-entry/source :journal-entry/external-id :db/id [:journal-entry/date :xform clj-time.coerce/from-date] {:journal-entry/vendor [:vendor/name :db/id] :journal-entry/original-entity [:invoice/invoice-number :invoice/source-url :transaction/description-original :db/id] :journal-entry/client [:client/name :client/code :db/id] :journal-entry/line-items [:journal-entry-line/debit :journal-entry-line/location :journal-entry-line/running-balance :journal-entry-line/credit {:journal-entry-line/account [:account/name :db/id :account/numeric-code :bank-account/name :bank-account/numeric-code {[:account/type :xform iol-ion.query/ident] [:db/ident :db/id]} {:account/client-overrides [:account-client-override/name {:account-client-override/client [:db/id]}]} {[:bank-account/type :xform iol-ion.query/ident] [:db/ident :db/id]}]}]}]) (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)])) (defn render-lines [key {:journal-entry/keys [line-items client]}] (let [lines (for [jel line-items :when (and (key jel) (not (dollars-0? (key jel))))] jel)] [:div.grid.grid-cols-2.gap-1.auto-cols-min.grid-flow-row.shrink (for [jel lines :let [account (d-accounts/clientize (:journal-entry-line/account jel) (:db/id client)) account-name (or (:account/name account) (:bank-account/name (:journal-entry-line/account jel)))]] (list (if account-name [:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel) (format "$%,.2f"))))} [:div.text-left.underline.cursor-pointer {:x-ref "source"} (:journal-entry-line/location jel) ": " (or (:account/numeric-code account) (:bank-account/numeric-code account)) " - " account-name]] [:div.text-left (com/pill {:color :yellow} "Unassigned")]) [:div.text-right.text-underline (format "$%,.2f" (key jel))])) (when-not (= 1 (count lines)) [:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines (map #(or (key %) 0.0)) (reduce + 0.0) (format "$%,.2f")))])])) (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]] [:client-id {:optional true} [:maybe entity-id]] [:only-unbalanced {:optional true} [:maybe [:boolean {:decode/string {:enter #(cond (= % "on") true (= % "") false :else (boolean %))} :encode/string {:enter #(if % "on" "")}}]]] [:numeric-code {:optional true :decode/string clojure.edn/read-string} [:maybe [:vector [:map [:from nat-int?] [:to nat-int?]]]]] [:numeric-code-gte {:optional true} [:maybe nat-int?]] [:numeric-code-lte {:optional true} [:maybe nat-int?]] [:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]] [:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/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]]]])) (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 (date-range-field* request) [1 :hx-swap-oob] true) (assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)]) :query-schema query-schema :action-buttons (fn [request] [(when-not (:external? (:route-params request)) (com/button {:color :primary :hx-get (bidi/path-for ssr-routes/only-routes ::route/new)} "Add journal entry"))]) :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))]) :breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Ledger"]] :title (fn [r] (str (some-> r :route-params :status name str/capitalize (str " ")) "Register")) :entity-name "register" :route ::route/table :csv-route ::route/csv :break-table (fn [request entity] (cond (= (-> request :query-params :sort first :name) "Vendor") (or (-> entity :journal-entry/vendor :vendor/name) "No vendor") (= (-> request :query-params :sort first :name) "Source") (or (-> entity :journal-entry/source) "No external source") :else nil)) :page->csv-entities (fn [[journal-entries]] (for [je journal-entries jel (:journal-entry/line-items je)] (merge jel je))) :headers [{:key "id" :name "Id" :render-csv :db/id :render-for #{:csv}} {: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 :journal-entry/client :client/name)]) :render-csv (fn [x] (-> x :journal-entry/client :client/name))} {:key "vendor" :name "Vendor" :sort-key "vendor" :render (fn [e] (or (-> e :journal-entry/vendor :vendor/name) [:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)])) :render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name) (-> e :journal-entry/alternate-description)))} {:key "source" :name "Source" :sort-key "source" :hide? (fn [args] (not (:external? (:route-params args)))) :render :journal-entry/source :render-csv :journal-entry/source} {:key "external-id" :name "External Id" :sort-key "external-id" :class "max-w-[12rem]" :hide? (fn [args] (not (:external? (:route-params args)))) :render (fn [x] [:p.truncate (:journal-entry/external-id x)]) :render-csv :journal-entry/external-id} {:key "date" :sort-key "date" :name "Date" :show-starting "lg" :render (fn [{:journal-entry/keys [date]}] (some-> date (atime/unparse-local atime/normal-date)))} {:key "amount" :sort-key "amount" :name "Amount" :show-starting "lg" :render (fn [{:journal-entry/keys [amount]}] (some->> amount (format "$%,.2f")))} {:key "account" :name "Account" :sort-key "account" :class "text-right" :render-csv #(or (-> % :journal-entry-line/account :account/name) (-> % :journal-entry-line/account :bank-account/name)) :render-for #{:csv}} {:key "debit" :name "Debit" :class "text-right" :render (partial render-lines :journal-entry-line/debit) :render-csv :journal-entry-line/debit} {:key "credit" :name "Credit" :class "text-right" :render (partial render-lines :journal-entry-line/credit) :render-csv :journal-entry-line/credit} {:key "links" :name "Links" :show-starting "lg" :class "w-8" :render (fn [i] (link-dropdown (cond-> [] (-> i :journal-entry/original-entity :invoice/invoice-number) (conj {:link (hu/url (bidi/path-for ssr-routes/only-routes ::invoice-route/all-page) {:exact-match-id (:db/id (:journal-entry/original-entity i))}) :color :primary :content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))}) (-> i :journal-entry/original-entity :invoice/source-url) {:link (-> i :journal-entry/original-entity :invoice/source-url) :color :secondary :content (str "File")} (-> i :journal-entry/original-entity :transaction/description-original) (conj {:link (hu/url (bidi/path-for ssr-routes/only-routes ::transaction-routes/all-page) {:exact-match-id (:db/id (:journal-entry/original-entity i))}) :color :primary :content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))}) (-> i :journal-entry/memo) (conj {:color :secondary :content (str "Memo: " (:journal-entry/memo i))})))) :render-for #{:html}}]})) (def row* (partial helper/row* grid-page))