Fix backend lint errors in the statement-parser code
CI / secrets-scan (push) Successful in 6s
CI / sast (push) Successful in 16s
CI / vuln-scan (push) Successful in 21s
CI / test (push) Successful in 27s
CI / lint (push) Successful in 33s
CI / build-images (push) Successful in 2m10s
CI / image-scan (push) Successful in 53s
CI / push (push) Has been skipped
CI / secrets-scan (pull_request) Successful in 6s
CI / sast (pull_request) Successful in 17s
CI / vuln-scan (pull_request) Successful in 19s
CI / test (pull_request) Successful in 26s
CI / lint (pull_request) Successful in 33s
CI / build-images (pull_request) Successful in 2m13s
CI / image-scan (pull_request) Successful in 53s
CI / push (pull_request) Has been skipped

The new statements module was hitting type-safety errors from
typescript-eslint's recommendedTypeChecked config:

- parse-statement.dto.ts: tighten the Transform decorator's signature so
  the JSON.parse path returns a typed object or undefined, not `any`.
- duplicate-detector.service.ts: drop the unused
  WEAK_DESCRIPTION_SIMILARITY constant left over from earlier logic.
- csv.parser.ts and ofx.parser.ts: the parse() methods were `async`
  without any `await` (require-await). Convert them to non-async
  functions that return a Promise — wrap parseSync() in a try/catch so
  thrown errors still surface as rejected promises for spec callers
  that use `.rejects.toThrow()`.
- ofx.parser.ts: replace `require('node-ofx-parser')` with a typed
  `import * as ofxLib`, backed by a hand-written declaration file at
  src/types/node-ofx-parser.d.ts that captures the bank + credit-card
  transaction shapes we consume.
- pdf.parser.ts: import the typed `PDFParse` class from pdf-parse
  directly instead of lazy-requiring it as `any`. Keep the test seam
  but back it with a typed PdfTextExtractor function instead of the
  ad-hoc `any` shape.

Also pulls in the prettier reformat that `eslint --fix` produced across
the touched files and their specs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 14:57:22 -07:00
parent 6a6d629bcf
commit 9f0af6bfb8
18 changed files with 297 additions and 271 deletions
@@ -16,18 +16,21 @@ export class ParseStatementDto {
@IsOptional()
@IsObject()
@Transform(({ value }) => {
@Transform(({ value }: { value: unknown }): ColumnMappingDto | undefined => {
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value);
const parsed: unknown = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null
? parsed
? (parsed as ColumnMappingDto)
: undefined;
} catch {
return undefined;
}
}
return value;
if (typeof value === 'object' && value !== null) {
return value as ColumnMappingDto;
}
return undefined;
})
mapping?: ColumnMappingDto;
}
@@ -5,7 +5,11 @@ import type { ParsedRow } from './parsers/parser.interface';
jest.mock('@prisma/client', () => ({
PrismaClient: class {},
TransactionType: { INCOME: 'INCOME', EXPENSE: 'EXPENSE', TRANSFER: 'TRANSFER' },
TransactionType: {
INCOME: 'INCOME',
EXPENSE: 'EXPENSE',
TRANSFER: 'TRANSFER',
},
}));
const row = (over: Partial<ParsedRow> = {}): ParsedRow => ({
@@ -58,7 +62,12 @@ describe('DuplicateDetectorService', () => {
const result = await service.classify('user-1', 'acc-1', [
row({ externalId: 'FITID-A', sourceIndex: 0 }),
row({ externalId: 'FITID-B', sourceIndex: 1, amount: 9, description: 'Diff' }),
row({
externalId: 'FITID-B',
sourceIndex: 1,
amount: 9,
description: 'Diff',
}),
]);
expect(result.get(0)).toMatchObject({
@@ -103,7 +112,11 @@ describe('DuplicateDetectorService', () => {
.mockResolvedValueOnce([]);
const result = await service.classify('user-1', 'acc-1', [
row({ amount: 42.1, date: '2026-04-10', description: 'Coffee Shop #1' }),
row({
amount: 42.1,
date: '2026-04-10',
description: 'Coffee Shop #1',
}),
]);
expect(result.get(0)?.status).toBe('duplicate');
@@ -171,7 +184,11 @@ describe('DuplicateDetectorService', () => {
]);
const result = await service.classify('user-1', 'acc-1', [
row({ amount: 200, type: 'EXPENSE', description: 'Transfer to savings' }),
row({
amount: 200,
type: 'EXPENSE',
description: 'Transfer to savings',
}),
]);
expect(result.get(0)?.status).toBe('possible_transfer');
@@ -9,7 +9,6 @@ const STRICT_DATE_WINDOW_DAYS = 1;
const LOOSE_DATE_WINDOW_DAYS = 3;
const TRANSFER_WINDOW_DAYS = 3;
const STRONG_DESCRIPTION_SIMILARITY = 0.85;
const WEAK_DESCRIPTION_SIMILARITY = 0.7;
export type ClassificationStatus =
| 'new'
@@ -60,7 +59,10 @@ function normalizeDescription(input: string): string {
function jaroWinkler(a: string, b: string): number {
if (a === b) return 1;
if (!a.length || !b.length) return 0;
const matchWindow = Math.max(0, Math.floor(Math.max(a.length, b.length) / 2) - 1);
const matchWindow = Math.max(
0,
Math.floor(Math.max(a.length, b.length) / 2) - 1,
);
const aMatches = new Array(a.length).fill(false);
const bMatches = new Array(b.length).fill(false);
let matches = 0;
@@ -185,7 +187,11 @@ export class DuplicateDetectorService {
const rowDate = parseInputDate(row.date);
const rowNormDesc = normalizeDescription(row.description);
let bestStrong: { match: ExistingTxn; sim: number } | null = null;
let bestNear: { match: ExistingTxn; sim: number; dDiff: number } | null = null;
let bestNear: {
match: ExistingTxn;
sim: number;
dDiff: number;
} | null = null;
for (const c of candidates) {
const dDiff = dayDiff(rowDate, c.date);
const amountDiff = Math.abs(Number(c.amount) - row.amount);
@@ -69,9 +69,7 @@ describe('isMappingUsable', () => {
credit: 'Credit',
}),
).toBe(true);
expect(
isMappingUsable({ date: 'Date', description: 'Desc' }),
).toBe(false);
expect(isMappingUsable({ date: 'Date', description: 'Desc' })).toBe(false);
expect(
isMappingUsable({
date: 'Date',
@@ -71,7 +71,6 @@ export function guessColumnMapping(headers: string[]): ColumnMapping {
export function isMappingUsable(mapping: ColumnMapping): boolean {
if (!mapping.date || !mapping.description) return false;
const hasAmount =
!!mapping.amount || (!!mapping.debit && !!mapping.credit);
const hasAmount = !!mapping.amount || (!!mapping.debit && !!mapping.credit);
return hasAmount;
}
@@ -16,10 +16,7 @@ jest.mock('@prisma/client', () => ({
},
}));
const fixturesDir = path.join(
__dirname,
'../../../test/fixtures/statements',
);
const fixturesDir = path.join(__dirname, '../../../test/fixtures/statements');
const loadFixture = (name: string) =>
fs.readFileSync(path.join(fixturesDir, name));
@@ -30,16 +27,24 @@ describe('CsvParser', () => {
describe('canParse', () => {
it('accepts text/csv mime', () => {
expect(parser.canParse({ buffer: Buffer.from(''), mimetype: 'text/csv' })).toBe(true);
expect(
parser.canParse({ buffer: Buffer.from(''), mimetype: 'text/csv' }),
).toBe(true);
});
it('accepts .csv extension', () => {
expect(
parser.canParse({ buffer: Buffer.from(''), originalname: 'export.csv' }),
parser.canParse({
buffer: Buffer.from(''),
originalname: 'export.csv',
}),
).toBe(true);
});
it('rejects pdf mime', () => {
expect(
parser.canParse({ buffer: Buffer.from(''), mimetype: 'application/pdf' }),
parser.canParse({
buffer: Buffer.from(''),
mimetype: 'application/pdf',
}),
).toBe(false);
});
});
@@ -79,7 +84,10 @@ describe('CsvParser', () => {
describe('debit/credit columns (BoA style)', () => {
it('uses Debit column → EXPENSE, Credit column → INCOME', async () => {
const result = await parser.parse(
{ buffer: loadFixture('boa-debit-credit.csv'), originalname: 'boa.csv' },
{
buffer: loadFixture('boa-debit-credit.csv'),
originalname: 'boa.csv',
},
{ account: checkingAccount },
);
@@ -117,9 +125,7 @@ describe('CsvParser', () => {
it('returns needsMapping when guess is not usable', async () => {
const result = await parser.parse(
{
buffer: Buffer.from(
'Col1,Col2,Col3\nfoo,bar,baz\nqux,quux,corge\n',
),
buffer: Buffer.from('Col1,Col2,Col3\nfoo,bar,baz\nqux,quux,corge\n'),
originalname: 'weird.csv',
},
{ account: checkingAccount },
@@ -261,7 +267,8 @@ describe('CsvParser', () => {
});
it('handles a thousands-separated amount like 1,234.56', async () => {
const csv = 'Date,Description,Amount\n2026-04-02,Big Coffee,"-1,234.56"\n';
const csv =
'Date,Description,Amount\n2026-04-02,Big Coffee,"-1,234.56"\n';
const result = await parser.parse(
{ buffer: Buffer.from(csv), originalname: 'big.csv' },
{ account: checkingAccount },
@@ -280,8 +287,7 @@ describe('CsvParser', () => {
});
it('parses EU thousands-separated amounts (1.234,56 form)', async () => {
const csv =
'Date;Description;Amount\n2026-04-02;Big Coffee;-1.234,56\n';
const csv = 'Date;Description;Amount\n2026-04-02;Big Coffee;-1.234,56\n';
const result = await parser.parse(
{ buffer: Buffer.from(csv), originalname: 'eu-thousands.csv' },
{ account: checkingAccount },
@@ -197,10 +197,17 @@ export class CsvParser implements StatementParser {
return false;
}
async parse(
file: ParserFileInput,
options: ParseOptions,
): Promise<ParseResult> {
parse(file: ParserFileInput, options: ParseOptions): Promise<ParseResult> {
try {
return Promise.resolve(this.parseSync(file, options));
} catch (err) {
return Promise.reject(
err instanceof Error ? err : new Error(String(err)),
);
}
}
private parseSync(file: ParserFileInput, options: ParseOptions): ParseResult {
const raw = stripBom(file.buffer.toString('utf8'));
if (!raw.trim()) {
return { rows: [], warnings: [] };
@@ -218,21 +225,24 @@ export class CsvParser implements StatementParser {
const warnings: string[] = [];
let mapping = options.mapping ?? guessColumnMapping(headers);
const mapping = options.mapping ?? guessColumnMapping(headers);
if (!isMappingUsable(mapping)) {
return {
rows: [],
warnings,
needsMapping: {
headers,
sample: rawRows.slice(0, 5).map((r) => headers.map((h) => r[h] ?? '')),
sample: rawRows
.slice(0, 5)
.map((r) => headers.map((h) => r[h] ?? '')),
guess: mapping,
},
};
}
const rows: ParsedRow[] = [];
const hasAmountOnly = !!mapping.amount && !(mapping.debit && mapping.credit);
const hasAmountOnly =
!!mapping.amount && !(mapping.debit && mapping.credit);
rawRows.forEach((row, i) => {
const dateRaw = mapping.date ? row[mapping.date] : '';
@@ -16,10 +16,7 @@ jest.mock('@prisma/client', () => ({
},
}));
const fixturesDir = path.join(
__dirname,
'../../../test/fixtures/statements',
);
const fixturesDir = path.join(__dirname, '../../../test/fixtures/statements');
const loadFixture = (name: string) =>
fs.readFileSync(path.join(fixturesDir, name));
@@ -32,7 +29,9 @@ describe('OfxParser', () => {
describe('canParse', () => {
it('accepts buffers beginning with OFXHEADER (SGML)', () => {
expect(
parser.canParse({ buffer: Buffer.from('OFXHEADER:100\nDATA:OFXSGML\n') }),
parser.canParse({
buffer: Buffer.from('OFXHEADER:100\nDATA:OFXSGML\n'),
}),
).toBe(true);
});
it('accepts XML OFX with the <?OFX header', () => {
@@ -1,4 +1,12 @@
import { AccountType } from '@prisma/client';
import * as ofxLib from 'node-ofx-parser';
import type {
OfxParsed,
OfxRoot,
OfxStatement,
OfxTransaction,
OfxTransactionList,
} from 'node-ofx-parser';
import {
ParseOptions,
ParseResult,
@@ -7,8 +15,6 @@ import {
StatementParser,
} from './parser.interface';
const ofxLib: { parse: (data: string) => any } = require('node-ofx-parser');
const LIABILITY_TYPES: AccountType[] = [AccountType.CREDIT, AccountType.LOAN];
function asArray<T>(value: T | T[] | undefined): T[] {
@@ -18,7 +24,7 @@ function asArray<T>(value: T | T[] | undefined): T[] {
function parseOfxDate(raw: string | undefined): string | null {
if (!raw) return null;
const cleaned = String(raw).trim();
const cleaned = raw.trim();
// OFX dates are YYYYMMDD[HHMMSS[.XXX]][TZ]
const match = cleaned.match(/^(\d{4})(\d{2})(\d{2})/);
if (!match) return null;
@@ -27,30 +33,25 @@ function parseOfxDate(raw: string | undefined): string | null {
function parseAmount(raw: string | undefined): number | null {
if (raw === undefined || raw === null) return null;
const n = parseFloat(String(raw).trim());
const n = parseFloat(raw.trim());
return isNaN(n) ? null : n;
}
function normalizeString(input: string | undefined): string {
if (!input) return '';
return String(input).replace(/\s+/g, ' ').trim();
return input.replace(/\s+/g, ' ').trim();
}
function findTransactionLists(parsed: any): any[] {
const lists: any[] = [];
const ofx = parsed?.OFX;
if (!ofx) return lists;
// Bank statement
const bankResp = asArray(ofx.BANKMSGSRSV1?.STMTTRNRS);
function findTransactionLists(root: OfxRoot): OfxTransactionList[] {
const lists: OfxTransactionList[] = [];
const bankResp = asArray(root.BANKMSGSRSV1?.STMTTRNRS);
for (const resp of bankResp) {
const stmt = resp?.STMTRS;
const stmt: OfxStatement | undefined = resp.STMTRS;
if (stmt?.BANKTRANLIST) lists.push(stmt.BANKTRANLIST);
}
// Credit-card statement
const ccResp = asArray(ofx.CREDITCARDMSGSRSV1?.CCSTMTTRNRS);
const ccResp = asArray(root.CREDITCARDMSGSRSV1?.CCSTMTTRNRS);
for (const resp of ccResp) {
const stmt = resp?.CCSTMTRS;
const stmt: OfxStatement | undefined = resp.CCSTMTRS;
if (stmt?.BANKTRANLIST) lists.push(stmt.BANKTRANLIST);
}
return lists;
@@ -68,12 +69,19 @@ export class OfxParser implements StatementParser {
return false;
}
async parse(
file: ParserFileInput,
options: ParseOptions,
): Promise<ParseResult> {
parse(file: ParserFileInput, options: ParseOptions): Promise<ParseResult> {
try {
return Promise.resolve(this.parseSync(file, options));
} catch (err) {
return Promise.reject(
err instanceof Error ? err : new Error(String(err)),
);
}
}
private parseSync(file: ParserFileInput, options: ParseOptions): ParseResult {
const raw = file.buffer.toString('utf8');
let parsed: any;
let parsed: OfxParsed;
try {
parsed = ofxLib.parse(raw);
} catch {
@@ -81,20 +89,19 @@ export class OfxParser implements StatementParser {
'Unable to read this OFX/QFX file. The file may be corrupt or in an unsupported format.',
);
}
if (!parsed?.OFX || typeof parsed.OFX !== 'object') {
throw new Error(
'This file does not appear to be an OFX/QFX statement.',
);
const root = parsed.OFX;
if (!root || typeof root !== 'object') {
throw new Error('This file does not appear to be an OFX/QFX statement.');
}
const isLiability = LIABILITY_TYPES.includes(options.account.type);
const lists = findTransactionLists(parsed);
const lists = findTransactionLists(root);
const rows: ParsedRow[] = [];
const warnings: string[] = [];
let sourceIndex = 0;
for (const list of lists) {
const txns = asArray(list.STMTTRN);
const txns: OfxTransaction[] = asArray(list.STMTTRN);
for (const t of txns) {
const date = parseOfxDate(t.DTPOSTED);
const amountRaw = parseAmount(t.TRNAMT);
@@ -124,7 +131,7 @@ export class OfxParser implements StatementParser {
amount: Math.round(absAmount * 100) / 100,
type,
description,
externalId: t.FITID ? String(t.FITID).trim() : undefined,
externalId: t.FITID ? t.FITID.trim() : undefined,
rawMemo: t.MEMO ? normalizeString(t.MEMO) : undefined,
confidence: 0.98,
});
@@ -1,4 +1,5 @@
import { AccountType } from '@prisma/client';
import { PDFParse } from 'pdf-parse';
import {
ParseOptions,
ParseResult,
@@ -10,7 +11,10 @@ import {
const LIABILITY_TYPES: AccountType[] = [AccountType.CREDIT, AccountType.LOAN];
// Date patterns we recognize at the start of a transaction line.
const DATE_PATTERNS: { regex: RegExp; toIso: (m: RegExpMatchArray) => string }[] = [
const DATE_PATTERNS: {
regex: RegExp;
toIso: (m: RegExpMatchArray) => string;
}[] = [
{
regex: /^(\d{4})-(\d{2})-(\d{2})/,
toIso: (m) => `${m[1]}-${m[2]}-${m[3]}`,
@@ -27,8 +31,18 @@ const DATE_PATTERNS: { regex: RegExp; toIso: (m: RegExpMatchArray) => string }[]
/^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\.?\s+(\d{1,2}),?\s+(\d{4})/i,
toIso: (m) => {
const months: Record<string, string> = {
jan: '01', feb: '02', mar: '03', apr: '04', may: '05', jun: '06',
jul: '07', aug: '08', sep: '09', oct: '10', nov: '11', dec: '12',
jan: '01',
feb: '02',
mar: '03',
apr: '04',
may: '05',
jun: '06',
jul: '07',
aug: '08',
sep: '09',
oct: '10',
nov: '11',
dec: '12',
};
return `${m[3]}-${months[m[1].toLowerCase().slice(0, 3)]}-${m[2].padStart(2, '0')}`;
},
@@ -37,7 +51,8 @@ const DATE_PATTERNS: { regex: RegExp; toIso: (m: RegExpMatchArray) => string }[]
// Amount at the end of a line: optional sign, digits with optional thousands
// separators, optional decimals, optional trailing CR/DR marker.
const AMOUNT_REGEX = /(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})?|\d+\.\d{2})(\s*(CR|DR))?\s*$/i;
const AMOUNT_REGEX =
/(-?\$?\d{1,3}(?:,\d{3})*(?:\.\d{2})?|\d+\.\d{2})(\s*(CR|DR))?\s*$/i;
function parseAmount(input: string): number | null {
const cleaned = input.replace(/[$,\s]/g, '');
@@ -45,57 +60,33 @@ function parseAmount(input: string): number | null {
return isNaN(n) ? null : n;
}
interface PdfTextResult {
export interface PdfTextResult {
text: string;
pages?: unknown[];
}
let cachedPdfParse:
| { parse: (buffer: Buffer) => Promise<PdfTextResult> }
| null = null;
export type PdfTextExtractor = (buffer: Buffer) => Promise<PdfTextResult>;
async function loadPdfParser(): Promise<{
parse: (buffer: Buffer) => Promise<PdfTextResult>;
}> {
if (cachedPdfParse) return cachedPdfParse;
// Lazy-load via require so tests can mock it. The library is an ES module
// but ships a CJS build; we resolve through the package main.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('pdf-parse');
// pdf-parse 2.x exposes `PDFParse` class; older 1.x exported a default
// function. Support both for resilience.
if (mod?.PDFParse) {
cachedPdfParse = {
parse: async (buffer: Buffer) => {
const parser = new mod.PDFParse({ data: buffer });
const result = await parser.getText();
try {
await parser.destroy();
} catch {
/* ignore */
}
return { text: result.text ?? '' };
},
};
} else if (typeof mod === 'function' || typeof mod?.default === 'function') {
const fn = typeof mod === 'function' ? mod : mod.default;
cachedPdfParse = {
parse: async (buffer: Buffer) => {
const result = await fn(buffer);
return { text: result?.text ?? '' };
},
};
} else {
throw new Error('PDF parser library is not available.');
const defaultExtractor: PdfTextExtractor = async (buffer) => {
const parser = new PDFParse({ data: buffer });
try {
const result = await parser.getText();
return { text: result.text ?? '' };
} finally {
try {
await parser.destroy();
} catch {
/* ignore */
}
}
return cachedPdfParse;
}
};
// Test seam — lets specs swap in a fake parser without touching require().
let extractor: PdfTextExtractor = defaultExtractor;
// Test seam — specs swap in a fake extractor without touching the real pdfjs.
export function __setPdfParseForTesting(
fake: { parse: (buffer: Buffer) => Promise<PdfTextResult> } | null,
): void {
cachedPdfParse = fake;
extractor = fake ? (b) => fake.parse(b) : defaultExtractor;
}
interface ExtractedLine {
@@ -125,7 +116,11 @@ function extractTransactionLines(text: string): ExtractedLine[] {
if (!amountMatch) continue;
const description = rest.slice(0, amountMatch.index).trim();
if (!description) continue;
out.push({ date, amountRaw: amountMatch[1] + (amountMatch[2] ?? ''), description });
out.push({
date,
amountRaw: amountMatch[1] + (amountMatch[2] ?? ''),
description,
});
}
return out;
}
@@ -144,10 +139,9 @@ export class PdfParser implements StatementParser {
file: ParserFileInput,
options: ParseOptions,
): Promise<ParseResult> {
const lib = await loadPdfParser();
let extracted: PdfTextResult;
try {
extracted = await lib.parse(file.buffer);
extracted = await extractor(file.buffer);
} catch (err) {
throw new Error(
err instanceof Error
@@ -174,7 +168,9 @@ export class PdfParser implements StatementParser {
const warnings: string[] = [];
lines.forEach((line, sourceIndex) => {
const trailingMarker = line.amountRaw.match(/(CR|DR)$/i)?.[1]?.toUpperCase();
const trailingMarker = line.amountRaw
.match(/(CR|DR)$/i)?.[1]
?.toUpperCase();
const cleanedAmount = line.amountRaw.replace(/(CR|DR)$/i, '');
const num = parseAmount(cleanedAmount);
if (num === null || num === 0) {
@@ -57,9 +57,13 @@ describe('StatementsController', () => {
it('rejects the request when no file is attached', () => {
expect(() =>
controller.parse(mockUser, undefined as any, {
accountId: 'acc-1',
} as any),
controller.parse(
mockUser,
undefined as any,
{
accountId: 'acc-1',
} as any,
),
).toThrow(BadRequestException);
});
@@ -29,10 +29,7 @@ export class TransactionsController {
}
@Post('bulk')
bulk(
@CurrentUser() user: User,
@Body() dto: BulkCreateTransactionsDto,
) {
bulk(@CurrentUser() user: User, @Body() dto: BulkCreateTransactionsDto) {
return this.transactionsService.createMany(user.id, dto.transactions, {
source: dto.source,
sourceLabel: dto.sourceLabel,
@@ -866,11 +866,10 @@ describe('TransactionsService', () => {
}));
const rows = Array.from({ length: 60 }, () => expenseDto());
await service.createMany(
userId,
rows,
{ source: 'statement-import', sourceLabel: 'chase.csv' },
);
await service.createMany(userId, rows, {
source: 'statement-import',
sourceLabel: 'chase.csv',
});
// 60 rows = 2 chunks, expect 2 log entries (not 60)
expect(mockActivityLog.log).toHaveBeenCalledTimes(2);
@@ -879,7 +878,9 @@ describe('TransactionsService', () => {
userId,
entityType: 'TRANSACTION',
action: 'CREATE',
summary: expect.stringMatching(/Imported \d+ transaction.*chase\.csv/),
summary: expect.stringMatching(
/Imported \d+ transaction.*chase\.csv/,
),
}),
);
});
+59
View File
@@ -0,0 +1,59 @@
// Hand-written types for node-ofx-parser. The package ships without
// declarations; this captures just the structure the statement-import code
// actually consumes (OFX bank + credit-card transactions and the FITID
// dedupe key).
declare module 'node-ofx-parser' {
export interface OfxTransaction {
TRNTYPE?: string;
DTPOSTED?: string;
TRNAMT?: string;
FITID?: string;
NAME?: string;
MEMO?: string;
PAYEE?: { NAME?: string };
CHECKNUM?: string;
}
export interface OfxTransactionList {
DTSTART?: string;
DTEND?: string;
STMTTRN?: OfxTransaction | OfxTransaction[];
}
export interface OfxStatement {
BANKACCTFROM?: { ACCTTYPE?: string };
CCACCTFROM?: { ACCTID?: string };
BANKTRANLIST?: OfxTransactionList;
LEDGERBAL?: { BALAMT?: string; DTASOF?: string };
}
export interface OfxBankResponse {
STMTRS?: OfxStatement;
}
export interface OfxCcResponse {
CCSTMTRS?: OfxStatement;
}
export interface OfxRoot {
SIGNONMSGSRSV1?: unknown;
BANKMSGSRSV1?: {
STMTTRNRS?: OfxBankResponse | OfxBankResponse[];
};
CREDITCARDMSGSRSV1?: {
CCSTMTTRNRS?: OfxCcResponse | OfxCcResponse[];
};
}
export interface OfxParsed {
OFX?: OfxRoot | string;
header?: Record<string, string>;
}
export function parse(data: string): OfxParsed;
export function serialize(
header: Record<string, string>,
body: unknown,
): string;
}
@@ -1,9 +1,6 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
ImportStatementDialog,
type ParseResponse,
} from './ImportStatementDialog';
import { ImportStatementDialog, type ParseResponse } from './ImportStatementDialog';
const bulkCreateTransactions = vi.fn();
const fetchTransactions = vi.fn();
@@ -103,19 +100,12 @@ describe('ImportStatementDialog', () => {
function open(extras: any = {}) {
return render(
<ImportStatementDialog
open
onOpenChange={vi.fn()}
defaultAccountId="acc-1"
{...extras}
/>,
<ImportStatementDialog open onOpenChange={vi.fn()} defaultAccountId="acc-1" {...extras} />,
);
}
function chooseFile(name = 'test.csv') {
const input = document.querySelector(
'input[type="file"]',
) as HTMLInputElement;
const input = document.querySelector('input[type="file"]') as HTMLInputElement;
const file = new File(['Date,Amount\n2026-04-01,10\n'], name, {
type: 'text/csv',
});
@@ -125,9 +115,7 @@ describe('ImportStatementDialog', () => {
it('renders the upload step initially', () => {
open();
expect(
screen.getByText(/Upload a CSV, OFX, QFX, or PDF/i),
).toBeInTheDocument();
expect(screen.getByText(/Upload a CSV, OFX, QFX, or PDF/i)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /continue/i })).toBeDisabled();
});
@@ -155,9 +143,7 @@ describe('ImportStatementDialog', () => {
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /continue/i }));
await waitFor(() =>
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
);
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();
});
@@ -172,18 +158,12 @@ describe('ImportStatementDialog', () => {
open();
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() =>
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
);
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();
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 () => {
@@ -196,9 +176,7 @@ describe('ImportStatementDialog', () => {
open();
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() =>
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
// Uncheck the first row.
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).toBeChecked();
@@ -206,9 +184,7 @@ describe('ImportStatementDialog', () => {
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();
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');
@@ -217,15 +193,11 @@ describe('ImportStatementDialog', () => {
});
it('does NOT call bulkCreateTransactions until Confirm is clicked', async () => {
mockFetchOnce(
mockParseResponse([newRowResponse({ sourceIndex: 0 })]),
);
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(),
);
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
fireEvent.click(screen.getByRole('button', { name: /Continue to confirm/i }));
expect(bulkCreateTransactions).not.toHaveBeenCalled();
@@ -255,9 +227,7 @@ describe('ImportStatementDialog', () => {
open();
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() =>
expect(screen.getByText(/Review each row/i)).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText(/Review each row/i)).toBeInTheDocument());
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes[0]).not.toBeChecked();
expect(checkboxes[1]).toBeChecked();
@@ -277,9 +247,7 @@ describe('ImportStatementDialog', () => {
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() =>
expect(
screen.getByText(/Tell us which field each column/i),
).toBeInTheDocument(),
expect(screen.getByText(/Tell us which field each column/i)).toBeInTheDocument(),
);
// Headers are listed as table cells
expect(screen.getByText('Col1')).toBeInTheDocument();
@@ -292,8 +260,6 @@ describe('ImportStatementDialog', () => {
open();
chooseFile();
fireEvent.click(screen.getByRole('button', { name: /^continue$/i }));
await waitFor(() =>
expect(screen.getByText('This file is corrupt')).toBeInTheDocument(),
);
await waitFor(() => expect(screen.getByText('This file is corrupt')).toBeInTheDocument());
});
});
@@ -181,12 +181,7 @@ async function uploadAndParse(
return res.json();
}
export function ImportStatementDialog({
open,
onOpenChange,
defaultAccountId,
onImported,
}: Props) {
export function ImportStatementDialog({ open, onOpenChange, defaultAccountId, onImported }: Props) {
const { accounts, fetchAccounts } = useAccountsStore();
const { bulkCreateTransactions, fetchTransactions } = useTransactionsStore();
@@ -253,17 +248,13 @@ export function ImportStatementDialog({
};
const updateRow = (sourceIndex: number, patch: Partial<ReviewRow>) => {
setRows((prev) =>
prev.map((r) => (r.sourceIndex === sourceIndex ? { ...r, ...patch } : r)),
);
setRows((prev) => prev.map((r) => (r.sourceIndex === sourceIndex ? { ...r, ...patch } : r)));
};
const markAllIncluded = (included: boolean) =>
setRows((prev) => prev.map((r) => ({ ...r, included })));
const skipAllDuplicates = () =>
setRows((prev) =>
prev.map((r) => (r.status === 'duplicate' ? { ...r, included: false } : r)),
);
setRows((prev) => prev.map((r) => (r.status === 'duplicate' ? { ...r, included: false } : r)));
const selected = rows.filter((r) => r.included);
const duplicates = rows.filter((r) => r.status === 'duplicate');
@@ -271,10 +262,7 @@ export function ImportStatementDialog({
const transfers = rows.filter((r) => r.type === 'TRANSFER' || r.status === 'possible_transfer');
const balanceDelta = useMemo(() => {
const byAccount = new Map<
string,
{ name: string; delta: number; type: Account['type'] }
>();
const byAccount = new Map<string, { name: string; delta: number; type: Account['type'] }>();
for (const row of selected) {
const acc = accounts.find((a) => a.id === accountId);
if (!acc) continue;
@@ -451,14 +439,11 @@ function UploadStep({
<>
<div className="space-y-4">
<p className="text-sm text-muted-foreground">
Upload a CSV, OFX, QFX, or PDF statement to bulk-import its transactions
into one of your accounts. Duplicates against your existing transactions
will be flagged for review.
Upload a CSV, OFX, QFX, or PDF statement to bulk-import its transactions into one of your
accounts. Duplicates against your existing transactions will be flagged for review.
</p>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
Import into account
</label>
<label className="text-xs font-medium text-muted-foreground">Import into account</label>
<Select value={accountId} onValueChange={(v) => onAccountChange(v ?? '')}>
<SelectTrigger>
<SelectValue placeholder="Select account">
@@ -498,10 +483,7 @@ function UploadStep({
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
<DialogFooter>
<Button
onClick={onSubmit}
disabled={!file || !accountId || working}
>
<Button onClick={onSubmit} disabled={!file || !accountId || working}>
{working ? 'Parsing…' : 'Continue'}
</Button>
</DialogFooter>
@@ -556,8 +538,8 @@ function MappingStep({
<>
<div className="space-y-3">
<p className="text-sm text-muted-foreground">
We couldn't auto-detect the columns in this file. Tell us which field
each column represents.
We couldn't auto-detect the columns in this file. Tell us which field each column
represents.
</p>
<div className="overflow-x-auto rounded-md border">
<Table>
@@ -580,8 +562,8 @@ function MappingStep({
<SelectTrigger className="min-w-[12rem]">
<SelectValue>
{(v: string | undefined) =>
MAPPING_FIELDS.find((f) => f.value === (v ?? 'ignore'))
?.label ?? 'Ignore'
MAPPING_FIELDS.find((f) => f.value === (v ?? 'ignore'))?.label ??
'Ignore'
}
</SelectValue>
</SelectTrigger>
@@ -595,7 +577,11 @@ function MappingStep({
</Select>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{sample.map((row) => row[i]).filter(Boolean).slice(0, 3).join(', ')}
{sample
.map((row) => row[i])
.filter(Boolean)
.slice(0, 3)
.join(', ')}
</TableCell>
</TableRow>
))}
@@ -654,8 +640,8 @@ function ReviewStep({
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-sm text-muted-foreground">
Review each row before import. Edit fields inline if needed; uncheck
anything you don't want to import.
Review each row before import. Edit fields inline if needed; uncheck anything you don't
want to import.
</p>
<div className="flex gap-2">
<Button size="sm" variant="outline" onClick={onIncludeAll}>
@@ -704,9 +690,7 @@ function ReviewStep({
type="checkbox"
aria-label={`Include row ${r.sourceIndex + 1}`}
checked={r.included}
onChange={(e) =>
onUpdateRow(r.sourceIndex, { included: e.target.checked })
}
onChange={(e) => onUpdateRow(r.sourceIndex, { included: e.target.checked })}
/>
</TableCell>
<TableCell>
@@ -714,18 +698,14 @@ function ReviewStep({
type="date"
className="h-8 w-[8.5rem]"
value={toDateInputValue(r.date)}
onChange={(e) =>
onUpdateRow(r.sourceIndex, { date: e.target.value })
}
onChange={(e) => onUpdateRow(r.sourceIndex, { date: e.target.value })}
/>
</TableCell>
<TableCell>
<Input
className="h-8 min-w-[12rem]"
value={r.description}
onChange={(e) =>
onUpdateRow(r.sourceIndex, { description: e.target.value })
}
onChange={(e) => onUpdateRow(r.sourceIndex, { description: e.target.value })}
/>
{r.duplicateOf && (
<p className="mt-1 text-xs text-muted-foreground">
@@ -735,8 +715,8 @@ function ReviewStep({
)}
{r.transferCandidate && !r.destinationAccountId && (
<p className="mt-1 text-xs text-sky-900">
Possible transfer with {r.transferCandidate.accountName}.
Select a destination to mark as transfer.
Possible transfer with {r.transferCandidate.accountName}. Select a
destination to mark as transfer.
</p>
)}
</TableCell>
@@ -762,9 +742,7 @@ function ReviewStep({
type: newType,
destinationAccountId:
newType === 'TRANSFER'
? (r.destinationAccountId ??
r.transferCandidate?.accountId ??
'')
? (r.destinationAccountId ?? r.transferCandidate?.accountId ?? '')
: undefined,
});
}}
@@ -772,8 +750,7 @@ function ReviewStep({
<SelectTrigger className="h-8 w-[7rem]">
<SelectValue>
{(v: string | undefined) =>
(v ?? 'EXPENSE').charAt(0) +
(v ?? 'EXPENSE').slice(1).toLowerCase()
(v ?? 'EXPENSE').charAt(0) + (v ?? 'EXPENSE').slice(1).toLowerCase()
}
</SelectValue>
</SelectTrigger>
@@ -816,9 +793,8 @@ function ReviewStep({
</Table>
</div>
<p className="text-xs text-muted-foreground">
{counts.selected} selected · {counts.duplicates} duplicates ·{' '}
{counts.needsReview} need review · {counts.possibleTransfers} possible
transfers
{counts.selected} selected · {counts.duplicates} duplicates · {counts.needsReview} need
review · {counts.possibleTransfers} possible transfers
</p>
</div>
<DialogFooter>
@@ -829,12 +805,7 @@ function ReviewStep({
onClick={onContinue}
disabled={
counts.selected === 0 ||
rows.some(
(r) =>
r.included &&
r.type === 'TRANSFER' &&
!r.destinationAccountId,
)
rows.some((r) => r.included && r.type === 'TRANSFER' && !r.destinationAccountId)
}
>
Continue to confirm <ArrowRight className="ml-1 size-4" />
@@ -911,11 +882,7 @@ function ConfirmStep({
<span>{info.name}</span>
<span>
{currency(current)} <ArrowRight className="inline size-3" />{' '}
<span
className={
info.delta >= 0 ? 'text-emerald-700' : 'text-red-700'
}
>
<span className={info.delta >= 0 ? 'text-emerald-700' : 'text-red-700'}>
{currency(projected)}
</span>
</span>
@@ -951,20 +918,15 @@ function ConfirmStep({
<TableRow key={r.sourceIndex}>
<TableCell>{formatDate(r.date)}</TableCell>
<TableCell>{r.description}</TableCell>
<TableCell className="text-right">
{currency(r.amount)}
</TableCell>
<TableCell>
{r.type.charAt(0) + r.type.slice(1).toLowerCase()}
</TableCell>
<TableCell className="text-right">{currency(r.amount)}</TableCell>
<TableCell>{r.type.charAt(0) + r.type.slice(1).toLowerCase()}</TableCell>
<TableCell>
{sourceAccount?.name ?? '—'}
{r.destinationAccountId && (
<>
{' '}
<ArrowRight className="inline size-3" />{' '}
{accounts.find((a) => a.id === r.destinationAccountId)
?.name ?? '—'}
{accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'}
</>
)}
</TableCell>
@@ -981,10 +943,7 @@ function ConfirmStep({
<Button variant="outline" onClick={onBack} disabled={submitting}>
Back to review
</Button>
<Button
onClick={onConfirm}
disabled={counts.selected === 0 || submitting}
>
<Button onClick={onConfirm} disabled={counts.selected === 0 || submitting}>
{submitting
? 'Importing…'
: `Import ${counts.selected} transaction${counts.selected === 1 ? '' : 's'}`}
@@ -404,8 +404,11 @@ export function Transactions() {
onOpenChange={setImportOpen}
defaultAccountId={filters.accountId}
onImported={({ created, skipped }) => {
const skippedNote = skipped > 0 ? ` Skipped ${skipped} duplicate${skipped === 1 ? '' : 's'}.` : '';
setImportToast(`Imported ${created} transaction${created === 1 ? '' : 's'}.${skippedNote}`);
const skippedNote =
skipped > 0 ? ` Skipped ${skipped} duplicate${skipped === 1 ? '' : 's'}.` : '';
setImportToast(
`Imported ${created} transaction${created === 1 ? '' : 's'}.${skippedNote}`,
);
fetchAccounts();
}}
/>
@@ -117,12 +117,10 @@ describe('useTransactionsStore', () => {
date: '2026-04-11',
},
];
const result = await useTransactionsStore
.getState()
.bulkCreateTransactions(rows, {
kind: 'statement-import',
label: 'chase-2026-04.csv',
});
const result = await useTransactionsStore.getState().bulkCreateTransactions(rows, {
kind: 'statement-import',
label: 'chase-2026-04.csv',
});
expect(mockApi.post).toHaveBeenCalledWith('/transactions/bulk', {
transactions: rows,
@@ -138,12 +136,10 @@ describe('useTransactionsStore', () => {
ids: Array.from({ length: 50 }, (_, i) => `id-${i}`),
partial: { attempted: 60, failed: 10, error: 'timeout' },
});
const result = await useTransactionsStore
.getState()
.bulkCreateTransactions([], {
kind: 'statement-import',
label: 'big.csv',
});
const result = await useTransactionsStore.getState().bulkCreateTransactions([], {
kind: 'statement-import',
label: 'big.csv',
});
expect(result.partial?.failed).toBe(10);
});
});