13 Commits

Author SHA1 Message Date
3759258ebe fix(ssr): require Apply for all date-range filters
Most grid pages auto-submitted their date-range filter on every change
event, which fired mid-typing and re-rendered the date inputs, breaking
manual date entry. Invoices and ledgers already gated date submission
behind an explicit Apply button; this brings the other ten pages in line.

- date-range component: stop `change` from the date inputs bubbling to
  the form (@change.stop) and always render the Apply button, so typed or
  picked dates submit only via the Apply button's `datesApplied` event.
  The All/Week/Month/Year presets and all other filters are unaffected.
- payments, invoice import, transactions, import batches, sales
  summaries, expected deposits, cash drawer shifts, refunds, tenders,
  sales orders: add `datesApplied` to the form hx-trigger.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 22:42:17 -07:00
55650c2dab Merge pull request 'refactor(charts): unify on Chart.js, remove Chartist' (#11) from integreat-unify-charts into staging
Reviewed-on: #11
2026-06-02 09:23:29 -07:00
19186097d5 fix(ssr): stop content-card forcing always-on scrollbars; add tmp/ scratch dir
content-card used `overflow-scroll`, which renders scrollbar tracks even
when the content fits — visible as superfluous bars around the admin chart
cards. Switch to `overflow-auto` so scrollbars only appear when content
genuinely overflows (e.g. wide data tables still scroll).

Also add a gitignored ./tmp/ scratch directory (tracked via .gitkeep) and
document in AGENTS.md that temp files belong there.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 09:16:16 -07:00
1f6395382d refactor(charts): unify on Chart.js, remove Chartist
The admin page was the only consumer of Chartist while the dashboard and
expense report already use Chart.js. Convert the admin "Growth in clients"
(bar) and "Changes by hour" (line) charts to Chart.js using the same
Alpine x-data/x-init canvas pattern as the dashboard, and drop the global
Chartist CSS/JS includes from the base page.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 07:55:47 -07:00
d52159637e fixes 2026-06-02 07:15:42 -07:00
3648597031 update 2026-06-02 07:14:37 -07:00
901d9eb508 date-choosing 2026-06-02 07:13:29 -07:00
569e52d1c1 Merge pull request 'feat(transactions): port manual bank-transaction import to SSR' (#9) from integreat-add-transaction-manual into staging
Reviewed-on: #9
2026-06-01 21:06:52 -07:00
9cc3418b1b fix(review): apply autofix feedback
- Alphabetize the import.clj :require block (AGENTS.md Import Formatting).
- Remove unused imports (digest, strip) flagged by clj-kondo.
- Make the client-not-found classify-table test independent: it previously
  reused the bank-account-not-found input and added zero marginal coverage;
  now seeds an orphan bank account so only the client error fires.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:34:31 -07:00
a1098b28f8 feat(transactions): port manual bank-transaction import to SSR
Implement the SSR/alpine/htmx manual transaction import, wiring the
already-declared but unhandled ::external-import-page/parse/import routes.
Mirrors the SSR ledger import: paste the exact master-branch Yodlee
positional-column TSV, review parsed rows in an editable grid with per-row
error/warning badges, and import. Every master validation is preserved and
the existing import.transactions engine is reused unchanged
(via import.manual/import-batch), so core components are untouched.

- New ns auto-ap.ssr.transaction.import (page, paste/parse, editable grid,
  two-tier validation, import handler) + admin-only transactions Import nav.
- Two-tier validation: fixable problems (bad date/amount, unknown client or
  bank-account code, missing fields) are hard errors that block the whole
  batch; inherent skip-conditions (non-POSTED, before start-date/locked,
  already-imported) are warnings computed from the engine's own
  categorize-transaction so the grid preview matches the import result.
- Tests: failing-first Playwright e2e (e2e/transaction-import.spec.ts) plus
  unit/integration coverage (ssr/transaction/import_test.clj, 10 tests).
- Deterministic bank-account code in the e2e seed.

Plan: docs/plans/2026-06-01-001-feat-manual-transaction-import-ssr-plan.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 11:18:28 -07:00
b6649a3d1d fixes 2026-05-31 08:37:44 -07:00
38ae6f460f Cleanup of simple/advanced mode 2026-05-31 08:30:11 -07:00
e156d8bfd8 fixes vendor selection bug 2026-05-30 09:21:39 -07:00
43 changed files with 2039 additions and 2606 deletions

3
.gitignore vendored
View File

@@ -51,3 +51,6 @@ 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.12"
"@opencode-ai/plugin": "1.15.10"
}
},
"node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
@@ -87,19 +87,19 @@
]
},
"node_modules/@opencode-ai/plugin": {
"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==",
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.15.10.tgz",
"integrity": "sha512-V2p7CvpBtKWB+FID7Dl1y0Ci02zUT40A9b2RD9R9BOiuD8ZcKhHWov+irN0xVJA0Eg6OhEBfA0lPKRn1FNKPlw==",
"license": "MIT",
"dependencies": {
"@opencode-ai/sdk": "1.15.12",
"@opencode-ai/sdk": "1.15.10",
"effect": "4.0.0-beta.66",
"zod": "4.1.8"
},
"peerDependencies": {
"@opentui/core": ">=0.2.16",
"@opentui/keymap": ">=0.2.16",
"@opentui/solid": ">=0.2.16"
"@opentui/core": ">=0.2.15",
"@opentui/keymap": ">=0.2.15",
"@opentui/solid": ">=0.2.15"
},
"peerDependenciesMeta": {
"@opentui/core": {
@@ -114,9 +114,9 @@
}
},
"node_modules/@opencode-ai/sdk": {
"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==",
"version": "1.15.10",
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.15.10.tgz",
"integrity": "sha512-CUhpmMGGOqzvPnNNjjWmEIodAfP6Qnuki2ChIUKWYF7UImZ4zUcMZnzO5BtUxu/Ni1P8qzWxDioXs+7aIZQEhA==",
"license": "MIT",
"dependencies": {
"cross-spawn": "7.0.6"

View File

@@ -1,5 +1,9 @@
# 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

@@ -0,0 +1,176 @@
---
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

@@ -0,0 +1,275 @@
---
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

@@ -1,298 +0,0 @@
# 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,6 +455,93 @@ 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

@@ -0,0 +1,125 @@
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,12 +118,7 @@
"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,7 +333,8 @@
(iol-ion.tx.upsert-sales-summary-ledger/upsert-sales-summary (dc/db conn) {:db/id 17592314241429})
(mark-all-dirty 5)
(mark-all-dirty 14)
(delete-all)
(sales-summaries-v2)

View File

@@ -10,8 +10,7 @@
[bidi.bidi :as bidi]
[clj-time.coerce :as coerce]
[clj-time.core :as time]
[datomic.api :as dc]
[hiccup2.core :as hiccup]))
[datomic.api :as dc]))
(defn hourly-changes []
(let [tx-instant-attr (:db/id (dc/pull (dc/db conn) '[:db/id] :db/txInstant))
@@ -56,34 +55,68 @@
[:div
[:h1.text-2xl.mb-3.font-bold "Growth in clients"]
[:div
[: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')))")]]]])
[: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
}
}
}
});"}]]]]])
(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 {: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')))")]]]])])
[: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
}
}
}
});"}]]]]])])
"Admin"))
(def key->handler

View File

@@ -35,7 +35,7 @@
default-grid-fields-schema)]))
(defn filters [request]
[:form {"hx-trigger" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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,9 +80,7 @@
(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)
#_(if (or (:start-date preserved) (:end-date preserved))
preserved
(merge default-params preserved)))))
{:date-range "month"})))
(defn left-aside- [{:keys [nav page-specific]} & _]
[:aside {:id "left-nav",
@@ -306,6 +304,12 @@
: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-scroll shadow-md dark:bg-gray-800 sm:rounded-lg border-2 border-gray-200 dark:border-gray-900 bg-white"}]
[: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"}]
children)]])

View File

@@ -7,7 +7,7 @@
[clj-time.core :as t]
[clj-time.periodic :as per]))
(defn date-range-field [{:keys [value id apply-button?]}]
(defn date-range-field [{:keys [value id]}]
[: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
[:div.flex.space-x-1.items-baseline.w-full.justify-start {"@change.stop" ""}
(com/date-input {:name "start-date"
:value (some-> (:start value)
(atime/unparse-local atime/normal-date))
@@ -31,9 +31,8 @@
:placeholder "Date"
:size :small
:class "shrink date-filter-input"})
(when apply-button?
(but/button- {:color :secondary
:size :small
:type "button"
"x-on:click" "$dispatch('datesApplied')"}
"Apply"))]])])
(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 => $dispatch('change')); "))))]
:x-init (hiccup/raw (str "$watch('value', v => { $el.value = (v && v.value != null) ? v.value : ''; $nextTick(() => $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,71 +93,72 @@
: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)
"@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()}) }})"}]
: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()}) }})"}]
[: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(); $refs.input.focus()"
"x-html" "element.label"}]]]
"@mouseover" "active = index"
"@mouseout" "active = -1"
"@click.prevent" "value = element; tippy.hide(); setTimeout(() => $refs.input.focus(), 10)"
"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"]]]]])
@@ -225,7 +226,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)
@@ -237,24 +238,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)
@@ -296,23 +297,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)
@@ -321,40 +322,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 ", "
@@ -368,21 +369,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)]
@@ -392,21 +393,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

@@ -1,54 +0,0 @@
(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

@@ -1,35 +0,0 @@
(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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#invoice-filters {"hx-trigger" "datesApplied, 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"}
[:div {:x-data (hx/json {:exact_match (:exact-match-id (:query-params request))}) :id "exact-match-id-tag" :class "filter-trigger"}
(com/hidden {:name "exact-match-id"
"x-model" "exact_match"})
(com/pill {:color :primary}
@@ -46,13 +46,14 @@
[: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"}
:hx-swap "outerHTML"
:class "filter-trigger"}
(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))))
"")
@@ -60,90 +61,96 @@
(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" "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"
[: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"
"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}))
:content-fn :vendor/name
:class "filter-trigger"}))
(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))))}))
(:db/id (:client request))))
:class "filter-trigger"}))
(bank-account-filter* request)
(date-range-field* request)
(dr/date-range-field {:value {:start (:start-date (:query-params request))
:end (:end-date (:query-params request))}
:id "date-range"
:apply-button? true})
(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)]])
@@ -184,12 +191,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] ...]]]}
@@ -202,28 +209,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)
@@ -235,77 +242,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
@@ -334,11 +341,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
@@ -362,8 +369,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)
@@ -375,8 +382,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)
@@ -386,7 +393,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)]
@@ -410,12 +417,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
@@ -443,9 +450,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")]]
@@ -459,17 +466,20 @@
[: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 (date-range-field* request) [1 :hx-swap-oob] true)
(assoc-in (exact-match-id* request) [1 :hx-swap-oob] true)])
[(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)])
:query-schema query-schema
:action-buttons (fn [request]
[(when-not (:external? (:route-params request)) (com/button {:color :primary
@@ -485,7 +495,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))}
@@ -497,14 +507,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
@@ -521,102 +531,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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#payment-filters {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form {"hx-trigger" "datesApplied, 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,18 +102,17 @@
(def key->handler
(merge edit/key->handler
bulk-code/key->handler
{::route/bulk-code-trial bulk-code-trial/open-trial
::route/bulk-code-trial-submit bulk-code-trial/submit-trial}
t-import/key->handler
(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

@@ -1,172 +0,0 @@
(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" "change delay:500ms, keyup changed from:.hot-filter delay:1000ms"
[:form#transaction-filters {"hx-trigger" "datesApplied, 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,12 +440,6 @@
"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,6 +514,7 @@
: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
@@ -882,9 +883,13 @@
#_(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] :as multi-form-state} :multi-form-state :as request}]
(render-step [this {{:keys [snapshot step-params] :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)]
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))))]
(mm/default-render-step
linear-wizard this
:head [:div.p-2 "Edit Transaction"]
@@ -950,7 +955,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* (manual-mode-initial snapshot) request)
(manual-coding-section* mode request)
(fc/with-field :transaction/approval-status
(com/validated-field
{:label "Status"
@@ -1429,10 +1434,13 @@
(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) "simple"))
mode (keyword (or (:mode step-params)
(get (:form-params request) "mode")
"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)
@@ -1440,18 +1448,30 @@
amount-mode (or (:amount-mode snapshot) "$")
existing-accounts (or (seq (:transaction/accounts step-params))
(seq (:transaction/accounts snapshot)))
default-account (when (and (empty? existing-accounts) vendor-id client-id)
;; 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)
(vendor-default-account vendor-id client-id))
render-request
(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)]
(-> (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))]
(html-response
(fc/start-form (:multi-form-state render-request) nil
(fc/with-field :step-params

View File

@@ -0,0 +1,300 @@
(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,8 +23,6 @@
[: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,9 +10,7 @@
"/bulk-code" {:get ::bulk-code
:put ::bulk-code-submit
"/new-account" ::bulk-code-new-account
"/vendor-changed" ::bulk-code-vendor-changed}
"/bulk-code-trial" {:get ::bulk-code-trial
:post ::bulk-code-trial-submit}}
"/vendor-changed" ::bulk-code-vendor-changed}}
"/new" {:get ::new
:post ::new-submit
"/location-select" ::location-select

View File

@@ -1,114 +0,0 @@
(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

@@ -1,76 +0,0 @@
(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

@@ -1,104 +0,0 @@
(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,12 +5,13 @@
[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]))
@@ -52,7 +53,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 [{} {} {}]})))))
@@ -105,7 +106,7 @@
(is (re-find #"Test Account" body)
"Response should contain the vendor's default account name")))
(testing "AC5: vendor selection in simple mode does NOT overwrite already-set account"
(testing "AC5: 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"
@@ -126,9 +127,10 @@
: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 means vendor should NOT overwrite
;; 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"
@@ -149,12 +151,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 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"))))
;; 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"))))
;;; ---------------------------------------------------------------------------
;;; AC6: save round-trip — manual mode saves vendor + account to DB
@@ -163,18 +165,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")
@@ -934,3 +936,384 @@
;; 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

@@ -0,0 +1,164 @@
(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")])
:client/bank-accounts [(test-bank-account :db/id "bank-account-id" :bank-account/code "TEST-CHK")])
(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

0
tmp/.gitkeep Normal file
View File