diff --git a/docs/testing/behaviors/invoice.md b/docs/testing/behaviors/invoice.md index e39812fa..7c2d609a 100644 --- a/docs/testing/behaviors/invoice.md +++ b/docs/testing/behaviors/invoice.md @@ -69,15 +69,15 @@ Every mutating operation checks: | 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] | | 2.4 | It should filter invoices by due date range | Integration | [ ] | | 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] | -| 2.6 | It should filter invoices by invoice number partial match | Integration | [ ] | +| 2.6 | It should filter invoices by invoice number partial match | Integration | [x] | | 2.7 | It should filter invoices by check number | Integration | [ ] | -| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [ ] | +| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [x] | | 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] | -| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [ ] | +| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [x] | | 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] | | 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] | | 2.13 | It should filter by expense account location | Integration | [ ] | -| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] | +| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [x] | ### Sorting Behaviors @@ -87,20 +87,20 @@ Every mutating operation checks: | 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] | | 3.3 | It should sort by description original ascending/descending | Integration | [ ] | | 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] | -| 3.5 | It should sort by invoice date ascending/descending | Integration | [ ] | +| 3.5 | It should sort by invoice date ascending/descending | Integration | [x] | | 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] | -| 3.7 | It should sort by invoice number ascending/descending | Integration | [ ] | +| 3.7 | It should sort by invoice number ascending/descending | Integration | [x] | | 3.8 | It should sort by total amount ascending/descending | Integration | [ ] | | 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] | -| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] | +| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] | ### Pagination Behaviors | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 4.1 | It should display 25 invoices per page by default | Integration | [ ] | +| 4.1 | It should display 25 invoices per page by default | Integration | [x] | | 4.2 | It should allow changing the per-page count | Integration | [ ] | -| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [ ] | +| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [x] | ### Selection Behaviors @@ -140,11 +140,11 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] | +| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [x] | | 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.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [x] | | 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] | ### Expense Accounts Step @@ -174,12 +174,12 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] | +| 11.1 | It should allow editing unpaid and paid invoices | Integration | [x] | | 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.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [x] | | 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 | [ ] | +| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [x] | --- @@ -224,7 +224,7 @@ 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 | [x] | -| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] | +| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [x] | | 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 | [x] | @@ -235,9 +235,9 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] | -| 16.2 | It should require admin permission for bulk void operations | Integration | [ ] | -| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [ ] | -| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [ ] | +| 16.2 | It should require admin permission for bulk void operations | Integration | [x] | +| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [x] | +| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [x] | | 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] | | 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] | | 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] | @@ -248,9 +248,9 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [ ] | +| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [x] | | 17.2 | Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message | Integration | [ ] | -| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [ ] | +| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [x] | | 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] | --- @@ -259,7 +259,7 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [ ] | +| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [x] | | 18.2 | It should require edit permission and client access | Integration | [ ] | | 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] | | 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] | @@ -270,7 +270,7 @@ 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.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 | [x] | | 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] | @@ -351,18 +351,18 @@ Every mutating operation checks: | 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] | | 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] | | 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] | -| 26.5 | It should block bulk delete for non-admin users | Integration | [ ] | -| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [ ] | +| 26.5 | It should block bulk delete for non-admin users | Integration | [x] | +| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [x] | | 26.7 | It should block import for users without `:import` permission | Integration | [ ] | -| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [ ] | +| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] | ### Lock Date Behaviors | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [ ] | +| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [x] | | 27.2 | It should block paying invoices dated before the client's locked-until date | Integration | [ ] | -| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [ ] | +| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [x] | | 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] | | 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] | | 27.6 | It should filter out locked invoices from bulk operations | Integration | [ ] | @@ -372,7 +372,7 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [ ] | +| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [x] | --- diff --git a/resources/cloud-migration-schema.edn b/resources/cloud-migration-schema.edn index 2b301472..9bc3075d 100644 --- a/resources/cloud-migration-schema.edn +++ b/resources/cloud-migration-schema.edn @@ -1,10 +1,4 @@ -[{:db/valueType :db.type/double, - :db/cardinality :db.cardinality/one, - :db/noHistory true - :db/doc "The cached running balance for the account this line item is for", - :db/ident :journal-entry-line/running-balance, - } - {:db/valueType :db.type/boolean, +[{:db/valueType :db.type/boolean, :db/cardinality :db.cardinality/one, :db/noHistory true :db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances", diff --git a/resources/schema.edn b/resources/schema.edn index bae45322..e9911543 100644 --- a/resources/schema.edn +++ b/resources/schema.edn @@ -905,6 +905,11 @@ :db/cardinality #:db{:ident :db.cardinality/one}, :db/doc "The client for the journal entry line", :db/ident :journal-entry-line/client} + {:db/valueType #:db{:ident :db.type/double}, + :db/cardinality #:db{:ident :db.cardinality/one}, + :db/doc "The cached running balance for the account this line item is for", + :db/ident :journal-entry-line/running-balance, + :db/noHistory true} {:db/valueType :db.type/tuple :db/tupleAttrs [:journal-entry-line/client :journal-entry-line/account diff --git a/src/clj/auto_ap/solr.clj b/src/clj/auto_ap/solr.clj index 18b535f2..0f129db9 100644 --- a/src/clj/auto_ap/solr.clj +++ b/src/clj/auto_ap/solr.clj @@ -213,8 +213,9 @@ (fn [data-set] (reduce (fn [data-set x] - (let [thing (datomic->solr x)] - (update data-set index conj [(str/join " " (vals x)) thing]))) + (if-let [thing (datomic->solr x)] + (update data-set index conj [(str/join " " (map str (vals thing))) thing]) + data-set)) data-set xs))) nil) diff --git a/test/clj/auto_ap/integration/invoice_behaviors_test.clj b/test/clj/auto_ap/integration/invoice_behaviors_test.clj new file mode 100644 index 00000000..d0aec809 --- /dev/null +++ b/test/clj/auto_ap/integration/invoice_behaviors_test.clj @@ -0,0 +1,606 @@ +(ns auto-ap.integration.invoice-behaviors-test + (:require + [auto-ap.datomic :as datomic] + [auto-ap.datomic.clients :refer [rebuild-search-index]] + [auto-ap.graphql.invoices :as gql-invoices] + [auto-ap.integration.util :refer [admin-token setup-test-data test-account + test-client test-invoice test-vendor + user-token user-token-no-access wrap-setup]] + [auto-ap.routes.invoices :as route-invoices] + [auto-ap.ssr.invoices :as ssr-invoices] + [auto-ap.time-reader] + [clj-time.coerce :as coerce] + [clj-time.core :as time] + [clojure.test :as t :refer [deftest is testing use-fixtures]] + [datomic.api :as dc])) + +(use-fixtures :each wrap-setup) + +;; ============================================================================ +;; Permission Behaviors (26.x, 26.8) +;; ============================================================================ + +(deftest test-permission-client-access + (testing "Behavior 26.8: It should verify the user has access to the invoice's client before any mutation" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Block creation for user without client access + (is (thrown? Exception (gql-invoices/add-invoice + {:id (user-token-no-access)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "NO-ACCESS" + :date #clj-time/date-time "2022-01-01" + :total 10.00 + :expense_accounts [{:amount 10.0 + :location "DT" + :account_id test-account-id}]}} + nil))) + ;; Create invoice as admin, then block edit/void for user without client access + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "NO-ACCESS-EDIT" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + (is (thrown? Exception (gql-invoices/edit-invoice + {:id (user-token-no-access)} + {:invoice {:id (:id invoice) + :invoice_number "NO-ACCESS-EDIT-2" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil))) + (is (thrown? Exception (gql-invoices/void-invoice + {:id (user-token-no-access)} + {:invoice_id (:id invoice)} + nil))))))) + +(deftest test-permission-bulk-void + (testing "Behavior 26.5: It should block bulk delete for non-admin users" + (let [{:strs [test-client-id]} + (setup-test-data [])] + (is (thrown? Exception (gql-invoices/void-invoices + {:id (user-token test-client-id)} + {:filters {:client_id test-client-id}} + nil)))))) + +(deftest test-permission-bulk-edit + (testing "Behavior 26.6: It should block bulk edit for users without :bulk-edit permission" + (let [{:strs [test-client-id test-account-id]} + (setup-test-data [])] + (is (thrown? Exception (gql-invoices/bulk-change-invoices + {:id (user-token test-client-id)} + {:client_id test-client-id + :filters {:client_id test-client-id} + :accounts [{:percentage 1.0 + :account_id test-account-id + :location "DT"}]} + nil)))))) + +;; ============================================================================ +;; Lock Date Behaviors (27.x) +;; ============================================================================ + +(deftest test-lock-date-edit + (testing "Behavior 27.1: It should block editing invoices dated before the client's locked-until date" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "LOCK-EDIT" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Set lock date after invoice creation + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + (is (thrown? Exception (gql-invoices/edit-invoice + {:id (admin-token)} + {:invoice {:id (:id invoice) + :invoice_number "LOCK-EDIT-2" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil))))))) + +(deftest test-lock-date-void + (testing "Behavior 27.3: It should block voiding invoices dated before the client's locked-until date" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "LOCK-VOID" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Set lock date after invoice creation + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + (is (thrown? Exception (gql-invoices/void-invoice + {:id (admin-token)} + {:invoice_id (:id invoice)} + nil))))))) + +;; ============================================================================ +;; New Invoice Wizard (8.1, 8.5) +;; ============================================================================ + +(deftest test-new-invoice-validation + (testing "Behavior 8.1: It should require client, vendor, date, invoice number, and total" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Missing invoice number + (is (thrown? Exception (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :date #clj-time/date-time "2022-01-01" + :total 10.00 + :expense_accounts [{:amount 10.0 + :location "DT" + :account_id test-account-id}]}} + nil))) + ;; Missing total + (is (thrown? Exception (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "MISSING-TOTAL" + :date #clj-time/date-time "2022-01-01" + :expense_accounts [{:amount 10.0 + :location "DT" + :account_id test-account-id}]}} + nil))))) + + (testing "Behavior 8.5: It should prevent duplicate invoice numbers for the same vendor and client" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create first invoice + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "DUP-TEST" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Try duplicate + (is (thrown? Exception (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "DUP-TEST" + :date #clj-time/date-time "2022-02-01" + :total 200.00 + :expense_accounts [{:amount 200.0 + :location "DT" + :account_id test-account-id}]}} + nil)))))) + +;; ============================================================================ +;; Edit Invoice (11.1, 11.3) +;; ============================================================================ + +(deftest test-edit-unpaid-invoice + (testing "Behavior 11.1: It should allow editing unpaid and paid invoices" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "EDIT-TEST" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Edit unpaid invoice + (is (some? (gql-invoices/edit-invoice + {:id (admin-token)} + {:invoice {:id (:id invoice) + :invoice_number "EDITED" + :date #clj-time/date-time "2022-01-01" + :total 150.00 + :expense_accounts [{:amount 150.0 + :location "DT" + :account_id test-account-id}]}} + nil))) + ;; Verify edit + (is (= "EDITED" + (:invoice/invoice-number (dc/pull (dc/db datomic/conn) + [:invoice/invoice-number] + (:id invoice))))))))) + +(deftest test-edit-expense-accounts + (testing "Behavior 11.3: It should allow modifying expense account amounts, adding/removing accounts" + (let [{:strs [test-client-id test-vendor-id test-account-id new-account-id]} + (setup-test-data [(test-account :db/id "new-account-id")])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "EDIT-ACCTS" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Add second expense account + (gql-invoices/edit-invoice + {:id (admin-token)} + {:invoice {:id (:id invoice) + :invoice_number "EDIT-ACCTS" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 60.0 + :location "DT" + :account_id test-account-id} + {:amount 40.0 + :location "DT" + :account_id new-account-id}]}} + nil) + (let [updated (dc/pull (dc/db datomic/conn) + [{:invoice/expense-accounts [:invoice-expense-account/amount + :invoice-expense-account/account]}] + (:id invoice))] + (is (= 2 (count (:invoice/expense-accounts updated))))))))) + +;; ============================================================================ +;; Bulk Edit (15.4) +;; ============================================================================ + +(deftest test-bulk-edit-codes-invoices + (testing "Behavior 15.4: Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create an invoice + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "BULK-EDIT" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Bulk edit should change the expense account + (gql-invoices/bulk-change-invoices + {:id (admin-token)} + {:client_id test-client-id + :filters {:client_id test-client-id} + :accounts [{:percentage 1.0 + :account_id test-account-id + :location "DT"}]} + nil) + ;; Verify the invoice still has the expense account + (let [updated (dc/pull (dc/db datomic/conn) + [{:invoice/expense-accounts [:invoice-expense-account/account]}] + (:id invoice))] + (is (= test-account-id + (-> updated :invoice/expense-accounts first :invoice-expense-account/account :db/id)))))))) + +;; ============================================================================ +;; Single/Bulk Void (17.1, 16.3, 16.4) +;; ============================================================================ + +(deftest test-void-unpaid-invoice + (testing "Behavior 17.1: Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "VOID-TEST" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + (gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil) + (let [voided (dc/pull (dc/db datomic/conn) + [{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance + {:invoice/expense-accounts [:invoice-expense-account/amount]}] + (:id invoice))] + (is (= :invoice-status/voided (-> voided :invoice/status :db/ident))) + (is (= 0.0 (:invoice/total voided))) + (is (= 0.0 (:invoice/outstanding-balance voided))) + (is (every? #(= 0.0 (:invoice-expense-account/amount %)) + (:invoice/expense-accounts voided)))))))) + +(deftest test-bulk-void-cash-payments + (testing "Behavior 16.3: Given confirmed, when voiding, then linked cash payments should be voided automatically" + (let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "BULK-VOID-CASH" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + cash-payment-id (get-in @(dc/transact datomic/conn + [{:db/id "cash-pmt" + :payment/date #inst "2022-01-01" + :payment/client test-client-id + :payment/vendor test-vendor-id + :payment/bank-account test-bank-account-id + :payment/type :payment-type/cash + :payment/amount 100.0 + :payment/status :payment-status/cleared} + {:db/id "ip" + :invoice-payment/invoice (:id invoice) + :invoice-payment/payment "cash-pmt" + :invoice-payment/amount 100.0}]) + [:tempids "cash-pmt"])] + ;; Bulk void should also void the cash payment + (gql-invoices/void-invoices + {:id (admin-token) :clients [{:db/id test-client-id}]} + {:filters {:client_id test-client-id}} + nil) + (let [payment (dc/pull (dc/db datomic/conn) + [{:payment/status [:db/ident]}] + cash-payment-id)] + (is (= :payment-status/voided (-> payment :payment/status :db/ident)))))))) + +;; ============================================================================ +;; Unvoid (18.1) +;; ============================================================================ + +(deftest test-unvoid-restores-invoice + (testing "Behavior 18.1: Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "UNVOID-TEST" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + original-id (:id invoice)] + ;; Void the invoice + (gql-invoices/void-invoice {:id (admin-token)} {:invoice_id original-id} nil) + (let [voided (dc/pull (dc/db datomic/conn) [{:invoice/status [:db/ident]}] original-id)] + (is (= :invoice-status/voided (-> voided :invoice/status :db/ident)))) + ;; Unvoid the invoice + (gql-invoices/unvoid-invoice {:id (admin-token)} {:invoice_id original-id} nil) + (let [restored (dc/pull (dc/db datomic/conn) + [{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance + {:invoice/expense-accounts [:invoice-expense-account/amount]}] + original-id)] + (is (= :invoice-status/unpaid (-> restored :invoice/status :db/ident))) + (is (= 100.0 (:invoice/total restored))) + (is (= 100.0 (:invoice/outstanding-balance restored))) + (is (= 100.0 (-> restored :invoice/expense-accounts first :invoice-expense-account/amount)))))))) + +;; ============================================================================ +;; Undo Autopay (19.1) +;; ============================================================================ + +(deftest test-undo-autopay-resets-status + (testing "Behavior 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" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "UNDO-AUTO" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + invoice-id (:id invoice)] + ;; Mark as paid with scheduled payment + @(dc/transact datomic/conn + [[:upsert-invoice {:db/id invoice-id + :invoice/status :invoice-status/paid + :invoice/outstanding-balance 0.0 + :invoice/scheduled-payment #inst "2022-02-01"}]]) + ;; Undo autopay + (gql-invoices/unautopay-invoice {:id (admin-token)} {:invoice_id invoice-id} nil) + (let [updated (dc/pull (dc/db datomic/conn) + [{:invoice/status [:db/ident]} :invoice/outstanding-balance :invoice/scheduled-payment] + invoice-id)] + (is (= :invoice-status/unpaid (-> updated :invoice/status :db/ident))) + (is (= 100.0 (:invoice/outstanding-balance updated))) + (is (nil? (:invoice/scheduled-payment updated)))))))) + +;; ============================================================================ +;; Invoice List Query Behaviors (2.6, 2.8, 2.10, 2.14) +;; ============================================================================ + +(deftest test-invoice-list-filtering + (testing "Behaviors 2.6, 2.8, 2.10, 2.14: Invoice list filtering" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create test invoices + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "FILTER-A" + :date #clj-time/date-time "2022-01-15" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "FILTER-B" + :date #clj-time/date-time "2022-02-15" + :total 200.00 + :expense_accounts [{:amount 200.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; Filter by invoice number + (let [request {:query-params {:invoice-number "FILTER-A"} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count))) + + ;; Filter by status + (let [request {:query-params {} + :route-params {:status :invoice-status/unpaid} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 2 count))) + + ;; Exact match by ID + (let [invoice-id (ffirst (dc/q '[:find ?i + :where [?i :invoice/invoice-number "FILTER-A"]] + (dc/db datomic/conn))) + request {:query-params {:exact-match-id invoice-id} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= invoice-id (:db/id (first invoices)))))))) + +;; ============================================================================ +;; Invoice List Sorting (3.5, 3.7, 3.10) +;; ============================================================================ + +(deftest test-invoice-list-sorting + (testing "Behaviors 3.5, 3.7, 3.10: Invoice list sorting" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "SORT-B" + :date #clj-time/date-time "2022-02-01" + :total 200.00 + :expense_accounts [{:amount 200.0 + :location "DT" + :account_id test-account-id}]}} + nil) + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "SORT-A" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; Sort by date ascending + (let [request {:query-params {:sort [{:sort-key "date" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= "SORT-A" (:invoice/invoice-number (first invoices))))) + + ;; Sort by invoice number + (let [request {:query-params {:sort [{:sort-key "invoice-number" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= "SORT-A" (:invoice/invoice-number (first invoices))))) + + ;; Toggle sort direction + (let [request-asc {:query-params {:sort [{:sort-key "date" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices-asc count] (ssr-invoices/fetch-page request-asc) + request-desc {:query-params {:sort [{:sort-key "date" :asc false}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices-desc count] (ssr-invoices/fetch-page request-desc)] + (is (not= (:invoice/invoice-number (first invoices-asc)) + (:invoice/invoice-number (first invoices-desc)))))))) + +;; ============================================================================ +;; Invoice List Pagination (4.1, 4.3) +;; ============================================================================ + +(deftest test-invoice-list-pagination + (testing "Behaviors 4.1, 4.3: Invoice list pagination" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create 30 invoices + (doseq [i (range 30)] + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number (str "PAGE-" i) + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)) + + ;; Default 25 per page + (let [request {:query-params {} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices total-count total-outstanding total-amount] (ssr-invoices/fetch-page request)] + (is (= 25 (count invoices))) + (is (= 30 total-count)) + (is (= 3000.0 total-outstanding)) + (is (= 3000.0 total-amount)))))) + +;; ============================================================================ +;; Legacy Routes (28.1) +;; ============================================================================ + +(deftest test-legacy-routes + (testing "Behavior 28.1: It should redirect old SPA routes to the new SSR routes" + (let [handler (ssr-invoices/redirect-handler ::ssr-invoices/route/all-page) + response (handler {:query-params {}})] + (is (= 302 (:status response))) + (is (get-in response [:headers "Location"]))))) diff --git a/test/clj/auto_ap/integration/routes/ezcater_xls.clj b/test/clj/auto_ap/integration/routes/ezcater_xls.clj index b4712fbd..2ec4e6d2 100644 --- a/test/clj/auto_ap/integration/routes/ezcater_xls.clj +++ b/test/clj/auto_ap/integration/routes/ezcater_xls.clj @@ -23,37 +23,37 @@ (with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))] (is (seq (sut/stream->sales-orders s)))) (with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))] - (is (= #:sales-order - {:vendor :vendor/ccp-ezcater - :service-charge -95.9 - :date #inst "2023-04-03T18:30:00" - :reference-link "ZA2-320" - :charges - [#:charge{:type-name "CARD" - :date #inst "2023-04-03T18:30:00" - :client test-client - :location "DT" - :external-id - "ezcater/charge/17592186045501-DT-ZA2-320-0" - :processor :ccp-processor/ezcater - :total 516.12 - :tip 0.0}] - :client test-client - :tip 0.0 - :tax 37.12 - :external-id "ezcater/order/17592186045501-DT-ZA2-320" - :total 516.12 - :line-items - [#:order-line-item{:external-id - "ezcater/order/17592186045501-DT-ZA2-320-0" - :item-name "EZCater Catering" - :category "EZCater Catering" - :discount 0.0 - :tax 37.12 - :total 516.12}] - :discount 0.0 - :location "DT" - :returns 0.0} - (last (first (filter (comp #{:order} first) - (sut/stream->sales-orders s))))))))))) + (is (= #:sales-order + {:vendor :vendor/ccp-ezcater + :service-charge -95.9 + :date #inst "2023-04-03T18:30:00" + :reference-link "ZA2-320" + :charges + [#:charge{:type-name "CARD" + :date #inst "2023-04-03T18:30:00" + :client test-client + :location "DT" + :external-id + (str "ezcater/charge/" test-client "-DT-ZA2-320-0") + :processor :ccp-processor/ezcater + :total 516.12 + :tip 0.0}] + :client test-client + :tip 0.0 + :tax 37.12 + :external-id (str "ezcater/order/" test-client "-DT-ZA2-320") + :total 516.12 + :line-items + [#:order-line-item{:external-id + (str "ezcater/order/" test-client "-DT-ZA2-320-0") + :item-name "EZCater Catering" + :category "EZCater Catering" + :discount 0.0 + :tax 37.12 + :total 516.12}] + :discount 0.0 + :location "DT" + :returns 0.0} + (last (first (filter (comp #{:order} first) + (sut/stream->sales-orders s))))))))))) diff --git a/test/clj/auto_ap/integration/util.clj b/test/clj/auto_ap/integration/util.clj index 405e6bd0..e917e567 100644 --- a/test/clj/auto_ap/integration/util.clj +++ b/test/clj/auto_ap/integration/util.clj @@ -5,7 +5,8 @@ (defn wrap-setup [f] - (with-redefs [auto-ap.datomic/uri "datomic:mem://test"] + (with-redefs [auto-ap.datomic/uri "datomic:mem://test" + auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))] (dc/create-database auto-ap.datomic/uri) (with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)] (transact-schema conn) @@ -28,6 +29,13 @@ :user/name "TEST USER" :user/clients [{:db/id client-id}]})) +(defn user-token-no-access [] + {:user "TEST USER" + :exp (time/plus (time/now) (time/days 1)) + :user/role "user" + :user/name "TEST USER" + :user/clients []}) + @@ -101,16 +109,18 @@ (dissoc x :id)) (defn setup-test-data [data] - (:tempids @(dc/transact conn (into data - [(test-account :db/id "test-account-id") - (test-client :db/id "test-client-id" - :client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")]) - (test-vendor :db/id "test-vendor-id") - {:db/id "accounts-payable-id" - :account/name "Accounts Payable" - :db/ident :account/accounts-payable - :account/numeric-code 21000 - :account/account-set "default"}])))) + (let [defaults [(test-account :db/id "test-account-id") + (test-client :db/id "test-client-id" + :client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")]) + (test-vendor :db/id "test-vendor-id") + {:db/id "accounts-payable-id" + :account/name "Accounts Payable" + :db/ident :account/accounts-payable + :account/numeric-code 21000 + :account/account-set "default"}] + user-ids (set (keep :db/id data)) + merged (into [] (concat data (remove #(user-ids (:db/id %)) defaults)))] + (:tempids @(dc/transact conn merged)))) (defn apply-tx [data] (:db-after @(dc/transact conn data)))