diff --git a/docs/testing/behaviors/invoice.md b/docs/testing/behaviors/invoice.md index 8c7003e4..e39812fa 100644 --- a/docs/testing/behaviors/invoice.md +++ b/docs/testing/behaviors/invoice.md @@ -55,7 +55,7 @@ Every mutating operation checks: | 1.4 | It should show "Voided" status as a red pill | UI | [ ] | | 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] | | 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] | -| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [ ] | +| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [x] | | 1.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] | | 1.9 | It should display a links dropdown showing payments, transactions, ledger entries, and source files for each invoice | UI | [ ] | | 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [ ] | @@ -141,9 +141,9 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] | -| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [ ] | -| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [ ] | -| 8.4 | It should suggest the vendor's default expense account | Unit | [ ] | +| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [x] | +| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [x] | +| 8.4 | It should suggest the vendor's default expense account | Unit | [x] | | 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [ ] | | 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] | @@ -153,8 +153,8 @@ Every mutating operation checks: |---|----------|---------------|--------| | 9.1 | It should allow adding multiple expense account rows | UI | [ ] | | 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] | -| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [ ] | -| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [ ] | +| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [x] | +| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [x] | | 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] | | 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] | | 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] | @@ -177,7 +177,7 @@ Every mutating operation checks: | 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] | | 11.2 | It should disable the vendor field when editing | UI | [ ] | | 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [ ] | -| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [ ] | +| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [x] | | 11.5 | Given the user saves changes, then the invoice row should update in place without a full page reload | UI | [ ] | | 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [ ] | @@ -202,7 +202,7 @@ Every mutating operation checks: | 13.1 | It should display a grid of selected invoices with vendor, number, total, and pay amount | UI | [ ] | | 13.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [ ] | | 13.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] | -| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [ ] | +| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [x] | | 13.5 | It should require a check number for handwritten checks | Integration | [ ] | | 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [ ] | | 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] | @@ -223,10 +223,10 @@ Every mutating operation checks: |---|----------|---------------|--------| | 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] | | 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] | -| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [ ] | +| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [x] | | 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] | | 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] | -| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [ ] | +| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] | --- @@ -271,9 +271,9 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [ ] | -| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [ ] | -| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [ ] | -| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [ ] | +| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [x] | +| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [x] | +| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [x] | | 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [ ] | --- @@ -324,11 +324,11 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [ ] | +| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [x] | | 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] | | 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] | -| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [ ] | -| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [ ] | +| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [x] | +| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [x] | ### Form Behaviors diff --git a/opencode.json b/opencode.json index 120ca721..ea8b27ed 100644 --- a/opencode.json +++ b/opencode.json @@ -108,7 +108,11 @@ "url": "https://mcp.context7.com/mcp", "enabled": true }, - + "clojure-mcp": { + "type": "local", + "command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"], + "enabled": true + } }, "permission": { "read": "allow", diff --git a/src/clj/user.clj b/src/clj/user.clj index 8a88dc44..d7a3c32c 100644 --- a/src/clj/user.clj +++ b/src/clj/user.clj @@ -1,32 +1,33 @@ (ns user - (:require [amazonica.aws.s3 :as s3] - [auto-ap.server] - [auto-ap.datomic :refer [conn pull-attr random-tempid]] - [auto-ap.solr :as solr] - [auto-ap.time :as atime] - [auto-ap.utils :refer [by]] - [clj-time.coerce :as c] - [clj-time.core :as t] - [clojure.core.async :as async] - [auto-ap.handler :refer [app]] - [ring.adapter.jetty :refer [run-jetty]] - [clojure.data.csv :as csv] - [clojure.java.io :as io] - [clojure.pprint] - [clojure.string :as str] - [clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]] - [com.brunobonacci.mulog :as mu] - [com.brunobonacci.mulog.buffer :as rb] - [config.core :refer [env]] - [datomic.api :as dc] - [puget.printer :as puget] - [datomic.api :as d] - [figwheel.main.api] - [hawk.core] - [mount.core :as mount] - [nrepl.middleware.print]) - (:import (org.apache.commons.io.input BOMInputStream) - [org.eclipse.jetty.server.handler.gzip GzipHandler])) + (:require [amazonica.aws.s3 :as s3] + [auto-ap.server] + [auto-ap.datomic :refer [conn pull-attr random-tempid]] + [auto-ap.solr :as solr] + [auto-ap.time :as atime] + [auto-ap.utils :refer [by]] + [clj-time.coerce :as c] + [clj-time.core :as t] + [clojure.core.async :as async] + [auto-ap.handler :refer [app]] + [ring.adapter.jetty :refer [run-jetty]] + [clojure.data.csv :as csv] + [clojure.java.io :as io] + [clojure.pprint] + [clojure.string :as str] + [clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]] + [com.brunobonacci.mulog :as mu] + [com.brunobonacci.mulog.buffer :as rb] + [config.core :refer [env]] + [datomic.api :as dc] + [datomic.api :as d] + [puget.printer :as puget] + + [figwheel.main.api] + [hawk.core] + [mount.core :as mount] + [nrepl.middleware.print]) + (:import (org.apache.commons.io.input BOMInputStream) + [org.eclipse.jetty.server.handler.gzip GzipHandler])) (defn println-event [item] #_(printf "%s: %s - %s:%s by %s\n" @@ -44,8 +45,7 @@ item :user))) (when (= :auto-ap.logging/peek (:mulog/event-name item)) - (println "\u001B[31mTEST") - ) + (println "\u001B[31mTEST")) (when (:error item) (println (:error item))) (puget/cprint (reduce @@ -58,18 +58,15 @@ {:seq-limit 10}) (println)) - (deftype DevPublisher [config buffer transform] com.brunobonacci.mulog.publisher.PPublisher (agent-buffer [_] buffer) - (publish-delay [_] 200) - (publish [_ buffer] ;; items are pairs [offset ] (doseq [item (transform (map second (rb/items buffer)))] @@ -77,8 +74,6 @@ (flush) (rb/clear buffer))) - - (defn dev-publisher [{:keys [transform pretty?] :as config}] (DevPublisher. config (rb/agent-buffer 10000) (or transform identity))) @@ -87,29 +82,27 @@ [config] (dev-publisher config)) - - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn load-accounts [conn] (let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv) - code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code - :db/id])] - :in ['$] + code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code + :db/id])] + :in ['$] :where ['[?e :account/name]]} (dc/db conn)))) also-merge-txes (fn [also-merge old-account-id] (if old-account-id (let [[sunset-account] - (first (dc/q {:find ['?a] - :in ['$ '?ac] + (first (dc/q {:find ['?a] + :in ['$ '?ac] :where ['[?a :account/numeric-code ?ac]]} (dc/db conn) also-merge))] (into (mapv (fn [[entity id _]] [:db/add entity id old-account-id]) - (dc/q {:find ['?e '?id '?a] - :in ['$ '?ac] + (dc/q {:find ['?e '?id '?a] + :in ['$ '?ac] :where ['[?a :account/numeric-code ?ac] '[?e ?at ?a] '[?at :db/ident ?id]]} @@ -120,7 +113,7 @@ txes (transduce (comp - (map (fn ->map [r] + (map (fn ->map [r] (into {} (map vector header r)))) (map (fn parse-map [r] {:old-account-id (:db/id (code->existing-account @@ -161,7 +154,6 @@ (also-merge-txes also-merge old-account-id)) tx))))) - conj [] rows)] @@ -169,8 +161,8 @@ #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn find-bad-accounts [] - (set (map second (dc/q {:find ['(pull ?x [*]) '?z] - :in ['$] + (set (map second (dc/q {:find ['(pull ?x [*]) '?z] + :in ['$] :where ['[?e :account/numeric-code ?z] '[(<= ?z 9999)] '[?x ?a ?e]]} @@ -186,13 +178,12 @@ [:db/retractEntity old-account-id]))) conj [] - (dc/q {:find ['?e] - :in ['$] + (dc/q {:find ['?e] + :in ['$] :where ['[?e :account/numeric-code ?z] '[(<= ?z 9999)]]} (dc/db conn))))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn find-conflicting-accounts [] (filter @@ -202,32 +193,30 @@ (fn [acc [e z]] (update acc z conj e)) {} - (dc/q {:find ['?e '?z] - :in ['$] + (dc/q {:find ['?e '?z] + :in ['$] :where ['[?e :account/numeric-code ?z]]} (dc/db conn))))) #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn customize-accounts [customer filename] (let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv) - [client-id] (first (dc/q (-> {:find ['?e] - :in ['$ '?z] + [client-id] (first (dc/q (-> {:find ['?e] + :in ['$ '?z] :where [['?e :client/code '?z]]} (dc/db conn) customer))) - code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code - {:account/applicability [:db/ident]} - :db/id])] - :in ['$] + code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code + {:account/applicability [:db/ident]} + :db/id])] + :in ['$] :where ['[?e :account/name]]} (dc/db conn)))) - existing-account-overrides (dc/q {:find ['?e] - :in ['$ '?client-id] + existing-account-overrides (dc/q {:find ['?e] + :in ['$ '?client-id] :where [['?e :account-client-override/client '?client-id]]} (dc/db conn) client-id) - - _ (when-let [bad-rows (seq (->> rows (group-by (fn [[_ account]] account)) @@ -285,12 +274,11 @@ txes #_@(d/transact conn txes))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn fix-transactions-without-locations [client-code location] (->> - (dc/q {:find ['(pull ?e [*])] - :in ['$ '?client-code] + (dc/q {:find ['(pull ?e [*])] + :in ['$ '?client-code] :where ['[?e :transaction/accounts ?ta] '[?e :transaction/matched-rule] '[?e :transaction/approval-status :transaction-approval-status/approved] @@ -307,12 +295,11 @@ accounts))) vec)) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn entity-history [i] (vec (sort-by first (dc/q - {:find ['?tx '?z '?v] - :in ['?i '$] + {:find ['?tx '?z '?v] + :in ['?i '$] :where ['[?i ?a ?v ?tx ?ad] '[?a :db/ident ?z] '[(= ?ad true)]]} @@ -321,8 +308,8 @@ #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn entity-history-with-revert [i] (vec (sort-by first (dc/q - {:find ['?tx '?z '?v '?ad] - :in ['?i '$] + {:find ['?tx '?z '?v '?ad] + :in ['?i '$] :where ['[?i ?a ?v ?tx ?ad] '[?a :db/ident ?z]]} i (dc/history (dc/db conn)))))) @@ -342,17 +329,15 @@ {:start (- i 100) :end (+ i 100)})))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn start-db [] (mu/start-publisher! {:type :dev}) (mount.core/start (mount.core/only #{#'auto-ap.datomic/conn}))) - (defn- auto-reset-handler [ctx event] (require 'figwheel.main.api) (binding [*ns* *ns*] - (clojure.tools.namespace.repl/refresh) + (clojure.tools.namespace.repl/refresh) ctx)) (defn auto-reset @@ -363,15 +348,13 @@ (hawk.core/watch! [{:paths ["src/" "test/"] :handler auto-reset-handler}])) - -(defn start-http [] +(defn start-http [] (mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty}))) - (defn start-dev [] (set-refresh-dirs "src") - #_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server)) - #_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time)) + #_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server)) + #_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time)) (start-db) (start-http) (auto-reset)) @@ -392,21 +375,20 @@ (for [r data] ((apply juxt columns) r))))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn find-queries [words] - (let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env) - :prefix (str "queries/")) - concurrent 30 + (let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env) + :prefix (str "queries/")) + concurrent 30 output-chan (async/chan)] (async/pipeline-blocking concurrent output-chan (comp (map #(do [(:key %) - (str (slurp (:object-content (s3/get-object - :bucket-name (:data-bucket env) - :key (:key %)))))])) + (str (slurp (:object-content (s3/get-object + :bucket-name (:data-bucket env) + :key (:key %)))))])) (filter #(->> words (every? (fn [w] (str/includes? (second %) w))))) @@ -418,12 +400,11 @@ (println "failed " e))) (async/invoice-id (fn [i] (try (Long/parseLong i) (catch Exception e @@ -460,15 +441,12 @@ target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date)) current-date (:invoice/date invoice) - current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0) target-expense-account-amount (- (Double/parseDouble amount)) - current-expense-account-location (:invoice-expense-account/location invoice-expense-account) target-expense-account-location location - [[_ _ invoice-payment]] (vec (dc/q '[:find ?p ?a ?ip :in $ ?i @@ -479,7 +457,7 @@ :when current-total] [(when (not (auto-ap.utils/dollars= current-total target-total)) - {:db/id invoice-id + {:db/id invoice-id :invoice/total target-total}) (when new-account? @@ -512,7 +490,6 @@ (filter identity) vec))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn get-schema [prefix] (->> (dc/q '[:find ?i @@ -537,7 +514,6 @@ (defn init-repl [] (set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint)) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn sample-ledger-import ([client-code] @@ -546,7 +522,7 @@ (let [client-location (ffirst (d/q '[:find ?l :in $ ?c :where [?c :client/locations ?l]] (dc/db conn) [:client/code client-code]))] (clojure.data.csv/write-csv *out* - (for [n (range n) + (for [n (range n) :let [v (rand-nth (map first (d/q '[:find ?vn :where [_ :vendor/name ?vn]] (dc/db conn)))) [{a-1 :account/numeric-code a-1-location :account/location} {a-2 :account/numeric-code a-2-location :account/location}] (->> (d/q '[:find (pull ?a [:account/numeric-code :account/location]) :where [?a :account/numeric-code]] @@ -559,12 +535,11 @@ (t/minus (t/days (rand-int 60))) (atime/unparse atime/normal-date)) id (rand-int 100000)] - a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount] - [(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]] + a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount] + [(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]] a) :separator \tab)))) - #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} (defn sample-manual-yodlee ([client-code] @@ -573,7 +548,7 @@ (let [bank-accounts (map first (d/q '[:find ?bac :in $ ?c :where [?c :client/bank-accounts ?b] [?b :bank-account/code ?bac]] (dc/db conn) [:client/code client-code]))] (clojure.data.csv/write-csv *out* - (for [n (range n) + (for [n (range n) :let [amount (rand-int 2000) d (-> (t/now) (t/minus (t/days (rand-int 60))) @@ -582,8 +557,6 @@ ["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code]) :separator \tab)))) - - (defn index-solr [] (println "invoice") @@ -591,7 +564,7 @@ :in $ :where [?i :invoice/invoice-number] (not [?i :invoice/status :invoice-status/voided])] - :args [(dc/db conn)]}) + :args [(dc/db conn)]}) (map first) (partition-all 500))] (print ".") @@ -604,7 +577,7 @@ :in $ :where [?i :payment/date] (not [?i :payment/status :payment-status/voided])] - :args [(dc/db conn)]}) + :args [(dc/db conn)]}) (map first) (partition-all 500))] (print ".") @@ -617,7 +590,7 @@ :in $ :where [?i :transaction/description-original] (not [?i :transaction/approval-status :transaction-approval-status/suppressed])] - :args [(dc/db conn)]}) + :args [(dc/db conn)]}) (map first) (partition-all 500))] (print ".") @@ -628,7 +601,7 @@ (doseq [batch (->> (dc/qseq {:query '[:find ?i :in $ :where [?i :journal-entry/date]] - :args [(dc/db conn)]}) + :args [(dc/db conn)]}) (map first) (partition-all 500))] (print ".") @@ -643,4 +616,3 @@ (print ".") @(dc/transact auto-ap.datomic/conn n))) - \ No newline at end of file diff --git a/test/clj/auto_ap/ssr/invoice/invoice_unit_test.clj b/test/clj/auto_ap/ssr/invoice/invoice_unit_test.clj new file mode 100644 index 00000000..5eab4fc0 --- /dev/null +++ b/test/clj/auto_ap/ssr/invoice/invoice_unit_test.clj @@ -0,0 +1,408 @@ +(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? []))))) diff --git a/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj b/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj index b71e88c0..0aa0894c 100644 --- a/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj +++ b/test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj @@ -2,7 +2,6 @@ (:require [clojure.test :refer [deftest testing is]] [auto-ap.ssr.invoice.new-invoice-wizard :as sut9])) - (deftest maybe-spread-locations-test (testing "Shared amount correctly spread across multiple locations" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 @@ -30,8 +29,6 @@ :invoice-expense-account/location "Location 2"}] (map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result)))))) - - (testing "Shared amount correctly spread with leftovers" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 :invoice-expense-account/location "Shared"}] @@ -77,14 +74,14 @@ {:invoice-expense-account/amount -50.66 :invoice-expense-account/location "Location 2"}] (map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result)))))) - + (testing "Leftovers should not exceed a single cent" (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount -100 :invoice-expense-account/location "Shared"} {:invoice-expense-account/amount -5 :invoice-expense-account/location "Shared"}] :invoice/total -101} - result (sut8/maybe-spread-locations invoice ["Location 1" ])] + result (sut9/maybe-spread-locations invoice ["Location 1"])] (is (= [{:invoice-expense-account/amount -100.0 :invoice-expense-account/location "Location 1"}