1 Commits

Author SHA1 Message Date
5452b8b779 critique of wizard design. 2026-06-01 09:40:49 -07:00
43 changed files with 2606 additions and 2039 deletions

3
.gitignore vendored
View File

@@ -51,6 +51,3 @@ sysco-poller/**/*.csv
.tmp/**
playwright-report/**
test-results/**
# Scratch dir for temp files (screenshots, logs, etc.); keep the dir, ignore contents
/tmp/*
!/tmp/.gitkeep

View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"dependencies": {
"@opencode-ai/plugin": "1.15.10"
"@opencode-ai/plugin": "1.15.12"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,19 +87,19 @@
]
},
"node_modules/@opencode-ai/plugin": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.12.tgz",
"integrity": "sha512-BBteGXEwJt+1ehHqQ+yKXmoWltrW+2xO++B1Fm/dnMGYWT9luEKA5RlUuVYA2qDF6uwlE7kmHZvQZAM79zWHEA==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.15.10",
"@opencode-ai/sdk": "1.15.12",
"effect": "4.0.0-beta.66",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -114,9 +114,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
"version": "1.15.12",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.12.tgz",
"integrity": "sha512-lOaBNX93dkakZe6C42ttX1bkSx3K2c6+Yv+w8Qv02v5rPlu1vCXbmdfYDh9/bw+oq+NKPSaBm9d6kPA19hA5Lg==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"

View File

@@ -1,9 +1,5 @@
# Integreat Development Guide
## Temporary Files
Write any temporary files (screenshots, scratch logs, generated artifacts, etc.) to the `./tmp/` directory at the repo root. Its contents are gitignored (only `.gitkeep` is tracked), so nothing there will be accidentally committed. Do not scatter temp files elsewhere in the repo or in the system `/tmp`.
## Build & Run Commands
### Build

View File

@@ -1,176 +0,0 @@
---
date: 2026-06-01
topic: manual-transaction-import-ssr
focus: Port the master-branch "manual import transactions" feature to the SSR/alpine/htmx stack, modeled on the SSR ledger import, preserving all validations, starting from a failing e2e test, with minimal core-component change.
mode: repo-grounded
---
# Ideation: Porting Manual Bank-Transaction Import to SSR
## Grounding Context (Codebase)
Three reference points were read in full:
**1. The master feature (what we must reproduce).**
- UI: `src/cljs/auto_ap/views/pages/transactions/manual.cljs` — a re-frame modal titled "Import Transactions" with a single `<textarea>` ("Yodlee manual import table"). User pastes tab-separated Yodlee data, clicks "Import". POSTs `(:data)` as EDN to `/api/transactions/batch-upload`.
- Route/handler: `src/clj/auto_ap/routes/invoices.clj:241` `batch-upload-transactions``assert-admin`, then `manual/import-batch (manual/tabulate-data data)`.
- Parsing: `src/clj/auto_ap/import/manual.clj``tabulate-data` reads CSV with `\tab` separator, drops the header row, and maps **fixed positional columns**: `[:status :raw-date :description-original :high-level-category nil nil :amount nil nil nil nil nil :bank-account-code :client-code]`.
- Per-row mapping/validation: `manual->transaction` uses `import.manual.common/assoc-or-error` to accumulate errors for: **client lookup** (by bank-account-code → client), **bank-account lookup** (by code), **date parse** (`parse-date`, requires `MM/dd/yyyy`), **amount parse** (`parse-amount`).
- Engine: `import.manual/import-batch` builds lookups, calls `t/start-import-batch :import-source/manual`, applies `t/apply-synthetic-ids` (dedupe key), imports only rows with no `:errors`, returns stats `{:import-batch/imported ... :failed-validation N :sample-error "..."}`.
- Deeper validations live in `src/clj/auto_ap/import/transactions.clj` `categorize-transaction`: status must be `"POSTED"`, transaction date must be after `bank-account/start-date` and after `client/locked-until`, duplicate detection via `:transaction/id` (extant cache), missing client/bank-account/id → `:error`, plus suppression. Synthetic-id (`apply-synthetic-ids`/`synthetic-key`) gives idempotent re-import.
**2. The SSR ledger import (the pattern to emulate).**
- `src/clj/auto_ap/ssr/ledger.clj` implements a **dedicated two-stage page**, not a modal:
- `external-import-text-form*` (~L246): alpine `x-data {clipboard}`, a hidden `text-area` bound `x-model`, an `hx-post` to `::route/external-import-parse` triggered on a `"pasted"` event; a "Load from clipboard" button reads `navigator.clipboard`.
- `external-import-parse` (~L373): re-renders `external-import-form*` with `:just-parsed? true`.
- `external-import-table-form*` (~L117): renders parsed rows into an **editable** `com/data-grid-card` — each cell is a `fc/with-field` text/money input (form-cursor round-trips values + errors); per-row error/warning badge with tooltip; "Import" `hx-post`s to `::route/external-import-import`.
- Validation: malli `parse-form-schema` (~L341) with `:decode/string tsv->import-data` + `:decode/arbitrary` (vector→map) enforces shape (min-length, `clj-date-schema`, `money`, location max 2). Business validation in `add-errors`/`table->entries` (~L380504) accumulates `[message status]` pairs (`:error`/`:warn`) at entry and line-item level; `flatten-errors` maps them to form-cursor field paths `[[:table idx] message status]`.
- `import-ledger` (~L554): splits good/ignored(warn-only)/bad entries; `throw+` `:field-validation` with `:form-errors` if any bad; upserts hidden vendors; retracts+re-inserts good entries (idempotent via `:journal-entry/external-id`); touches solr; returns `{:successful :ignored :form-errors}`. `external-import-import` wraps it in an `html-response` with an `hx-trigger` notification.
**3. THE KEY DISCOVERY — scaffolding already exists, handlers do not.**
- `src/cljc/auto_ap/routes/transactions.cljc` **already declares** the route names, mirroring ledger exactly:
```
"/external-new" ::external-page
"/external-import-new" {"" ::external-import-page
"/parse" ::external-import-parse
"/import" ::external-import-import}
```
- But `src/clj/auto_ap/ssr/transaction.clj` `key->handler` (L101) wires **only** `page/table/csv/bank-account-filter/bulk-delete` (+ `edit/` + `bulk-code/`). **No handler exists for any `external-import-*` route.** The aside nav (`ssr/components/aside.clj`) wires the *ledger* import nav (`::ledger-routes/external-import-page`) but there is no transaction-import nav entry. So the routes are declared dead ends; the gap is handlers + UI + validation + nav + tests.
**Test conventions.** `test/clj/auto_ap/ssr/ledger_test.clj` is the template: pure-fn tests (`tsv->import-data`, `trim-header`, `line->id`, `flatten-errors`), validation tests (`add-errors` per error type), tx-building tests (`entry->tx`), and end-to-end import tests (`import-ledger` against Datomic via `wrap-setup` + `setup-test-data` + `admin-token`). `test/clj/auto_ap/test-server.clj` is a Playwright/browser e2e harness with `wrap-test-auth` and seeded `setup-test-data`. Existing engine unit tests: `test/clj/auto_ap/import/transactions_test.clj` already covers `categorize-transaction`.
## Topic Axes
- **Input/paste fidelity** — keeping the exact Yodlee positional-column paste payload (req #1)
- **UI flow & surface** — modal vs. dedicated two-stage paste→review→import page (req #2)
- **Validation architecture** — where shape vs. business rules live, and how errors surface (req #3)
- **Core/backend reuse** — how much of `import.transactions` / `import.manual` is reused vs. reimplemented (req #5)
- **Test strategy** — failing-first e2e, then unit coverage (req #4)
## The Central Design Fork
Requirements #1 ("paste the exact same content") and #2 ("follow ledger patterns") pull in slightly different directions, but resolve cleanly:
- **Input format stays identical** — the user still copies the same Yodlee table and pastes the same tab-separated positional columns. We reuse `manual/columns` + `manual/tabulate-data` for the *paste shape*; we do **not** switch to ledger's named-header columns.
- **Everything downstream follows ledger** — dedicated page, clipboard paste, editable review grid, per-row error/warning badges, re-validate loop, notification on import.
The one decision genuinely worth the user's input is **how much of the ledger UX to adopt**: the full editable review grid (idea #5, higher value, more work) vs. a lighter paste→validate→summary page closer to master (still SSR, less scope). Both are presented below as ranked survivors; this is the seed question for brainstorming.
## Ranked Ideas
### 1. Wire the pre-scaffolded routes into a dedicated two-stage SSR import page
**Description:** Implement `external-import-page` / `external-import-parse` / `external-import-import` handlers in a new `src/clj/auto_ap/ssr/transaction/import.clj`, add them to `ssr/transaction.clj` `key->handler` (with the same middleware stack: `wrap-must {:activity :view :subject :transaction}`, `wrap-client-redirect-unauthenticated`, etc.), and add a transaction "Import" nav entry in `aside.clj` parallel to the ledger one. The page structure mirrors `ledger/external-import-page`: breadcrumb → clipboard script → paste form → review table.
**Axis:** UI flow & surface
**Basis:** `direct:` `routes/transactions.cljc` already declares `::external-import-page/parse/import` but `ssr/transaction.clj:101 key->handler` wires none of them; `ssr/ledger.clj:686 key->handler` shows the exact wiring shape to copy.
**Rationale:** The routing contract already exists and is unused — the cheapest, lowest-risk foundation, and it's what makes req #2 ("like the ledger import") literally true at the routing/page level.
**Downsides:** Adds a new namespace; needs a nav entry and middleware parity to avoid auth gaps.
**Confidence:** 95%
**Complexity:** Low
**Status:** Unexplored
### 2. Preserve the exact Yodlee positional paste format (req #1)
**Description:** Reuse `import.manual/columns` + `tabulate-data` (or a malli `:decode/string` wrapper around them) for parsing the pasted TSV, instead of ledger's named-header `tsv->import-data`. Keep the same column positions (`:status :raw-date :description-original ... :amount ... :bank-account-code :client-code`) so users paste identical content.
**Axis:** Input/paste fidelity
**Basis:** `direct:` Requirement #1 ("Paste the exact same type of content as was in the master branch version") + `import/manual.clj:10` fixed `columns` vector; the master textarea label is literally "Yodlee manual import table".
**Rationale:** Users' upstream copy/paste habit (and the Yodlee export shape) is the contract that must not break; the ledger named-header approach would silently change what content is valid.
**Downsides:** Positional columns are brittle if Yodlee changes column order — but that's already the status quo, not a regression.
**Confidence:** 90%
**Complexity:** Low
**Status:** Unexplored
### 3. Reuse the `import.transactions` engine as the backend (req #5)
**Description:** The import handler maps parsed rows → `:transaction/*` maps via the existing `manual->transaction` shape, then drives `import.transactions/start-import-batch :import-source/manual` + `import-transaction!` + `finish!` + `get-stats` — exactly as `import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only; the categorization, rule-matching, payment/deposit clearing, synthetic-id dedupe, and audit-transact paths are untouched.
**Axis:** Core/backend reuse
**Basis:** `direct:` Requirement #5 ("Minimally, if at all, change any core components") + `import/manual.clj:32 import-batch` already encapsulates the whole engine call; `import/transactions.clj` is covered by `transactions_test.clj`.
**Rationale:** This is the battle-tested core. Re-implementing it ledger-style would risk dropping validations (`categorize-transaction`) and duplicate a large, audited code path. Wrapping it keeps core change near zero.
**Downsides:** `import-batch` returns summary stats, not per-row form-cursor errors — so the pre-validation layer (idea #4) must surface row errors *before* the engine runs.
**Confidence:** 90%
**Complexity:** Medium
**Status:** Unexplored
### 4. Two-tier validation: malli shape-parse + a transaction `add-errors` business layer
**Description:** Mirror ledger's split. Tier 1: a malli `parse-form-schema` decodes the pasted TSV and coerces/validates *shape* (date parses as `MM/dd/yyyy`, amount parses, required fields present) — reusing `manual.common/parse-date`/`parse-amount` as `:decode`/predicate fns. Tier 2: a transaction-specific `add-errors`/`table->entries` adds *business* errors/warnings by reusing the predicates already encoded in `categorize-transaction`: client-not-found (`:error`), bank-account-not-found (`:error`), date before `bank-account/start-date` (`:warn`/`:not-ready`), client locked-until (`:warn`), status not `POSTED` (`:not-ready`), already-imported/extant (`:warn`). Errors are `[message status]` pairs surfaced via `flatten-errors` → form-cursor field paths.
**Axis:** Validation architecture
**Basis:** `direct:` Requirement #3 ("every validation maintained… but doesn't have to follow the same structure — make it like the ledger import"). Maps master validations (`manual.clj:23-30`, `transactions.clj:191-225 categorize-transaction`) onto ledger's `add-errors`/`flatten-errors` shape (`ledger.clj:380-519`).
**Rationale:** Gives the ledger-style inline error UX while guaranteeing 1:1 validation parity — each master check becomes an explicit `add-errors` clause, which is also directly unit-testable (one test per error type, like `ledger_test/add-errors-test`).
**Downsides:** Some checks (extant/duplicate, start-date, locked-until) need a DB read at validation time that master does lazily inside the engine — must decide whether to pre-check or let the engine's stats report them. Risk of double-validation drift if engine and pre-validator disagree.
**Confidence:** 80%
**Complexity:** High
**Status:** Unexplored
### 5. Editable review grid with per-row error/warning badges and a re-validate loop
**Description:** After paste+parse, render rows into a `com/data-grid-card` where each field (date, amount, description, bank-account-code, client-code) is an editable `fc/with-field` input, with `com/validated-field` error display and a per-row alert badge+tooltip — exactly like `ledger/external-import-table-form*`. The user can fix a wrong client/bank-account code or date inline and re-submit; only clean rows import, warn-only rows are skipped, error rows block. A "Show table" toggle keeps the default view compact.
**Axis:** UI flow & surface / validation surfacing
**Basis:** `direct:` Requirement #2 ("follow slightly better design patterns, like how the ledger import works"). `ledger.clj:117-244` is the editable-grid implementation to copy; master has no inline correction at all (fire-and-forget modal + summary stats).
**Rationale:** This is the concrete UX upgrade over master and the main reason to model on ledger — turning "paste, pray, read a stats blob" into "paste, see exactly which rows are wrong and why, fix them, import."
**Downsides:** Highest-effort idea; form-cursor round-tripping of an editable grid is the trickiest part of the ledger code. If scope must shrink, a read-only review table + summary (lighter survivor) is the fallback.
**Confidence:** 75%
**Complexity:** High
**Status:** Unexplored
### 6. Preserve idempotent re-import via synthetic-id duplicate detection, surfaced as "already imported"
**Description:** Keep `apply-synthetic-ids`/`synthetic-key` so re-pasting the same export is idempotent (the engine categorizes extant rows as `:extant` and skips them). Surface this in the review grid as a `:warn`-level "already imported" badge rather than silently dropping it, so the user understands why a row didn't import.
**Axis:** Validation architecture
**Basis:** `direct:` `import/transactions.clj:405-421 apply-synthetic-ids` + `categorize-transaction:192-225` extant handling. This is an existing master behavior that req #3 requires us to maintain.
**Rationale:** Duplicate-safety is an easy validation to lose in a port; making it visible (vs. master's opaque stats) is a small, high-trust UX win that costs almost nothing on top of idea #5.
**Downsides:** Requires a DB read of existing `:transaction/id`s at validation time (or reading it back from engine stats post-import).
**Confidence:** 80%
**Complexity:** Low
**Status:** Unexplored
### 7. Start with a failing Playwright e2e, then backfill ledger_test-style unit coverage (req #4)
**Description:** First commit: a failing e2e (against `test-server`) that dev-logs in, navigates dashboard → transactions → Import, pastes a known-good Yodlee TSV into the paste box, asserts parsed rows render, clicks Import, and asserts the transactions appear / a "N imported" notification fires. It fails initially (no handler/nav). Then make it pass incrementally: route+page (idea #1) → parse (#2/#4 tier 1) → review grid (#5) → import via engine (#3) → business validation (#4 tier 2). Backstop with unit tests mirroring `ledger_test.clj`: `tabulate-data`/parse, each `add-errors` validation clause, and an end-to-end `import` test against Datomic. Reuse the existing `transactions_test.clj` `categorize-transaction` coverage as the validation-parity oracle.
**Axis:** Test strategy
**Basis:** `direct:` Requirement #4 ("Write detailed acceptance criteria, and start with a failing e2e test, making it pass over time"). `test-server.clj` already provides the browser harness + test auth; `ledger_test.clj` provides the unit-test template.
**Rationale:** A red e2e pins down the acceptance contract before any handler exists and gives an unambiguous "done" signal; the unit layer locks in validation parity clause-by-clause so req #3 can't silently regress.
**Downsides:** e2e clipboard paste may need a direct `type`-into-textarea path (or a test seam) since `navigator.clipboard.read()` is awkward to drive headless — plan a paste fallback the test can use.
**Confidence:** 85%
**Complexity:** Medium
**Status:** Unexplored
## Draft Acceptance Criteria (seed for brainstorm/plan, per req #4)
**Routing & access**
- [ ] `GET /transactions/external-import-new` renders an import page (admin-gated, same middleware as other transaction routes); 401/redirect for unauthenticated.
- [ ] A "Import" nav entry appears under the transactions section, active on the import route.
**Paste & parse (req #1)**
- [ ] Pasting the exact master Yodlee TSV (same positional columns) parses into the same field set as `manual/tabulate-data`.
- [ ] Header row is dropped; blank rows ignored.
- [ ] `POST …/parse` re-renders the page with a "N rows found" banner and the review table.
**Validation parity (req #3)** — each must produce a visible, row-attributed message:
- [ ] Client not found for bank-account-code → error.
- [ ] Bank account not found by code → error.
- [ ] Date not `MM/dd/yyyy` / unparseable → error.
- [ ] Amount unparseable → error.
- [ ] Status ≠ `POSTED` → not-imported (warn/not-ready).
- [ ] Date before `bank-account/start-date` → not-imported (not-ready).
- [ ] Date on/before `client/locked-until` → not-imported (not-ready).
- [ ] Already-imported (synthetic-id extant) row → skipped, surfaced as warn.
- [ ] Missing client / bank-account / id → error.
**Import (req #5, minimal core change)**
- [ ] `POST …/import` runs the existing `import.transactions` engine via the `:import-source/manual` batch path; no change to `categorize-transaction`/`import-transaction!`/`apply-synthetic-ids`.
- [ ] Only clean rows import; warn-only rows skipped; any error blocks (or imports clean rows + reports errors — match master's "import valid, report failed-validation").
- [ ] Success notification reports counts (imported / skipped / errors), mirroring master's stats.
- [ ] Re-importing the same paste is idempotent (no duplicates).
**Tests (req #4)**
- [ ] A Playwright e2e covering paste → review → import → assert, committed red first, green at the end.
- [ ] Unit tests per validation clause + a Datomic-backed end-to-end import test, modeled on `ledger_test.clj`.
## Failing-First e2e: concrete starting point
Add `test/clj/auto_ap/ssr/transaction/import_test.clj` (unit) and a Playwright spec driven through `test-server`. The e2e is the first artifact and is expected to fail because no `external-import` handler is wired in `ssr/transaction.clj`. Make it green by walking ideas #1 → #2 → #5 → #3 → #4 in that order; the unit suite grows alongside #4.
## Rejection Summary
| # | Idea | Reason Rejected |
|---|------|-----------------|
| 1 | Switch paste format to ledger's named-header columns | Violates req #1 — users paste the exact Yodlee positional export; changing valid input is a silent regression |
| 2 | Keep it a re-frame/CLJS modal | Branch eliminated the CLJS app; contradicts the whole port. (An SSR htmx *modal* was considered but rejected vs. the dedicated page — ledger uses a page and the editable review grid needs the room) |
| 3 | Reimplement validation entirely ledger-style, ignoring `import.transactions` | Duplicates audited `categorize-transaction` logic, risks dropping validations (req #3), and churns core (violates req #5) |
| 4 | Async/streaming import for large pastes | Scope overrun — master is synchronous; YAGNI for the manual paste workflow |
| 5 | Add CSV file-upload alongside paste | Scope overrun — not part of the master manual-import feature |
| 6 | Replace `import-batch` stats with a bespoke result type | Unnecessary core change; the existing stats map already carries imported/failed/sample-error |

View File

@@ -1,275 +0,0 @@
---
date: 2026-06-01
type: feat
status: active
plan_id: 2026-06-01-001
title: "feat: Port manual bank-transaction import to SSR (alpine/htmx)"
depth: standard
origin: docs/ideation/2026-06-01-manual-transaction-import-ssr-ideation.md
---
# feat: Port Manual Bank-Transaction Import to SSR
## Summary
Port the master-branch "manual import transactions" feature into the SSR/alpinejs/htmx stack by implementing the `external-import` handlers that `src/cljc/auto_ap/routes/transactions.cljc` already declares but that no handler currently serves. The feature is a dedicated two-stage page — paste the same Yodlee positional-column TSV → an editable review grid with per-row error/warning badges → import — modeled directly on the SSR ledger import (`src/clj/auto_ap/ssr/ledger.clj`). Validation follows the ledger's `add-errors` shape but preserves every master validation, and the actual write reuses the existing `auto-ap.import.transactions` engine unchanged.
---
## Problem Frame
On `master`, admins import bank transactions by pasting a tab-separated Yodlee export into a re-frame modal (`src/cljs/auto_ap/views/pages/transactions/manual.cljs`) that POSTs EDN to `/api/transactions/batch-upload`. This branch removed the ClojureScript React app and re-implemented the transactions surface server-side, but the manual-import feature was never ported — so admins on this branch cannot manually import transactions at all.
The route names are already scaffolded (`::external-page`, `::external-import-page`, `::external-import-parse`, `::external-import-import` in `routes/transactions.cljc`) but `src/clj/auto_ap/ssr/transaction.clj` wires no handlers for them — they are declared dead ends. The work is to fill that gap with handlers + UI + validation + nav + tests, mirroring the already-shipped ledger import.
---
## Scope Boundaries
**In scope**
- A dedicated SSR import page at `/transaction2/external-import-new` (+ `/parse`, `/import` sub-routes), admin-gated with the same middleware posture as other transaction routes and the ledger import.
- Reuse of the exact Yodlee positional-column paste format (no named-header columns).
- An editable review grid with inline per-field editing and per-row error/warning badges (ledger-style, form-cursor driven).
- Two-tier validation preserving every master validation, with the agreed severity split.
- Import via the existing `auto-ap.import.transactions` engine, block-whole-batch on hard errors.
- A transactions-section "Import" nav entry and an import-result notification.
- A Playwright e2e (committed failing first) plus unit/integration tests modeled on `test/clj/auto_ap/ssr/ledger_test.clj`.
### Deferred to Follow-Up Work
- CSV file upload as an alternative to paste.
- Asynchronous/streaming import for very large pastes.
- Any change to `categorize-transaction` or engine internals.
**Outside this change**
- Named-header column format (rejected — would silently change valid input).
- A bespoke import-result type replacing the engine's stats map.
---
## Key Technical Decisions
1. **Full editable review grid, block-whole-batch on hard error** (from brainstorm). Any remaining hard error blocks the entire import (ledger behavior: `throw+ {:type :field-validation ...}`, re-render the grid with errors highlighted); warn-level rows skip just that row and the rest import. Rationale: with an editable grid, the user can fix fixable problems inline, so "nothing imports until clean-or-skippable" is the coherent contract.
2. **Severity split between fixable errors and inherent warnings.**
- **Hard errors (block, must fix inline):** unparseable/invalid date (must match `MM/dd/yyyy`), unparseable amount, unknown client code (no client for the bank-account-code), unknown bank-account code, missing required fields.
- **Warnings (skip that row, import the rest):** status ≠ `"POSTED"`, transaction date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported (synthetic-id `extant`).
Rationale: fixable problems are correctable by editing a cell; inherent skip-conditions are facts about the data/account that editing cannot change, so they should not block the batch — this also reproduces master's "import valid, report the rest" outcome for those rows.
3. **Reuse the exact Yodlee positional paste format** (req #1). Parse with the master positional `columns` mapping (`auto-ap.import.manual/columns` + `tabulate-data` shape), not ledger's named-header `tsv->import-data`. Rationale: admins paste an unchanged Yodlee export; changing valid input is a silent regression.
4. **Reuse the `import.transactions` engine unchanged** (req #5). The import handler maps reviewed rows → `:transaction/*` maps (the `auto-ap.import.manual/manual->transaction` shape) and drives `start-import-batch :import-source/manual``import-transaction!``finish!``get-stats`, with `apply-synthetic-ids` for dedupe — exactly as `auto-ap.import.manual/import-batch` does today. The SSR layer is presentation + pre-validation only.
5. **Preview/engine parity via shared predicates** (the key design tension). The warn-level conditions shown in the grid before import (`not-ready` from start-date/locked-until, `extant`/already-imported, non-`POSTED`) and the engine's write-time `categorize-transaction` decisions must not drift. Decision: the pre-validation layer computes warn conditions by calling the **same** predicate functions the engine uses (`auto-ap.import.transactions/categorize-transaction` and its inputs — `get-existing` for extant, the bank-account `start-date`/`locked-until` checks), rather than re-deriving parallel logic. The grid is advisory display; the engine remains authoritative at write time, and because both read the same functions they agree. Hard-error (fixable) validations have no engine equivalent and live only in the pre-validation layer / malli schema.
6. **Testable paste path.** The ledger import populates a hidden textarea from `navigator.clipboard` via an alpine `@click`/`paste` handler, which is awkward to drive in headless Playwright. Decision: keep the "Load from clipboard" affordance, but ensure the paste textarea is fillable and that a `pasted`/`change` trigger fires the parse `hx-post`, so the e2e can set the value and dispatch the event without the clipboard API. (Implementation detail of how the trigger is wired is deferred to execution.)
---
## High-Level Technical Design
Two-stage flow mirroring `ssr/ledger.clj`, on the transactions surface:
```
GET /transaction2/external-import-new -> external-import-page (paste form + empty review area)
POST /transaction2/external-import-new/parse -> external-import-parse (decode TSV -> validate -> render editable grid)
POST /transaction2/external-import-new/import -> external-import-import (re-validate -> if any hard error: re-render grid (blocked);
else run import.transactions engine on clean rows,
skip warn rows, return notification with stats)
```
*This illustrates the intended approach and is directional guidance for review, not implementation specification. The implementing agent should treat it as context, not code to reproduce.*
Validation is two-tier:
- **Tier 1 (shape, hard errors):** a malli parse-form-schema decodes the pasted TSV positionally (reusing the master column order) and coerces/flags shape problems — date parses as `MM/dd/yyyy`, amount parses, required fields present.
- **Tier 2 (business):** a transaction `add-errors`/`table->entries` pass attaches `[message status]` pairs (`:error` / `:warn`) per row, with the hard/warn split from Decision 2, computing warn conditions from the shared engine predicates (Decision 5). `flatten-errors` maps them onto form-cursor field paths for the editable grid.
---
## Implementation Units
Build order is **failing-e2e-first** (req #4): U1 lands the red acceptance test, then U2U7 turn it green incrementally. Each feature-bearing unit also grows the unit-test suite in `test/clj/auto_ap/ssr/transaction/import_test.clj`.
### U1. Failing e2e acceptance test + deterministic import seed
**Goal:** Commit the end-to-end acceptance test (expected to fail) that defines "done", plus the deterministic test fixture it needs.
**Requirements:** req #4; advances acceptance criteria AC-1, AC-2, AC-9, AC-10.
**Dependencies:** none.
**Files:**
- `e2e/transaction-import.spec.ts` (new)
- `test/clj/auto_ap/test_server.clj` (modify `seed-test-data` to give the seeded bank account a **fixed** `:bank-account/code`, e.g. `"TEST-CHK"`, since `test-bank-account` otherwise assigns a random code)
**Approach:** Mirror `e2e/transaction-navigation.spec.ts` conventions (`x-clients: "mine"` header, `page.goto`, locators). The spec navigates to `/transaction2/external-import-new`, fills the paste box with a known-good Yodlee TSV whose bank-account-code/client-code match the seed (`TEST` client, `TEST-CHK` bank account), triggers parse, asserts parsed rows render in the review grid, clicks Import, and asserts a success notification with an imported count and that the imported transaction is visible on `/transaction2`. Include a second scenario pasting a row with an unknown client code and asserting a blocking error badge + that nothing imports. Drive paste by filling the textarea and dispatching the parse trigger (Decision 6), not the clipboard API.
**Patterns to follow:** `e2e/transaction-navigation.spec.ts`, `e2e/bulk-code-transactions.spec.ts`; seed shape in `test/clj/auto_ap/test_server.clj` `seed-test-data`.
**Test scenarios:**
- Covers AE/AC-1, AC-2: paste valid TSV → rows render → import → "N imported" notification → transaction appears on the list page.
- Covers AC-9: paste TSV with an unknown client code → row shows a blocking error badge, Import is blocked, no transaction created.
- Edge: empty paste → no rows / friendly empty state (assert no crash).
**Verification:** `npx playwright test e2e/transaction-import.spec.ts` runs and **fails** at this unit (no handler yet); the seed change does not break existing e2e specs (`npx playwright test` green except the new file).
**Execution note:** Start red. This is the acceptance contract; do not weaken it to pass — make U2U7 satisfy it.
### U2. Wire routes and render the import page shell
**Goal:** Make `/transaction2/external-import-new` serve a real page with the correct admin middleware; wire `parse`/`import` routes to placeholder handlers.
**Requirements:** AC-1, AC-12 (auth); req #2.
**Dependencies:** U1.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (new — namespace for the import handlers)
- `src/clj/auto_ap/ssr/transaction.clj` (merge the new `key->handler` entries into the existing map at the `key->handler` def)
**Approach:** Create `external-import-page` returning a `base-page` + `com/page` with breadcrumb ("Transactions" → "Import"), the clipboard helper script, and a forms container (initially just the paste form placeholder). Wire `::route/external-import-page`, `::route/external-import-parse`, `::route/external-import-import` into the transaction `key->handler` with the same middleware chain ledger uses for its import routes (`wrap-schema-enforce`/`wrap-form-4xx-2`/`wrap-schema-decode`/`wrap-nested-form-params` on parse/import) under the transaction page middleware (`wrap-must {:activity :import :subject :transaction}` analogous to ledger's `:subject :ledger`, `wrap-client-redirect-unauthenticated`). Confirm the correct `:activity`/`:subject` against the permissions model.
**Patterns to follow:** `src/clj/auto_ap/ssr/ledger.clj` `external-import-page` and `key->handler` (~lines 276318, 686718); `src/clj/auto_ap/ssr/transaction.clj` existing `key->handler` (~line 101).
**Test scenarios:**
- Happy path: `GET` the page as admin → 200, renders the paste form container.
- Error/auth: unauthenticated request → redirect/401 per `wrap-client-redirect-unauthenticated`.
**Verification:** Page loads at the route in the running app and in `test_server`; the e2e gets past navigation (still fails later in the flow).
### U3. Paste + parse using the master positional column format
**Goal:** Parse the pasted Yodlee TSV (exact master columns) into rows and render them; wire the paste form's `pasted`-triggered `hx-post` to the parse handler.
**Requirements:** req #1, req #2; AC-1, AC-3, AC-4.
**Dependencies:** U2.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-text-form*`, `external-import-parse`, the parse malli schema, and a positional `tsv->rows` decode)
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (new)
**Approach:** Reuse the master column order from `auto-ap.import.manual/columns` and `tabulate-data` (CSV read with `\tab`, drop header) to map positional columns. Define a malli `parse-form-schema` (ledger-style) whose `:table` field uses a `:decode/string` that runs the positional parse and a per-row `:decode/arbitrary` to build row maps; encode Tier-1 shape constraints (date `MM/dd/yyyy`, amount parses, required fields) reusing `auto-ap.import.manual.common/parse-date`/`parse-amount` semantics. `external-import-parse` re-renders the forms fragment with `:just-parsed? true`. Keep the paste textarea fillable and fire the parse trigger on a `pasted`/`change` event (Decision 6).
**Patterns to follow:** `ssr/ledger.clj` `external-import-text-form*`, `external-import-parse`, `tsv->import-data`, `parse-form-schema` (~lines 246375); `import/manual.clj` `columns`/`tabulate-data`; `import/manual/common.clj` `parse-date`/`parse-amount`.
**Test scenarios:**
- Happy path: a known Yodlee TSV string decodes to the expected row count with the expected field keys/values (positional mapping correct).
- Header handling: first row dropped; blank rows ignored.
- Edge: amount with currency formatting parses; amount unparseable flagged at Tier 1.
- Edge: date not `MM/dd/yyyy` flagged at Tier 1; valid date parses.
- Covers AC-3: pasting the exact master column layout yields the same field set master's `tabulate-data` produced.
**Verification:** After paste, the parsed rows render (read-only at this unit is acceptable); parse unit tests green.
### U4. Editable review grid with per-row error/warning badges
**Goal:** Render parsed rows into an editable `data-grid` where each field is editable and per-row error/warning badges show, with a "Show table" toggle and an Import button.
**Requirements:** req #2; AC-5, AC-6.
**Dependencies:** U3.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `external-import-table-form*`, `external-import-form*`)
**Approach:** Mirror `ledger/external-import-table-form*` using form-cursor (`fc/start-form`, `fc/with-field`, `fc/cursor-map`, `fc/field-value`/`field-name`/`field-errors`) and `com/data-grid-card` / `com/validated-field` / `com/text-input` / `com/money-input`. Columns reflect the transaction row shape (date, description, amount, bank-account-code, client-code, status). Per-row badge summarizes that row's error/warn state with a tooltip listing messages (red for `:error`, yellow for `:warn`). A parsed-summary banner shows row count + error/warning pill counts. Values round-trip on re-submit so inline edits persist.
**Patterns to follow:** `ssr/ledger.clj` `external-import-table-form*` and `external-import-form*` (~lines 117274).
**Test scenarios:**
- Test expectation: none for pure rendering structure beyond what U5 exercises — but include: rows with no errors render without a badge; rows with errors render a red badge; rows with only warnings render a yellow badge (assert via the rendered hiccup/markup in a handler-level test once U5 attaches errors).
**Verification:** Parsed grid is visibly editable; badges appear once U5 attaches errors; e2e can see rows.
### U5. Two-tier validation preserving every master validation
**Goal:** Attach hard-error and warning statuses to rows per the severity split, reusing the engine's predicates for the warn conditions so the preview matches the engine.
**Requirements:** req #3, req #5 (Decision 5); AC-7, AC-8, AC-9.
**Dependencies:** U3 (Tier 1 shape errors), U4 (badges to display them).
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `add-errors`, `table->entries`, `flatten-errors`, `entry-error-types` analogues)
- `test/clj/auto_ap/ssr/transaction/import_test.clj` (extend)
**Approach:** Build a transaction `add-errors` that, given lookups (client-by-bank-account-code, bank-account-by-code, bank-account `start-date`/`locked-until`, existing transaction ids), assigns:
- **Hard errors:** unknown client code, unknown bank-account code, missing required fields. (Tier-1 date/amount errors already present from U3.)
- **Warnings:** status ≠ `POSTED`; date before `bank-account/start-date`; date on/before `client/locked-until`; already-imported (synthetic-id present in existing ids).
Compute the warn conditions by calling the same functions the engine uses — `auto-ap.import.transactions/categorize-transaction` (and its inputs `get-existing`, the `apply-synthetic-ids` key) — rather than parallel logic (Decision 5). `flatten-errors` maps `[message status]` onto form-cursor field paths so badges render against the right rows. Map every master validation explicitly (see `import/manual.clj manual->transaction` and `import/transactions.clj categorize-transaction`).
**Patterns to follow:** `ssr/ledger.clj` `add-errors`/`table->entries`/`flatten-errors`/`entry-error-types` (~lines 380523); `import/transactions.clj` `categorize-transaction`/`get-existing`/`apply-synthetic-ids`.
**Test scenarios (one per validation, modeled on `ledger_test/add-errors-test`):**
- Hard error: unknown client code → `:error` with a clear message.
- Hard error: unknown bank-account code → `:error`.
- Hard error: missing required field → `:error`.
- (Tier 1) invalid date / unparseable amount → `:error`.
- Warning: status ≠ `POSTED``:warn`, row skipped.
- Warning: date before `bank-account/start-date``:warn`.
- Warning: date on/before `client/locked-until``:warn`.
- Warning: already-imported (extant synthetic id) → `:warn`.
- Parity: a row the grid marks clean is categorized `:import` by `categorize-transaction`; a row marked warn-skip is categorized to the matching non-`:import` action (assert grid preview agrees with engine).
- Pass-through: a fully valid row has no errors/warnings.
**Verification:** Validation unit tests green; badges reflect the correct severities in the grid.
### U6. Import via the existing engine, block-on-error, with notification
**Goal:** Implement `external-import-import`: block the whole batch if any hard error remains; otherwise run the `import.transactions` engine on clean rows (skipping warn rows) and return a result notification.
**Requirements:** req #5, Decisions 1 & 4; AC-2, AC-9, AC-10, AC-11.
**Dependencies:** U5.
**Files:**
- `src/clj/auto_ap/ssr/transaction/import.clj` (add `rows->transactions`, `import-transactions`, `external-import-import`)
**Approach:** Re-validate submitted (possibly edited) rows via U5. If any `:error` rows remain, `throw+ {:type :field-validation :form-errors ... :form-params ...}` and re-render the grid with errors (the `wrap-form-4xx-2` middleware handles re-render) — nothing imports. Otherwise map clean rows → `:transaction/*` maps using the `auto-ap.import.manual/manual->transaction` shape, apply `apply-synthetic-ids`, then drive `start-import-batch :import-source/manual``import-transaction!` (per row) → `finish!``get-stats`. Warn-only rows are excluded from the engine input (skipped). Return `html-response` re-rendering the form with an `hx-trigger` notification reporting counts from the engine stats (imported / skipped / not-ready / extant), mirroring master's stats surface.
**Patterns to follow:** `ssr/ledger.clj` `import-ledger` + `external-import-import` (~lines 554684); `import/manual.clj` `import-batch` (engine driving); `import/manual.clj` `manual->transaction`.
**Test scenarios (modeled on `ledger_test` `import-ledger-*` tests, against Datomic via `wrap-setup`/`setup-test-data`/`admin-token`):**
- Happy path: all-clean batch → engine imports all rows; stats report the imported count; transactions exist in the DB afterward.
- Block-on-error: a batch with one hard-error row → throws `:field-validation`; **no** transactions are created (assert DB unchanged).
- Warning skip: a batch with one warn-only row (e.g., non-`POSTED`) and clean rows → clean rows import, warn row skipped, stats reflect the skip.
- Idempotency: importing the same paste twice → second run imports 0 new (extant/synthetic-id dedupe); no duplicates.
- Integration: imported transaction carries `:import-source/manual` and is categorized/coded by the engine as it would be for any import (engine unchanged).
**Verification:** Import unit/integration tests green; the e2e's import step succeeds and the transaction appears on the list page.
### U7. Transactions "Import" nav entry + final polish
**Goal:** Add an "Import" entry to the transactions section nav (parallel to the ledger import nav) and finish the parsed-summary banner / notification copy.
**Requirements:** req #2; AC-1, AC-11.
**Dependencies:** U2 (route exists), U6 (notification exists).
**Files:**
- `src/clj/auto_ap/ssr/components/aside.clj` (add a transactions "Import" nav button + mark active on `::transaction-routes/external-import-page`)
**Approach:** Mirror the ledger import nav entry in `aside.clj` — add a sub-menu button under the transactions section linking to `::transaction-routes/external-import-page`, active-highlighted on that matched route. Confirm the banner shows row counts + error/warning pills (from U4) and the success notification copy matches the engine stats.
**Patterns to follow:** `ssr/components/aside.clj` ledger import nav (~lines 360366) and the transactions sub-menu (~lines 285298).
**Test scenarios:**
- Test expectation: none (navigation markup) — covered indirectly by the e2e navigating via the nav link; optionally assert the nav button renders with the correct href on the import route.
**Verification:** Full `e2e/transaction-import.spec.ts` passes; nav link is present and active on the import page.
---
## Acceptance Criteria
**Routing & access**
- **AC-1.** `GET /transaction2/external-import-new` renders the import page for an admin; an "Import" nav entry under the transactions section links to it and is active there.
- **AC-12.** Unauthenticated access redirects/401s per the standard transaction-route middleware.
**Paste & parse (req #1)**
- **AC-3.** Pasting the exact master Yodlee positional TSV parses into the same field set as `auto-ap.import.manual/tabulate-data`; the header row is dropped and blank rows ignored.
- **AC-4.** `POST .../parse` re-renders the page with a "N rows found" banner and the review grid.
**Review grid (req #2)**
- **AC-5.** Parsed rows render in an editable grid; each field is editable and inline edits persist across re-submit.
- **AC-6.** Each row shows an error badge (red) when it has a hard error, a warning badge (yellow) when it has only warnings, and no badge when clean; badges list messages on hover.
**Validation parity (req #3)** — each produces a visible, row-attributed message:
- **AC-7.** Hard errors block: client not found, bank account not found, date not `MM/dd/yyyy`, amount unparseable, missing required field.
- **AC-8.** Warnings skip just that row: status ≠ `POSTED`, date before `bank-account/start-date`, date on/before `client/locked-until`, already-imported.
- **AC-9.** With any remaining hard error, clicking Import blocks the whole batch (nothing imports) and re-renders the grid with errors highlighted.
**Import (req #5)**
- **AC-2.** `POST .../import` imports the clean rows via the existing `import.transactions` engine on the `:import-source/manual` batch path; the success notification reports counts (imported / skipped / not-ready / extant).
- **AC-10.** Re-importing the same paste is idempotent — no duplicate transactions (synthetic-id dedupe preserved).
- **AC-11.** `categorize-transaction` and the engine internals are unchanged by this work.
**Tests (req #4)**
- The Playwright e2e `e2e/transaction-import.spec.ts` exists, was committed failing first, and passes at the end.
- Unit/integration tests in `test/clj/auto_ap/ssr/transaction/import_test.clj` cover each validation clause and the end-to-end import flow against Datomic.
---
## Test Strategy
- **e2e (Playwright):** `e2e/transaction-import.spec.ts`, driven through `test/clj/auto_ap/test_server.clj` (real routes, injected test auth). Committed red in U1, green by U7.
- **Unit/integration (clojure.test):** `test/clj/auto_ap/ssr/transaction/import_test.clj`, modeled on `test/clj/auto_ap/ssr/ledger_test.clj` — pure parse/format tests, one validation test per clause, and Datomic-backed import tests via `wrap-setup` / `setup-test-data` / `admin-token`. Run with `clj-nrepl-eval -p PORT "(clojure.test/run-tests 'auto-ap.ssr.transaction.import-test)"` per AGENTS.md (preferred over `lein test`).
- **Validation-parity oracle:** existing `test/clj/auto_ap/import/transactions_test.clj` (`categorize-transaction`) backs Decision 5 — the warn-condition predicates the grid reuses are already under test.
---
## System-Wide Impact
- **Editing discipline (AGENTS.md):** all Clojure edits go through the clojure-mcp editing tools (or `@clojure-author`), not raw file edits; use `clojure-eval`/`clj-nrepl-eval` to compile-check and run tests. Run `lein cljfmt fix` before committing; `clj-paren-repair` on any file that won't compile.
- **Shared component reuse:** the feature composes existing `ssr/components` (`data-grid-card`, `validated-field`, `text-input`, `money-input`, `button`, `checkbox`, `errors`, `pill`, `form-errors`) and `ssr/form-cursor` — no core-component changes expected (req #5). If a component genuinely needs a new option, prefer an additive, backward-compatible change and flag it.
- **Test fixture change:** giving the seeded bank account a deterministic `:bank-account/code` in `test_server.clj` could affect other e2e specs that assume the random code; U1 verifies the existing suite stays green.
- **Permissions:** confirm the `wrap-must` `:activity`/`:subject` for the import routes matches the permission model (ledger uses `{:activity :import :subject :ledger}`); use the transaction equivalent.
---
## Risks & Mitigations
- **Preview/engine drift (highest risk).** Mitigated by Decision 5 — share the engine's predicates for warn conditions; the parity test in U5 asserts the grid and `categorize-transaction` agree.
- **Headless clipboard paste.** Mitigated by Decision 6 — fillable textarea + explicit parse trigger so the e2e never needs `navigator.clipboard`.
- **form-cursor round-tripping of an editable grid** is the trickiest ledger mechanic to copy; mitigate by mirroring `external-import-table-form*` closely and testing edit-persist-on-resubmit early (U4).
- **Positional column brittleness** is inherited from master (Yodlee column order); not a regression, and out of scope to fix here.
---
## Deferred Implementation Notes (execution-time unknowns)
- Exact helper/function names in the new `import.clj` namespace.
- The precise malli `:decode` wiring for positional parsing (reuse vs. thin wrapper around `tabulate-data`).
- The exact `:activity`/`:subject` keyword for the import-route `wrap-must` (verify against the permissions model).
- The seeded bank-account code value and whether any existing e2e needs adjustment after making it deterministic.
- Final notification/banner copy.

View File

@@ -0,0 +1,298 @@
# Phase I Trial: Django-Formtools Style Wizard (Server-Side Storage)
## Goal
Create a **copy** of one existing wizard using Approach B (server-side session storage) without modifying any existing multi_modal.clj code. This is an isolated trial to validate the pattern.
## Trial Subject: Transaction Bulk Code Wizard
**Why this one:**
- Single-step form (simplest case)
- Currently uses wizard protocols unnecessarily
- ~420 lines in `transaction/bulk_code.clj`
- Well-contained with clear inputs/outputs
---
## Architecture
```
Browser Server
| |
|-- GET /bulk-code-trial -->|
| |-- Create session entry
| |-- Return form with wizard-id
|<-- HTML + wizard-id -----|
| |
|-- POST (wizard-id + -->|
| current-step data) |-- Validate step
| |-- Store in session
| |-- Return next step or done
|<-- HTML -----------------|
```
**Key difference from current approach:**
- Current: EDN snapshot serialized in hidden form fields
- Trial: Only `wizard-id` and current step fields in form. State lives server-side in atom.
---
## Files (All New - No Existing Files Modified)
```
src/clj/auto_ap/ssr/components/wizard_trial/
state.clj - Session storage backend
core.clj - Trial wizard engine (minimal)
src/clj/auto_ap/ssr/transaction/
bulk_code_trial.clj - Trial implementation of bulk code
test/clj/auto_ap/ssr/transaction/
bulk_code_trial_test.clj - Tests for trial
```
---
## Phase I: Trial Implementation (This Week)
### Step 1: Create Session Storage
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/state.clj`
```clojure
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session. Returns wizard-id."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session."
[id step-key step-data]
(swap! store assoc-in [id :data step-key] step-data))
(defn get-all-data
"Returns merged data from all steps."
[id]
(-> (get-wizard id)
:data
vals
(apply merge)))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))
```
### Step 2: Create Minimal Wizard Engine
**File:** `src/clj/auto_ap/ssr/components/wizard_trial/core.clj`
```clojure
(ns auto-ap.ssr.components.wizard-trial.core
(:require [auto-ap.ssr.components.wizard-trial.state :as ws]
[malli.core :as mc]))
(defn render-step
"Renders a single step form."
[{:keys [wizard-id step-config request]}]
(let [step-data (get-in (ws/get-wizard wizard-id) [:data (:key step-config)])]
[:form {:hx-post (:submit-route step-config)
:hx-target "this"
:hx-swap "outerHTML"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name (:key step-config))}]
((:render step-config) (assoc request :step-data step-data))]))
(defn handle-submit
"Handles step submission."
[step-config request]
(let [{:keys [wizard-id step-key]} (:form-params request)
wizard-id (str wizard-id)
step-key (keyword step-key)
step-data (select-keys (:form-params request) (:fields step-config))]
(if-let [errors (mc/explain (:schema step-config) step-data)]
;; Validation failed - re-render with errors
(render-step {:wizard-id wizard-id
:step-config step-config
:request (assoc request :errors errors)})
;; Success - save and done (single step for trial)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))))
```
### Step 3: Create Trial Bulk Code Form
**File:** `src/clj/auto_ap/ssr/transaction/bulk_code_trial.clj`
```clojure
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require [auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.utils :refer [html-response]]
[auto-ap.datomic :refer [conn pull-attr]]
[datomic.api :as dc]))
(def bulk-code-schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage :double]]]]]])
(defn render-bulk-code-form
"Pure function - renders the form given data."
[{:keys [step-data errors]}]
[:div.bulk-code-trial
[:h2 "Bulk Code (Trial - Phase I)"]
[:div.space-y-4
;; Vendor field
[:div
[:label "Vendor"]
(com/typeahead {:name "vendor"
:value (:vendor step-data)
:url "/api/vendors/search"})]
;; Accounts table
[:div
[:h3 "Accounts"]
[:table
[:thead
[:tr
[:th "Account"]
[:th "Location"]
[:th "%"]]]
[:tbody
(for [[idx account] (map-indexed vector (:accounts step-data []))]
[:tr {:key idx}
[:td (com/typeahead {:name (str "accounts[" idx "][account]")
:value (:account account)})]
[:td (com/text-input {:name (str "accounts[" idx "][location]")
:value (:location account)})]
[:td (com/money-input {:name (str "accounts[" idx "][percentage]")
:value (:percentage account)})]])]]]
;; Submit
[:button {:type "submit"} "Apply Bulk Code"]]])
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
;; Apply bulk coding logic here
(html-response [:div.success "Bulk code applied! (Trial)"]))})
;; Route handlers
(defn open-trial [request]
(let [wizard-id (ws/create! {:accounts []})]
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request})))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))
```
### Step 4: Add Routes
In your routes file (new entries, don't modify existing):
```clojure
{::route/bulk-code-trial open-trial
::route/bulk-code-trial-submit submit-trial}
```
---
## Phase II: Expand Trial (If Phase I Works)
**Goal:** Test with a multi-step wizard
**Subject:** New Invoice Wizard (2-3 steps)
- Step 1: Basic details
- Step 2: Accounts (conditional)
- Step 3: Submit
**New additions to trial engine:**
- Step navigation (next/prev)
- Conditional steps (skip accounts if not customizing)
- Step validation per-step
- Progress indicator
**Files to create:**
```
src/clj/auto_ap/ssr/invoice/
new_invoice_trial.clj
```
---
## Phase III: Full Migration Decision (If Phase II Works)
**Goal:** Decide whether to migrate all wizards or keep both systems
**Evaluation criteria:**
1. ✅ Line count reduction (target: 50%+)
2. ✅ Testability (pure functions easier to test)
3. ✅ Performance (server-side storage vs EDN serialization)
4. ✅ Complexity (fewer protocols/middleware)
5. ⚠️ Session handling (what happens on server restart?)
6. ⚠️ Multiple tabs (can user have two wizards open?)
**Decision matrix:**
| Criteria | Current | Trial | Winner |
|----------|---------|-------|--------|
| Lines of code | ~8,200 | ~3,000 (est.) | Trial |
| Server restarts | Survives (state in form) | Loses state | Current |
| Multiple tabs | Works (independent forms) | Needs separate IDs | Tie |
| Testability | Hard (cursor context) | Easy (pure functions) | Trial |
| Complex merges | Painful | Simple (keyed steps) | Trial |
**If trial wins:** Migrate all wizards using Phase II pattern
**If mixed:** Use trial for simple forms, keep current for complex multi-step
---
## How to Run the Trial
1. **Start server:** `lein run`
2. **Navigate to:** `/transaction/bulk-code-trial`
3. **Test:** Fill form, submit, verify state handling
4. **Compare:** Open existing `/transaction/bulk-code` in another tab
5. **Evaluate:** Which feels simpler? Which is easier to debug?
---
## Success Criteria for Phase I
- [ ] Trial renders without errors
- [ ] Form submission validates correctly
- [ ] Server-side state persists across requests
- [ ] No modifications to existing multi_modal.clj
- [ ] Code is < 200 lines (vs 420 original)
- [ ] Developer can understand flow in 5 minutes
**Ready to implement Phase I?**

File diff suppressed because it is too large Load Diff

View File

@@ -455,93 +455,6 @@ test.describe('Transaction Edit Vendor Pre-population', () => {
});
});
// Drives the *real* vendor typeahead the way a user does: open the dropdown,
// click a rendered result. The vendor search is backed by Solr (unavailable in
// tests), so the result option is injected into the typeahead's Alpine
// `elements` instead of being fetched. Everything else -- the dropdown's own
// search input firing a native `change` on blur, the `value = element` click
// handler, the Alpine reactivity, and the HTMX round-trip to
// `edit-vendor-changed` -- runs exactly as in production. This is the flow that
// regressed: a stale native `change` from the search input used to win the race
// and revert the vendor to its previous value.
async function selectVendorViaDropdown(page: any, vendorId: number, vendorName: string) {
const wrapper = page.locator('div[hx-post*="edit-vendor-changed"]').first();
const typeahead = wrapper.locator('div.relative[x-data]').first();
// Open the dropdown (tippy renders the popper into [data-tippy-root]).
await typeahead.locator('a[x-ref="input"]').click();
const search = page.locator('[data-tippy-root] input[x-model="search"]').first();
await search.waitFor({ state: 'visible' });
// Type under the 3-char search threshold so no Solr request fires and clears
// our injected option, while still dirtying the input so it fires a native
// `change` on blur -- the event that used to clobber the selection.
await search.fill('te');
// Inject a clickable result into the typeahead's Alpine state.
await typeahead.evaluate(
(el: HTMLElement, opt: { id: number; label: string }) => {
(window as any).Alpine.$data(el).elements = [{ value: opt.id, label: opt.label }];
},
{ id: vendorId, label: vendorName }
);
// Click the rendered option: fires the search input's native change (stale
// value) AND the synthetic change carrying the new value, then HTMX swaps.
await page.locator('[data-tippy-root] a', { hasText: vendorName }).first().click();
await page.waitForResponse(
(response: any) =>
response.url().includes('/edit-vendor-changed') && response.status() === 200
);
await page.waitForTimeout(500);
}
// Opens the edit modal and activates the Manual tab, waiting on the vendor
// typeahead rather than the account grid (which only exists in advanced mode).
async function openManualVendorSection(page: any, transactionIndex: number) {
await page.goto('/transaction2');
await page.waitForSelector('table tbody tr');
const editButton = page
.locator('button[hx-get*="/transaction2/"][hx-get*="/edit"]')
.nth(transactionIndex);
await editButton.click();
await page.waitForSelector('#modal-holder[x-show="open"]', { state: 'visible' });
await page.waitForSelector('#wizardmodal');
await page.click('button:has-text("Manual")');
await page.waitForSelector('div[hx-post*="edit-vendor-changed"]');
}
test.describe('Transaction Edit Vendor Selection', () => {
test('selecting a vendor from the dropdown updates the displayed vendor', async ({ page }) => {
await openManualVendorSection(page, 3);
const testInfo = await getTestInfo(page);
const vendorId: number = testInfo.accounts.vendor;
await selectVendorViaDropdown(page, vendorId, 'Test Vendor');
// The displayed vendor label must reflect the selection after the HTMX
// round-trip. Before the fix this reverted to blank because a stale
// `change` event submitted the previous vendor and its response won.
const label = page
.locator('div[hx-post*="edit-vendor-changed"] span[x-text="value.label"]')
.first();
await expect(label).toHaveText('Test Vendor');
// The server-rendered hidden input must carry the newly selected vendor id.
const hidden = page
.locator(
'div[hx-post*="edit-vendor-changed"] input[type="hidden"][name="step-params[transaction/vendor]"]'
)
.first();
await expect(hidden).toHaveValue(vendorId.toString());
});
});
test.describe('Transaction Link Date Display', () => {
test('should show payment date when linking to payment', async ({ page }) => {
await openEditModalForTransaction(page, 'Transaction for payment link');

View File

@@ -1,125 +0,0 @@
import { test, expect } from '@playwright/test';
// The SSR manual transaction import accepts the exact Yodlee positional-column
// TSV format from the master branch. Column order (14 columns), per
// auto-ap.import.manual/columns:
// 0:status 1:raw-date 2:description-original 3:high-level-category
// 4,5:(unused) 6:amount 7..11:(unused) 12:bank-account-code 13:client-code
//
// The test server (auto-ap.test-server) seeds client "TEST" with a bank
// account whose code is the deterministic "TEST-CHK" (see seed-test-data).
const IMPORT_PATH = '/transaction2/external-import-new';
function yodleeRow(opts: {
status?: string;
date?: string;
description?: string;
category?: string;
amount?: string;
bankAccountCode?: string;
clientCode?: string;
}): string {
const cols = new Array(14).fill('');
cols[0] = opts.status ?? 'POSTED';
cols[1] = opts.date ?? '';
cols[2] = opts.description ?? '';
cols[3] = opts.category ?? '';
cols[6] = opts.amount ?? '';
cols[12] = opts.bankAccountCode ?? '';
cols[13] = opts.clientCode ?? '';
return cols.join('\t');
}
function yodleeTsv(rows: string[]): string {
// First line is a header that the importer drops.
const header = new Array(14).fill('');
header[0] = 'Status';
header[1] = 'Date';
header[2] = 'Description';
header[6] = 'Amount';
header[12] = 'Bank Account';
header[13] = 'Client';
return [header.join('\t'), ...rows].join('\n');
}
async function gotoImport(page: any) {
await page.setExtraHTTPHeaders({ 'x-clients': '"mine"' });
await page.goto(IMPORT_PATH);
}
async function pasteAndParse(page: any, tsv: string) {
const textarea = page.locator('#parse-form textarea').first();
await textarea.fill(tsv);
// A visible "Parse" button submits the paste form (htmx swaps in the grid).
await page.getByRole('button', { name: /parse/i }).click();
await page.waitForTimeout(800);
}
test.describe('Manual Transaction Import (SSR)', () => {
test('renders the import page with a paste box', async ({ page }) => {
await gotoImport(page);
await expect(page.locator('#parse-form textarea').first()).toBeVisible();
});
test('paste -> parse -> review grid -> import a valid transaction', async ({ page }) => {
await gotoImport(page);
const description = 'E2E Imported Coffee';
const tsv = yodleeTsv([
yodleeRow({
date: '01/15/2024',
description,
category: 'Food',
amount: '12.50',
bankAccountCode: 'TEST-CHK',
clientCode: 'TEST',
}),
]);
await pasteAndParse(page, tsv);
// The review grid renders the parsed row as editable inputs (the
// description lives in an input value, so assert on the input, not text).
await expect(page.locator('input[value="TEST-CHK"]').first()).toBeVisible();
await expect(page.locator(`input[value="${description}"]`).first()).toBeVisible();
// Import the clean batch.
await page.getByRole('button', { name: /^import$/i }).click();
await page.waitForTimeout(1000);
// The imported transaction shows up on the transactions list.
await page.goto('/transaction2?date-range=all');
await page.waitForSelector('table tbody tr');
await expect(page.getByText(description)).toBeVisible();
});
test('blocks the whole batch when a row has an unknown bank-account code', async ({ page }) => {
await gotoImport(page);
const description = 'E2E Blocked Row';
const tsv = yodleeTsv([
yodleeRow({
date: '01/16/2024',
description,
amount: '20.00',
bankAccountCode: 'NOPE-DOES-NOT-EXIST',
clientCode: 'TEST',
}),
]);
await pasteAndParse(page, tsv);
// The grid surfaces a blocking error for the bad row. The importer reuses
// the master-branch message wording ("Cannot find bank account by code …").
await expect(page.getByText(/cannot find bank account/i).first()).toBeVisible();
// Importing does not create the transaction (batch blocked).
await page.getByRole('button', { name: /^import$/i }).click();
await page.waitForTimeout(800);
await page.goto('/transaction2?date-range=all');
await page.waitForSelector('table tbody tr');
await expect(page.getByText(description)).toHaveCount(0);
});
});

View File

@@ -118,7 +118,12 @@
"type": "local",
"command": ["clojure", "-Tmcp", "start", ":config-profile", ":cli-assist"],
"enabled": true
}
} ,
"tavily": {
"type": "remote",
"url": "https://mcp.tavily.com/mcp/?tavilyApiKey=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",
"enabled": true
}
},
"permission": {
"read": "allow",

File diff suppressed because one or more lines are too long

View File

@@ -333,8 +333,7 @@
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
(mark-all-dirty 14)
(mark-all-dirty 5)
(delete-all)
(sales-summaries-v2)

View File

@@ -10,7 +10,8 @@
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[datomic.api :as dc]))
[datomic.api :as dc]
[hiccup2.core :as hiccup]))
(defn hourly-changes []
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
@@ -55,68 +56,34 @@
[:div
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
[:div
[:div.w-full.h-64
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
:labels ["2 years ago" "1 year ago" "today"]
:data (for [n [2 1 0]
:let [start (time/plus (time/now) (time/years (- n)))]]
(->> (dc/q '[:find (count ?c)
:in $
:where [?c :client/code]]
(dc/as-of (dc/db conn) (coerce/to-date start)))
first
first))})
:x-init "new Chart($el, {
type: 'bar',
data: {
labels: labels,
datasets: [{
label: 'Clients',
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});"}]]]]])
[:div {:class "w-full h-64"
:id "client-chart"
:data-chart (hx/json {:labels ["2 years ago" "1 year ago" "today"],
:series [(for [n [2 1 0]
:let [start (time/plus (time/now) (time/years (- n)))]]
(->> (dc/q '[:find (count ?c)
:in $
:where [?c :client/code]]
(dc/as-of (dc/db conn) (coerce/to-date start)))
first
first))]})}]
[:script {:lang "javascript"}
(hiccup/raw
"new Chartist.Bar('#client-chart', JSON.parse(document.getElementById('client-chart').getAttribute('data-chart')))")]]]])
(com/content-card {:class "w-1/2"}
[:div {:class "flex flex-col px-4 py-3 space-y-3"}
[:div
[:h1.text-2xl.mb-3.font-bold "Changes by hour"]
[:div
[:div.w-full.h-64
[:canvas.w-full.h-full {:x-data (hx/json {:chart nil
:labels (for [n (range -24 0)]
(format "%d" n))
:data (hourly-changes)})
:x-init "new Chart($el, {
type: 'line',
data: {
labels: labels,
datasets: [{
label: 'Changes',
data: data,
borderWidth: 1
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true
}
}
}
});"}]]]]])])
[:div {:class "w-full h-64"
:id "changes"
:data-chart (hx/json {:labels (for [n (range -24 0)]
(format "%d" n)),
:series [(hourly-changes)]})}]
[:script {:lang "javascript"}
(hiccup/raw
"new Chartist.Line('#changes', JSON.parse(document.getElementById('changes').getAttribute('data-chart')))")]]]])])
"Admin"))
(def key->handler

View File

@@ -35,7 +35,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -80,7 +80,9 @@
(defn- transaction-nav-url [request route & {:keys [default-params] :or {default-params {:date-range "month"}}}]
(let [preserved (transaction-nav-params request)]
(hu/url (bidi/path-for ssr-routes/only-routes route)
{:date-range "month"})))
#_(if (or (:start-date preserved) (:end-date preserved))
preserved
(merge default-params preserved)))))
(defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav",
@@ -304,12 +306,6 @@
:hx-boost "true"
:hx-include "#transaction-filters"}
"Approved")
(when (is-admin? (:identity request))
(menu-button- {:href (bidi/path-for ssr-routes/only-routes
::transaction-routes/external-import-page)
:active? (= ::transaction-routes/external-import-page (:matched-route request))
:hx-boost "true"}
"Import"))
(when (can? (:identity request)
{:subject :transaction :activity :insights})
(menu-button- {:href (bidi/path-for ssr-routes/only-routes

View File

@@ -14,5 +14,5 @@
[:section (merge params {:class (hh/add-class " py-3 sm:py-5" (:class params))})
[:div {:class (:max-w params "max-w-screen-2xl")}
(into
[:div {:class "relative overflow-auto shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
[:div {:class "relative overflow-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
children)]])

View File

@@ -7,7 +7,7 @@
[clj-time.core :as t]
[clj-time.periodic :as per]))
(defn date-range-field [{:keys [value id]}]
(defn date-range-field [{:keys [value id apply-button?]}]
[:div {:id id}
(com/field {:label "Date Range"}
[:div.space-y-4
@@ -17,7 +17,7 @@
(com/button-group-button {:size :small :value "week" :hx-trigger "click"} "Week")
(com/button-group-button {:size :small :value "month" :hx-trigger "click"} "Month")
(com/button-group-button {:size :small :value "year" :hx-trigger "click"} "Year"))]
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
[:div.flex.space-x-1.items-baseline.w-full.justify-start
(com/date-input {:name "start-date"
:value (some-> (:start value)
(atime/unparse-local atime/normal-date))
@@ -31,8 +31,9 @@
:placeholder "Date"
:size :small
:class "shrink date-filter-input"})
(but/button- {:color :secondary
:size :small
:type "button"
"x-on:click" "$dispatch('datesApplied')"}
"Apply")]])])
(when apply-button?
(but/button- {:color :secondary
:size :small
:type "button"
"x-on:click" "$dispatch('datesApplied')"}
"Apply"))]])])

View File

@@ -63,14 +63,14 @@
:x-model (:x-model params)}
(if (:disabled params)
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value = {value: '', label: '' }"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
[:input (-> params
(dissoc :class)
(dissoc :value-fn)
@@ -81,9 +81,9 @@
(assoc
"x-ref" "hidden"
:type "hidden"
:type "hidden"
":value" "value.value"
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $dispatch('change')); }); "))))]
:x-init (hiccup/raw (str "$watch('value', v => $dispatch('change')); "))))]
[:div.flex.w-full.justify-items-stretch
[:span.flex-grow.text-left {"x-text" "value.label"}]
[:div {:class "w-3 h-3 m-1 inline ml-1 justify-self-end text-gray-500 self-center"}
@@ -93,72 +93,71 @@
:x-tooltip "value.warning"} "!")]]])
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 ring-1"
"@keydown.escape" "tippy.hide(); value = {value: '', label: '' }; "
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:input {:type "text"
[:input {:type "text"
:autofocus true
:class (-> (:class params)
(or "")
(hh/add-class default-input-classes)
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
"x-model" "search"
"placeholder" (:placeholder params)
"@change.stop" ""
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active >= 0 ? active : 0] : {'value': '', label: ''}; $refs.input.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
:class (-> (:class params)
(or "")
(hh/add-class default-input-classes)
(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))
"x-model" "search"
"placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "tippy.hide(); value = elements.length > 0 ? $data.elements[active] : {'value': '', label: ''}; $refs.input.focus()"
"x-init" "$el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => {elements = data; active=-1; tippy.popperInstance.update()}) }})"}]
[:div.dropdown-options {:class "rounded-b-lg overflow-hidden"}
[:template {:x-for "(element, index) in elements"}
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
:href "#"
":class" "active == index ? 'active' : ''"
[:li [:a {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 [&.active]:dark:bg-primary-700 text-gray-800 dark:text-gray-100"
:href "#"
":class" "active == index ? 'active' : ''"
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"x-html" "element.label"}]]]
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); $refs.input.focus()"
"x-html" "element.label"}]]]
[:template {:x-if "elements.length == 0"}
[:li {:class "px-4 py-2 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs "}
"No results found"]]]]]])
(defn multi-typeahead-dropdown- [params]
[:template {:x-ref "dropdown"}
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
"@keydown.escape.prevent" "tippy.hide();"
[:ul.dropdown-contents {:class "bg-gray-100 dark:bg-gray-600 rounded-lg shadow-2xl w-max z-50 ring-1 p-4"
"@keydown.escape.prevent" "tippy.hide();"
:x-destroy "if ($refs.input) {$refs.input.focus();}"}
[:div {:class (-> "relative"
#_(hh/replace-wildcard ["rounded" "border"] "border-bottom bg-gray-100 rounded-t-lg w-full"))}
[:div {:class "absolute inset-y-0 start-0 flex items-center ps-3 pointer-events-none"}
[:svg {:class "w-4 h-4 text-gray-500 dark:text-gray-400", :aria-hidden "true", :xmlns "http://www.w3.org/2000/svg", :fill "none", :viewbox "0 0 20 20"}
[:path {:stroke "currentColor", :stroke-linecap "round", :stroke-linejoin "round", :stroke-width "2", :d "m19 19-4-4m0-7A7 7 0 1 1 1 8a7 7 0 0 1 14 0Z"}]]]
[:input {:type "text"
[:input {:type "text"
:class (-> (:class params)
(or "")
(hh/add-class "block w-full p-2 ps-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500")
(hh/add-class default-input-classes))
"x-model" "search"
"placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"x-model" "search"
"placeholder" (:placeholder params)
"@keydown.down.prevent" "active ++; active = active >= elements.length - 1 ? elements.length - 1 : active"
"@keydown.up.prevent" "active --; active = active < 0 ? 0 : active"
"@keydown.enter.prevent.stop" "if ($data.elements[active]) { if (value.has($data.elements[active].value)) { value.delete($data.elements[active].value) } else {value.add($data.elements[active].value); lookup[$data.elements[active].value] = $data.elements[active].label} } "
"x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]]
"x-init" " $el.focus(); $watch('search', s => { if($el.value.length > 2) {fetch(addQueryParam(baseUrl, 'q', s)).then(data=>data.json()).then(data => reset_elements(data)) }})"}]]
[:div.dropdown-options {:class "overflow-hidden divide-y divide-gray-200 "}
[:template {:x-for "(element, index) in elements"}
[:li {":style" "index == 0 && 'border: 0 !important;'"}
[:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer"
[:li {":style" "index == 0 && 'border: 0 !important;'"}
[:label {:class "p-3 group rounded flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-300 [&.active]:dark:bg-primary-700 [&.implied]:text-gray-500 text-gray-800 dark:text-gray-100 cursor-pointer"
:href "#"
":class" (hx/json {"active" (hx/js-fn "active==index")
:href "#"
":class" (hx/json {"active" (hx/js-fn "active==index")
"implied" (hx/js-fn "all_selected && index != 0")})
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "toggle(element)"}
(checkbox- {":checked" "value.has(element.value) || all_selected"
:class "group-[&.implied]:bg-green-200"})
#_[:input {:type "checkbox"}]
[:span {"x-html" "element.label"}]]]]
[:span {"x-html" "element.label"}]]]]
[:template {:x-if "elements.length == 0"}
[:li {:class "px-4 pt-4 flex gap-2 items-center outline-0 focus:bg-neutral-100 hover:bg-neutral-100 whitespace-nowrap [&.active]:bg-primary-500 text-gray-800 dark:text-gray-100 text-xs " "style" "border: 0 !important"}
"No results found"]]]]])
@@ -226,7 +225,7 @@
:x-init (str "$watch('value', v => $dispatch('change')); ")
:search ""
:active -1
:elements (cond-> [{:value "all" :label "All"}]
:elements (cond-> [{:value "all" :label "All"}]
(sequential? (:value params))
(into (map (fn [v]
{:value ((:value-fn params identity) v)
@@ -238,24 +237,24 @@
:x-init "value=new Set(value || []); "}
(if (:disabled params)
[:span {:x-text "value.label"}]
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
[:a {:class (-> (hh/add-class (or (:class params) "") default-input-classes)
(hh/add-class "cursor-pointer"))
"x-tooltip.on.click.prevent" "{content: ()=>$refs.dropdown.innerHTML, placement: 'bottom', onMount(i) {htmx.process(i.popper); }, popperOptions: {strategy: 'fixed', modifiers: [{name: 'flip', options: {fallbackPlacements: ['top']}}]}, theme: 'dropdown', allowHTML: true, interactive:true}"
"@keydown.down.prevent.stop" "tippy.show();"
"@keydown.backspace" "tippy.hide(); value=new Set( []);"
:tabindex 0
:x-init (str "$nextTick(() => tippy = $el.__x_tippy); " (:x-init params))
:x-ref "input"}
[:template {:x-for "v in Array.from(value.values())"}
[:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc
:type "hidden"
:type "hidden"
"x-bind:value" "v"))]]
[:template {:x-if "value.size == 0"}
[:input (-> params
(dissoc :class :value-fn :content-fn :placeholder :x-model)
(assoc :type "hidden"
(assoc :type "hidden"
:value ""))]]
[:div.flex.w-full.justify-items-stretch
(multi-typeahead-selected-pill- params)
@@ -297,23 +296,23 @@
(defn money-input- [{:keys [size] :as params}]
[:input
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size)))
(assoc :type "number"
:step "0.01")
(dissoc :size))])
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size)))
(assoc :type "number"
:step "0.01")
(dissoc :size))])
(defn int-input- [{:keys [size] :as params}]
[:input
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size)))
(assoc :type "number"
:step "1")
(dissoc :size))])
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(update :class hh/add-class "appearance-none text-right")
(update :class #(str % (use-size size)))
(assoc :type "number"
:step "1")
(dissoc :size))])
(defn date-input- [{:keys [size] :as params}]
[:div.shrink {:x-data (hx/json {:value (:value params)
@@ -322,40 +321,40 @@
"x-effect" "console.log('changed to' +value)"
"@change-date.camel" "$dispatch('change')"}
[:input
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value")
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :x-model "value")
(assoc "x-tooltip.on.focus" "{content: ()=>$refs.tooltip.innerHTML, theme: 'light', onMount(i) { htmx.process(i.popper); }, allowHTML: true, interactive:true}")
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text")
(assoc :x-init "$nextTick(() => tippy = $el.__x_tippy); ")
(assoc :type "text")
(assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;")
(assoc "autocomplete" "off")
(assoc "@change" "value = $event.target.value;")
(assoc "@keydown.escape" "tippy.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size))]
(assoc "@keydown.escape" "tippy.hide(); ")
#_(assoc "hx-on" (hiccup/raw "changeDate: htmx.trigger(this, \"change\") "))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size))]
[:template {:x-ref "tooltip"}
[:div.shrink
[:div
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value (:value params))
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value (:value params))
;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]]])
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]]])
(defn multi-calendar-input- [{:keys [size] :as params}]
(let [value (str/join ", "
@@ -369,21 +368,21 @@
[:template {:x-for "v in value"}
[:input {:type "hidden" :name (:name params) :x-model "v"}]]
[:div
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value value)
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(assoc :x-init "$nextTick(() => { dp = initMultiDatepicker($el, value); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(Array.from(value), {clear: true}); } ")
(assoc ":data-date" "Array.prototype.join.call(value, ', ')")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]))
(defn calendar-input- [{:keys [size] :as params}]
(let [value (:value params)]
@@ -393,21 +392,21 @@
:x-model (:x-model params)}
[:input {:type "hidden" :name (:name params) :x-model "value"}]
[:div
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value value)
(-> params
(update :class (fnil hh/add-class "") default-input-classes)
(assoc :type "text")
(assoc :value value)
;; the data-date field has to be bound before the datepicker can be initialized
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(assoc :x-init "$nextTick(() => { dp = initCalendar($el); ;}); ")
(assoc "x-effect" "if(dp) { dp.setDate(value); } ")
(assoc ":data-date" "value")
(assoc "@htmx:before-history-save" "destroyDatepicker(dp)")
(assoc "@htmx:before-cleanup-element" "destroyDatepicker(dp)")
(assoc "x-destroy" "destroyDatepicker(dp)")
(assoc "@change-date.camel" "value = dp.getDate(\"mm/dd/yyyy\");")
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]))
(update :class #(str % (use-size size) " w-full"))
(dissoc :size :name :x-model :x-modelable))]]))
(defn field-errors- [{:keys [source key]} & rest]
(let [errors (:errors (cond-> (meta source)

View File

@@ -0,0 +1,54 @@
(ns auto-ap.ssr.components.wizard-trial.core
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.utils :refer [html-response main-transformer modal-response]]
[malli.core :as mc]
[malli.error :as me]))
(defn render-step
"Renders a single step form.
step-config is a map with:
- :key - step keyword
- :render - function taking {:keys [step-data errors request]} returning hiccup
- :submit-route - string URL for form POST"
[{:keys [wizard-id step-config request errors]}]
(let [{:keys [key render submit-route]} step-config
wizard (ws/get-wizard wizard-id)
step-data (get-in wizard [:data key] {})]
[:form {:hx-post submit-route
:hx-target "this"}
[:input {:type "hidden" :name "wizard-id" :value wizard-id}]
[:input {:type "hidden" :name "step-key" :value (name key)}]
(render {:step-data step-data :errors errors :request request})]))
(defn handle-submit
"Handles step submission.
Validates step data against schema.
If valid: saves to session and calls done-fn.
If invalid: re-renders step with errors."
[step-config request]
(let [{:keys [form-params]} request
wizard-id (get form-params "wizard-id")
step-key (keyword (get form-params "step-key"))
fields (:fields step-config)
step-data (reduce (fn [acc field]
(if-let [v (get form-params (name field))]
(assoc acc field v)
acc))
{}
fields)
schema (:schema step-config)
decoded (mc/decode schema step-data main-transformer)
valid? (mc/validate schema decoded)]
(if valid?
(do
(ws/update-step! wizard-id step-key decoded)
(let [all-data (ws/get-all-data wizard-id)]
(ws/destroy! wizard-id)
((:done-fn step-config) all-data request)))
(let [errors (me/humanize (mc/explain schema decoded))]
(modal-response
(render-step {:wizard-id wizard-id
:step-config step-config
:request request
:errors errors}))))))

View File

@@ -0,0 +1,35 @@
(ns auto-ap.ssr.components.wizard-trial.state)
(defonce ^:private store (atom {}))
(defn create!
"Creates new wizard session with initial data. Returns wizard-id string."
[initial-data]
(let [id (str (java.util.UUID/randomUUID))]
(swap! store assoc id {:data initial-data
:created-at (java.util.Date.)})
id))
(defn get-wizard
"Retrieves wizard data by id. Returns nil if not found."
[id]
(get @store id))
(defn update-step!
"Merges step data into wizard session under step-key."
[id step-key step-data]
(swap! store update-in [id :data step-key] merge step-data))
(defn get-all-data
"Returns merged data from all steps for final submission."
[id]
(when-let [wizard (get-wizard id)]
(let [data (:data wizard)]
(apply merge
(into {} (remove (comp map? val) data))
(filter map? (vals data))))))
(defn destroy!
"Removes wizard session."
[id]
(swap! store dissoc id))

View File

@@ -56,7 +56,7 @@
[:div {:id "exact-match-id-tag"}]))
(defn filters [request]
[:form#invoice-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#invoice-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/import-table)
"hx-target" "#entity-table"

View File

@@ -11,10 +11,10 @@
[auto-ap.routes.transactions :as transaction-routes]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.date-range :as dr]
[auto-ap.ssr.components.link-dropdown :refer [link-dropdown]]
[auto-ap.ssr.grid-page-helper :as helper]
[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 [clj-date-schema entity-id html-response ref->enum-schema
@@ -31,7 +31,7 @@
(defn exact-match-id* [request]
(if (nat-int? (:exact-match-id (:query-params request)))
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag"}
(com/hidden {:name "exact-match-id"
"x-model" "exact_match"})
(com/pill {:color :primary}
@@ -46,14 +46,13 @@
[:div {:hx-trigger "clientSelected from:body"
:hx-get (bidi.bidi/path-for ssr-routes/only-routes ::route/bank-account-filter)
:hx-target "this"
:hx-swap "outerHTML"
:class "filter-trigger"}
:hx-swap "outerHTML"}
(when (:client request)
(let [bank-account-belongs-to-client? (get (set (map :db/id (:client/bank-accounts (:client request))))
(:db/id (:bank-account (:query-params request))))]
(com/field {:label "Bank Account"}
(com/radio-card {:size :small
:name "bank-account"
(com/radio-card {:size :small
:name "bank-account"
:value (or (when bank-account-belongs-to-client?
(:db/id (:bank-account (:query-params request))))
"")
@@ -61,96 +60,90 @@
(into [{:value ""
:content "All"}]
(for [ba (:client/bank-accounts (:client request))]
{:value (:db/id ba)
{:value (:db/id ba)
:content (:bank-account/name ba)}))}))))])
(defn bank-account-filter [request]
(html-response (bank-account-filter* request)))
(defn filters [request]
[:form#ledger-filters {"hx-trigger" "datesApplied, change delay:500ms from:.filter-trigger, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
[:form#ledger-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
"hx-indicator" "#entity-table"}
(com/hidden {:name "status"
:value (some-> (:status (:query-params request)) name)})
[:fieldset.space-y-6
(com/field {:label "Vendor"}
(com/typeahead {:name "vendor"
:id "vendor"
(com/typeahead {:name "vendor"
:id "vendor"
:url (bidi/path-for ssr-routes/only-routes :vendor-search)
:value (:vendor (:query-params request))
:value (:vendor (:query-params request))
:value-fn :db/id
:content-fn :vendor/name
:class "filter-trigger"}))
:content-fn :vendor/name}))
(com/field {:label "Account"}
(com/typeahead {:name "account"
:id "account"
(com/typeahead {:name "account"
:id "account"
:url (bidi/path-for ssr-routes/only-routes :account-search)
:value (:account (:query-params request))
:value (:account (:query-params request))
:value-fn :db/id
:content-fn #(:account/name (d-accounts/clientize (dc/pull (dc/db conn) d-accounts/default-read (:db/id %))
(:db/id (:client request))))
:class "filter-trigger"}))
(:db/id (:client request))))}))
(bank-account-filter* request)
(dr/date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"
:apply-button? true})
(date-range-field* request)
(com/field {:label "Invoice #"}
(com/text-input {:name "invoice-number"
:id "invoice-number"
:class "hot-filter"
:value (:invoice-number (:query-params request))
(com/text-input {:name "invoice-number"
:id "invoice-number"
:class "hot-filter"
:value (:invoice-number (:query-params request))
:placeholder "e.g., ABC-456"
:size :small}))
:size :small}))
(com/field {:label "Account Code"}
[:div.flex.space-x-4.items-baseline
(com/int-input {:name "numeric-code-gte"
:id "numeric-code-gte"
(com/int-input {:name "numeric-code-gte"
:id "numeric-code-gte"
:hx-preserve "true"
:class "hot-filter w-20"
:value (:numeric-code-gte (:query-params request))
:class "hot-filter w-20"
:value (:numeric-code-gte (:query-params request))
:placeholder "40000"
:size :small})
:size :small})
[:div.align-baseline
"to"]
(com/int-input {:name "numeric-code-lte"
(com/int-input {:name "numeric-code-lte"
:hx-preserve "true"
:id "numeric-code-lte"
:class "hot-filter w-20"
:value (:numeric-code-lte (:query-params request))
:id "numeric-code-lte"
:class "hot-filter w-20"
:value (:numeric-code-lte (:query-params request))
:placeholder "50000"
:size :small})])
:size :small})])
(com/field {:label "Amount"}
[:div.flex.space-x-4.items-baseline
(com/money-input {:name "amount-gte"
:id "amount-gte"
(com/money-input {:name "amount-gte"
:id "amount-gte"
:hx-preserve "true"
:class "hot-filter w-20"
:value (:amount-gte (:query-params request))
:class "hot-filter w-20"
:value (:amount-gte (:query-params request))
:placeholder "0.01"
:size :small})
:size :small})
[:div.align-baseline
"to"]
(com/money-input {:name "amount-lte"
(com/money-input {:name "amount-lte"
:hx-preserve "true"
:id "amount-lte"
:class "hot-filter w-20"
:value (:amount-lte (:query-params request))
:id "amount-lte"
:class "hot-filter w-20"
:value (:amount-lte (:query-params request))
:placeholder "9999.34"
:size :small})])
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
:size :small})])
[:div.mt-4 {:x-data (hx/json {:onlyUnbalanced (:only-unbalanced (:query-params request))})}
(com/hidden {:name "only-unbalanced"
":value" "onlyUnbalanced ? 'on' : ''"})
(com/checkbox {:value (:only-unbalanced (:query-params request))
:class "filter-trigger"
:x-model "onlyUnbalanced"}
"Show unbalanced")]
(exact-match-id* request)]])
@@ -191,12 +184,12 @@
args query-params
query
(if (:exact-match-id args)
{:query {:find '[?e]
:in '[$ ?e [?c ...]]
{:query {:find '[?e]
:in '[$ ?e [?c ...]]
:where '[[?e :journal-entry/client ?c]]}
:args [db
(:exact-match-id args)
valid-clients]}
:args [db
(:exact-match-id args)
valid-clients]}
(cond-> {:query {:find []
:in ['$ '[?clients ?start ?end]]
:where '[[(iol-ion.query/scan-ledger $ ?clients ?start ?end) [[?e _ ?sort-default] ...]]]}
@@ -209,28 +202,28 @@
(merge-query {:query {:where ['(not [?e :journal-entry/original-entity])]}})
(seq (:external-id-like args))
(merge-query {:query {:in ['?external-id-like]
(merge-query {:query {:in ['?external-id-like]
:where ['[?e :journal-entry/external-id ?external-id]
'[(.contains ^String ?external-id ?external-id-like)]]}
:args [(:external-id-like args)]})
:args [(:external-id-like args)]})
(seq (:source args))
(merge-query {:query {:in ['?source]
(merge-query {:query {:in ['?source]
:where ['[?e :journal-entry/source ?source]]}
:args [(:source args)]})
:args [(:source args)]})
(:external? route-params)
(merge-query {:query {:where ['[?e :journal-entry/external-id]]}})
(:vendor args)
(merge-query {:query {:in ['?vendor-id]
(merge-query {:query {:in ['?vendor-id]
:where ['[?e :journal-entry/vendor ?vendor-id]]}
:args [(:db/id (:vendor args))]})
:args [(:db/id (:vendor args))]})
(:invoice-number args)
(merge-query {:query {:in ['?invoice-number]
(merge-query {:query {:in ['?invoice-number]
:where ['[?e :journal-entry/original-entity ?oe]
'[?oe :invoice/invoice-number ?invoice-number]]}
:args [(:invoice-number args)]})
:args [(:invoice-number args)]})
(or (:numeric-code-lte args)
(:numeric-code-gte args)
@@ -242,77 +235,77 @@
(or (:numeric-code-gte args)
(:numeric-code-lte args))
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
(merge-query {:query {:in '[?from-numeric-code ?to-numeric-code]
:where ['[?li :journal-entry-line/account ?a]
'(or-join [?a ?c]
[?a :account/numeric-code ?c]
[?a :bank-account/numeric-code ?c])
'[(>= ?c ?from-numeric-code)]
'[(<= ?c ?to-numeric-code)]]}
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
:args [(or (:numeric-code-gte args) 0) (or (:numeric-code-lte args) 99999)]})
(seq (:numeric-code args))
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
(merge-query {:query {:in '[[[?from-numeric-code ?to-numeric-code] ...]]
:where ['[?li :journal-entry-line/account ?a]
'(or-join [?a ?c]
[?a :account/numeric-code ?c]
[?a :bank-account/numeric-code ?c])
'[(>= ?c ?from-numeric-code)]
'[(<= ?c ?to-numeric-code)]]}
:args [(map (juxt :from :to) (:numeric-code args))]})
:args [(map (juxt :from :to) (:numeric-code args))]})
(seq (:account args))
(merge-query {:query {:in ['?a3]
(merge-query {:query {:in ['?a3]
:where ['[?li :journal-entry-line/account ?a3]]}
:args [(:db/id (:account args))]})
:args [(:db/id (:account args))]})
(:amount-gte args)
(merge-query {:query {:in ['?amount-gte]
(merge-query {:query {:in ['?amount-gte]
:where ['[?e :journal-entry/amount ?a]
'[(>= ?a ?amount-gte)]]}
:args [(:amount-gte args)]})
:args [(:amount-gte args)]})
(:amount-lte args)
(merge-query {:query {:in ['?amount-lte]
(merge-query {:query {:in ['?amount-lte]
:where ['[?e :journal-entry/amount ?a]
'[(<= ?a ?amount-lte)]]}
:args [(:amount-lte args)]})
:args [(:amount-lte args)]})
(:db/id (:bank-account args))
(merge-query {:query {:in ['?a]
(merge-query {:query {:in ['?a]
:where ['[?li :journal-entry-line/account ?a]]}
:args [(:db/id (:bank-account args))]})
:args [(:db/id (:bank-account args))]})
(:account-id args)
(merge-query {:query {:in ['?a2]
(merge-query {:query {:in ['?a2]
:where ['[?e :journal-entry/line-items ?li2]
'[?li2 :journal-entry-line/account ?a2]]}
:args [(:account-id args)]})
:args [(:account-id args)]})
(not-empty (:location args))
(merge-query {:query {:in ['?location]
(merge-query {:query {:in ['?location]
:where ['[?li :journal-entry-line/location ?location]]}
:args [(:location args)]})
:args [(:location args)]})
(not-empty (:locations args))
(merge-query {:query {:in ['[?location ...]]
(merge-query {:query {:in ['[?location ...]]
:where ['[?li :journal-entry-line/location ?location]]}
:args [(:locations args)]})
:args [(:locations args)]})
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
'[?c :client/name ?sort-client]]
"date" ['[?e :journal-entry/date ?sort-date]]
"vendor" '[(or-join [?e ?sort-vendor]
(and
[?e :journal-entry/vendor ?v]
[?v :vendor/name ?sort-vendor])
(and [(missing? $ ?e :journal-entry/vendor)]
[(ground "") ?sort-vendor]))]
"amount" ['[?e :journal-entry/amount ?sort-amount]]
(:sort args) (add-sorter-fields {"client" ['[?e :journal-entry/client ?c]
'[?c :client/name ?sort-client]]
"date" ['[?e :journal-entry/date ?sort-date]]
"vendor" '[(or-join [?e ?sort-vendor]
(and
[?e :journal-entry/vendor ?v]
[?v :vendor/name ?sort-vendor])
(and [(missing? $ ?e :journal-entry/vendor)]
[(ground "") ?sort-vendor]))]
"amount" ['[?e :journal-entry/amount ?sort-amount]]
"external-id" ['[?e :journal-entry/external-id ?sort-external-id]]
"source" '[(or-join [?e ?sort-source]
[?e :journal-entry/source ?sort-source]
(and [(missing? $ ?e :journal-entry/source)]
[(ground "") ?sort-source]))]}
"source" '[(or-join [?e ?sort-source]
[?e :journal-entry/source ?sort-source]
(and [(missing? $ ?e :journal-entry/source)]
[(ground "") ?sort-source]))]}
args)
true
@@ -341,11 +334,11 @@
:journal-entry/external-id
:db/id
[:journal-entry/date :xform clj-time.coerce/from-date]
{:journal-entry/vendor [:vendor/name :db/id]
{:journal-entry/vendor [:vendor/name :db/id]
:journal-entry/original-entity [:invoice/invoice-number
:invoice/source-url
:transaction/description-original :db/id]
:journal-entry/client [:client/name :client/code :db/id]
:journal-entry/client [:client/name :client/code :db/id]
:journal-entry/line-items [:journal-entry-line/debit
:journal-entry-line/location
:journal-entry-line/running-balance
@@ -369,8 +362,8 @@
(defn sum-outstanding [ids]
(->>
(dc/q {:find ['?id '?o]
:in ['$ '[?id ...]]
(dc/q {:find ['?id '?o]
:in ['$ '[?id ...]]
:where ['[?id :invoice/outstanding-balance ?o]]}
(dc/db conn)
ids)
@@ -382,8 +375,8 @@
(defn sum-total-amount [ids]
(->>
(dc/q {:find ['?id '?o]
:in ['$ '[?id ...]]
(dc/q {:find ['?id '?o]
:in ['$ '[?id ...]]
:where ['[?id :invoice/total ?o]]}
(dc/db conn)
ids)
@@ -393,7 +386,7 @@
0.0)))
(defn fetch-page [request]
(let [db (dc/db conn)
(let [db (dc/db conn)
{ids-to-retrieve :ids matching-count :count
all-ids :all-ids} (fetch-ids db request)]
@@ -417,12 +410,12 @@
(if account-name
[:div {:x-tooltip (hx/json (str "Running Balance: " (some->> (:journal-entry-line/running-balance jel)
(format "$%,.2f"))))}
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
[:div.text-left.underline.cursor-pointer {:x-ref "source"}
(:journal-entry-line/location jel) ": "
(or (:account/numeric-code account) (:bank-account/numeric-code account))
" - " account-name]]
[:div.text-left (com/pill {:color :yellow} "Unassigned")])
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
[:div.text-right.text-underline (format "$%,.2f" (key jel))]))
(when-not (= 1 (count lines))
[:div.col-span-2 (com/pill {:color :primary} "Total: " (->> lines
@@ -450,9 +443,9 @@
[:to nat-int?]]]]]
[:numeric-code-gte {:optional true} [:maybe nat-int?]]
[:numeric-code-lte {:optional true} [:maybe nat-int?]]
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
[:vendor {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :vendor/name]}]]]
[:bank-account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :bank-account/name]}]]]
[:account {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :account/name]}]]]
[:check-number {:optional true} [:maybe [:string {:decode/string strip}]]]
[:invoice-number {:optional true} [:maybe [:string {:decode/string strip}]]]
[:status {:optional true} [:maybe (ref->enum-schema "invoice-status")]]
@@ -466,20 +459,17 @@
[:maybe clj-date-schema]]]]))
(def grid-page
(helper/build {:id "entity-table"
:nav com/main-aside-nav
(helper/build {:id "entity-table"
:nav com/main-aside-nav
:check-boxes? true
:check-box-warning? (fn [e]
(some? (:invoice/scheduled-payment e)))
:page-specific-nav filters
:fetch-page fetch-page
:page-specific-nav filters
:fetch-page fetch-page
:oob-render
(fn [request]
[(assoc-in (dr/date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"
:apply-button? true}) [1 :hx-swap-oob] true)
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
[(assoc-in (date-range-field* request) [1 :hx-swap-oob] true)
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
:query-schema query-schema
:action-buttons (fn [request]
[(when-not (:external? (:route-params request)) (com/button {:color :primary
@@ -495,7 +485,7 @@
:hx-confirm "Are you sure you want to void this invoice?"}
svg/trash))
(when (and (can? (:identity request) {:subject :invoice :activity :edit})
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
(#{:invoice-status/unpaid :invoice-status/paid} (:invoice/status entity)))
(com/icon-button {:hx-put (bidi/path-for ssr-routes/only-routes
::route/edit-wizard
:db/id (:db/id entity))}
@@ -507,14 +497,14 @@
:db/id (:db/id entity))}
svg/undo))])
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Ledger"]]
:title (fn [r]
(str
(some-> r :route-params :status name str/capitalize (str " "))
"Register"))
:breadcrumbs [[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)}
"Ledger"]]
:title (fn [r]
(str
(some-> r :route-params :status name str/capitalize (str " "))
"Register"))
:entity-name "register"
:route ::route/table
:route ::route/table
:csv-route ::route/csv
:break-table (fn [request entity]
(cond
@@ -531,102 +521,102 @@
(for [je journal-entries
jel (:journal-entry/line-items je)]
(merge jel je)))
:headers [{:key "id"
:name "Id"
:render-csv :db/id
:render-for #{:csv}}
{:key "client"
:name "Client"
:sort-key "client"
:hide? (fn [args]
(and (= (count (:clients args)) 1)
(= 1 (count (:client/locations (:client args))))))
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
:headers [{:key "id"
:name "Id"
:render-csv :db/id
:render-for #{:csv}}
{:key "client"
:name "Client"
:sort-key "client"
:hide? (fn [args]
(and (= (count (:clients args)) 1)
(= 1 (count (:client/locations (:client args))))))
:render (fn [x] [:div.flex.items-center.gap-2 (-> x :journal-entry/client :client/name)])
:render-csv (fn [x] (-> x :journal-entry/client :client/name))}
{:key "vendor"
:name "Vendor"
:sort-key "vendor"
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
(-> e :journal-entry/alternate-description)))}
{:key "source"
:name "Source"
:sort-key "source"
:hide? (fn [args]
(not (:external? (:route-params args))))
:render :journal-entry/source
:render-csv :journal-entry/source}
{:key "external-id"
:name "External Id"
:sort-key "external-id"
:class "max-w-[12rem]"
:hide? (fn [args]
(not (:external? (:route-params args))))
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
:render-csv :journal-entry/external-id}
{:key "date"
:sort-key "date"
:name "Date"
:show-starting "lg"
:render (fn [{:journal-entry/keys [date]}]
(some-> date (atime/unparse-local atime/normal-date)))}
{:key "amount"
:sort-key "amount"
:name "Amount"
:show-starting "lg"
:render (fn [{:journal-entry/keys [amount]}]
(some->> amount
(format "$%,.2f")))}
{:key "account"
:name "Account"
:sort-key "account"
:class "text-right"
:render-csv #(or (-> % :journal-entry-line/account :account/name)
(-> % :journal-entry-line/account :bank-account/name))
:render-for #{:csv}}
{:key "debit"
:name "Debit"
:class "text-right"
:render (partial render-lines :journal-entry-line/debit)
:render-csv :journal-entry-line/debit}
{:key "vendor"
:name "Vendor"
:sort-key "vendor"
:render (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
[:span.italic.text-gray-400 (-> e :journal-entry/alternate-description)]))
:render-csv (fn [e] (or (-> e :journal-entry/vendor :vendor/name)
(-> e :journal-entry/alternate-description)))}
{:key "source"
:name "Source"
:sort-key "source"
:hide? (fn [args]
(not (:external? (:route-params args))))
:render :journal-entry/source
:render-csv :journal-entry/source}
{:key "external-id"
:name "External Id"
:sort-key "external-id"
:class "max-w-[12rem]"
:hide? (fn [args]
(not (:external? (:route-params args))))
:render (fn [x] [:p.truncate (:journal-entry/external-id x)])
:render-csv :journal-entry/external-id}
{:key "date"
:sort-key "date"
:name "Date"
:show-starting "lg"
:render (fn [{:journal-entry/keys [date]}]
(some-> date (atime/unparse-local atime/normal-date)))}
{:key "amount"
:sort-key "amount"
:name "Amount"
:show-starting "lg"
:render (fn [{:journal-entry/keys [amount]}]
(some->> amount
(format "$%,.2f")))}
{:key "account"
:name "Account"
:sort-key "account"
:class "text-right"
:render-csv #(or (-> % :journal-entry-line/account :account/name)
(-> % :journal-entry-line/account :bank-account/name))
:render-for #{:csv}}
{:key "debit"
:name "Debit"
:class "text-right"
:render (partial render-lines :journal-entry-line/debit)
:render-csv :journal-entry-line/debit}
{:key "credit"
:name "Credit"
:class "text-right"
:render (partial render-lines :journal-entry-line/credit)
:render-csv :journal-entry-line/credit}
{:key "credit"
:name "Credit"
:class "text-right"
:render (partial render-lines :journal-entry-line/credit)
:render-csv :journal-entry-line/credit}
{:key "links"
:name "Links"
:show-starting "lg"
:class "w-8"
:render (fn [i]
(link-dropdown
(cond-> []
(-> i :journal-entry/original-entity :invoice/invoice-number)
(conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::invoice-route/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
(-> i :journal-entry/original-entity :invoice/source-url)
{:link (-> i :journal-entry/original-entity :invoice/source-url)
:color :secondary
:content (str "File")}
{:key "links"
:name "Links"
:show-starting "lg"
:class "w-8"
:render (fn [i]
(link-dropdown
(cond-> []
(-> i :journal-entry/original-entity :invoice/invoice-number)
(conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::invoice-route/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary
:content (format "Invoice '%s'" (-> i :journal-entry/original-entity :invoice/invoice-number))})
(-> i :journal-entry/original-entity :invoice/source-url)
{:link (-> i :journal-entry/original-entity :invoice/source-url)
:color :secondary
:content (str "File")}
(-> i :journal-entry/original-entity :transaction/description-original)
(conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
(-> i :journal-entry/memo)
(conj {:color :secondary
:content (str "Memo: " (:journal-entry/memo i))}))))
:render-for #{:html}}]}))
(-> i :journal-entry/original-entity :transaction/description-original)
(conj
{:link (hu/url (bidi/path-for ssr-routes/only-routes
::transaction-routes/all-page)
{:exact-match-id (:db/id (:journal-entry/original-entity i))})
:color :primary
:content (format "Transaction '%s'" (-> i :journal-entry/original-entity :transaction/description-original))})
(-> i :journal-entry/memo)
(conj {:color :secondary
:content (str "Memo: " (:journal-entry/memo i))}))))
:render-for #{:html}}]}))
(def row* (partial helper/row* grid-page))

View File

@@ -53,7 +53,7 @@
[:div {:id "exact-match-id-tag"}]))
(defn filters [request]
[:form#payment-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#payment-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -29,7 +29,7 @@
default-grid-fields-schema)]))
(defn filters [params]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-cash-drawer-shift-table)
"hx-target" "#cash-drawer-shift-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-expected-deposit-table)
"hx-target" "#expected-deposit-table"

View File

@@ -29,7 +29,7 @@
[:client {:optional true :default nil} [:maybe [:entity-map {:pull [:db/id :client/name]}]]]]
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-refund-table)
"hx-target" "#refund-table"

View File

@@ -34,7 +34,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-sales-table)
"hx-target" "#sales-table"

View File

@@ -44,7 +44,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"

View File

@@ -22,7 +22,7 @@
;; always should be fast
(defn filters [request]
[:form {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
:pos-tender-table)
"hx-target" "#tender-table"

View File

@@ -15,11 +15,11 @@
[auto-ap.ssr.grid-page-helper :as helper :refer [wrap-apply-sort]]
[auto-ap.ssr.ledger :refer [wrap-ensure-bank-account-belongs]]
[auto-ap.ssr.transaction.bulk-code :as bulk-code :refer [all-ids-not-locked]]
[auto-ap.ssr.transaction.bulk-code-trial :as bulk-code-trial]
[auto-ap.ssr.transaction.common :refer [bank-account-filter* fetch-ids
grid-page query-schema
wrap-status-from-source]]
[auto-ap.ssr.transaction.edit :as edit :refer [transaction-account-row*]]
[auto-ap.ssr.transaction.import :as t-import]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers entity-id html-response
many-entity modal-response percentage ref->enum-schema
@@ -54,7 +54,7 @@
all-selected
(:ids (fetch-ids (dc/db conn) (-> request
(assoc-in [:form-params :start] 0)
(assoc-in [:form-params :per-page] 250))))
(assoc-in [:form-params :per-page] 250))))
:else
selected)
all-ids (all-ids-not-locked ids)
@@ -102,17 +102,18 @@
(def key->handler
(merge edit/key->handler
bulk-code/key->handler
t-import/key->handler
{::route/bulk-code-trial bulk-code-trial/open-trial
::route/bulk-code-trial-submit bulk-code-trial/submit-trial}
(apply-middleware-to-all-handlers
{::route/page page
{::route/page page
::route/approved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/approved))
::route/unapproved-page (-> page (wrap-implied-route-param :status :transaction-approval-status/unapproved))
::route/requires-feedback-page (-> page (wrap-implied-route-param :status :transaction-approval-status/requires-feedback))
::route/table table
::route/csv csv
::route/table table
::route/csv csv
::route/bank-account-filter bank-account-filter
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
::route/bulk-delete (-> bulk-delete
(wrap-schema-enforce :form-schema query-schema))}
(fn [h]
(-> h
(wrap-copy-qp-pqp)

View File

@@ -0,0 +1,172 @@
(ns auto-ap.ssr.transaction.bulk-code-trial
(:require
[auto-ap.datomic :refer [conn pull-attr pull-many]]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.components.wizard-trial.core :as wt]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.edit :as edit :refer [account-typeahead* location-select*]]
[auto-ap.ssr.utils :refer [html-response modal-response percentage]]
[auto-ap.ssr.svg :as svg]
[bidi.bidi :as bidi]
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]))
(def bulk-code-schema
(mc/schema
[:map
[:vendor {:optional true} [:maybe int?]]
[:approval-status {:optional true} [:maybe keyword?]]
[:accounts {:optional true}
[:vector {:coerce? true}
[:map
[:account int?]
[:location :string]
[:percentage percentage]]]]]))
(defn- account-row
"Renders a single account row with typeahead, location select, percentage, and delete button."
[index {:keys [account location percentage]} errors request]
(let [account-name (str "accounts[" index "][account]")
location-name (str "accounts[" index "][location]")
percentage-name (str "accounts[" index "][percentage]")
row-errors (get errors index)
client-id (-> request :clients first :db/id)
account-location (try
(when (nat-int? account)
(:account/location (dc/pull (dc/db conn) '[:account/location] account)))
(catch Exception e nil))
client-locations (try
(pull-attr (dc/db conn) :client/locations client-id)
(catch Exception e nil))]
[:tr
[:td (com/validated-field
{:errors (get row-errors :account)}
(account-typeahead* {:value account
:client-id client-id
:name account-name}))]
[:td (com/validated-field
{:errors (get row-errors :location)}
(location-select* {:name location-name
:account-location account-location
:client-locations client-locations
:value location}))]
[:td (com/validated-field
{:errors (get row-errors :percentage)}
(com/money-input {:name percentage-name
:value (some-> percentage (* 100) long)
:class "w-16"}))]
[:td (com/a-icon-button {"@click.prevent.stop" "this.closest('tr').remove()"}
svg/x)]]))
(defn render-bulk-code-form
"Renders the bulk code form inside a modal card structure.
Takes {:keys [step-data errors request]}"
[{:keys [step-data errors request]}]
(let [vendor (get step-data :vendor)
approval-status (get step-data :approval-status)
accounts (get step-data :accounts
[{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "Shared" :percentage 0.5}
{:account nil :location "" :percentage nil}])
selected-ids [] ; Would come from request in real implementation
all-ids []]
(com/modal-card-advanced
{:class "md:w-[750px] md:h-[600px] w-full h-full"}
(com/modal-header {}
[:div.p-2 "Bulk editing " (count all-ids) " transactions"])
(com/modal-body {}
[:div.space-y-4.p-4
[:div.grid.grid-cols-2.gap-4
;; Vendor field
[:div
(com/validated-field
{:label "Vendor"
:errors (get errors :vendor)}
(com/typeahead {:name "vendor"
:placeholder "Search for vendor..."
:url (bidi/path-for auto-ap.ssr-routes/only-routes :vendor-search)
:value vendor
:content-fn (fn [c]
(try
(pull-attr (dc/db conn) :vendor/name c)
(catch Exception e
"Vendor")))}))]
;; Approval status field
[:div
(com/validated-field
{:label "Status"
:errors (get errors :approval-status)}
(com/select {:name "approval-status"
:value (some-> approval-status name)
:allow-blank? true
:options [["" "No Change"]
["approved" "Approved"]
["unapproved" "Unapproved"]
["suppressed" "Suppressed"]
["requires_feedback" "Requires Feedback"]]}))]]
;; Accounts section
[:div.col-span-2.pt-4
[:h3.text-lg.font-medium.mb-3 "Expense Accounts"]
(com/validated-field
{:errors (get errors :accounts)}
[:table.w-full.text-sm.text-left
[:thead
[:tr
[:th "Account"]
[:th {:class "w-32"} "Location"]
[:th {:class "w-16"} "%"]
[:th {:class "w-16"}]]]
[:tbody
(map-indexed
(fn [idx account]
(account-row idx account (get errors :accounts) request))
accounts)]])]
;; Add new account button
[:div
(com/button {:color :secondary
:type "button"
:class "mt-2"
"@click" (str "
const tbody = this.closest('form').querySelector('tbody');
const newRow = document.createElement('tr');
newRow.innerHTML = `
<td><input type='text' name='accounts[" (count accounts) "][account]' placeholder='Account ID' class='w-full'></td>
<td><input type='text' name='accounts[" (count accounts) "][location]' value='Shared' class='w-full'></td>
<td><input type='number' name='accounts[" (count accounts) "][percentage]' class='w-16'></td>
<td><button type='button' onclick='this.closest(\"tr\").remove()'>×</button></td>
`;
tbody.appendChild(newRow);
")}
"New account")]])
(com/modal-footer {}
[:div.flex.justify-end
[:div.flex.items-baseline.gap-x-4
(com/form-errors {:errors (seq errors)})
(com/button {:color :primary :type "submit" :class "w-32"} "Save")]]))))
(def trial-step-config
{:key :bulk-code
:schema bulk-code-schema
:fields [:vendor :approval-status :accounts]
:render render-bulk-code-form
:submit-route "/transaction/bulk-code-trial"
:done-fn (fn [data request]
(modal-response
(com/success-modal {:title "Transactions Coded (Trial)"}
[:p "This was a trial run. No transactions were actually modified."])
:headers {"hx-trigger" "refreshTable"}))})
(defn open-trial [request]
(let [wizard-id (ws/create! {})]
(modal-response
(wt/render-step {:wizard-id wizard-id
:step-config trial-step-config
:request request}))))
(defn submit-trial [request]
(wt/handle-submit trial-step-config request))

View File

@@ -316,7 +316,7 @@
:content (:bank-account/name ba)}))}))))])
(defn filters [request]
[:form#transaction-filters {"hx-trigger" "datesApplied, change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#transaction-filters {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
"hx-get" (bidi/path-for ssr-routes/only-routes
::route/table)
"hx-target" "#entity-table"
@@ -440,6 +440,12 @@
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code")
(com/button {:color :secondary
:hx-get (bidi/path-for ssr-routes/only-routes ::route/bulk-code-trial)
:hx-target "#modal-holder"
"x-bind:hx-vals" "JSON.stringify({selected: $data.selected, 'all-selected': $data.all_selected})"
"hx-include" "#transaction-filters"}
"Code (Trial)")
(com/button {:color :primary
:hx-post (bidi/path-for ssr-routes/only-routes ::route/bulk-delete)
:hx-target "#modal-holder"

View File

@@ -514,7 +514,6 @@
:hx-post (bidi/path-for ssr-routes/only-routes ::route/edit-vendor-changed)
:hx-target "#manual-coding-section"
:hx-swap "outerHTML"
:hx-sync "this:replace"
:hx-include "closest form"}
(fc/with-field :transaction/vendor
(com/validated-field
@@ -883,13 +882,9 @@
#_(require-approval (mut/select-keys (mm/form-schema linear-wizard) #{:transaction/client :transaction/vendor :transaction/memo :transaction/approval-status :db/id}))
(mm/form-schema linear-wizard))
(render-step [this {{:keys [snapshot step-params] :as multi-form-state} :multi-form-state :as request}]
(render-step [this {{:keys [snapshot] :as multi-form-state} :multi-form-state :as request}]
(let [tx-id (mm/get-mfs-field multi-form-state :db/id)
tx (d-transactions/get-by-id tx-id)
;; Preserve explicit mode choice from step-params; only fall back to
;; row-count heuristic on initial load when no mode has been chosen.
mode (keyword (or (:mode step-params)
(name (manual-mode-initial snapshot))))]
tx (d-transactions/get-by-id tx-id)]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit Transaction"]
@@ -955,7 +950,7 @@
(transaction-rules-view request)]
[:div {:x-show "activeForm === 'manual'", :x-transition:enter "transition ease-out duration-500", :x-transition:enter-start "opacity-0 transform scale-95", :x-transition:enter-end "opacity-100 transform scale-100"}
[:div {}
(manual-coding-section* mode request)
(manual-coding-section* (manual-mode-initial snapshot) request)
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
@@ -1434,13 +1429,10 @@
(let [multi-form-state (:multi-form-state request)
snapshot (:snapshot multi-form-state)
step-params (:step-params multi-form-state)
mode (keyword (or (:mode step-params)
(get (:form-params request) "mode")
"simple"))
mode (keyword (or (:mode step-params) "simple"))
client-id (or (:transaction/client snapshot)
(-> request :entity :transaction/client :db/id))
vendor-id (or (:transaction/vendor step-params)
(->db-id (get step-params "transaction/vendor"))
(:transaction/vendor snapshot))
total (Math/abs (or (-> request :entity :transaction/amount)
(:transaction/amount snapshot)
@@ -1448,30 +1440,18 @@
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
;; The form always submits an account row (even when empty with account=nil),
;; so we check if any row has a meaningful account ID.
has-meaningful-accounts? (some #(some? (:transaction-account/account %))
existing-accounts)
;; Simple mode: always populate vendor default (overwrite existing).
;; Advanced mode: populate only when 0 rows OR 1 empty row.
should-populate? (case mode
:simple true
:advanced (or (empty? existing-accounts)
(and (= 1 (count existing-accounts))
(not has-meaningful-accounts?))))
default-account (when (and should-populate? vendor-id client-id)
default-account (when (and (empty? existing-accounts) vendor-id client-id)
(vendor-default-account vendor-id client-id))
render-request
(-> (if (and should-populate? vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)
(assoc-in [:multi-form-state :step-params :transaction/vendor] vendor-id))]
(if (and (empty? existing-accounts) vendor-id client-id)
(let [new-account (cond-> {:db/id (str (java.util.UUID/randomUUID))
:transaction-account/location (or (:account/location default-account) "Shared")
:transaction-account/amount (if (= amount-mode "%") 100.0 total)}
default-account (assoc :transaction-account/account (:db/id default-account)))]
(-> request
(assoc-in [:multi-form-state :snapshot :transaction/accounts] [new-account])
(assoc-in [:multi-form-state :step-params :transaction/accounts] [new-account])))
request)]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params

View File

@@ -1,300 +0,0 @@
(ns auto-ap.ssr.transaction.import
"SSR manual bank-transaction import. Mirrors the SSR ledger import
(auto-ap.ssr.ledger) but accepts the exact master-branch Yodlee
positional-column TSV and drives the existing
auto-ap.import.transactions engine (via auto-ap.import.manual/import-batch)
unchanged. Two-stage flow: paste -> editable review grid -> import."
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.graphql.utils :refer [assert-admin]]
[auto-ap.import.manual :as manual]
[auto-ap.import.transactions :as t]
[auto-ap.permissions :refer [wrap-must]]
[auto-ap.routes.transactions :as route]
[auto-ap.routes.utils :refer [wrap-client-redirect-unauthenticated]]
[auto-ap.ssr-routes :as ssr-routes]
[auto-ap.ssr.components :as com]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.hx :as hx]
[auto-ap.ssr.nested-form-params :refer [wrap-nested-form-params]]
[auto-ap.ssr.ui :refer [base-page]]
[auto-ap.ssr.utils
:refer [apply-middleware-to-all-handlers html-response
wrap-form-4xx-2 wrap-schema-decode wrap-schema-enforce]]
[bidi.bidi :as bidi]
[clojure.data.csv :as csv]
[clojure.java.io :as io]
[clojure.string :as str]
[datomic.api :as dc]
[malli.core :as mc]
[slingshot.slingshot :refer [throw+]]))
;; ---------------------------------------------------------------------------
;; Parsing (positional Yodlee columns, identical to master)
;; ---------------------------------------------------------------------------
(defn tsv->rows
"Decode a pasted tab-separated Yodlee export into a vector of raw column
vectors. Drops the header row (like auto-ap.import.manual/tabulate-data) and
skips blank lines. No-op when already decoded."
[data]
(if (string? data)
(with-open [r (io/reader (char-array data))]
(into []
(comp (drop 1)
(filter (fn [row] (some (fn [c] (seq (str/trim (or c "")))) row))))
(csv/read-csv r :separator \tab)))
data))
(defn vector->row
"Map a raw column vector onto the master positional column keys."
[t]
(if (vector? t)
(into {} (filter first (map vector manual/columns t)))
t))
(def parse-form-schema
(mc/schema
[:map
[:table {:min 1
:error/message "Paste should contain at least one row to import"
:decode/string tsv->rows}
[:vector {:coerce? true}
[:map {:decode/arbitrary vector->row}
[:status {:optional true} [:maybe :string]]
[:raw-date {:optional true} [:maybe :string]]
[:description-original {:optional true} [:maybe :string]]
[:high-level-category {:optional true} [:maybe :string]]
[:amount {:optional true} [:maybe :string]]
[:bank-account-code {:optional true} [:maybe :string]]
[:client-code {:optional true} [:maybe :string]]]]]]))
;; ---------------------------------------------------------------------------
;; Validation (two-tier, preserving every master validation)
;; ---------------------------------------------------------------------------
(defn- bank-account-code->client [db]
(into {} (dc/q '[:find ?bac ?c
:where
[?c :client/bank-accounts ?ba]
[?ba :bank-account/code ?bac]]
db)))
(defn- bank-account-code->bank-account [db]
(into {} (dc/q '[:find ?bac ?ba
:where [?ba :bank-account/code ?bac]]
db)))
(defn warn-message
"Map a non-:import engine categorization to a [message :warn] pair, or nil
when the row will import cleanly."
[action]
(case action
:extant ["Already imported — skipped" :warn]
:not-ready ["Not ready (before account start date, client locked, or not posted) — skipped" :warn]
:suppressed ["Suppressed — skipped" :warn]
nil))
(defn classify-table
"Given parsed row maps, return {:form-errors {:table {idx [[msg status]...]}}
:has-errors? bool}. Hard (fixable) errors come from
manual/manual->transaction; warnings come from the engine's own
categorize-transaction so the grid preview matches what the import will do."
[rows]
(let [db (dc/db conn)
client-lookup (bank-account-code->client db)
ba-lookup (bank-account-code->bank-account db)
indexed (map-indexed
(fn [i row]
(assoc (manual/manual->transaction row ba-lookup client-lookup)
::idx i))
rows)
with-ids (t/apply-synthetic-ids indexed)
ba-cache (atom {})
existing-cache (atom {})
entries (->> with-ids
(map (fn [txn]
(let [idx (::idx txn)
hard (mapv (fn [e] [(:info e) :error]) (:errors txn))
warn (when (and (empty? hard)
(:transaction/bank-account txn))
(let [ba-id (:transaction/bank-account txn)
ba (or (get @ba-cache ba-id)
(get (swap! ba-cache assoc ba-id
(dc/pull db t/bank-account-pull ba-id))
ba-id))
existing (or (get @existing-cache ba-id)
(get (swap! existing-cache assoc ba-id
(t/get-existing ba-id))
ba-id))]
(warn-message (t/categorize-transaction txn ba existing))))]
[idx (cond-> hard warn (conj warn))])))
(sort-by first))
form-errors {:table (into {} (filter (fn [[_ errs]] (seq errs)) entries))}
has-errors? (boolean (some (fn [[_ errs]] (some (fn [[_ s]] (= :error s)) errs)) entries))]
{:form-errors form-errors
:has-errors? has-errors?}))
;; ---------------------------------------------------------------------------
;; Views
;; ---------------------------------------------------------------------------
(defn- row-badge [errors]
(when (seq errors)
[:div.p-1.flex.flex-col.gap-1
(for [[m s] errors]
[:div.text-xs {:class (if (= :error s) "text-red-600" "text-yellow-600")} m])]))
(defn- parsed-banner [request]
(let [errs (->> (:form-errors request) :table vals (mapcat identity))
n-err (count (filter (fn [[_ s]] (= :error s)) errs))
n-warn (count (filter (fn [[_ s]] (= :warn s)) errs))
n-rows (count (:table (:form-params request)))]
[:div.bg-green-50.text-green-700.rounded.p-3.my-2
(format "%,d rows parsed. " n-rows)
(when (pos? n-err)
[:span.text-red-700.font-semibold (format "%d error(s) must be fixed. " n-err)])
(when (pos? n-warn)
[:span.text-yellow-700.font-semibold (format "%d warning row(s) will be skipped. " n-warn)])]))
(defn external-import-text-form* [request]
(fc/start-form
(or (:form-params request) {}) (:form-errors request)
[:form#parse-form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-parse)
:hx-target "#forms"
:hx-swap "outerHTML"}
(fc/with-field :table
[:div.flex.flex-col.gap-2
(com/errors {:errors (when (string? (fc/field-errors)) (fc/field-errors))})
(com/text-area {:name (fc/field-name)
:rows 6
:class "w-full font-mono text-xs"
:placeholder "Paste your Yodlee transaction export (tab-separated, including the header row) here"})])
(com/button {:color :primary :type "submit"} "Parse")]))
(defn external-import-table-form* [request]
(fc/start-form
(:form-params request) (:form-errors request)
(fc/with-field :table
(when (seq (fc/field-value))
[:div.mt-4 {:x-data (hx/json {"showTable" true})}
(when (:just-parsed? request)
(parsed-banner request))
[:form {:hx-post (bidi/path-for ssr-routes/only-routes ::route/external-import-import)
:hx-target "#forms"
:hx-swap "outerHTML"
:autocomplete "off"}
[:div.flex.gap-4.items-center.my-2
(com/checkbox {"@click" "showTable=!showTable"} "Show table")
(com/button {:color :primary :type "submit"} "Import")]
[:div {:x-show "showTable"}
(com/data-grid-card
{:id "transaction-import-data"
:route nil
:title "Transactions to import"
:paginate? false
:headers [(com/data-grid-header {} "Date")
(com/data-grid-header {} "Description")
(com/data-grid-header {} "Amount")
(com/data-grid-header {} "Bank Account")
(com/data-grid-header {} "Client")
(com/data-grid-header {} "Status")
(com/data-grid-header {} "")]
:rows
(fc/cursor-map
(fn [_]
(let [row-errors (fc/field-errors)]
(com/data-grid-row
{}
(com/data-grid-cell {} (fc/with-field :raw-date
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :description-original
(com/text-input {:value (fc/field-value) :name (fc/field-name)})))
(com/data-grid-cell {} (fc/with-field :amount
(com/money-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :bank-account-code
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-28"})))
(com/data-grid-cell {} (fc/with-field :client-code
(com/text-input {:value (fc/field-value) :name (fc/field-name) :class "w-24"})))
(com/data-grid-cell {} [:span.text-xs.text-gray-500 (fc/with-field :status (fc/field-value))])
(com/data-grid-cell {:class "align-top"} (row-badge row-errors))))))}
nil)]]]))))
(defn external-import-form* [request]
[:div#forms
(external-import-text-form* request)
(external-import-table-form* request)])
(defn external-import-page [request]
(base-page
request
(com/page {:nav com/main-aside-nav
:client-selection (:client-selection request)
:clients (:clients request)
:client (:client request)
:identity (:identity request)
:request request}
(com/breadcrumbs {}
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/page)} "Transactions"]
[:a {:href (bidi/path-for ssr-routes/only-routes ::route/external-import-page)} "Import"])
(external-import-form* request))
"Import Transactions"))
;; ---------------------------------------------------------------------------
;; Handlers
;; ---------------------------------------------------------------------------
(defn external-import-parse [request]
(let [{:keys [form-errors]} (classify-table (:table (:form-params request)))]
(html-response
(external-import-form* (assoc request :form-errors form-errors :just-parsed? true)))))
(defn import-transactions
"Validate the (possibly edited) rows. Block the whole batch when any hard
error remains; otherwise run the existing import engine on the rows. Returns
the engine stats."
[request]
(assert-admin (:identity request))
(let [rows (:table (:form-params request))
{:keys [form-errors has-errors?]} (classify-table rows)]
(when has-errors?
(throw+ {:type :field-validation
:form-errors form-errors
:form-params (:form-params request)}))
(let [user (or (:user/name (:identity request))
(:user (:identity request))
"SSR import")]
(manual/import-batch rows user))))
(defn external-import-import [request]
(let [stats (import-transactions request)
imported (:import-batch/imported stats 0)
extant (:import-batch/extant stats 0)
not-ready (:import-batch/not-ready stats 0)
errored (+ (:import-batch/error stats 0) (:failed-validation stats 0))]
(html-response
(external-import-form* (assoc request :form-params {} :form-errors {}))
:headers {"hx-trigger"
(hx/json {"notification"
(format "%d imported, %d already imported, %d not ready, %d errored."
imported extant not-ready errored)})})))
;; ---------------------------------------------------------------------------
;; Routing
;; ---------------------------------------------------------------------------
(def key->handler
(apply-middleware-to-all-handlers
{::route/external-import-page external-import-page
::route/external-import-parse (-> external-import-parse
(wrap-schema-enforce :form-schema parse-form-schema)
(wrap-form-4xx-2 external-import-parse)
(wrap-schema-decode :form-schema parse-form-schema))
::route/external-import-import (-> external-import-import
(wrap-schema-enforce :form-schema parse-form-schema)
(wrap-form-4xx-2 external-import-parse)
(wrap-nested-form-params))}
(fn [h]
(-> h
(wrap-must {:activity :import :subject :transaction})
(wrap-client-redirect-unauthenticated)))))

View File

@@ -23,6 +23,8 @@
[:title (str "Integreat | " page-name)]
[:link {:href "/css/font.min.css", :rel "stylesheet"}]
[:link {:rel "icon" :type "image/png" :href "/favicon.png"}]
[:link {:rel "stylesheet" :href "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.css"}]
[:script {:src "//cdn.jsdelivr.net/chartist.js/latest/chartist.min.js"}]
[:link {:rel "stylesheet", :href "/output.css"}]
[:script {:src "https://cdn.plaid.com/link/v2/stable/link-initialize.js"}]
[:script {:src "https://cdn.jsdelivr.net/npm/@ryangjchandler/alpine-tooltip@1.x.x/dist/cdn.min.js" :defer true}]

View File

@@ -10,7 +10,9 @@
"/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit
"/new-account" ::bulk-code-new-account
"/vendor-changed" ::bulk-code-vendor-changed}}
"/vendor-changed" ::bulk-code-vendor-changed}
"/bulk-code-trial" {:get ::bulk-code-trial
:post ::bulk-code-trial-submit}}
"/new" {:get ::new
:post ::new-submit
"/location-select" ::location-select

View File

@@ -0,0 +1,114 @@
(ns auto-ap.ssr.components.wizard-trial.core-test
(:require
[auto-ap.ssr.components.wizard-trial.core :as sut]
[auto-ap.ssr.components.wizard-trial.state :as ws]
[clojure.test :refer [deftest is testing]]
[hiccup.core :as hiccup]))
(deftest render-step-test
(testing "render-step produces a form with hidden wizard-id and step-key inputs"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:render (fn [{:keys [step-data errors request]}]
[:div (str "data: " step-data) (when errors [:span.error (str errors)])])
:submit-route "/test-submit"}
result (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {}})]
(is (= :form (first result)))
(let [attrs (second result)
children (drop 2 result)]
(is (= "/test-submit" (:hx-post attrs)))
(is (= "this" (:hx-target attrs)))
(let [html (hiccup/html result)]
(is (re-find #"name=\"wizard-id\"" html))
(is (re-find #"value=\"" html))
(is (re-find #"name=\"step-key\"" html))
(is (re-find #"value=\"test-step\"" html))))))
(testing "render-step passes step-data, errors, and request to the render function"
(let [wizard-id (ws/create! {:test-step {:field "value"}})
captured (atom nil)
step-config {:key :test-step
:render (fn [args] (reset! captured args) [:div "rendered"])
:submit-route "/test-submit"}
_ (sut/render-step {:wizard-id wizard-id
:step-config step-config
:request {:client-id 123}
:errors {:field ["is invalid"]}})
{:keys [step-data errors request]} @captured]
(is (= {:field "value"} step-data))
(is (= {:field ["is invalid"]} errors))
(is (= {:client-id 123} request)))))
(deftest handle-submit-test
(testing "handle-submit with valid data saves step and calls done-fn"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data request]
(reset! done-result {:data data :request request})
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" "Alice"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= "done" (:body response)))
(is (= "Alice" (get-in @done-result [:data :name])))
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit")))
(testing "handle-submit with invalid data re-renders step with errors"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"name" ""}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (string? (:body response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should still exist after failed validation")))
(testing "handle-submit with missing required field shows validation error"
(let [wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:name :string]]
:fields [:name]
:render (fn [{:keys [errors]}]
[:div (when errors [:span.error (str errors)])])
:submit-route "/test-submit"
:done-fn (fn [_ _] {:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response body should contain error markup for missing field")))
(testing "handle-submit decodes step data using main-transformer"
(let [done-result (atom nil)
wizard-id (ws/create! {})
step-config {:key :test-step
:schema [:map [:count int?]]
:fields [:count]
:render (fn [_] [:div "rendered"])
:submit-route "/test-submit"
:done-fn (fn [data _]
(reset! done-result data)
{:status 200 :body "done"})}
request {:form-params {"wizard-id" wizard-id
"step-key" "test-step"
"count" "42"}}
response (sut/handle-submit step-config request)]
(is (= 200 (:status response)))
(is (= 42 (:count @done-result)) "String count should be decoded to integer"))))

View File

@@ -0,0 +1,76 @@
(ns auto-ap.ssr.components.wizard-trial.state-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as sut]
[clojure.test :refer [deftest is testing]]))
(deftest create-and-get-wizard-test
(testing "Session creation returns a non-nil wizard-id"
(let [wizard-id (sut/create! {:foo "bar"})]
(is (string? wizard-id))
(is (seq wizard-id))))
(testing "Session retrieval returns the stored data"
(let [wizard-id (sut/create! {:foo "bar"})
wizard (sut/get-wizard wizard-id)]
(is (map? wizard))
(is (= {:foo "bar"} (:data wizard)))
(is (inst? (:created-at wizard)))))
(testing "Session retrieval returns nil for unknown id"
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest update-step-test
(testing "update-step! merges data into the specified step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))))
(testing "update-step! merges without overwriting other step keys"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step2 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a"} (get-in wizard [:data :step1])))
(is (= {:field-b "b"} (get-in wizard [:data :step2])))))
(testing "update-step! merges within the same step key"
(let [wizard-id (sut/create! {})
_ (sut/update-step! wizard-id :step1 {:field-a "a"})
_ (sut/update-step! wizard-id :step1 {:field-b "b"})
wizard (sut/get-wizard wizard-id)]
(is (= {:field-a "a" :field-b "b"} (get-in wizard [:data :step1]))))))
(deftest destroy-test
(testing "destroy! removes the wizard session"
(let [wizard-id (sut/create! {:foo "bar"})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "destroy! is a no-op for unknown id"
(sut/destroy! "non-existent-id")
(is (nil? (sut/get-wizard "non-existent-id")))))
(deftest get-all-data-test
(testing "get-all-data merges non-map values and map values from all steps"
(let [wizard-id (sut/create! {:client-id 123})
_ (sut/update-step! wizard-id :step1 {:vendor 456})
_ (sut/update-step! wizard-id :step2 {:accounts [{:account 1}]})
all-data (sut/get-all-data wizard-id)]
(is (= {:client-id 123 :vendor 456 :accounts [{:account 1}]} all-data))))
(testing "get-all-data returns nil for unknown id"
(is (nil? (sut/get-all-data "non-existent-id")))))
(deftest session-exists-test
(testing "Session exists after creation"
(let [wizard-id (sut/create! {})]
(is (some? (sut/get-wizard wizard-id)))))
(testing "Session does not exist after destruction"
(let [wizard-id (sut/create! {})
_ (sut/destroy! wizard-id)]
(is (nil? (sut/get-wizard wizard-id)))))
(testing "Session does not exist for random id"
(is (nil? (sut/get-wizard (str (java.util.UUID/randomUUID)))))))

View File

@@ -0,0 +1,104 @@
(ns auto-ap.ssr.transaction.bulk-code-trial-test
(:require
[auto-ap.ssr.components.wizard-trial.state :as ws]
[auto-ap.ssr.transaction.bulk-code-trial :as sut]
[clojure.test :refer [deftest is testing use-fixtures]]
[mount.core :as mount]))
(use-fixtures :each
(fn [test-fn]
(mount/start #'auto-ap.datomic/conn)
(test-fn)
(mount/stop #'auto-ap.datomic/conn)))
(deftest open-trial-test
(testing "open-trial returns modal-response with a form containing expected fields"
(let [response (sut/open-trial {})]
(is (= 200 (:status response)))
(is (= "text/html" (get-in response [:headers "Content-Type"])))
(let [body (:body response)]
(is (string? body))
(is (re-find #"modal-card" body) "Should contain modal card structure")
(is (re-find #"Bulk editing" body) "Should show header with transaction count")
(is (re-find #"Vendor" body) "Form should contain Vendor label")
(is (re-find #"Status" body) "Form should contain Status label")
(is (re-find #"Expense Accounts" body) "Form should contain Expense Accounts heading")
(is (re-find #"Account" body) "Form should contain Account column header")
(is (re-find #"Location" body) "Form should contain Location column header")
(is (re-find #"%" body) "Form should contain percentage column header")
(is (re-find #"Save" body) "Form should contain Save button")
(is (re-find #"New account" body) "Form should contain New account button")
(is (re-find #"name=\"wizard-id\"" body) "Form should contain hidden wizard-id input")
(is (re-find #"name=\"step-key\"" body) "Form should contain hidden step-key input")))))
(deftest submit-trial-valid-test
(testing "submit-trial with valid data returns success response and destroys session"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "123"
"approval-status" "approved"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"Transactions Coded" (:body response)) "Response should indicate success")
(is (nil? (ws/get-wizard wizard-id)) "Wizard session should be destroyed after successful submit"))))
(deftest submit-trial-invalid-test
(testing "submit-trial with invalid vendor id shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" "not-a-number"
"approval-status" "approved"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid vendor")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with invalid account data shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "not-a-number"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "50"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for invalid account")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with percentage over 100% shows validation error"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"accounts[0][account]" "1"
"accounts[0][location]" "DT"
"accounts[0][percentage]" "150"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (re-find #"error" (:body response)) "Response should contain error markup for percentage > 100%")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))
(deftest submit-trial-empty-test
(testing "submit-trial with empty form data shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Empty form should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation")))
(testing "submit-trial with no account rows selected shows validation errors"
(let [wizard-id (ws/create! {})
request {:form-params {"wizard-id" wizard-id
"step-key" "bulk-code"
"vendor" ""
"approval-status" ""}}
response (sut/submit-trial request)]
(is (= 200 (:status response)))
(is (not (re-find #"Bulk code applied" (:body response))) "Form with empty values should not succeed")
(is (some? (ws/get-wizard wizard-id)) "Wizard session should persist after failed validation"))))

View File

@@ -5,13 +5,12 @@
[auto-ap.solr]
[auto-ap.ssr.components.multi-modal :as mm]
[auto-ap.ssr.form-cursor :as fc]
[auto-ap.ssr.transaction.edit
:refer [clientize-vendor
edit-vendor-changed-handler
edit-wizard-toggle-mode-handler
location-select*
manual-coding-section*
vendor-default-account]]
[auto-ap.ssr.transaction.edit :refer [clientize-vendor
edit-vendor-changed-handler
edit-wizard-toggle-mode-handler
location-select*
manual-coding-section*
vendor-default-account]]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]
[hiccup.core :as hiccup]))
@@ -53,7 +52,7 @@
(testing "AC3: multi-account (2+) transaction opens in advanced mode"
(is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{:transaction-account/account 1}
{:transaction-account/account 2}]})))
{:transaction-account/account 2}]})))
(is (= :advanced (manual-mode-initial {:db/id 123
:transaction/accounts [{} {} {}]})))))
@@ -106,7 +105,7 @@
(is (re-find #"Test Account" body)
"Response should contain the vendor's default account name")))
(testing "AC5: vendor selection in simple mode DOES overwrite already-set account"
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
@@ -127,10 +126,9 @@
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id")
;; existing-accounts already set — but simple mode should still overwrite
;; existing-accounts already set means vendor should NOT overwrite
existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id
:transaction-account/location "DT"
@@ -151,12 +149,12 @@
;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element")
;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str account-id)) body)
"Vendor change in simple mode should overwrite with vendor's default account")
;; The previous account should NOT appear
(is (not (re-find (re-pattern (str other-account-id)) body))
"Previous account should be replaced by vendor default"))))
;; The original account ID must still appear in the rendered HTML
(is (re-find (re-pattern (str other-account-id)) body)
"Response should contain the original (pre-existing) account ID")
;; The vendor's default account ID must NOT appear — it was not used
(is (not (re-find (re-pattern (str (tempid->id result "account-id"))) body))
"Response should NOT contain the vendor's default account ID when existing account is set"))))
;;; ---------------------------------------------------------------------------
;;; AC6: save round-trip — manual mode saves vendor + account to DB
@@ -165,18 +163,18 @@
(deftest save-manual-round-trip-test
(testing "AC6: save in simple mode persists vendor/account/location — re-opening shows same values"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Save Vendor"}
{:db/id "account-id"
:account/name "Save Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "SAVECL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
:vendor/name "Save Vendor"}
{:db/id "account-id"
:account/name "Save Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "SAVECL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
@@ -936,384 +934,3 @@
;; Should NOT show 'Switch to simple mode'
(is (not (re-find #"Switch to simple mode" html))
"AC20: Simple mode should NOT show 'Switch to simple mode' link"))))
;;; ---------------------------------------------------------------------------
;;; Bug: vendor selection gets erased on vendor-changed HTMX response
;;; ---------------------------------------------------------------------------
(deftest vendor-selection-preserved-in-htmx-response-test
(testing "BUG: vendor selection should be preserved when HTMX re-renders the edit form"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Existing Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "VENDORCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
client-id (tempid->id result "client-id")
;; Simulate the request after middleware decoding.
;; In production, form values arrive as strings. The middleware decodes
;; step-params with keyword keys but leaves values as strings.
existing-accounts [{:db/id "row-1"
:transaction-account/account account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
;; This is how the vendor ID arrives from the form:
;; as a string, not a long.
:transaction/vendor (str vendor-id)
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
;; The handler should return a successful response with the vendor
;; preserved. Currently it crashes because the string vendor-id is
;; not converted to a long before being passed to Datomic.
response (try
(edit-vendor-changed-handler request)
(catch Exception e
{:error e}))]
(is (not (:error response))
(str "BUG: String vendor-id from form submission should be converted to long. "
"Server crashes with: " (some-> response :error ex-message)))
(when-not (:error response)
(is (= 200 (:status response))
"Response should be successful")
(is (re-find #"Test Vendor" (:body response))
"Vendor name should appear in the HTMX response")
(is (re-find (re-pattern (str vendor-id)) (:body response))
"Vendor ID should be preserved in the response HTML")))))
;;; ---------------------------------------------------------------------------
;;; Bug: vendor change does not populate account
;;; ---------------------------------------------------------------------------
(deftest vendor-change-simple-mode-overwrites-test
(testing "BUG: vendor change in simple mode should overwrite existing account"
;; When a vendor is changed in simple mode, it should always populate
;; the vendor's default account, even if an account was already set.
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Previously Selected Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "VENDORCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Simulate form state with an already-selected account (as the form submits)
existing-accounts [{:db/id "row-1"
:transaction-account/account existing-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear (overwriting the previous)
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previously selected account should NOT appear
(is (not (re-find (re-pattern (str existing-account-id)) body))
"Previously selected account should be replaced by vendor default")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear"))))
(deftest vendor-change-advanced-mode-empty-row-test
(testing "BUG: vendor change in advanced mode should populate empty row"
;; In advanced mode with 1 empty row, changing vendor should populate it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "client-id"
:client/code "ADVEMPTYCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
client-id (tempid->id result "client-id")
;; Simulate advanced mode with 1 empty row (account=nil, as form submits)
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The vendor's default account SHOULD appear in the row
(is (re-find (re-pattern (str vendor-account-id)) body)
"BUG: Vendor change in advanced mode with empty row should populate it")
(is (re-find #"Vendor Default Account" body)
"Vendor default account name should appear in the row"))))
(deftest vendor-change-advanced-mode-filled-row-test
(testing "AC15b: vendor change in advanced mode with filled row should NOT overwrite"
;; In advanced mode with 1 row that already has an account selected,
;; changing vendor should NOT overwrite it
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "existing-account-id"
:account/name "Manually Selected Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVFILLEDCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
existing-account-id (tempid->id result "existing-account-id")
client-id (tempid->id result "client-id")
;; Advanced mode with 1 row that already has an account
filled-row [{:db/id "row-1"
:transaction-account/account existing-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts filled-row}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts filled-row})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The existing account should still be there
(is (re-find (re-pattern (str existing-account-id)) body)
"Existing account should remain when vendor changes in advanced mode with filled row")
;; The vendor's default account should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT overwrite filled row in advanced mode"))))
(deftest vendor-change-advanced-mode-two-rows-test
(testing "AC15c: vendor change in advanced mode with 2+ rows should NOT modify any"
;; In advanced mode with 2 or more rows, vendor change should not touch any row
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Vendor With Default"}
{:db/id "vendor-account-id"
:account/name "Vendor Default Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "vendor-account-id"}
{:db/id "account-1"
:account/name "Account One"
:account/type :account-type/expense}
{:db/id "account-2"
:account/name "Account Two"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "ADVTWOROWCL"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
vendor-account-id (tempid->id result "vendor-account-id")
account-1 (tempid->id result "account-1")
account-2 (tempid->id result "account-2")
client-id (tempid->id result "client-id")
;; Advanced mode with 2 rows
two-rows [{:db/id "row-1"
:transaction-account/account account-1
:transaction-account/location "DT"
:transaction-account/amount 50.0}
{:db/id "row-2"
:transaction-account/account account-2
:transaction-account/location "DT"
:transaction-account/amount 50.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts two-rows}
[]
{:mode "advanced"
:transaction/vendor vendor-id
:transaction/accounts two-rows})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; Both existing accounts should remain
(is (re-find (re-pattern (str account-1)) body)
"First row account should remain")
(is (re-find (re-pattern (str account-2)) body)
"Second row account should remain")
;; Vendor default should NOT appear
(is (not (re-find (re-pattern (str vendor-account-id)) body))
"Vendor default should NOT modify rows when 2+ exist"))))
(deftest vendor-change-client-specific-override-test
(testing "BUG: vendor change should use client-specific account override if present"
;; When a vendor has a client-specific account override, changing vendor
;; should populate the client-specific account, not the global default.
(let [result @(dc/transact conn [{:db/id "global-account-id"
:account/name "Global Default"
:account/type :account-type/expense}
{:db/id "client-specific-account-id"
:account/name "Client Specific Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "CLIOVERRIDE"
:client/locations ["DT"]}
{:db/id "vendor-id"
:vendor/name "Clientized Vendor"
:vendor/default-account "global-account-id"
:vendor/account-overrides [{:vendor-account-override/client "client-id"
:vendor-account-override/account "client-specific-account-id"}]}])
vendor-id (tempid->id result "vendor-id")
client-id (tempid->id result "client-id")
global-account-id (tempid->id result "global-account-id")
client-specific-account-id (tempid->id result "client-specific-account-id")
;; Simple mode with empty account row
empty-row [{:db/id "row-1"
:transaction-account/account nil
:transaction-account/location "Shared"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id 999999
:transaction/client client-id
:transaction/accounts empty-row}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts empty-row})
:entity {:db/id 999999
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The client-specific account should appear, not the global default
(is (re-find (re-pattern (str client-specific-account-id)) body)
"BUG: Vendor change should populate client-specific account override")
(is (re-find #"Client Specific Account" body)
"Client-specific account name should appear")
;; The global default should NOT appear
(is (not (re-find (re-pattern (str global-account-id)) body))
"Global vendor default should NOT appear when client override exists"))))
;;; Update AC5: simple mode SHOULD overwrite existing accounts
(deftest vendor-change-simple-mode-overwrites-ac5-test
(testing "AC5 UPDATED: vendor selection in simple mode DOES overwrite already-set account"
(let [result @(dc/transact conn [{:db/id "vendor-id"
:vendor/name "Test Vendor"}
{:db/id "account-id"
:account/name "Test Account"
:account/type :account-type/expense}
{:db/id "vendor-id"
:vendor/default-account "account-id"}
{:db/id "other-account-id"
:account/name "Other Account"
:account/type :account-type/expense}
{:db/id "client-id"
:client/code "TESTCL2"
:client/locations ["DT"]}
{:db/id "transaction-id"
:transaction/amount 100.0
:transaction/date #inst "2023-01-01"
:transaction/id (str (java.util.UUID/randomUUID))
:transaction/client "client-id"}])
tx-id (tempid->id result "transaction-id")
vendor-id (tempid->id result "vendor-id")
account-id (tempid->id result "account-id")
other-account-id (tempid->id result "other-account-id")
client-id (tempid->id result "client-id")
;; existing-accounts already set — but simple mode should still overwrite
existing-accounts [{:db/id "row-id"
:transaction-account/account other-account-id
:transaction-account/location "DT"
:transaction-account/amount 100.0}]
request {:multi-form-state (mm/->MultiStepFormState
{:db/id tx-id
:transaction/client client-id
:transaction/accounts existing-accounts}
[]
{:mode "simple"
:transaction/vendor vendor-id
:transaction/accounts existing-accounts})
:entity {:db/id tx-id
:transaction/client {:db/id client-id}
:transaction/amount 100.0}}
response (edit-vendor-changed-handler request)
body (:body response)]
;; The handler returns an html-response; verify the body is HTML
(is (re-find #"manual-coding-section" body)
"Response body should contain the manual-coding-section element")
;; The vendor's default account SHOULD appear (overwriting previous)
(is (re-find (re-pattern (str account-id)) body)
"BUG: Vendor change in simple mode should overwrite with vendor's default account")
;; The previous account should NOT appear
(is (not (re-find (re-pattern (str other-account-id)) body))
"Previous account should be replaced by vendor default"))))

View File

@@ -1,164 +0,0 @@
(ns auto-ap.ssr.transaction.import-test
(:require
[auto-ap.datomic :refer [conn]]
[auto-ap.integration.util :refer [admin-token setup-test-data test-bank-account
test-client wrap-setup]]
[auto-ap.ssr.transaction.import :as sut]
[auto-ap.ssr.utils :refer [main-transformer]]
[clojure.test :refer [deftest is testing use-fixtures]]
[datomic.api :as dc]
[malli.core :as mc]
[slingshot.slingshot :refer [try+]]))
(use-fixtures :each wrap-setup)
(defn- seed-client! []
(setup-test-data
[(test-client :db/id "import-client"
:client/code "TEST"
:client/locations ["DT"]
:client/bank-accounts [(test-bank-account :db/id "import-ba"
:bank-account/code "TEST-CHK")])]))
(defn- txn-count []
(or (dc/q '[:find (count ?e) . :where [?e :transaction/id]] (dc/db conn)) 0))
(defn- import! [rows]
(sut/import-transactions {:form-params {:table rows} :identity (admin-token)}))
;; =============================================================================
;; Pure parsing — tsv->rows, vector->row, parse-form-schema
;; =============================================================================
(deftest tsv->rows-test
(testing "Drops the header row and parses tab-separated columns"
(let [tsv "Status\tDate\tDescription\nPOSTED\t01/15/2024\tCoffee"
rows (sut/tsv->rows tsv)]
(is (= 1 (count rows)))
(is (= ["POSTED" "01/15/2024" "Coffee"] (first rows)))))
(testing "Skips blank lines"
(is (= 1 (count (sut/tsv->rows "h1\th2\nPOSTED\tx\n\t\n")))))
(testing "No-op on already-decoded data"
(is (= [{:raw-date "x"}] (sut/tsv->rows [{:raw-date "x"}])))))
(deftest vector->row-test
(testing "Maps the exact master positional columns"
(let [row (sut/vector->row
["POSTED" "01/15/2024" "Coffee" "Food" "" "" "12.50" "" "" "" "" "" "TEST-CHK" "TEST"])]
(is (= "POSTED" (:status row)))
(is (= "01/15/2024" (:raw-date row)))
(is (= "Coffee" (:description-original row)))
(is (= "12.50" (:amount row)))
(is (= "TEST-CHK" (:bank-account-code row)))
(is (= "TEST" (:client-code row))))))
(deftest parse-form-schema-test
(testing "Decodes a pasted Yodlee TSV string into row maps"
(let [tsv (str "Status\tDate\tDescription\t\t\t\tAmount\t\t\t\t\t\tBank\tClient\n"
"POSTED\t01/15/2024\tCoffee\tFood\t\t\t12.50\t\t\t\t\t\tTEST-CHK\tTEST")
decoded (mc/decode sut/parse-form-schema {:table tsv} main-transformer)]
(is (= 1 (count (:table decoded))))
(is (= "TEST-CHK" (:bank-account-code (first (:table decoded))))))))
;; =============================================================================
;; Validation — classify-table (hard errors + warnings, preserving master)
;; =============================================================================
(deftest classify-hard-errors-test
(seed-client!)
(testing "Unknown bank-account code is a hard error"
(let [{:keys [form-errors has-errors?]}
(sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}])]
(is has-errors?)
(is (some (fn [[m _]] (re-find #"bank account" m)) (get-in form-errors [:table 0])))))
(testing "Unknown client fires independently when the bank account exists but is linked to no client"
@(dc/transact conn [{:db/id "orphan-ba"
:bank-account/code "ORPHAN-CHK"
:bank-account/type :bank-account-type/check}])
(let [{:keys [form-errors has-errors?]}
(sut/classify-table [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "ORPHAN-CHK"}])
msgs (map first (get-in form-errors [:table 0]))]
(is has-errors?)
(is (some #(re-find #"Cannot find client" %) msgs)
"client-not-found error fires")
(is (not (some #(re-find #"bank account by code" %) msgs))
"bank-account-not-found error does not fire because the bank account exists")))
(testing "Invalid date is a hard error"
(let [{:keys [form-errors has-errors?]}
(sut/classify-table [{:raw-date "not-a-date" :amount "1.00" :bank-account-code "TEST-CHK"}])]
(is has-errors?)
(is (some (fn [[m _]] (re-find #"(?i)mm/dd/yyyy|date" m)) (get-in form-errors [:table 0]))))))
(deftest classify-clean-test
(seed-client!)
(testing "A fully valid row produces no errors or warnings"
(let [{:keys [form-errors has-errors?]}
(sut/classify-table [{:raw-date "01/15/2024" :description-original "Coffee"
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
(is (not has-errors?))
(is (empty? (get-in form-errors [:table 0]))))))
(deftest classify-not-ready-warning-test
(testing "A date before the bank-account start-date is a (skippable) warning, not an error"
(setup-test-data
[(test-client :db/id "import-client"
:client/code "TEST"
:client/locations ["DT"]
:client/bank-accounts [(test-bank-account :db/id "import-ba"
:bank-account/code "TEST-CHK"
:bank-account/start-date #inst "2030-01-01")])])
(let [{:keys [form-errors has-errors?]}
(sut/classify-table [{:raw-date "01/15/2024" :description-original "Early"
:amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
(is (not has-errors?) "warnings do not block")
(is (some (fn [[_ s]] (= :warn s)) (get-in form-errors [:table 0]))))))
;; =============================================================================
;; Import flow — import-transactions (engine reuse, block, idempotency, skip)
;; =============================================================================
(deftest import-clean-test
(seed-client!)
(testing "Clean rows import via the engine and persist"
(let [stats (import! [{:raw-date "01/15/2024" :description-original "Coffee"
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
(is (= 1 (:import-batch/imported stats)))
(is (= 1 (txn-count))))))
(deftest import-blocks-on-hard-error-test
(seed-client!)
(testing "Any hard error blocks the whole batch — nothing is written"
(is (= :blocked
(try+
(import! [{:raw-date "01/15/2024" :amount "1.00" :bank-account-code "NOPE"}])
:did-not-throw
(catch [:type :field-validation] _ :blocked))))
(is (= 0 (txn-count)))))
(deftest import-idempotent-test
(seed-client!)
(testing "Re-importing the same paste is idempotent (extant), no duplicates"
(let [row [{:raw-date "01/15/2024" :description-original "Coffee"
:amount "12.50" :bank-account-code "TEST-CHK" :client-code "TEST"}]]
(import! row)
(let [stats (import! row)]
(is (= 0 (:import-batch/imported stats)))
(is (= 1 (:import-batch/extant stats)))
(is (= 1 (txn-count)))))))
(deftest import-skips-warning-rows-test
(testing "Warning rows (not-ready) are skipped, not imported, without blocking"
(setup-test-data
[(test-client :db/id "import-client"
:client/code "TEST"
:client/locations ["DT"]
:client/bank-accounts [(test-bank-account :db/id "import-ba"
:bank-account/code "TEST-CHK"
:bank-account/start-date #inst "2030-01-01")])])
(let [stats (import! [{:raw-date "01/15/2024" :description-original "Early"
:amount "5.00" :bank-account-code "TEST-CHK" :client-code "TEST"}])]
(is (= 0 (:import-batch/imported stats)))
(is (= 1 (:import-batch/not-ready stats)))
(is (= 0 (txn-count))))))

View File

@@ -67,7 +67,7 @@
[(assoc (test-client :db/id "client-id"
:client/code "TEST"
:client/locations ["DT"])
:client/bank-accounts [(test-bank-account :db/id "bank-account-id" :bank-account/code "TEST-CHK")])
:client/bank-accounts [(test-bank-account :db/id "bank-account-id")])
(test-client :db/id "client-id-2"
:client/code "TEST2"
:client/locations ["NY"])
@@ -135,19 +135,19 @@
:payment/status :payment-status/pending
:payment/date #inst "2023-06-15")
;; Transaction and unpaid invoice for link testing
(test-transaction :db/id "transaction-id-unpaid"
:transaction/client "client-id"
:transaction/bank-account "bank-account-id"
:transaction/amount -150.0
:transaction/description-original "Transaction for unpaid invoice link"
:transaction/approval-status :transaction-approval-status/unapproved)
(test-transaction :db/id "transaction-id-feedback"
:transaction/client "client-id"
:transaction/bank-account "bank-account-id"
:transaction/amount 400.0
:transaction/description-original "Transaction for feedback review"
:transaction/approval-status :transaction-approval-status/requires-feedback)
(test-invoice :db/id "invoice-unpaid-id"
(test-transaction :db/id "transaction-id-unpaid"
:transaction/client "client-id"
:transaction/bank-account "bank-account-id"
:transaction/amount -150.0
:transaction/description-original "Transaction for unpaid invoice link"
:transaction/approval-status :transaction-approval-status/unapproved)
(test-transaction :db/id "transaction-id-feedback"
:transaction/client "client-id"
:transaction/bank-account "bank-account-id"
:transaction/amount 400.0
:transaction/description-original "Transaction for feedback review"
:transaction/approval-status :transaction-approval-status/requires-feedback)
(test-invoice :db/id "invoice-unpaid-id"
:invoice/client "client-id"
:invoice/vendor "vendor-id"
:invoice/total 150.0

View File