Files
integreat/e2e/transaction-import.spec.ts
Bryce 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

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);
});
});