17 Commits

Author SHA1 Message Date
8cc3dc8bd7 Revert "fix(ledger): honor include-in-reports in the register"
This reverts commit 6e7f66a78f.
2026-08-16 08:01:01 -07:00
6e7f66a78f fix(ledger): honor include-in-reports in the register
A journal entry with a line posted to a bank account flagged
:bank-account/include-in-reports false shows on the SSR register but not on
the GraphQL/CLJS ledger page, which has always dropped those entries in
auto-ap.datomic.ledger/graphql-results (and the CSV export does the same in
auto-ap.routes.exports). Same entry, same filters, visible on one page and
missing from the other. The admin client form defaults the flag to false, so
any bank account saved without ticking the box is affected -- 33 of 676 bank
accounts carry an explicit false today.

Resolve the affected entries up front off VAET and drop them before sorting
rather than excluding them inside the query: the equivalent not-join measured
~36ms against ~3ms for the bare scan on a wide date range, while the two
lookups cost ~2ms and are skipped entirely for clients with nothing flagged.

Unlike the GraphQL page, which filters after pagination and so quietly serves
short pages against an unfiltered total, this runs before pagination, so the
row count matches what the register renders.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 18:16:17 -07:00
ab27d3a4da fix(ledger): match bank accounts sharing a code in register account search
A client's bank account and the financial account it posts to share a
numeric code, and :journal-entry-line/account points at either entity.
The register's Account search matched the selected entity id exactly, so
picking a financial account missed every line posted to the bank account
on that code -- while the Account Code range filter, which already
or-joins both namespaces, found them.

Resolve the shared bank accounts up front and pass the id set into the
existing clause rather than or-joining inside the query: an or-join turns
a selective indexed lookup into per-line work and measured 2.5-3.4x
slower on every account search, including ones that share no code. When
nothing shares the code the emitted query is unchanged, so the common
case stays at parity (0.97-1.04x, plus ~0.2ms to resolve).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 21:23:01 -07:00
19d936693a Merge pull request 'fix(reports): collapse accounts sharing a numeric code to one row' (#16) from integreat-fix-report into staging
Reviewed-on: #16
2026-08-14 16:40:45 -07:00
e9970bd41a fix(reports): collapse accounts sharing a numeric code to one row
A client's bank account and the financial account it posts to carry the
same numeric code. Reports keyed their detail rows on [code, name], so
the pair rendered as two rows — one labelled for the bank account, one
for the financial account.

The amount was duplicated too, not just the label: the row's figure is
filtered by code alone, so both rows printed the whole code's total. The
group subtotal counts the code once, so a section's visible rows stopped
footing to their own subtotal. Penelope's Coffee and Tea overshot the
2000 Accounts Payable subtotal by $251,751.80 this way.

Resolve it in two places:

- build-account-lookup now maps every account at a shared code down to
  the bank account's name, per client. Where a client has two bank
  accounts on one code, lowest :bank-account/sort-order wins, then
  lowest :db/id, so the label is stable across runs.
- used-accounts now keys rows on the code alone. Across a multi-client
  report the clients can still disagree, since only some of them have a
  bank account at the code; a bank-sourced name wins there, which the
  new :bank_account_name? flag carries through from the lookup.

Rows are code-keyed now, so detail-rows decides whether to print a
figure by asking whether the client has data at the code rather than
under the winning name — otherwise a client reaching a code under a name
another client won would blank out.

Balance sheet, profit and loss and cash flows all route through
used-accounts and are all fixed. The GraphQL and cljs balance sheets
pick up the unified name through build-account-lookup.

A sweep of all 146 clients with bank accounts finds duplicate rows on 8
of them before this change and none after, with every section total
unchanged.
2026-08-14 16:36:01 -07:00
366781e818 sort fix 2026-08-13 10:13:57 -07:00
bcb1978f44 fix(ledger): ignore external import rows with no debit or credit
A pasted row with both amount columns empty carries no accounting
information, but it still became a line item: it tripped the "Line item
amount 0.0 must be greater than 0." warning, which demoted the entire
journal entry to "ignored" -- so the good rows around it were dropped
and any existing entry with that external id was retracted.

Drop those rows in table->entries before add-errors runs, so they never
reach the balance check, :amount, or :line-items. An entry whose rows are
all blank disappears completely: not imported, not retracted, not
counted. Only genuinely empty values qualify (nil or a whitespace-only
string, since form input decodes "" to nil); an explicit 0 still warns as
before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-12 12:59:47 -07:00
785c6b3731 feat(ssr): add errors-only filter to the external import grids
Both external import review grids (ledger and transaction) can run to
hundreds of rows, so finding the handful that failed validation meant
scrolling the whole table. Add an "Only show errors" checkbox next to
"Show table" that hides every clean row.

Done with native Alpine: the wrapper's x-data carries errorsOnly, the
checkbox is x-model bound, and each row is rendered server-side knowing
whether it has errors -- flagged rows get no directive, clean rows get
x-show="!errorsOnly". The grid container becomes
x-show="showTable || errorsOnly" so ticking the filter reveals the table
in one click.

Submitting is unaffected: x-show only toggles display, so hidden rows
keep their inputs in the DOM and still post. Verified in the browser that
the full row set serializes while the filter is on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:30:44 -07:00
33cfbab54a fix(parse): extract Bonanza bill-to lines with mixed case and punctuation
The Bonanza Produce invoice template captured the bill-to name and street
with [A-Z\s] / [A-Z0-9\s] classes, so any store name or address containing
a lowercase letter or punctuation failed to match and came back nil. On the
McCarran invoice that meant both :customer-identifier and :account-number
were empty, leaving the import with nothing to look a client up by.

Anchor instead on the B/I/L/L letters printed down the left margin at the
start of a line (the ship-to block on the right reuses the same letters
mid-line) and take the whole column up to the next column gap, without
restricting the character set. The leading \d on the account-number capture
is what selects the street L line over the name L line.

Every value the template already extracted is unchanged; only the nils
moved. Adds regression tests for both Reno locations, covering the address
that used to drop out and the sibling one that already worked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:11:08 -07:00
e99ac6e978 fix(ssr): stop parse-sort leaking a render fn into wizard snapshots
Sorting a grid before opening a bulk wizard 500'd the submit with
"No reader function for tag object".

parse-sort returned the grid's whole :matching-header map, which carries a
:render fn. That sort rides along in :query-params, which the bulk wizards
copy verbatim into their form snapshot (bulk_code.clj:88, invoices.clj:1437).
The snapshot is serialized with pr-str into a hidden field and read back with
clojure.edn/read-string on submit; a fn pr-strs as #object[...], which edn has
no reader for, so wrap-decode-multi-form-state threw before the handler ran.

:matching-header was only ever read inside parse-sort itself - nothing
downstream consumes it, and apply-toggle-sort in this same namespace already
builds entries without it. Keep it as a local binding to derive :name and to
drop unknown columns, and leave it out of the result.

Fixes transaction bulk-code (11 production 500s over 2026-08-10/11) and the
same latent bug in invoice bulk-edit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 22:10:26 -07:00
b43f107610 fix(ssr): make the CSV export button visible, add account number to ledger CSV
The grid export button rendered white-on-white: a-button's :secondary-light
asked for bg-white-200, which tailwind never generated (no white scale in the
config), then fell through to the generic color branch that layers text-white
on top. Give the variant a real light-blue fill and stop the fallthrough, and
label the button "CSV" so it isn't a lone download glyph.

The ledger register CSV named the account but not its number, so exports had
to be joined back by name. Add an Account Number column beside it, falling
back to the bank account's numeric code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:48:30 -07:00
379bfa78f7 feat(ssr): add clear button to typeahead
The single typeahead had no visible way to unset a selection — clearing
was only possible via the undiscoverable backspace keybinding.

Adds an x icon on the right side of the field, shown only when a value is
selected. It reuses the same `value = {value: '', label: ''}` assignment
the existing backspace handler uses, so it rides the already-wired clear
path: the hidden input's $watch dispatches `change` (driving htmx filter
refetches) and `x-modelable "value.value"` propagates the empty value to
any parent scope bound via x-model (driving dependent fetches such as the
transaction-rule Account -> Location and Client -> Bank Account chains).

The icon is a plain div with tabindex="-1" and aria-hidden, so it stays
out of the tab order; @click.prevent.stop keeps the click from bubbling
to the wrapping anchor and popping the dropdown. Matches the clear
affordance multi-typeahead already had.

Rebuilds output.css for the new dark:hover:text-gray-200 utility.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 21:32:25 -07:00
0575c88c4d cleans up ux. 2026-08-04 23:33:50 -07:00
bc2abf4cb1 fixes 2026-08-04 23:00:29 -07:00
acc5a7aa1b Merge branch 'staging' of gitea.story-basking.ts.net:notid/integreat into staging 2026-08-04 22:59:46 -07:00
46fdc29712 Merge pull request 'integreat-send-email' (#15) from integreat-send-email into staging
Reviewed-on: #15
2026-08-04 22:59:33 -07:00
a17e16e31b feat(ledger): remove a whole ledger entry from the external import grid
The external ledger import review grid is one row per line item, so a
journal entry spans two or more rows. When one entry fails validation the
whole paste is rejected, and the only way forward was to re-paste without
it.

Each row now carries its entry id (client-source-externalId, the same key
table->entries groups on) and the last column gets a trash button that
drops every row sharing that id. The rows are the form inputs, so removing
them from the DOM removes them from the next import post -- which also
works for rows that fail schema validation. Index gaps left behind are
compacted by coerce-vector on the way back in.

Also drops two leftover pprint calls that dumped form-errors to stdout on
every render.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 22:44:52 -07:00
26 changed files with 867 additions and 162 deletions

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@@ -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

View File

@@ -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]}}

View File

@@ -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 []))
[]))

View File

@@ -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))

View File

@@ -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)))

View File

@@ -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"}

View File

@@ -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

View File

@@ -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)

View File

@@ -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)))

View File

@@ -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))

View File

@@ -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"

View File

@@ -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)))))))

View File

@@ -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))}}))

View File

@@ -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]

View File

@@ -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)]

View File

@@ -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

View File

@@ -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)}

View 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"))))

View File

@@ -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]}

View File

@@ -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")

View 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")))))))

View File

@@ -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"

View File

@@ -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))))))))))))