Compare commits
2 Commits
feat/compl
...
ececdc8f5f
| Author | SHA1 | Date | |
|---|---|---|---|
| ececdc8f5f | |||
| da7897c0d6 |
@@ -1,219 +0,0 @@
|
|||||||
---
|
|
||||||
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`
|
|
||||||
@@ -55,7 +55,7 @@ Every mutating operation checks:
|
|||||||
| 1.4 | It should show "Voided" status as a red pill | UI | [ ] |
|
| 1.4 | It should show "Voided" status as a red pill | UI | [ ] |
|
||||||
| 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] |
|
| 1.5 | It should show "Scheduled" status as a yellow pill when a scheduled payment exists | UI | [ ] |
|
||||||
| 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] |
|
| 1.6 | It should show "Unpaid" status as a secondary-colored pill | UI | [ ] |
|
||||||
| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [ ] |
|
| 1.7 | It should display due dates relative to today: "today", "in X days", or "X days ago" with appropriate color coding | Unit + UI | [x] |
|
||||||
| 1.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] |
|
| 1.8 | It should show a partial payment indicator "of $X.XX" when outstanding balance differs from total | UI | [ ] |
|
||||||
| 1.9 | It should display a links dropdown showing payments, transactions, ledger entries, and source files for each invoice | UI | [ ] |
|
| 1.9 | It should display a links dropdown showing payments, transactions, ledger entries, and source files for each invoice | UI | [ ] |
|
||||||
| 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [ ] |
|
| 1.10 | It should group table rows by vendor name when sorted by vendor | Integration | [ ] |
|
||||||
@@ -69,15 +69,15 @@ Every mutating operation checks:
|
|||||||
| 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] |
|
| 2.3 | It should filter invoices by date range (invoice date) | Integration | [ ] |
|
||||||
| 2.4 | It should filter invoices by due date range | Integration | [ ] |
|
| 2.4 | It should filter invoices by due date range | Integration | [ ] |
|
||||||
| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] |
|
| 2.5 | It should filter invoices by amount range (min/max total) | Integration | [ ] |
|
||||||
| 2.6 | It should filter invoices by invoice number partial match | Integration | [ ] |
|
| 2.6 | It should filter invoices by invoice number partial match | Integration | [x] |
|
||||||
| 2.7 | It should filter invoices by check number | Integration | [ ] |
|
| 2.7 | It should filter invoices by check number | Integration | [ ] |
|
||||||
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [ ] |
|
| 2.8 | It should filter invoices by status via route (all/unpaid/paid/voided) | Integration | [x] |
|
||||||
| 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] |
|
| 2.9 | It should filter invoices by import status (pending/imported) | Integration | [ ] |
|
||||||
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [ ] |
|
| 2.10 | It should support exact-match navigation to a specific invoice by ID, bypassing other filters | Integration | [x] |
|
||||||
| 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] |
|
| 2.11 | It should filter to invoices with scheduled payments | Integration | [ ] |
|
||||||
| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] |
|
| 2.12 | It should filter to unresolved invoices (missing or unassigned expense accounts) | Integration | [ ] |
|
||||||
| 2.13 | It should filter by expense account location | Integration | [ ] |
|
| 2.13 | It should filter by expense account location | Integration | [ ] |
|
||||||
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [ ] |
|
| 2.14 | Given multiple filters are applied, when the user changes one filter, then the table should refresh with the combined filter set | Integration | [x] |
|
||||||
|
|
||||||
### Sorting Behaviors
|
### Sorting Behaviors
|
||||||
|
|
||||||
@@ -87,20 +87,20 @@ Every mutating operation checks:
|
|||||||
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
|
| 3.2 | It should sort by vendor name ascending/descending | Integration | [ ] |
|
||||||
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] |
|
| 3.3 | It should sort by description original ascending/descending | Integration | [ ] |
|
||||||
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] |
|
| 3.4 | It should sort by expense account location ascending/descending | Integration | [ ] |
|
||||||
| 3.5 | It should sort by invoice date ascending/descending | Integration | [ ] |
|
| 3.5 | It should sort by invoice date ascending/descending | Integration | [x] |
|
||||||
| 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] |
|
| 3.6 | It should sort by due date ascending/descending, with nulls last | Integration | [ ] |
|
||||||
| 3.7 | It should sort by invoice number ascending/descending | Integration | [ ] |
|
| 3.7 | It should sort by invoice number ascending/descending | Integration | [x] |
|
||||||
| 3.8 | It should sort by total amount ascending/descending | Integration | [ ] |
|
| 3.8 | It should sort by total amount ascending/descending | Integration | [ ] |
|
||||||
| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] |
|
| 3.9 | It should sort by outstanding balance ascending/descending | Integration | [ ] |
|
||||||
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [ ] |
|
| 3.10 | Given the user clicks a column header twice, then the sort direction should toggle | Integration | [x] |
|
||||||
|
|
||||||
### Pagination Behaviors
|
### Pagination Behaviors
|
||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 4.1 | It should display 25 invoices per page by default | Integration | [ ] |
|
| 4.1 | It should display 25 invoices per page by default | Integration | [x] |
|
||||||
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
| 4.2 | It should allow changing the per-page count | Integration | [ ] |
|
||||||
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [ ] |
|
| 4.3 | It should calculate the total outstanding balance and total amount across ALL matching invoices, not just the current page | Unit | [x] |
|
||||||
|
|
||||||
### Selection Behaviors
|
### Selection Behaviors
|
||||||
|
|
||||||
@@ -140,11 +140,11 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [ ] |
|
| 8.1 | It should require client, vendor, date, invoice number, and total | Integration | [x] |
|
||||||
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [ ] |
|
| 8.2 | It should auto-calculate the due date from vendor terms when client, date, and vendor are selected | Unit | [x] |
|
||||||
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [ ] |
|
| 8.3 | It should auto-calculate the scheduled payment date from vendor autopay settings | Unit | [x] |
|
||||||
| 8.4 | It should suggest the vendor's default expense account | Unit | [ ] |
|
| 8.4 | It should suggest the vendor's default expense account | Unit | [x] |
|
||||||
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [ ] |
|
| 8.5 | It should prevent duplicate invoice numbers for the same vendor and client | Unit + Integration | [x] |
|
||||||
| 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
|
| 8.6 | It should allow editing all fields when creating a new invoice | UI | [ ] |
|
||||||
|
|
||||||
### Expense Accounts Step
|
### Expense Accounts Step
|
||||||
@@ -153,8 +153,8 @@ Every mutating operation checks:
|
|||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 9.1 | It should allow adding multiple expense account rows | UI | [ ] |
|
| 9.1 | It should allow adding multiple expense account rows | UI | [ ] |
|
||||||
| 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] |
|
| 9.2 | It should allow selecting an account, location, and amount per row | UI | [ ] |
|
||||||
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [ ] |
|
| 9.3 | It should auto-populate the location from the account's configured location, or default to "Shared" | Unit | [x] |
|
||||||
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [ ] |
|
| 9.4 | It should validate that expense account amounts sum to the invoice total | Unit + Integration | [x] |
|
||||||
| 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] |
|
| 9.5 | Given a "Shared" location, when the invoice is saved, then the amount should be spread equally across all client locations | Unit | [ ] |
|
||||||
| 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] |
|
| 9.6 | Given a "Shared" location with an odd total, when spread across N locations, then the remainder should be distributed 1 cent at a time to the first locations | Unit | [x] |
|
||||||
| 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] |
|
| 9.7 | Given a negative total, when spread across locations, then negative amounts should be distributed correctly | Unit | [x] |
|
||||||
@@ -174,12 +174,12 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [ ] |
|
| 11.1 | It should allow editing unpaid and paid invoices | Integration | [x] |
|
||||||
| 11.2 | It should disable the vendor field when editing | UI | [ ] |
|
| 11.2 | It should disable the vendor field when editing | UI | [ ] |
|
||||||
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [ ] |
|
| 11.3 | It should allow modifying expense account amounts, adding/removing accounts | Integration | [x] |
|
||||||
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [ ] |
|
| 11.4 | It should validate that modified amounts still sum to the total | Unit + Integration | [x] |
|
||||||
| 11.5 | Given the user saves changes, then the invoice row should update in place without a full page reload | UI | [ ] |
|
| 11.5 | Given the user saves changes, then the invoice row should update in place without a full page reload | UI | [ ] |
|
||||||
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 11.6 | It should block editing invoices with dates before the client's locked-until date | Integration | [x] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -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.1 | It should display a grid of selected invoices with vendor, number, total, and pay amount | UI | [ ] |
|
||||||
| 13.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [ ] |
|
| 13.2 | It should default to "Pay in full" mode, paying the outstanding balance of each invoice | Integration | [ ] |
|
||||||
| 13.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] |
|
| 13.3 | It should allow switching to "Customize payments" mode to set individual pay amounts | UI | [ ] |
|
||||||
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [ ] |
|
| 13.4 | It should validate that custom payment amounts do not exceed the outstanding balance | Unit + Integration | [x] |
|
||||||
| 13.5 | It should require a check number for handwritten checks | Integration | [ ] |
|
| 13.5 | It should require a check number for handwritten checks | Integration | [ ] |
|
||||||
| 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [ ] |
|
| 13.6 | It should block payment if the invoice date is before the client's locked-until date | Integration | [ ] |
|
||||||
| 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] |
|
| 13.7 | Given the user submits a check payment, when successful, then a PDF download link should be provided | Integration | [ ] |
|
||||||
@@ -223,10 +223,10 @@ Every mutating operation checks:
|
|||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] |
|
| 15.1 | It should allow selecting multiple invoices and opening the bulk edit wizard | UI | [ ] |
|
||||||
| 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
|
| 15.2 | It should allow adding expense account rows with account, location, and percentage | UI | [ ] |
|
||||||
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [ ] |
|
| 15.3 | It should validate that percentages sum to 100% | Unit + Integration | [x] |
|
||||||
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [ ] |
|
| 15.4 | Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts | Integration | [x] |
|
||||||
| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 15.5 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||||
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [ ] |
|
| 15.6 | It should spread "Shared" locations across all client locations, rounding cents correctly | Unit | [x] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -235,9 +235,9 @@ Every mutating operation checks:
|
|||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] |
|
| 16.1 | It should show a confirmation modal with the count of invoices to void | UI | [ ] |
|
||||||
| 16.2 | It should require admin permission for bulk void operations | Integration | [ ] |
|
| 16.2 | It should require admin permission for bulk void operations | Integration | [x] |
|
||||||
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [ ] |
|
| 16.3 | Given confirmed, when voiding, then linked cash payments should be voided automatically | Integration | [x] |
|
||||||
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [ ] |
|
| 16.4 | Given confirmed, when voiding, then each invoice's total, outstanding balance, and expense account amounts should be set to 0 | Integration | [x] |
|
||||||
| 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] |
|
| 16.5 | It should exclude invoices with linked non-cash payments | Integration | [ ] |
|
||||||
| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 16.6 | It should exclude invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||||
| 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] |
|
| 16.7 | Given successful voiding, then the table should refresh with a success notification | UI | [ ] |
|
||||||
@@ -248,9 +248,9 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [ ] |
|
| 17.1 | Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts | Integration | [x] |
|
||||||
| 17.2 | Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message | Integration | [ ] |
|
| 17.2 | Given a paid invoice with linked payments, when the user attempts to void it, then it should be blocked with an error message | Integration | [ ] |
|
||||||
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 17.3 | It should block voiding invoices with dates before the client's locked-until date | Integration | [x] |
|
||||||
| 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
|
| 17.4 | Given successful voiding, then the row should update in place with a "live-removed" animation | UI | [ ] |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -259,7 +259,7 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [ ] |
|
| 18.1 | Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history | Integration | [x] |
|
||||||
| 18.2 | It should require edit permission and client access | Integration | [ ] |
|
| 18.2 | It should require edit permission and client access | Integration | [ ] |
|
||||||
| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 18.3 | It should block unvoiding invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||||
| 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
|
| 18.4 | Given successful unvoiding, then the row should update in place with a flash animation | UI | [ ] |
|
||||||
@@ -270,10 +270,10 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [ ] |
|
| 19.1 | Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total | Integration | [x] |
|
||||||
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [ ] |
|
| 19.2 | It should block undoing autopay for invoices without scheduled payments | Unit + Integration | [x] |
|
||||||
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [ ] |
|
| 19.3 | It should block undoing autopay for invoices with linked payments | Unit + Integration | [x] |
|
||||||
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [ ] |
|
| 19.4 | It should block undoing autopay for invoices that are not paid | Unit + Integration | [x] |
|
||||||
| 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [ ] |
|
| 19.5 | It should block undoing autopay for invoices with dates before the client's locked-until date | Integration | [ ] |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -324,11 +324,11 @@ Every mutating operation checks:
|
|||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [ ] |
|
| 24.1 | It should extract total from AMOUNT_DUE or TOTAL fields | Unit | [x] |
|
||||||
| 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
| 24.2 | It should extract customer from CUSTOMER_NUMBER or RECEIVER_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
||||||
| 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
| 24.3 | It should extract vendor from VENDOR_NAME, falling back to Solr search | Unit + Integration | [ ] |
|
||||||
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [ ] |
|
| 24.4 | It should extract date from INVOICE_RECEIPT_DATE, ORDER_DATE, or DELIVERY_DATE | Unit | [x] |
|
||||||
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [ ] |
|
| 24.5 | It should extract invoice number from INVOICE_RECEIPT_ID or PO_NUMBER | Unit | [x] |
|
||||||
|
|
||||||
### Form Behaviors
|
### Form Behaviors
|
||||||
|
|
||||||
@@ -351,18 +351,18 @@ Every mutating operation checks:
|
|||||||
| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] |
|
| 26.2 | It should block invoice editing for users without `:edit` permission | Integration | [ ] |
|
||||||
| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] |
|
| 26.3 | It should block invoice voiding for users without `:delete` permission | Integration | [ ] |
|
||||||
| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] |
|
| 26.4 | It should block invoice payment for users without `:pay` permission | Integration | [ ] |
|
||||||
| 26.5 | It should block bulk delete for non-admin users | Integration | [ ] |
|
| 26.5 | It should block bulk delete for non-admin users | Integration | [x] |
|
||||||
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [ ] |
|
| 26.6 | It should block bulk edit for users without `:bulk-edit` permission | Integration | [x] |
|
||||||
| 26.7 | It should block import for users without `:import` permission | Integration | [ ] |
|
| 26.7 | It should block import for users without `:import` permission | Integration | [ ] |
|
||||||
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [ ] |
|
| 26.8 | It should verify the user has access to the invoice's client before any mutation | Integration | [x] |
|
||||||
|
|
||||||
### Lock Date Behaviors
|
### Lock Date Behaviors
|
||||||
|
|
||||||
| # | Behavior | Test Strategy | Status |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [ ] |
|
| 27.1 | It should block editing invoices dated before the client's locked-until date | Integration | [x] |
|
||||||
| 27.2 | It should block paying invoices dated before the client's locked-until date | Integration | [ ] |
|
| 27.2 | It should block paying invoices dated before the client's locked-until date | Integration | [ ] |
|
||||||
| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [ ] |
|
| 27.3 | It should block voiding invoices dated before the client's locked-until date | Integration | [x] |
|
||||||
| 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] |
|
| 27.4 | It should block importing invoices dated before the client's locked-until date | Integration | [ ] |
|
||||||
| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] |
|
| 27.5 | It should block approving imported invoices dated before the client's locked-until date | Integration | [ ] |
|
||||||
| 27.6 | It should filter out locked invoices from bulk operations | 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 |
|
| # | Behavior | Test Strategy | Status |
|
||||||
|---|----------|---------------|--------|
|
|---|----------|---------------|--------|
|
||||||
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [ ] |
|
| 28.1 | It should redirect old SPA routes (`/invoices`, `/invoices/unpaid`, etc.) to the new SSR routes | Integration | [x] |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -49,9 +49,9 @@
|
|||||||
(datomic-fn :upsert-entity #'iol-ion.tx.upsert-entity/upsert-entity)
|
(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-invoice #'iol-ion.tx.upsert-invoice/upsert-invoice)
|
||||||
(datomic-fn :upsert-ledger #'iol-ion.tx.upsert-ledger/upsert-ledger)
|
(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
|
(comment
|
||||||
(regenerate-literals)
|
(regenerate-literals)
|
||||||
(auto-ap.datomic/install-functions))
|
|
||||||
|
(auto-ap.datomic/install-functions))
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
(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)}]))))))
|
|
||||||
@@ -108,7 +108,11 @@
|
|||||||
"url": "https://mcp.context7.com/mcp",
|
"url": "https://mcp.context7.com/mcp",
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
"clojure-mcp": {
|
||||||
|
"type": "local",
|
||||||
|
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
|
||||||
|
"enabled": true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"permission": {
|
"permission": {
|
||||||
"read": "allow",
|
"read": "allow",
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
[{:db/valueType :db.type/double,
|
[{:db/valueType :db.type/boolean,
|
||||||
:db/cardinality :db.cardinality/one,
|
|
||||||
:db/noHistory true
|
|
||||||
:db/doc "The cached running balance for the account this line item is for",
|
|
||||||
:db/ident :journal-entry-line/running-balance,
|
|
||||||
}
|
|
||||||
{:db/valueType :db.type/boolean,
|
|
||||||
:db/cardinality :db.cardinality/one,
|
:db/cardinality :db.cardinality/one,
|
||||||
:db/noHistory true
|
:db/noHistory true
|
||||||
:db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances",
|
:db/doc "Whether or not this journal entry line is dirty and needs to recalculate balances",
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -905,6 +905,11 @@
|
|||||||
:db/cardinality #:db{:ident :db.cardinality/one},
|
:db/cardinality #:db{:ident :db.cardinality/one},
|
||||||
:db/doc "The client for the journal entry line",
|
:db/doc "The client for the journal entry line",
|
||||||
:db/ident :journal-entry-line/client}
|
:db/ident :journal-entry-line/client}
|
||||||
|
{:db/valueType #:db{:ident :db.type/double},
|
||||||
|
:db/cardinality #:db{:ident :db.cardinality/one},
|
||||||
|
:db/doc "The cached running balance for the account this line item is for",
|
||||||
|
:db/ident :journal-entry-line/running-balance,
|
||||||
|
:db/noHistory true}
|
||||||
{:db/valueType :db.type/tuple
|
{:db/valueType :db.type/tuple
|
||||||
:db/tupleAttrs [:journal-entry-line/client
|
:db/tupleAttrs [:journal-entry-line/client
|
||||||
:journal-entry-line/account
|
:journal-entry-line/account
|
||||||
@@ -1949,12 +1954,12 @@
|
|||||||
:db/unique :db.unique/identity
|
:db/unique :db.unique/identity
|
||||||
:db/index true}
|
:db/index true}
|
||||||
{:db/ident :sales-summary/client+dirty
|
{:db/ident :sales-summary/client+dirty
|
||||||
:db/valueType :db.type/tuple
|
:db/valueType :db.type/tuple
|
||||||
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
:db/tupleAttrs [:sales-summary/client :sales-summary/dirty]
|
||||||
:db/cardinality :db.cardinality/one
|
:db/cardinality :db.cardinality/one
|
||||||
:db/index true}
|
:db/index true}
|
||||||
|
|
||||||
{:db/ident :sales-summary-item/category
|
{:db/ident :sales-summary-item/category
|
||||||
:db/valueType :db.type/string
|
:db/valueType :db.type/string
|
||||||
:db/cardinality :db.cardinality/one}
|
:db/cardinality :db.cardinality/one}
|
||||||
{:db/ident :sales-summary-item/sort-order
|
{:db/ident :sales-summary-item/sort-order
|
||||||
|
|||||||
@@ -5,11 +5,10 @@
|
|||||||
[iol-ion.tx.propose-invoice]
|
[iol-ion.tx.propose-invoice]
|
||||||
[iol-ion.tx.reset-rels]
|
[iol-ion.tx.reset-rels]
|
||||||
[iol-ion.tx.reset-scalars]
|
[iol-ion.tx.reset-scalars]
|
||||||
[iol-ion.tx.upsert-entity]
|
[iol-ion.tx.upsert-entity]
|
||||||
[iol-ion.tx.upsert-invoice]
|
[iol-ion.tx.upsert-invoice]
|
||||||
[iol-ion.tx.upsert-ledger]
|
[iol-ion.tx.upsert-ledger]
|
||||||
[iol-ion.tx.upsert-transaction]
|
[iol-ion.tx.upsert-transaction]
|
||||||
[iol-ion.tx.upsert-sales-summary-ledger]
|
|
||||||
[com.github.ivarref.gen-fn :refer [gen-fn! datomic-fn]]
|
[com.github.ivarref.gen-fn :refer [gen-fn! datomic-fn]]
|
||||||
[auto-ap.utils :refer [default-pagination-size by]]
|
[auto-ap.utils :refer [default-pagination-size by]]
|
||||||
[clojure.edn :as edn]
|
[clojure.edn :as edn]
|
||||||
|
|||||||
@@ -278,42 +278,46 @@
|
|||||||
|
|
||||||
(defn sales-summaries-v2 []
|
(defn sales-summaries-v2 []
|
||||||
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
(doseq [[c client-code] (dc/q '[:find ?c ?client-code
|
||||||
:in $
|
:in $
|
||||||
:where [?c :client/code ?client-code]]
|
:where [?c :client/code ?client-code]]
|
||||||
(dc/db conn))
|
(dc/db conn))
|
||||||
{:sales-summary/keys [date] :db/keys [id] :as existing-summary} (dirty-sales-summaries c)]
|
{:sales-summary/keys [date] :db/keys [id]} (dirty-sales-summaries c)]
|
||||||
(mu/with-context {:client-code client-code
|
(mu/with-context {:client-code client-code
|
||||||
:date date}
|
:date date}
|
||||||
(alog/info ::updating)
|
(alog/info ::updating)
|
||||||
(let [manual-items (->> existing-summary
|
(let [result {:db/id id
|
||||||
:sales-summary/items
|
:sales-summary/client c
|
||||||
(filter :sales-summary-item/manual?))
|
:sales-summary/date date
|
||||||
calculated-items (->>
|
:sales-summary/dirty false
|
||||||
(get-sales c date)
|
:sales-summary/client+date [c date]
|
||||||
(concat (get-payment-items c date))
|
|
||||||
(concat (get-refund-items c date))
|
:sales-summary/items
|
||||||
(cons (get-discounts c date))
|
(->>
|
||||||
(cons (get-fees c date))
|
(get-sales c date)
|
||||||
(cons (get-tax c date))
|
(concat (get-payment-items c date))
|
||||||
(cons (get-tip c date))
|
(concat (get-refund-items c date))
|
||||||
(cons (get-returns c date))
|
(cons (get-discounts c date))
|
||||||
(filter identity)
|
(cons (get-fees c date))
|
||||||
(map (fn [z]
|
(cons (get-tax c date))
|
||||||
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
(cons (get-tip c date))
|
||||||
:sales-summary-item/manual? false))))
|
(cons (get-returns c date))
|
||||||
all-items (concat calculated-items manual-items)
|
(filter identity)
|
||||||
result {:db/id id
|
(map (fn [z]
|
||||||
:sales-summary/client c
|
(assoc z :ledger-mapped/account (some-> z :sales-summary-item/category str/lower-case name->number lookup-account)
|
||||||
:sales-summary/date date
|
:sales-summary-item/manual? false))
|
||||||
:sales-summary/dirty false
|
)) }]
|
||||||
:sales-summary/client+date [c date]
|
(if (seq (:sales-summary/items result))
|
||||||
:sales-summary/items all-items}]
|
(do
|
||||||
(if (seq (:sales-summary/items result))
|
(alog/info ::upserting-summaries
|
||||||
(do
|
:category-count (count (:sales-summary/items result)))
|
||||||
(alog/info ::upserting-summaries
|
@(dc/transact conn [[:upsert-entity result]]))
|
||||||
:category-count (count (:sales-summary/items result)))
|
@(dc/transact conn [{:db/id id :sales-summary/dirty false}]))))))
|
||||||
@(dc/transact conn [[:upsert-sales-summary 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)
|
||||||
|
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
(defn reset-summaries []
|
(defn reset-summaries []
|
||||||
@@ -330,39 +334,29 @@
|
|||||||
(comment
|
(comment
|
||||||
(auto-ap.datomic/transact-schema conn)
|
(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 "NGCL"] (last-n-days 30))
|
||||||
|
|
||||||
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
|
(apply mark-dirty [:client/code "NGDG"] (last-n-days 30))
|
||||||
|
|
||||||
(dirty-sales-summaries [:client/code "NGWH"])
|
(apply mark-dirty [:client/code "NGPG"] (last-n-days 30))
|
||||||
|
|
||||||
|
(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)
|
(delete-all)
|
||||||
|
|
||||||
|
|
||||||
(sales-summaries-v2)
|
(sales-summaries-v2)
|
||||||
|
|
||||||
|
|
||||||
1
|
|
||||||
|
|
||||||
|
|
||||||
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
|
(dc/q '[:find (pull ?sos [* {:sales-summary/sales-items [*]}])
|
||||||
:in $
|
:in $
|
||||||
:where [?sos :sales-summary/client [:client/code "NGHW"]]
|
:where [?sos :sales-summary/client [:client/code "NGHW"]]
|
||||||
[?sos :sales-summary/date ?d]
|
[?sos :sales-summary/date ?d]
|
||||||
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
[(= ?d #inst "2024-04-10T00:00:00-07:00")]]
|
||||||
(dc/db conn))
|
(dc/db conn))
|
||||||
|
|
||||||
(dc/q '[:find ?n ?p2 (sum ?total)
|
(dc/q '[:find ?n ?p2 (sum ?total)
|
||||||
:with ?c
|
:with ?c
|
||||||
:in $ [?clients ?start-date ?end-date]
|
:in $ [?clients ?start-date ?end-date]
|
||||||
@@ -375,21 +369,18 @@
|
|||||||
(dc/db conn)
|
(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"])
|
[[(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]
|
:in $ [?clients ?start-date ?end-date]
|
||||||
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
:where [(iol-ion.query/scan-sales-orders $ ?clients ?start-date ?end-date) [[?e _ ?sort-default] ...]]
|
||||||
[?e :sales-order/line-items ?li]
|
[?e :sales-order/line-items ?li]
|
||||||
[?li :order-line-item/item-name ?n]]
|
[?li :order-line-item/item-name ?n] ]
|
||||||
(dc/db conn)
|
(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"])
|
[[(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}])
|
||||||
|
|
||||||
@(dc/transact conn [{:db/id :sales-summary/total-tax :db/ident :sales-summary/total-tax-legacy}
|
(auto-ap.datomic/transact-schema conn)
|
||||||
{:db/id :sales-summary/total-tip :db/ident :sales-summary/total-tip-legacy}])
|
|
||||||
|
|
||||||
(auto-ap.datomic/transact-schema conn)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -35,42 +35,27 @@
|
|||||||
|
|
||||||
|
|
||||||
invoices-missing-ledger-entries (->> (dc/q {:find ['?t ]
|
invoices-missing-ledger-entries (->> (dc/q {:find ['?t ]
|
||||||
:in ['$ '?sd]
|
:in ['$ '?sd]
|
||||||
:where ['[?t :invoice/date ?d]
|
:where ['[?t :invoice/date ?d]
|
||||||
'[(>= ?d ?sd)]
|
'[(>= ?d ?sd)]
|
||||||
'(not [_ :journal-entry/original-entity ?t])
|
'(not [_ :journal-entry/original-entity ?t])
|
||||||
'[?t :invoice/total ?amt]
|
'[?t :invoice/total ?amt]
|
||||||
'[(not= 0.0 ?amt)]
|
'[(not= 0.0 ?amt)]
|
||||||
'(not [?t :invoice/status :invoice-status/voided])
|
'(not [?t :invoice/status :invoice-status/voided])
|
||||||
'(not [?t :invoice/import-status :import-status/pending])
|
'(not [?t :invoice/import-status :import-status/pending])
|
||||||
'(not [?t :invoice/exclude-from-ledger true])
|
'(not [?t :invoice/exclude-from-ledger true])
|
||||||
]}
|
]}
|
||||||
(dc/db conn) start-date)
|
(dc/db conn) start-date)
|
||||||
(map first)
|
(map first)
|
||||||
(mapv (fn [i]
|
(mapv (fn [i]
|
||||||
[:upsert-invoice {:db/id 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)
|
(when (seq repairs)
|
||||||
(mu/log ::ledger-repairs-needed
|
(mu/log ::ledger-repairs-needed
|
||||||
:sample (take 3 repairs)
|
:sample (take 3 repairs)
|
||||||
:transaction-count (count txes-missing-ledger-entries)
|
:transaction-count (count txes-missing-ledger-entries)
|
||||||
:invoice-count (count invoices-missing-ledger-entries)
|
:invoice-count (count invoices-missing-ledger-entries))
|
||||||
:sales-summary-count (count sales-summaries-missing-ledger-entries))
|
@(dc/transact conn repairs)))))
|
||||||
@(dc/transact conn repairs)))))
|
|
||||||
|
|
||||||
|
|
||||||
(defn touch-transaction [e]
|
(defn touch-transaction [e]
|
||||||
|
|||||||
@@ -213,8 +213,9 @@
|
|||||||
(fn [data-set]
|
(fn [data-set]
|
||||||
(reduce
|
(reduce
|
||||||
(fn [data-set x]
|
(fn [data-set x]
|
||||||
(let [thing (datomic->solr x)]
|
(if-let [thing (datomic->solr x)]
|
||||||
(update data-set index conj [(str/join " " (vals x)) thing])))
|
(update data-set index conj [(str/join " " (map str (vals thing))) thing])
|
||||||
|
data-set))
|
||||||
data-set
|
data-set
|
||||||
xs)))
|
xs)))
|
||||||
nil)
|
nil)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
(ns auto-ap.ssr.pos.sales-summaries
|
(ns auto-ap.ssr.admin.sales-summaries
|
||||||
(:require
|
(:require
|
||||||
[auto-ap.datomic
|
[auto-ap.datomic
|
||||||
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
:refer [apply-pagination apply-sort-3 conn merge-query pull-many
|
||||||
@@ -6,7 +6,9 @@
|
|||||||
[auto-ap.datomic.accounts :as d-accounts]
|
[auto-ap.datomic.accounts :as d-accounts]
|
||||||
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
[auto-ap.graphql.utils :refer [extract-client-ids]]
|
||||||
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
|
[auto-ap.query-params :refer [wrap-copy-qp-pqp]]
|
||||||
[auto-ap.routes.pos.sales-summaries :as route]
|
[auto-ap.routes.admin.sales-summaries :as route]
|
||||||
|
[auto-ap.routes.utils
|
||||||
|
:refer [wrap-admin wrap-client-redirect-unauthenticated]]
|
||||||
[auto-ap.ssr-routes :as ssr-routes]
|
[auto-ap.ssr-routes :as ssr-routes]
|
||||||
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
[auto-ap.ssr.common-handlers :refer [add-new-entity-handler]]
|
||||||
[auto-ap.ssr.components :as com]
|
[auto-ap.ssr.components :as com]
|
||||||
@@ -14,8 +16,6 @@
|
|||||||
[auto-ap.ssr.form-cursor :as fc]
|
[auto-ap.ssr.form-cursor :as fc]
|
||||||
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
|
||||||
[auto-ap.ssr.hx :as hx]
|
[auto-ap.ssr.hx :as hx]
|
||||||
[auto-ap.ssr.pos.common
|
|
||||||
:refer [date-range-field*]]
|
|
||||||
[auto-ap.ssr.svg :as svg]
|
[auto-ap.ssr.svg :as svg]
|
||||||
[auto-ap.ssr.utils
|
[auto-ap.ssr.utils
|
||||||
:refer [apply-middleware-to-all-handlers clj-date-schema
|
:refer [apply-middleware-to-all-handlers clj-date-schema
|
||||||
@@ -48,22 +48,38 @@
|
|||||||
"hx-target" "#entity-table"
|
"hx-target" "#entity-table"
|
||||||
"hx-indicator" "#entity-table"}
|
"hx-indicator" "#entity-table"}
|
||||||
|
|
||||||
[:fieldset.space-y-6
|
#_[:fieldset.space-y-6
|
||||||
(date-range-field* request)]])
|
(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}))]])
|
||||||
|
|
||||||
(def default-read '[:db/id
|
(def default-read '[:db/id
|
||||||
*
|
*
|
||||||
[:sales-summary/date :xform clj-time.coerce/from-date]
|
[:sales-summary/date :xform clj-time.coerce/from-date]
|
||||||
{:sales-summary/client [:client/code :client/name :db/id]}
|
{:sales-summary/client [:client/code :client/name :db/id]}
|
||||||
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]
|
{:sales-summary/items [{[:ledger-mapped/ledger-side :xform iol-ion.query/ident] [:db/ident]
|
||||||
}
|
} ;; TODO clientize
|
||||||
:ledger-mapped/account
|
:ledger-mapped/account
|
||||||
:ledger-mapped/amount
|
:ledger-mapped/amount
|
||||||
:sales-summary-item/category
|
:sales-summary-item/category
|
||||||
:sales-summary-item/sort-order
|
:sales-summary-item/sort-order
|
||||||
:db/id
|
:db/id
|
||||||
:sales-summary-item/manual?]
|
:sales-summary-item/manual?]
|
||||||
} ])
|
} ]) ;; TODO
|
||||||
|
|
||||||
(defn fetch-ids [db request]
|
(defn fetch-ids [db request]
|
||||||
(let [query-params (:query-params request)
|
(let [query-params (:query-params request)
|
||||||
@@ -113,12 +129,31 @@
|
|||||||
[(->> (hydrate-results ids-to-retrieve db request))
|
[(->> (hydrate-results ids-to-retrieve db request))
|
||||||
matching-count]))
|
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]
|
(defn sort-items [ss]
|
||||||
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
(sort-by (juxt :ledger-mapped/ledger-side :sales-summary-item/sort-order :sales-summary-item/category) ss))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn total-debits [items]
|
(defn total-debits [items]
|
||||||
(->> items
|
(->> items
|
||||||
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
|
(filter #(= :ledger-side/debit (:ledger-mapped/ledger-side %)))
|
||||||
@@ -134,7 +169,7 @@
|
|||||||
(def grid-page
|
(def grid-page
|
||||||
(helper/build {:id "entity-table"
|
(helper/build {:id "entity-table"
|
||||||
:id-fn :db/id
|
:id-fn :db/id
|
||||||
:nav com/main-aside-nav
|
:nav com/admin-aside-nav
|
||||||
:fetch-page fetch-page
|
:fetch-page fetch-page
|
||||||
:page-specific-nav filters
|
:page-specific-nav filters
|
||||||
:query-schema query-schema
|
:query-schema query-schema
|
||||||
@@ -144,11 +179,13 @@
|
|||||||
:db/id (:db/id entity))}
|
:db/id (:db/id entity))}
|
||||||
svg/pencil)])
|
svg/pencil)])
|
||||||
:oob-render
|
:oob-render
|
||||||
(fn [request]
|
(fn [request]
|
||||||
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)])
|
[#_(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
|
||||||
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||||
:company)}
|
:admin)}
|
||||||
"POS"]
|
"Admin"]
|
||||||
|
|
||||||
[:a {:href (bidi/path-for ssr-routes/only-routes
|
[:a {:href (bidi/path-for ssr-routes/only-routes
|
||||||
::route/page)}
|
::route/page)}
|
||||||
@@ -204,6 +241,14 @@
|
|||||||
:primary
|
:primary
|
||||||
:red)} "Total: " (format "$%,.2f" total-credits))]]))}]}))
|
: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 row* (partial helper/row* grid-page))
|
||||||
(def table* (partial helper/table* grid-page))
|
(def table* (partial helper/table* grid-page))
|
||||||
|
|
||||||
@@ -391,14 +436,15 @@
|
|||||||
(com/data-grid-header {} "")]}
|
(com/data-grid-header {} "")]}
|
||||||
(fc/with-field :sales-summary/items
|
(fc/with-field :sales-summary/items
|
||||||
(list
|
(list
|
||||||
(fc/cursor-map #(sales-summary-item-row* {:value %
|
(fc/cursor-map #(sales-summary-item-row* {:value %
|
||||||
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
|
:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state))) }))
|
||||||
(com/data-grid-new-row {:colspan 5
|
;; TODO
|
||||||
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
(com/data-grid-new-row {:colspan 5
|
||||||
:row-offset 0
|
:hx-get (bidi/path-for ssr-routes/only-routes ::route/new-summary-item)
|
||||||
:index (count (fc/field-value))
|
:row-offset 0
|
||||||
:tr-params {:hx-vals (hx/json {:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}}
|
:index (count (fc/field-value))
|
||||||
"New Summary Item")))
|
:tr-params {:hx-vals (hx/json {:client-id (:db/id (:sales-summary/client (:snapshot multi-form-state)))})}} ;; TODO
|
||||||
|
"New Summary Item")))
|
||||||
(summary-total-row* request)
|
(summary-total-row* request)
|
||||||
(unbalanced-row* request)) ])
|
(unbalanced-row* request)) ])
|
||||||
|
|
||||||
@@ -444,7 +490,7 @@
|
|||||||
edit-schema)
|
edit-schema)
|
||||||
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
(submit [this {:keys [multi-form-state request-method identity] :as request}]
|
||||||
(let [result (:snapshot multi-form-state )
|
(let [result (:snapshot multi-form-state )
|
||||||
transaction [:upsert-sales-summary {:db/id (:db/id result)
|
transaction [:upsert-entity {:db/id (:db/id result)
|
||||||
:sales-summary/items (map
|
:sales-summary/items (map
|
||||||
(fn [i]
|
(fn [i]
|
||||||
(if (:sales-summary-item/manual? i)
|
(if (:sales-summary-item/manual? i)
|
||||||
@@ -507,4 +553,8 @@
|
|||||||
(wrap-apply-sort grid-page)
|
(wrap-apply-sort grid-page)
|
||||||
(wrap-merge-prior-hx)
|
(wrap-merge-prior-hx)
|
||||||
(wrap-schema-enforce :query-schema query-schema)
|
(wrap-schema-enforce :query-schema query-schema)
|
||||||
(wrap-schema-enforce :hx-schema query-schema)))))
|
(wrap-schema-enforce :hx-schema query-schema)
|
||||||
|
(wrap-admin)
|
||||||
|
(wrap-client-redirect-unauthenticated)))))
|
||||||
|
|
||||||
|
|
||||||
@@ -6,8 +6,7 @@
|
|||||||
[auto-ap.routes.admin.excel-invoices :as ei-routes]
|
[auto-ap.routes.admin.excel-invoices :as ei-routes]
|
||||||
[auto-ap.routes.admin.import-batch :as ib-routes]
|
[auto-ap.routes.admin.import-batch :as ib-routes]
|
||||||
[auto-ap.routes.admin.transaction-rules :as transaction-rules]
|
[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.invoice :as invoice-route]
|
||||||
[auto-ap.routes.ledger :as ledger-routes]
|
[auto-ap.routes.ledger :as ledger-routes]
|
||||||
[auto-ap.routes.outgoing-invoice :as oi-routes]
|
[auto-ap.routes.outgoing-invoice :as oi-routes]
|
||||||
@@ -91,8 +90,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))
|
(#{::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"
|
"invoices"
|
||||||
|
|
||||||
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts ::ss-routes/page} (:matched-route request))
|
(#{:pos-sales :pos-expected-deposits :pos-tenders :pos-refunds :pos-cash-drawer-shifts} (:matched-route request))
|
||||||
"sales"
|
"sales"
|
||||||
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
(#{::payment-routes/all-page ::payment-routes/pending-page ::payment-routes/cleared-page ::payment-routes/voided-page} (:matched-route request))
|
||||||
"payments"
|
"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))
|
(#{::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))
|
||||||
@@ -208,18 +207,12 @@
|
|||||||
:hx-boost "true"}
|
:hx-boost "true"}
|
||||||
|
|
||||||
"Refunds")
|
"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
|
(menu-button- {:href (str (bidi/path-for ssr-routes/only-routes
|
||||||
::ss-routes/page)
|
:pos-cash-drawer-shifts)
|
||||||
"?date-range=week")
|
"?date-range=week")
|
||||||
:active? (= ::ss-routes/page (:matched-route request))
|
:active? (= :pos-cash-drawer-shifts (:matched-route request))
|
||||||
:hx-boost "true"}
|
:hx-boost "true"}
|
||||||
"Summaries"))))
|
"Cash drawer shifts"))))
|
||||||
|
|
||||||
(menu-button- {"@click.prevent" "if (selected == 'payments') {selected = null } else { selected = 'payments'} "
|
(menu-button- {"@click.prevent" "if (selected == 'payments') {selected = null } else { selected = 'payments'} "
|
||||||
:icon svg/payments}
|
:icon svg/payments}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
[auto-ap.ssr.admin.excel-invoice :as admin-excel-invoices]
|
[auto-ap.ssr.admin.excel-invoice :as admin-excel-invoices]
|
||||||
[auto-ap.ssr.admin.history :as history]
|
[auto-ap.ssr.admin.history :as history]
|
||||||
[auto-ap.ssr.admin.import-batch :as import-batch]
|
[auto-ap.ssr.admin.import-batch :as import-batch]
|
||||||
[auto-ap.ssr.pos.sales-summaries :as pos-sales-summaries]
|
[auto-ap.ssr.admin.sales-summaries :as admin-sales-summaries]
|
||||||
[auto-ap.ssr.admin.transaction-rules :as admin-rules]
|
[auto-ap.ssr.admin.transaction-rules :as admin-rules]
|
||||||
[auto-ap.ssr.admin.vendors :as admin-vendors]
|
[auto-ap.ssr.admin.vendors :as admin-vendors]
|
||||||
[auto-ap.ssr.auth :as auth]
|
[auto-ap.ssr.auth :as auth]
|
||||||
@@ -85,17 +85,17 @@
|
|||||||
(into company-1099/key->handler)
|
(into company-1099/key->handler)
|
||||||
(into invoice/key->handler)
|
(into invoice/key->handler)
|
||||||
(into import-batch/key->handler)
|
(into import-batch/key->handler)
|
||||||
(into pos-sales/key->handler)
|
(into pos-sales/key->handler)
|
||||||
(into pos-expected-deposits/key->handler)
|
(into pos-expected-deposits/key->handler)
|
||||||
(into pos-tenders/key->handler)
|
(into pos-tenders/key->handler)
|
||||||
(into pos-cash-drawer-shifts/key->handler)
|
(into pos-cash-drawer-shifts/key->handler)
|
||||||
(into pos-refunds/key->handler)
|
(into pos-refunds/key->handler)
|
||||||
(into pos-sales-summaries/key->handler)
|
(into users/key->handler)
|
||||||
(into users/key->handler)
|
(into admin-accounts/key->handler)
|
||||||
(into admin-accounts/key->handler)
|
(into admin-excel-invoices/key->handler)
|
||||||
(into admin-excel-invoices/key->handler)
|
(into admin/key->handler)
|
||||||
(into admin/key->handler)
|
(into admin-jobs/key->handler)
|
||||||
(into admin-jobs/key->handler)
|
(into admin-sales-summaries/key->handler)
|
||||||
(into admin-vendors/key->handler)
|
(into admin-vendors/key->handler)
|
||||||
(into admin-clients/key->handler)
|
(into admin-clients/key->handler)
|
||||||
(into admin-rules/key->handler)
|
(into admin-rules/key->handler)
|
||||||
|
|||||||
188
src/clj/user.clj
188
src/clj/user.clj
@@ -1,32 +1,33 @@
|
|||||||
(ns user
|
(ns user
|
||||||
(:require [amazonica.aws.s3 :as s3]
|
(:require [amazonica.aws.s3 :as s3]
|
||||||
[auto-ap.server]
|
[auto-ap.server]
|
||||||
[auto-ap.datomic :refer [conn pull-attr random-tempid]]
|
[auto-ap.datomic :refer [conn pull-attr random-tempid]]
|
||||||
[auto-ap.solr :as solr]
|
[auto-ap.solr :as solr]
|
||||||
[auto-ap.time :as atime]
|
[auto-ap.time :as atime]
|
||||||
[auto-ap.utils :refer [by]]
|
[auto-ap.utils :refer [by]]
|
||||||
[clj-time.coerce :as c]
|
[clj-time.coerce :as c]
|
||||||
[clj-time.core :as t]
|
[clj-time.core :as t]
|
||||||
[clojure.core.async :as async]
|
[clojure.core.async :as async]
|
||||||
[auto-ap.handler :refer [app]]
|
[auto-ap.handler :refer [app]]
|
||||||
[ring.adapter.jetty :refer [run-jetty]]
|
[ring.adapter.jetty :refer [run-jetty]]
|
||||||
[clojure.data.csv :as csv]
|
[clojure.data.csv :as csv]
|
||||||
[clojure.java.io :as io]
|
[clojure.java.io :as io]
|
||||||
[clojure.pprint]
|
[clojure.pprint]
|
||||||
[clojure.string :as str]
|
[clojure.string :as str]
|
||||||
[clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]]
|
[clojure.tools.namespace.repl :refer [refresh set-refresh-dirs]]
|
||||||
[com.brunobonacci.mulog :as mu]
|
[com.brunobonacci.mulog :as mu]
|
||||||
[com.brunobonacci.mulog.buffer :as rb]
|
[com.brunobonacci.mulog.buffer :as rb]
|
||||||
[config.core :refer [env]]
|
[config.core :refer [env]]
|
||||||
[datomic.api :as dc]
|
[datomic.api :as dc]
|
||||||
[puget.printer :as puget]
|
[datomic.api :as d]
|
||||||
[datomic.api :as d]
|
[puget.printer :as puget]
|
||||||
[figwheel.main.api]
|
|
||||||
[hawk.core]
|
[figwheel.main.api]
|
||||||
[mount.core :as mount]
|
[hawk.core]
|
||||||
[nrepl.middleware.print])
|
[mount.core :as mount]
|
||||||
(:import (org.apache.commons.io.input BOMInputStream)
|
[nrepl.middleware.print])
|
||||||
[org.eclipse.jetty.server.handler.gzip GzipHandler]))
|
(:import (org.apache.commons.io.input BOMInputStream)
|
||||||
|
[org.eclipse.jetty.server.handler.gzip GzipHandler]))
|
||||||
|
|
||||||
(defn println-event [item]
|
(defn println-event [item]
|
||||||
#_(printf "%s: %s - %s:%s by %s\n"
|
#_(printf "%s: %s - %s:%s by %s\n"
|
||||||
@@ -44,8 +45,7 @@
|
|||||||
item
|
item
|
||||||
:user)))
|
:user)))
|
||||||
(when (= :auto-ap.logging/peek (:mulog/event-name item))
|
(when (= :auto-ap.logging/peek (:mulog/event-name item))
|
||||||
(println "\u001B[31mTEST")
|
(println "\u001B[31mTEST"))
|
||||||
)
|
|
||||||
(when (:error item)
|
(when (:error item)
|
||||||
(println (:error item)))
|
(println (:error item)))
|
||||||
(puget/cprint (reduce
|
(puget/cprint (reduce
|
||||||
@@ -58,18 +58,15 @@
|
|||||||
{:seq-limit 10})
|
{:seq-limit 10})
|
||||||
(println))
|
(println))
|
||||||
|
|
||||||
|
|
||||||
(deftype DevPublisher [config buffer transform]
|
(deftype DevPublisher [config buffer transform]
|
||||||
|
|
||||||
com.brunobonacci.mulog.publisher.PPublisher
|
com.brunobonacci.mulog.publisher.PPublisher
|
||||||
(agent-buffer [_]
|
(agent-buffer [_]
|
||||||
buffer)
|
buffer)
|
||||||
|
|
||||||
|
|
||||||
(publish-delay [_]
|
(publish-delay [_]
|
||||||
200)
|
200)
|
||||||
|
|
||||||
|
|
||||||
(publish [_ buffer]
|
(publish [_ buffer]
|
||||||
;; items are pairs [offset <item>]
|
;; items are pairs [offset <item>]
|
||||||
(doseq [item (transform (map second (rb/items buffer)))]
|
(doseq [item (transform (map second (rb/items buffer)))]
|
||||||
@@ -77,8 +74,6 @@
|
|||||||
(flush)
|
(flush)
|
||||||
(rb/clear buffer)))
|
(rb/clear buffer)))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn dev-publisher
|
(defn dev-publisher
|
||||||
[{:keys [transform pretty?] :as config}]
|
[{:keys [transform pretty?] :as config}]
|
||||||
(DevPublisher. config (rb/agent-buffer 10000) (or transform identity)))
|
(DevPublisher. config (rb/agent-buffer 10000) (or transform identity)))
|
||||||
@@ -87,29 +82,27 @@
|
|||||||
[config]
|
[config]
|
||||||
(dev-publisher config))
|
(dev-publisher config))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn load-accounts [conn]
|
(defn load-accounts [conn]
|
||||||
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
(let [[header & rows] (-> "master-account-list.csv" (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
||||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||||
:db/id])]
|
:db/id])]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/name]]}
|
:where ['[?e :account/name]]}
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
|
|
||||||
also-merge-txes (fn [also-merge old-account-id]
|
also-merge-txes (fn [also-merge old-account-id]
|
||||||
(if old-account-id
|
(if old-account-id
|
||||||
(let [[sunset-account]
|
(let [[sunset-account]
|
||||||
(first (dc/q {:find ['?a]
|
(first (dc/q {:find ['?a]
|
||||||
:in ['$ '?ac]
|
:in ['$ '?ac]
|
||||||
:where ['[?a :account/numeric-code ?ac]]}
|
:where ['[?a :account/numeric-code ?ac]]}
|
||||||
(dc/db conn) also-merge))]
|
(dc/db conn) also-merge))]
|
||||||
(into (mapv
|
(into (mapv
|
||||||
(fn [[entity id _]]
|
(fn [[entity id _]]
|
||||||
[:db/add entity id old-account-id])
|
[:db/add entity id old-account-id])
|
||||||
(dc/q {:find ['?e '?id '?a]
|
(dc/q {:find ['?e '?id '?a]
|
||||||
:in ['$ '?ac]
|
:in ['$ '?ac]
|
||||||
:where ['[?a :account/numeric-code ?ac]
|
:where ['[?a :account/numeric-code ?ac]
|
||||||
'[?e ?at ?a]
|
'[?e ?at ?a]
|
||||||
'[?at :db/ident ?id]]}
|
'[?at :db/ident ?id]]}
|
||||||
@@ -120,7 +113,7 @@
|
|||||||
|
|
||||||
txes (transduce
|
txes (transduce
|
||||||
(comp
|
(comp
|
||||||
(map (fn ->map [r]
|
(map (fn ->map [r]
|
||||||
(into {} (map vector header r))))
|
(into {} (map vector header r))))
|
||||||
(map (fn parse-map [r]
|
(map (fn parse-map [r]
|
||||||
{:old-account-id (:db/id (code->existing-account
|
{:old-account-id (:db/id (code->existing-account
|
||||||
@@ -161,7 +154,6 @@
|
|||||||
(also-merge-txes also-merge old-account-id))
|
(also-merge-txes also-merge old-account-id))
|
||||||
tx)))))
|
tx)))))
|
||||||
|
|
||||||
|
|
||||||
conj
|
conj
|
||||||
[]
|
[]
|
||||||
rows)]
|
rows)]
|
||||||
@@ -169,8 +161,8 @@
|
|||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn find-bad-accounts []
|
(defn find-bad-accounts []
|
||||||
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
(set (map second (dc/q {:find ['(pull ?x [*]) '?z]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]
|
:where ['[?e :account/numeric-code ?z]
|
||||||
'[(<= ?z 9999)]
|
'[(<= ?z 9999)]
|
||||||
'[?x ?a ?e]]}
|
'[?x ?a ?e]]}
|
||||||
@@ -186,13 +178,12 @@
|
|||||||
[:db/retractEntity old-account-id])))
|
[:db/retractEntity old-account-id])))
|
||||||
conj
|
conj
|
||||||
[]
|
[]
|
||||||
(dc/q {:find ['?e]
|
(dc/q {:find ['?e]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]
|
:where ['[?e :account/numeric-code ?z]
|
||||||
'[(<= ?z 9999)]]}
|
'[(<= ?z 9999)]]}
|
||||||
(dc/db conn)))))
|
(dc/db conn)))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn find-conflicting-accounts []
|
(defn find-conflicting-accounts []
|
||||||
(filter
|
(filter
|
||||||
@@ -202,32 +193,30 @@
|
|||||||
(fn [acc [e z]]
|
(fn [acc [e z]]
|
||||||
(update acc z conj e))
|
(update acc z conj e))
|
||||||
{}
|
{}
|
||||||
(dc/q {:find ['?e '?z]
|
(dc/q {:find ['?e '?z]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/numeric-code ?z]]}
|
:where ['[?e :account/numeric-code ?z]]}
|
||||||
(dc/db conn)))))
|
(dc/db conn)))))
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn customize-accounts [customer filename]
|
(defn customize-accounts [customer filename]
|
||||||
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
(let [[_ & rows] (-> filename (io/resource) io/input-stream (BOMInputStream.) (io/reader) csv/read-csv)
|
||||||
[client-id] (first (dc/q (-> {:find ['?e]
|
[client-id] (first (dc/q (-> {:find ['?e]
|
||||||
:in ['$ '?z]
|
:in ['$ '?z]
|
||||||
:where [['?e :client/code '?z]]}
|
:where [['?e :client/code '?z]]}
|
||||||
(dc/db conn) customer)))
|
(dc/db conn) customer)))
|
||||||
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
code->existing-account (by :account/numeric-code (map first (dc/q {:find ['(pull ?e [:account/numeric-code
|
||||||
{:account/applicability [:db/ident]}
|
{:account/applicability [:db/ident]}
|
||||||
:db/id])]
|
:db/id])]
|
||||||
:in ['$]
|
:in ['$]
|
||||||
:where ['[?e :account/name]]}
|
:where ['[?e :account/name]]}
|
||||||
(dc/db conn))))
|
(dc/db conn))))
|
||||||
|
|
||||||
existing-account-overrides (dc/q {:find ['?e]
|
existing-account-overrides (dc/q {:find ['?e]
|
||||||
:in ['$ '?client-id]
|
:in ['$ '?client-id]
|
||||||
:where [['?e :account-client-override/client '?client-id]]}
|
:where [['?e :account-client-override/client '?client-id]]}
|
||||||
(dc/db conn) client-id)
|
(dc/db conn) client-id)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
_ (when-let [bad-rows (seq (->> rows
|
_ (when-let [bad-rows (seq (->> rows
|
||||||
(group-by (fn [[_ account]]
|
(group-by (fn [[_ account]]
|
||||||
account))
|
account))
|
||||||
@@ -285,12 +274,11 @@
|
|||||||
txes
|
txes
|
||||||
#_@(d/transact conn txes)))
|
#_@(d/transact conn txes)))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn fix-transactions-without-locations [client-code location]
|
(defn fix-transactions-without-locations [client-code location]
|
||||||
(->>
|
(->>
|
||||||
(dc/q {:find ['(pull ?e [*])]
|
(dc/q {:find ['(pull ?e [*])]
|
||||||
:in ['$ '?client-code]
|
:in ['$ '?client-code]
|
||||||
:where ['[?e :transaction/accounts ?ta]
|
:where ['[?e :transaction/accounts ?ta]
|
||||||
'[?e :transaction/matched-rule]
|
'[?e :transaction/matched-rule]
|
||||||
'[?e :transaction/approval-status :transaction-approval-status/approved]
|
'[?e :transaction/approval-status :transaction-approval-status/approved]
|
||||||
@@ -307,12 +295,11 @@
|
|||||||
accounts)))
|
accounts)))
|
||||||
vec))
|
vec))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn entity-history [i]
|
(defn entity-history [i]
|
||||||
(vec (sort-by first (dc/q
|
(vec (sort-by first (dc/q
|
||||||
{:find ['?tx '?z '?v]
|
{:find ['?tx '?z '?v]
|
||||||
:in ['?i '$]
|
:in ['?i '$]
|
||||||
:where ['[?i ?a ?v ?tx ?ad]
|
:where ['[?i ?a ?v ?tx ?ad]
|
||||||
'[?a :db/ident ?z]
|
'[?a :db/ident ?z]
|
||||||
'[(= ?ad true)]]}
|
'[(= ?ad true)]]}
|
||||||
@@ -321,8 +308,8 @@
|
|||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn entity-history-with-revert [i]
|
(defn entity-history-with-revert [i]
|
||||||
(vec (sort-by first (dc/q
|
(vec (sort-by first (dc/q
|
||||||
{:find ['?tx '?z '?v '?ad]
|
{:find ['?tx '?z '?v '?ad]
|
||||||
:in ['?i '$]
|
:in ['?i '$]
|
||||||
:where ['[?i ?a ?v ?tx ?ad]
|
:where ['[?i ?a ?v ?tx ?ad]
|
||||||
'[?a :db/ident ?z]]}
|
'[?a :db/ident ?z]]}
|
||||||
i (dc/history (dc/db conn))))))
|
i (dc/history (dc/db conn))))))
|
||||||
@@ -342,17 +329,15 @@
|
|||||||
{:start (- i 100)
|
{:start (- i 100)
|
||||||
:end (+ i 100)}))))
|
:end (+ i 100)}))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn start-db []
|
(defn start-db []
|
||||||
(mu/start-publisher! {:type :dev})
|
(mu/start-publisher! {:type :dev})
|
||||||
(mount.core/start (mount.core/only #{#'auto-ap.datomic/conn})))
|
(mount.core/start (mount.core/only #{#'auto-ap.datomic/conn})))
|
||||||
|
|
||||||
|
|
||||||
(defn- auto-reset-handler [ctx event]
|
(defn- auto-reset-handler [ctx event]
|
||||||
(require 'figwheel.main.api)
|
(require 'figwheel.main.api)
|
||||||
(binding [*ns* *ns*]
|
(binding [*ns* *ns*]
|
||||||
(clojure.tools.namespace.repl/refresh)
|
(clojure.tools.namespace.repl/refresh)
|
||||||
ctx))
|
ctx))
|
||||||
|
|
||||||
(defn auto-reset
|
(defn auto-reset
|
||||||
@@ -363,15 +348,13 @@
|
|||||||
(hawk.core/watch! [{:paths ["src/" "test/"]
|
(hawk.core/watch! [{:paths ["src/" "test/"]
|
||||||
:handler auto-reset-handler}]))
|
:handler auto-reset-handler}]))
|
||||||
|
|
||||||
|
(defn start-http []
|
||||||
(defn start-http []
|
|
||||||
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
|
(mount.core/start (mount.core/only #{#'auto-ap.server/port #'auto-ap.server/jetty})))
|
||||||
|
|
||||||
|
|
||||||
(defn start-dev []
|
(defn start-dev []
|
||||||
(set-refresh-dirs "src")
|
(set-refresh-dirs "src")
|
||||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
|
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.server))
|
||||||
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
|
#_(clojure.tools.namespace.repl/disable-reload! (find-ns 'auto-ap.time))
|
||||||
(start-db)
|
(start-db)
|
||||||
(start-http)
|
(start-http)
|
||||||
(auto-reset))
|
(auto-reset))
|
||||||
@@ -392,21 +375,20 @@
|
|||||||
(for [r data]
|
(for [r data]
|
||||||
((apply juxt columns) r)))))
|
((apply juxt columns) r)))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn find-queries [words]
|
(defn find-queries [words]
|
||||||
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
(let [obj (s3/list-objects-v2 :bucket-name (:data-bucket env)
|
||||||
:prefix (str "queries/"))
|
:prefix (str "queries/"))
|
||||||
concurrent 30
|
concurrent 30
|
||||||
output-chan (async/chan)]
|
output-chan (async/chan)]
|
||||||
(async/pipeline-blocking concurrent
|
(async/pipeline-blocking concurrent
|
||||||
output-chan
|
output-chan
|
||||||
(comp
|
(comp
|
||||||
(map #(do
|
(map #(do
|
||||||
[(:key %)
|
[(:key %)
|
||||||
(str (slurp (:object-content (s3/get-object
|
(str (slurp (:object-content (s3/get-object
|
||||||
:bucket-name (:data-bucket env)
|
:bucket-name (:data-bucket env)
|
||||||
:key (:key %)))))]))
|
:key (:key %)))))]))
|
||||||
|
|
||||||
(filter #(->> words
|
(filter #(->> words
|
||||||
(every? (fn [w] (str/includes? (second %) w)))))
|
(every? (fn [w] (str/includes? (second %) w)))))
|
||||||
@@ -418,12 +400,11 @@
|
|||||||
(println "failed " e)))
|
(println "failed " e)))
|
||||||
(async/<!! (async/into [] output-chan))))
|
(async/<!! (async/into [] output-chan))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn upsert-invoice-amounts [tsv]
|
(defn upsert-invoice-amounts [tsv]
|
||||||
(let [data (with-open [reader (io/reader (char-array tsv))]
|
(let [data (with-open [reader (io/reader (char-array tsv))]
|
||||||
(doall (csv/read-csv reader :separator \tab)))
|
(doall (csv/read-csv reader :separator \tab)))
|
||||||
db (dc/db conn)
|
db (dc/db conn)
|
||||||
i->invoice-id (fn [i]
|
i->invoice-id (fn [i]
|
||||||
(try (Long/parseLong i)
|
(try (Long/parseLong i)
|
||||||
(catch Exception e
|
(catch Exception e
|
||||||
@@ -460,15 +441,12 @@
|
|||||||
target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date))
|
target-date (clj-time.coerce/to-date (atime/parse target-date atime/normal-date))
|
||||||
current-date (:invoice/date invoice)
|
current-date (:invoice/date invoice)
|
||||||
|
|
||||||
|
|
||||||
current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0)
|
current-expense-account-amount (:invoice-expense-account/amount invoice-expense-account 0.0)
|
||||||
target-expense-account-amount (- (Double/parseDouble amount))
|
target-expense-account-amount (- (Double/parseDouble amount))
|
||||||
|
|
||||||
|
|
||||||
current-expense-account-location (:invoice-expense-account/location invoice-expense-account)
|
current-expense-account-location (:invoice-expense-account/location invoice-expense-account)
|
||||||
target-expense-account-location location
|
target-expense-account-location location
|
||||||
|
|
||||||
|
|
||||||
[[_ _ invoice-payment]] (vec (dc/q
|
[[_ _ invoice-payment]] (vec (dc/q
|
||||||
'[:find ?p ?a ?ip
|
'[:find ?p ?a ?ip
|
||||||
:in $ ?i
|
:in $ ?i
|
||||||
@@ -479,7 +457,7 @@
|
|||||||
:when current-total]
|
:when current-total]
|
||||||
|
|
||||||
[(when (not (auto-ap.utils/dollars= current-total target-total))
|
[(when (not (auto-ap.utils/dollars= current-total target-total))
|
||||||
{:db/id invoice-id
|
{:db/id invoice-id
|
||||||
:invoice/total target-total})
|
:invoice/total target-total})
|
||||||
|
|
||||||
(when new-account?
|
(when new-account?
|
||||||
@@ -512,7 +490,6 @@
|
|||||||
(filter identity)
|
(filter identity)
|
||||||
vec)))
|
vec)))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn get-schema [prefix]
|
(defn get-schema [prefix]
|
||||||
(->> (dc/q '[:find ?i
|
(->> (dc/q '[:find ?i
|
||||||
@@ -537,7 +514,6 @@
|
|||||||
(defn init-repl []
|
(defn init-repl []
|
||||||
(set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint))
|
(set! nrepl.middleware.print/*print-fn* clojure.pprint/pprint))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn sample-ledger-import
|
(defn sample-ledger-import
|
||||||
([client-code]
|
([client-code]
|
||||||
@@ -546,7 +522,7 @@
|
|||||||
(let [client-location (ffirst (d/q '[:find ?l :in $ ?c :where [?c :client/locations ?l]] (dc/db conn) [:client/code client-code]))]
|
(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
|
(clojure.data.csv/write-csv
|
||||||
*out*
|
*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))))
|
: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-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]]
|
{a-2 :account/numeric-code a-2-location :account/location}] (->> (d/q '[:find (pull ?a [:account/numeric-code :account/location]) :where [?a :account/numeric-code]]
|
||||||
@@ -559,12 +535,11 @@
|
|||||||
(t/minus (t/days (rand-int 60)))
|
(t/minus (t/days (rand-int 60)))
|
||||||
(atime/unparse atime/normal-date))
|
(atime/unparse atime/normal-date))
|
||||||
id (rand-int 100000)]
|
id (rand-int 100000)]
|
||||||
a [[(str id) client-code "synthetic" v d a-1 (or a-1-location client-location) 0 amount]
|
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]]]
|
[(str id) client-code "synthetic" v d a-2 (or a-2-location client-location) amount 0]]]
|
||||||
a)
|
a)
|
||||||
:separator \tab))))
|
:separator \tab))))
|
||||||
|
|
||||||
|
|
||||||
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
#_{:clj-kondo/ignore [:clojure-lsp/unused-public-var]}
|
||||||
(defn sample-manual-yodlee
|
(defn sample-manual-yodlee
|
||||||
([client-code]
|
([client-code]
|
||||||
@@ -573,7 +548,7 @@
|
|||||||
(let [bank-accounts (map first (d/q '[:find ?bac :in $ ?c :where [?c :client/bank-accounts ?b] [?b :bank-account/code ?bac]] (dc/db conn) [:client/code client-code]))]
|
(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
|
(clojure.data.csv/write-csv
|
||||||
*out*
|
*out*
|
||||||
(for [n (range n)
|
(for [n (range n)
|
||||||
:let [amount (rand-int 2000)
|
:let [amount (rand-int 2000)
|
||||||
d (-> (t/now)
|
d (-> (t/now)
|
||||||
(t/minus (t/days (rand-int 60)))
|
(t/minus (t/days (rand-int 60)))
|
||||||
@@ -582,8 +557,6 @@
|
|||||||
["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code])
|
["posted" d (str "Random Description - " id) "Travel" nil nil (- amount) nil nil nil nil nil (rand-nth bank-accounts) client-code])
|
||||||
:separator \tab))))
|
:separator \tab))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(defn index-solr
|
(defn index-solr
|
||||||
[]
|
[]
|
||||||
(println "invoice")
|
(println "invoice")
|
||||||
@@ -591,7 +564,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :invoice/invoice-number]
|
:where [?i :invoice/invoice-number]
|
||||||
(not [?i :invoice/status :invoice-status/voided])]
|
(not [?i :invoice/status :invoice-status/voided])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -604,7 +577,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :payment/date]
|
:where [?i :payment/date]
|
||||||
(not [?i :payment/status :payment-status/voided])]
|
(not [?i :payment/status :payment-status/voided])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -617,7 +590,7 @@
|
|||||||
:in $
|
:in $
|
||||||
:where [?i :transaction/description-original]
|
:where [?i :transaction/description-original]
|
||||||
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
|
(not [?i :transaction/approval-status :transaction-approval-status/suppressed])]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -628,7 +601,7 @@
|
|||||||
(doseq [batch (->> (dc/qseq {:query '[:find ?i
|
(doseq [batch (->> (dc/qseq {:query '[:find ?i
|
||||||
:in $
|
:in $
|
||||||
:where [?i :journal-entry/date]]
|
:where [?i :journal-entry/date]]
|
||||||
:args [(dc/db conn)]})
|
:args [(dc/db conn)]})
|
||||||
(map first)
|
(map first)
|
||||||
(partition-all 500))]
|
(partition-all 500))]
|
||||||
(print ".")
|
(print ".")
|
||||||
@@ -643,4 +616,3 @@
|
|||||||
(print ".")
|
(print ".")
|
||||||
@(dc/transact auto-ap.datomic/conn n)))
|
@(dc/transact auto-ap.datomic/conn n)))
|
||||||
|
|
||||||
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
(ns auto-ap.routes.pos.sales-summaries)
|
(ns auto-ap.routes.admin.sales-summaries)
|
||||||
(def routes {"" {:get ::page
|
(def routes {"" {:get ::page
|
||||||
:put ::edit-wizard-submit}
|
:put ::edit-wizard-submit}
|
||||||
"/table" ::table
|
"/table" ::table
|
||||||
|
|
||||||
["/" [#"\d+" :db/id]] {:get ::edit-wizard }
|
["/" [#"\d+" :db/id]] {:get ::edit-wizard }
|
||||||
|
|
||||||
"/edit/navigate" ::edit-wizard-navigate
|
"/edit/navigate" ::edit-wizard-navigate
|
||||||
"/edit/sales-summary-item" ::new-summary-item})
|
"/edit/sales-summary-item" ::new-summary-item})
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
[auto-ap.routes.transactions :as t-routes]
|
[auto-ap.routes.transactions :as t-routes]
|
||||||
|
|
||||||
[auto-ap.routes.admin.clients :as ac-routes]
|
[auto-ap.routes.admin.clients :as ac-routes]
|
||||||
[auto-ap.routes.pos.sales-summaries :as ss-routes]
|
[auto-ap.routes.admin.sales-summaries :as ss-routes]
|
||||||
[auto-ap.routes.admin.transaction-rules :as tr-routes]))
|
[auto-ap.routes.admin.transaction-rules :as tr-routes]))
|
||||||
|
|
||||||
(def routes {"impersonate" :impersonate
|
(def routes {"impersonate" :impersonate
|
||||||
|
|||||||
606
test/clj/auto_ap/integration/invoice_behaviors_test.clj
Normal file
606
test/clj/auto_ap/integration/invoice_behaviors_test.clj
Normal file
@@ -0,0 +1,606 @@
|
|||||||
|
(ns auto-ap.integration.invoice-behaviors-test
|
||||||
|
(:require
|
||||||
|
[auto-ap.datomic :as datomic]
|
||||||
|
[auto-ap.datomic.clients :refer [rebuild-search-index]]
|
||||||
|
[auto-ap.graphql.invoices :as gql-invoices]
|
||||||
|
[auto-ap.integration.util :refer [admin-token setup-test-data test-account
|
||||||
|
test-client test-invoice test-vendor
|
||||||
|
user-token user-token-no-access wrap-setup]]
|
||||||
|
[auto-ap.routes.invoices :as route-invoices]
|
||||||
|
[auto-ap.ssr.invoices :as ssr-invoices]
|
||||||
|
[auto-ap.time-reader]
|
||||||
|
[clj-time.coerce :as coerce]
|
||||||
|
[clj-time.core :as time]
|
||||||
|
[clojure.test :as t :refer [deftest is testing use-fixtures]]
|
||||||
|
[datomic.api :as dc]))
|
||||||
|
|
||||||
|
(use-fixtures :each wrap-setup)
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Permission Behaviors (26.x, 26.8)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-permission-client-access
|
||||||
|
(testing "Behavior 26.8: It should verify the user has access to the invoice's client before any mutation"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Block creation for user without client access
|
||||||
|
(is (thrown? Exception (gql-invoices/add-invoice
|
||||||
|
{:id (user-token-no-access)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "NO-ACCESS"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 10.00
|
||||||
|
:expense_accounts [{:amount 10.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))
|
||||||
|
;; Create invoice as admin, then block edit/void for user without client access
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "NO-ACCESS-EDIT"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
(is (thrown? Exception (gql-invoices/edit-invoice
|
||||||
|
{:id (user-token-no-access)}
|
||||||
|
{:invoice {:id (:id invoice)
|
||||||
|
:invoice_number "NO-ACCESS-EDIT-2"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))
|
||||||
|
(is (thrown? Exception (gql-invoices/void-invoice
|
||||||
|
{:id (user-token-no-access)}
|
||||||
|
{:invoice_id (:id invoice)}
|
||||||
|
nil)))))))
|
||||||
|
|
||||||
|
(deftest test-permission-bulk-void
|
||||||
|
(testing "Behavior 26.5: It should block bulk delete for non-admin users"
|
||||||
|
(let [{:strs [test-client-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(is (thrown? Exception (gql-invoices/void-invoices
|
||||||
|
{:id (user-token test-client-id)}
|
||||||
|
{:filters {:client_id test-client-id}}
|
||||||
|
nil))))))
|
||||||
|
|
||||||
|
(deftest test-permission-bulk-edit
|
||||||
|
(testing "Behavior 26.6: It should block bulk edit for users without :bulk-edit permission"
|
||||||
|
(let [{:strs [test-client-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(is (thrown? Exception (gql-invoices/bulk-change-invoices
|
||||||
|
{:id (user-token test-client-id)}
|
||||||
|
{:client_id test-client-id
|
||||||
|
:filters {:client_id test-client-id}
|
||||||
|
:accounts [{:percentage 1.0
|
||||||
|
:account_id test-account-id
|
||||||
|
:location "DT"}]}
|
||||||
|
nil))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Lock Date Behaviors (27.x)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-lock-date-edit
|
||||||
|
(testing "Behavior 27.1: It should block editing invoices dated before the client's locked-until date"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "LOCK-EDIT"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
;; Set lock date after invoice creation
|
||||||
|
@(dc/transact datomic/conn
|
||||||
|
[{:db/id test-client-id
|
||||||
|
:client/locked-until #inst "2022-06-01"}])
|
||||||
|
(is (thrown? Exception (gql-invoices/edit-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:id (:id invoice)
|
||||||
|
:invoice_number "LOCK-EDIT-2"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))))))
|
||||||
|
|
||||||
|
(deftest test-lock-date-void
|
||||||
|
(testing "Behavior 27.3: It should block voiding invoices dated before the client's locked-until date"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "LOCK-VOID"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
;; Set lock date after invoice creation
|
||||||
|
@(dc/transact datomic/conn
|
||||||
|
[{:db/id test-client-id
|
||||||
|
:client/locked-until #inst "2022-06-01"}])
|
||||||
|
(is (thrown? Exception (gql-invoices/void-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice_id (:id invoice)}
|
||||||
|
nil)))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; New Invoice Wizard (8.1, 8.5)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-new-invoice-validation
|
||||||
|
(testing "Behavior 8.1: It should require client, vendor, date, invoice number, and total"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Missing invoice number
|
||||||
|
(is (thrown? Exception (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 10.00
|
||||||
|
:expense_accounts [{:amount 10.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))
|
||||||
|
;; Missing total
|
||||||
|
(is (thrown? Exception (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "MISSING-TOTAL"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:expense_accounts [{:amount 10.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))))
|
||||||
|
|
||||||
|
(testing "Behavior 8.5: It should prevent duplicate invoice numbers for the same vendor and client"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Create first invoice
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "DUP-TEST"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
;; Try duplicate
|
||||||
|
(is (thrown? Exception (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "DUP-TEST"
|
||||||
|
:date #clj-time/date-time "2022-02-01"
|
||||||
|
:total 200.00
|
||||||
|
:expense_accounts [{:amount 200.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Edit Invoice (11.1, 11.3)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-edit-unpaid-invoice
|
||||||
|
(testing "Behavior 11.1: It should allow editing unpaid and paid invoices"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "EDIT-TEST"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
;; Edit unpaid invoice
|
||||||
|
(is (some? (gql-invoices/edit-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:id (:id invoice)
|
||||||
|
:invoice_number "EDITED"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 150.00
|
||||||
|
:expense_accounts [{:amount 150.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)))
|
||||||
|
;; Verify edit
|
||||||
|
(is (= "EDITED"
|
||||||
|
(:invoice/invoice-number (dc/pull (dc/db datomic/conn)
|
||||||
|
[:invoice/invoice-number]
|
||||||
|
(:id invoice)))))))))
|
||||||
|
|
||||||
|
(deftest test-edit-expense-accounts
|
||||||
|
(testing "Behavior 11.3: It should allow modifying expense account amounts, adding/removing accounts"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id new-account-id]}
|
||||||
|
(setup-test-data [(test-account :db/id "new-account-id")])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "EDIT-ACCTS"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
;; Add second expense account
|
||||||
|
(gql-invoices/edit-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:id (:id invoice)
|
||||||
|
:invoice_number "EDIT-ACCTS"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 60.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}
|
||||||
|
{:amount 40.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id new-account-id}]}}
|
||||||
|
nil)
|
||||||
|
(let [updated (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:invoice/expense-accounts [:invoice-expense-account/amount
|
||||||
|
:invoice-expense-account/account]}]
|
||||||
|
(:id invoice))]
|
||||||
|
(is (= 2 (count (:invoice/expense-accounts updated)))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Bulk Edit (15.4)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-bulk-edit-codes-invoices
|
||||||
|
(testing "Behavior 15.4: Given valid percentages, when submitted, then all selected invoices should be coded with the new expense accounts"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Create an invoice
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "BULK-EDIT"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
;; Bulk edit should change the expense account
|
||||||
|
(gql-invoices/bulk-change-invoices
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:client_id test-client-id
|
||||||
|
:filters {:client_id test-client-id}
|
||||||
|
:accounts [{:percentage 1.0
|
||||||
|
:account_id test-account-id
|
||||||
|
:location "DT"}]}
|
||||||
|
nil)
|
||||||
|
;; Verify the invoice still has the expense account
|
||||||
|
(let [updated (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:invoice/expense-accounts [:invoice-expense-account/account]}]
|
||||||
|
(:id invoice))]
|
||||||
|
(is (= test-account-id
|
||||||
|
(-> updated :invoice/expense-accounts first :invoice-expense-account/account :db/id))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Single/Bulk Void (17.1, 16.3, 16.4)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-void-unpaid-invoice
|
||||||
|
(testing "Behavior 17.1: Given an unpaid invoice with no linked payments, when the user voids it, then the invoice status should change to voided with zero amounts"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "VOID-TEST"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)]
|
||||||
|
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id (:id invoice)} nil)
|
||||||
|
(let [voided (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
|
||||||
|
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
||||||
|
(:id invoice))]
|
||||||
|
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident)))
|
||||||
|
(is (= 0.0 (:invoice/total voided)))
|
||||||
|
(is (= 0.0 (:invoice/outstanding-balance voided)))
|
||||||
|
(is (every? #(= 0.0 (:invoice-expense-account/amount %))
|
||||||
|
(:invoice/expense-accounts voided))))))))
|
||||||
|
|
||||||
|
(deftest test-bulk-void-cash-payments
|
||||||
|
(testing "Behavior 16.3: Given confirmed, when voiding, then linked cash payments should be voided automatically"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id test-bank-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "BULK-VOID-CASH"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
cash-payment-id (get-in @(dc/transact datomic/conn
|
||||||
|
[{:db/id "cash-pmt"
|
||||||
|
:payment/date #inst "2022-01-01"
|
||||||
|
:payment/client test-client-id
|
||||||
|
:payment/vendor test-vendor-id
|
||||||
|
:payment/bank-account test-bank-account-id
|
||||||
|
:payment/type :payment-type/cash
|
||||||
|
:payment/amount 100.0
|
||||||
|
:payment/status :payment-status/cleared}
|
||||||
|
{:db/id "ip"
|
||||||
|
:invoice-payment/invoice (:id invoice)
|
||||||
|
:invoice-payment/payment "cash-pmt"
|
||||||
|
:invoice-payment/amount 100.0}])
|
||||||
|
[:tempids "cash-pmt"])]
|
||||||
|
;; Bulk void should also void the cash payment
|
||||||
|
(gql-invoices/void-invoices
|
||||||
|
{:id (admin-token) :clients [{:db/id test-client-id}]}
|
||||||
|
{:filters {:client_id test-client-id}}
|
||||||
|
nil)
|
||||||
|
(let [payment (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:payment/status [:db/ident]}]
|
||||||
|
cash-payment-id)]
|
||||||
|
(is (= :payment-status/voided (-> payment :payment/status :db/ident))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Unvoid (18.1)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-unvoid-restores-invoice
|
||||||
|
(testing "Behavior 18.1: Given a voided invoice, when the user unvoids it, then it should restore the original status, total, outstanding balance, and expense accounts from Datomic history"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "UNVOID-TEST"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
original-id (:id invoice)]
|
||||||
|
;; Void the invoice
|
||||||
|
(gql-invoices/void-invoice {:id (admin-token)} {:invoice_id original-id} nil)
|
||||||
|
(let [voided (dc/pull (dc/db datomic/conn) [{:invoice/status [:db/ident]}] original-id)]
|
||||||
|
(is (= :invoice-status/voided (-> voided :invoice/status :db/ident))))
|
||||||
|
;; Unvoid the invoice
|
||||||
|
(gql-invoices/unvoid-invoice {:id (admin-token)} {:invoice_id original-id} nil)
|
||||||
|
(let [restored (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:invoice/status [:db/ident]} :invoice/total :invoice/outstanding-balance
|
||||||
|
{:invoice/expense-accounts [:invoice-expense-account/amount]}]
|
||||||
|
original-id)]
|
||||||
|
(is (= :invoice-status/unpaid (-> restored :invoice/status :db/ident)))
|
||||||
|
(is (= 100.0 (:invoice/total restored)))
|
||||||
|
(is (= 100.0 (:invoice/outstanding-balance restored)))
|
||||||
|
(is (= 100.0 (-> restored :invoice/expense-accounts first :invoice-expense-account/amount))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Undo Autopay (19.1)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-undo-autopay-resets-status
|
||||||
|
(testing "Behavior 19.1: Given a paid invoice with a scheduled payment and no linked payments, when the user undoes autopay, then the status should reset to unpaid and outstanding should equal total"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(let [invoice (gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "UNDO-AUTO"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
invoice-id (:id invoice)]
|
||||||
|
;; Mark as paid with scheduled payment
|
||||||
|
@(dc/transact datomic/conn
|
||||||
|
[[:upsert-invoice {:db/id invoice-id
|
||||||
|
:invoice/status :invoice-status/paid
|
||||||
|
:invoice/outstanding-balance 0.0
|
||||||
|
:invoice/scheduled-payment #inst "2022-02-01"}]])
|
||||||
|
;; Undo autopay
|
||||||
|
(gql-invoices/unautopay-invoice {:id (admin-token)} {:invoice_id invoice-id} nil)
|
||||||
|
(let [updated (dc/pull (dc/db datomic/conn)
|
||||||
|
[{:invoice/status [:db/ident]} :invoice/outstanding-balance :invoice/scheduled-payment]
|
||||||
|
invoice-id)]
|
||||||
|
(is (= :invoice-status/unpaid (-> updated :invoice/status :db/ident)))
|
||||||
|
(is (= 100.0 (:invoice/outstanding-balance updated)))
|
||||||
|
(is (nil? (:invoice/scheduled-payment updated))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Invoice List Query Behaviors (2.6, 2.8, 2.10, 2.14)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-invoice-list-filtering
|
||||||
|
(testing "Behaviors 2.6, 2.8, 2.10, 2.14: Invoice list filtering"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Create test invoices
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "FILTER-A"
|
||||||
|
:date #clj-time/date-time "2022-01-15"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "FILTER-B"
|
||||||
|
:date #clj-time/date-time "2022-02-15"
|
||||||
|
:total 200.00
|
||||||
|
:expense_accounts [{:amount 200.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
|
||||||
|
;; Filter by invoice number
|
||||||
|
(let [request {:query-params {:invoice-number "FILTER-A"}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices count] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= 1 count)))
|
||||||
|
|
||||||
|
;; Filter by status
|
||||||
|
(let [request {:query-params {}
|
||||||
|
:route-params {:status :invoice-status/unpaid}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices count] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= 2 count)))
|
||||||
|
|
||||||
|
;; Exact match by ID
|
||||||
|
(let [invoice-id (ffirst (dc/q '[:find ?i
|
||||||
|
:where [?i :invoice/invoice-number "FILTER-A"]]
|
||||||
|
(dc/db datomic/conn)))
|
||||||
|
request {:query-params {:exact-match-id invoice-id}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices count] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= 1 count))
|
||||||
|
(is (= invoice-id (:db/id (first invoices))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Invoice List Sorting (3.5, 3.7, 3.10)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-invoice-list-sorting
|
||||||
|
(testing "Behaviors 3.5, 3.7, 3.10: Invoice list sorting"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "SORT-B"
|
||||||
|
:date #clj-time/date-time "2022-02-01"
|
||||||
|
:total 200.00
|
||||||
|
:expense_accounts [{:amount 200.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number "SORT-A"
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil)
|
||||||
|
|
||||||
|
;; Sort by date ascending
|
||||||
|
(let [request {:query-params {:sort [{:sort-key "date" :asc true}]}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices count] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
|
||||||
|
|
||||||
|
;; Sort by invoice number
|
||||||
|
(let [request {:query-params {:sort [{:sort-key "invoice-number" :asc true}]}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices count] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= "SORT-A" (:invoice/invoice-number (first invoices)))))
|
||||||
|
|
||||||
|
;; Toggle sort direction
|
||||||
|
(let [request-asc {:query-params {:sort [{:sort-key "date" :asc true}]}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices-asc count] (ssr-invoices/fetch-page request-asc)
|
||||||
|
request-desc {:query-params {:sort [{:sort-key "date" :asc false}]}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices-desc count] (ssr-invoices/fetch-page request-desc)]
|
||||||
|
(is (not= (:invoice/invoice-number (first invoices-asc))
|
||||||
|
(:invoice/invoice-number (first invoices-desc))))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Invoice List Pagination (4.1, 4.3)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-invoice-list-pagination
|
||||||
|
(testing "Behaviors 4.1, 4.3: Invoice list pagination"
|
||||||
|
(let [{:strs [test-client-id test-vendor-id test-account-id]}
|
||||||
|
(setup-test-data [])]
|
||||||
|
;; Create 30 invoices
|
||||||
|
(doseq [i (range 30)]
|
||||||
|
(gql-invoices/add-invoice
|
||||||
|
{:id (admin-token)}
|
||||||
|
{:invoice {:client_id test-client-id
|
||||||
|
:vendor_id test-vendor-id
|
||||||
|
:invoice_number (str "PAGE-" i)
|
||||||
|
:date #clj-time/date-time "2022-01-01"
|
||||||
|
:total 100.00
|
||||||
|
:expense_accounts [{:amount 100.0
|
||||||
|
:location "DT"
|
||||||
|
:account_id test-account-id}]}}
|
||||||
|
nil))
|
||||||
|
|
||||||
|
;; Default 25 per page
|
||||||
|
(let [request {:query-params {}
|
||||||
|
:route-params {:status nil}
|
||||||
|
:clients [{:db/id test-client-id}]}
|
||||||
|
[invoices total-count total-outstanding total-amount] (ssr-invoices/fetch-page request)]
|
||||||
|
(is (= 25 (count invoices)))
|
||||||
|
(is (= 30 total-count))
|
||||||
|
(is (= 3000.0 total-outstanding))
|
||||||
|
(is (= 3000.0 total-amount))))))
|
||||||
|
|
||||||
|
;; ============================================================================
|
||||||
|
;; Legacy Routes (28.1)
|
||||||
|
;; ============================================================================
|
||||||
|
|
||||||
|
(deftest test-legacy-routes
|
||||||
|
(testing "Behavior 28.1: It should redirect old SPA routes to the new SSR routes"
|
||||||
|
(let [handler (ssr-invoices/redirect-handler ::ssr-invoices/route/all-page)
|
||||||
|
response (handler {:query-params {}})]
|
||||||
|
(is (= 302 (:status response)))
|
||||||
|
(is (get-in response [:headers "Location"])))))
|
||||||
@@ -23,37 +23,37 @@
|
|||||||
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
|
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
|
||||||
(is (seq (sut/stream->sales-orders s))))
|
(is (seq (sut/stream->sales-orders s))))
|
||||||
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
|
(with-open [s (io/input-stream (io/resource "sample-ezcater.xlsx"))]
|
||||||
(is (= #:sales-order
|
(is (= #:sales-order
|
||||||
{:vendor :vendor/ccp-ezcater
|
{:vendor :vendor/ccp-ezcater
|
||||||
:service-charge -95.9
|
:service-charge -95.9
|
||||||
:date #inst "2023-04-03T18:30:00"
|
:date #inst "2023-04-03T18:30:00"
|
||||||
:reference-link "ZA2-320"
|
:reference-link "ZA2-320"
|
||||||
:charges
|
:charges
|
||||||
[#:charge{:type-name "CARD"
|
[#:charge{:type-name "CARD"
|
||||||
:date #inst "2023-04-03T18:30:00"
|
:date #inst "2023-04-03T18:30:00"
|
||||||
:client test-client
|
:client test-client
|
||||||
:location "DT"
|
:location "DT"
|
||||||
:external-id
|
:external-id
|
||||||
"ezcater/charge/17592186045501-DT-ZA2-320-0"
|
(str "ezcater/charge/" test-client "-DT-ZA2-320-0")
|
||||||
:processor :ccp-processor/ezcater
|
:processor :ccp-processor/ezcater
|
||||||
:total 516.12
|
:total 516.12
|
||||||
:tip 0.0}]
|
:tip 0.0}]
|
||||||
:client test-client
|
:client test-client
|
||||||
:tip 0.0
|
:tip 0.0
|
||||||
:tax 37.12
|
:tax 37.12
|
||||||
:external-id "ezcater/order/17592186045501-DT-ZA2-320"
|
:external-id (str "ezcater/order/" test-client "-DT-ZA2-320")
|
||||||
:total 516.12
|
:total 516.12
|
||||||
:line-items
|
:line-items
|
||||||
[#:order-line-item{:external-id
|
[#:order-line-item{:external-id
|
||||||
"ezcater/order/17592186045501-DT-ZA2-320-0"
|
(str "ezcater/order/" test-client "-DT-ZA2-320-0")
|
||||||
:item-name "EZCater Catering"
|
:item-name "EZCater Catering"
|
||||||
:category "EZCater Catering"
|
:category "EZCater Catering"
|
||||||
:discount 0.0
|
:discount 0.0
|
||||||
:tax 37.12
|
:tax 37.12
|
||||||
:total 516.12}]
|
:total 516.12}]
|
||||||
:discount 0.0
|
:discount 0.0
|
||||||
:location "DT"
|
:location "DT"
|
||||||
:returns 0.0}
|
:returns 0.0}
|
||||||
(last (first (filter (comp #{:order} first)
|
(last (first (filter (comp #{:order} first)
|
||||||
(sut/stream->sales-orders s)))))))))))
|
(sut/stream->sales-orders s)))))))))))
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,8 @@
|
|||||||
|
|
||||||
(defn wrap-setup
|
(defn wrap-setup
|
||||||
[f]
|
[f]
|
||||||
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"]
|
(with-redefs [auto-ap.datomic/uri "datomic:mem://test"
|
||||||
|
auto-ap.solr/impl (auto-ap.solr/->InMemSolrClient (atom {}))]
|
||||||
(dc/create-database auto-ap.datomic/uri)
|
(dc/create-database auto-ap.datomic/uri)
|
||||||
(with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)]
|
(with-redefs [auto-ap.datomic/conn (dc/connect auto-ap.datomic/uri)]
|
||||||
(transact-schema conn)
|
(transact-schema conn)
|
||||||
@@ -28,6 +29,13 @@
|
|||||||
:user/name "TEST USER"
|
:user/name "TEST USER"
|
||||||
:user/clients [{:db/id client-id}]}))
|
:user/clients [{:db/id client-id}]}))
|
||||||
|
|
||||||
|
(defn user-token-no-access []
|
||||||
|
{:user "TEST USER"
|
||||||
|
:exp (time/plus (time/now) (time/days 1))
|
||||||
|
:user/role "user"
|
||||||
|
:user/name "TEST USER"
|
||||||
|
:user/clients []})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -101,16 +109,18 @@
|
|||||||
(dissoc x :id))
|
(dissoc x :id))
|
||||||
|
|
||||||
(defn setup-test-data [data]
|
(defn setup-test-data [data]
|
||||||
(:tempids @(dc/transact conn (into data
|
(let [defaults [(test-account :db/id "test-account-id")
|
||||||
[(test-account :db/id "test-account-id")
|
(test-client :db/id "test-client-id"
|
||||||
(test-client :db/id "test-client-id"
|
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
|
||||||
:client/bank-accounts [(test-bank-account :db/id "test-bank-account-id")])
|
(test-vendor :db/id "test-vendor-id")
|
||||||
(test-vendor :db/id "test-vendor-id")
|
{:db/id "accounts-payable-id"
|
||||||
{:db/id "accounts-payable-id"
|
:account/name "Accounts Payable"
|
||||||
:account/name "Accounts Payable"
|
:db/ident :account/accounts-payable
|
||||||
:db/ident :account/accounts-payable
|
:account/numeric-code 21000
|
||||||
:account/numeric-code 21000
|
:account/account-set "default"}]
|
||||||
:account/account-set "default"}]))))
|
user-ids (set (keep :db/id data))
|
||||||
|
merged (into [] (concat data (remove #(user-ids (:db/id %)) defaults)))]
|
||||||
|
(:tempids @(dc/transact conn merged))))
|
||||||
|
|
||||||
(defn apply-tx [data]
|
(defn apply-tx [data]
|
||||||
(:db-after @(dc/transact conn data)))
|
(:db-after @(dc/transact conn data)))
|
||||||
|
|||||||
408
test/clj/auto_ap/ssr/invoice/invoice_unit_test.clj
Normal file
408
test/clj/auto_ap/ssr/invoice/invoice_unit_test.clj
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
(ns auto-ap.ssr.invoice.invoice-unit-test
|
||||||
|
(:require [clojure.test :refer [deftest testing is]]
|
||||||
|
[auto-ap.ssr.invoice.new-invoice-wizard :as sut]
|
||||||
|
[auto-ap.ssr.invoices :as invoices]
|
||||||
|
[auto-ap.ssr.invoice.glimpse :as glimpse]
|
||||||
|
[slingshot.slingshot :refer [try+]]
|
||||||
|
[clj-time.core :as time]))
|
||||||
|
|
||||||
|
(deftest assert-invoice-amounts-add-up-test
|
||||||
|
(testing "Valid when expense accounts sum equals invoice total"
|
||||||
|
(is (nil? (sut/assert-invoice-amounts-add-up
|
||||||
|
{:invoice/expense-accounts [{:invoice-expense-account/amount 50.0}
|
||||||
|
{:invoice-expense-account/amount 50.0}]
|
||||||
|
:invoice/total 100.0}))))
|
||||||
|
|
||||||
|
(testing "Valid with single expense account matching total"
|
||||||
|
(is (nil? (sut/assert-invoice-amounts-add-up
|
||||||
|
{:invoice/expense-accounts [{:invoice-expense-account/amount 100.0}]
|
||||||
|
:invoice/total 100.0}))))
|
||||||
|
|
||||||
|
(testing "Valid with floating point amounts within tolerance"
|
||||||
|
(is (nil? (sut/assert-invoice-amounts-add-up
|
||||||
|
{:invoice/expense-accounts [{:invoice-expense-account/amount 33.33}
|
||||||
|
{:invoice-expense-account/amount 33.33}
|
||||||
|
{:invoice-expense-account/amount 33.34}]
|
||||||
|
:invoice/total 100.0}))))
|
||||||
|
|
||||||
|
(testing "Throws when expense accounts sum does not equal total"
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo
|
||||||
|
(sut/assert-invoice-amounts-add-up
|
||||||
|
{:invoice/expense-accounts [{:invoice-expense-account/amount 40.0}]
|
||||||
|
:invoice/total 100.0}))))
|
||||||
|
|
||||||
|
(testing "Throws when expense accounts sum is greater than total"
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo
|
||||||
|
(sut/assert-invoice-amounts-add-up
|
||||||
|
{:invoice/expense-accounts [{:invoice-expense-account/amount 150.0}]
|
||||||
|
:invoice/total 100.0})))))
|
||||||
|
|
||||||
|
(deftest does-amount-exceed-outstanding-test
|
||||||
|
(testing "Valid when amount equals positive outstanding balance"
|
||||||
|
(is (not (invoices/does-amount-exceed-outstanding? 100.0 100.0))))
|
||||||
|
|
||||||
|
(testing "Valid when amount is less than positive outstanding balance"
|
||||||
|
(is (not (invoices/does-amount-exceed-outstanding? 50.0 100.0))))
|
||||||
|
|
||||||
|
(testing "Invalid when amount exceeds positive outstanding balance"
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? 150.0 100.0)))
|
||||||
|
|
||||||
|
(testing "Invalid when amount is zero or negative for positive outstanding"
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? 0.0 100.0))
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? -10.0 100.0)))
|
||||||
|
|
||||||
|
(testing "Valid when amount equals negative outstanding balance"
|
||||||
|
(is (not (invoices/does-amount-exceed-outstanding? -100.0 -100.0))))
|
||||||
|
|
||||||
|
(testing "Valid when amount is greater than negative outstanding balance"
|
||||||
|
(is (not (invoices/does-amount-exceed-outstanding? -50.0 -100.0))))
|
||||||
|
|
||||||
|
(testing "Invalid when amount is less than negative outstanding balance"
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? -150.0 -100.0)))
|
||||||
|
|
||||||
|
(testing "Invalid when amount is zero or positive for negative outstanding"
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? 0.0 -100.0))
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? 10.0 -100.0)))
|
||||||
|
|
||||||
|
(testing "Invalid when amount is non-zero for zero outstanding"
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? 10.0 0.0))
|
||||||
|
(is (invoices/does-amount-exceed-outstanding? -10.0 0.0)))
|
||||||
|
|
||||||
|
(testing "Valid when amount is zero for zero outstanding"
|
||||||
|
(is (not (invoices/does-amount-exceed-outstanding? 0.0 0.0)))))
|
||||||
|
|
||||||
|
(deftest assert-percentages-add-up-test
|
||||||
|
(testing "Valid when percentages sum to 100%"
|
||||||
|
(is (nil? (invoices/assert-percentages-add-up
|
||||||
|
{:expense-accounts [{:percentage 0.5}
|
||||||
|
{:percentage 0.5}]}))))
|
||||||
|
|
||||||
|
(testing "Valid with single account at 100%"
|
||||||
|
(is (nil? (invoices/assert-percentages-add-up
|
||||||
|
{:expense-accounts [{:percentage 1.0}]}))))
|
||||||
|
|
||||||
|
(testing "Valid with floating point within tolerance"
|
||||||
|
(is (nil? (invoices/assert-percentages-add-up
|
||||||
|
{:expense-accounts [{:percentage 0.333}
|
||||||
|
{:percentage 0.333}
|
||||||
|
{:percentage 0.334}]}))))
|
||||||
|
|
||||||
|
(testing "Throws when percentages sum to less than 100%"
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo
|
||||||
|
(invoices/assert-percentages-add-up
|
||||||
|
{:expense-accounts [{:percentage 0.5}]}))))
|
||||||
|
|
||||||
|
(testing "Throws when percentages sum to more than 100%"
|
||||||
|
(is (thrown? clojure.lang.ExceptionInfo
|
||||||
|
(invoices/assert-percentages-add-up
|
||||||
|
{:expense-accounts [{:percentage 0.8}
|
||||||
|
{:percentage 0.8}]})))))
|
||||||
|
|
||||||
|
(deftest stack-rank-test
|
||||||
|
(testing "Ranks fields by confidence and returns text values"
|
||||||
|
(let [fields [{:type {:text "AMOUNT_DUE" :confidence 0.9}
|
||||||
|
:value-detection {:text "$123.45" :confidence 0.95}}
|
||||||
|
{:type {:text "AMOUNT_DUE" :confidence 0.8}
|
||||||
|
:value-detection {:text "$100.00" :confidence 0.9}}
|
||||||
|
{:type {:text "TOTAL" :confidence 0.9}
|
||||||
|
:value-detection {:text "$150.00" :confidence 0.85}}]]
|
||||||
|
(is (= ["$123.45" "$150.00" "$100.00"]
|
||||||
|
(glimpse/stack-rank #{"AMOUNT_DUE" "TOTAL"} fields)))))
|
||||||
|
|
||||||
|
(testing "Filters out fields not in valid-values set"
|
||||||
|
(let [fields [{:type {:text "AMOUNT_DUE" :confidence 0.9}
|
||||||
|
:value-detection {:text "$123.45" :confidence 0.95}}
|
||||||
|
{:type {:text "OTHER" :confidence 0.9}
|
||||||
|
:value-detection {:text "$999.00" :confidence 0.99}}]]
|
||||||
|
(is (= ["$123.45"]
|
||||||
|
(glimpse/stack-rank #{"AMOUNT_DUE"} fields)))))
|
||||||
|
|
||||||
|
(testing "Returns empty when no fields match"
|
||||||
|
(is (empty? (glimpse/stack-rank #{"TOTAL"} []))))
|
||||||
|
|
||||||
|
(testing "Filters blank values"
|
||||||
|
(let [fields [{:type {:text "TOTAL" :confidence 0.9}
|
||||||
|
:value-detection {:text "" :confidence 0.95}}
|
||||||
|
{:type {:text "TOTAL" :confidence 0.8}
|
||||||
|
:value-detection {:text " " :confidence 0.9}}]]
|
||||||
|
(is (empty? (glimpse/stack-rank #{"TOTAL"} fields))))))
|
||||||
|
|
||||||
|
(deftest deduplicate-test
|
||||||
|
(testing "Removes duplicate parsed values keeping first occurrence"
|
||||||
|
(let [data [["$123.45" 123.45]
|
||||||
|
["123.45" 123.45]
|
||||||
|
["$100.00" 100.0]
|
||||||
|
["100" 100.0]]]
|
||||||
|
(is (= [["$123.45" 123.45] ["$100.00" 100.0]]
|
||||||
|
(glimpse/deduplicate data)))))
|
||||||
|
|
||||||
|
(testing "Returns empty for empty input"
|
||||||
|
(is (empty? (glimpse/deduplicate []))))
|
||||||
|
|
||||||
|
(testing "Preserves all unique values"
|
||||||
|
(let [data [["A" 1] ["B" 2] ["C" 3]]]
|
||||||
|
(is (= [["A" 1] ["B" 2] ["C" 3]]
|
||||||
|
(glimpse/deduplicate data)))))
|
||||||
|
|
||||||
|
(testing "Handles nil parsed values (nil is not deduplicated due to set semantics)"
|
||||||
|
(let [data [["A" nil] ["B" nil] ["C" 3]]]
|
||||||
|
(is (= [["A" nil] ["B" nil] ["C" 3]]
|
||||||
|
(glimpse/deduplicate data))))))
|
||||||
|
|
||||||
|
(deftest clientize-vendor-test
|
||||||
|
(testing "Returns nil when vendor is nil"
|
||||||
|
(is (nil? (sut/clientize-vendor nil 123))))
|
||||||
|
|
||||||
|
(testing "Applies terms override for matching client"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/terms-overrides [{:vendor-terms-override/client {:db/id 123}
|
||||||
|
:vendor-terms-override/terms 15}]
|
||||||
|
:vendor/automatically-paid-when-due []
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (= 15 (:vendor/terms (sut/clientize-vendor vendor 123))))))
|
||||||
|
|
||||||
|
(testing "Keeps default terms when no override for client"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/terms-overrides [{:vendor-terms-override/client {:db/id 999}
|
||||||
|
:vendor-terms-override/terms 15}]
|
||||||
|
:vendor/automatically-paid-when-due []
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (= 30 (:vendor/terms (sut/clientize-vendor vendor 123))))))
|
||||||
|
|
||||||
|
(testing "Applies account override for matching client"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/account-overrides [{:vendor-account-override/client {:db/id 123}
|
||||||
|
:vendor-account-override/account {:db/id 2 :account/name "Override"}}]
|
||||||
|
:vendor/automatically-paid-when-due []
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (= "Override" (:account/name (:vendor/default-account (sut/clientize-vendor vendor 123)))))))
|
||||||
|
|
||||||
|
(testing "Uses default account when no account override for client"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/account-overrides [{:vendor-account-override/client {:db/id 999}
|
||||||
|
:vendor-account-override/account {:db/id 2 :account/name "Override"}}]
|
||||||
|
:vendor/automatically-paid-when-due []
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (= "Food" (:account/name (:vendor/default-account (sut/clientize-vendor vendor 123)))))))
|
||||||
|
|
||||||
|
(testing "Sets automatically-paid-when-due when client is in the list"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/automatically-paid-when-due [{:db/id 123}]
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (true? (:vendor/automatically-paid-when-due (sut/clientize-vendor vendor 123))))))
|
||||||
|
|
||||||
|
(testing "Clears automatically-paid-when-due when client is not in the list"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/automatically-paid-when-due [{:db/id 999}]
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}]
|
||||||
|
(is (false? (:vendor/automatically-paid-when-due (sut/clientize-vendor vendor 123))))))
|
||||||
|
|
||||||
|
(testing "Removes override fields from result"
|
||||||
|
(let [vendor {:vendor/terms 30
|
||||||
|
:vendor/terms-overrides [{:vendor-terms-override/client {:db/id 123}
|
||||||
|
:vendor-terms-override/terms 15}]
|
||||||
|
:vendor/account-overrides [{:vendor-account-override/client {:db/id 123}
|
||||||
|
:vendor-account-override/account {:db/id 2 :account/name "Override"}}]
|
||||||
|
:vendor/automatically-paid-when-due []
|
||||||
|
:vendor/default-account {:db/id 1 :account/name "Food"}}
|
||||||
|
result (sut/clientize-vendor vendor 123)]
|
||||||
|
(is (nil? (:vendor/terms-overrides result)))
|
||||||
|
(is (nil? (:vendor/account-overrides result))))))
|
||||||
|
|
||||||
|
(deftest location-select-test
|
||||||
|
(testing "Uses account location when provided"
|
||||||
|
(let [result (sut/location-select* {:name "loc"
|
||||||
|
:account-location "DT"
|
||||||
|
:client-locations ["MH" "DE"]
|
||||||
|
:value nil})]
|
||||||
|
(is (= :select (first result)))
|
||||||
|
(is (some #(= "DT" %) (flatten result)))))
|
||||||
|
|
||||||
|
(testing "Defaults to Shared when no account location but client locations exist"
|
||||||
|
(let [result (sut/location-select* {:name "loc"
|
||||||
|
:account-location nil
|
||||||
|
:client-locations ["MH" "DE"]
|
||||||
|
:value nil})]
|
||||||
|
(is (= :select (first result)))
|
||||||
|
(is (some #(= "Shared" %) (flatten result)))
|
||||||
|
(is (some #(= "MH" %) (flatten result)))
|
||||||
|
(is (some #(= "DE" %) (flatten result)))))
|
||||||
|
|
||||||
|
(testing "Defaults to Shared when no locations provided"
|
||||||
|
(let [result (sut/location-select* {:name "loc"
|
||||||
|
:account-location nil
|
||||||
|
:client-locations nil
|
||||||
|
:value nil})]
|
||||||
|
(is (= :select (first result)))
|
||||||
|
(is (some #(= "Shared" %) (flatten result))))))
|
||||||
|
|
||||||
|
(deftest maybe-code-accounts-test
|
||||||
|
(testing "Creates single account with specified location"
|
||||||
|
(let [invoice {:invoice/total 100.0}
|
||||||
|
rules [{:percentage 1.0 :account "acc-1" :location "DT"}]
|
||||||
|
result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])]
|
||||||
|
(is (= 1 (count result)))
|
||||||
|
(is (= "acc-1" (:invoice-expense-account/account (first result))))
|
||||||
|
(is (= "DT" (:invoice-expense-account/location (first result))))
|
||||||
|
(is (= 100.0 (:invoice-expense-account/amount (first result))))))
|
||||||
|
|
||||||
|
(testing "Spreads Shared location across all valid locations"
|
||||||
|
(let [invoice {:invoice/total 100.0}
|
||||||
|
rules [{:percentage 1.0 :account "acc-1" :location "Shared"}]
|
||||||
|
result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])]
|
||||||
|
(is (= 2 (count result)))
|
||||||
|
(is (= #{"MH" "DE"} (set (map :invoice-expense-account/location result))))
|
||||||
|
(is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result))))))
|
||||||
|
|
||||||
|
(testing "Handles odd totals with correct rounding for Shared locations"
|
||||||
|
(let [invoice {:invoice/total 100.0}
|
||||||
|
rules [{:percentage 1.0 :account "acc-1" :location "Shared"}]
|
||||||
|
result (invoices/maybe-code-accounts invoice rules ["MH" "DE" "DT"])]
|
||||||
|
(is (= 3 (count result)))
|
||||||
|
(is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result))))
|
||||||
|
(is (every? #(<= (count (re-find #"\.\d+" (str %))) 3) (map :invoice-expense-account/amount result)))))
|
||||||
|
|
||||||
|
(testing "Handles multiple account rules"
|
||||||
|
(let [invoice {:invoice/total 100.0}
|
||||||
|
rules [{:percentage 0.5 :account "acc-1" :location "DT"}
|
||||||
|
{:percentage 0.5 :account "acc-2" :location "Shared"}]
|
||||||
|
result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])]
|
||||||
|
(is (= 3 (count result)))
|
||||||
|
(is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result))))))
|
||||||
|
|
||||||
|
(testing "Uses absolute value for negative totals (produces positive amounts)"
|
||||||
|
(let [invoice {:invoice/total -100.0}
|
||||||
|
rules [{:percentage 1.0 :account "acc-1" :location "Shared"}]
|
||||||
|
result (invoices/maybe-code-accounts invoice rules ["MH" "DE"])]
|
||||||
|
(is (= 2 (count result)))
|
||||||
|
(is (= 100.0 (reduce + 0.0 (map :invoice-expense-account/amount result)))))))
|
||||||
|
|
||||||
|
(deftest can-undo-autopayment-test
|
||||||
|
(testing "Returns true for paid invoice with scheduled payment and no linked payments"
|
||||||
|
(with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)]
|
||||||
|
(is (true? (invoices/can-undo-autopayment
|
||||||
|
{:invoice/status :invoice-status/paid
|
||||||
|
:invoice/scheduled-payment #inst "2024-01-01"
|
||||||
|
:invoice/payments nil
|
||||||
|
:invoice/client {:db/id 1}
|
||||||
|
:invoice/date #inst "2024-01-01"})))))
|
||||||
|
|
||||||
|
(testing "Returns false for invoice without scheduled payment (behavior 19.2)"
|
||||||
|
(with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)]
|
||||||
|
(is (false? (invoices/can-undo-autopayment
|
||||||
|
{:invoice/status :invoice-status/paid
|
||||||
|
:invoice/scheduled-payment nil
|
||||||
|
:invoice/payments nil
|
||||||
|
:invoice/client {:db/id 1}
|
||||||
|
:invoice/date #inst "2024-01-01"})))))
|
||||||
|
|
||||||
|
(testing "Returns false for invoice with linked payments (behavior 19.3)"
|
||||||
|
(with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)]
|
||||||
|
(is (false? (invoices/can-undo-autopayment
|
||||||
|
{:invoice/status :invoice-status/paid
|
||||||
|
:invoice/scheduled-payment #inst "2024-01-01"
|
||||||
|
:invoice/payments [{:db/id 1}]
|
||||||
|
:invoice/client {:db/id 1}
|
||||||
|
:invoice/date #inst "2024-01-01"})))))
|
||||||
|
|
||||||
|
(testing "Returns false for invoice that is not paid (behavior 19.4)"
|
||||||
|
(with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)]
|
||||||
|
(is (false? (invoices/can-undo-autopayment
|
||||||
|
{:invoice/status :invoice-status/unpaid
|
||||||
|
:invoice/scheduled-payment #inst "2024-01-01"
|
||||||
|
:invoice/payments nil
|
||||||
|
:invoice/client {:db/id 1}
|
||||||
|
:invoice/date #inst "2024-01-01"})))))
|
||||||
|
|
||||||
|
(testing "Returns false for voided invoice"
|
||||||
|
(with-redefs [auto-ap.graphql.utils/assert-not-locked-ssr (fn [& _] nil)]
|
||||||
|
(is (false? (invoices/can-undo-autopayment
|
||||||
|
{:invoice/status :invoice-status/voided
|
||||||
|
:invoice/scheduled-payment #inst "2024-01-01"
|
||||||
|
:invoice/payments nil
|
||||||
|
:invoice/client {:db/id 1}
|
||||||
|
:invoice/date #inst "2024-01-01"}))))))
|
||||||
|
|
||||||
|
(deftest due-date-calculation-test
|
||||||
|
(testing "Calculates due date from vendor terms (behavior 8.2)"
|
||||||
|
(let [invoice-date (time/date-time 2024 1 1)
|
||||||
|
vendor-terms 30
|
||||||
|
expected-due (time/plus invoice-date (time/days vendor-terms))]
|
||||||
|
(is (= expected-due
|
||||||
|
(time/plus invoice-date (time/days vendor-terms))))))
|
||||||
|
|
||||||
|
(testing "Due date is date plus terms days"
|
||||||
|
(let [date (time/date-time 2024 6 15)
|
||||||
|
terms 15]
|
||||||
|
(is (= (time/date-time 2024 6 30)
|
||||||
|
(time/plus date (time/days terms)))))))
|
||||||
|
|
||||||
|
(deftest scheduled-payment-calculation-test
|
||||||
|
(testing "Scheduled payment equals due date when autopay is enabled (behavior 8.3)"
|
||||||
|
(let [due-date (time/date-time 2024 1 31)
|
||||||
|
vendor {:vendor/automatically-paid-when-due true}]
|
||||||
|
(is (= due-date
|
||||||
|
(when (:vendor/automatically-paid-when-due vendor)
|
||||||
|
due-date)))))
|
||||||
|
|
||||||
|
(testing "No scheduled payment when autopay is disabled"
|
||||||
|
(let [due-date (time/date-time 2024 1 31)
|
||||||
|
vendor {:vendor/automatically-paid-when-due false}]
|
||||||
|
(is (nil?
|
||||||
|
(when (:vendor/automatically-paid-when-due vendor)
|
||||||
|
due-date)))))
|
||||||
|
|
||||||
|
(testing "No scheduled payment when no due date"
|
||||||
|
(let [vendor {:vendor/automatically-paid-when-due true}]
|
||||||
|
(is (nil?
|
||||||
|
(when nil
|
||||||
|
(:vendor/automatically-paid-when-due vendor)))))))
|
||||||
|
|
||||||
|
(deftest due-date-display-test
|
||||||
|
(testing "Displays 'today' when due date is today (behavior 1.7)"
|
||||||
|
(let [today (time/now)
|
||||||
|
days 0]
|
||||||
|
(is (= 0 days))
|
||||||
|
(is (= "today"
|
||||||
|
(cond (= 0 days) "today"
|
||||||
|
(> days 0) (format "in %d days" days)
|
||||||
|
:else (format "%d days ago" (- days))))))))
|
||||||
|
|
||||||
|
(deftest can-handwrite-test
|
||||||
|
(testing "Returns true for single vendor with positive balance"
|
||||||
|
(is (true? (invoices/can-handwrite?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance 100.0}]))))
|
||||||
|
|
||||||
|
(testing "Returns false for multiple vendors"
|
||||||
|
(is (false? (invoices/can-handwrite?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance 100.0}
|
||||||
|
{:invoice/vendor {:db/id 2}
|
||||||
|
:invoice/outstanding-balance 50.0}]))))
|
||||||
|
|
||||||
|
(testing "Returns false for zero or negative total balance"
|
||||||
|
(is (false? (invoices/can-handwrite?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance 0.0}])))
|
||||||
|
(is (false? (invoices/can-handwrite?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance -50.0}])))))
|
||||||
|
|
||||||
|
(deftest credit-only-test
|
||||||
|
(testing "Returns true when all vendor totals are zero or negative"
|
||||||
|
(is (true? (invoices/credit-only?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance -100.0}
|
||||||
|
{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance -50.0}]))))
|
||||||
|
|
||||||
|
(testing "Returns false when any vendor total is positive"
|
||||||
|
(is (false? (invoices/credit-only?
|
||||||
|
[{:invoice/vendor {:db/id 1}
|
||||||
|
:invoice/outstanding-balance -100.0}
|
||||||
|
{:invoice/vendor {:db/id 2}
|
||||||
|
:invoice/outstanding-balance 50.0}]))))
|
||||||
|
|
||||||
|
(testing "Returns true for empty invoice list"
|
||||||
|
(is (true? (invoices/credit-only? [])))))
|
||||||
@@ -2,7 +2,6 @@
|
|||||||
(:require [clojure.test :refer [deftest testing is]]
|
(:require [clojure.test :refer [deftest testing is]]
|
||||||
[auto-ap.ssr.invoice.new-invoice-wizard :as sut9]))
|
[auto-ap.ssr.invoice.new-invoice-wizard :as sut9]))
|
||||||
|
|
||||||
|
|
||||||
(deftest maybe-spread-locations-test
|
(deftest maybe-spread-locations-test
|
||||||
(testing "Shared amount correctly spread across multiple locations"
|
(testing "Shared amount correctly spread across multiple locations"
|
||||||
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
|
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
|
||||||
@@ -30,8 +29,6 @@
|
|||||||
:invoice-expense-account/location "Location 2"}]
|
:invoice-expense-account/location "Location 2"}]
|
||||||
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
|
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
(testing "Shared amount correctly spread with leftovers"
|
(testing "Shared amount correctly spread with leftovers"
|
||||||
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
|
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount 100.0
|
||||||
:invoice-expense-account/location "Shared"}]
|
:invoice-expense-account/location "Shared"}]
|
||||||
@@ -77,14 +74,14 @@
|
|||||||
{:invoice-expense-account/amount -50.66
|
{:invoice-expense-account/amount -50.66
|
||||||
:invoice-expense-account/location "Location 2"}]
|
:invoice-expense-account/location "Location 2"}]
|
||||||
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
|
(map #(select-keys % #{:invoice-expense-account/amount :invoice-expense-account/location}) (:invoice/expense-accounts result))))))
|
||||||
|
|
||||||
(testing "Leftovers should not exceed a single cent"
|
(testing "Leftovers should not exceed a single cent"
|
||||||
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount -100
|
(let [invoice {:invoice/expense-accounts [{:invoice-expense-account/amount -100
|
||||||
:invoice-expense-account/location "Shared"}
|
:invoice-expense-account/location "Shared"}
|
||||||
{:invoice-expense-account/amount -5
|
{:invoice-expense-account/amount -5
|
||||||
:invoice-expense-account/location "Shared"}]
|
:invoice-expense-account/location "Shared"}]
|
||||||
:invoice/total -101}
|
:invoice/total -101}
|
||||||
result (sut8/maybe-spread-locations invoice ["Location 1" ])]
|
result (sut9/maybe-spread-locations invoice ["Location 1"])]
|
||||||
(is (=
|
(is (=
|
||||||
[{:invoice-expense-account/amount -100.0
|
[{:invoice-expense-account/amount -100.0
|
||||||
:invoice-expense-account/location "Location 1"}
|
:invoice-expense-account/location "Location 1"}
|
||||||
|
|||||||
Reference in New Issue
Block a user