6 Commits

Author SHA1 Message Date
Bryce
b02aec3546 Move sales summaries from admin to pos menu
- Move SSR handler from auto-ap.ssr.admin.sales-summaries to auto-ap.ssr.pos.sales-summaries
- Move route namespace from auto-ap.routes.admin.sales-summaries to auto-ap.routes.pos.sales-summaries
- Update nav to use main-aside-nav with POS breadcrumbs
- Use pos.common date-range-field* filter component
- Remove wrap-admin/wrap-client-redirect-unauthenticated from middleware
- Add Summaries to Sales sidebar menu
2026-05-16 00:13:42 -07:00
Bryce
5a39a0c762 fixes for sales summaries being automatic. 2026-05-15 23:22:38 -07:00
95f12a6072 refactor: remove dead calc-aggregate-totals and unused schema attributes
The 13 sales-summary/total-* attributes were computed and stored but never
read — the only consumer (get-debits) was commented out. Active display code
computes totals on-the-fly from the items list instead.
2026-05-15 23:22:38 -07:00
0e76506c22 consolidate sales summary ledger entry creation into upsert-sales-summary tx
Move journal entry calculation and creation from the reconcile-ledger
background job into the upsert-sales-summary tx function. Now any save
of a sales summary (job recalculation, admin edit wizard, or manual
touch) automatically creates the journal entry if balanced with all
accounts mapped, or retracts it if conditions no longer hold. Eliminates
the need for a separate upsert-sales-summary-ledger call and the
reconcile ledger pass for sales summaries.
2026-05-15 23:22:38 -07:00
baf8cfff97 feat: complete automatic sales summary calculations and ledger posting 2026-05-15 23:22:38 -07:00
a78c818270 Merge pull request 'docs: comprehensive test behavior documentation for all pages' (#6) from test-plan-docs into master
Reviewed-on: #6
2026-05-04 13:55:03 -07:00
23 changed files with 666 additions and 1394 deletions

View File

@@ -0,0 +1,219 @@
---
title: Complete Automatic Sales Summary Calculations and Ledger Posting
type: feat
status: completed
date: 2026-04-24
---
# Complete Automatic Sales Summary Calculations and Ledger Posting
## What's Incomplete
- **Automatic Totals**: Aggregate attributes (e.g., `:sales-summary/total-card-payments`) are not calculated/stored by the job.
- **Data Persistence**: Automatic recalculations risk overwriting manual user adjustments.
- **Automation Gap**: Ledger entries are currently imported from external Excel files rather than generated automatically from the summaries.
- **UI Polish**: "Clientization" and HTMX context (`client-id`) TODOs remain in the admin interface.
---
## Overview
...
This plan completes the implementation of automatic sales summary calculations and ensures they are correctly posted to the ledger. Currently, the `sales-summaries-v2` job calculates detailed daily summaries, but it doesn't store aggregate totals, preserve manual adjustments, or trigger the creation of actual ledger entries. Additionally, the admin UI has several unresolved TODOs.
---
## Problem Frame
The system currently aggregates raw sales data into a `sales-summary` entity, but the final step—creating balanced journal entries for the general ledger—is a manual process involving external Excel calculations and subsequent imports. This creates a dependency on external tools and increases the risk of data entry errors. The goal is to automate this pipeline entirely within the product.
---
## Requirements Trace
- R1. Calculate and store aggregate totals (e.g., `:sales-summary/total-card-payments`) on the `sales-summary` entity.
- R2. Preserve user-made manual adjustments (`:sales-summary-item/manual? true`) during automatic recalculations.
- R3. Aggregate detailed `sales-summary-item`s into balanced `journal-entry` lines by account and location.
- R4. Automate the posting of these aggregated totals to the ledger.
- R5. Resolve UI TODOs in the Sales Summaries admin page, specifically regarding client-scoping ("clientize") and HTMX context (`client-id`).
---
## Scope Boundaries
- **In-Scope**:
- Enhancements to the `sales-summaries-v2` job.
- Implementation of the summary-to-ledger aggregation and posting logic.
- Cleanup of the Sales Summaries admin UI.
- **Out-of-Scope**:
- Changing the fundamental calculation logic for sales orders/refunds.
- Creating new ledger accounts (assume existing account mapping is sufficient).
- Changing the naming of refunds/returns (user requested to keep as is).
---
## Context & Research
### Relevant Code and Patterns
- **Jobs**: `src/clj/auto_ap/jobs/sales_summaries.clj` contains the main calculation logic.
- **UI**: `src/clj/auto_ap/ssr/admin/sales_summaries.clj` implements the admin interface.
- **Ledger Posting**: `src/clj/auto_ap/ledger.clj` and `iol_ion/src/iol_ion/tx/upsert_ledger.clj` handle journal entry creation.
- **Reconciliation Pattern**: `reconcile-ledger` in `src/clj/auto_ap/ledger.clj` shows how to find missing ledger entries and trigger their creation.
### Institutional Learnings
- No existing documented patterns for sales summary posting were found in `docs/solutions/`. This implementation will establish the pattern.
---
## Key Technical Decisions
- **Detailed Summary $\to$ Aggregated Ledger**: The `sales-summary` will maintain granular detail (line items, specific fee types), but the ledger posting will aggregate these items by account and location to create balanced `journal-entry` lines.
- **Automatic Posting**: Posting to the ledger will be integrated into the reconciliation process, similar to how invoices and transactions are handled in `reconcile-ledger`.
- **Location Handling**: Since `sales-summary-item`s don't have a location, a default location for the client will be used for ledger posting.
---
## Open Questions
### Resolved During Planning
- **Architectural Decision**: Use a detailed summary that aggregates into the ledger.
- **Renaming**: Keep "Refunds/Returns" as is.
### Deferred to Implementation
- **Default Location Logic**: Exactly how the "default location" for a client is retrieved or defined.
---
## Implementation Units
- U1. **Enhance `sales-summaries-v2` Job**
**Goal:** Ensure the job stores aggregate totals and preserves manual adjustments.
**Requirements:** R1, R2
**Dependencies:** None
**Files:**
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
**Approach:**
- Update `sales-summaries-v2` to calculate totals for attributes like `:sales-summary/total-card-payments`, `:sales-summary/total-cash-payments`, etc., based on the generated items.
- Implement a merge strategy: when updating a summary, keep any items where `:sales-summary-item/manual?` is true, and only replace the automatically calculated items.
**Test scenarios:**
- Happy path: Running the job for a client with sales and refunds results in a `sales-summary` with correct `:sales-summary/total-*` attributes.
- Edge case: Running the job on a summary that already has a manual item ensures the manual item is not overwritten.
**Verification:**
- Datomic query shows `sales-summary` entities have populated total attributes and preserved manual items.
---
- U2. **Implement Summary-to-Ledger Aggregation**
**Goal:** Create a function to transform detailed summary items into balanced ledger lines.
**Requirements:** R3
**Dependencies:** U1
**Files:**
- Create: `src/clj/auto_ap/ledger/sales_summaries.clj` (or add to `src/clj/auto_ap/ledger.clj`)
- Test: `test/clj/auto_ap/ledger_test.clj`
**Approach:**
- Create a function `aggregate-summary-items` that:
1. Groups `sales-summary-item`s by `:ledger-mapped/account`.
2. Sums the `:ledger-mapped/amount` based on `:ledger-mapped/ledger-side` (debit vs credit).
3. Assigns a location (default client location).
4. Returns a list of `journal-entry-line` maps.
**Test scenarios:**
- Happy path: A set of items with mixed accounts and sides aggregates into the correct number of ledger lines with summed amounts.
- Edge case: Items with `nil` accounts are handled gracefully (e.g., mapped to an "Unknown" account or logged as error).
**Verification:**
- Unit tests verify that a list of `sales-summary-item`s is correctly transformed into `journal-entry-line`s.
---
- U3. **Implement Automatic Ledger Posting for Summaries**
**Goal:** Ensure sales summaries trigger the creation of ledger entries.
**Requirements:** R4
**Dependencies:** U2
**Files:**
- Modify: `src/clj/auto_ap/ledger.clj`
- Modify: `src/clj/auto_ap/jobs/sales_summaries.clj`
**Approach:**
- Implement a `:upsert-sales-summary-ledger` transaction or function that takes a `sales-summary` and uses the aggregation logic from U2 to post to the ledger.
- Integrate this into the `reconcile-ledger` function in `src/clj/auto_ap/ledger.clj` to find summaries missing ledger entries and post them.
**Test scenarios:**
- Integration: Running `reconcile-ledger` identifies a `sales-summary` missing a `journal-entry` and creates a balanced `journal-entry` for it.
- Happy path: The created `journal-entry` has the correct total amount and matches the summary totals.
**Verification:**
- A `sales-summary` entity is linked to a `journal-entry` via `:journal-entry/original-entity`.
---
- U4. **Resolve UI TODOs in Sales Summaries Admin**
**Goal:** Fix client-scoping and HTMX context in the admin UI.
**Requirements:** R5
**Dependencies:** None
**Files:**
- Modify: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
**Approach:**
- Resolve "clientize" TODOs: Ensure the data pulled for the table and edit wizard is correctly scoped and transformed using client-specific context.
- Fix HTMX `client-id` passing: Update the `new-summary-item` trigger to correctly pass the `client-id` via `hx-vals` from the form state.
- Clean up any remaining schema TODOs in the SSR file.
**Test scenarios:**
- Integration: Adding a new summary item in the UI correctly sends the `client-id` and the item is created for the correct client.
- Happy path: The summary table displays correctly and "missing account" warnings appear only for items without a mapped account.
**Verification:**
- Manual verification in the browser: New items are added correctly, and the UI is free of "missing account" red pills for mapped items.
---
## System-Wide Impact
- **Interaction graph**: The `sales-summaries-v2` job now feeds into the ledger system via `reconcile-ledger`.
- **Error propagation**: Failures in the aggregation logic will prevent the `journal-entry` from being created, which will be surfaced by `reconcile-ledger` as a missing entry.
- **State lifecycle risks**: Ensuring that `manual?` items are not overwritten during automatic recalculation is critical to avoid losing user adjustments.
- **Integration coverage**: Integration tests must cover the full flow: `sales-orders` $\to$ `sales-summary` $\to$ `journal-entry`.
---
## Risks & Dependencies
| Risk | Mitigation |
|------|------------|
| Overwriting manual adjustments | Implement explicit merge logic based on the `:sales-summary-item/manual?` flag. |
| Unbalanced ledger entries | Use a strict aggregation function that ensures debits = credits for every posted summary. |
| Missing location data | Implement a robust fallback to a default client location. |
---
## Sources & References
- Related code: `src/clj/auto_ap/jobs/sales_summaries.clj`
- Related code: `src/clj/auto_ap/ssr/admin/sales_summaries.clj`
- Related code: `src/clj/auto_ap/ledger.clj`
- Related code: `iol_ion/src/iol_ion/tx/upsert_ledger.clj`

View File

@@ -55,7 +55,7 @@ Every mutating operation checks:
| 1.4 | It should show "Voided" status as a red pill | UI | [ ] |
| 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] |
| 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] |
| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [x] |
| 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.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] |
| 1.9 | It should display a links dropdown showing payments, transactions, ledger entries, and source files for each invoice | UI | [ ] |
| 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [ ] |
@@ -69,15 +69,15 @@ Every mutating operation checks:
| 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] |
| 2.4 | It should filter invoices by due date range | Integration | [ ] |
| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] |
| 2.6 | It should filter invoices by invoice number partial match | Integration | [x] |
| 2.6 | It should filter invoices by invoice number partial match | Integration | [ ] |
| 2.7 | It should filter invoices by check number | Integration | [ ] |
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [x] |
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [ ] |
| 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] |
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [x] |
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [ ] |
| 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] |
| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] |
| 2.13 | It should filter by expense account location | Integration | [ ] |
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [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 | [ ] |
### Sorting Behaviors
@@ -87,20 +87,20 @@ Every mutating operation checks:
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] |
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] |
| 3.5 | It should sort by invoice date ascending/descending | Integration | [x] |
| 3.5 | It should sort by invoice date ascending/descending | Integration | [ ] |
| 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] |
| 3.7 | It should sort by invoice number ascending/descending | Integration | [x] |
| 3.7 | It should sort by invoice number ascending/descending | Integration | [ ] |
| 3.8 | It should sort by total amount ascending/descending | Integration | [ ] |
| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] |
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] |
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
### Pagination Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 4.1 | It should display 25 invoices per page by default | Integration | [x] |
| 4.1 | It should display 25 invoices per page by default | Integration | [ ] |
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [x] |
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [ ] |
### Selection Behaviors
@@ -140,11 +140,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [x] |
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [x] |
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [x] |
| 8.4 | It should suggest the vendor's default expense account | Unit | [x] |
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [x] |
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] |
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [ ] |
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [ ] |
| 8.4 | It should suggest the vendor's default expense account | Unit | [ ] |
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [ ] |
| 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
### Expense Accounts Step
@@ -153,8 +153,8 @@ Every mutating operation checks:
|---|----------|---------------|--------|
| 9.1 | It should allow adding multiple expense account rows | UI | [ ] |
| 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] |
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [x] |
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [x] |
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [ ] |
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [ ] |
| 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] |
| 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] |
| 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] |
@@ -174,12 +174,12 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [x] |
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] |
| 11.2 | It should disable the vendor field when editing | UI | [ ] |
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [x] |
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [x] |
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [ ] |
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [ ] |
| 11.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 | [x] |
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [ ] |
---
@@ -202,7 +202,7 @@ Every mutating operation checks:
| 13.1 | It should display a grid of selected invoices with vendor, number, total, and pay amount | UI | [ ] |
| 13.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [ ] |
| 13.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] |
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [x] |
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [ ] |
| 13.5 | It should require a check number for handwritten checks | Integration | [ ] |
| 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [ ] |
| 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] |
@@ -223,10 +223,10 @@ Every mutating operation checks:
|---|----------|---------------|--------|
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] |
| 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [x] |
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [x] |
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [ ] |
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] |
| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] |
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [ ] |
---
@@ -235,9 +235,9 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] |
| 16.2 | It should require admin permission for bulk void operations | Integration | [x] |
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [x] |
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [x] |
| 16.2 | It should require admin permission for bulk void operations | Integration | [ ] |
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [ ] |
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [ ] |
| 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] |
| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
| 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] |
@@ -248,9 +248,9 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [x] |
| 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.2 | Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message | Integration | [ ] |
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [x] |
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [ ] |
| 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
---
@@ -259,7 +259,7 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [x] |
| 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.2 | It should require edit permission and client access | Integration | [ ] |
| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] |
| 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
@@ -270,10 +270,10 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [x] |
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [x] |
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [x] |
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [x] |
| 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [ ] |
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [ ] |
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [ ] |
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [ ] |
| 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [ ] |
---
@@ -324,11 +324,11 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [x] |
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [ ] |
| 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] |
| 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] |
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [x] |
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [x] |
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [ ] |
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [ ] |
### Form Behaviors
@@ -351,18 +351,18 @@ Every mutating operation checks:
| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] |
| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] |
| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] |
| 26.5 | It should block bulk delete for non-admin users | Integration | [x] |
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [x] |
| 26.5 | It should block bulk delete for non-admin users | Integration | [ ] |
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [ ] |
| 26.7 | It should block import for users without `:import` permission | Integration | [ ] |
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] |
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [ ] |
### Lock Date Behaviors
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [x] |
| 27.1 | It should block editing 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 | [ ] |
| 27.3 | It should block voiding 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.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] |
| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] |
| 27.6 | It should filter out locked invoices from bulk operations | Integration | [ ] |
@@ -372,7 +372,7 @@ Every mutating operation checks:
| # | Behavior | Test Strategy | Status |
|---|----------|---------------|--------|
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [x] |
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [ ] |
---

View File

@@ -49,9 +49,9 @@
(datomic-fn :upsert-entity #'iol-ion.tx.upsert-entity/upsert-entity)
(datomic-fn :upsert-invoice #'iol-ion.tx.upsert-invoice/upsert-invoice)
(datomic-fn :upsert-ledger #'iol-ion.tx.upsert-ledger/upsert-ledger)
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)])))
(datomic-fn :upsert-transaction #'iol-ion.tx.upsert-transaction/upsert-transaction)
(datomic-fn :upsert-sales-summary #'iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary)])))
(comment
(regenerate-literals)
(auto-ap.datomic/install-functions))
(auto-ap.datomic/install-functions))

View File

@@ -0,0 +1,70 @@
(ns iol-ion.tx.upsert-sales-summary-ledger
(:require [datomic.api :as dc]))
(defn summary->journal-entry [db summary-id]
(let [summary (dc/pull db '[:sales-summary/client
:sales-summary/date
{:sales-summary/items [:sales-summary-item/category
:ledger-mapped/account
:ledger-mapped/amount
{:ledger-mapped/ledger-side [:db/ident]}]}]
summary-id)
items (:sales-summary/items summary)
aggregated (->> items
(filter :ledger-mapped/account)
(group-by :ledger-mapped/account)
(map (fn [[account acc-items]]
(reduce
(fn [m item]
(update m (:db/ident (:ledger-mapped/ledger-side item)) (fnil + 0.0) (:ledger-mapped/amount item 0.0)))
{:account account}
acc-items))))
_ (clojure.pprint/pprint aggregated)
line-items (mapv (fn [{:keys [account] :as m}]
(cond-> {:db/id (str (java.util.UUID/randomUUID))
:journal-entry-line/account account
:journal-entry-line/location "A"}
(get m :ledger-side/debit) (assoc :journal-entry-line/debit (get m :ledger-side/debit))
(get m :ledger-side/credit) (assoc :journal-entry-line/credit (get m :ledger-side/credit))))
aggregated)
total-debits (reduce + 0.0 (map #(get % :ledger-side/debit 0.0) aggregated))
total-credits (reduce + 0.0 (map #(get % :ledger-side/credit 0.0) aggregated))
_ (clojure.pprint/pprint [total-debits total-credits])
]
(when (and (seq line-items)
(= (Math/round (* 1000 total-debits))
(Math/round (* 1000 total-credits))))
{:journal-entry/source "sales-summary"
:journal-entry/client (:db/id (:sales-summary/client summary))
:journal-entry/date (:sales-summary/date summary)
:journal-entry/original-entity summary-id
:journal-entry/amount total-debits
:journal-entry/line-items line-items})))
(defn current-date [db]
(let [last-tx (dc/t->tx (dc/basis-t db))
[[date]] (seq (dc/q '[:find ?ti :in $ ?tx
:where [?tx :db/txInstant ?ti]]
db
last-tx))]
date))
(defn upsert-sales-summary [db summary]
(let [upserted-summary [[:upsert-entity summary]]
db-after (-> (dc/with db upserted-summary) :db-after)
summary-id (:db/id summary)
client-id (-> (dc/pull db-after [{:sales-summary/client [:db/id]}] summary-id)
:sales-summary/client
:db/id)
journal-entry (summary->journal-entry db-after summary-id)]
upserted-summary
#_(into upserted-summary
(if journal-entry
[[:upsert-ledger journal-entry]]
(concat
[[:db/retractEntity [:journal-entry/original-entity (:db/id summary)]]]
(when client-id [{:db/id client-id
:client/ledger-last-change (current-date db)}]))))))

View File

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

View File

@@ -1,4 +1,10 @@
[{:db/valueType :db.type/boolean,
[{:db/valueType :db.type/double,
:db/cardinality :db.cardinality/one,
:db/noHistory true
:db/doc "The cached running balance for the account this line item is for",
:db/ident :journal-entry-line/running-balance,
}
{:db/valueType :db.type/boolean,
:db/cardinality :db.cardinality/one,
:db/noHistory true
:db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances",

File diff suppressed because one or more lines are too long

View File

@@ -905,11 +905,6 @@
:db/cardinality #:db{:ident :db.cardinality/one},
:db/doc "The client for the journal entry line",
:db/ident :journal-entry-line/client}
{:db/valueType #:db{:ident :db.type/double},
:db/cardinality #:db{:ident :db.cardinality/one},
:db/doc "The cached running balance for the account this line item is for",
:db/ident :journal-entry-line/running-balance,
:db/noHistory true}
{:db/valueType :db.type/tuple
:db/tupleAttrs [:journal-entry-line/client
:journal-entry-line/account
@@ -1954,12 +1949,12 @@
:db/unique :db.unique/identity
:db/index true}
{:db/ident :sales-summary/client+dirty
:db/valueType :db.type/tuple
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
:db/cardinality :db.cardinality/one
:db/index true}
:db/valueType :db.type/tuple
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
:db/cardinality :db.cardinality/one
:db/index true}
{:db/ident :sales-summary-item/category
{:db/ident :sales-summary-item/category
:db/valueType :db.type/string
:db/cardinality :db.cardinality/one}
{:db/ident :sales-summary-item/sort-order

View File

@@ -5,10 +5,11 @@
[iol-ion.tx.propose-invoice]
[iol-ion.tx.reset-rels]
[iol-ion.tx.reset-scalars]
[iol-ion.tx.upsert-entity]
[iol-ion.tx.upsert-invoice]
[iol-ion.tx.upsert-ledger]
[iol-ion.tx.upsert-transaction]
[iol-ion.tx.upsert-entity]
[iol-ion.tx.upsert-invoice]
[iol-ion.tx.upsert-ledger]
[iol-ion.tx.upsert-transaction]
[iol-ion.tx.upsert-sales-summary-ledger]
[com.github.ivarref.gen-fn :refer [gen-fn! datomic-fn]]
[auto-ap.utils :refer [default-pagination-size by]]
[clojure.edn :as edn]

View File

@@ -278,46 +278,42 @@
(defn sales-summaries-v2 []
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
:in $
:where [?c :client/code ?client-code]]
(dc/db conn))
{:sales-summary/keys [date] :db/keys [id]} (dirty-sales-summaries c)]
:in $
:where [?c :client/code ?client-code]]
(dc/db conn))
{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)]
(mu/with-context {:client-code client-code
:date date}
(alog/info ::updating)
(let [result {:db/id id
:sales-summary/client c
:sales-summary/date date
:sales-summary/dirty false
:sales-summary/client+date [c date]
:sales-summary/items
(->>
(get-sales c date)
(concat (get-payment-items c date))
(concat (get-refund-items c date))
(cons (get-discounts c date))
(cons (get-fees c date))
(cons (get-tax c date))
(cons (get-tip c date))
(cons (get-returns c date))
(filter identity)
(map (fn [z]
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
:sales-summary-item/manual? false))
)) }]
(if (seq (:sales-summary/items result))
(do
(alog/info ::upserting-summaries
:category-count (count (:sales-summary/items result)))
@(dc/transact conn [[:upsert-entity result]]))
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
(let [c (auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL" ])
date #inst "2024-04-14T00:00:00-07:00"]
(get-payment-items c date)
)
:date date}
(alog/info ::updating)
(let [manual-items (->> existing-summary
:sales-summary/items
(filter :sales-summary-item/manual?))
calculated-items (->>
(get-sales c date)
(concat (get-payment-items c date))
(concat (get-refund-items c date))
(cons (get-discounts c date))
(cons (get-fees c date))
(cons (get-tax c date))
(cons (get-tip c date))
(cons (get-returns c date))
(filter identity)
(map (fn [z]
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
:sales-summary-item/manual? false))))
all-items (concat calculated-items manual-items)
result {:db/id id
:sales-summary/client c
:sales-summary/date date
:sales-summary/dirty false
:sales-summary/client+date [c date]
:sales-summary/items all-items}]
(if (seq (:sales-summary/items result))
(do
(alog/info ::upserting-summaries
:category-count (count (:sales-summary/items result)))
@(dc/transact conn [[:upsert-sales-summary result]]))
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
(defn reset-summaries []
@@ -334,29 +330,39 @@
(comment
(auto-ap.datomic/transact-schema conn)
@(dc/transact conn [{:db/ident :sales-summary/total-unknown-processor-payments
:db/noHistory true,
:db/valueType :db.type/double
:db/cardinality :db.cardinality/one}])
(apply mark-dirty [:client/code "NGCL"] (last-n-days 30))
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
(apply mark-dirty [:client/code "NGPG"] (last-n-days 30))
(dirty-sales-summaries [:client/code "NGWH"])
(mark-all-dirty 50)
(apply mark-dirty [:client/code "NGWH"] (last-n-days 5))
(iol-ion.tx.upsert-sales-summary-ledger/summary->journal-entry (dc/db conn) 17592314245819)
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
(mark-all-dirty 5)
(delete-all)
(sales-summaries-v2)
1
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
:in $
:where [?sos :sales-summary/client [:client/code "NGHW"]]
[?sos :sales-summary/date ?d]
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
(dc/db conn))
(dc/q '[:find ?n ?p2 (sum ?total)
:with ?c
:in $ [?clients ?start-date ?end-date]
@@ -369,18 +375,21 @@
(dc/db conn)
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGHW"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-11T00:00:00-07:00"])
(dc/q '[:find ?n
(dc/q '[:find ?n
:in $ [?clients ?start-date ?end-date]
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
[?e :sales-order/line-items ?li]
[?li :order-line-item/item-name ?n] ]
[?li :order-line-item/item-name ?n]]
(dc/db conn)
[[(auto-ap.datomic/pull-attr (dc/db conn) :db/id [:client/code "NGCL"])] #inst "2024-04-11T00:00:00-07:00" #inst "2024-04-24T00:00:00-07:00"])
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
(auto-ap.datomic/transact-schema conn)
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
(auto-ap.datomic/transact-schema conn)
)

View File

@@ -35,27 +35,42 @@
invoices-missing-ledger-entries (->> (dc/q {:find ['?t ]
:in ['$ '?sd]
:where ['[?t :invoice/date ?d]
'[(>= ?d ?sd)]
'(not [_ :journal-entry/original-entity ?t])
'[?t :invoice/total ?amt]
'[(not= 0.0 ?amt)]
'(not [?t :invoice/status :invoice-status/voided])
'(not [?t :invoice/import-status :import-status/pending])
'(not [?t :invoice/exclude-from-ledger true])
]}
(dc/db conn) start-date)
:in ['$ '?sd]
:where ['[?t :invoice/date ?d]
'[(>= ?d ?sd)]
'(not [_ :journal-entry/original-entity ?t])
'[?t :invoice/total ?amt]
'[(not= 0.0 ?amt)]
'(not [?t :invoice/status :invoice-status/voided])
'(not [?t :invoice/import-status :import-status/pending])
'(not [?t :invoice/exclude-from-ledger true])
]}
(dc/db conn) start-date)
(map first)
(mapv (fn [i]
[:upsert-invoice {:db/id i}])))
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries))]
sales-summaries-missing-ledger-entries (->> (dc/q {:find ['?ss ]
:in ['$ '?sd]
:where ['[?ss :sales-summary/date ?d]
'[(>= ?d ?sd)]
'(not [_ :journal-entry/original-entity ?ss])
'[?ss :sales-summary/items ?item]
'[?item :ledger-mapped/account]
]}
(dc/db conn) start-date)
(map first)
(mapv (fn [ss]
[:upsert-sales-summary {:db/id ss}])))
repairs (vec (concat txes-missing-ledger-entries invoices-missing-ledger-entries sales-summaries-missing-ledger-entries))]
(when (seq repairs)
(mu/log ::ledger-repairs-needed
:sample (take 3 repairs)
:transaction-count (count txes-missing-ledger-entries)
:invoice-count (count invoices-missing-ledger-entries))
@(dc/transact conn repairs)))))
(mu/log ::ledger-repairs-needed
:sample (take 3 repairs)
:transaction-count (count txes-missing-ledger-entries)
:invoice-count (count invoices-missing-ledger-entries)
:sales-summary-count (count sales-summaries-missing-ledger-entries))
@(dc/transact conn repairs)))))
(defn touch-transaction [e]

View File

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

View File

@@ -6,7 +6,8 @@
[auto-ap.routes.admin.excel-invoices :as ei-routes]
[auto-ap.routes.admin.import-batch :as ib-routes]
[auto-ap.routes.admin.transaction-rules :as transaction-rules]
[auto-ap.routes.admin.vendors :as v-routes]
[auto-ap.routes.admin.vendors :as v-routes]
[auto-ap.routes.pos.sales-summaries :as ss-routes]
[auto-ap.routes.invoice :as invoice-route]
[auto-ap.routes.ledger :as ledger-routes]
[auto-ap.routes.outgoing-invoice :as oi-routes]
@@ -90,8 +91,8 @@
(#{::invoice-route/all-page ::invoice-route/unpaid-page ::invoice-route/voided-page ::invoice-route/paid-page ::oi-routes/new ::invoice-route/import-page :invoice-glimpse :invoice-glimpse-textract-invoice} (:matched-route request))
"invoices"
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts} (:matched-route request))
"sales"
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts ::ss-routes/page} (:matched-route request))
"sales"
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
"payments"
(#{::ledger-routes/all-page ::ledger-routes/external-page ::ledger-routes/external-import-page ::ledger-routes/balance-sheet ::ledger-routes/cash-flows ::ledger-routes/profit-and-loss} (:matched-route request))
@@ -207,12 +208,18 @@
:hx-boost "true"}
"Refunds")
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
:pos-cash-drawer-shifts)
"?date-range=week")
:active? (= :pos-cash-drawer-shifts (:matched-route request))
:hx-boost "true"}
"Cash drawer shifts")
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
:pos-cash-drawer-shifts)
::ss-routes/page)
"?date-range=week")
:active? (= :pos-cash-drawer-shifts (:matched-route request))
:active? (= ::ss-routes/page (:matched-route request))
:hx-boost "true"}
"Cash drawer shifts"))))
"Summaries"))))
(menu-button- {"@click.prevent" "if (selected == 'payments') {selected = null } else { selected = 'payments'} "
:icon svg/payments}

View File

@@ -12,7 +12,7 @@
[auto-ap.ssr.admin.excel-invoice :as admin-excel-invoices]
[auto-ap.ssr.admin.history :as history]
[auto-ap.ssr.admin.import-batch :as import-batch]
[auto-ap.ssr.admin.sales-summaries :as admin-sales-summaries]
[auto-ap.ssr.pos.sales-summaries :as pos-sales-summaries]
[auto-ap.ssr.admin.transaction-rules :as admin-rules]
[auto-ap.ssr.admin.vendors :as admin-vendors]
[auto-ap.ssr.auth :as auth]
@@ -85,17 +85,17 @@
(into company-1099/key->handler)
(into invoice/key->handler)
(into import-batch/key->handler)
(into pos-sales/key->handler)
(into pos-expected-deposits/key->handler)
(into pos-tenders/key->handler)
(into pos-cash-drawer-shifts/key->handler)
(into pos-refunds/key->handler)
(into users/key->handler)
(into admin-accounts/key->handler)
(into admin-excel-invoices/key->handler)
(into admin/key->handler)
(into admin-jobs/key->handler)
(into admin-sales-summaries/key->handler)
(into pos-sales/key->handler)
(into pos-expected-deposits/key->handler)
(into pos-tenders/key->handler)
(into pos-cash-drawer-shifts/key->handler)
(into pos-refunds/key->handler)
(into pos-sales-summaries/key->handler)
(into users/key->handler)
(into admin-accounts/key->handler)
(into admin-excel-invoices/key->handler)
(into admin/key->handler)
(into admin-jobs/key->handler)
(into admin-vendors/key->handler)
(into admin-clients/key->handler)
(into admin-rules/key->handler)

View File

@@ -1,4 +1,4 @@
(ns auto-ap.ssr.admin.sales-summaries
(ns auto-ap.ssr.pos.sales-summaries
(:require
[auto-ap.datomic
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
@@ -6,9 +6,7 @@
[auto-ap.datomic.accounts :as d-accounts]
[auto-ap.graphql.utils :refer [extract-client-ids]]
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
[auto-ap.routes.admin.sales-summaries :as route]
[auto-ap.routes.utils
:refer [wrap-admin wrap-client-redirect-unauthenticated]]
[auto-ap.routes.pos.sales-summaries :as route]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
[auto-ap.ssr.components :as com]
@@ -16,6 +14,8 @@
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.pos.common
:refer [date-range-field*]]
[auto-ap.ssr.svg :as svg]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers clj-date-schema
@@ -48,38 +48,22 @@
"hx-target" "#entity-table"
"hx-indicator" "#entity-table"}
#_[:fieldset.space-y-6
(date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"})
(com/field {:label "Source"}
(com/select {:name "source"
:class "hot-filter w-full"
:value (:source (:query-params request))
:placeholder ""
:options (ref->select-options "import-source" :allow-nil? true)}))
#_(com/field {:label "Code"}
(com/text-input {:name "code"
:id "code"
:class "hot-filter"
:value (:code (:query-params request))
:placeholder "11101"
:size :small}))]])
[:fieldset.space-y-6
(date-range-field* request)]])
(def default-read '[:db/id
*
[:sales-summary/date :xform clj-time.coerce/from-date]
{:sales-summary/client [:client/code :client/name :db/id]}
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]
} ;; TODO clientize
:ledger-mapped/account
:ledger-mapped/amount
:sales-summary-item/category
:sales-summary-item/sort-order
:db/id
:sales-summary-item/manual?]
} ]) ;; TODO
}
:ledger-mapped/account
:ledger-mapped/amount
:sales-summary-item/category
:sales-summary-item/sort-order
:db/id
:sales-summary-item/manual?]
} ])
(defn fetch-ids [db request]
(let [query-params (:query-params request)
@@ -129,31 +113,12 @@
[(->> (hydrate-results ids-to-retrieve db request))
matching-count]))
#_(defn get-debits [ss]
{:card-payments (+ (:sales-summary/total-card-payments ss 0.0)
(:sales-summary/total-card-fees ss 0.0)
(- (:sales-summary/total-card-refunds ss 0.0)))
:food-app-payments (+ (:sales-summary/total-food-app-payments ss 0.0)
(:sales-summary/total-food-app-fees ss 0.0)
(- (:sales-summary/total-food-app-refunds ss 0.0)))
:gift-card-payments (+ (:sales-summary/total-gift-card-payments ss 0.0)
(:sales-summary/total-gift-card-fees ss 0.0)
(- (:sales-summary/total-gift-card-refunds ss 0.0)))
#_#_:refunds (+ (:sales-summary/total-food-app-refunds ss 0.0)
(:sales-summary/total-card-refunds ss 0.0)
(:sales-summary/total-cash-refunds ss 0.0))
:fees (- (:sales-summary/total-card-fees ss 0.0))
:cash-payments (+ (:sales-summary/total-cash-payments ss 0.0)
(- (:sales-summary/total-cash-refunds ss 0.0)))
:total-unknown-processor-payments (:sales-summary/total-unknown-processor-payments ss 0.0)
:discounts (+ (:sales-summary/discount ss 0.0))
:returns (+ (:sales-summary/total-returns ss 0.0))})
(defn sort-items [ss]
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
(defn total-debits [items]
(->> items
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
@@ -169,7 +134,7 @@
(def grid-page
(helper/build {:id "entity-table"
:id-fn :db/id
:nav com/admin-aside-nav
:nav com/main-aside-nav
:fetch-page fetch-page
:page-specific-nav filters
:query-schema query-schema
@@ -179,13 +144,11 @@
:db/id (:db/id entity))}
svg/pencil)])
:oob-render
(fn [request]
[#_(assoc-in (date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"}) [1 :hx-swap-oob] true)]) ;; TODO
(fn [request]
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
:admin)}
"Admin"]
:company)}
"POS"]
[:a {:href (bidi/path-for ssr-routes/only-routes
::route/page)}
@@ -241,14 +204,6 @@
:primary
:red)} "Total: " (format "$%,.2f" total-credits))]]))}]}))
;; TODO schema cleanup
;; Decide on what should be calculated as generating ledger entries, and what should be calculated
;; as part of the summary
;; default thought here is that the summary has more detail (e.g., line items), fees broken out by type
;; and aggregated into the final ledger entry
;; that allows customization at any level.
;; TODO rename refunds/returns
(def row* (partial helper/row* grid-page))
(def table* (partial helper/table* grid-page))
@@ -436,15 +391,14 @@
(com/data-grid-header {} "")]}
(fc/with-field :sales-summary/items
(list
(fc/cursor-map #(sales-summary-item-row* {:value %
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
;; TODO
(com/data-grid-new-row {:colspan 5
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}} ;; TODO
"New Summary Item")))
(fc/cursor-map #(sales-summary-item-row* {:value %
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
(com/data-grid-new-row {:colspan 5
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
:row-offset 0
:index (count (fc/field-value))
:tr-params {:hx-vals (hx/json {:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}}
"New Summary Item")))
(summary-total-row* request)
(unbalanced-row* request)) ])
@@ -490,7 +444,7 @@
edit-schema)
(submit [this {:keys [multi-form-state request-method identity] :as request}]
(let [result (:snapshot multi-form-state )
transaction [:upsert-entity {:db/id (:db/id result)
transaction [:upsert-sales-summary {:db/id (:db/id result)
:sales-summary/items (map
(fn [i]
(if (:sales-summary-item/manual? i)
@@ -553,8 +507,4 @@
(wrap-apply-sort grid-page)
(wrap-merge-prior-hx)
(wrap-schema-enforce :query-schema query-schema)
(wrap-schema-enforce :hx-schema query-schema)
(wrap-admin)
(wrap-client-redirect-unauthenticated)))))
(wrap-schema-enforce :hx-schema query-schema)))))

View File

@@ -1,33 +1,32 @@
(ns user
(:require [amazonica.aws.s3 :as s3]
[auto-ap.server]
[auto-ap.datomic :refer [conn pull-attr random-tempid]]
[auto-ap.solr :as solr]
[auto-ap.time :as atime]
[auto-ap.utils :refer [by]]
[clj-time.coerce :as c]
[clj-time.core :as t]
[clojure.core.async :as async]
[auto-ap.handler :refer [app]]
[ring.adapter.jetty :refer [run-jetty]]
[clojure.data.csv :as csv]
[clojure.java.io :as io]
[clojure.pprint]
[clojure.string :as str]
[clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]]
[com.brunobonacci.mulog :as mu]
[com.brunobonacci.mulog.buffer :as rb]
[config.core :refer [env]]
[datomic.api :as dc]
[datomic.api :as d]
[puget.printer :as puget]
[figwheel.main.api]
[hawk.core]
[mount.core :as mount]
[nrepl.middleware.print])
(:import (org.apache.commons.io.input BOMInputStream)
[org.eclipse.jetty.server.handler.gzip GzipHandler]))
(:require [amazonica.aws.s3 :as s3]
[auto-ap.server]
[auto-ap.datomic :refer [conn pull-attr random-tempid]]
[auto-ap.solr :as solr]
[auto-ap.time :as atime]
[auto-ap.utils :refer [by]]
[clj-time.coerce :as c]
[clj-time.core :as t]
[clojure.core.async :as async]
[auto-ap.handler :refer [app]]
[ring.adapter.jetty :refer [run-jetty]]
[clojure.data.csv :as csv]
[clojure.java.io :as io]
[clojure.pprint]
[clojure.string :as str]
[clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]]
[com.brunobonacci.mulog :as mu]
[com.brunobonacci.mulog.buffer :as rb]
[config.core :refer [env]]
[datomic.api :as dc]
[puget.printer :as puget]
[datomic.api :as d]
[figwheel.main.api]
[hawk.core]
[mount.core :as mount]
[nrepl.middleware.print])
(:import (org.apache.commons.io.input BOMInputStream)
[org.eclipse.jetty.server.handler.gzip GzipHandler]))
(defn println-event [item]
#_(printf "%s: %s - %s:%s by %s\n"
@@ -45,7 +44,8 @@
item
:user)))
(when (= :auto-ap.logging/peek (:mulog/event-name item))
(println "\u001B[31mTEST"))
(println "\u001B[31mTEST")
)
(when (:error item)
(println (:error item)))
(puget/cprint (reduce
@@ -58,15 +58,18 @@
{:seq-limit 10})
(println))
(deftype DevPublisher [config buffer transform]
com.brunobonacci.mulog.publisher.PPublisher
(agent-buffer [_]
buffer)
(publish-delay [_]
200)
(publish [_ buffer]
;; items are pairs [offset <item>]
(doseq [item (transform (map second (rb/items buffer)))]
@@ -74,6 +77,8 @@
(flush)
(rb/clear buffer)))
(defn dev-publisher
[{:keys [transform pretty?] :as config}]
(DevPublisher. config (rb/agent-buffer 10000) (or transform identity)))
@@ -82,27 +87,29 @@
[config]
(dev-publisher config))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn load-accounts [conn]
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
:db/id])]
:in ['$]
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
:db/id])]
:in ['$]
:where ['[?e :account/name]]}
(dc/db conn))))
also-merge-txes (fn [also-merge old-account-id]
(if old-account-id
(let [[sunset-account]
(first (dc/q {:find ['?a]
:in ['$ '?ac]
(first (dc/q {:find ['?a]
:in ['$ '?ac]
:where ['[?a :account/numeric-code ?ac]]}
(dc/db conn) also-merge))]
(into (mapv
(fn [[entity id _]]
[:db/add entity id old-account-id])
(dc/q {:find ['?e '?id '?a]
:in ['$ '?ac]
(dc/q {:find ['?e '?id '?a]
:in ['$ '?ac]
:where ['[?a :account/numeric-code ?ac]
'[?e ?at ?a]
'[?at :db/ident ?id]]}
@@ -113,7 +120,7 @@
txes (transduce
(comp
(map (fn ->map [r]
(map (fn ->map [r]
(into {} (map vector header r))))
(map (fn parse-map [r]
{:old-account-id (:db/id (code->existing-account
@@ -154,6 +161,7 @@
(also-merge-txes also-merge old-account-id))
tx)))))
conj
[]
rows)]
@@ -161,8 +169,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-bad-accounts []
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
:in ['$]
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
:in ['$]
:where ['[?e :account/numeric-code ?z]
'[(<= ?z 9999)]
'[?x ?a ?e]]}
@@ -178,12 +186,13 @@
[:db/retractEntity old-account-id])))
conj
[]
(dc/q {:find ['?e]
:in ['$]
(dc/q {:find ['?e]
:in ['$]
:where ['[?e :account/numeric-code ?z]
'[(<= ?z 9999)]]}
(dc/db conn)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-conflicting-accounts []
(filter
@@ -193,30 +202,32 @@
(fn [acc [e z]]
(update acc z conj e))
{}
(dc/q {:find ['?e '?z]
:in ['$]
(dc/q {:find ['?e '?z]
:in ['$]
:where ['[?e :account/numeric-code ?z]]}
(dc/db conn)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn customize-accounts [customer filename]
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
[client-id] (first (dc/q (-> {:find ['?e]
:in ['$ '?z]
[client-id] (first (dc/q (-> {:find ['?e]
:in ['$ '?z]
:where [['?e :client/code '?z]]}
(dc/db conn) customer)))
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
{:account/applicability [:db/ident]}
:db/id])]
:in ['$]
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
{:account/applicability [:db/ident]}
:db/id])]
:in ['$]
:where ['[?e :account/name]]}
(dc/db conn))))
existing-account-overrides (dc/q {:find ['?e]
:in ['$ '?client-id]
existing-account-overrides (dc/q {:find ['?e]
:in ['$ '?client-id]
:where [['?e :account-client-override/client '?client-id]]}
(dc/db conn) client-id)
_ (when-let [bad-rows (seq (->> rows
(group-by (fn [[_ account]]
account))
@@ -274,11 +285,12 @@
txes
#_@(d/transact conn txes)))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn fix-transactions-without-locations [client-code location]
(->>
(dc/q {:find ['(pull ?e [*])]
:in ['$ '?client-code]
(dc/q {:find ['(pull ?e [*])]
:in ['$ '?client-code]
:where ['[?e :transaction/accounts ?ta]
'[?e :transaction/matched-rule]
'[?e :transaction/approval-status :transaction-approval-status/approved]
@@ -295,11 +307,12 @@
accounts)))
vec))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn entity-history [i]
(vec (sort-by first (dc/q
{:find ['?tx '?z '?v]
:in ['?i '$]
{:find ['?tx '?z '?v]
:in ['?i '$]
:where ['[?i ?a ?v ?tx ?ad]
'[?a :db/ident ?z]
'[(= ?ad true)]]}
@@ -308,8 +321,8 @@
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn entity-history-with-revert [i]
(vec (sort-by first (dc/q
{:find ['?tx '?z '?v '?ad]
:in ['?i '$]
{:find ['?tx '?z '?v '?ad]
:in ['?i '$]
:where ['[?i ?a ?v ?tx ?ad]
'[?a :db/ident ?z]]}
i (dc/history (dc/db conn))))))
@@ -329,15 +342,17 @@
{:start (- i 100)
:end (+ i 100)}))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn start-db []
(mu/start-publisher! {:type :dev})
(mount.core/start (mount.core/only #{#'auto-ap.datomic/conn})))
(defn- auto-reset-handler [ctx event]
(require 'figwheel.main.api)
(binding [*ns* *ns*]
(clojure.tools.namespace.repl/refresh)
(clojure.tools.namespace.repl/refresh)
ctx))
(defn auto-reset
@@ -348,13 +363,15 @@
(hawk.core/watch! [{:paths ["src/" "test/"]
:handler auto-reset-handler}]))
(defn start-http []
(defn start-http []
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
(defn start-dev []
(set-refresh-dirs "src")
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
(start-db)
(start-http)
(auto-reset))
@@ -375,20 +392,21 @@
(for [r data]
((apply juxt columns) r)))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn find-queries [words]
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
:prefix (str "queries/"))
concurrent 30
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
:prefix (str "queries/"))
concurrent 30
output-chan (async/chan)]
(async/pipeline-blocking concurrent
output-chan
(comp
(map #(do
[(:key %)
(str (slurp (:object-content (s3/get-object
:bucket-name (:data-bucket env)
:key (:key %)))))]))
(str (slurp (:object-content (s3/get-object
:bucket-name (:data-bucket env)
:key (:key %)))))]))
(filter #(->> words
(every? (fn [w] (str/includes? (second %) w)))))
@@ -400,11 +418,12 @@
(println "failed " e)))
(async/<!! (async/into [] output-chan))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn upsert-invoice-amounts [tsv]
(let [data (with-open [reader (io/reader (char-array tsv))]
(doall (csv/read-csv reader :separator \tab)))
db (dc/db conn)
(let [data (with-open [reader (io/reader (char-array tsv))]
(doall (csv/read-csv reader :separator \tab)))
db (dc/db conn)
i->invoice-id (fn [i]
(try (Long/parseLong i)
(catch Exception e
@@ -441,12 +460,15 @@
target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date))
current-date (:invoice/date invoice)
current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0)
target-expense-account-amount (- (Double/parseDouble amount))
current-expense-account-location (:invoice-expense-account/location invoice-expense-account)
target-expense-account-location location
[[_ _ invoice-payment]] (vec (dc/q
'[:find ?p ?a ?ip
:in $ ?i
@@ -457,7 +479,7 @@
:when current-total]
[(when (not (auto-ap.utils/dollars= current-total target-total))
{:db/id invoice-id
{:db/id invoice-id
:invoice/total target-total})
(when new-account?
@@ -490,6 +512,7 @@
(filter identity)
vec)))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn get-schema [prefix]
(->> (dc/q '[:find ?i
@@ -514,6 +537,7 @@
(defn init-repl []
(set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn sample-ledger-import
([client-code]
@@ -522,7 +546,7 @@
(let [client-location (ffirst (d/q '[:find ?l :in $ ?c :where [?c :client/locations ?l]] (dc/db conn) [:client/code client-code]))]
(clojure.data.csv/write-csv
*out*
(for [n (range n)
(for [n (range n)
:let [v (rand-nth (map first (d/q '[:find ?vn :where [_ :vendor/name ?vn]] (dc/db conn))))
[{a-1 :account/numeric-code a-1-location :account/location}
{a-2 :account/numeric-code a-2-location :account/location}] (->> (d/q '[:find (pull ?a [:account/numeric-code :account/location]) :where [?a :account/numeric-code]]
@@ -535,11 +559,12 @@
(t/minus (t/days (rand-int 60)))
(atime/unparse atime/normal-date))
id (rand-int 100000)]
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
a)
:separator \tab))))
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
(defn sample-manual-yodlee
([client-code]
@@ -548,7 +573,7 @@
(let [bank-accounts (map first (d/q '[:find ?bac :in $ ?c :where [?c :client/bank-accounts ?b] [?b :bank-account/code ?bac]] (dc/db conn) [:client/code client-code]))]
(clojure.data.csv/write-csv
*out*
(for [n (range n)
(for [n (range n)
:let [amount (rand-int 2000)
d (-> (t/now)
(t/minus (t/days (rand-int 60)))
@@ -557,6 +582,8 @@
["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code])
:separator \tab))))
(defn index-solr
[]
(println "invoice")
@@ -564,7 +591,7 @@
:in $
:where [?i :invoice/invoice-number]
(not [?i :invoice/status :invoice-status/voided])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -577,7 +604,7 @@
:in $
:where [?i :payment/date]
(not [?i :payment/status :payment-status/voided])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -590,7 +617,7 @@
:in $
:where [?i :transaction/description-original]
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -601,7 +628,7 @@
(doseq [batch (->> (dc/qseq {:query '[:find ?i
:in $
:where [?i :journal-entry/date]]
:args [(dc/db conn)]})
:args [(dc/db conn)]})
(map first)
(partition-all 500))]
(print ".")
@@ -616,3 +643,4 @@
(print ".")
@(dc/transact auto-ap.datomic/conn n)))

View File

@@ -1,9 +1,7 @@
(ns auto-ap.routes.admin.sales-summaries)
(ns auto-ap.routes.pos.sales-summaries)
(def routes {"" {:get ::page
:put ::edit-wizard-submit}
"/table" ::table
["/" [#"\d+" :db/id]] {:get ::edit-wizard }
"/edit/navigate" ::edit-wizard-navigate
"/edit/sales-summary-item" ::new-summary-item})
"/edit/sales-summary-item" ::new-summary-item})

View File

@@ -12,7 +12,7 @@
[auto-ap.routes.transactions :as t-routes]
[auto-ap.routes.admin.clients :as ac-routes]
[auto-ap.routes.admin.sales-summaries :as ss-routes]
[auto-ap.routes.pos.sales-summaries :as ss-routes]
[auto-ap.routes.admin.transaction-rules :as tr-routes]))
(def routes {"impersonate" :impersonate

View File

@@ -1,606 +0,0 @@
(ns auto-ap.integration.invoice-behaviors-test
(:require
[auto-ap.datomic :as datomic]
[auto-ap.datomic.clients :refer [rebuild-search-index]]
[auto-ap.graphql.invoices :as gql-invoices]
[auto-ap.integration.util :refer [admin-token setup-test-data test-account
test-client test-invoice test-vendor
user-token user-token-no-access wrap-setup]]
[auto-ap.routes.invoices :as route-invoices]
[auto-ap.ssr.invoices :as ssr-invoices]
[auto-ap.time-reader]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[clojure.test :as t :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]))
(use-fixtures :each wrap-setup)
;; ============================================================================
;; Permission Behaviors (26.x, 26.8)
;; ============================================================================
(deftest test-permission-client-access
(testing "Behavior 26.8: It should verify the user has access to the invoice's client before any mutation"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Block creation for user without client access
(is (thrown? Exception (gql-invoices/add-invoice
{:id (user-token-no-access)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "NO-ACCESS"
:date #clj-time/date-time "2022-01-01"
:total 10.00
:expense_accounts [{:amount 10.0
:location "DT"
:account_id test-account-id}]}}
nil)))
;; Create invoice as admin, then block edit/void for user without client access
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "NO-ACCESS-EDIT"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
(is (thrown? Exception (gql-invoices/edit-invoice
{:id (user-token-no-access)}
{:invoice {:id (:id invoice)
:invoice_number "NO-ACCESS-EDIT-2"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)))
(is (thrown? Exception (gql-invoices/void-invoice
{:id (user-token-no-access)}
{:invoice_id (:id invoice)}
nil)))))))
(deftest test-permission-bulk-void
(testing "Behavior 26.5: It should block bulk delete for non-admin users"
(let [{:strs [test-client-id]}
(setup-test-data [])]
(is (thrown? Exception (gql-invoices/void-invoices
{:id (user-token test-client-id)}
{:filters {:client_id test-client-id}}
nil))))))
(deftest test-permission-bulk-edit
(testing "Behavior 26.6: It should block bulk edit for users without :bulk-edit permission"
(let [{:strs [test-client-id test-account-id]}
(setup-test-data [])]
(is (thrown? Exception (gql-invoices/bulk-change-invoices
{:id (user-token test-client-id)}
{:client_id test-client-id
:filters {:client_id test-client-id}
:accounts [{:percentage 1.0
:account_id test-account-id
:location "DT"}]}
nil))))))
;; ============================================================================
;; Lock Date Behaviors (27.x)
;; ============================================================================
(deftest test-lock-date-edit
(testing "Behavior 27.1: It should block editing invoices dated before the client's locked-until date"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "LOCK-EDIT"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
;; Set lock date after invoice creation
@(dc/transact datomic/conn
[{:db/id test-client-id
:client/locked-until #inst "2022-06-01"}])
(is (thrown? Exception (gql-invoices/edit-invoice
{:id (admin-token)}
{:invoice {:id (:id invoice)
:invoice_number "LOCK-EDIT-2"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)))))))
(deftest test-lock-date-void
(testing "Behavior 27.3: It should block voiding invoices dated before the client's locked-until date"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "LOCK-VOID"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
;; Set lock date after invoice creation
@(dc/transact datomic/conn
[{:db/id test-client-id
:client/locked-until #inst "2022-06-01"}])
(is (thrown? Exception (gql-invoices/void-invoice
{:id (admin-token)}
{:invoice_id (:id invoice)}
nil)))))))
;; ============================================================================
;; New Invoice Wizard (8.1, 8.5)
;; ============================================================================
(deftest test-new-invoice-validation
(testing "Behavior 8.1: It should require client, vendor, date, invoice number, and total"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Missing invoice number
(is (thrown? Exception (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:date #clj-time/date-time "2022-01-01"
:total 10.00
:expense_accounts [{:amount 10.0
:location "DT"
:account_id test-account-id}]}}
nil)))
;; Missing total
(is (thrown? Exception (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "MISSING-TOTAL"
:date #clj-time/date-time "2022-01-01"
:expense_accounts [{:amount 10.0
:location "DT"
:account_id test-account-id}]}}
nil)))))
(testing "Behavior 8.5: It should prevent duplicate invoice numbers for the same vendor and client"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Create first invoice
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "DUP-TEST"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
;; Try duplicate
(is (thrown? Exception (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "DUP-TEST"
:date #clj-time/date-time "2022-02-01"
:total 200.00
:expense_accounts [{:amount 200.0
:location "DT"
:account_id test-account-id}]}}
nil))))))
;; ============================================================================
;; Edit Invoice (11.1, 11.3)
;; ============================================================================
(deftest test-edit-unpaid-invoice
(testing "Behavior 11.1: It should allow editing unpaid and paid invoices"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "EDIT-TEST"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
;; Edit unpaid invoice
(is (some? (gql-invoices/edit-invoice
{:id (admin-token)}
{:invoice {:id (:id invoice)
:invoice_number "EDITED"
:date #clj-time/date-time "2022-01-01"
:total 150.00
:expense_accounts [{:amount 150.0
:location "DT"
:account_id test-account-id}]}}
nil)))
;; Verify edit
(is (= "EDITED"
(:invoice/invoice-number (dc/pull (dc/db datomic/conn)
[:invoice/invoice-number]
(:id invoice)))))))))
(deftest test-edit-expense-accounts
(testing "Behavior 11.3: It should allow modifying expense account amounts, adding/removing accounts"
(let [{:strs [test-client-id test-vendor-id test-account-id new-account-id]}
(setup-test-data [(test-account :db/id "new-account-id")])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "EDIT-ACCTS"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
;; Add second expense account
(gql-invoices/edit-invoice
{:id (admin-token)}
{:invoice {:id (:id invoice)
:invoice_number "EDIT-ACCTS"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 60.0
:location "DT"
:account_id test-account-id}
{:amount 40.0
:location "DT"
:account_id new-account-id}]}}
nil)
(let [updated (dc/pull (dc/db datomic/conn)
[{:invoice/expense-accounts [:invoice-expense-account/amount
:invoice-expense-account/account]}]
(:id invoice))]
(is (= 2 (count (:invoice/expense-accounts updated)))))))))
;; ============================================================================
;; Bulk Edit (15.4)
;; ============================================================================
(deftest test-bulk-edit-codes-invoices
(testing "Behavior 15.4: Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Create an invoice
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "BULK-EDIT"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
;; Bulk edit should change the expense account
(gql-invoices/bulk-change-invoices
{:id (admin-token)}
{:client_id test-client-id
:filters {:client_id test-client-id}
:accounts [{:percentage 1.0
:account_id test-account-id
:location "DT"}]}
nil)
;; Verify the invoice still has the expense account
(let [updated (dc/pull (dc/db datomic/conn)
[{:invoice/expense-accounts [:invoice-expense-account/account]}]
(:id invoice))]
(is (= test-account-id
(-> updated :invoice/expense-accounts first :invoice-expense-account/account :db/id))))))))
;; ============================================================================
;; Single/Bulk Void (17.1, 16.3, 16.4)
;; ============================================================================
(deftest test-void-unpaid-invoice
(testing "Behavior 17.1: Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "VOID-TEST"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)]
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil)
(let [voided (dc/pull (dc/db datomic/conn)
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
(:id invoice))]
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident)))
(is (= 0.0 (:invoice/total voided)))
(is (= 0.0 (:invoice/outstanding-balance voided)))
(is (every? #(= 0.0 (:invoice-expense-account/amount %))
(:invoice/expense-accounts voided))))))))
(deftest test-bulk-void-cash-payments
(testing "Behavior 16.3: Given confirmed, when voiding, then linked cash payments should be voided automatically"
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "BULK-VOID-CASH"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
cash-payment-id (get-in @(dc/transact datomic/conn
[{:db/id "cash-pmt"
:payment/date #inst "2022-01-01"
:payment/client test-client-id
:payment/vendor test-vendor-id
:payment/bank-account test-bank-account-id
:payment/type :payment-type/cash
:payment/amount 100.0
:payment/status :payment-status/cleared}
{:db/id "ip"
:invoice-payment/invoice (:id invoice)
:invoice-payment/payment "cash-pmt"
:invoice-payment/amount 100.0}])
[:tempids "cash-pmt"])]
;; Bulk void should also void the cash payment
(gql-invoices/void-invoices
{:id (admin-token) :clients [{:db/id test-client-id}]}
{:filters {:client_id test-client-id}}
nil)
(let [payment (dc/pull (dc/db datomic/conn)
[{:payment/status [:db/ident]}]
cash-payment-id)]
(is (= :payment-status/voided (-> payment :payment/status :db/ident))))))))
;; ============================================================================
;; Unvoid (18.1)
;; ============================================================================
(deftest test-unvoid-restores-invoice
(testing "Behavior 18.1: Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "UNVOID-TEST"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
original-id (:id invoice)]
;; Void the invoice
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id original-id} nil)
(let [voided (dc/pull (dc/db datomic/conn) [{:invoice/status [:db/ident]}] original-id)]
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident))))
;; Unvoid the invoice
(gql-invoices/unvoid-invoice {:id (admin-token)} {:invoice_id original-id} nil)
(let [restored (dc/pull (dc/db datomic/conn)
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
original-id)]
(is (= :invoice-status/unpaid (-> restored :invoice/status :db/ident)))
(is (= 100.0 (:invoice/total restored)))
(is (= 100.0 (:invoice/outstanding-balance restored)))
(is (= 100.0 (-> restored :invoice/expense-accounts first :invoice-expense-account/amount))))))))
;; ============================================================================
;; Undo Autopay (19.1)
;; ============================================================================
(deftest test-undo-autopay-resets-status
(testing "Behavior 19.1: Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(let [invoice (gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "UNDO-AUTO"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
invoice-id (:id invoice)]
;; Mark as paid with scheduled payment
@(dc/transact datomic/conn
[[:upsert-invoice {:db/id invoice-id
:invoice/status :invoice-status/paid
:invoice/outstanding-balance 0.0
:invoice/scheduled-payment #inst "2022-02-01"}]])
;; Undo autopay
(gql-invoices/unautopay-invoice {:id (admin-token)} {:invoice_id invoice-id} nil)
(let [updated (dc/pull (dc/db datomic/conn)
[{:invoice/status [:db/ident]} :invoice/outstanding-balance :invoice/scheduled-payment]
invoice-id)]
(is (= :invoice-status/unpaid (-> updated :invoice/status :db/ident)))
(is (= 100.0 (:invoice/outstanding-balance updated)))
(is (nil? (:invoice/scheduled-payment updated))))))))
;; ============================================================================
;; Invoice List Query Behaviors (2.6, 2.8, 2.10, 2.14)
;; ============================================================================
(deftest test-invoice-list-filtering
(testing "Behaviors 2.6, 2.8, 2.10, 2.14: Invoice list filtering"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Create test invoices
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "FILTER-A"
:date #clj-time/date-time "2022-01-15"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "FILTER-B"
:date #clj-time/date-time "2022-02-15"
:total 200.00
:expense_accounts [{:amount 200.0
:location "DT"
:account_id test-account-id}]}}
nil)
;; Filter by invoice number
(let [request {:query-params {:invoice-number "FILTER-A"}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices count] (ssr-invoices/fetch-page request)]
(is (= 1 count)))
;; Filter by status
(let [request {:query-params {}
:route-params {:status :invoice-status/unpaid}
:clients [{:db/id test-client-id}]}
[invoices count] (ssr-invoices/fetch-page request)]
(is (= 2 count)))
;; Exact match by ID
(let [invoice-id (ffirst (dc/q '[:find ?i
:where [?i :invoice/invoice-number "FILTER-A"]]
(dc/db datomic/conn)))
request {:query-params {:exact-match-id invoice-id}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices count] (ssr-invoices/fetch-page request)]
(is (= 1 count))
(is (= invoice-id (:db/id (first invoices))))))))
;; ============================================================================
;; Invoice List Sorting (3.5, 3.7, 3.10)
;; ============================================================================
(deftest test-invoice-list-sorting
(testing "Behaviors 3.5, 3.7, 3.10: Invoice list sorting"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "SORT-B"
:date #clj-time/date-time "2022-02-01"
:total 200.00
:expense_accounts [{:amount 200.0
:location "DT"
:account_id test-account-id}]}}
nil)
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number "SORT-A"
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil)
;; Sort by date ascending
(let [request {:query-params {:sort [{:sort-key "date" :asc true}]}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices count] (ssr-invoices/fetch-page request)]
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
;; Sort by invoice number
(let [request {:query-params {:sort [{:sort-key "invoice-number" :asc true}]}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices count] (ssr-invoices/fetch-page request)]
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
;; Toggle sort direction
(let [request-asc {:query-params {:sort [{:sort-key "date" :asc true}]}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices-asc count] (ssr-invoices/fetch-page request-asc)
request-desc {:query-params {:sort [{:sort-key "date" :asc false}]}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices-desc count] (ssr-invoices/fetch-page request-desc)]
(is (not= (:invoice/invoice-number (first invoices-asc))
(:invoice/invoice-number (first invoices-desc))))))))
;; ============================================================================
;; Invoice List Pagination (4.1, 4.3)
;; ============================================================================
(deftest test-invoice-list-pagination
(testing "Behaviors 4.1, 4.3: Invoice list pagination"
(let [{:strs [test-client-id test-vendor-id test-account-id]}
(setup-test-data [])]
;; Create 30 invoices
(doseq [i (range 30)]
(gql-invoices/add-invoice
{:id (admin-token)}
{:invoice {:client_id test-client-id
:vendor_id test-vendor-id
:invoice_number (str "PAGE-" i)
:date #clj-time/date-time "2022-01-01"
:total 100.00
:expense_accounts [{:amount 100.0
:location "DT"
:account_id test-account-id}]}}
nil))
;; Default 25 per page
(let [request {:query-params {}
:route-params {:status nil}
:clients [{:db/id test-client-id}]}
[invoices total-count total-outstanding total-amount] (ssr-invoices/fetch-page request)]
(is (= 25 (count invoices)))
(is (= 30 total-count))
(is (= 3000.0 total-outstanding))
(is (= 3000.0 total-amount))))))
;; ============================================================================
;; Legacy Routes (28.1)
;; ============================================================================
(deftest test-legacy-routes
(testing "Behavior 28.1: It should redirect old SPA routes to the new SSR routes"
(let [handler (ssr-invoices/redirect-handler ::ssr-invoices/route/all-page)
response (handler {:query-params {}})]
(is (= 302 (:status response)))
(is (get-in response [:headers "Location"])))))

View File

@@ -23,37 +23,37 @@
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
(is (seq (sut/stream->sales-orders s))))
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
(is (= #:sales-order
{:vendor :vendor/ccp-ezcater
:service-charge -95.9
:date #inst "2023-04-03T18:30:00"
:reference-link "ZA2-320"
:charges
[#:charge{:type-name "CARD"
:date #inst "2023-04-03T18:30:00"
:client test-client
:location "DT"
:external-id
(str "ezcater/charge/" test-client "-DT-ZA2-320-0")
:processor :ccp-processor/ezcater
:total 516.12
:tip 0.0}]
:client test-client
:tip 0.0
:tax 37.12
:external-id (str "ezcater/order/" test-client "-DT-ZA2-320")
:total 516.12
:line-items
[#:order-line-item{:external-id
(str "ezcater/order/" test-client "-DT-ZA2-320-0")
:item-name "EZCater Catering"
:category "EZCater Catering"
:discount 0.0
:tax 37.12
:total 516.12}]
:discount 0.0
:location "DT"
:returns 0.0}
(last (first (filter (comp #{:order} first)
(sut/stream->sales-orders s)))))))))))
(is (= #:sales-order
{:vendor :vendor/ccp-ezcater
:service-charge -95.9
:date #inst "2023-04-03T18:30:00"
:reference-link "ZA2-320"
:charges
[#:charge{:type-name "CARD"
:date #inst "2023-04-03T18:30:00"
:client test-client
:location "DT"
:external-id
"ezcater/charge/17592186045501-DT-ZA2-320-0"
:processor :ccp-processor/ezcater
:total 516.12
:tip 0.0}]
:client test-client
:tip 0.0
:tax 37.12
:external-id "ezcater/order/17592186045501-DT-ZA2-320"
:total 516.12
:line-items
[#:order-line-item{:external-id
"ezcater/order/17592186045501-DT-ZA2-320-0"
:item-name "EZCater Catering"
:category "EZCater Catering"
:discount 0.0
:tax 37.12
:total 516.12}]
:discount 0.0
:location "DT"
:returns 0.0}
(last (first (filter (comp #{:order} first)
(sut/stream->sales-orders s)))))))))))

View File

@@ -5,8 +5,7 @@
(defn wrap-setup
[f]
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"
auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"]
(dc/create-database auto-ap.datomic/uri)
(with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)]
(transact-schema conn)
@@ -29,13 +28,6 @@
:user/name "TEST USER"
:user/clients [{:db/id client-id}]}))
(defn user-token-no-access []
{:user "TEST USER"
:exp (time/plus (time/now) (time/days 1))
:user/role "user"
:user/name "TEST USER"
:user/clients []})
@@ -109,18 +101,16 @@
(dissoc x :id))
(defn setup-test-data [data]
(let [defaults [(test-account :db/id "test-account-id")
(test-client :db/id "test-client-id"
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
(test-vendor :db/id "test-vendor-id")
{:db/id "accounts-payable-id"
:account/name "Accounts Payable"
:db/ident :account/accounts-payable
:account/numeric-code 21000
:account/account-set "default"}]
user-ids (set (keep :db/id data))
merged (into [] (concat data (remove #(user-ids (:db/id %)) defaults)))]
(:tempids @(dc/transact conn merged))))
(:tempids @(dc/transact conn (into data
[(test-account :db/id "test-account-id")
(test-client :db/id "test-client-id"
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
(test-vendor :db/id "test-vendor-id")
{:db/id "accounts-payable-id"
:account/name "Accounts Payable"
:db/ident :account/accounts-payable
:account/numeric-code 21000
:account/account-set "default"}]))))
(defn apply-tx [data]
(:db-after @(dc/transact conn data)))

View File

@@ -1,408 +0,0 @@
(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,6 +2,7 @@
(:require [clojure.test :refer [deftest testing is]]
[auto-ap.ssr.invoice.new-invoice-wizard :as sut9]))
(deftest maybe-spread-locations-test
(testing "Shared amount correctly spread across multiple locations"
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
@@ -29,6 +30,8 @@
:invoice-expense-account/location "Location 2"}]
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
(testing "Shared amount correctly spread with leftovers"
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
:invoice-expense-account/location "Shared"}]
@@ -74,14 +77,14 @@
{:invoice-expense-account/amount -50.66
:invoice-expense-account/location "Location 2"}]
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
(testing "Leftovers should not exceed a single cent"
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount -100
:invoice-expense-account/location "Shared"}
{:invoice-expense-account/amount -5
:invoice-expense-account/location "Shared"}]
:invoice/total -101}
result (sut9/maybe-spread-locations invoice ["Location 1"])]
result (sut8/maybe-spread-locations invoice ["Location 1" ])]
(is (=
[{:invoice-expense-account/amount -100.0
:invoice-expense-account/location "Location 1"}