a8a47e38c1
CI / secrets-scan (push) Successful in 8s
CI / sast (push) Successful in 13s
CI / vuln-scan (push) Successful in 15s
CI / test (push) Failing after 29s
CI / lint (push) Failing after 31s
CI / build-images (push) Has been skipped
CI / image-scan (push) Has been skipped
CI / push (push) Has been skipped
Lets a user upload a CSV, OFX/QFX, or PDF bank statement, parses the transactions, flags duplicates and possible transfers against existing records, and bulk-creates the accepted rows after a final read-only confirmation step. Backend - New `statements` module with CSV (papaparse), OFX/QFX (node-ofx-parser), and PDF (pdf-parse) parsers behind a `StatementParser` strategy interface; format detected by content sniffing + extension. - `DuplicateDetectorService` checks FITID/externalId exact matches first, then a date+amount+description Jaro-Winkler heuristic, then cross-account transfer pairing. - New `POST /statements/parse` (multipart, in-memory, 10MB cap) returns the parsed preview without writing, including per-row `status` (`new`, `duplicate`, `needs_review`, `possible_transfer`) and any `needsMapping` payload when CSV headers are unrecognized. - `POST /transactions/bulk` accepts up to 500 rows, chunks them 50 at a time inside `prisma.$transaction`, applies balance deltas, and writes a single `ActivityLog` row per chunk instead of one per transaction. - Schema: nullable `external_id` column on `Transaction` plus composite indexes on `(account_id, external_id)` and `(account_id, date)` for fast dedupe-window queries. Not encrypted — it's an opaque bank ID used as a lookup key. Frontend - `ImportStatementDialog` runs a 4-step wizard: Upload → Column Mapping (if needed) → Review (editable table with duplicate/transfer badges) → Confirm (read-only summary with projected per-account balance impact and count-bearing primary button). The Confirm step gates the actual write, and Back to Review preserves all edit/checkbox state. - New `bulkCreateTransactions` action on the transactions store. - "Import Statement" button added next to Export on the Transactions page, with a success toast and a refresh of the transactions + accounts stores. Tests - 306 backend tests (29 suites), 195 frontend tests (31 suites), all green. - Fixtures under `test/fixtures/statements/` cover three CSV sign conventions (signed-amount, debit/credit, credit-card), OFX 1.x SGML, OFX 2.x XML, and a credit-card QFX with the CCSTMTRS branch. Versions bumped to 0.4.0 on both packages per the lockstep rule. NOTE: the Prisma migration in `prisma/migrations/20260527203542_add_transaction_external_id/` still needs to be applied to the live database with `prisma migrate deploy` — the DB at 10.0.3.82 wasn't reachable from the dev environment. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
300 lines
8.3 KiB
TypeScript
300 lines
8.3 KiB
TypeScript
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
import {
|
|
ImportStatementDialog,
|
|
type ParseResponse,
|
|
} from './ImportStatementDialog';
|
|
|
|
const bulkCreateTransactions = vi.fn();
|
|
const fetchTransactions = vi.fn();
|
|
const fetchAccounts = vi.fn();
|
|
|
|
const accounts = [
|
|
{
|
|
id: 'acc-1',
|
|
userId: 'u',
|
|
name: 'Chase Checking',
|
|
type: 'CHECKING' as const,
|
|
balance: 1000,
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
},
|
|
{
|
|
id: 'acc-2',
|
|
userId: 'u',
|
|
name: 'Savings',
|
|
type: 'SAVINGS' as const,
|
|
balance: 5000,
|
|
createdAt: '',
|
|
updatedAt: '',
|
|
},
|
|
];
|
|
|
|
vi.mock('@/stores/transactions', () => ({
|
|
useTransactionsStore: () => ({
|
|
bulkCreateTransactions,
|
|
fetchTransactions,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/stores/accounts', () => ({
|
|
useAccountsStore: () => ({
|
|
accounts,
|
|
fetchAccounts,
|
|
}),
|
|
}));
|
|
|
|
vi.mock('@/lib/supabase', () => ({
|
|
supabase: {
|
|
auth: {
|
|
getSession: async () => ({
|
|
data: { session: { access_token: 'fake-token' } },
|
|
}),
|
|
},
|
|
},
|
|
}));
|
|
|
|
vi.mock('@/lib/runtime-config', () => ({
|
|
getConfig: () => 'http://api.test',
|
|
}));
|
|
|
|
function newRowResponse(over: any = {}) {
|
|
return {
|
|
sourceIndex: 0,
|
|
date: '2026-04-10',
|
|
amount: 42.1,
|
|
type: 'EXPENSE',
|
|
description: 'Coffee',
|
|
confidence: 0.95,
|
|
status: 'new',
|
|
...over,
|
|
};
|
|
}
|
|
|
|
function mockParseResponse(rows: any[], extra: Partial<ParseResponse> = {}) {
|
|
return {
|
|
format: 'csv' as const,
|
|
account: { id: 'acc-1', name: 'Chase Checking', type: 'CHECKING' },
|
|
rows,
|
|
warnings: [],
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function mockFetchOnce(payload: any, ok = true) {
|
|
(globalThis as any).fetch = vi.fn(async () => ({
|
|
ok,
|
|
status: ok ? 200 : 400,
|
|
json: async () => payload,
|
|
}));
|
|
}
|
|
|
|
describe('ImportStatementDialog', () => {
|
|
beforeEach(() => {
|
|
bulkCreateTransactions.mockReset();
|
|
bulkCreateTransactions.mockResolvedValue({
|
|
created: 1,
|
|
ids: ['new-1'],
|
|
});
|
|
fetchTransactions.mockReset();
|
|
fetchTransactions.mockResolvedValue(undefined);
|
|
fetchAccounts.mockReset();
|
|
});
|
|
|
|
function open(extras: any = {}) {
|
|
return render(
|
|
<ImportStatementDialog
|
|
open
|
|
onOpenChange={vi.fn()}
|
|
defaultAccountId="acc-1"
|
|
{...extras}
|
|
/>,
|
|
);
|
|
}
|
|
|
|
function chooseFile(name = 'test.csv') {
|
|
const input = document.querySelector(
|
|
'input[type="file"]',
|
|
) as HTMLInputElement;
|
|
const file = new File(['Date,Amount\n2026-04-01,10\n'], name, {
|
|
type: 'text/csv',
|
|
});
|
|
fireEvent.change(input, { target: { files: [file] } });
|
|
return file;
|
|
}
|
|
|
|
it('renders the upload step initially', () => {
|
|
open();
|
|
expect(
|
|
screen.getByText(/Upload a CSV, OFX, QFX, or PDF/i),
|
|
).toBeInTheDocument();
|
|
expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
|
|
});
|
|
|
|
it('enables Continue once a file is chosen', () => {
|
|
open();
|
|
chooseFile();
|
|
expect(screen.getByTestId('selected-file')).toHaveTextContent('test.csv');
|
|
expect(screen.getByRole('button', { name: /continue/i })).not.toBeDisabled();
|
|
});
|
|
|
|
it('after parsing a clean statement, advances to Review with rows', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([
|
|
newRowResponse({ sourceIndex: 0, description: 'Coffee', status: 'new' }),
|
|
newRowResponse({
|
|
sourceIndex: 1,
|
|
description: 'Payday',
|
|
type: 'INCOME',
|
|
amount: 2000,
|
|
status: 'new',
|
|
}),
|
|
]),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
|
|
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
|
|
);
|
|
expect(screen.getAllByRole('row').length).toBeGreaterThan(1);
|
|
expect(screen.getByRole('button', { name: /Continue to confirm/i })).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows the Confirm step with the count in the primary button label', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([
|
|
newRowResponse({ sourceIndex: 0 }),
|
|
newRowResponse({ sourceIndex: 1, description: 'B' }),
|
|
]),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
|
|
);
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
|
|
|
expect(
|
|
screen.getByRole('button', { name: /Import 2 transactions/i }),
|
|
).toBeInTheDocument();
|
|
expect(
|
|
screen.getByRole('button', { name: /Back to review/i }),
|
|
).toBeInTheDocument();
|
|
});
|
|
|
|
it('Back to review preserves the row selection state', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([
|
|
newRowResponse({ sourceIndex: 0, description: 'Coffee' }),
|
|
newRowResponse({ sourceIndex: 1, description: 'Lunch' }),
|
|
]),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
|
|
);
|
|
// Uncheck the first row.
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
expect(checkboxes[0]).toBeChecked();
|
|
fireEvent.click(checkboxes[0]);
|
|
expect(checkboxes[0]).not.toBeChecked();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
|
expect(
|
|
screen.getByRole('button', { name: /Import 1 transaction/i }),
|
|
).toBeInTheDocument();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Back to review/i }));
|
|
const recheckboxes = screen.getAllByRole('checkbox');
|
|
expect(recheckboxes[0]).not.toBeChecked();
|
|
expect(recheckboxes[1]).toBeChecked();
|
|
});
|
|
|
|
it('does NOT call bulkCreateTransactions until Confirm is clicked', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([newRowResponse({ sourceIndex: 0 })]),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
|
|
);
|
|
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
|
|
expect(bulkCreateTransactions).not.toHaveBeenCalled();
|
|
|
|
fireEvent.click(screen.getByRole('button', { name: /Import 1 transaction/i }));
|
|
await waitFor(() => expect(bulkCreateTransactions).toHaveBeenCalled());
|
|
const [rows, source] = bulkCreateTransactions.mock.calls[0];
|
|
expect(rows).toHaveLength(1);
|
|
expect(source.kind).toBe('statement-import');
|
|
});
|
|
|
|
it('defaults duplicate rows to unchecked', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([
|
|
newRowResponse({
|
|
sourceIndex: 0,
|
|
status: 'duplicate',
|
|
duplicateOf: {
|
|
id: 'existing',
|
|
date: '2026-04-10',
|
|
amount: 42.1,
|
|
description: 'Coffee',
|
|
},
|
|
}),
|
|
newRowResponse({ sourceIndex: 1, description: 'Lunch' }),
|
|
]),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
|
|
);
|
|
const checkboxes = screen.getAllByRole('checkbox');
|
|
expect(checkboxes[0]).not.toBeChecked();
|
|
expect(checkboxes[1]).toBeChecked();
|
|
});
|
|
|
|
it('shows the Column Mapping step when the backend asks for one', async () => {
|
|
mockFetchOnce(
|
|
mockParseResponse([], {
|
|
needsMapping: {
|
|
headers: ['Col1', 'Col2', 'Col3'],
|
|
sample: [['1', '2', '3']],
|
|
guess: {},
|
|
},
|
|
}),
|
|
);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(
|
|
screen.getByText(/Tell us which field each column/i),
|
|
).toBeInTheDocument(),
|
|
);
|
|
// Headers are listed as table cells
|
|
expect(screen.getByText('Col1')).toBeInTheDocument();
|
|
expect(screen.getByText('Col2')).toBeInTheDocument();
|
|
expect(screen.getByText('Col3')).toBeInTheDocument();
|
|
});
|
|
|
|
it('shows a parse error inline', async () => {
|
|
mockFetchOnce({ message: 'This file is corrupt' }, false);
|
|
open();
|
|
chooseFile();
|
|
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
|
|
await waitFor(() =>
|
|
expect(screen.getByText('This file is corrupt')).toBeInTheDocument(),
|
|
);
|
|
});
|
|
});
|