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>
This commit is contained in:
2026-06-01 11:18:28 -07:00
parent b6649a3d1d
commit a1098b28f8
8 changed files with 1056 additions and 14 deletions

View File

@@ -0,0 +1,157 @@
(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 (via 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 #"client" m)) (get-in form-errors [:table 0])))))
(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