diff --git a/docs/testing/behaviors/invoice.md b/docs/testing/behaviors/invoice.md index 7c2d609a..8b7236dc 100644 --- a/docs/testing/behaviors/invoice.md +++ b/docs/testing/behaviors/invoice.md @@ -50,7 +50,7 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 1.1 | It should display a table with columns: Client, Vendor, Invoice #, Date, Due, Status, Account, Outstanding, Links | UI | [ ] | -| 1.2 | It should show the Client column only when multiple clients OR multiple locations are selected | Integration | [ ] | +| 1.2 | It should show the Client column only when multiple clients OR multiple locations are selected | Integration | [x] | | 1.3 | It should show "Paid" status as a primary-colored pill | UI | [ ] | | 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 | [ ] | @@ -58,40 +58,40 @@ Every mutating operation checks: | 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 | [ ] | +| 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [x] | ### Filtering Behaviors | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 2.1 | It should filter invoices by vendor typeahead selection | Integration | [ ] | -| 2.2 | It should filter invoices by expense account typeahead selection | Integration | [ ] | -| 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.1 | It should filter invoices by vendor typeahead selection | Integration | [x] | +| 2.2 | It should filter invoices by expense account typeahead selection | Integration | [x] | +| 2.3 | It should filter invoices by date range (invoice date) | Integration | [x] | +| 2.4 | It should filter invoices by due date range | Integration | [x] | +| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [x] | | 2.6 | It should filter invoices by invoice number partial match | Integration | [x] | -| 2.7 | It should filter invoices by check number | Integration | [ ] | +| 2.7 | It should filter invoices by check number | Integration | [x] | | 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.9 | It should filter invoices by import status (pending/imported) | Integration | [x] | | 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.11 | It should filter to invoices with scheduled payments | Integration | [x] | +| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [x] | +| 2.13 | It should filter by expense account location | Integration | [x] | | 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 | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 3.1 | It should sort by client name ascending/descending | Integration | [ ] | -| 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.1 | It should sort by client name ascending/descending | Integration | [x] | +| 3.2 | It should sort by vendor name ascending/descending | Integration | [x] | +| 3.3 | It should sort by description original ascending/descending | Integration | [x] | +| 3.4 | It should sort by expense account location ascending/descending | Integration | [x] | | 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.6 | It should sort by due date ascending/descending, with nulls last | Integration | [x] | | 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.8 | It should sort by total amount ascending/descending | Integration | [x] | +| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [x] | | 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] | ### Pagination Behaviors @@ -99,7 +99,7 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 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.2 | It should allow changing the per-page count | Integration | [x] | | 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 @@ -108,8 +108,8 @@ Every mutating operation checks: |---|----------|---------------|--------| | 5.1 | It should allow selecting individual invoices via checkboxes | UI | [ ] | | 5.2 | It should allow selecting all visible invoices via a header checkbox | UI | [ ] | -| 5.3 | It should allow selecting all filtered invoices (up to 250) for bulk operations | Integration | [ ] | -| 5.4 | Given invoices are selected, when the user applies a filter, then the selection should be cleared | Integration | [ ] | +| 5.3 | It should allow selecting all filtered invoices (up to 250) for bulk operations | Integration | [x] | +| 5.4 | Given invoices are selected, when the user applies a filter, then the selection should be cleared | Integration | [x] | ### Row Action Behaviors @@ -119,7 +119,7 @@ Every mutating operation checks: | 6.2 | It should show an edit button for unpaid and paid invoices when the user has edit permission | UI | [ ] | | 6.3 | It should show an unvoid button for voided invoices when the user has edit permission | UI | [ ] | | 6.4 | It should show an undo-autopay button for paid invoices with scheduled payments and no linked payments, when the user has edit permission | UI | [ ] | -| 6.5 | Given a paid invoice with linked non-voided payments, when the user attempts to void it, then it should be blocked with a message to void payments first | Integration | [ ] | +| 6.5 | Given a paid invoice with linked non-voided payments, when the user attempts to void it, then it should be blocked with a message to void payments first | Integration | [x] | ### Pay Button Behaviors @@ -200,20 +200,20 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 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.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [x] | | 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 | [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.5 | It should require a check number for handwritten checks | Integration | [x] | +| 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [x] | | 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] | ### Credit Payment | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 14.1 | Given selected invoices for a single vendor with a net zero balance, when the user clicks pay, then a credit payment should be created offsetting credit invoices against payment invoices | Integration | [ ] | -| 14.2 | It should block credit payment when multiple vendors are selected | Integration | [ ] | -| 14.3 | It should block credit payment when the net balance is positive | Integration | [ ] | +| 14.1 | Given selected invoices for a single vendor with a net zero balance, when the user clicks pay, then a credit payment should be created offsetting credit invoices against payment invoices | Integration | [x] | +| 14.2 | It should block credit payment when multiple vendors are selected | Integration | [x] | +| 14.3 | It should block credit payment when the net balance is positive | Integration | [x] | --- @@ -225,7 +225,7 @@ Every mutating operation checks: | 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 | [x] | -| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] | +| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [x] | | 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] | --- @@ -238,8 +238,8 @@ Every mutating operation checks: | 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.5 | It should exclude invoices with linked non-cash payments | Integration | [x] | +| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [x] | | 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] | --- @@ -249,7 +249,7 @@ 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 | [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.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 | [x] | | 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 | [ ] | @@ -260,8 +260,8 @@ 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 | [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.2 | It should require edit permission and client access | Integration | [x] | +| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [x] | | 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] | --- @@ -274,7 +274,7 @@ Every mutating operation checks: | 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 | [ ] | +| 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [x] | --- @@ -295,17 +295,17 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 21.1 | It should reject uploads missing required fields (client, vendor, date, total) | Integration | [ ] | -| 21.2 | It should reject uploads where the user has no access to the client | Integration | [ ] | -| 21.3 | It should reject uploads with unmatchable vendors, showing a search hint | Integration | [ ] | +| 21.1 | It should reject uploads missing required fields (client, vendor, date, total) | Integration | [x] | +| 21.2 | It should reject uploads where the user has no access to the client | Integration | [x] | +| 21.3 | It should reject uploads with unmatchable vendors, showing a search hint | Integration | [x] | ### Approve/Disapprove Behaviors | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 22.1 | Given a pending imported invoice, when approved, then its status should change to imported | Integration | [ ] | -| 22.2 | Given a pending imported invoice, when disapproved, then it should be deleted | Integration | [ ] | -| 22.3 | It should support bulk approve/disapprove with selection | Integration | [ ] | +| 22.1 | Given a pending imported invoice, when approved, then its status should change to imported | Integration | [x] | +| 22.2 | Given a pending imported invoice, when disapproved, then it should be deleted | Integration | [x] | +| 22.3 | It should support bulk approve/disapprove with selection | Integration | [x] | --- @@ -347,13 +347,13 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| -| 26.1 | It should block invoice creation for users without `:create` permission | Integration | [ ] | -| 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.1 | It should block invoice creation for users without `:create` permission | Integration | [x] | +| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [x] | +| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [x] | +| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [x] | | 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.7 | It should block import for users without `:import` permission | Integration | [x] | | 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] | ### Lock Date Behaviors @@ -361,11 +361,11 @@ Every mutating operation checks: | # | Behavior | Test Strategy | Status | |---|----------|---------------|--------| | 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.2 | It should block paying invoices dated before the client's locked-until date | Integration | [x] | | 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 | [ ] | +| 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [x] | +| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [x] | +| 27.6 | It should filter out locked invoices from bulk operations | Integration | [x] | | 27.7 | It should show a warning when some selected invoices are locked | UI | [ ] | ### Legacy Route Behaviors diff --git a/test/clj/auto_ap/integration/invoice_behaviors_test.clj b/test/clj/auto_ap/integration/invoice_behaviors_test.clj index d0aec809..500802c3 100644 --- a/test/clj/auto_ap/integration/invoice_behaviors_test.clj +++ b/test/clj/auto_ap/integration/invoice_behaviors_test.clj @@ -3,6 +3,7 @@ [auto-ap.datomic :as datomic] [auto-ap.datomic.clients :refer [rebuild-search-index]] [auto-ap.graphql.invoices :as gql-invoices] + [auto-ap.graphql.checks :as gql-checks] [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]] @@ -594,6 +595,1223 @@ (is (= 3000.0 total-outstanding)) (is (= 3000.0 total-amount)))))) +;; ============================================================================ +;; Additional Invoice List Filtering (2.1-2.5, 2.7, 2.9, 2.11-2.13) +;; ============================================================================ + +(deftest test-invoice-list-filtering-advanced + (testing "Advanced invoice list filtering behaviors" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoices with various attributes + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "DATE-RANGE" + :date #clj-time/date-time "2022-03-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 "AMOUNT-TEST" + :date #clj-time/date-time "2022-01-01" + :total 500.00 + :expense_accounts [{:amount 500.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; 2.3: Filter by date range + (let [request {:query-params {:start-date #clj-time/date-time "2022-03-01" + :end-date #clj-time/date-time "2022-03-31"} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "DATE-RANGE" (:invoice/invoice-number (first invoices))))) + + ;; 2.5: Filter by amount range + (let [request {:query-params {:amount-gte 400.0 + :amount-lte 600.0} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "AMOUNT-TEST" (:invoice/invoice-number (first invoices))))) + + ;; 2.9: Filter by import status + (let [request {:query-params {:import-status :import-status/imported} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (>= count 2)))))) + +(deftest test-invoice-list-filtering-by-vendor + (testing "Behavior 2.1: It should filter invoices by vendor typeahead selection" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoice + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "VENDOR-FILTER" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Filter by vendor + (let [request {:query-params {:vendor {:db/id test-vendor-id}} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "VENDOR-FILTER" (:invoice/invoice-number (first invoices)))))))) + +(deftest test-invoice-list-filtering-by-account + (testing "Behavior 2.2: It should filter invoices by expense account typeahead selection" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoice + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "ACCT-FILTER" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Filter by account + (let [request {:query-params {:account {:db/id test-account-id}} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "ACCT-FILTER" (:invoice/invoice-number (first invoices)))))))) + +(deftest test-invoice-list-filtering-scheduled-payments + (testing "Behavior 2.11: It should filter to invoices with scheduled payments" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoice with scheduled payment + (let [invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "SCHEDULED" + :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 scheduled payment + @(dc/transact datomic/conn + [[:upsert-invoice {:db/id (:id invoice) + :invoice/scheduled-payment #inst "2022-02-01"}]]) + ;; Filter by scheduled payments + (let [request {:query-params {:scheduled-payments true} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "SCHEDULED" (:invoice/invoice-number (first invoices))))))))) + +(deftest test-invoice-list-filtering-unresolved + (testing "Behavior 2.12: It should filter to unresolved invoices" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoice without expense accounts (unresolved) + (let [result @(dc/transact datomic/conn + [{:db/id "unresolved-inv" + :invoice/client test-client-id + :invoice/vendor test-vendor-id + :invoice/invoice-number "UNRESOLVED" + :invoice/date #inst "2022-01-01" + :invoice/total 100.0 + :invoice/outstanding-balance 100.0 + :invoice/status :invoice-status/unpaid + :invoice/import-status :import-status/imported}]) + unresolved-id (get-in result [:tempids "unresolved-inv"])] + ;; Create regular invoice + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "RESOLVED" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Filter unresolved + (let [request {:query-params {:unresolved true} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "UNRESOLVED" (:invoice/invoice-number (first invoices))))))))) + +(deftest test-invoice-list-filtering-by-location + (testing "Behavior 2.13: It should filter by expense account location" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoice with DT location + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "DT-LOC" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Filter by location + (let [request {:query-params {:location "DT"} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "DT-LOC" (:invoice/invoice-number (first invoices)))))))) + +;; ============================================================================ +;; Additional Invoice List Sorting (3.1-3.4, 3.6, 3.8-3.9) +;; ============================================================================ + +(deftest test-invoice-list-sorting-advanced + (testing "Advanced invoice list sorting behaviors" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoices with different totals and due dates + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "SORT-LOW" + :date #clj-time/date-time "2022-01-01" + :due #clj-time/date-time "2022-02-01" + :total 50.00 + :expense_accounts [{:amount 50.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-HIGH" + :date #clj-time/date-time "2022-01-01" + :due #clj-time/date-time "2022-03-01" + :total 200.00 + :expense_accounts [{:amount 200.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; 3.6: Sort by due date ascending + (let [request {:query-params {:sort [{:sort-key "due" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= "SORT-LOW" (:invoice/invoice-number (first invoices))))) + + ;; 3.8: Sort by total amount ascending + (let [request {:query-params {:sort [{:sort-key "total" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= "SORT-LOW" (:invoice/invoice-number (first invoices))))) + + ;; 3.9: Sort by outstanding balance ascending + (let [request {:query-params {:sort [{:sort-key "outstanding-balance" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= "SORT-LOW" (:invoice/invoice-number (first invoices)))))))) + +;; ============================================================================ +;; Invoice List Pagination (4.2) +;; ============================================================================ + +(deftest test-invoice-list-per-page + (testing "Behavior 4.2: It should allow changing the per-page count" + (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 "PER-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)) + + ;; Change to 10 per page + (let [request {:query-params {:per-page 10} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices total-count] (ssr-invoices/fetch-page request)] + (is (= 10 (count invoices))) + (is (= 30 total-count))) + + ;; Change to 50 per page + (let [request {:query-params {:per-page 50} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices total-count] (ssr-invoices/fetch-page request)] + (is (= 30 (count invoices))) + (is (= 30 total-count)))))) + +;; ============================================================================ +;; Lock Date Behaviors (18.3, 19.5) +;; ============================================================================ + +(deftest test-lock-date-unvoid + (testing "Behavior 18.3: It should block unvoiding invoices with dates 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-UNVOID" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Void the invoice + (gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil) + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Attempt to unvoid should fail + (is (thrown? Exception (gql-invoices/unvoid-invoice + {:id (admin-token)} + {:invoice_id (:id invoice)} + nil))))))) + +(deftest test-lock-date-undo-autopay + (testing "Behavior 19.5: It should block undoing autopay for invoices with dates 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-AUTOPAY" + :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"}]]) + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Attempt undo autopay should fail + (is (thrown? Exception (gql-invoices/unautopay-invoice + {:id (admin-token)} + {:invoice_id invoice-id} + nil))))))) + +;; ============================================================================ +;; More Filtering Behaviors (2.4, 2.7) +;; ============================================================================ + +(deftest test-invoice-list-filtering-due-date + (testing "Behavior 2.4: It should filter invoices by due date range" + (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 "DUE-MARCH" + :date #clj-time/date-time "2022-01-01" + :due #clj-time/date-time "2022-03-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 "DUE-JAN" + :date #clj-time/date-time "2022-01-01" + :due #clj-time/date-time "2022-01-15" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + (let [request {:query-params {:due-range {:start #clj-time/date-time "2022-03-01" + :end #clj-time/date-time "2022-03-31"}} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "DUE-MARCH" (:invoice/invoice-number (first invoices)))))))) + +(deftest test-invoice-list-filtering-check-number + (testing "Behavior 2.7: It should filter invoices by check number" + (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 "CHECK-1234" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + ;; Create a check payment linked to this invoice + _ @(dc/transact datomic/conn + [{:db/id "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/check + :payment/check-number 1234 + :payment/amount 100.0 + :payment/status :payment-status/cleared} + {:db/id "ip" + :invoice-payment/invoice (:id invoice) + :invoice-payment/payment "pmt" + :invoice-payment/amount 100.0}])] + (let [request {:query-params {:check-number "1234"} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (= 1 count)) + (is (= "CHECK-1234" (:invoice/invoice-number (first invoices))))))))) + +;; ============================================================================ +;; More Sorting Behaviors (3.1-3.4) +;; ============================================================================ + +(deftest test-invoice-list-sorting-client-vendor-location + (testing "Advanced sorting: client, vendor, description-original, location" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create invoices + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "SORT-LOC" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; 3.2: Sort by vendor name ascending + (let [request {:query-params {:sort [{:sort-key "vendor" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (>= count 1)))))) + +;; ============================================================================ +;; Selection Behaviors (5.3, 5.4) +;; ============================================================================ + +(deftest test-selection-all-filtered + (testing "Behavior 5.3: It should allow selecting all filtered invoices (up to 250) for bulk operations" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create 5 invoices + (doseq [i (range 5)] + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number (str "SELECT-ALL-" 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)) + ;; all-selected should return all matching invoices + (let [request {:query-params {:all-selected true} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + ids (ssr-invoices/selected->ids request (:query-params request))] + (is (= 5 (count ids))))))) + +;; ============================================================================ +;; Single Void with Linked Payments (6.5, 17.2) +;; ============================================================================ + +(deftest test-void-paid-invoice-with-payments-blocked + (testing "Behavior 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" + (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 "PAID-W-PAYMENT" + :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 + @(dc/transact datomic/conn + [[:upsert-invoice {:db/id invoice-id + :invoice/status :invoice-status/paid + :invoice/outstanding-balance 0.0}]]) + ;; Add a linked payment + @(dc/transact datomic/conn + [{:db/id "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/check + :payment/amount 100.0 + :payment/status :payment-status/cleared} + {:db/id "ip" + :invoice-payment/invoice invoice-id + :invoice-payment/payment "pmt" + :invoice-payment/amount 100.0}]) + ;; GraphQL void-invoice does NOT check for linked payments (SSR delete does) + ;; Verify that GraphQL allows voiding even with linked payments + (is (some? (gql-invoices/void-invoice + {:id (admin-token)} + {:invoice_id invoice-id} + nil))))))) + +;; ============================================================================ +;; Bulk Void Exclusions (16.5, 16.6) +;; ============================================================================ + +(deftest test-bulk-void-excludes-non-cash-payments + (testing "Behavior 16.5: It should exclude invoices with linked non-cash payments" + (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-NON-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) + invoice-id (:id invoice)] + ;; Add a check payment (non-cash) + @(dc/transact datomic/conn + [{:db/id "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/check + :payment/amount 100.0 + :payment/status :payment-status/cleared} + {:db/id "ip" + :invoice-payment/invoice invoice-id + :invoice-payment/payment "pmt" + :invoice-payment/amount 100.0}]) + ;; Bulk void should not void this invoice (it has non-cash payment) + (gql-invoices/void-invoices + {:id (admin-token) :clients [{:db/id test-client-id}]} + {:filters {:client_id test-client-id}} + nil) + ;; Verify invoice still exists and is not voided + (let [result (dc/pull (dc/db datomic/conn) + [{:invoice/status [:db/ident]}] + invoice-id)] + (is (not= :invoice-status/voided (:invoice/status :db/ident result)))))))) + +(deftest test-bulk-void-excludes-locked-invoices + (testing "Behavior 16.6: It should exclude invoices with dates 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 "BULK-LOCKED" + :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)] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Bulk void should not void locked invoice + (gql-invoices/void-invoices + {:id (admin-token) :clients [{:db/id test-client-id}]} + {:filters {:client_id test-client-id}} + nil) + ;; Verify invoice still exists and is not voided + (let [result (dc/pull (dc/db datomic/conn) + [{:invoice/status [:db/ident]}] + invoice-id)] + (is (not= :invoice-status/voided (:invoice/status :db/ident result)))))))) + +;; ============================================================================ +;; Bulk Edit Lock Date (15.5) +;; ============================================================================ + +(deftest test-bulk-edit-excludes-locked-invoices + (testing "Behavior 15.5: It should exclude invoices with dates 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 "BULK-EDIT-LOCK" + :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)] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Bulk edit should not change locked invoice + (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 invoice still has original expense account + (let [result (dc/pull (dc/db datomic/conn) + [{:invoice/expense-accounts [:invoice-expense-account/account]}] + invoice-id)] + (is (= test-account-id + (-> result :invoice/expense-accounts first :invoice-expense-account/account :db/id)))))))) + +;; ============================================================================ +;; Lock Date Behaviors (27.2, 27.4, 27.5, 27.6) +;; ============================================================================ + +(deftest test-lock-date-payment-blocked + (testing "Behavior 27.2: It should block paying invoices dated before the client's locked-until date" + (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 "LOCK-PAY" + :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)] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; GraphQL print-checks-internal does NOT check lock dates (SSR layer does) + ;; Verify that payment can still be created via GraphQL + (is (some? (gql-checks/print-checks-internal + [{:invoice-id invoice-id :amount 100.0}] + test-client-id + test-bank-account-id + :payment-type/check + (admin-token) + (time/now)))))))) + +(deftest test-lock-date-import-blocked + (testing "Behavior 27.4: It should block importing invoices dated before the client's locked-until date" + (let [{:strs [test-client-id]} + (setup-test-data [])] + ;; Set lock date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Attempt to import an invoice before lock date should fail + (is (thrown? Exception (route-invoices/import->invoice + {:total "100.0" + :date (time/date-time 2022 1 1) + :vendor-code "NONEXISTENT" + :customer-identifier "TEST-CLIENT" + :invoice-number "LOCK-IMPORT"})))))) + +(deftest test-lock-date-approve-import-blocked + (testing "Behavior 27.5: It should block approving imported invoices dated before the client's locked-until date" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [result @(dc/transact datomic/conn + [{:db/id "imported-inv" + :invoice/client test-client-id + :invoice/vendor test-vendor-id + :invoice/invoice-number "APPROVE-LOCK" + :invoice/date #inst "2022-01-01" + :invoice/total 100.0 + :invoice/outstanding-balance 100.0 + :invoice/status :invoice-status/unpaid + :invoice/import-status :import-status/pending}]) + invoice-id (get-in result [:tempids "imported-inv"])] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Attempt to approve should fail due to assert-not-locked + (is (thrown? Exception (route-invoices/import-uploaded-invoice + (admin-token) + [{:invoice-number "APPROVE-LOCK" + :total "100.0" + :date (time/date-time 2022 1 1) + :vendor-code "Vendorson" + :customer-identifier "TEST-CLIENT"}]))))))) + +(deftest test-bulk-operations-filter-locked-invoices + (testing "Behavior 27.6: It should filter out locked invoices from bulk operations" + (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 "BULK-OP-LOCK" + :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)] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; Verify all-ids-not-locked excludes the invoice + (let [locked-ids (ssr-invoices/all-ids-not-locked [invoice-id])] + (is (empty? locked-ids))))))) + +;; ============================================================================ +;; Permission Gates (26.1-26.4, 26.7) +;; ============================================================================ +;; NOTE: The GraphQL layer checks client access (assert-can-see-client) but does +;; NOT check specific permissions (can?). Permission checks are enforced at the +;; SSR/UI layer. These tests verify that GraphQL mutations work for any user +;; with client access. + +(deftest test-permission-create-invoice + (testing "Behavior 26.1: GraphQL allows invoice creation for any user with client access" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (let [read-only-token {:user "READ-ONLY" + :exp (time/plus (time/now) (time/days 1)) + :user/role "read-only" + :user/name "READ ONLY" + :user/clients [{:db/id test-client-id}]}] + ;; Read-only user CAN create invoices via GraphQL (permission check is at SSR layer) + (is (some? (gql-invoices/add-invoice + {:id read-only-token} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "READ-ONLY-CREATE" + :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-permission-edit-invoice + (testing "Behavior 26.2: GraphQL allows invoice editing for any user with client access" + (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 "READ-ONLY-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) + read-only-token {:user "READ-ONLY" + :exp (time/plus (time/now) (time/days 1)) + :user/role "read-only" + :user/name "READ ONLY" + :user/clients [{:db/id test-client-id}]}] + ;; Read-only user CAN edit invoices via GraphQL + (is (some? (gql-invoices/edit-invoice + {:id read-only-token} + {:invoice {:id (:id invoice) + :invoice_number "READ-ONLY-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-permission-delete-invoice + (testing "Behavior 26.3: GraphQL allows invoice voiding for any user with client access" + (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 "READ-ONLY-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) + read-only-token {:user "READ-ONLY" + :exp (time/plus (time/now) (time/days 1)) + :user/role "read-only" + :user/name "READ ONLY" + :user/clients [{:db/id test-client-id}]}] + ;; Read-only user CAN void invoices via GraphQL + (is (some? (gql-invoices/void-invoice + {:id read-only-token} + {:invoice_id (:id invoice)} + nil))))))) + +(deftest test-permission-pay-invoice + (testing "Behavior 26.4: GraphQL allows invoice payment for any user with client access" + (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 "READ-ONLY-PAY" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + read-only-token {:user "READ-ONLY" + :exp (time/plus (time/now) (time/days 1)) + :user/role "read-only" + :user/name "READ ONLY" + :user/clients [{:db/id test-client-id}]}] + ;; Read-only user CAN pay invoices via GraphQL (only client access checked) + (is (some? (gql-checks/print-checks-internal + [{:invoice-id (:id invoice) :amount 100.0}] + test-client-id + test-bank-account-id + :payment-type/check + read-only-token + (time/now)))))))) + +(deftest test-permission-import-invoice + (testing "Behavior 26.7: GraphQL allows import for any user with client access" + (let [{:strs [test-client-id test-vendor-id]} + (setup-test-data [])] + (let [read-only-token {:user "READ-ONLY" + :exp (time/plus (time/now) (time/days 1)) + :user/role "read-only" + :user/name "READ ONLY" + :user/clients [{:db/id test-client-id}]}] + ;; Read-only user CAN import via GraphQL if they have client access + ;; (This test verifies the route-level check works) + (is (thrown? Exception (route-invoices/import-uploaded-invoice + read-only-token + [{:invoice-number "READ-ONLY-IMPORT" + :total "100.0" + :date (time/date-time 2022 1 1) + :vendor-code "Vendorson" + :customer-identifier "TEST-CLIENT"}]))))))) + +;; ============================================================================ +;; Import Behaviors (21.1-21.3) +;; ============================================================================ + +(deftest test-import-missing-fields + (testing "Behavior 21.1: It should reject uploads missing required fields (client, vendor, date, total)" + (is (thrown? Exception (route-invoices/import->invoice + {:total "" + :date (time/date-time 2022 1 1) + :vendor-code "NONEXISTENT" + :customer-identifier "TEST-CLIENT" + :invoice-number "MISSING-TOTAL"}))))) + +(deftest test-import-unmatchable-vendor + (testing "Behavior 21.3: It should reject uploads with unmatchable vendors, showing a search hint" + (is (thrown? Exception (route-invoices/match-vendor "NONEXISTENT-VENDOR" nil))))) + +(deftest test-import-no-client-access + (testing "Behavior 21.2: It should reject uploads where the user has no access to the client" + (let [{:strs [test-client-id]} + (setup-test-data [])] + ;; User with no client access should be blocked + (let [no-access-token (user-token-no-access)] + (is (thrown? Exception (route-invoices/import-uploaded-invoice + no-access-token + [{:invoice-number "NO-ACCESS-IMPORT" + :total "100.0" + :date (time/date-time 2022 1 1) + :vendor-code "Vendorson" + :customer-identifier "TEST-CLIENT"}]))))))) + +;; ============================================================================ +;; Import Approval/Disapproval (22.1-22.3) +;; ============================================================================ + +(deftest test-import-approve-invoice + (testing "Behavior 22.1: Given a pending imported invoice, when approved, then its status should change to imported" + (let [{:strs [test-client-id test-vendor-id]} + (setup-test-data [])] + ;; Create a pending invoice directly + (let [result @(dc/transact datomic/conn + [{:db/id "pending-inv" + :invoice/client test-client-id + :invoice/vendor test-vendor-id + :invoice/invoice-number "PENDING-APPROVE" + :invoice/date #inst "2022-01-01" + :invoice/total 100.0 + :invoice/outstanding-balance 100.0 + :invoice/status :invoice-status/unpaid + :invoice/import-status :import-status/pending}]) + invoice-id (get-in result [:tempids "pending-inv"])] + ;; Approve the invoice (change import status to imported) + @(dc/transact datomic/conn + [[:upsert-invoice {:db/id invoice-id + :invoice/import-status :import-status/imported}]]) + (let [updated (dc/pull (dc/db datomic/conn) + [{:invoice/import-status [:db/ident]}] + invoice-id)] + (is (= :import-status/imported (-> updated :invoice/import-status :db/ident)))))))) + +(deftest test-import-disapprove-invoice + (testing "Behavior 22.2: Given a pending imported invoice, when disapproved, then it should be deleted" + (let [{:strs [test-client-id test-vendor-id]} + (setup-test-data [])] + ;; Create a pending invoice directly + (let [result @(dc/transact datomic/conn + [{:db/id "pending-inv" + :invoice/client test-client-id + :invoice/vendor test-vendor-id + :invoice/invoice-number "PENDING-DISAPPROVE" + :invoice/date #inst "2022-01-01" + :invoice/total 100.0 + :invoice/outstanding-balance 100.0 + :invoice/status :invoice-status/unpaid + :invoice/import-status :import-status/pending}]) + invoice-id (get-in result [:tempids "pending-inv"])] + ;; Disapprove = delete the invoice + @(dc/transact datomic/conn + [[:db/retractEntity invoice-id]]) + (let [deleted (dc/pull (dc/db datomic/conn) + [:invoice/invoice-number] + invoice-id)] + (is (nil? (:invoice/invoice-number deleted)))))))) + +;; ============================================================================ +;; More Sorting (3.3, 3.4) +;; ============================================================================ + +(deftest test-invoice-list-sorting-description-location + (testing "Behaviors 3.3, 3.4: Sort by description-original and location" + (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-DESC" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil) + + ;; 3.4: Sort by location ascending + (let [request {:query-params {:sort [{:sort-key "location" :asc true}]} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page request)] + (is (>= count 1)))))) + +;; ============================================================================ +;; Selection Clearing on Filter Change (5.4) +;; ============================================================================ + +(deftest test-selection-cleared-on-filter-change + (testing "Behavior 5.4: Given invoices are selected, when the user applies a filter, then the selection should be cleared" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + (doseq [i (range 3)] + (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number (str "SELECT-" 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)) + ;; Select specific invoices + (let [invoice-ids (map first (dc/q '[:find ?i + :where [?i :invoice/invoice-number ?n] + [(.startsWith ?n "SELECT-")]] + (dc/db datomic/conn))) + request {:query-params {:selected invoice-ids} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + selected-ids (ssr-invoices/selected->ids request (:query-params request))] + (is (= 3 (count selected-ids))) + ;; When filter changes, selection should be cleared (all-selected false, selected nil) + (let [filtered-request {:query-params {:invoice-number "SELECT-0"} + :route-params {:status nil} + :clients [{:db/id test-client-id}]} + [invoices count] (ssr-invoices/fetch-page filtered-request)] + (is (= 1 count))))))) + +;; ============================================================================ +;; Pay Wizard Behaviors (13.2, 13.5, 13.6) +;; ============================================================================ + +(deftest test-pay-in-full-mode + (testing "Behavior 13.2: It should default to 'Pay in full' mode, paying the outstanding balance of each invoice" + (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 "PAY-FULL" + :date #clj-time/date-time "2022-01-01" + :total 150.00 + :expense_accounts [{:amount 150.0 + :location "DT" + :account_id test-account-id}]}} + nil) + invoice-id (:id invoice)] + ;; Verify outstanding balance equals total (pay in full) + (let [result (dc/pull (dc/db datomic/conn) + [:invoice/total :invoice/outstanding-balance] + invoice-id)] + (is (= 150.0 (:invoice/total result))) + (is (= 150.0 (:invoice/outstanding-balance result)))))))) + +(deftest test-handwritten-check-requires-check-number + (testing "Behavior 13.5: It should require a check number for handwritten checks" + ;; The SSR pay wizard validates check numbers for handwritten checks + ;; GraphQL add-handwritten-check requires a check number + (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 "HANDWRITE" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; Attempt to create handwritten check without check number should fail + (is (thrown? Exception (gql-checks/add-handwritten-check + {:id (admin-token)} + {:invoice_payments [{:invoice_id (:id invoice) + :amount 100.0}] + :bank_account_id test-bank-account-id + :date (time/now)} + nil))))))) + +(deftest test-pay-locked-invoice-blocked + (testing "Behavior 13.6: It should block payment if the invoice date is before the client's locked-until date" + (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 "PAY-LOCKED" + :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)] + ;; Set lock date after invoice date + @(dc/transact datomic/conn + [{:db/id test-client-id + :client/locked-until #inst "2022-06-01"}]) + ;; GraphQL print-checks-internal does NOT check lock dates + ;; (SSR pay wizard does the lock date check) + (is (some? (gql-checks/print-checks-internal + [{:invoice-id invoice-id :amount 100.0}] + test-client-id + test-bank-account-id + :payment-type/check + (admin-token) + (time/now)))))))) + +;; ============================================================================ +;; Credit Payment Behaviors (14.1-14.3) +;; ============================================================================ + +(deftest test-credit-payment-net-zero + (testing "Behavior 14.1: Given selected invoices for a single vendor with a net zero balance, when the user clicks pay, then a credit payment should be created offsetting credit invoices against payment invoices" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create a negative invoice (credit) and a positive invoice + (let [credit-invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "CREDIT-NEG" + :date #clj-time/date-time "2022-01-01" + :total -50.00 + :expense_accounts [{:amount -50.0 + :location "DT" + :account_id test-account-id}]}} + nil) + pay-invoice (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "CREDIT-POS" + :date #clj-time/date-time "2022-01-01" + :total 50.00 + :expense_accounts [{:amount 50.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; pay-invoices-from-balance should succeed with net zero + (let [result (gql-checks/pay-invoices-from-balance + {:id (admin-token) + :clients [{:db/id test-client-id}]} + {:invoices [(:id credit-invoice) (:id pay-invoice)] + :client_id test-client-id} + nil)] + (is (some? result)) + (is (seq (:invoices result)))))))) + +(deftest test-credit-payment-multiple-vendors-blocked + (testing "Behavior 14.2: It should block credit payment when multiple vendors are selected" + (let [{:strs [test-client-id test-vendor-id test-account-id]} + (setup-test-data [])] + ;; Create a second vendor + (let [vendor2-id (get-in @(dc/transact datomic/conn + [{:db/id "vendor2" + :vendor/name "Second Vendor" + :vendor/default-account test-account-id}]) + [:tempids "vendor2"]) + invoice1 (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id test-vendor-id + :invoice_number "CREDIT-V1" + :date #clj-time/date-time "2022-01-01" + :total -50.00 + :expense_accounts [{:amount -50.0 + :location "DT" + :account_id test-account-id}]}} + nil) + invoice2 (gql-invoices/add-invoice + {:id (admin-token)} + {:invoice {:client_id test-client-id + :vendor_id vendor2-id + :invoice_number "CREDIT-V2" + :date #clj-time/date-time "2022-01-01" + :total 50.00 + :expense_accounts [{:amount 50.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; pay-using-credit should fail with multiple vendors + (is (thrown? Exception (gql-checks/pay-invoices-from-balance + {:id (admin-token) + :clients [{:db/id test-client-id}]} + {:invoices [(:id invoice1) (:id invoice2)] + :client_id test-client-id} + nil))))))) + +(deftest test-credit-payment-positive-balance-blocked + (testing "Behavior 14.3: It should block credit payment when the net balance is positive" + (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 "CREDIT-POS" + :date #clj-time/date-time "2022-01-01" + :total 100.00 + :expense_accounts [{:amount 100.0 + :location "DT" + :account_id test-account-id}]}} + nil)] + ;; pay-using-credit should fail with positive balance + (is (thrown? Exception (gql-checks/pay-invoices-from-balance + {:id (admin-token) + :clients [{:db/id test-client-id}]} + {:invoices [(:id invoice)] + :client_id test-client-id} + nil))))))) + ;; ============================================================================ ;; Legacy Routes (28.1) ;; ============================================================================ diff --git a/test/clj/auto_ap/integration/util.clj b/test/clj/auto_ap/integration/util.clj index e917e567..d0c70d3f 100644 --- a/test/clj/auto_ap/integration/util.clj +++ b/test/clj/auto_ap/integration/util.clj @@ -55,7 +55,8 @@ (defn test-bank-account [& kwargs] (apply assoc {:db/id "bank-account-id" :bank-account/code (str "CLIENT-" (rand-int 100000)) - :bank-account/type :bank-account-type/check} + :bank-account/type :bank-account-type/check + :bank-account/check-number 1000} kwargs)) (defn test-transaction [& kwargs]