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>
882 lines
48 KiB
Clojure
882 lines
48 KiB
Clojure
(ns auto-ap.ssr.ledger-test
|
|
(:require
|
|
[auto-ap.datomic :refer [conn audit-transact transact-schema install-functions]]
|
|
[auto-ap.datomic.accounts :as a]
|
|
[auto-ap.integration.util :refer [wrap-setup test-client test-vendor test-bank-account test-account
|
|
setup-test-data admin-token user-token]]
|
|
[auto-ap.ssr.ledger :as sut]
|
|
[auto-ap.ssr.utils :refer [main-transformer]]
|
|
[auto-ap.ssr.ledger.common :as common]
|
|
[clojure.test :refer [deftest is testing use-fixtures]]
|
|
[clj-time.core :as t]
|
|
[clj-time.coerce :as coerce]
|
|
[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)
|
|
|
|
;; =============================================================================
|
|
;; Pure Functions - trim-header, tsv->import-data, line->id, entry-errors,
|
|
;; flatten-errors, entry-error-types
|
|
;; =============================================================================
|
|
|
|
(deftest trim-header-test
|
|
(testing "Should trim header with expected keys - returns rest when header matches"
|
|
(is (= [["data1" "data2" "data3"]]
|
|
(sut/trim-header [["id" "client" "date"] ["data1" "data2" "data3"]]))))
|
|
(testing "Should trim header when all columns match"
|
|
(is (= [["1" "TEST" "2021-01-01" "100.0"]]
|
|
(sut/trim-header [["id" "client" "date" "debit"] ["1" "TEST" "2021-01-01" "100.0"]]))))
|
|
(testing "Should not trim header when no columns match"
|
|
(is (= [["random" "header" "here"]]
|
|
(sut/trim-header [["random" "header" "here"]])))))
|
|
|
|
(deftest tsv->import-data-test
|
|
(testing "Should parse TSV data from string"
|
|
(let [data "external-id\tclient-code\tdate\n1\tTEST\t2021-01-01"
|
|
result (sut/tsv->import-data data)]
|
|
(is (vector? result))
|
|
(is (= 1 (count result)))
|
|
;; tsv->import-data returns vectors of vectors, not maps
|
|
(is (= "1" (first (first result))))
|
|
(is (= "TEST" (second (first result))))))
|
|
(testing "Should return parsed vector when data is already vector"
|
|
(let [data [{:external-id "1" :client-code "TEST"}]
|
|
result (sut/tsv->import-data data)]
|
|
(is (= 1 (count result)))
|
|
(is (= {:external-id "1" :client-code "TEST"} (first result))))))
|
|
|
|
(deftest line->id-test
|
|
(testing "Should create unique id from source, client-code, and external-id"
|
|
(is (= "TEST-source-123"
|
|
(sut/line->id {:source "source"
|
|
:client-code "TEST"
|
|
:external-id "123"}))))
|
|
(testing "Should produce consistent id for same inputs"
|
|
(let [id1 (sut/line->id {:source "source" :client-code "TEST" :external-id "123"})
|
|
id2 (sut/line->id {:source "source" :client-code "TEST" :external-id "123"})]
|
|
(is (= id1 id2)))))
|
|
|
|
(deftest entry-errors-test
|
|
(testing "Should return entry errors concatenated with line-item errors"
|
|
(let [entry {:errors [["client not found" :error]]
|
|
:line-items [{:errors [["invalid account" :error]]}
|
|
{:errors [["amount is zero" :warn]]}]}
|
|
errors (sut/entry-errors entry)]
|
|
(is (= 3 (count errors)))
|
|
(is (contains? (set errors) ["client not found" :error]))
|
|
(is (contains? (set errors) ["invalid account" :error]))
|
|
(is (contains? (set errors) ["amount is zero" :warn])))))
|
|
|
|
(deftest flatten-errors-test
|
|
(testing "Should flatten entry and line-item errors into flat list"
|
|
(let [entries [{:errors [["entry error" :error]]
|
|
:indices [0 1]
|
|
:line-items [{:errors [["line error" :error]] :index 0}]}
|
|
{:errors [["entry warning" :warn]]
|
|
:indices [2]
|
|
:line-items [{:errors [["line warning" :warn]] :index 1}]}]
|
|
errors (sut/flatten-errors entries)]
|
|
(is (= 5 (count errors)))
|
|
;; Check that all expected errors are present in the result
|
|
(is (some #(= [[:table 0] "entry error" :error] %) errors))
|
|
(is (some #(= [[:table 1] "entry error" :error] %) errors))
|
|
(is (some #(= [[:table 0] "line error" :error] %) errors))
|
|
(is (some #(= [[:table 2] "entry warning" :warn] %) errors))
|
|
(is (some #(= [[:table 1] "line warning" :warn] %) errors))))
|
|
(testing "Should return empty sequence for no errors"
|
|
(is (empty? (sut/flatten-errors [])))))
|
|
|
|
(deftest entry-error-types-test
|
|
(testing "Should return set of error types from entry errors"
|
|
(let [entry {:errors [["client not found" :error]
|
|
["date is invalid" :error]
|
|
["warn message" :warn]]}
|
|
types (sut/entry-error-types entry)]
|
|
(is (= #{:error :warn} types)))))
|
|
|
|
;; =============================================================================
|
|
;; Validation - add-errors
|
|
;; =============================================================================
|
|
|
|
(deftest add-errors-test
|
|
(testing "Should add error when client not found"
|
|
(let [entry {:client-code "NONEXISTENT"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0}]}
|
|
result (sut/add-errors entry
|
|
{} ; all-vendors
|
|
#{} ; all-accounts
|
|
{} ; client-locked-lookup (empty = client not found)
|
|
{} ; all-client-bank-accounts
|
|
{})] ; all-client-locations
|
|
(is (= ["Client 'NONEXISTENT' not found." :error]
|
|
(first (:errors result))))))
|
|
|
|
(testing "Should add error when vendor not found"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "NONEXISTENT"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{} ; all-vendors - vendor not present
|
|
#{"1100"} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
(is (= ["Vendor 'NONEXISTENT' not found." :error]
|
|
(first (:errors result))))))
|
|
|
|
(testing "Should add error when client data is locked"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "LOCKED"
|
|
:client/locked-until #inst "2099-12-31")])
|
|
entry {:client-code "LOCKED"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2020-01-01") ; before locked date
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100"} ; all-accounts
|
|
{"LOCKED" #inst "2099-12-31"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{})] ; all-client-locations
|
|
(is (some #(str/includes? (first %) "locked") (:errors result)))))
|
|
|
|
(testing "Should add error when debit and credit don't balance"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:amount 150.0
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0 :location "HQ"}
|
|
{:account-code "1101" :debit 0.0 :credit 50.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100" "1101"} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
(is (some #(str/includes? (first %) "do not add up") (:errors result)))))
|
|
|
|
(testing "Should add warning when line-item amount is zero or negative"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:amount 100.0
|
|
:line-items [{:account-code "1100" :debit 0.0 :credit 0.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100"} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
;; The entry itself has a warning about $0 total, check line item errors
|
|
(is (some #(= ["Line item amount 0.0 must be greater than 0." :warn] %)
|
|
(mapcat :errors (:line-items result))))))
|
|
|
|
(testing "Should add error when account not found"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:line-items [{:account-code 99999 :debit 100.0 :credit 0.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100"} ; all-accounts - 99999 not present
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
(is (some #(str/includes? (first %) "Account '99999' not found")
|
|
(mapcat :errors (:line-items result))))))
|
|
|
|
(testing "Should add error when bank account not found"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"]
|
|
:client/bank-accounts [(test-bank-account :db/id "bank-1"
|
|
:bank-account/code "CLIENT123-999")])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:line-items [{:account-code "CLIENT123-123" :debit 100.0 :credit 0.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{"CLIENT123" #{"CLIENT123-999"}} ; all-client-bank-accounts - 123 not present
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
(is (some #(str/includes? (first %) "Bank Account 'CLIENT123-123' not found")
|
|
(mapcat :errors (:line-items result))))))
|
|
|
|
(testing "Should add error when location not found"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0
|
|
:location "XX"}]} ; XX is not a valid location
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100"} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations - XX not present
|
|
(is (some #(str/includes? (first %) "Location 'XX' not found")
|
|
(mapcat :errors (:line-items result))))))
|
|
|
|
(testing "Should pass through when all validations pass"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "CLIENT123"
|
|
:client/locations ["HQ"])
|
|
{:db/id "pass-account-1100"
|
|
:account/numeric-code 1100
|
|
:account/account-set "default"
|
|
:account/name "Cash"}])
|
|
entry {:client-code "CLIENT123"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:amount 100.0
|
|
:line-items [{:account-code 1100 :debit 100.0 :credit 100.0 :location "HQ"}]}
|
|
result (sut/add-errors entry
|
|
{"Vendor" {:db/id "vendor-1"}} ; all-vendors
|
|
#{"1100"} ; all-accounts
|
|
{"CLIENT123" #inst "2000-01-01"} ; client-locked-lookup
|
|
{} ; all-client-bank-accounts
|
|
{"CLIENT123" #{"HQ"}})] ; all-client-locations
|
|
(is (empty? (:errors result)))
|
|
(is (empty? (mapcat :errors (:line-items result)))))))
|
|
|
|
;; =============================================================================
|
|
;; Transaction Building - entry->tx
|
|
;; =============================================================================
|
|
|
|
(deftest entry->tx-test
|
|
(testing "Should create upsert transaction for valid entry"
|
|
(let [vendors {"Vendor" {:db/id "vendor-1" :vendor/name "Vendor"}}
|
|
entry {:source "manual"
|
|
:client-code "CLIENT123"
|
|
:external-id "ext-1"
|
|
:vendor-name "Vendor"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:amount 100.0
|
|
:note "Test note"
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0
|
|
:location "HQ"}]}
|
|
tx (sut/entry->tx entry vendors)]
|
|
(is (= :upsert-ledger (first tx)))
|
|
(is (= "manual" (get-in (second tx) [:journal-entry/source])))
|
|
(is (= [:client/code "CLIENT123"] (get-in (second tx) [:journal-entry/client])))
|
|
(is (= "ext-1" (get-in (second tx) [:journal-entry/external-id])))
|
|
(is (= 100.0 (get-in (second tx) [:journal-entry/amount])))
|
|
(is (= "Test note" (get-in (second tx) [:journal-entry/note])))
|
|
(is (true? (get-in (second tx) [:journal-entry/cleared])))
|
|
(is (= 1 (count (get-in (second tx) [:journal-entry/line-items]))))
|
|
(is (= "HQ" (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/location])))
|
|
(is (= 100.0 (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/debit])))
|
|
(is (= [:bank-account/code "1100"] (get-in (second tx) [:journal-entry/line-items 0 :journal-entry-line/account])))))
|
|
|
|
(testing "Should use bank-account code when no matching account"
|
|
(let [vendors {"Vendor" {:db/id "vendor-1"}}
|
|
entry {:client-code "TEST"
|
|
:external-id "ext-2"
|
|
:vendor-name "Vendor"
|
|
:amount 50.0
|
|
:line-items [{:account-code "TEST-BANK" :debit 50.0 :credit 0.0
|
|
:location "HQ"}]}
|
|
tx (sut/entry->tx entry vendors)]
|
|
(is (-> tx
|
|
last
|
|
:journal-entry/line-items
|
|
(-> first
|
|
(get :journal-entry-line/account)
|
|
(= [:bank-account/code "TEST-BANK"]))))))
|
|
|
|
(testing "Should skip zero debit"
|
|
(let [vendors {"Vendor" {:db/id "vendor-1"}}
|
|
entry {:client-code "TEST"
|
|
:external-id "ext-3"
|
|
:vendor-name "Vendor"
|
|
:amount 100.0
|
|
:line-items [{:account-code "1100" :debit 0.0 :credit 100.0
|
|
:location "HQ"}]}
|
|
tx (sut/entry->tx entry vendors)]
|
|
(is (empty? (filter #(= 0.0 (:journal-entry-line/debit %))
|
|
(-> tx last :journal-entry/line-items))))))
|
|
|
|
(testing "Should skip zero credit"
|
|
(let [vendors {"Vendor" {:db/id "vendor-1"}}
|
|
entry {:client-code "TEST"
|
|
:external-id "ext-4"
|
|
:vendor-name "Vendor"
|
|
:amount 100.0
|
|
:line-items [{:account-code "1100" :debit 100.0 :credit 0.0
|
|
:location "HQ"}]}
|
|
tx (sut/entry->tx entry vendors)]
|
|
(is (empty? (filter #(= 0.0 (:journal-entry-line/credit %))
|
|
(-> tx last :journal-entry/line-items)))))))
|
|
|
|
;; =============================================================================
|
|
;; Import Flow - table->entries, import-ledger
|
|
;; =============================================================================
|
|
|
|
(deftest table->entries-test
|
|
(testing "Should group lines with same line->id and add errors"
|
|
(let [_ (setup-test-data [(test-client :db/id "client-1"
|
|
:client/code "TEST"
|
|
:client/locations ["HQ"])])
|
|
table [{:source "manual"
|
|
:client-code "TEST"
|
|
:external-id "ext-1"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "Vendor"
|
|
:debit 100.0
|
|
:credit 0.0
|
|
:account-code "1100"
|
|
:location "HQ"}
|
|
{:source "manual"
|
|
:client-code "TEST"
|
|
:external-id "ext-1"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "Vendor"
|
|
:debit 50.0
|
|
:credit 150.0
|
|
:account-code "1101"
|
|
:location "HQ"}]
|
|
vendors {"Vendor" {:db/id "vendor-1"}}
|
|
entries (sut/table->entries table
|
|
vendors
|
|
#{"1100" "1101"} ; all-accounts
|
|
{"TEST" #inst "2000-01-01"} ; client-locked-lookup
|
|
{"TEST" #{}} ; all-client-bank-accounts
|
|
{"TEST" #{"HQ"}})] ; all-client-locations
|
|
(is (= 1 (count entries)))
|
|
(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"
|
|
:client/code "IMPORT-TEST"
|
|
:client/locations ["HQ"])
|
|
{:db/id "import-account-1100"
|
|
:account/numeric-code 1100
|
|
:account/account-set "default"
|
|
:account/name "Cash"}])
|
|
form-params {:table [{:source "manual"
|
|
:client-code "IMPORT-TEST"
|
|
:external-id "ext-import-unique-1"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "New Vendor Import Unique"
|
|
:debit 100.0
|
|
:credit 100.0
|
|
:account-code 1100
|
|
:location "HQ"}]}
|
|
admin-identity (admin-token)
|
|
result (sut/import-ledger {:form-params form-params
|
|
:identity admin-identity})
|
|
db-after (dc/db conn)]
|
|
(is (= 1 (:successful result)))
|
|
;; Verify vendor was upserted as hidden
|
|
(let [vendor-id (dc/q '[:find ?e .
|
|
:where [?e :vendor/name "New Vendor Import Unique"]]
|
|
db-after)]
|
|
(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"
|
|
(let [form-params {:table [{:source "manual"
|
|
:client-code "NONEXISTENT-ERR"
|
|
:external-id "ext-err-1"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "Vendor"
|
|
:debit 100.0
|
|
:credit 0.0
|
|
:account-code 1100
|
|
:location "HQ"}]}
|
|
admin-identity (admin-token)]
|
|
(is (thrown? Exception (sut/import-ledger {:form-params form-params
|
|
:identity admin-identity})))))
|
|
|
|
(testing "Should produce form-errors for invalid account entries"
|
|
(let [_ (setup-test-data [(test-client :db/id "err-client-1"
|
|
:client/code "ERR-TEST"
|
|
:client/locations ["HQ"])
|
|
(test-vendor :db/id "err-vendor-1"
|
|
:vendor/name "Err Vendor")])
|
|
form-params {:table [{:source "manual"
|
|
:client-code "ERR-TEST"
|
|
:external-id "ext-err-2"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "Err Vendor"
|
|
:debit 100.0
|
|
:credit 100.0
|
|
:account-code 99999
|
|
:location "HQ"}]}
|
|
admin-identity (admin-token)]
|
|
(is (thrown? Exception (sut/import-ledger {:form-params form-params
|
|
:identity admin-identity}))))))
|
|
|
|
(deftest import-ledger-with-warnings-test
|
|
(testing "Should ignore entries with only warnings"
|
|
(let [_ (setup-test-data [(test-client :db/id "warn-client-1"
|
|
:client/code "WARN-CLIENT"
|
|
:client/locations ["HQ"])
|
|
(test-vendor :db/id "warn-vendor-1"
|
|
:vendor/name "Warn Vendor")
|
|
{:db/id "warn-account-1100"
|
|
:account/numeric-code 1100
|
|
:account/account-set "default"
|
|
:account/name "Cash"}])
|
|
client-code "WARN-CLIENT"
|
|
form-params {:table [{:source "manual"
|
|
:client-code client-code
|
|
:external-id "ext-warn-unique-1"
|
|
:date (coerce/to-date-time #inst "2021-01-01")
|
|
:vendor-name "Warn Vendor"
|
|
:debit 0.0
|
|
:credit 0.0
|
|
:account-code 1100
|
|
:location "HQ"}]}
|
|
admin-identity (admin-token)
|
|
result (sut/import-ledger {:form-params form-params
|
|
:identity admin-identity})]
|
|
;; Entries with only warnings are ignored, not imported
|
|
(is (= 0 (:successful result)))
|
|
(is (= 1 (:ignored result))))))
|
|
|
|
;; =============================================================================
|
|
;; Selected IDs - selected->ids
|
|
;; =============================================================================
|
|
|
|
(deftest selected->ids-test
|
|
(testing "Should return all ids when all-selected is true"
|
|
(with-redefs [common/fetch-ids (constantly {:ids ["id1" "id2" "id3"]})]
|
|
(let [request {:query-params {:all-selected true :start 0 :per-page 10}}
|
|
result (sut/selected->ids request {:all-selected true})]
|
|
(is (= #{"id1" "id2" "id3"} (set result))))))
|
|
|
|
(testing "Should return selected ids when all-selected is false"
|
|
(is (= #{"id1" "id2"} (set (sut/selected->ids {} {:all-selected false
|
|
:selected ["id1" "id2"]})))))
|
|
|
|
(testing "Should return empty set when no selection"
|
|
(is (empty? (sut/selected->ids {} {})))))
|
|
|
|
;; =============================================================================
|
|
;; Bank Account Middleware
|
|
;; =============================================================================
|
|
|
|
(deftest wrap-ensure-bank-account-belongs-test
|
|
(testing "Should remove bank-account when client not present"
|
|
(let [handler (fn [req] req)
|
|
wrapped (sut/wrap-ensure-bank-account-belongs handler)
|
|
request {:query-params {:bank-account {:db/id "bank-1"}}
|
|
:identity (admin-token)}]
|
|
(is (nil? (-> (wrapped request) :query-params :bank-account)))))
|
|
|
|
(testing "Should remove bank-account when client doesn't own it"
|
|
(let [handler (fn [req] req)
|
|
wrapped (sut/wrap-ensure-bank-account-belongs handler)
|
|
request {:query-params {:bank-account {:db/id "bank-1"}}
|
|
:client {:client/bank-accounts []}
|
|
:identity (admin-token)}]
|
|
(is (nil? (-> (wrapped request) :query-params :bank-account)))))
|
|
|
|
(testing "Should keep bank-account when client owns it"
|
|
(let [handler (fn [req] req)
|
|
wrapped (sut/wrap-ensure-bank-account-belongs handler)
|
|
request {:query-params {:bank-account {:db/id "bank-1"}}
|
|
:client {:client/bank-accounts [{:db/id "bank-1"}]}
|
|
:identity (admin-token)}]
|
|
(is (= "bank-1" (-> (wrapped request) :query-params :bank-account :db/id))))))
|
|
|
|
(deftest parse-form-schema-test
|
|
(testing "Should parse valid TSV data"
|
|
(let [tsv "id\tclient\tsource\tvendor\tdate\taccount\tlocation\tdebit\tcredit\n1\tTEST\tmanual\tVendor\t2021-01-01\t1100\tHQ\t100.0\t0.0"
|
|
result (mc/decode sut/parse-form-schema
|
|
{:table tsv}
|
|
main-transformer)]
|
|
(is (vector? (:table result)))
|
|
(is (= 1 (count (:table result))))))
|
|
(testing "Should not validate for invalid date"
|
|
;; mc/decode doesn't throw - it returns invalid data that fails validation
|
|
;; Need to add a data row (header gets trimmed), so use two rows
|
|
(let [result (mc/decode sut/parse-form-schema
|
|
{:table "id\tclient\tsource\tvendor\tdate\taccount\tlocation\tdebit\tcredit\n1\tTEST\tmanual\tVendor\tinvalid-date\t1100\tHQ\t100.0\t0.0"}
|
|
main-transformer)]
|
|
;; The result should fail validation due to invalid date
|
|
(is (not (mc/validate sut/parse-form-schema result))))))
|
|
|
|
;; =============================================================================
|
|
;; Helper Functions
|
|
;; =============================================================================
|
|
|
|
(deftest delete-invoice-test
|
|
(testing "Should throw notification exception when trying to void a paid invoice"
|
|
(is (thrown? Exception (sut/delete {:entity {:invoice/status :invoice-status/paid
|
|
:invoice/payments []}
|
|
:identity (admin-token)}))))
|
|
|
|
(testing "Should throw notification exception when trying to void invoice with payments"
|
|
(is (thrown? Exception (sut/delete {:entity {:invoice/status :invoice-status/unpaid
|
|
:invoice/payments [{:payment/status :payment-status/cleared}]}
|
|
:identity (admin-token)}))))
|
|
|
|
(testing "Should void unpaid invoice with no payments"
|
|
(let [tempids (setup-test-data [(test-client :db/id "del-client-1"
|
|
:client/code "TESTCLIENT-DEL")])
|
|
client-id (get tempids "del-client-1")]
|
|
(with-redefs [auto-ap.graphql.utils/assert-can-see-client (fn [_ _] true)]
|
|
(let [temp-id (str (java.util.UUID/randomUUID))
|
|
tx-result @(dc/transact conn [{:db/id temp-id
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/total 100.0
|
|
:invoice/outstanding-balance 100.0
|
|
:invoice/client client-id}])
|
|
invoice-id (get-in tx-result [:tempids temp-id])]
|
|
(let [response (sut/delete {:entity {:db/id invoice-id
|
|
:invoice/status :invoice-status/unpaid
|
|
:invoice/payments []
|
|
:invoice/client {:db/id client-id}}
|
|
:identity (admin-token)})]
|
|
(is (= (format "#entity-table tr[data-id=\"%d\"]" invoice-id)
|
|
(get-in response [:headers "hx-retarget"])))))))))
|
|
|
|
;; =============================================================================
|
|
;; Bulk Delete - all-ids-not-locked, bulk-delete
|
|
;; =============================================================================
|
|
|
|
(defn- create-journal-entry [client-id date external-id]
|
|
(let [temp (str (java.util.UUID/randomUUID))
|
|
tx @(dc/transact conn [{:db/id temp
|
|
:journal-entry/client client-id
|
|
:journal-entry/date date
|
|
:journal-entry/external-id external-id
|
|
:journal-entry/source "manual"
|
|
:journal-entry/amount 100.0}])]
|
|
(get-in tx [:tempids temp])))
|
|
|
|
(deftest all-ids-not-locked-test
|
|
(testing "Should exclude entries dated before the client's locked-until date"
|
|
(let [tempids (setup-test-data [(test-client :db/id "lock-client"
|
|
:client/code "LOCKTEST"
|
|
:client/locked-until #inst "2099-01-01")])
|
|
client-id (get tempids "lock-client")
|
|
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-locked")
|
|
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-open")
|
|
result (set (sut/all-ids-not-locked [locked-id open-id]))]
|
|
(is (contains? result open-id))
|
|
(is (not (contains? result locked-id))))))
|
|
|
|
(deftest bulk-delete-test
|
|
(testing "Admin can delete selected ledger entries"
|
|
(let [tempids (setup-test-data [(test-client :db/id "bd-client"
|
|
:client/code "BDTEST")])
|
|
client-id (get tempids "bd-client")
|
|
id1 (create-journal-entry client-id #inst "2021-01-01" "ext-bd-1")
|
|
id2 (create-journal-entry client-id #inst "2021-02-01" "ext-bd-2")
|
|
response (sut/bulk-delete {:identity (admin-token)
|
|
:form-params {:selected [id1 id2]}})
|
|
db-after (dc/db conn)]
|
|
(is (= 200 (:status response)))
|
|
;; modal-response retargets to the persistent #modal-content shell (innerHTML)
|
|
;; so the modal-holder survives repeated deletes; it also appends modalopen.
|
|
(is (= "invalidated, reset-selection, modalopen" (get-in response [:headers "hx-trigger"])))
|
|
(is (= "#modal-content" (get-in response [:headers "hx-retarget"])))
|
|
(is (= "innerHTML" (get-in response [:headers "hx-reswap"])))
|
|
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id1))))
|
|
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] id2))))))
|
|
|
|
(testing "Should preserve entries in a locked period even when selected"
|
|
(let [tempids (setup-test-data [(test-client :db/id "bd-lock-client"
|
|
:client/code "BDLOCK"
|
|
:client/locked-until #inst "2099-01-01")])
|
|
client-id (get tempids "bd-lock-client")
|
|
locked-id (create-journal-entry client-id #inst "2020-01-01" "ext-bd-locked")
|
|
open-id (create-journal-entry client-id #inst "2099-06-01" "ext-bd-open")
|
|
_ (sut/bulk-delete {:identity (admin-token)
|
|
:form-params {:selected [locked-id open-id]}})
|
|
db-after (dc/db conn)]
|
|
(is (some? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] locked-id))))
|
|
(is (nil? (:journal-entry/external-id (dc/pull db-after [:journal-entry/external-id] open-id))))))
|
|
|
|
(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))))))))))))
|
|
|
|
(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))))))
|