Files
TehRiehlBudget/tehriehlbudget-frontend/src/components/ImportStatementDialog.test.tsx
T
TehRiehlDeal 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
Add bulk statement-import feature for transactions
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>
2026-05-27 14:23:45 -07:00

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