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>
126 lines
4.2 KiB
TypeScript
126 lines
4.2 KiB
TypeScript
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);
|
|
});
|
|
});
|