6 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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