(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]] [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] [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 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))))) (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"])))))))))