9 Commits

Author SHA1 Message Date
69eae3b7dc Merge branch 'staging' of gitea.story-basking.ts.net:notid/integreat into staging 2026-08-16 08:41:06 -07:00
58f6714399 fix(transactions): drop suppressed transactions from the transactions page (#19)
Suppressed transactions were showing up on the transactions page.

## The cause

The SSR transactions page never excluded `:transaction-approval-status/suppressed`. The GraphQL/CLJS transactions page has always excluded them unconditionally (`auto_ap/datomic/transactions.clj`, the `true` branch of its `cond->>`), as do the ledger queries in `auto_ap/ledger.clj`.

The `scan-transactions` ion does no status filtering of its own — it is a bare `index-range` over `:transaction/client+date` — so nothing downstream was dropping them either. Suppressing a transaction is the user's way of saying "stop showing me this", and it stayed visible on the page it was suppressed from.

## Impact

22,813 suppressed transactions exist db-wide out of 621,492. For the worst-affected client, a five-year range returned **14,736 rows against 3,641 real ones** — the other 11,095 were suppressed.

## The fix

One `not` clause in the main scan in `auto_ap/ssr/transaction/common.clj`, applied inside the query rather than after it so the row count and the amount total — both derived from `fetch-ids` — match the rows actually rendered. Measured 6ms -> 29ms on that worst-case client and range.

The three status routes (approved, unapproved, requires-feedback) constrain the status themselves and there is no suppressed route, so no page loses its own rows.

## Note on the commit list

This branch carries `6e7f66a7` (include-in-reports in the register) and its revert. That commit was only ever on a local staging; it never reached this remote. The net diff of this branch against `staging` is exactly the transactions fix below — the add and the revert cancel out. Both are kept so local staging reconciles cleanly after merge.

## Testing

- New `test/clj/auto_ap/ssr/transaction/common_test.clj` — 6 assertions, passing. Covers the suppressed row being dropped from `ids`, `all-ids` and `count`; the approved status route still listing its rows; and an all-suppressed client returning empty.
- `auto-ap.ssr.transaction.import-test` and `auto-ap.ssr.ledger-test` pass, with one exception: `external-import-remove-button-test` fails identically with and without these changes, so it is pre-existing and unrelated.
- `auto-ap.ssr.transaction.edit-test` still has its known `apply-rule does not exist` compile break, untouched here.
- `lein cljfmt check` clean.

## Loose end, not addressed

`:transaction/exclude-from-ledger` is defined in `resources/schema.edn` but read nowhere in the codebase — only `:invoice/exclude-from-ledger` is honored. If anything ever wrote that attribute expecting it to hide transactions, it never did.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #19
Co-authored-by: Bryce <bryce@brycecovertoperations.com>
Co-committed-by: Bryce <bryce@brycecovertoperations.com>
2026-08-16 08:40:47 -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
14 changed files with 662 additions and 42 deletions

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

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

@@ -159,7 +159,7 @@
(fc/with-field :table
(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)
@@ -180,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"
@@ -199,14 +201,18 @@
:rows
(fc/cursor-map
(fn [r]
(let [entry-id (line->id (fc/field-value r))]
(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.
(com/data-grid-row {:data-entry-id entry-id
:x-data (hx/json {"entryId" entry-id})
"@remove-import-entry.window" "if ($event.detail.entryId === entryId) $el.remove()"}
;; 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
@@ -520,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

@@ -203,12 +203,76 @@
result))
results))
(defn- entries-off-reports
"Journal entries with a line posted to one of `clients`' bank accounts flagged
`:bank-account/include-in-reports` false, or nil when there are none.
The GraphQL ledger page has always dropped these (see
auto-ap.datomic.ledger/graphql-results), so the register has to agree --
otherwise the same entry is visible on one ledger page and missing from the
other. Resolved up front off VAET, which is ~150 entries db-wide and costs
~2ms; excluding them inside the query with a not-join instead measured ~10x
the cost of the whole scan on a wide date range."
[db clients]
(when-let [off-reports (seq (dc/q '[:find [?ba ...]
:in $ [?client ...]
:where
[?client :client/bank-accounts ?ba]
[?ba :bank-account/include-in-reports false]]
db clients))]
(set (dc/q '[:find [?e ...]
:in $ [?ba ...]
:where
[?li :journal-entry-line/account ?ba]
[?e :journal-entry/line-items ?li]]
db off-reports))))
(defn- apply-include-in-reports [db clients results]
(if-let [hidden (entries-off-reports db clients)]
(remove (comp hidden last) results)
results))
;; TODO
;; 1. Sorting in investigate dialog
;; 2. actual date range filtering in investigate dialog
;; 3. CSVs
;; 4. better date range / advanced mode for dialog
(defn- 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 +352,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]
@@ -346,6 +408,7 @@
(merge-query {:query {:find ['?sort-default '?e]}})))]
(->> (observable-query query)
(apply-include-in-reports db valid-clients)
(apply-sort-4 (assoc query-params :default-asc? true))
(apply-only-unbalanced query-params)
(apply-pagination query-params))))

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

@@ -132,7 +132,13 @@
valid-clients]}
(cond-> {:query {:find []
:in ['$ '[?clients ?start ?end]]
:where '[[(iol-ion.query/scan-transactions $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
;; Suppressed transactions are never listed -- the GraphQL page has
;; always dropped them (auto-ap.datomic.transactions/graphql-results),
;; as do the ledger queries. The three status routes (approved,
;; unapproved, requires-feedback) constrain the status themselves, and
;; there is no suppressed route, so this never hides a page's own rows.
:where '[[(iol-ion.query/scan-transactions $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]
(not [?e :transaction/approval-status :transaction-approval-status/suppressed])]}
:args [db
[valid-clients
(some-> (:start-date query-params) coerce/to-date)
@@ -248,6 +254,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 +557,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

@@ -370,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"
@@ -397,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"
@@ -711,3 +797,85 @@
[:journal-entry/external-id (external-id "ext-good-1")])]
(is (= 100.0 (:journal-entry/amount entry)))
(is (= 2 (count (:journal-entry/line-items entry))))))))))))
(deftest fetch-ids-include-in-reports-test
(testing "Should leave an entry out of the register when a line posts to a bank account off reports"
(let [{:strs [reports-client shown-entry]}
(setup-test-data [(test-bank-account :db/id "shown-bank"
:bank-account/name "Shown Bank"
:bank-account/numeric-code 11000
:bank-account/include-in-reports true)
(test-bank-account :db/id "hidden-bank"
:bank-account/name "Hidden Bank"
:bank-account/numeric-code 11001
:bank-account/include-in-reports false)
(test-client :db/id "reports-client"
:client/code "REPORTS-TEST"
:client/locations ["HQ"]
:client/bank-accounts ["shown-bank" "hidden-bank"])
{:db/id "reports-expense"
:account/name "Expense"
:account/numeric-code 60000
:account/account-set "default"}
{:db/id "shown-entry"
:journal-entry/client "reports-client"
:journal-entry/date #inst "2024-03-01"
:journal-entry/amount 100.0
:journal-entry/source "transaction"
:journal-entry/line-items
[{:journal-entry-line/account "shown-bank"
:journal-entry-line/credit 100.0}
{:journal-entry-line/account "reports-expense"
:journal-entry-line/debit 100.0}]}
{:db/id "hidden-entry"
:journal-entry/client "reports-client"
:journal-entry/date #inst "2024-03-02"
:journal-entry/amount 50.0
:journal-entry/source "transaction"
:journal-entry/line-items
[{:journal-entry-line/account "hidden-bank"
:journal-entry-line/credit 50.0}
{:journal-entry-line/account "reports-expense"
:journal-entry-line/debit 50.0}]}])
request {:client-id reports-client
:clients [reports-client]
:route-params {}
:query-params {}}
result (common/fetch-ids (dc/db conn) request)]
(is (= [shown-entry] (vec (:all-ids result))))
(is (= [shown-entry] (vec (:ids result))))
(testing "and counts it out of the total, so the page is not silently short"
(is (= 1 (:count result))))))
(testing "Should keep every entry when no bank account is off reports"
(let [{:strs [open-client open-entry]}
(setup-test-data [(test-bank-account :db/id "open-bank"
:bank-account/name "Open Bank"
:bank-account/numeric-code 11002
:bank-account/include-in-reports true)
(test-client :db/id "open-client"
:client/code "REPORTS-OPEN"
:client/locations ["HQ"]
:client/bank-accounts ["open-bank"])
{:db/id "open-expense"
:account/name "Expense"
:account/numeric-code 60001
:account/account-set "default"}
{:db/id "open-entry"
:journal-entry/client "open-client"
:journal-entry/date #inst "2024-03-01"
:journal-entry/amount 100.0
:journal-entry/source "transaction"
:journal-entry/line-items
[{:journal-entry-line/account "open-bank"
:journal-entry-line/credit 100.0}
{:journal-entry-line/account "open-expense"
:journal-entry-line/debit 100.0}]}])
result (common/fetch-ids (dc/db conn) {:client-id open-client
:clients [open-client]
:route-params {}
:query-params {}})]
(is (= [open-entry] (vec (:all-ids result))))
(is (= 1 (:count result))))))

View File

@@ -0,0 +1,68 @@
(ns auto-ap.ssr.transaction.common-test
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.integration.util :refer [setup-test-data test-bank-account test-client
test-transaction wrap-setup]]
[auto-ap.ssr.transaction.common :as sut]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]))
(use-fixtures :each wrap-setup)
(defn- fetch
"Runs the transactions page query the way the page does, over a range wide
enough to cover the fixture data."
[client-id & {:keys [route-params query-params]}]
(sut/fetch-ids (dc/db conn)
{:clients [client-id]
:route-params (or route-params {})
:query-params (merge {:start-date "2021-01-01"
:end-date "2023-12-31"}
query-params)}))
(deftest fetch-ids-suppressed-test
(let [{:strs [suppressed-client kept-tx suppressed-tx]}
(setup-test-data
[(test-bank-account :db/id "suppressed-bank")
(test-client :db/id "suppressed-client"
:client/bank-accounts ["suppressed-bank"])
(test-transaction :db/id "kept-tx"
:transaction/client "suppressed-client"
:transaction/bank-account "suppressed-bank"
:transaction/date #inst "2022-06-01"
:transaction/amount 100.0
:transaction/approval-status :transaction-approval-status/approved)
(test-transaction :db/id "suppressed-tx"
:transaction/client "suppressed-client"
:transaction/bank-account "suppressed-bank"
:transaction/date #inst "2022-06-02"
:transaction/amount 250.0
:transaction/approval-status :transaction-approval-status/suppressed)])]
(testing "Should leave a suppressed transaction out of the transactions page"
(let [{:keys [ids all-ids count]} (fetch suppressed-client)]
(is (= [kept-tx] (vec ids)))
(is (not (contains? (set all-ids) suppressed-tx))
"a suppressed transaction must not reach the amount total either")
(is (= 1 count)
"the row count has to match what the page renders, not the unfiltered scan")))
(testing "Should still list a transaction on an explicit status route"
(let [{:keys [ids]} (fetch suppressed-client
:route-params {:status :transaction-approval-status/approved})]
(is (= [kept-tx] (vec ids)))))
(testing "Should return nothing for a client whose transactions are all suppressed"
(let [{:strs [empty-client]}
(setup-test-data
[(test-bank-account :db/id "empty-bank")
(test-client :db/id "empty-client"
:client/bank-accounts ["empty-bank"])
(test-transaction :db/id "only-tx"
:transaction/client "empty-client"
:transaction/bank-account "empty-bank"
:transaction/date #inst "2022-06-03"
:transaction/approval-status :transaction-approval-status/suppressed)])
{:keys [ids count]} (fetch empty-client)]
(is (empty? ids))
(is (= 0 count))))))