4 Commits

Author SHA1 Message Date
ddbb6abc3a test(admin): implement integration and unit tests for admin behaviors
Implement comprehensive test coverage for admin dashboard behaviors:
- Dashboard access control (2.1, 2.2)
- Client filtering by name, code, group (4.1-4.5)
- Client sorting and pagination (5.1-5.3)
- Client wizard validation (6.12, 6.17, 6.18, 6.20)
- Account filtering, sorting, and dialog validation (9.1-11.9)
- Vendor filtering and wizard validation (13.1-14.12)
- Vendor merge validation (15.2, 15.3)
- Transaction rule filtering, wizard, execution, and deletion (17.1-20.3)

Also fixes vendor terms override duplicate validation in vendors.clj.
2026-05-06 23:00:25 -07:00
1a48abdd7c test(invoice): implement integration tests for invoice behaviors
Adds comprehensive integration tests covering:
- Invoice list filtering (vendor, account, date range, due date, amount, import status, scheduled payments, unresolved, location)
- Invoice list sorting (date, invoice number, due date, total, outstanding balance, vendor, client, location)
- Invoice list pagination (default 25, custom per-page)
- Selection behaviors (select all filtered)
- Permission gates (GraphQL layer behavior)
- Lock date behaviors (edit, void, unvoid, undo autopay, bulk operations)
- Single/Bulk void with payment exclusions
- Bulk edit with lock date exclusions
- Credit payment (net zero, multiple vendors blocked, positive balance blocked)
- Import validation (missing fields, unmatchable vendors, no client access)
- Import approve/disapprove
- Legacy route redirects

Updates docs/testing/behaviors/invoice.md with 76 completed behavior markers.

57 tests, 99 assertions, all passing.
2026-05-05 05:00:51 -07:00
ececdc8f5f test(invoice): add integration tests for invoice behaviors
- Fix schema ordering: move :journal-entry-line/running-balance to schema.edn
- Add invoice_behaviors_test.clj covering:
  - Permission gates (26.5, 26.6, 26.8)
  - Lock date blocking (27.1, 27.3)
  - New invoice validation (8.1, 8.5)
  - Edit invoice (11.1, 11.3)
  - Bulk edit (15.4)
  - Single/bulk void (16.3, 16.4, 17.1)
  - Unvoid restoring from history (18.1)
  - Undo autopay (19.1)
  - Invoice list filtering (2.6, 2.8, 2.10, 2.14)
  - Invoice list sorting (3.5, 3.7, 3.10)
  - Invoice list pagination (4.1, 4.3)
  - Legacy route redirects (28.1)
- Mock Solr in wrap-setup fixture to prevent Connection refused
- Fix setup-test-data to merge user-provided entities with defaults
- Fix InMemSolrClient.index_documents to handle entity IDs
- Fix ezcater_xls test to use dynamic entity IDs
- Update invoice.md behavior checklist with completed items
2026-05-04 23:10:46 -07:00
da7897c0d6 test(invoice): implement unit tests for invoice behaviors
Add comprehensive unit tests for pure invoice business logic:
- assert-invoice-amounts-add-up (behaviors 9.4, 11.4)
- does-amount-exceed-outstanding? (behavior 13.4)
- assert-percentages-add-up (behavior 15.3)
- stack-rank and deduplicate (behaviors 24.1, 24.4, 24.5)
- clientize-vendor (behavior 8.4)
- location-select* (behavior 9.3)
- maybe-code-accounts with Shared location spreading (behavior 15.6)
- can-undo-autopayment (behaviors 19.2-19.4)
- due date / scheduled payment calculations (behaviors 8.2, 8.3)
- can-handwrite? and credit-only? (pay wizard behaviors)
- due date display logic (behavior 1.7)

Also fixes:
- user.clj: add missing datomic.api alias (d) used in sample functions
- new_invoice_wizard_test.clj: fix sut8 -> sut9 typo

Marks completed unit-test behaviors with [x] in invoice.md
2026-05-04 21:29:40 -07:00
14 changed files with 4612 additions and 617 deletions

View File

@@ -57,8 +57,8 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 2.1 | It should redirect unauthenticated users to the login page | Integration | [ ] | | 2.1 | It should redirect unauthenticated users to the login page | Integration | [x] |
| 2.2 | It should show an authorization failure for authenticated non-admin users | Integration | [ ] | | 2.2 | It should show an authorization failure for authenticated non-admin users | Integration | [x] |
--- ---
@@ -84,19 +84,19 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 4.1 | It should filter clients by name using case-insensitive substring match | Integration | [ ] | | 4.1 | It should filter clients by name using case-insensitive substring match | Integration | [x] |
| 4.2 | It should filter clients by code using exact match on upper-cased code | Integration | [ ] | | 4.2 | It should filter clients by code using exact match on upper-cased code | Integration | [x] |
| 4.3 | It should filter clients by group using exact match on upper-cased group | Integration | [ ] | | 4.3 | It should filter clients by group using exact match on upper-cased group | Integration | [x] |
| 4.4 | It should support an "All" or "Only mine" filter to show only clients assigned to the current user | Integration | [ ] | | 4.4 | It should support an "All" or "Only mine" filter to show only clients assigned to the current user | Integration | [x] |
| 4.5 | It should trigger HTMX requests with 500ms debounce on filter change and 1000ms debounce on keyup | Integration | [ ] | | 4.5 | It should trigger HTMX requests with 500ms debounce on filter change and 1000ms debounce on keyup | Integration | [x] |
### Sorting Behaviors ### Sorting Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 5.1 | It should sort clients by name ascending/descending | Integration | [ ] | | 5.1 | It should sort clients by name ascending/descending | Integration | [x] |
| 5.2 | It should sort clients by code ascending/descending | Integration | [ ] | | 5.2 | It should sort clients by code ascending/descending | Integration | [x] |
| 5.3 | It should paginate results with 25 clients per page by default | Integration | [ ] | | 5.3 | It should paginate results with 25 clients per page by default | Integration | [x] |
### Client Wizard Behaviors ### Client Wizard Behaviors
@@ -113,15 +113,15 @@ Every admin operation checks:
| 6.9 | It should allow adding cash accounts with nickname, code, financial code, start date, include-in-reports, and visible-for-payment fields | UI | [ ] | | 6.9 | It should allow adding cash accounts with nickname, code, financial code, start date, include-in-reports, and visible-for-payment fields | UI | [ ] |
| 6.10 | It should allow adding credit card accounts with bank name, account number, and Plaid/Yodlee/Intuit integration selectors | UI | [ ] | | 6.10 | It should allow adding credit card accounts with bank name, account number, and Plaid/Yodlee/Intuit integration selectors | UI | [ ] |
| 6.11 | It should allow adding checking accounts with routing number, bank code, and check number fields | UI | [ ] | | 6.11 | It should allow adding checking accounts with routing number, bank code, and check number fields | UI | [ ] |
| 6.12 | It should require a financial code when "Include in Reports" is enabled for a bank account | Unit + Integration | [ ] | | 6.12 | It should require a financial code when "Include in Reports" is enabled for a bank account | Unit + Integration | [x] |
| 6.13 | It should allow entering a Square auth token and mapping Square locations to client locations on the Integrations step | UI | [ ] | | 6.13 | It should allow entering a Square auth token and mapping Square locations to client locations on the Integrations step | UI | [ ] |
| 6.14 | It should show "No locations found" when the Square location refresh times out after 2 seconds | Integration | [ ] | | 6.14 | It should show "No locations found" when the Square location refresh times out after 2 seconds | Integration | [ ] |
| 6.15 | It should allow entering Week A/B credits and debits on the Cash Flow step | UI | [ ] | | 6.15 | It should allow entering Week A/B credits and debits on the Cash Flow step | UI | [ ] |
| 6.16 | It should allow selecting feature flags and entering groups on the Other Settings step | UI | [ ] | | 6.16 | It should allow selecting feature flags and entering groups on the Other Settings step | UI | [ ] |
| 6.17 | It should validate that the client code is unique when creating a new client | Unit + Integration | [ ] | | 6.17 | It should validate that the client code is unique when creating a new client | Unit + Integration | [x] |
| 6.18 | It should upper-case group values on save | Unit | [ ] | | 6.18 | It should upper-case group values on save | Unit | [x] |
| 6.19 | It should flash the updated row in the grid and close the modal after a successful save | UI | [ ] | | 6.19 | It should flash the updated row in the grid and close the modal after a successful save | UI | [ ] |
| 6.20 | It should reindex the client in Solr after a successful save | Integration | [ ] | | 6.20 | It should reindex the client in Solr after a successful save | Integration | [x] |
### Biweekly Sales PowerQuery Behaviors ### Biweekly Sales PowerQuery Behaviors
@@ -147,30 +147,30 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 9.1 | It should filter accounts by name using case-insensitive substring match on upper-cased name | Integration | [ ] | | 9.1 | It should filter accounts by name using case-insensitive substring match on upper-cased name | Integration | [x] |
| 9.2 | It should filter accounts by code using exact numeric match | Integration | [ ] | | 9.2 | It should filter accounts by code using exact numeric match | Integration | [x] |
| 9.3 | It should filter accounts by type: All, Dividend, Asset, Equity, Liability, Expense, Revenue, or None | Integration | [ ] | | 9.3 | It should filter accounts by type: All, Dividend, Asset, Equity, Liability, Expense, Revenue, or None | Integration | [x] |
### Sorting Behaviors ### Sorting Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 10.1 | It should sort accounts by code, name, type, or location ascending/descending | Integration | [ ] | | 10.1 | It should sort accounts by code, name, type, or location ascending/descending | Integration | [x] |
| 10.2 | It should default sort by upper-cased numeric code | Integration | [ ] | | 10.2 | It should default sort by upper-cased numeric code | Integration | [x] |
### Account Dialog Behaviors ### Account Dialog Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 11.1 | It should show a modal dialog with a live-updating header displaying the numeric code and name | UI | [ ] | | 11.1 | It should show a modal dialog with a live-updating header displaying the numeric code and name | UI | [ ] |
| 11.2 | It should require a numeric code when creating a new account | Integration | [ ] | | 11.2 | It should require a numeric code when creating a new account | Integration | [x] |
| 11.3 | It should hide the numeric code field when editing an existing account | UI | [ ] | | 11.3 | It should hide the numeric code field when editing an existing account | UI | [ ] |
| 11.4 | It should require a name and account type | Integration | [ ] | | 11.4 | It should require a name and account type | Integration | [x] |
| 11.5 | It should allow setting Invoice Allowance, Vendor Allowance, and Applicability as dropdown enums | UI | [ ] | | 11.5 | It should allow setting Invoice Allowance, Vendor Allowance, and Applicability as dropdown enums | UI | [ ] |
| 11.6 | It should show a Client Overrides grid with client typeahead and override name | UI | [ ] | | 11.6 | It should show a Client Overrides grid with client typeahead and override name | UI | [ ] |
| 11.7 | It should validate that no client appears more than once in the Client Overrides grid | Unit + Integration | [ ] | | 11.7 | It should validate that no client appears more than once in the Client Overrides grid | Unit + Integration | [x] |
| 11.8 | It should validate that the numeric code is unique when creating a new account | Unit + Integration | [ ] | | 11.8 | It should validate that the numeric code is unique when creating a new account | Unit + Integration | [x] |
| 11.9 | It should reindex the account and all client overrides in Solr after a successful save | Integration | [ ] | | 11.9 | It should reindex the account and all client overrides in Solr after a successful save | Integration | [x] |
--- ---
@@ -192,33 +192,33 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 13.1 | It should filter vendors by name using case-insensitive substring match on upper-cased name | Integration | [ ] | | 13.1 | It should filter vendors by name using case-insensitive substring match on upper-cased name | Integration | [x] |
| 13.2 | It should filter vendors by visibility: All, Only hidden, or Only global | Integration | [ ] | | 13.2 | It should filter vendors by visibility: All, Only hidden, or Only global | Integration | [x] |
### Vendor Wizard Behaviors ### Vendor Wizard Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 14.1 | It should show a multi-step wizard with steps: Info, Terms, Account, Address, Legal | UI | [ ] | | 14.1 | It should show a multi-step wizard with steps: Info, Terms, Account, Address, Legal | UI | [ ] |
| 14.2 | It should require a name of at least 3 characters on the Info step | Unit + Integration | [ ] | | 14.2 | It should require a name of at least 3 characters on the Info step | Unit + Integration | [x] |
| 14.3 | It should allow toggling a "Print As" alias on the Info step | UI | [ ] | | 14.3 | It should allow toggling a "Print As" alias on the Info step | UI | [ ] |
| 14.4 | It should show a "Hidden" checkbox on the Info step visible only to admins | UI | [ ] | | 14.4 | It should show a "Hidden" checkbox on the Info step visible only to admins | UI | [ ] |
| 14.5 | It should allow setting terms in days and a grid of client-specific terms overrides on the Terms step | UI | [ ] | | 14.5 | It should allow setting terms in days and a grid of client-specific terms overrides on the Terms step | UI | [ ] |
| 14.6 | It should allow configuring a list of clients for automatically paid when due on the Terms step | UI | [ ] | | 14.6 | It should allow configuring a list of clients for automatically paid when due on the Terms step | UI | [ ] |
| 14.7 | It should allow selecting a default account via typeahead on the Account step | UI | [ ] | | 14.7 | It should allow selecting a default account via typeahead on the Account step | UI | [ ] |
| 14.8 | It should show an Account Overrides grid where account typeahead is scoped by selected client | Integration | [ ] | | 14.8 | It should show an Account Overrides grid where account typeahead is scoped by selected client | Integration | [x] |
| 14.9 | It should allow entering address fields with a 2-character state and 5-character zip on the Address step | UI | [ ] | | 14.9 | It should allow entering address fields with a 2-character state and 5-character zip on the Address step | UI | [ ] |
| 14.10 | It should allow entering a legal entity name OR first/middle/last name, TIN, TIN type, and 1099 type on the Legal step | UI | [ ] | | 14.10 | It should allow entering a legal entity name OR first/middle/last name, TIN, TIN type, and 1099 type on the Legal step | UI | [ ] |
| 14.11 | It should validate that terms override clients are unique with no duplicates | Unit + Integration | [ ] | | 14.11 | It should validate that terms override clients are unique with no duplicates | Unit + Integration | [x] |
| 14.12 | It should reindex the vendor name and hidden flag in Solr after a successful save | Integration | [ ] | | 14.12 | It should reindex the vendor name and hidden flag in Solr after a successful save | Integration | [x] |
### Vendor Merge Behaviors ### Vendor Merge Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 15.1 | It should open a modal with Source Vendor and Target Vendor selectors | UI | [ ] | | 15.1 | It should open a modal with Source Vendor and Target Vendor selectors | UI | [ ] |
| 15.2 | It should validate that the source and target vendors are different | Unit + Integration | [ ] | | 15.2 | It should validate that the source and target vendors are different | Unit + Integration | [x] |
| 15.3 | It should retract all references to the source vendor and assert them as the target vendor on merge | Integration | [ ] | | 15.3 | It should retract all references to the source vendor and assert them as the target vendor on merge | Integration | [x] |
| 15.4 | It should show a success notification after a successful merge | UI | [ ] | | 15.4 | It should show a success notification after a successful merge | UI | [ ] |
--- ---
@@ -239,25 +239,25 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 17.1 | It should filter rules by vendor using an entity typeahead | Integration | [ ] | | 17.1 | It should filter rules by vendor using an entity typeahead | Integration | [x] |
| 17.2 | It should filter rules by note using case-insensitive regex match | Integration | [ ] | | 17.2 | It should filter rules by note using case-insensitive regex match | Integration | [x] |
| 17.3 | It should filter rules by description using case-insensitive substring match | Integration | [ ] | | 17.3 | It should filter rules by description using case-insensitive substring match | Integration | [x] |
| 17.4 | It should filter rules by client group using exact upper-cased match | Integration | [ ] | | 17.4 | It should filter rules by client group using exact upper-cased match | Integration | [x] |
### Transaction Rule Wizard Behaviors ### Transaction Rule Wizard Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 18.1 | It should show a two-step wizard: Edit then Test | UI | [ ] | | 18.1 | It should show a two-step wizard: Edit then Test | UI | [ ] |
| 18.2 | It should require a description regex pattern of at least 3 characters on the Edit step | Unit + Integration | [ ] | | 18.2 | It should require a description regex pattern of at least 3 characters on the Edit step | Unit + Integration | [x] |
| 18.3 | It should allow toggling optional filters for Client, Client Group, Bank Account, Amount range, and Day of Month range | UI | [ ] | | 18.3 | It should allow toggling optional filters for Client, Client Group, Bank Account, Amount range, and Day of Month range | UI | [ ] |
| 18.4 | It should scope the bank account selector to the selected client | Integration | [ ] | | 18.4 | It should scope the bank account selector to the selected client | Integration | [x] |
| 18.5 | It should allow assigning a vendor, configuring account grids, and setting approval status as outcomes | UI | [ ] | | 18.5 | It should allow assigning a vendor, configuring account grids, and setting approval status as outcomes | UI | [ ] |
| 18.6 | It should derive account location from the account's fixed location, client locations, or "Shared" | Unit | [ ] | | 18.6 | It should derive account location from the account's fixed location, client locations, or "Shared" | Unit | [x] |
| 18.7 | It should validate that account percentages sum to exactly 100% | Unit + Integration | [ ] | | 18.7 | It should validate that account percentages sum to exactly 100% | Unit + Integration | [x] |
| 18.8 | It should validate that the selected bank account belongs to the selected client | Unit + Integration | [ ] | | 18.8 | It should validate that the selected bank account belongs to the selected client | Unit + Integration | [x] |
| 18.9 | It should validate that the rule location matches the account's fixed location when one is set | Unit + Integration | [ ] | | 18.9 | It should validate that the rule location matches the account's fixed location when one is set | Unit + Integration | [x] |
| 18.10 | It should show up to 15 matching transactions on the Test step with client, bank, date, and description | Integration | [ ] | | 18.10 | It should show up to 15 matching transactions on the Test step with client, bank, date, and description | Integration | [x] |
| 18.11 | It should display a badge showing the total match count with "99+" when 99 or more transactions match | UI | [ ] | | 18.11 | It should display a badge showing the total match count with "99+" when 99 or more transactions match | UI | [ ] |
### Rule Execution Behaviors ### Rule Execution Behaviors
@@ -265,10 +265,10 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 19.1 | It should open a dialog with checkbox-selectable transactions that match the rule and are unapproved | UI | [ ] | | 19.1 | It should open a dialog with checkbox-selectable transactions that match the rule and are unapproved | UI | [ ] |
| 19.2 | It should include only transactions on or after the client's locked-until date | Integration | [ ] | | 19.2 | It should include only transactions on or after the client's locked-until date | Integration | [x] |
| 19.3 | It should allow selecting all matching transactions or individual transactions | UI | [ ] | | 19.3 | It should allow selecting all matching transactions or individual transactions | UI | [ ] |
| 19.4 | It should apply rule coding to each selected transaction | Integration | [ ] | | 19.4 | It should apply rule coding to each selected transaction | Integration | [x] |
| 19.5 | It should update the Solr index after rule execution | Integration | [ ] | | 19.5 | It should update the Solr index after rule execution | Integration | [x] |
| 19.6 | It should show a notification reading "Successfully coded X of Y transactions!" after execution | UI | [ ] | | 19.6 | It should show a notification reading "Successfully coded X of Y transactions!" after execution | UI | [ ] |
### Rule Deletion Behaviors ### Rule Deletion Behaviors
@@ -276,7 +276,7 @@ Every admin operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 20.1 | It should show a confirmation dialog before deleting a rule | UI | [ ] | | 20.1 | It should show a confirmation dialog before deleting a rule | UI | [ ] |
| 20.2 | It should retract the rule entity from the database on confirmation | Integration | [ ] | | 20.2 | It should retract the rule entity from the database on confirmation | Integration | [x] |
| 20.3 | It should fade out the row with a "live-removed" animation after deletion | UI | [ ] | | 20.3 | It should fade out the row with a "live-removed" animation after deletion | UI | [ ] |
--- ---

View File

@@ -50,57 +50,57 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 1.1 | It should display a table with columns: Client, Vendor, Invoice #, Date, Due, Status, Account, Outstanding, Links | UI | [ ] | | 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.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.4 | It should show "Voided" status as a red pill | UI | [ ] |
| 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] | | 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] |
| 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] | | 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] |
| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [ ] | | 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [x] |
| 1.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] | | 1.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.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 ### Filtering Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 2.1 | It should filter invoices by vendor typeahead selection | 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 | [ ] | | 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 | [ ] | | 2.3 | It should filter invoices by date range (invoice date) | Integration | [x] |
| 2.4 | It should filter invoices by due date range | Integration | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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.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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 ### Sorting Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 3.1 | It should sort by client name 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 | [ ] | | 3.2 | It should sort by vendor name ascending/descending | Integration | [x] |
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] | | 3.3 | It should sort by description original ascending/descending | Integration | [x] |
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] | | 3.4 | It should sort by expense account location ascending/descending | Integration | [x] |
| 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.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 | [ ] | | 3.7 | It should sort by invoice number ascending/descending | Integration | [x] |
| 3.8 | It should sort by total amount 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 | [ ] | | 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 | [ ] | | 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] |
### Pagination Behaviors ### Pagination Behaviors
| # | Behavior | Test Strategy | Status | | # | 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.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 | [ ] | | 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 ### Selection Behaviors
@@ -108,8 +108,8 @@ Every mutating operation checks:
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 5.1 | It should allow selecting individual invoices via checkboxes | UI | [ ] | | 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.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.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 | [ ] | | 5.4 | Given invoices are selected, when the user applies a filter, then the selection should be cleared | Integration | [x] |
### Row Action Behaviors ### 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.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.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.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 ### Pay Button Behaviors
@@ -140,11 +140,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
### Expense Accounts Step ### Expense Accounts Step
@@ -153,8 +153,8 @@ Every mutating operation checks:
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 9.1 | It should allow adding multiple expense account rows | UI | [ ] | | 9.1 | It should allow adding multiple expense account rows | UI | [ ] |
| 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] | | 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] |
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [ ] | | 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [x] |
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [ ] | | 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [x] |
| 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] | | 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] |
| 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] | | 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] |
| 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] | | 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] |
@@ -174,12 +174,12 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | 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.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 | [ ] | | 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.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] |
--- ---
@@ -200,20 +200,20 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 13.1 | It should display a grid of selected invoices with vendor, number, total, and pay amount | UI | [ ] | | 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.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] |
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [ ] | | 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [x] |
| 13.5 | It should require a check number for handwritten checks | Integration | [ ] | | 13.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 | [ ] | | 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 | [ ] | | 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] |
### Credit Payment ### Credit Payment
| # | Behavior | Test Strategy | Status | | # | 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.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 | [ ] | | 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 | [ ] | | 14.3 | It should block credit payment when the net balance is positive | Integration | [x] |
--- ---
@@ -223,10 +223,10 @@ Every mutating operation checks:
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] | | 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.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [ ] | | 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [x] |
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] | | 15.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 | [ ] | | 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] |
--- ---
@@ -235,11 +235,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] | | 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.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 | [ ] | | 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 | [ ] | | 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.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 | [ ] | | 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 | [ ] | | 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 | | # | 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.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 | [ ] | | 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 | [ ] | | 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
--- ---
@@ -259,9 +259,9 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | 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.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 | [ ] | | 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 | [ ] | | 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
--- ---
@@ -270,11 +270,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 21.1 | It should reject uploads missing required fields (client, vendor, date, total) | 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 | [ ] | | 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 | [ ] | | 21.3 | It should reject uploads with unmatchable vendors, showing a search hint | Integration | [x] |
### Approve/Disapprove Behaviors ### Approve/Disapprove Behaviors
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 22.1 | Given a pending imported invoice, when approved, then its status should change to imported | 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 | [ ] | | 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 | [ ] | | 22.3 | It should support bulk approve/disapprove with selection | Integration | [x] |
--- ---
@@ -324,11 +324,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [ ] | | 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [x] |
| 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] | | 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] |
| 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] | | 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] |
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [ ] | | 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [x] |
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [ ] | | 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [x] |
### Form Behaviors ### Form Behaviors
@@ -347,32 +347,32 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status | | # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------| |---|----------|---------------|--------|
| 26.1 | It should block invoice creation for users without `:create` 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] |
### Lock Date Behaviors ### Lock Date Behaviors
| # | Behavior | Test Strategy | Status | | # | 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.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 | [ ] | | 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.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 | [ ] | | 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 | [ ] | | 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 | [ ] | | 27.7 | It should show a warning when some selected invoices are locked | UI | [ ] |
### Legacy Route Behaviors ### Legacy Route Behaviors
| # | Behavior | Test Strategy | Status | | # | 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] |
--- ---

View File

@@ -108,7 +108,11 @@
"url": "https://mcp.context7.com/mcp", "url": "https://mcp.context7.com/mcp",
"enabled": true "enabled": true
}, },
"clojure-mcp": {
"type": "local",
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
"enabled": true
}
}, },
"permission": { "permission": {
"read": "allow", "read": "allow",

View File

@@ -1,10 +1,4 @@
[{:db/valueType :db.type/double, [{:db/valueType :db.type/boolean,
: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/cardinality :db.cardinality/one, :db/cardinality :db.cardinality/one,
:db/noHistory true :db/noHistory true
:db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances", :db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances",

View File

@@ -905,6 +905,11 @@
:db/cardinality #:db{:ident :db.cardinality/one}, :db/cardinality #:db{:ident :db.cardinality/one},
:db/doc "The client for the journal entry line", :db/doc "The client for the journal entry line",
:db/ident :journal-entry-line/client} :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/valueType :db.type/tuple
:db/tupleAttrs [:journal-entry-line/client :db/tupleAttrs [:journal-entry-line/client
:journal-entry-line/account :journal-entry-line/account

View File

@@ -213,8 +213,9 @@
(fn [data-set] (fn [data-set]
(reduce (reduce
(fn [data-set x] (fn [data-set x]
(let [thing (datomic->solr x)] (if-let [thing (datomic->solr x)]
(update data-set index conj [(str/join " " (vals x)) thing]))) (update data-set index conj [(str/join " " (map str (vals thing))) thing])
data-set))
data-set data-set
xs))) xs)))
nil) nil)

View File

@@ -41,7 +41,7 @@
(into [:map {} (into [:map {}
[:name {:optional true :default nil} [:maybe [:string {:string/decode strip}]]] [:name {:optional true :default nil} [:maybe [:string {:string/decode strip}]]]
#_[:role {:optional true} [:maybe (ref->enum-schema "user-role")]] #_[:role {:optional true} [:maybe (ref->enum-schema "user-role")]]
#_[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]] ] #_[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
default-grid-fields-schema)])) default-grid-fields-schema)]))
(defn filters [request] (defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms" [:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
@@ -203,8 +203,6 @@
(def row* (partial helper/row* grid-page)) (def row* (partial helper/row* grid-page))
(def table* (partial helper/table* grid-page)) (def table* (partial helper/table* grid-page))
(defn merge-submit [{:keys [form-params request-method identity] :as request}] (defn merge-submit [{:keys [form-params request-method identity] :as request}]
(if (= (:source-vendor form-params) (if (= (:source-vendor form-params)
(:target-vendor form-params)) (:target-vendor form-params))
@@ -245,7 +243,6 @@
(= i (dec (count steps))) (assoc :last? true)) (= i (dec (count steps))) (assoc :last? true))
n))))) n)))))
;; TODO add plaid merchant ;; TODO add plaid merchant
;; TODO each client only used once ;; TODO each client only used once
@@ -285,7 +282,6 @@
(com/data-grid-cell {:class "align-top"} (com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn automatically-paid-when-due-row [terms-override-cursor] (defn automatically-paid-when-due-row [terms-override-cursor]
(com/data-grid-row (com/data-grid-row
(-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? terms-override-cursor))))}) (-> {:x-data (hx/json {:show (boolean (not (fc/field-value (:new? terms-override-cursor))))})
@@ -303,15 +299,12 @@
:value (fc/field-value) :value (fc/field-value)
:value-fn :db/id :value-fn :db/id
:content-fn #(pull-attr (dc/db conn) :client/name (:db/id %)) :content-fn #(pull-attr (dc/db conn) :client/name (:db/id %))
:size :small}))) :size :small})))
(com/data-grid-cell {:class "align-top"} (com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))) (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))
(defn- account-typeahead* (defn- account-typeahead*
[{:keys [name value client-id x-model]}] [{:keys [name value client-id x-model]}]
[:div.flex.flex-col [:div.flex.flex-col
@@ -370,12 +363,6 @@
(com/data-grid-cell {:class "align-top"} (com/data-grid-cell {:class "align-top"}
(com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x)))))) (com/a-icon-button {"@click.prevent.stop" "show=false; setTimeout(() => $refs.p.remove(), 500)"} svg/x))))))
(defn dialog* [{:keys [entity form-params form-errors] :as params}] (defn dialog* [{:keys [entity form-params form-errors] :as params}]
(alog/peek ::dialog-entity form-params) (alog/peek ::dialog-entity form-params)
(fc/start-form form-params form-errors (fc/start-form form-params form-errors
@@ -847,6 +834,21 @@
mt/strip-extra-keys-transformer) mt/strip-extra-keys-transformer)
entity (cond-> snapshot entity (cond-> snapshot
(= :post request-method) (assoc :db/id "new")) (= :post request-method) (assoc :db/id "new"))
_ (some->> snapshot
:vendor/terms-overrides
(group-by :vendor-terms-override/client)
(filter (fn [[_ overrides]]
(> (count overrides) 1)))
(map first)
seq
(#(form-validation-error (format "Client(s) %s have more than one terms override."
(str/join ", "
(map (fn [client]
(format "'%s'" (pull-attr (dc/db conn)
:client/name
(-> client))))
%)))
:form-params (:snapshot multi-form-state))))
{:keys [tempids]} (audit-transact [[:upsert-entity entity]] {:keys [tempids]} (audit-transact [[:upsert-entity entity]]
(:identity request)) (:identity request))
updated-vendor (dc/pull (dc/db conn) updated-vendor (dc/pull (dc/db conn)
@@ -868,7 +870,6 @@
(def vendor-wizard (->VendorWizard :info)) (def vendor-wizard (->VendorWizard :info))
(def key->handler (def key->handler
(apply-middleware-to-all-handlers (apply-middleware-to-all-handlers
(->> (->>
@@ -921,7 +922,7 @@
(fn [cursor _] (account-override-row cursor)))}) (fn [cursor _] (account-override-row cursor)))})
(fn [h] (fn [h]
(-> h (-> h
(wrap-copy-qp-pqp) (wrap-copy-qp-pqp)
(wrap-apply-sort grid-page) (wrap-apply-sort grid-page)
(wrap-merge-prior-hx) (wrap-merge-prior-hx)
(wrap-schema-enforce :query-schema query-schema) (wrap-schema-enforce :query-schema query-schema)

View File

@@ -19,8 +19,9 @@
[com.brunobonacci.mulog.buffer :as rb] [com.brunobonacci.mulog.buffer :as rb]
[config.core :refer [env]] [config.core :refer [env]]
[datomic.api :as dc] [datomic.api :as dc]
[puget.printer :as puget]
[datomic.api :as d] [datomic.api :as d]
[puget.printer :as puget]
[figwheel.main.api] [figwheel.main.api]
[hawk.core] [hawk.core]
[mount.core :as mount] [mount.core :as mount]
@@ -44,8 +45,7 @@
item item
:user))) :user)))
(when (= :auto-ap.logging/peek (:mulog/event-name item)) (when (= :auto-ap.logging/peek (:mulog/event-name item))
(println "\u001B[31mTEST") (println "\u001B[31mTEST"))
)
(when (:error item) (when (:error item)
(println (:error item))) (println (:error item)))
(puget/cprint (reduce (puget/cprint (reduce
@@ -58,18 +58,15 @@
{:seq-limit 10}) {:seq-limit 10})
(println)) (println))
(deftype DevPublisher [config buffer transform] (deftype DevPublisher [config buffer transform]
com.brunobonacci.mulog.publisher.PPublisher com.brunobonacci.mulog.publisher.PPublisher
(agent-buffer [_] (agent-buffer [_]
buffer) buffer)
(publish-delay [_] (publish-delay [_]
200) 200)
(publish [_ buffer] (publish [_ buffer]
;; items are pairs [offset <item>] ;; items are pairs [offset <item>]
(doseq [item (transform (map second (rb/items buffer)))] (doseq [item (transform (map second (rb/items buffer)))]
@@ -77,8 +74,6 @@
(flush) (flush)
(rb/clear buffer))) (rb/clear buffer)))
(defn dev-publisher (defn dev-publisher
[{:keys [transform pretty?] :as config}] [{:keys [transform pretty?] :as config}]
(DevPublisher. config (rb/agent-buffer 10000) (or transform identity))) (DevPublisher. config (rb/agent-buffer 10000) (or transform identity)))
@@ -87,8 +82,6 @@
[config] [config]
(dev-publisher config)) (dev-publisher config))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn load-accounts [conn] (defn load-accounts [conn]
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv) (let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
@@ -161,7 +154,6 @@
(also-merge-txes also-merge old-account-id)) (also-merge-txes also-merge old-account-id))
tx))))) tx)))))
conj conj
[] []
rows)] rows)]
@@ -192,7 +184,6 @@
'[(<= ?z 9999)]]} '[(<= ?z 9999)]]}
(dc/db conn))))) (dc/db conn)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-conflicting-accounts [] (defn find-conflicting-accounts []
(filter (filter
@@ -226,8 +217,6 @@
:where [['?e :account-client-override/client '?client-id]]} :where [['?e :account-client-override/client '?client-id]]}
(dc/db conn) client-id) (dc/db conn) client-id)
_ (when-let [bad-rows (seq (->> rows _ (when-let [bad-rows (seq (->> rows
(group-by (fn [[_ account]] (group-by (fn [[_ account]]
account)) account))
@@ -285,7 +274,6 @@
txes txes
#_@(d/transact conn txes))) #_@(d/transact conn txes)))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn fix-transactions-without-locations [client-code location] (defn fix-transactions-without-locations [client-code location]
(->> (->>
@@ -307,7 +295,6 @@
accounts))) accounts)))
vec)) vec))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn entity-history [i] (defn entity-history [i]
(vec (sort-by first (dc/q (vec (sort-by first (dc/q
@@ -342,13 +329,11 @@
{:start (- i 100) {:start (- i 100)
:end (+ i 100)})))) :end (+ i 100)}))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn start-db [] (defn start-db []
(mu/start-publisher! {:type :dev}) (mu/start-publisher! {:type :dev})
(mount.core/start (mount.core/only #{#'auto-ap.datomic/conn}))) (mount.core/start (mount.core/only #{#'auto-ap.datomic/conn})))
(defn- auto-reset-handler [ctx event] (defn- auto-reset-handler [ctx event]
(require 'figwheel.main.api) (require 'figwheel.main.api)
(binding [*ns* *ns*] (binding [*ns* *ns*]
@@ -363,11 +348,9 @@
(hawk.core/watch! [{:paths ["src/" "test/"] (hawk.core/watch! [{:paths ["src/" "test/"]
:handler auto-reset-handler}])) :handler auto-reset-handler}]))
(defn start-http [] (defn start-http []
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty}))) (mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
(defn start-dev [] (defn start-dev []
(set-refresh-dirs "src") (set-refresh-dirs "src")
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server)) #_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
@@ -392,7 +375,6 @@
(for [r data] (for [r data]
((apply juxt columns) r))))) ((apply juxt columns) r)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-queries [words] (defn find-queries [words]
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env) (let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
@@ -418,7 +400,6 @@
(println "failed " e))) (println "failed " e)))
(async/<!! (async/into [] output-chan)))) (async/<!! (async/into [] output-chan))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn upsert-invoice-amounts [tsv] (defn upsert-invoice-amounts [tsv]
(let [data (with-open [reader (io/reader (char-array tsv))] (let [data (with-open [reader (io/reader (char-array tsv))]
@@ -460,15 +441,12 @@
target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date)) target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date))
current-date (:invoice/date invoice) current-date (:invoice/date invoice)
current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0) current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0)
target-expense-account-amount (- (Double/parseDouble amount)) target-expense-account-amount (- (Double/parseDouble amount))
current-expense-account-location (:invoice-expense-account/location invoice-expense-account) current-expense-account-location (:invoice-expense-account/location invoice-expense-account)
target-expense-account-location location target-expense-account-location location
[[_ _ invoice-payment]] (vec (dc/q [[_ _ invoice-payment]] (vec (dc/q
'[:find ?p ?a ?ip '[:find ?p ?a ?ip
:in $ ?i :in $ ?i
@@ -512,7 +490,6 @@
(filter identity) (filter identity)
vec))) vec)))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn get-schema [prefix] (defn get-schema [prefix]
(->> (dc/q '[:find ?i (->> (dc/q '[:find ?i
@@ -537,7 +514,6 @@
(defn init-repl [] (defn init-repl []
(set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint)) (set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn sample-ledger-import (defn sample-ledger-import
([client-code] ([client-code]
@@ -564,7 +540,6 @@
a) a)
:separator \tab)))) :separator \tab))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]} #_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn sample-manual-yodlee (defn sample-manual-yodlee
([client-code] ([client-code]
@@ -582,8 +557,6 @@
["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code]) ["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code])
:separator \tab)))) :separator \tab))))
(defn index-solr (defn index-solr
[] []
(println "invoice") (println "invoice")
@@ -643,4 +616,3 @@
(print ".") (print ".")
@(dc/transact auto-ap.datomic/conn n))) @(dc/transact auto-ap.datomic/conn n)))

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -34,18 +34,18 @@
:client test-client :client test-client
:location "DT" :location "DT"
:external-id :external-id
"ezcater/charge/17592186045501-DT-ZA2-320-0" (str "ezcater/charge/" test-client "-DT-ZA2-320-0")
:processor :ccp-processor/ezcater :processor :ccp-processor/ezcater
:total 516.12 :total 516.12
:tip 0.0}] :tip 0.0}]
:client test-client :client test-client
:tip 0.0 :tip 0.0
:tax 37.12 :tax 37.12
:external-id "ezcater/order/17592186045501-DT-ZA2-320" :external-id (str "ezcater/order/" test-client "-DT-ZA2-320")
:total 516.12 :total 516.12
:line-items :line-items
[#:order-line-item{:external-id [#:order-line-item{:external-id
"ezcater/order/17592186045501-DT-ZA2-320-0" (str "ezcater/order/" test-client "-DT-ZA2-320-0")
:item-name "EZCater Catering" :item-name "EZCater Catering"
:category "EZCater Catering" :category "EZCater Catering"
:discount 0.0 :discount 0.0

View File

@@ -5,7 +5,8 @@
(defn wrap-setup (defn wrap-setup
[f] [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) (dc/create-database auto-ap.datomic/uri)
(with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)] (with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)]
(transact-schema conn) (transact-schema conn)
@@ -28,6 +29,13 @@
:user/name "TEST USER" :user/name "TEST USER"
:user/clients [{:db/id client-id}]})) :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 []})
@@ -47,7 +55,8 @@
(defn test-bank-account [& kwargs] (defn test-bank-account [& kwargs]
(apply assoc {:db/id "bank-account-id" (apply assoc {:db/id "bank-account-id"
:bank-account/code (str "CLIENT-" (rand-int 100000)) :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)) kwargs))
(defn test-transaction [& kwargs] (defn test-transaction [& kwargs]
@@ -101,8 +110,7 @@
(dissoc x :id)) (dissoc x :id))
(defn setup-test-data [data] (defn setup-test-data [data]
(:tempids @(dc/transact conn (into data (let [defaults [(test-account :db/id "test-account-id")
[(test-account :db/id "test-account-id")
(test-client :db/id "test-client-id" (test-client :db/id "test-client-id"
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")]) :client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
(test-vendor :db/id "test-vendor-id") (test-vendor :db/id "test-vendor-id")
@@ -110,7 +118,10 @@
:account/name "Accounts Payable" :account/name "Accounts Payable"
:db/ident :account/accounts-payable :db/ident :account/accounts-payable
:account/numeric-code 21000 :account/numeric-code 21000
:account/account-set "default"}])))) :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] (defn apply-tx [data]
(:db-after @(dc/transact conn data))) (:db-after @(dc/transact conn data)))

View File

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

View File

@@ -2,7 +2,6 @@
(:require [clojure.test :refer [deftest testing is]] (:require [clojure.test :refer [deftest testing is]]
[auto-ap.ssr.invoice.new-invoice-wizard :as sut9])) [auto-ap.ssr.invoice.new-invoice-wizard :as sut9]))
(deftest maybe-spread-locations-test (deftest maybe-spread-locations-test
(testing "Shared amount correctly spread across multiple locations" (testing "Shared amount correctly spread across multiple locations"
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
@@ -30,8 +29,6 @@
:invoice-expense-account/location "Location 2"}] :invoice-expense-account/location "Location 2"}]
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result)))))) (map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
(testing "Shared amount correctly spread with leftovers" (testing "Shared amount correctly spread with leftovers"
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0 (let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
:invoice-expense-account/location "Shared"}] :invoice-expense-account/location "Shared"}]
@@ -84,7 +81,7 @@
{:invoice-expense-account/amount -5 {:invoice-expense-account/amount -5
:invoice-expense-account/location "Shared"}] :invoice-expense-account/location "Shared"}]
:invoice/total -101} :invoice/total -101}
result (sut8/maybe-spread-locations invoice ["Location 1" ])] result (sut9/maybe-spread-locations invoice ["Location 1"])]
(is (= (is (=
[{:invoice-expense-account/amount -100.0 [{:invoice-expense-account/amount -100.0
:invoice-expense-account/location "Location 1"} :invoice-expense-account/location "Location 1"}