(ns auto-ap.ssr.invoice.invoice-unit-test (:require [clojure.test :refer [deftest testing is]] [auto-ap.ssr.invoice.new-invoice-wizard :as sut] [auto-ap.ssr.invoices :as invoices] [auto-ap.ssr.invoice.glimpse :as glimpse] [slingshot.slingshot :refer [try+]] [clj-time.core :as time])) (deftest assert-invoice-amounts-add-up-test (testing "Valid when expense accounts sum equals invoice total" (is (nil? (sut/assert-invoice-amounts-add-up {:invoice/expense-accounts [{:invoice-expense-account/amount 50.0} {:invoice-expense-account/amount 50.0}] :invoice/total 100.0})))) (testing "Valid with single expense account matching total" (is (nil? (sut/assert-invoice-amounts-add-up {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0}] :invoice/total 100.0})))) (testing "Valid with floating point amounts within tolerance" (is (nil? (sut/assert-invoice-amounts-add-up {:invoice/expense-accounts [{:invoice-expense-account/amount 33.33} {:invoice-expense-account/amount 33.33} {:invoice-expense-account/amount 33.34}] :invoice/total 100.0})))) (testing "Throws when expense accounts sum does not equal total" (is (thrown? clojure.lang.ExceptionInfo (sut/assert-invoice-amounts-add-up {:invoice/expense-accounts [{:invoice-expense-account/amount 40.0}] :invoice/total 100.0})))) (testing "Throws when expense accounts sum is greater than total" (is (thrown? clojure.lang.ExceptionInfo (sut/assert-invoice-amounts-add-up {:invoice/expense-accounts [{:invoice-expense-account/amount 150.0}] :invoice/total 100.0}))))) (deftest does-amount-exceed-outstanding-test (testing "Valid when amount equals positive outstanding balance" (is (not (invoices/does-amount-exceed-outstanding? 100.0 100.0)))) (testing "Valid when amount is less than positive outstanding balance" (is (not (invoices/does-amount-exceed-outstanding? 50.0 100.0)))) (testing "Invalid when amount exceeds positive outstanding balance" (is (invoices/does-amount-exceed-outstanding? 150.0 100.0))) (testing "Invalid when amount is zero or negative for positive outstanding" (is (invoices/does-amount-exceed-outstanding? 0.0 100.0)) (is (invoices/does-amount-exceed-outstanding? -10.0 100.0))) (testing "Valid when amount equals negative outstanding balance" (is (not (invoices/does-amount-exceed-outstanding? -100.0 -100.0)))) (testing "Valid when amount is greater than negative outstanding balance" (is (not (invoices/does-amount-exceed-outstanding? -50.0 -100.0)))) (testing "Invalid when amount is less than negative outstanding balance" (is (invoices/does-amount-exceed-outstanding? -150.0 -100.0))) (testing "Invalid when amount is zero or positive for negative outstanding" (is (invoices/does-amount-exceed-outstanding? 0.0 -100.0)) (is (invoices/does-amount-exceed-outstanding? 10.0 -100.0))) (testing "Invalid when amount is non-zero for zero outstanding" (is (invoices/does-amount-exceed-outstanding? 10.0 0.0)) (is (invoices/does-amount-exceed-outstanding? -10.0 0.0))) (testing "Valid when amount is zero for zero outstanding" (is (not (invoices/does-amount-exceed-outstanding? 0.0 0.0))))) (deftest assert-percentages-add-up-test (testing "Valid when percentages sum to 100%" (is (nil? (invoices/assert-percentages-add-up {:expense-accounts [{:percentage 0.5} {:percentage 0.5}]})))) (testing "Valid with single account at 100%" (is (nil? (invoices/assert-percentages-add-up {:expense-accounts [{:percentage 1.0}]})))) (testing "Valid with floating point within tolerance" (is (nil? (invoices/assert-percentages-add-up {:expense-accounts [{:percentage 0.333} {:percentage 0.333} {:percentage 0.334}]})))) (testing "Throws when percentages sum to less than 100%" (is (thrown? clojure.lang.ExceptionInfo (invoices/assert-percentages-add-up {:expense-accounts [{:percentage 0.5}]})))) (testing "Throws when percentages sum to more than 100%" (is (thrown? clojure.lang.ExceptionInfo (invoices/assert-percentages-add-up {:expense-accounts [{:percentage 0.8} {:percentage 0.8}]}))))) (deftest stack-rank-test (testing "Ranks fields by confidence and returns text values" (let [fields [{:type {:text "AMOUNT_DUE" :confidence 0.9} :value-detection {:text "$123.45" :confidence 0.95}} {:type {:text "AMOUNT_DUE" :confidence 0.8} :value-detection {:text "$100.00" :confidence 0.9}} {:type {:text "TOTAL" :confidence 0.9} :value-detection {:text "$150.00" :confidence 0.85}}]] (is (= ["$123.45" "$150.00" "$100.00"] (glimpse/stack-rank #{"AMOUNT_DUE" "TOTAL"} fields))))) (testing "Filters out fields not in valid-values set" (let [fields [{:type {:text "AMOUNT_DUE" :confidence 0.9} :value-detection {:text "$123.45" :confidence 0.95}} {:type {:text "OTHER" :confidence 0.9} :value-detection {:text "$999.00" :confidence 0.99}}]] (is (= ["$123.45"] (glimpse/stack-rank #{"AMOUNT_DUE"} fields))))) (testing "Returns empty when no fields match" (is (empty? (glimpse/stack-rank #{"TOTAL"} [])))) (testing "Filters blank values" (let [fields [{:type {:text "TOTAL" :confidence 0.9} :value-detection {:text "" :confidence 0.95}} {:type {:text "TOTAL" :confidence 0.8} :value-detection {:text " " :confidence 0.9}}]] (is (empty? (glimpse/stack-rank #{"TOTAL"} fields)))))) (deftest deduplicate-test (testing "Removes duplicate parsed values keeping first occurrence" (let [data [["$123.45" 123.45] ["123.45" 123.45] ["$100.00" 100.0] ["100" 100.0]]] (is (= [["$123.45" 123.45] ["$100.00" 100.0]] (glimpse/deduplicate data))))) (testing "Returns empty for empty input" (is (empty? (glimpse/deduplicate [])))) (testing "Preserves all unique values" (let [data [["A" 1] ["B" 2] ["C" 3]]] (is (= [["A" 1] ["B" 2] ["C" 3]] (glimpse/deduplicate data))))) (testing "Handles nil parsed values (nil is not deduplicated due to set semantics)" (let [data [["A" nil] ["B" nil] ["C" 3]]] (is (= [["A" nil] ["B" nil] ["C" 3]] (glimpse/deduplicate data)))))) (deftest clientize-vendor-test (testing "Returns nil when vendor is nil" (is (nil? (sut/clientize-vendor nil 123)))) (testing "Applies terms override for matching client" (let [vendor {:vendor/terms 30 :vendor/terms-overrides [{:vendor-terms-override/client {:db/id 123} :vendor-terms-override/terms 15}] :vendor/automatically-paid-when-due [] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (= 15 (:vendor/terms (sut/clientize-vendor vendor 123)))))) (testing "Keeps default terms when no override for client" (let [vendor {:vendor/terms 30 :vendor/terms-overrides [{:vendor-terms-override/client {:db/id 999} :vendor-terms-override/terms 15}] :vendor/automatically-paid-when-due [] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (= 30 (:vendor/terms (sut/clientize-vendor vendor 123)))))) (testing "Applies account override for matching client" (let [vendor {:vendor/terms 30 :vendor/account-overrides [{:vendor-account-override/client {:db/id 123} :vendor-account-override/account {:db/id 2 :account/name "Override"}}] :vendor/automatically-paid-when-due [] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (= "Override" (:account/name (:vendor/default-account (sut/clientize-vendor vendor 123))))))) (testing "Uses default account when no account override for client" (let [vendor {:vendor/terms 30 :vendor/account-overrides [{:vendor-account-override/client {:db/id 999} :vendor-account-override/account {:db/id 2 :account/name "Override"}}] :vendor/automatically-paid-when-due [] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (= "Food" (:account/name (:vendor/default-account (sut/clientize-vendor vendor 123))))))) (testing "Sets automatically-paid-when-due when client is in the list" (let [vendor {:vendor/terms 30 :vendor/automatically-paid-when-due [{:db/id 123}] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (true? (:vendor/automatically-paid-when-due (sut/clientize-vendor vendor 123)))))) (testing "Clears automatically-paid-when-due when client is not in the list" (let [vendor {:vendor/terms 30 :vendor/automatically-paid-when-due [{:db/id 999}] :vendor/default-account {:db/id 1 :account/name "Food"}}] (is (false? (:vendor/automatically-paid-when-due (sut/clientize-vendor vendor 123)))))) (testing "Removes override fields from result" (let [vendor {:vendor/terms 30 :vendor/terms-overrides [{:vendor-terms-override/client {:db/id 123} :vendor-terms-override/terms 15}] :vendor/account-overrides [{:vendor-account-override/client {:db/id 123} :vendor-account-override/account {:db/id 2 :account/name "Override"}}] :vendor/automatically-paid-when-due [] :vendor/default-account {:db/id 1 :account/name "Food"}} result (sut/clientize-vendor vendor 123)] (is (nil? (:vendor/terms-overrides result))) (is (nil? (:vendor/account-overrides result)))))) (deftest location-select-test (testing "Uses account location when provided" (let [result (sut/location-select* {:name "loc" :account-location "DT" :client-locations ["MH" "DE"] :value nil})] (is (= :select (first result))) (is (some #(= "DT" %) (flatten result))))) (testing "Defaults to Shared when no account location but client locations exist" (let [result (sut/location-select* {:name "loc" :account-location nil :client-locations ["MH" "DE"] :value nil})] (is (= :select (first result))) (is (some #(= "Shared" %) (flatten result))) (is (some #(= "MH" %) (flatten result))) (is (some #(= "DE" %) (flatten result))))) (testing "Defaults to Shared when no locations provided" (let [result (sut/location-select* {:name "loc" :account-location nil :client-locations nil :value nil})] (is (= :select (first result))) (is (some #(= "Shared" %) (flatten result)))))) (deftest maybe-code-accounts-test (testing "Creates single account with specified location" (let [invoice {:invoice/total 100.0} rules [{:percentage 1.0 :account "acc-1" :location "DT"}] result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])] (is (= 1 (count result))) (is (= "acc-1" (:invoice-expense-account/account (first result)))) (is (= "DT" (:invoice-expense-account/location (first result)))) (is (= 100.0 (:invoice-expense-account/amount (first result)))))) (testing "Spreads Shared location across all valid locations" (let [invoice {:invoice/total 100.0} rules [{:percentage 1.0 :account "acc-1" :location "Shared"}] result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])] (is (= 2 (count result))) (is (= #{"MH" "DE"} (set (map :invoice-expense-account/location result)))) (is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result)))))) (testing "Handles odd totals with correct rounding for Shared locations" (let [invoice {:invoice/total 100.0} rules [{:percentage 1.0 :account "acc-1" :location "Shared"}] result (invoices/maybe-code-accounts invoice rules ["MH" "DE" "DT"])] (is (= 3 (count result))) (is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result)))) (is (every? #(<= (count (re-find #"\.\d+" (str %))) 3) (map :invoice-expense-account/amount result))))) (testing "Handles multiple account rules" (let [invoice {:invoice/total 100.0} rules [{:percentage 0.5 :account "acc-1" :location "DT"} {:percentage 0.5 :account "acc-2" :location "Shared"}] result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])] (is (= 3 (count result))) (is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result)))))) (testing "Uses absolute value for negative totals (produces positive amounts)" (let [invoice {:invoice/total -100.0} rules [{:percentage 1.0 :account "acc-1" :location "Shared"}] result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])] (is (= 2 (count result))) (is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result))))))) (deftest can-undo-autopayment-test (testing "Returns true for paid invoice with scheduled payment and no linked payments" (with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)] (is (true? (invoices/can-undo-autopayment {:invoice/status :invoice-status/paid :invoice/scheduled-payment #inst "2024-01-01" :invoice/payments nil :invoice/client {:db/id 1} :invoice/date #inst "2024-01-01"}))))) (testing "Returns false for invoice without scheduled payment (behavior 19.2)" (with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)] (is (false? (invoices/can-undo-autopayment {:invoice/status :invoice-status/paid :invoice/scheduled-payment nil :invoice/payments nil :invoice/client {:db/id 1} :invoice/date #inst "2024-01-01"}))))) (testing "Returns false for invoice with linked payments (behavior 19.3)" (with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)] (is (false? (invoices/can-undo-autopayment {:invoice/status :invoice-status/paid :invoice/scheduled-payment #inst "2024-01-01" :invoice/payments [{:db/id 1}] :invoice/client {:db/id 1} :invoice/date #inst "2024-01-01"}))))) (testing "Returns false for invoice that is not paid (behavior 19.4)" (with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)] (is (false? (invoices/can-undo-autopayment {:invoice/status :invoice-status/unpaid :invoice/scheduled-payment #inst "2024-01-01" :invoice/payments nil :invoice/client {:db/id 1} :invoice/date #inst "2024-01-01"}))))) (testing "Returns false for voided invoice" (with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)] (is (false? (invoices/can-undo-autopayment {:invoice/status :invoice-status/voided :invoice/scheduled-payment #inst "2024-01-01" :invoice/payments nil :invoice/client {:db/id 1} :invoice/date #inst "2024-01-01"})))))) (deftest due-date-calculation-test (testing "Calculates due date from vendor terms (behavior 8.2)" (let [invoice-date (time/date-time 2024 1 1) vendor-terms 30 expected-due (time/plus invoice-date (time/days vendor-terms))] (is (= expected-due (time/plus invoice-date (time/days vendor-terms)))))) (testing "Due date is date plus terms days" (let [date (time/date-time 2024 6 15) terms 15] (is (= (time/date-time 2024 6 30) (time/plus date (time/days terms))))))) (deftest scheduled-payment-calculation-test (testing "Scheduled payment equals due date when autopay is enabled (behavior 8.3)" (let [due-date (time/date-time 2024 1 31) vendor {:vendor/automatically-paid-when-due true}] (is (= due-date (when (:vendor/automatically-paid-when-due vendor) due-date))))) (testing "No scheduled payment when autopay is disabled" (let [due-date (time/date-time 2024 1 31) vendor {:vendor/automatically-paid-when-due false}] (is (nil? (when (:vendor/automatically-paid-when-due vendor) due-date))))) (testing "No scheduled payment when no due date" (let [vendor {:vendor/automatically-paid-when-due true}] (is (nil? (when nil (:vendor/automatically-paid-when-due vendor))))))) (deftest due-date-display-test (testing "Displays 'today' when due date is today (behavior 1.7)" (let [today (time/now) days 0] (is (= 0 days)) (is (= "today" (cond (= 0 days) "today" (> days 0) (format "in %d days" days) :else (format "%d days ago" (- days)))))))) (deftest can-handwrite-test (testing "Returns true for single vendor with positive balance" (is (true? (invoices/can-handwrite? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance 100.0}])))) (testing "Returns false for multiple vendors" (is (false? (invoices/can-handwrite? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance 100.0} {:invoice/vendor {:db/id 2} :invoice/outstanding-balance 50.0}])))) (testing "Returns false for zero or negative total balance" (is (false? (invoices/can-handwrite? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance 0.0}]))) (is (false? (invoices/can-handwrite? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance -50.0}]))))) (deftest credit-only-test (testing "Returns true when all vendor totals are zero or negative" (is (true? (invoices/credit-only? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance -100.0} {:invoice/vendor {:db/id 1} :invoice/outstanding-balance -50.0}])))) (testing "Returns false when any vendor total is positive" (is (false? (invoices/credit-only? [{:invoice/vendor {:db/id 1} :invoice/outstanding-balance -100.0} {:invoice/vendor {:db/id 2} :invoice/outstanding-balance 50.0}])))) (testing "Returns true for empty invoice list" (is (true? (invoices/credit-only? [])))))