Compare commits
17 Commits
integreat-
...
8cc3dc8bd7
| Author | SHA1 | Date | |
|---|---|---|---|
| 8cc3dc8bd7 | |||
| 6e7f66a78f | |||
| ab27d3a4da | |||
| 19d936693a | |||
| e9970bd41a | |||
| 366781e818 | |||
| bcb1978f44 | |||
| 785c6b3731 | |||
| 33cfbab54a | |||
| e99ac6e978 | |||
| b43f107610 | |||
| 379bfa78f7 | |||
| 0575c88c4d | |||
| bc2abf4cb1 | |||
| acc5a7aa1b | |||
| 46fdc29712 | |||
| a17e16e31b |
BIN
dev-resources/Bonanza Sample Reno 2.pdf
Executable file
BIN
dev-resources/Bonanza Sample Reno 2.pdf
Executable file
Binary file not shown.
BIN
dev-resources/Bonanza Sample Reno.pdf
Executable file
BIN
dev-resources/Bonanza Sample Reno.pdf
Executable file
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -315,21 +315,55 @@
|
||||
[[(:db/id a) (:db/id (:account-client-override/client o))]
|
||||
(:account-client-override/name o)])
|
||||
(:account/client-overrides a))))
|
||||
(into {}))]
|
||||
(into {}))
|
||||
|
||||
;; A client's bank account and the financial account it posts to share a
|
||||
;; numeric code. Reports key their rows off that code, so the two would
|
||||
;; otherwise render as two rows for the same account. Resolve every
|
||||
;; account at a shared code down to the bank account's name. Where a
|
||||
;; client has two bank accounts on one code (a data-entry error, but it
|
||||
;; happens) the lowest :bank-account/sort-order wins, then the lowest
|
||||
;; :db/id, so the name is stable across runs.
|
||||
bank-name-by-code (->> (dc/q {:find ['(pull ?b [:db/id :bank-account/name
|
||||
:bank-account/numeric-code
|
||||
:bank-account/sort-order])]
|
||||
:in ['$ '?c]
|
||||
:where ['[?c :client/bank-accounts ?b]]}
|
||||
(dc/db conn)
|
||||
client-id)
|
||||
(map first)
|
||||
(filter (every-pred :bank-account/numeric-code :bank-account/name))
|
||||
(sort-by (juxt #(or (:bank-account/sort-order %) Long/MAX_VALUE)
|
||||
:db/id))
|
||||
(reduce (fn [m b]
|
||||
(let [code (:bank-account/numeric-code b)]
|
||||
(cond-> m
|
||||
(not (contains? m code))
|
||||
(assoc code (:bank-account/name b)))))
|
||||
{}))]
|
||||
(fn [a]
|
||||
{:name (or (:bank-account/name (bank-accounts a))
|
||||
(overrides-by-client [a client-id])
|
||||
(:account/name (accounts a)))
|
||||
:account_type (or (:db/ident (:account/type (accounts a)))
|
||||
({:bank-account-type/check :account-type/asset
|
||||
:bank-account-type/cash :account-type/asset
|
||||
:bank-account-type/credit :account-type/liability}
|
||||
(:db/ident (:bank-account/type (bank-accounts a))))
|
||||
:account-type/asset ;; DEFAULT TO ASSET, for things like unknown
|
||||
)
|
||||
:numeric_code (or (:account/numeric-code (accounts a))
|
||||
(:bank-account/numeric-code (bank-accounts a)))
|
||||
:client_id client-id})))
|
||||
(let [account (accounts a)
|
||||
bank-account (bank-accounts a)
|
||||
numeric-code (or (:account/numeric-code account)
|
||||
(:bank-account/numeric-code bank-account))
|
||||
bank-name (or (bank-name-by-code numeric-code)
|
||||
(:bank-account/name bank-account))]
|
||||
{:name (or bank-name
|
||||
(overrides-by-client [a client-id])
|
||||
(:account/name account))
|
||||
;; Whether :name above came from a bank account. Reports that pool
|
||||
;; several clients into one row per code use this to pick the label,
|
||||
;; since only some of those clients may have a bank account at the code.
|
||||
:bank_account_name? (some? bank-name)
|
||||
:account_type (or (:db/ident (:account/type account))
|
||||
({:bank-account-type/check :account-type/asset
|
||||
:bank-account-type/cash :account-type/asset
|
||||
:bank-account-type/credit :account-type/liability}
|
||||
(:db/ident (:bank-account/type bank-account)))
|
||||
:account-type/asset ;; DEFAULT TO ASSET, for things like unknown
|
||||
)
|
||||
:numeric_code numeric-code
|
||||
:client_id client-id}))))
|
||||
|
||||
(defn find-mismatch-index []
|
||||
(reduce + 0
|
||||
|
||||
@@ -771,8 +771,22 @@
|
||||
:keywords [#"530-544-4136"]
|
||||
:extract {:invoice-number #"NO\s+(\d{8,})\s+\d{2}/\d{2}/\d{2}"
|
||||
:date #"NO\s+\d{8,}\s+(\d{2}/\d{2}/\d{2})"
|
||||
:customer-identifier #"(?s)I\s+([A-Z][A-Z\s]+?)\s{2,}.*?L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||
:account-number #"(?s)L\s+([0-9][A-Z0-9\s]+?)(?=\s{2,}|\n)"
|
||||
;; The bill-to block spells BILL TO down the left margin, one letter
|
||||
;; per line, with the customer's details in the column to its right:
|
||||
;; B NICKGK
|
||||
;; I NICK THE GREEK <<McCARRAN>>
|
||||
;; L NICK THE GREEK <<McCARRAN>>
|
||||
;; L 10310 N McCARRAN BLVD STE.400
|
||||
;; RENO, NV 89503
|
||||
;; Anchor on those margin letters at the start of a line -- the ship-to
|
||||
;; block to the right reuses the same letters mid-line -- and take the
|
||||
;; whole column up to the next column gap. That column carries mixed
|
||||
;; case and punctuation, so it must not be restricted to [A-Z0-9\s];
|
||||
;; doing so dropped store names like McCARRAN entirely.
|
||||
:customer-identifier #"(?m)^\s+I\s{2,}(\S.*?)(?:\s{2,}|$)"
|
||||
;; Both the name and the street sit on an L line; the street is the
|
||||
;; one that starts with a house number.
|
||||
:account-number #"(?m)^\s+L\s{2,}(\d\S*.*?)(?:\s{2,}|$)"
|
||||
:total #"SHIPPED\s+[\d\.]+\s+TOTAL\s+([\d\.]+)"}
|
||||
:parser {:date [:clj-time "MM/dd/yy"]
|
||||
:total [:trim-commas nil]}}
|
||||
|
||||
@@ -22,17 +22,22 @@
|
||||
(if (not-empty q)
|
||||
(->>
|
||||
(str/split q #",")
|
||||
(map (fn [k]
|
||||
(let [[key asc?] (str/split k #":")
|
||||
matching-header (first (filter #(= (str key) (:sort-key %)) (:headers grid-spec)))]
|
||||
{:sort-key (str key)
|
||||
:asc (boolean (= "asc" asc?))
|
||||
:matching-header matching-header
|
||||
:name (:name matching-header)
|
||||
:sort-icon (if (= (boolean (= "asc" asc?)) true)
|
||||
svg/sort-down
|
||||
svg/sort-up)})))
|
||||
(filter :matching-header)
|
||||
;; NOTE: matching-header is deliberately not carried in the result. It is the
|
||||
;; grid's header map, which holds a :render fn. This sort lands in :query-params,
|
||||
;; which the bulk wizards copy into their form snapshot, and that snapshot is
|
||||
;; round-tripped through pr-str / clojure.edn/read-string. A fn pr-strs as
|
||||
;; #object[...], which edn has no reader for, 500ing the submit.
|
||||
;; apply-toggle-sort below already builds entries without it.
|
||||
(keep (fn [k]
|
||||
(let [[key asc?] (str/split k #":")
|
||||
matching-header (first (filter #(= (str key) (:sort-key %)) (:headers grid-spec)))]
|
||||
(when matching-header
|
||||
{:sort-key (str key)
|
||||
:asc (boolean (= "asc" asc?))
|
||||
:name (:name matching-header)
|
||||
:sort-icon (if (= (boolean (= "asc" asc?)) true)
|
||||
svg/sort-down
|
||||
svg/sort-up)}))))
|
||||
(into []))
|
||||
[]))
|
||||
|
||||
|
||||
@@ -112,9 +112,11 @@
|
||||
true (str " focus:ring-4 font-bold rounded-lg text-xs p-3 text-center mr-2 inline-flex items-center hover:scale-105 transition duration-100 justify-center")
|
||||
(= :secondary (:color params)) (str " text-white bg-blue-500 hover:bg-blue-600 focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700")
|
||||
(= :primary (:color params)) (str " text-white bg-green-500 hover:bg-green-600 focus:ring-green-300 dark:bg-green-600 dark:hover:bg-green-700 ")
|
||||
(= :secondary-light (:color params)) (str " text-blue-800 bg-white-200 border-gray-100 border hover:bg-blue-100 focus:ring-blue-100 dark:bg-blue-400 dark:hover:bg-blue-800 ")
|
||||
(= :secondary-light (:color params)) (str " text-blue-800 bg-blue-100 border-blue-300 border hover:bg-blue-200 focus:ring-blue-100 dark:text-white dark:bg-blue-700 dark:border-blue-500 dark:hover:bg-blue-600 ")
|
||||
|
||||
(not (nil? (:color params)))
|
||||
;; the light variants paint their own text/background above -
|
||||
;; falling through here would stack white text on them
|
||||
(not (contains? #{nil :secondary-light} (:color params)))
|
||||
(str " text-white " (bg-colors (:color params) (:disabled params)))
|
||||
|
||||
(nil? (:color params))
|
||||
|
||||
@@ -12,9 +12,14 @@
|
||||
[hiccup2.core :as hiccup]))
|
||||
|
||||
(defn header- [params & rest]
|
||||
(into [:th.px-4.py-3 {:scope "col" :class (:class params)
|
||||
"@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))
|
||||
:style (:style params)}]
|
||||
;; NOTE: only attach the sort dispatcher when there is a :sort-key. Otherwise
|
||||
;; (format "%s" nil) renders the literal string "null", which sails through the
|
||||
;; thead's `event.detail.key || ""` guard and reaches the server as
|
||||
;; toggle-sort=null, ending in a Datomic unbound-variable error on ?sort-null.
|
||||
(into [:th.px-4.py-3 (cond-> {:scope "col" :class (:class params)
|
||||
:style (:style params)}
|
||||
(:sort-key params)
|
||||
(assoc "@click" (format "$dispatch('sorted', {key: '%s'})" (:sort-key params))))]
|
||||
(if (:sort-key params)
|
||||
[(into [:a {:href "#"}] rest)]
|
||||
rest)))
|
||||
|
||||
@@ -86,6 +86,13 @@
|
||||
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
|
||||
[:div.flex.w-full.justify-items-stretch
|
||||
[:span.flex-grow.text-left {"x-text" "value.label"}]
|
||||
[:div {:class "w-4 h-4 m-1 inline ml-1 justify-self-end self-center cursor-pointer text-gray-400 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
:tabindex "-1"
|
||||
:aria-hidden "true"
|
||||
:title "Clear selection"
|
||||
"@click.prevent.stop" "value = {value: '', label: ''}; if (tippy) { tippy.hide(); }"
|
||||
:x-show "!!(value && value.value)"}
|
||||
svg/x]
|
||||
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
|
||||
svg/drop-down]
|
||||
[:div {:x-show "value.warning"}
|
||||
|
||||
@@ -149,8 +149,11 @@
|
||||
main-transformer))
|
||||
"sort" sort->query)
|
||||
"selected" "all-selected"))
|
||||
:color :secondary-light}
|
||||
[:div.w-4.h-4 svg/download])))
|
||||
:color :secondary-light
|
||||
:title "Export CSV"
|
||||
:aria-label "Export CSV"}
|
||||
[:div.w-4.h-4 svg/download]
|
||||
"CSV")))
|
||||
:rows
|
||||
(let [break-table-fn (some-> grid-spec :break-table (create-break-table-fn grid-spec))]
|
||||
(for [entity entities
|
||||
|
||||
@@ -148,17 +148,18 @@
|
||||
(= ::route/external-page matched-current-page-route) (assoc-in [:route-params :external?] true))]
|
||||
(handler request))))
|
||||
|
||||
(defn line->id [{:keys [source external-id client-code]}]
|
||||
(str client-code "-" source "-" external-id))
|
||||
|
||||
(defn external-import-table-form* [request]
|
||||
[:div#table-form
|
||||
(clojure.pprint/pprint (:form-errors request))
|
||||
(fc/start-form
|
||||
(:form-params request)
|
||||
(:form-errors request)
|
||||
(fc/with-field :table
|
||||
(clojure.pprint/pprint (fc/field-errors))
|
||||
(when (seq (fc/field-value))
|
||||
|
||||
[:div {:x-data (hx/json {"showTable" false})}
|
||||
[:div {:x-data (hx/json {"showTable" false "errorsOnly" false})}
|
||||
[:form {:hx-post (bidi.bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||
:autocomplete "off"}
|
||||
(when (:just-parsed? request)
|
||||
@@ -179,8 +180,10 @@
|
||||
[:div.flex.gap-4.items-center
|
||||
(com/checkbox {"@click" "showTable=!showTable"}
|
||||
"Show table")
|
||||
(com/checkbox {:x-model "errorsOnly"}
|
||||
"Only show errors")
|
||||
(com/button {:color :primary} "Import")]
|
||||
[:div {:x-show "showTable"}
|
||||
[:div {:x-show "showTable || errorsOnly"}
|
||||
(com/data-grid-card {:id "ledger-import-data"
|
||||
:route nil
|
||||
:title "Data to import"
|
||||
@@ -198,81 +201,100 @@
|
||||
:rows
|
||||
(fc/cursor-map
|
||||
(fn [r]
|
||||
(com/data-grid-row {} (com/data-grid-cell {}
|
||||
(fc/with-field :external-id
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :client-code
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :source
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input
|
||||
{:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :vendor-name
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :date
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (some-> (fc/field-value) (atime/unparse-local
|
||||
atime/normal-date))
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :account-code
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-16"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :location
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:size 2}))))
|
||||
(com/data-grid-cell {} (fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
[:div.p-2
|
||||
(let [errors (seq (fc/field-errors))]
|
||||
(cond errors
|
||||
[:div
|
||||
{"x-tooltip" "{content: ()=>$refs.tt.innerHTML , allowHTML: true}"}
|
||||
[:div.w-8.h-8.rounded-full.p-2.flex.items-start {:class
|
||||
(if (seq (filter
|
||||
(fn [[_ status]]
|
||||
(let [entry-id (line->id (fc/field-value r))
|
||||
row-errors (seq (fc/field-errors))]
|
||||
;; A ledger entry spans several rows. Each row knows its own
|
||||
;; entry id and drops itself when any of its siblings' remove
|
||||
;; buttons announces that id. Removing the row removes its
|
||||
;; inputs, so the entry is gone from the next import post.
|
||||
;; Clean rows are only hidden (x-show), never removed, so
|
||||
;; "Only show errors" never changes what gets posted.
|
||||
(com/data-grid-row (cond-> {:data-entry-id entry-id
|
||||
:x-data (hx/json {"entryId" entry-id})
|
||||
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
|
||||
(not row-errors) (assoc :x-show "!errorsOnly"))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :external-id
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :client-code
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :source
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input
|
||||
{:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :vendor-name
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)}))))
|
||||
(com/data-grid-cell {} (fc/with-field :date
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (some-> (fc/field-value) (atime/unparse-local
|
||||
atime/normal-date))
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {}
|
||||
(fc/with-field :account-code
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-16"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :location
|
||||
(com/validated-field
|
||||
{:errors (fc/field-errors)}
|
||||
(com/text-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:size 2}))))
|
||||
(com/data-grid-cell {} (fc/with-field :debit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {} (fc/with-field :credit
|
||||
(com/validated-field {:errors (fc/field-errors)}
|
||||
(com/money-input {:value (fc/field-value)
|
||||
:name (fc/field-name)
|
||||
:class "w-24"}))))
|
||||
(com/data-grid-cell {:class "align-top"}
|
||||
[:div.p-2.flex.items-start.gap-2
|
||||
(let [errors (seq (fc/field-errors))]
|
||||
(cond errors
|
||||
[:div
|
||||
{"x-tooltip" "{content: ()=>$refs.tt.innerHTML , allowHTML: true}"}
|
||||
[:div.w-8.h-8.rounded-full.p-2.flex.items-start {:class
|
||||
(if (seq (filter
|
||||
(fn [[_ status]]
|
||||
|
||||
(= :error status))
|
||||
errors))
|
||||
"bg-red-50 text-red-300"
|
||||
"bg-yellow-100 text-yellow-600")}
|
||||
svg/alert]
|
||||
[:template {:x-ref "tt"}
|
||||
[:ul
|
||||
(for [[m] errors]
|
||||
[:li m])]]]
|
||||
:else
|
||||
nil))]))))}
|
||||
(= :error status))
|
||||
errors))
|
||||
"bg-red-50 text-red-300"
|
||||
"bg-yellow-100 text-yellow-600")}
|
||||
svg/alert]
|
||||
[:template {:x-ref "tt"}
|
||||
[:ul
|
||||
(for [[m] errors]
|
||||
[:li m])]]]
|
||||
:else
|
||||
nil))
|
||||
(com/icon-button {:type "button"
|
||||
:color :danger-light
|
||||
:title (str "Remove ledger entry " entry-id)
|
||||
:aria-label (str "Remove ledger entry " entry-id)
|
||||
:data-remove-entry-id entry-id
|
||||
"@click.prevent.stop" "$dispatch('remove-import-entry', {entryId: entryId})"}
|
||||
svg/trash)])))))}
|
||||
|
||||
[:div.flex.m-4.flex-row-reverse
|
||||
(com/button {:color :primary} "Import")])]]])))])
|
||||
@@ -408,9 +430,6 @@
|
||||
(html-response
|
||||
(external-import-form* (assoc request :just-parsed? true))))
|
||||
|
||||
(defn line->id [{:keys [source external-id client-code]}]
|
||||
(str client-code "-" source "-" external-id))
|
||||
|
||||
(defn add-errors [entry all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
|
||||
(let [vendor (all-vendors (:vendor-name entry))
|
||||
locked-until (client-locked-lookup (:client-code entry))
|
||||
@@ -507,12 +526,23 @@
|
||||
ea)))
|
||||
line-items)))))
|
||||
|
||||
(defn blank-amount?
|
||||
"A row with neither a debit nor a credit carries no amount at all, so it is
|
||||
skipped entirely on import rather than flagged."
|
||||
[{:keys [debit credit]}]
|
||||
(letfn [(blank? [v]
|
||||
(or (nil? v)
|
||||
(and (string? v) (str/blank? v))))]
|
||||
(and (blank? debit) (blank? credit))))
|
||||
|
||||
(defn table->entries [table all-vendors all-accounts client-locked-lookup all-client-bank-accounts all-client-locations]
|
||||
(let [lines-with-indexes (for [[i l] (map vector (range) table)]
|
||||
(assoc l :index i))]
|
||||
(into []
|
||||
(for [[_ lines] (group-by line->id lines-with-indexes)
|
||||
:let [{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]]
|
||||
(for [[_ grouped-lines] (group-by line->id lines-with-indexes)
|
||||
:let [lines (remove blank-amount? grouped-lines)
|
||||
{:keys [source client-code date vendor-name note cleared-against] :as line} (first lines)]
|
||||
:when (seq lines)]
|
||||
(add-errors {:source source
|
||||
:indices (map :index lines)
|
||||
:external-id (line->id line)
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:period (coerce/to-date d)}))
|
||||
args (assoc (:query-params request)
|
||||
:periods (map coerce/to-date (filter identity date)))
|
||||
|
||||
@@ -88,6 +88,7 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
args (assoc (:form-params request)
|
||||
:periods (map (fn [d] {:start (coerce/to-date (:start d)) :end (coerce/to-date (:end d))}) periods))
|
||||
|
||||
@@ -209,6 +209,37 @@
|
||||
;; 3. CSVs
|
||||
;; 4. better date range / advanced mode for dialog
|
||||
|
||||
(defn- accounts-sharing-code
|
||||
"The searched account, plus any of `clients`' bank accounts on the same numeric
|
||||
code. :journal-entry-line/account points at either entity, so a search for the
|
||||
financial account has to match lines posted to the bank account too."
|
||||
[db clients account-id]
|
||||
(into [account-id]
|
||||
(when-let [code (:account/numeric-code (dc/entity db account-id))]
|
||||
(dc/q '[:find [?ba ...]
|
||||
|
||||
:in $ [?client ...] ?code
|
||||
|
||||
:where
|
||||
[?client :client/bank-accounts ?ba]
|
||||
[?ba :bank-account/numeric-code ?code]]
|
||||
db clients code))))
|
||||
|
||||
(defn- account-filter-query
|
||||
"Ledger clause for the Account search. Stays a scalar binding when nothing
|
||||
shares the account's code, so the common case costs what it always did."
|
||||
[db clients account-id]
|
||||
(let [ids (accounts-sharing-code db clients account-id)]
|
||||
(if (second ids)
|
||||
{:query {:in ['[?a3 ...]]
|
||||
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [ids]}
|
||||
{:query {:in ['?a3]
|
||||
|
||||
:where ['[?li :journal-entry-line/account ?a3]]}
|
||||
:args [account-id]})))
|
||||
|
||||
(defn fetch-ids [db {:keys [query-params route-params] :as request}]
|
||||
(let [valid-clients (extract-client-ids (:clients request)
|
||||
(:client-id request)
|
||||
@@ -288,9 +319,7 @@
|
||||
'[(<= ?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))]})
|
||||
(merge-query (account-filter-query db valid-clients (:db/id (:account args))))
|
||||
|
||||
(:amount-gte args)
|
||||
(merge-query {:query {:in ['?amount-gte]
|
||||
@@ -627,6 +656,12 @@
|
||||
:render (fn [{:journal-entry/keys [amount]}]
|
||||
(some->> amount
|
||||
(format "$%,.2f")))}
|
||||
{:key "account-number"
|
||||
:name "Account Number"
|
||||
:class "text-right"
|
||||
:render-csv #(or (-> % :journal-entry-line/account :account/numeric-code)
|
||||
(-> % :journal-entry-line/account :bank-account/numeric-code))
|
||||
:render-for #{:csv}}
|
||||
{:key "account"
|
||||
:name "Account"
|
||||
:sort-key "account"
|
||||
|
||||
@@ -69,37 +69,80 @@ NOTE: Please review the transactions we may have question for you here: " (app-u
|
||||
"?subject=" (url-encode (str name " is ready"))
|
||||
"&body=" (url-encode (email-body url))))
|
||||
|
||||
(defn describe-contacts [contacts]
|
||||
(str/join ", " (map (fn [{:email-contact/keys [email description]}]
|
||||
(if (str/blank? description)
|
||||
email
|
||||
(str email " (" description ")")))
|
||||
contacts)))
|
||||
(def eyebrow-class
|
||||
"Section label for the two hand-offs. The modal is a transmittal slip: each
|
||||
section is one destination the report can go to."
|
||||
"text-[11px] font-semibold uppercase tracking-wider text-gray-400 dark:text-gray-500")
|
||||
|
||||
(defn contact-row
|
||||
"One recipient, as a role/address pair. The contacts used to be joined into
|
||||
the prose of a sentence, which ran on past two of them - as a distribution
|
||||
list they stay scannable however many the client has. Returns the grid cells;
|
||||
the columns are sized by the parent so every address lines up. `roles?` is
|
||||
false when no contact has a description, so the empty role column is dropped
|
||||
rather than indenting every address past nothing."
|
||||
[roles? {:email-contact/keys [email description]}]
|
||||
(list
|
||||
(when roles?
|
||||
[:dt {:class "max-w-[10rem] truncate text-[11px] font-medium uppercase tracking-wide text-gray-400 dark:text-gray-500"}
|
||||
description])
|
||||
[:dd {:class "min-w-0 truncate text-xs text-gray-700 dark:text-gray-200" :title email}
|
||||
email]))
|
||||
|
||||
(defn modal-header []
|
||||
[:div {:class "flex items-start justify-between gap-4 border-b border-gray-200 px-6 py-4 dark:border-gray-600"}
|
||||
[:div {:class "flex items-center gap-3"}
|
||||
[:span {:class "flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300"}
|
||||
[:div.h-3.w-3 svg/checkmark]]
|
||||
[:h3 {:class "text-base font-semibold text-gray-900 dark:text-white"} "Your report is ready"]]
|
||||
[:button {:type "button"
|
||||
"@click" "$dispatch('modalclose')"
|
||||
:aria-label "Close"
|
||||
:class "shrink-0 rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-900 focus:ring-2 focus:ring-green-400 dark:hover:bg-gray-600 dark:hover:text-white"}
|
||||
[:div.h-4.w-4 svg/x]]])
|
||||
|
||||
(defn download-section
|
||||
"`labelled?` is false when the report has nowhere else to go - with no second
|
||||
destination to contrast it against, \"Your copy\" labels nothing."
|
||||
[labelled? {:report/keys [name url]}]
|
||||
[:section {:class "px-6 py-5"}
|
||||
(when labelled?
|
||||
[:p {:class eyebrow-class} "Your copy"])
|
||||
[:div {:class (str (when labelled? "mt-3 ") "flex flex-col gap-3 sm:flex-row sm:items-center sm:gap-4")}
|
||||
[:div {:class "flex min-w-0 grow items-center gap-3"}
|
||||
[:div {:class "flex h-10 w-10 shrink-0 items-center justify-center rounded-md bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500"}
|
||||
[:div.h-5.w-5 svg/accounting-invoice-mail]]
|
||||
[:p {:class "min-w-0 break-words text-sm font-medium text-gray-900 dark:text-gray-100"} name]]
|
||||
(com/a-button {:href url :color :primary :download name :indicator? false
|
||||
:class "mr-0 w-full shrink-0 sm:w-auto"}
|
||||
[:div.h-4.w-4 svg/download]
|
||||
"Download PDF")]])
|
||||
|
||||
(defn email-section [contacts report]
|
||||
(let [roles? (boolean (some (comp not str/blank? :email-contact/description) contacts))]
|
||||
[:section {:class "border-t border-gray-200 px-6 py-5 dark:border-gray-600"}
|
||||
[:p {:class eyebrow-class} "Send to client"]
|
||||
[:dl {:class (str "mt-3 grid max-h-40 items-baseline gap-x-4 gap-y-1.5 overflow-y-auto "
|
||||
(if roles? "grid-cols-[max-content_minmax(0,1fr)]" "grid-cols-1"))}
|
||||
(map (partial contact-row roles?) contacts)]
|
||||
[:div {:class "mt-5 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"}
|
||||
[:p {:class "text-xs text-gray-500 dark:text-gray-400"}
|
||||
"Nothing sends until you send it."]
|
||||
(com/a-button {:href (mailto-href contacts report) :indicator? false
|
||||
:class "mr-0 w-full shrink-0 sm:w-auto"}
|
||||
[:div.h-4.w-4 svg/envelope]
|
||||
"Open email draft")]]))
|
||||
|
||||
(defn export-modal
|
||||
"Modal response for a freshly printed `report` - the map returned by the
|
||||
report namespaces' print-* functions."
|
||||
[request {:report/keys [url clients] :as report}]
|
||||
[request {:report/keys [clients] :as report}]
|
||||
(let [contacts (recipients request clients)]
|
||||
(modal-response
|
||||
(com/modal {}
|
||||
(com/modal-card
|
||||
{}
|
||||
"Ready!"
|
||||
(com/modal-body {}
|
||||
[:div.flex.flex-col.mt-4.space-y-4.items-center
|
||||
[:a {:href url}
|
||||
[:div.w-24.h-24.bg-green-50.rounded-full.p-4.text-green-300 {:class " hover:scale-110 transition duration-100"}
|
||||
svg/download]]
|
||||
[:span.text-gray-800
|
||||
"Click "
|
||||
(com/link {:href url} "here")
|
||||
" to download"]
|
||||
(when contacts
|
||||
[:span.text-gray-800.text-center
|
||||
"Once you've confirmed you're happy with it, click "
|
||||
(com/link {:href (mailto-href contacts report)} "here")
|
||||
" to open your email client and to send it to "
|
||||
(describe-contacts contacts)
|
||||
"."])])
|
||||
nil)))))
|
||||
(com/modal-card-advanced
|
||||
{:class "m-4 w-full md:m-0 md:w-[560px]"}
|
||||
(modal-header)
|
||||
(download-section (boolean contacts) report)
|
||||
(when contacts
|
||||
(email-section contacts report)))))))
|
||||
|
||||
@@ -101,6 +101,7 @@
|
||||
:account-type (:account_type account)
|
||||
:numeric-code (:numeric_code account)
|
||||
:name (:name account)
|
||||
:bank-account-name? (:bank_account_name? account)
|
||||
:sample sample
|
||||
:period {:start (coerce/to-date (:start p)) :end (coerce/to-date (:end p))}}))
|
||||
|
||||
|
||||
@@ -114,6 +114,14 @@
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "2", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"}]])
|
||||
|
||||
(def checkmark
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "3", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M4.5 12.75l6 6 9-13.5"}]])
|
||||
|
||||
(def envelope
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 24 24", :stroke-width "1.5", :stroke "currentColor", :aria-hidden "true"}
|
||||
[:path {:stroke-linecap "round", :stroke-linejoin "round", :d "M21.75 6.75v10.5a2.25 2.25 0 01-2.25 2.25h-15a2.25 2.25 0 01-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0019.5 4.5h-15a2.25 2.25 0 00-2.25 2.25m19.5 0v.243a2.25 2.25 0 01-1.07 1.916l-7.5 4.615a2.25 2.25 0 01-2.36 0L3.32 8.91a2.25 2.25 0 01-1.07-1.916V6.75"}]])
|
||||
|
||||
(def vendors
|
||||
[:svg {:xmlns "http://www.w3.org/2000/svg", :viewbox "0 0 24 24"}
|
||||
[:defs]
|
||||
|
||||
@@ -248,6 +248,19 @@
|
||||
[?v :vendor/name ?sort-vendor])
|
||||
(and [(missing? $ ?e :transaction/vendor)]
|
||||
[(ground "") ?sort-vendor]))]
|
||||
"bank-account" '[(or-join [?e ?sort-bank-account]
|
||||
(and [?e :transaction/bank-account ?sort-ba]
|
||||
[?sort-ba :bank-account/name ?sort-bank-account])
|
||||
(and [?e :transaction/bank-account ?sort-ba]
|
||||
[(missing? $ ?sort-ba :bank-account/name)]
|
||||
[?sort-ba :bank-account/numeric-code ?sort-ba-code]
|
||||
[(str ?sort-ba-code) ?sort-bank-account])
|
||||
(and [?e :transaction/bank-account ?sort-ba]
|
||||
[(missing? $ ?sort-ba :bank-account/name)]
|
||||
[(missing? $ ?sort-ba :bank-account/numeric-code)]
|
||||
[(ground "") ?sort-bank-account])
|
||||
(and [(missing? $ ?e :transaction/bank-account)]
|
||||
[(ground "") ?sort-bank-account]))]
|
||||
"date" ['[?e :transaction/date ?sort-date]]
|
||||
"amount" ['[?e :transaction/amount ?sort-amount]]
|
||||
"description" ['[?e :transaction/description-original ?sort-description]]}
|
||||
@@ -538,6 +551,7 @@
|
||||
:render-csv (fn [x] (-> x :transaction/client :client/name))}
|
||||
{:key "bank-account"
|
||||
:name "Bank Account"
|
||||
:sort-key "bank-account"
|
||||
:show-starting "lg"
|
||||
:render (fn [x]
|
||||
(let [ba (:transaction/bank-account x)]
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
(:form-params request) (:form-errors request)
|
||||
(fc/with-field :table
|
||||
(when (seq (fc/field-value))
|
||||
[:div.mt-4 {:x-data (hx/json {"showTable" true})}
|
||||
[:div.mt-4 {:x-data (hx/json {"showTable" true "errorsOnly" false})}
|
||||
(when (:just-parsed? request)
|
||||
(parsed-banner request))
|
||||
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
|
||||
@@ -186,8 +186,9 @@
|
||||
:autocomplete "off"}
|
||||
[:div.flex.gap-4.items-center.my-2
|
||||
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
|
||||
(com/checkbox {:x-model "errorsOnly"} "Only show errors")
|
||||
(com/button {:color :primary :type "submit"} "Import")]
|
||||
[:div {:x-show "showTable"}
|
||||
[:div {:x-show "showTable || errorsOnly"}
|
||||
(com/data-grid-card
|
||||
{:id "transaction-import-data"
|
||||
:route nil
|
||||
@@ -204,8 +205,11 @@
|
||||
(fc/cursor-map
|
||||
(fn [_]
|
||||
(let [row-errors (fc/field-errors)]
|
||||
;; Clean rows are only hidden (x-show), never removed, so
|
||||
;; "Only show errors" never changes what gets posted.
|
||||
(com/data-grid-row
|
||||
{}
|
||||
(cond-> {}
|
||||
(not (seq row-errors)) (assoc :x-show "!errorsOnly"))
|
||||
(com/data-grid-cell {} (fc/with-field :raw-date
|
||||
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
|
||||
(com/data-grid-cell {} (fc/with-field :description-original
|
||||
|
||||
@@ -267,12 +267,33 @@
|
||||
account))
|
||||
accounts))))
|
||||
|
||||
(defn used-accounts [pnl-datas]
|
||||
(defn used-accounts
|
||||
"One entry per numeric code in play, with the name to label its row.
|
||||
|
||||
Rows are keyed on the code alone, never on [code name]: the amount a row
|
||||
reports is the total for its code, so two names at one code would print the
|
||||
same figure twice and the section would stop footing to its own subtotal.
|
||||
|
||||
`build-account-lookup` has already resolved a client's shared codes down to
|
||||
the bank account's name, so the name here is normally unanimous. It can
|
||||
still differ across a multi-client report, where only some of the clients
|
||||
have a bank account at the code. A bank account's name wins there; failing
|
||||
that the most common name does, ties broken alphabetically so the report is
|
||||
stable across runs."
|
||||
[pnl-datas]
|
||||
(->>
|
||||
pnl-datas
|
||||
(mapcat :data)
|
||||
(map #(select-keys % [:numeric-code :name]))
|
||||
(set)
|
||||
(map #(select-keys % [:numeric-code :name :bank-account-name?]))
|
||||
(group-by :numeric-code)
|
||||
(map (fn [[numeric-code entries]]
|
||||
{:numeric-code numeric-code
|
||||
:name (->> (or (seq (filter :bank-account-name? entries))
|
||||
entries)
|
||||
(map :name)
|
||||
frequencies
|
||||
(sort-by (juxt (comp - val) key))
|
||||
ffirst)}))
|
||||
(sort-by :numeric-code)))
|
||||
|
||||
(defn subtotal-by-column-row [pnl-datas title & [cell-args]]
|
||||
@@ -477,11 +498,11 @@
|
||||
(map
|
||||
(fn [p]
|
||||
(let [pnl-data (-> p (filter-numeric-code numeric-code numeric-code))
|
||||
this-name-exists? (->> (:data p)
|
||||
(filter (comp #{name} :name))
|
||||
seq)]
|
||||
;; Code, not name: the row is keyed on the code, and a
|
||||
;; client can reach it under a name another client won.
|
||||
this-code-exists? (seq (:data pnl-data))]
|
||||
(merge
|
||||
(if this-name-exists?
|
||||
(if this-code-exists?
|
||||
{:format :dollar
|
||||
:filters (:filters pnl-data)
|
||||
:value (aggregate-accounts pnl-data)}
|
||||
|
||||
129
test/clj/auto_ap/ledger/reports_test.clj
Normal file
129
test/clj/auto_ap/ledger/reports_test.clj
Normal file
@@ -0,0 +1,129 @@
|
||||
(ns auto-ap.ledger.reports-test
|
||||
(:require
|
||||
[auto-ap.ledger.reports :as sut]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
;; A client's bank account and the financial account it posts to share a
|
||||
;; numeric code. Reports key their rows off that code, so before the fix the
|
||||
;; pair rendered as two rows carrying the same code-level total, and the
|
||||
;; section stopped footing to its own subtotal.
|
||||
|
||||
(def ^:private period #inst "2026-08-14")
|
||||
|
||||
(defn- account
|
||||
[numeric-code name amount]
|
||||
{:client-id 1
|
||||
:location "M"
|
||||
:numeric-code numeric-code
|
||||
:name name
|
||||
:amount amount
|
||||
:debits 0.0
|
||||
:credits amount
|
||||
:count 1
|
||||
:account-type :account-type/liability
|
||||
:period period})
|
||||
|
||||
(defn- bank-account
|
||||
"Same as `account`, but its name came from a :bank-account rather than the
|
||||
chart of accounts — which is what earns it the row label."
|
||||
[numeric-code name amount]
|
||||
(assoc (account numeric-code name amount) :bank-account-name? true))
|
||||
|
||||
(defn- pnl-data
|
||||
[data]
|
||||
(sut/->PNLData {:periods [period]} data {1 "CLIENT"}))
|
||||
|
||||
(defn- labels
|
||||
[rows]
|
||||
(map (comp :value first) rows))
|
||||
|
||||
(deftest used-accounts-collapses-a-shared-numeric-code
|
||||
(testing "two names on one code yield a single entry, named for the bank account"
|
||||
(is (= [{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "the bank account wins even when the financial name is more common"
|
||||
(is (= [{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "distinct codes are left alone, ordered by code"
|
||||
(is (= [{:numeric-code 21009 :name "Due to Grand Ventures"}
|
||||
{:numeric-code 21010 :name "Capital One CC - 3196"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21009 "Due to Grand Ventures" 25.0)]}]))))
|
||||
|
||||
(testing "with no bank account in play the most common name wins"
|
||||
(is (= [{:numeric-code 21010 :name "Accounts Payable 10"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(account 21010 "Due to Sandwich Monkey" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}]))))
|
||||
|
||||
(testing "an even split breaks alphabetically so runs are reproducible"
|
||||
(is (= [{:numeric-code 21010 :name "Accounts Payable 10"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(account 21010 "Due to Sandwich Monkey" -100.0)]}
|
||||
{:data [(account 21010 "Accounts Payable 10" -150.0)]}])))
|
||||
(is (= [{:numeric-code 21010 :name "Amex - 41001"}]
|
||||
(sut/used-accounts
|
||||
[{:data [(bank-account 21010 "BofA CC - 8779" -100.0)]}
|
||||
{:data [(bank-account 21010 "Amex - 41001" -150.0)]}]))
|
||||
"two bank accounts still resolve to one, deterministically")))
|
||||
|
||||
(deftest balance-sheet-renders-one-row-per-shared-code
|
||||
(let [report (sut/summarize-balance-sheet
|
||||
(pnl-data [(bank-account 21010 "Capital One CC - 3196" -100.0)
|
||||
(account 21010 "Accounts Payable 10" -150.0)
|
||||
(account 21009 "Due to Grand Ventures" 25.0)]))
|
||||
rows (:rows report)]
|
||||
|
||||
(testing "the shared code appears once, under the bank account's name"
|
||||
(is (= 1 (count (filter #(= "Capital One CC - 3196:21010" %) (labels rows)))))
|
||||
(is (not (some #{"Accounts Payable 10:21010"} (labels rows)))))
|
||||
|
||||
(testing "its row carries the total for the code, not one account's share"
|
||||
(is (= -250.0
|
||||
(->> rows
|
||||
(filter #(= "Capital One CC - 3196:21010" (:value (first %))))
|
||||
first
|
||||
second
|
||||
:value))))
|
||||
|
||||
(testing "the detail rows foot to the section subtotal"
|
||||
(let [detail (->> rows
|
||||
(filter #(re-find #":\d+$" (str (:value (first %)))))
|
||||
(map (comp :value second)))
|
||||
subtotal (->> rows
|
||||
(filter #(= "Liabilities" (:value (first %))))
|
||||
(keep (comp :value second))
|
||||
last)]
|
||||
(is (= 2 (count detail))
|
||||
"three accounts across two codes collapse to two rows")
|
||||
(is (= -225.0 (reduce + 0.0 detail) subtotal)
|
||||
"before the fix 21010 printed twice and the rows overshot the subtotal")))))
|
||||
|
||||
(deftest balance-sheet-keeps-a-clients-figure-when-another-client-named-the-code
|
||||
(testing "a column is blank only when that client has nothing at the code"
|
||||
(let [rows (:rows (sut/summarize-balance-sheet
|
||||
(sut/->PNLData
|
||||
{:periods [period]}
|
||||
[(assoc (bank-account 21010 "Capital One CC - 3196" -100.0) :client-id 1)
|
||||
(assoc (account 21010 "Accounts Payable 10" -150.0) :client-id 2)
|
||||
(assoc (account 21009 "Due to Grand Ventures" 25.0) :client-id 1)]
|
||||
{1 "ONE" 2 "TWO"})))
|
||||
shared (->> rows
|
||||
(filter #(re-find #":21010$" (str (:value (first %)))))
|
||||
first)
|
||||
absent (->> rows
|
||||
(filter #(re-find #":21009$" (str (:value (first %)))))
|
||||
first)]
|
||||
(is (= [-100.0 -150.0] (map :value (drop-last (rest shared))))
|
||||
"client TWO reaches 21010 under a name client ONE won, and still shows its balance")
|
||||
(is (= [25.0 ""] (map :value (drop-last (rest absent))))
|
||||
"client TWO has nothing at 21009 and stays blank"))))
|
||||
@@ -1,10 +1,98 @@
|
||||
(ns auto-ap.ledger-test
|
||||
(:require
|
||||
[auto-ap.integration.util :refer [wrap-setup]]
|
||||
[clojure.test :as t]))
|
||||
[auto-ap.datomic :refer [conn]]
|
||||
[auto-ap.integration.util :refer [test-account test-bank-account test-client
|
||||
wrap-setup]]
|
||||
[auto-ap.ledger :refer [build-account-lookup]]
|
||||
[clojure.test :as t]
|
||||
[datomic.api :as dc]))
|
||||
|
||||
(t/use-fixtures :each wrap-setup)
|
||||
|
||||
(t/deftest build-account-lookup-prefers-the-bank-account-name
|
||||
(t/testing "a bank account and the financial account sharing its code resolve to one name"
|
||||
(let [{:strs [client bank financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Accounts Payable 10"
|
||||
:account/numeric-code 21010
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
|
||||
(t/is (= "Capital One CC - 3196" (:name (lookup bank))))
|
||||
(t/is (= "Capital One CC - 3196" (:name (lookup financial)))
|
||||
"the financial account takes the bank account's name, so reports render one row")
|
||||
|
||||
(t/testing "and both are flagged as bank-named for downstream report labelling"
|
||||
(t/is (true? (:bank_account_name? (lookup bank))))
|
||||
(t/is (true? (:bank_account_name? (lookup financial)))))
|
||||
|
||||
(t/testing "without changing the code or the account type"
|
||||
(t/is (= 21010 (:numeric_code (lookup bank))))
|
||||
(t/is (= 21010 (:numeric_code (lookup financial))))
|
||||
(t/is (= :account-type/liability (:account_type (lookup financial))))))))
|
||||
|
||||
(t/deftest build-account-lookup-leaves-unshared-codes-alone
|
||||
(t/testing "an account with no bank account at its code keeps its own name"
|
||||
(let [{:strs [client financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Sales Taxes Payable"
|
||||
:account/numeric-code 23000
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
(t/is (= "Sales Taxes Payable" (:name (lookup financial))))
|
||||
(t/is (false? (:bank_account_name? (lookup financial))))))
|
||||
|
||||
(t/testing "another client's bank account never renames this client's accounts"
|
||||
(let [{:strs [mine financial]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-account :db/id "financial"
|
||||
:account/name "Accounts Payable 10"
|
||||
:account/numeric-code 21010
|
||||
:account/type :account-type/liability)
|
||||
(test-client :db/id "mine")
|
||||
(test-client :db/id "theirs"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "bank"
|
||||
:bank-account/name "Capital One CC - 3196"
|
||||
:bank-account/numeric-code 21010
|
||||
:bank-account/type :bank-account-type/credit)])]))
|
||||
lookup (build-account-lookup mine)]
|
||||
(t/is (= "Accounts Payable 10" (:name (lookup financial))))
|
||||
(t/is (false? (:bank_account_name? (lookup financial)))))))
|
||||
|
||||
(t/deftest build-account-lookup-breaks-ties-between-bank-accounts
|
||||
(t/testing "two bank accounts on one code resolve deterministically by sort-order"
|
||||
(let [{:strs [client first-bank second-bank]}
|
||||
(:tempids @(dc/transact conn
|
||||
[(test-client :db/id "client"
|
||||
:client/bank-accounts
|
||||
[(test-bank-account :db/id "second-bank"
|
||||
:bank-account/name "US Bank 2974"
|
||||
:bank-account/numeric-code 13101
|
||||
:bank-account/sort-order 3)
|
||||
(test-bank-account :db/id "first-bank"
|
||||
:bank-account/name "Fremont Gyro HB Main 8576"
|
||||
:bank-account/numeric-code 13101
|
||||
:bank-account/sort-order 0)])]))
|
||||
lookup (build-account-lookup client)]
|
||||
(t/is (= "Fremont Gyro HB Main 8576"
|
||||
(:name (lookup first-bank))
|
||||
(:name (lookup second-bank)))))))
|
||||
|
||||
(t/deftest entity-change->ledger
|
||||
#_(t/testing "Should code an expected deposit"
|
||||
(let [{:strs [ed ccp receipts-split client]}
|
||||
|
||||
@@ -71,6 +71,48 @@
|
||||
(is (= "600 VISTA WAY" (str/trim (:account-number result))))
|
||||
(is (= "946.24" (:total result)))))))
|
||||
|
||||
(deftest parse-bonanza-produce-invoice-03932070
|
||||
(testing "Should parse a Bonanza invoice whose bill-to block carries a mixed-case, punctuated street address"
|
||||
(let [pdf-file (io/file "dev-resources/Bonanza Sample Reno 2.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)
|
||||
result (first results)]
|
||||
(is (some? result) "Template should match and return a result")
|
||||
(when result
|
||||
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||
(is (= "03932070" (:invoice-number result)))
|
||||
(let [d (:date result)]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 8 (time/month d)))
|
||||
(is (= 4 (time/day d))))
|
||||
;; "10310 N McCARRAN BLVD STE.400" has both lowercase letters and a
|
||||
;; period; an uppercase/digit-only capture used to drop it entirely,
|
||||
;; leaving the invoice with no identifier to match a client against.
|
||||
(is (= "10310 N McCARRAN BLVD STE.400" (str/trim (:account-number result))))
|
||||
(is (str/starts-with? (:customer-identifier result) "NICK THE GREEK"))
|
||||
(is (str/includes? (:customer-identifier result) "McCARRAN"))
|
||||
(is (= "524.17" (:total result)))))))
|
||||
|
||||
(deftest parse-bonanza-produce-invoice-03933054
|
||||
(testing "Should keep parsing the sibling Reno location whose address was already extractable"
|
||||
(let [pdf-file (io/file "dev-resources/Bonanza Sample Reno.pdf")
|
||||
pdf-text (:out (clojure.java.shell/sh "pdftotext" "-layout" (str pdf-file) "-"))
|
||||
results (sut/parse pdf-text)
|
||||
result (first results)]
|
||||
(is (some? result) "Template should match and return a result")
|
||||
(when result
|
||||
(is (= "Bonanza Produce" (:vendor-code result)))
|
||||
(is (= "03933054" (:invoice-number result)))
|
||||
(let [d (:date result)]
|
||||
(is (= 2026 (time/year d)))
|
||||
(is (= 8 (time/month d)))
|
||||
(is (= 7 (time/day d))))
|
||||
(is (= "5140 KIETZKE" (str/trim (:account-number result))))
|
||||
(is (str/starts-with? (:customer-identifier result) "NICK THE GREEK"))
|
||||
(is (str/includes? (:customer-identifier result) "KIETZKE"))
|
||||
;; Two-page invoice: the totals only appear on the final page.
|
||||
(is (= "955.75" (:total result)))))))
|
||||
|
||||
(deftest parse-reel-produce-statement-28676
|
||||
(testing "Should parse the Reel Produce statement layout that no longer prints 'Reel Produce' on the page"
|
||||
(let [pdf-file (io/file "dev-resources/Statement1_from_REEL_Produce_Inc.28676.pdf")
|
||||
|
||||
40
test/clj/auto_ap/query_params_test.clj
Normal file
40
test/clj/auto_ap/query_params_test.clj
Normal file
@@ -0,0 +1,40 @@
|
||||
(ns auto-ap.query-params-test
|
||||
(:require
|
||||
[auto-ap.query-params :as sut]
|
||||
[auto-ap.ssr.invoices :as invoices]
|
||||
[auto-ap.ssr.transaction.common :as transaction]
|
||||
[clojure.edn :as edn]
|
||||
[clojure.test :refer [deftest is testing]]))
|
||||
|
||||
(defn- sortable-keys [grid]
|
||||
(->> (:headers grid) (keep :sort-key)))
|
||||
|
||||
(deftest parse-sort-round-trips-through-edn
|
||||
;; The bulk wizards copy :query-params into their form snapshot, serialize it with
|
||||
;; pr-str into a hidden field, and read it back with clojure.edn/read-string on
|
||||
;; submit. Anything parse-sort puts in :query-params has to survive that trip --
|
||||
;; a bare fn or other unprintable object pr-strs as #object[...], which edn cannot
|
||||
;; read, and the submit 500s before the handler ever runs.
|
||||
(doseq [[label grid] [["transactions" transaction/grid-page]
|
||||
["invoices" invoices/grid-page]]
|
||||
sort-key (sortable-keys grid)
|
||||
direction ["asc" "desc"]]
|
||||
(testing (str label " sorted by " sort-key ":" direction)
|
||||
(let [parsed (sut/parse-sort grid (str sort-key ":" direction))]
|
||||
(is (seq parsed) "should produce a sort entry")
|
||||
(is (= parsed (edn/read-string (pr-str parsed)))
|
||||
"parsed sort must survive a pr-str / edn round trip")))))
|
||||
|
||||
(deftest parse-sort-behaviour
|
||||
(testing "Unknown columns are dropped"
|
||||
(is (= [] (sut/parse-sort transaction/grid-page "not-a-column:asc"))))
|
||||
(testing "An empty sort query yields no sort"
|
||||
(is (= [] (sut/parse-sort transaction/grid-page ""))))
|
||||
(testing "Multiple sort keys are preserved in order"
|
||||
(is (= ["client" "vendor"]
|
||||
(mapv :sort-key (sut/parse-sort transaction/grid-page "client:asc,vendor:desc")))))
|
||||
(testing "Direction is parsed per key"
|
||||
(is (= [true false]
|
||||
(mapv :asc (sut/parse-sort transaction/grid-page "client:asc,vendor:desc")))))
|
||||
(testing "The display name is carried over from the matching header"
|
||||
(is (= "Date" (:name (first (sut/parse-sort transaction/grid-page "date:asc")))))))
|
||||
@@ -37,7 +37,7 @@
|
||||
(assoc report :report/clients [client]))]
|
||||
(is (str/includes? body (:report/url report))
|
||||
"the S3 url should be linked")
|
||||
(is (str/includes? body "to download")))))
|
||||
(is (str/includes? body "Download PDF")))))
|
||||
|
||||
(deftest export-modal-email-handoff
|
||||
(testing "Should offer a mailto hand-off addressed to the client's contacts"
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
[clojure.data.csv :as csv]
|
||||
[clojure.string :as str]
|
||||
[datomic.api :as dc]
|
||||
[hiccup2.core :as hiccup]
|
||||
[malli.core :as mc]))
|
||||
|
||||
(use-fixtures :each wrap-setup)
|
||||
@@ -369,6 +370,55 @@
|
||||
(is (= "TEST" (:client-code (first entries))))
|
||||
(is (= 2 (count (:line-items (first entries))))))))
|
||||
|
||||
(deftest table->entries-blank-amount-rows-test
|
||||
(let [line (fn [external-id debit credit account-code]
|
||||
{:source "manual"
|
||||
:client-code "TEST"
|
||||
:external-id external-id
|
||||
:date (coerce/to-date-time #inst "2021-01-01")
|
||||
:vendor-name "Vendor"
|
||||
:debit debit
|
||||
:credit credit
|
||||
:account-code account-code
|
||||
:location "HQ"})
|
||||
entries (fn [table]
|
||||
(sut/table->entries table
|
||||
{"Vendor" {:db/id "vendor-1"}}
|
||||
#{"1100" "1101"}
|
||||
{"TEST" #inst "2000-01-01"}
|
||||
{"TEST" #{}}
|
||||
{"TEST" #{"HQ"}}))]
|
||||
(testing "Should skip rows with no debit and no credit without flagging them"
|
||||
(let [_ (setup-test-data [(test-client :db/id "blank-client-1"
|
||||
:client/code "TEST"
|
||||
:client/locations ["HQ"])])
|
||||
result (entries [(line "ext-blank-1" 100.0 nil 1100)
|
||||
(line "ext-blank-1" nil nil 1101)
|
||||
(line "ext-blank-1" nil 100.0 1101)])]
|
||||
(is (= 1 (count result)))
|
||||
(is (= 2 (count (:line-items (first result)))))
|
||||
(is (= [0 2] (vec (:indices (first result)))))
|
||||
(is (= 100.0 (:amount (first result))))
|
||||
(is (empty? (sut/entry-errors (first result))))))
|
||||
|
||||
(testing "Should treat blank strings in the amount columns as no amount"
|
||||
(let [_ (setup-test-data [(test-client :db/id "blank-client-2"
|
||||
:client/code "TEST"
|
||||
:client/locations ["HQ"])])
|
||||
result (entries [(line "ext-blank-2" 100.0 nil 1100)
|
||||
(line "ext-blank-2" "" " " 1101)
|
||||
(line "ext-blank-2" nil 100.0 1101)])]
|
||||
(is (= 2 (count (:line-items (first result)))))
|
||||
(is (empty? (sut/entry-errors (first result))))))
|
||||
|
||||
(testing "Should drop an entry entirely when every one of its rows is blank"
|
||||
(let [_ (setup-test-data [(test-client :db/id "blank-client-3"
|
||||
:client/code "TEST"
|
||||
:client/locations ["HQ"])])
|
||||
result (entries [(line "ext-blank-3" nil nil 1100)
|
||||
(line "ext-blank-3" nil nil 1101)])]
|
||||
(is (empty? result))))))
|
||||
|
||||
(deftest import-ledger-test
|
||||
(testing "Should upsert hidden vendors and create transactions"
|
||||
(let [_ (setup-test-data [(test-client :db/id "import-client-1"
|
||||
@@ -396,7 +446,44 @@
|
||||
(let [vendor-id (dc/q '[:find ?e .
|
||||
:where [?e :vendor/name "New Vendor Import Unique"]]
|
||||
db-after)]
|
||||
(is vendor-id)))))
|
||||
(is vendor-id))))
|
||||
|
||||
(testing "Should import the entry while ignoring its rows with no debit and no credit"
|
||||
(let [_ (setup-test-data [(test-client :db/id "import-client-2"
|
||||
:client/code "IMPORT-BLANK"
|
||||
:client/locations ["HQ"])
|
||||
{:db/id "import-blank-account-1100"
|
||||
:account/numeric-code 1100
|
||||
:account/account-set "default"
|
||||
:account/name "Cash"}
|
||||
{:db/id "import-blank-account-1101"
|
||||
:account/numeric-code 1101
|
||||
:account/account-set "default"
|
||||
:account/name "Other Cash"}])
|
||||
row (fn [debit credit account-code]
|
||||
{:source "manual"
|
||||
:client-code "IMPORT-BLANK"
|
||||
:external-id "ext-import-blank-1"
|
||||
:date (coerce/to-date-time #inst "2021-01-01")
|
||||
:vendor-name "Blank Row Vendor"
|
||||
:debit debit
|
||||
:credit credit
|
||||
:account-code account-code
|
||||
:location "HQ"})
|
||||
result (sut/import-ledger {:form-params {:table [(row 100.0 nil 1100)
|
||||
(row nil nil 1101)
|
||||
(row nil 100.0 1101)]}
|
||||
:identity (admin-token)})
|
||||
entry (dc/pull (dc/db conn)
|
||||
[:journal-entry/amount
|
||||
{:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/credit]}]
|
||||
[:journal-entry/external-id "IMPORT-BLANK-manual-ext-import-blank-1"])]
|
||||
(is (= 1 (:successful result)))
|
||||
(is (= 0 (:ignored result)))
|
||||
(is (empty? (:form-errors result)))
|
||||
(is (= 100.0 (:journal-entry/amount entry)))
|
||||
(is (= 2 (count (:journal-entry/line-items entry)))))))
|
||||
|
||||
(deftest import-ledger-with-errors-test
|
||||
(testing "Should throw exception when entries have errors - client not found"
|
||||
@@ -619,3 +706,94 @@
|
||||
(testing "Non-admin cannot bulk-delete"
|
||||
(is (thrown? Exception (sut/bulk-delete {:identity (user-token)
|
||||
:form-params {:selected [1]}})))))
|
||||
|
||||
;; =============================================================================
|
||||
;; External Import - removing an entry from the review grid
|
||||
;; =============================================================================
|
||||
|
||||
(defn- import-row [external-id account-code debit credit]
|
||||
{:external-id external-id
|
||||
:client-code "REMOVE-TEST"
|
||||
:source "manual"
|
||||
:vendor-name "Remove Vendor"
|
||||
:date (coerce/to-date-time #inst "2021-01-01")
|
||||
:account-code account-code
|
||||
:location "HQ"
|
||||
:debit debit
|
||||
:credit credit})
|
||||
|
||||
(defn- entry-rows
|
||||
"The two rows a balanced ledger entry is typically pasted as."
|
||||
[external-id debit-account amount]
|
||||
[(import-row external-id debit-account amount 0.0)
|
||||
(import-row external-id 2000 0.0 amount)])
|
||||
|
||||
(deftest external-import-remove-button-test
|
||||
(testing "Every row is tagged with the entry id the importer groups on"
|
||||
(let [table (vec (concat (entry-rows "ext-a" 1100 100.0)
|
||||
(entry-rows "ext-b" 1100 50.0)))
|
||||
html (str (hiccup/html (sut/external-import-table-form*
|
||||
{:form-params {:table table}
|
||||
:form-errors {}})))]
|
||||
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
|
||||
(is (= 2 (count (re-seq #"data-entry-id=\"REMOVE-TEST-manual-ext-b\"" html))))
|
||||
(testing "and offers a remove button that drops the whole entry"
|
||||
(is (= 4 (count (re-seq #"\$dispatch\('remove-import-entry'" html))))
|
||||
(is (= 4 (count (re-seq #"remove-import-entry\.window" html))))
|
||||
(is (= 2 (count (re-seq #"data-remove-entry-id=\"REMOVE-TEST-manual-ext-a\"" html))))
|
||||
(is (= 2 (count (re-seq #"aria-label=\"Remove ledger entry REMOVE-TEST-manual-ext-a\"" html))))))))
|
||||
|
||||
(deftest external-import-remove-entry-then-import-test
|
||||
(testing "Removing the failing entry lets the remaining entries import"
|
||||
(let [_ (setup-test-data [(test-client :db/id "remove-client"
|
||||
:client/code "REMOVE-TEST"
|
||||
:client/locations ["HQ"])
|
||||
(test-vendor :db/id "remove-vendor"
|
||||
:vendor/name "Remove Vendor")
|
||||
{:db/id "remove-account-1100"
|
||||
:account/numeric-code 1100
|
||||
:account/account-set "default"
|
||||
:account/name "Cash"}
|
||||
{:db/id "remove-account-2000"
|
||||
:account/numeric-code 2000
|
||||
:account/account-set "default"
|
||||
:account/name "Accounts Payable"}])
|
||||
;; Three entries, six rows. The middle one posts to an account that
|
||||
;; does not exist, which is the kind of error a user has to resolve.
|
||||
table (vec (concat (entry-rows "ext-good-1" 1100 100.0)
|
||||
(entry-rows "ext-bad" 99999 75.0)
|
||||
(entry-rows "ext-good-2" 1100 25.0)))
|
||||
admin (admin-token)
|
||||
external-id (fn [id] (str "REMOVE-TEST-manual-" id))
|
||||
imported? (fn [db id]
|
||||
(boolean (dc/q '[:find ?je .
|
||||
:in $ ?ext
|
||||
:where [?je :journal-entry/external-id ?ext]]
|
||||
db (external-id id))))]
|
||||
|
||||
(testing "the bad entry blocks the whole paste"
|
||||
(is (thrown? Exception (sut/import-ledger {:form-params {:table table}
|
||||
:identity admin})))
|
||||
(let [db (dc/db conn)]
|
||||
(is (not (imported? db "ext-good-1")))
|
||||
(is (not (imported? db "ext-good-2")))))
|
||||
|
||||
(testing "removing it drops both of its rows"
|
||||
(let [remaining (vec (remove #(= (external-id "ext-bad") (sut/line->id %)) table))]
|
||||
(is (= 4 (count remaining)))
|
||||
|
||||
(testing "and the other two entries import"
|
||||
(let [result (sut/import-ledger {:form-params {:table remaining}
|
||||
:identity admin})
|
||||
db (dc/db conn)]
|
||||
(is (= 2 (:successful result)))
|
||||
(is (imported? db "ext-good-1"))
|
||||
(is (imported? db "ext-good-2"))
|
||||
(is (not (imported? db "ext-bad")))
|
||||
(let [entry (dc/pull db
|
||||
[:journal-entry/amount
|
||||
{:journal-entry/line-items [:journal-entry-line/debit
|
||||
:journal-entry-line/credit]}]
|
||||
[:journal-entry/external-id (external-id "ext-good-1")])]
|
||||
(is (= 100.0 (:journal-entry/amount entry)))
|
||||
(is (= 2 (count (:journal-entry/line-items entry))))))))))))
|
||||
|
||||
Reference in New Issue
Block a user