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:
125
e2e/transaction-import.spec.ts
Normal file
125
e2e/transaction-import.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user