# Invoice Behaviors ## Overview Invoices are the core entity of the Integreat accounts payable system. They represent bills from vendors that clients must pay. The invoice subsystem supports full lifecycle management from creation/import through payment and voiding, with comprehensive filtering, bulk operations, and OCR-based automated entry via Glimpse. **User value:** - Track all vendor bills in one place - Pay invoices individually or in bulk via checks, debits, cash, or credit - Import invoices from CSV/PDF files with automatic data extraction - Spread expenses across multiple locations - Manage invoice status (unpaid, paid, voided, scheduled) ## Routes & Pages | Route | Handler | Purpose | |-------|---------|---------| | `GET /invoices` | `::all-page` | List all invoices | | `GET /invoices/unpaid` | `::unpaid-page` | List unpaid invoices | | `GET /invoices/paid` | `::paid-page` | List paid invoices | | `GET /invoices/voided` | `::voided-page` | List voided invoices | | `GET /invoices/table` | `::table` | HTMX table refresh | | `GET /invoices/new` | `::new-wizard` | Create new invoice wizard | | `POST/PUT /invoices/new` | `::new-invoice-submit` | Submit new/edit invoice | | `PUT /invoices/new/navigate` | `::new-wizard-navigate` | Wizard step navigation | | `PUT /invoices/new/due-date` | `::due-date` | Calculate due date from vendor terms | | `PUT /invoices/new/scheduled-payment-date` | `::scheduled-payment-date` | Calculate scheduled payment from vendor autopay | | `PUT /invoices/new/account/prediction` | `::account-prediction` | Show vendor default account suggestion | | `PUT /invoices/new/total` | `::expense-account-total` | Calculate expense account total | | `PUT /invoices/new/balance` | `::expense-account-balance` | Calculate balance vs invoice total | | `GET /invoices/new/account/new` | `::new-wizard-new-account` | Add new expense account row | | `GET /invoices/new/account/location-select` | `::location-select` | Location dropdown for account | | `GET /invoices/pay-button` | `::pay-button` | Render pay button with selected invoice totals | | `GET /invoices/pay` | `::pay-wizard` | Pay selected invoices wizard | | `POST /invoices/pay` | `::pay-submit` | Submit payment | | `POST /invoices/pay/using-credit` | `::pay-using-credit` | Pay using vendor credit balance | | `PUT /invoices/pay/navigate` | `::pay-wizard-navigate` | Pay wizard step navigation | | `GET /invoices/bulk-delete` | `::bulk-delete` | Bulk void confirmation dialog | | `DELETE /invoices/bulk-delete` | `::bulk-delete-confirm` | Execute bulk void | | `GET /invoices/bulk-edit` | `::bulk-edit` | Bulk edit expense accounts wizard | | `PUT /invoices/bulk-edit` | `::bulk-edit-submit` | Submit bulk edit | | `GET /invoices/bulk-edit/account` | `::bulk-edit-new-account` | Add new account row in bulk edit | | `PUT /invoices/bulk-edit/total` | `::bulk-edit-total` | Calculate percentage total | | `PUT /invoices/bulk-edit/balance` | `::bulk-edit-balance` | Calculate percentage balance | | `DELETE /invoices/:id` | `::delete` | Void single invoice | | `PUT /invoices/:id/undo-autopay` | `::undo-autopay` | Undo scheduled autopay | | `PUT /invoices/:id/unvoid` | `::unvoid` | Restore voided invoice | | `PUT /invoices/:id/edit` | `::edit-wizard` | Edit existing invoice | | `GET /invoices/import` | `::import-page` | Import invoices page | | `POST /invoices/import/upload` | `::import-file` | Upload and parse invoice files | | `GET /invoices/import/table` | `::import-table` | Import table refresh | | `DELETE /invoices/import/:id/disapprove` | `::disapprove` | Remove pending imported invoice | | `PUT /invoices/import/:id/approve` | `::approve` | Approve pending imported invoice | | `DELETE /invoices/import/disapprove` | `::bulk-disapprove` | Bulk remove pending imports | | `PUT /invoices/import/approve` | `::bulk-approve` | Bulk approve pending imports | ## Behaviors by Page ### Invoice List Page #### Unit Tests - `fetch-ids` generates correct Datomic queries for all filter combinations - `fetch-page` returns `[results count outstanding-sum total-sum]` tuple - `sum-outstanding` and `sum-total-amount` calculate aggregates correctly - `selected->ids` resolves "all selected" vs individual selection - `hydrate-results` groups and orders pulled entities by input IDs - `payable-ids` filters to only unpaid, non-scheduled invoices - `can-undo-autopayment` validates invoice state for undo eligibility #### Integration Tests - **Happy path:** Load list page with invoices, verify table renders - **Filter by vendor:** Select vendor, table refreshes with filtered results - **Filter by account:** Select account, table refreshes - **Filter by date range:** Apply date range, results filtered - **Filter by amount range:** Set min/max amounts, results filtered - **Filter by invoice number:** Partial match on invoice number - **Filter by check number:** Search by check number - **Exact match:** Navigate from notification to specific invoice via `exact-match-id` - **Sort by each column:** Click column headers (client, vendor, date, due, invoice-number, total, outstanding-balance, location, description-original) - **Pagination:** Navigate pages, change per-page (default 25) - **Selection:** Check individual rows, select all, select all filtered - **Action buttons appear conditionally:** Based on permissions and selection state - **Legacy redirects:** Old routes redirect to new SSR routes #### UI Tests (SSR) - Table renders with correct columns: Client, Vendor, Invoice #, Date, Due, Status, Account, Outstanding, Links - Status pills display correctly: Paid (primary), Voided (red), Scheduled (yellow), Unpaid (secondary) - Due date shows relative time: "today", "in X days", "X days ago" with color coding - Outstanding shows partial payment indicator when balance != total - Links dropdown shows payments, transactions, ledger entries, source files - Client column hidden when single client with single location - Row action buttons: Void (unpaid), Edit (unpaid/paid), Unvoid (voided), Undo autopay (eligible) - Pay button tooltip explains why disabled: no selection, multiple clients, mixed debit/credit - Break table grouping by vendor name when sorting by vendor ### New Invoice Wizard #### Unit Tests - `new-form-schema` validates required fields, money amounts, entity IDs - Vendor must have default expense account (`check-vendor-default-account`) - `clientize-vendor` applies client-specific terms, account overrides, autopay settings - `assert-no-conflicting` prevents duplicate invoice numbers per vendor/client - `assert-invoice-amounts-add-up` validates expense accounts sum to total - `maybe-spread-locations` spreads "Shared" location across all client locations - Existing tests in `new_invoice_wizard_test.clj` cover: - Equal spread across 2 locations - Negative amount spreading - Remainder distribution (33.34, 33.33, 33.33) - Negative remainder distribution - Single cent leftover tolerance (`apply-total-delta-to-account`) - `$->cents` and `cents->$` round correctly - `calculate-spread` distributes base amount + remainder #### Integration Tests - **Happy path create:** 1. Open wizard, select client, vendor, date, invoice number, total 2. Due date auto-calculates from vendor terms when client+date+vendor selected 3. Scheduled payment auto-calculates from vendor autopay setting 4. Save with default account → invoice created with vendor's default account 5. Wizard navigates to "Next Steps" with Pay now / Add another / Close options - **Happy path customize accounts:** 1. Select "Customize accounts" on basic details 2. Add multiple expense account rows 3. Select account, location (auto-populated from account or Shared), amount 4. Total and balance update dynamically via HTMX 5. Location dropdown updates when account changes 6. Save validates totals match - **Edit existing invoice:** 1. Open edit wizard for unpaid/paid invoice 2. Vendor field disabled (cannot change) 3. Modify amounts, add/remove accounts 4. Save updates row in place #### UI Tests (SSR) - Wizard renders as modal with Basic Details → Expense Accounts (optional) → Next Steps - Client typeahead searches companies - Vendor typeahead searches vendors, disabled when editing - Date inputs use normal date format - "Add due / scheduled payment date" link appears when dates not set - Account prediction radio shows vendor default account name - Expense account grid: Account, Location, Amount columns - New account row button adds empty row - Remove row button with Alpine.js transition - Total/Balance rows update on amount change - Validation errors display per field ### Pay Wizard #### Unit Tests - `payment-form-schema` validates payment structure - `does-amount-exceed-outstanding?` catches over/under payments - `can-handwrite?` validates single vendor and positive balance for handwritten checks - `credit-only?` detects when all vendor totals are negative (credit scenario) - `pay-button*` calculates vendor totals and detects credit payment scenarios #### Integration Tests - **Happy path - debit:** 1. Select unpaid invoices from same client 2. Click Pay → Choose Payment Method modal opens 3. Select bank account → click Debit 4. Confirm "Pay in full" or switch to "Customize payments" 5. Submit → payments created, checks printed if applicable - **Happy path - print check:** 1. Select check-type bank account 2. Choose "Print check" 3. Payment processed, PDF URL returned for printing - **Happy path - cash:** 1. Select cash-type bank account 2. Payment processed immediately - **Happy path - handwrite check:** 1. Single vendor, positive balance required 2. Enter check number and date 3. Payment created with pending status - **Happy path - credit payment:** 1. Select invoices where vendor total is 0 (credit + debit balance) 2. Pay button shows "Pay invoices using credit" 3. Credit invoices offset payment invoices 4. Balance payment created - **Validation:** - Cannot pay invoices from multiple clients - Cannot pay with mixed vendor debit/credit totals - Custom payment amounts cannot exceed outstanding balance - Handwritten check requires check number - Locked client dates blocked #### UI Tests (SSR) - Pay button shows count and total amount - Warning pill appears when some selected invoices are locked/scheduled - Bank account cards show type-appropriate icons (cash=green, check=blue, credit=purple) - Tooltip on Pay button explains disabled state - Payment method selection buttons: Print check, With cash, Debit, Handwrite check - Payment details step shows invoice grid with vendor, number, total, pay amount - Mode toggle: "Pay in full" vs "Customize payments" - Success modal shows thumbs up, PDF download link for checks, scaling reminder ### Bulk Edit #### Unit Tests - `assert-percentages-add-up` validates percentages sum to 100% - `maybe-code-accounts` distributes expense accounts by percentage - Shared location spreads across all client locations - Rounds cents correctly with remainder handling - Validates account location matches #### Integration Tests - **Happy path:** 1. Select invoices via checkboxes 2. Click "Bulk Edit" → wizard opens 3. Add expense account rows with account, location, percentage 4. Submit → all selected invoices coded with new expense accounts - **Locked invoices excluded:** Only invoices with date >= client locked-until are processed - **Validation:** - Percentages must sum to 100% - Account location must match account's configured location #### UI Tests (SSR) - Wizard shows count of invoices to be edited - Account typeahead with location dropdown - Percentage input (displayed as whole number, stored as decimal) - Total/Balance percentage display - New account row button ### Bulk Delete/Void #### Unit Tests - `void-invoices-internal`: - Voids cash payments linked to selected invoices - Only voids invoices without linked invoice-payments - Only voids invoices with date >= client locked-until - Sets total, outstanding-balance, and expense account amounts to 0 #### Integration Tests - **Happy path:** 1. Select invoices 2. Click "Void selected" → confirmation modal 3. Confirm → invoices voided, table refreshes with notification - **Admin only:** Bulk delete requires admin permission - **Validation:** - Paid invoices with linked payments require voiding payments first - Locked dates blocked #### UI Tests (SSR) - Red alert icon in confirmation modal - Shows count of invoices to void - Modal closes on success with notification ### Import Page #### Unit Tests - `import->invoice` maps parsed data to invoice entity - `validate-invoice` checks required keys and client access - `match-vendor` finds vendor by code or throws with search hint - `admin-only-if-multiple-clients` flags source-url for admin-only when multiple clients - `upload-schema` validates force-client, force-vendor, force-location, force-chatgpt #### Integration Tests - **Happy path CSV/PDF import:** 1. Upload file(s) via drag-and-drop or file input 2. File parsed (CSV direct, PDF via OCR when allowed) 3. Invoices created with pending import status 4. Results table shows success/failure per file - **Force overrides:** - Force client overrides parsed client - Force vendor overrides parsed vendor - Force location overrides parsed location - Force ChatGPT restricts to GPT-only parsing - **Approve/disapprove:** - Individual approve → status changes to imported - Individual disapprove → invoice deleted - Bulk approve/disapprove with selection - **Validation:** - Missing client, vendor, date, total → error - No client access → error - Vendor not found → error with search hint #### UI Tests (SSR) - Upload zone with drag-and-drop styling (blue idle, green hover) - File list shows selected files - Force client/vendor/location typeaheads - "Only use ChatGPT" checkbox - Results table: File, Result, Template, Sample columns - Green/red styling for success/error - Error list shows first 5 errors - Import table shows pending invoices with approve/disapprove buttons - Filters: vendor, date range, check number, invoice number, amount range ### Glimpse (OCR Import) #### Unit Tests - `textract->textract-invoice` extracts fields from AWS Textract response - `stack-rank` sorts field descriptors by confidence - `clean-customer` normalizes customer names for search - `deduplicate` removes duplicate parsed values - `textract-invoice->invoice` converts to invoice with vendor terms and autopay #### Integration Tests - **Happy path:** 1. Upload PDF to Glimpse page 2. File uploaded to S3, Textract job started 3. Poll every 5s while IN_PROGRESS 4. On SUCCESS, display extracted fields with alternatives 5. User selects client, vendor, date, total, invoice number from alternatives 6. Save creates invoice and links to textract job - **Field extraction:** - Total: from AMOUNT_DUE or TOTAL fields - Customer: from CUSTOMER_NUMBER or RECEIVER_NAME (with Solr fallback) - Vendor: from VENDOR_NAME with Solr search - Date: from INVOICE_RECEIPT_DATE, ORDER_DATE, DELIVERY_DATE - Invoice number: from INVOICE_RECEIPT_ID or PO_NUMBER #### UI Tests (SSR) - Upload form with Dropzone integration (PDF only) - Progress indicator while analyzing - Side-by-side layout: PDF preview iframe + form - Form fields: Client, Location, Vendor, Date, Total, Invoice Number - Alternatives shown as clickable pills below each field - Disabled fields for client and vendor (must select from alternatives) - Success notification with link to view new invoice ## Cross-Cutting Behaviors ### Filtering All list pages support these filters (applied via HTMX with debounce): - **Vendor:** Typeahead search, filters to specific vendor - **Account:** Typeahead search, filters invoices with that expense account - **Date range:** Start/end date for invoice date (uses `scan-invoices` for performance) - **Due date range:** Start/end due date - **Amount range:** Min/max total amount (inclusive) - **Invoice number:** Partial string match via `.contains` - **Check number:** Text search - **Status:** Route-param based (all, unpaid, paid, voided) or query-param - **Import status:** Filter by pending/imported - **Exact match:** Navigate to specific invoice by ID (bypasses other filters) - **Scheduled payments:** Filter to invoices with scheduled payment date - **Unresolved:** Invoices missing expense accounts or with unassigned accounts - **Location:** Filter by expense account location ### Sorting Click column headers to sort. Supported sort keys: - client (by client name) - vendor (by vendor name) - description-original - location (by expense account location) - date - due (nulls last with default 2050-01-01) - invoice-number - total - outstanding-balance Default sort: descending by scan-invoices default. Toggle asc/desc. ### Pagination - Default 25 per page - Configurable per-page - Offset-based pagination - Import table supports up to 250 for "all selected" operations ### Selection - Individual row checkboxes - "Select all" checkbox selects all visible - "Select all filtered" selects all matching current filters (up to 250) - Selection state maintained via Alpine.js - Pay/Void/Edit/Bulk-Edit buttons use selection ### Permissions All invoice operations check permissions via `can?`: | Activity | Required Permission | |----------|-------------------| | Create invoice | `{:subject :invoice :activity :create}` | | Edit invoice | `{:subject :invoice :activity :edit}` | | Delete/Void invoice | `{:subject :invoice :activity :delete}` | | Pay invoice | `{:subject :invoice :activity :pay}` | | Bulk delete | `{:subject :invoice :activity :bulk-delete}` | | Bulk edit | `{:subject :invoice :activity :bulk-edit}` | | Import | `{:subject :invoice :activity :import}` | Additional checks: - `assert-can-see-client` - user must have access to invoice's client - `assert-not-locked` - invoice date must be >= client's locked-until date - Admin-only for bulk delete confirmation ## Edge Cases ### Voiding Paid Invoices - Single void: Blocked if invoice has linked non-voided payments. User must void payments first. - Bulk void: Automatically voids linked cash payments before voiding invoices. ### Unvoiding - Restores invoice to original status, total, outstanding balance, and expense accounts - Uses Datomic history to reconstruct previous state - Requires edit permission and client access ### Undo Autopay - Only available for paid invoices with scheduled payment and no linked payments - Resets status to unpaid, outstanding to total, clears scheduled payment - Locked dates blocked ### Paying with Credit - Triggered when selected invoices for single vendor have net 0 balance (credit invoices + payment invoices) - Credit invoices (negative balance) offset payment invoices - Creates balance-credit payment ### Location Spreading - "Shared" location in expense accounts spreads across all client locations - Base amount = total / location count (integer division in cents) - Remainder distributed 1 cent at a time to first N locations - Total delta applied to first account to handle rounding ### Negative Amounts - Supported throughout (credit memos, overpayments) - Spread calculations handle negative totals - Pay button validates all-credits-or-debits (can't mix positive and negative vendor totals) ### Zero Balances - Invoices with 0 outstanding balance show as paid - Zero-balance selections excluded from payment ### Duplicate Invoice Numbers - `assert-no-conflicting` prevents same invoice number for same vendor/client - Edit wizard preserves existing invoice number unless changed ### Missing Vendor/Account - New invoice wizard requires vendor with default expense account - Import validation fails if vendor not found - Unresolved filter finds invoices missing expense accounts ### Locked Clients - Client has `locked-until` date - Invoices with date < locked-until cannot be: edited, paid, voided, imported, approved - Bulk operations filter out locked invoices - Pay wizard shows warning when some selections are locked ### Large Invoice Counts - `scan-invoices` Datomic query used for efficient date/client filtering - Pagination limits to 25 per page by default - "All selected" capped at 250 for bulk operations - Outstanding/total sums calculated from all-ids, not just page ## Test Data Requirements ### Entities Needed - **Clients:** Multiple clients with different locations, some with locked-until dates - **Vendors:** With/without default accounts, terms, autopay settings, account overrides, terms overrides - **Accounts:** Expense accounts with/without invoice allowance, different locations - **Bank Accounts:** Check, cash, credit types, visible/hidden - **Invoices:** Various statuses, dates, amounts, with/without payments, with/without scheduled payments ### Specific Test Scenarios - Client with multiple locations (for spreading) - Client with single location (client column hidden) - Vendor with terms (auto-due date) and autopay (auto-scheduled payment) - Invoice with partial payment (outstanding < total) - Invoice with linked payment (void blocked) - Voided invoice with history (unvoid test) - Paid invoice with scheduled payment, no linked payment (undo autopay) - Negative balance invoice (credit) - Imported pending invoice (approve/disapprove) - Locked client with pre/post-lock invoices ### File Upload Test Data - Valid CSV with proper headers - PDF with embedded text - PDF requiring OCR - File with multiple clients (admin-only source-url) - File with missing required fields - File with unmatchable vendor ## Dependencies - **Datomic:** Primary data store, history for unvoid - **AWS S3:** File storage for imports and Glimpse PDFs - **AWS Textract:** OCR for Glimpse PDF processing - **Solr:** Search for vendor/client matching in Glimpse - **HTMX/Alpine.js:** Frontend interactivity - **Bidi:** Route generation - **Malli:** Schema validation for forms and query params - **clj-time:** Date parsing and manipulation ## Existing Tests to Preserve ### Unit Tests - `test/clj/auto_ap/ssr/invoice/new_invoice_wizard_test.clj` - `maybe-spread-locations-test` - 6 test cases for location spreading logic ### Integration Tests - `test/clj/auto_ap/integration/routes/invoice_test.clj` - `import-uploaded-invoices` - import single, duplicate prevention, location override, coded invoice - `test/clj/auto_ap/integration/graphql/invoices.clj` - `test-add-invoice` - add with valid/invalid accounts, vendor special accounts - `edit-invoice` - edit fields, vendor change blocked, conflicting invoice numbers - `edit-expense-accounts` - modify expense accounts - `bulk-change-invoices` - bulk coding with percentages - `void-invoices` - bulk void and unvoid - `void-invoice` - single void and unvoid