diff --git a/tehriehlbudget-backend/src/statements/dto/parse-statement.dto.ts b/tehriehlbudget-backend/src/statements/dto/parse-statement.dto.ts index 910b043..8bfca42 100644 --- a/tehriehlbudget-backend/src/statements/dto/parse-statement.dto.ts +++ b/tehriehlbudget-backend/src/statements/dto/parse-statement.dto.ts @@ -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; } diff --git a/tehriehlbudget-backend/src/statements/duplicate-detector.service.spec.ts b/tehriehlbudget-backend/src/statements/duplicate-detector.service.spec.ts index bf33dea..8b595c1 100644 --- a/tehriehlbudget-backend/src/statements/duplicate-detector.service.spec.ts +++ b/tehriehlbudget-backend/src/statements/duplicate-detector.service.spec.ts @@ -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 => ({ @@ -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'); diff --git a/tehriehlbudget-backend/src/statements/duplicate-detector.service.ts b/tehriehlbudget-backend/src/statements/duplicate-detector.service.ts index f971ec2..62ce49d 100644 --- a/tehriehlbudget-backend/src/statements/duplicate-detector.service.ts +++ b/tehriehlbudget-backend/src/statements/duplicate-detector.service.ts @@ -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); diff --git a/tehriehlbudget-backend/src/statements/parsers/column-mapping.spec.ts b/tehriehlbudget-backend/src/statements/parsers/column-mapping.spec.ts index 343f262..6483f75 100644 --- a/tehriehlbudget-backend/src/statements/parsers/column-mapping.spec.ts +++ b/tehriehlbudget-backend/src/statements/parsers/column-mapping.spec.ts @@ -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', diff --git a/tehriehlbudget-backend/src/statements/parsers/column-mapping.ts b/tehriehlbudget-backend/src/statements/parsers/column-mapping.ts index 17743b7..34b7585 100644 --- a/tehriehlbudget-backend/src/statements/parsers/column-mapping.ts +++ b/tehriehlbudget-backend/src/statements/parsers/column-mapping.ts @@ -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; } diff --git a/tehriehlbudget-backend/src/statements/parsers/csv.parser.spec.ts b/tehriehlbudget-backend/src/statements/parsers/csv.parser.spec.ts index a96896f..2f22a29 100644 --- a/tehriehlbudget-backend/src/statements/parsers/csv.parser.spec.ts +++ b/tehriehlbudget-backend/src/statements/parsers/csv.parser.spec.ts @@ -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 }, diff --git a/tehriehlbudget-backend/src/statements/parsers/csv.parser.ts b/tehriehlbudget-backend/src/statements/parsers/csv.parser.ts index e8c2920..76a07fd 100644 --- a/tehriehlbudget-backend/src/statements/parsers/csv.parser.ts +++ b/tehriehlbudget-backend/src/statements/parsers/csv.parser.ts @@ -197,10 +197,17 @@ export class CsvParser implements StatementParser { return false; } - async parse( - file: ParserFileInput, - options: ParseOptions, - ): Promise { + parse(file: ParserFileInput, options: ParseOptions): Promise { + 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] : ''; diff --git a/tehriehlbudget-backend/src/statements/parsers/ofx.parser.spec.ts b/tehriehlbudget-backend/src/statements/parsers/ofx.parser.spec.ts index a3d3186..64edf83 100644 --- a/tehriehlbudget-backend/src/statements/parsers/ofx.parser.spec.ts +++ b/tehriehlbudget-backend/src/statements/parsers/ofx.parser.spec.ts @@ -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 { diff --git a/tehriehlbudget-backend/src/statements/parsers/ofx.parser.ts b/tehriehlbudget-backend/src/statements/parsers/ofx.parser.ts index 93d42d1..dd5af69 100644 --- a/tehriehlbudget-backend/src/statements/parsers/ofx.parser.ts +++ b/tehriehlbudget-backend/src/statements/parsers/ofx.parser.ts @@ -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(value: T | T[] | undefined): T[] { @@ -18,7 +24,7 @@ function asArray(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 { + parse(file: ParserFileInput, options: ParseOptions): Promise { + 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, }); diff --git a/tehriehlbudget-backend/src/statements/parsers/pdf.parser.ts b/tehriehlbudget-backend/src/statements/parsers/pdf.parser.ts index cc40f39..053d9f0 100644 --- a/tehriehlbudget-backend/src/statements/parsers/pdf.parser.ts +++ b/tehriehlbudget-backend/src/statements/parsers/pdf.parser.ts @@ -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 = { - 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 } - | null = null; +export type PdfTextExtractor = (buffer: Buffer) => Promise; -async function loadPdfParser(): Promise<{ - parse: (buffer: Buffer) => Promise; -}> { - 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 } | 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 { - 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) { diff --git a/tehriehlbudget-backend/src/statements/statements.controller.spec.ts b/tehriehlbudget-backend/src/statements/statements.controller.spec.ts index 07d82ed..f38c058 100644 --- a/tehriehlbudget-backend/src/statements/statements.controller.spec.ts +++ b/tehriehlbudget-backend/src/statements/statements.controller.spec.ts @@ -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); }); diff --git a/tehriehlbudget-backend/src/transactions/transactions.controller.ts b/tehriehlbudget-backend/src/transactions/transactions.controller.ts index 0625acc..edd8a7c 100644 --- a/tehriehlbudget-backend/src/transactions/transactions.controller.ts +++ b/tehriehlbudget-backend/src/transactions/transactions.controller.ts @@ -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, diff --git a/tehriehlbudget-backend/src/transactions/transactions.service.spec.ts b/tehriehlbudget-backend/src/transactions/transactions.service.spec.ts index 0ca6b93..7df2cc2 100644 --- a/tehriehlbudget-backend/src/transactions/transactions.service.spec.ts +++ b/tehriehlbudget-backend/src/transactions/transactions.service.spec.ts @@ -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/, + ), }), ); }); diff --git a/tehriehlbudget-backend/src/types/node-ofx-parser.d.ts b/tehriehlbudget-backend/src/types/node-ofx-parser.d.ts new file mode 100644 index 0000000..fc26512 --- /dev/null +++ b/tehriehlbudget-backend/src/types/node-ofx-parser.d.ts @@ -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; + } + + export function parse(data: string): OfxParsed; + export function serialize( + header: Record, + body: unknown, + ): string; +} diff --git a/tehriehlbudget-frontend/src/components/ImportStatementDialog.test.tsx b/tehriehlbudget-frontend/src/components/ImportStatementDialog.test.tsx index c9169f2..0ce73a3 100644 --- a/tehriehlbudget-frontend/src/components/ImportStatementDialog.test.tsx +++ b/tehriehlbudget-frontend/src/components/ImportStatementDialog.test.tsx @@ -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( - , + , ); } 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()); }); }); diff --git a/tehriehlbudget-frontend/src/components/ImportStatementDialog.tsx b/tehriehlbudget-frontend/src/components/ImportStatementDialog.tsx index 1d536d4..77db299 100644 --- a/tehriehlbudget-frontend/src/components/ImportStatementDialog.tsx +++ b/tehriehlbudget-frontend/src/components/ImportStatementDialog.tsx @@ -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) => { - 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(); for (const row of selected) { const acc = accounts.find((a) => a.id === accountId); if (!acc) continue; @@ -451,14 +439,11 @@ function UploadStep({ <>

- 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.

- + - {sample.map((row) => row[i]).filter(Boolean).slice(0, 3).join(', ')} + {sample + .map((row) => row[i]) + .filter(Boolean) + .slice(0, 3) + .join(', ')} ))} @@ -654,8 +640,8 @@ function ReviewStep({

- 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.

- {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

@@ -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 @@ -911,11 +882,7 @@ function ConfirmStep({ {info.name} {currency(current)} {' '} - = 0 ? 'text-emerald-700' : 'text-red-700' - } - > + = 0 ? 'text-emerald-700' : 'text-red-700'}> {currency(projected)} @@ -951,20 +918,15 @@ function ConfirmStep({ {formatDate(r.date)} {r.description} - - {currency(r.amount)} - - - {r.type.charAt(0) + r.type.slice(1).toLowerCase()} - + {currency(r.amount)} + {r.type.charAt(0) + r.type.slice(1).toLowerCase()} {sourceAccount?.name ?? '—'} {r.destinationAccountId && ( <> {' '} {' '} - {accounts.find((a) => a.id === r.destinationAccountId) - ?.name ?? '—'} + {accounts.find((a) => a.id === r.destinationAccountId)?.name ?? '—'} )} @@ -981,10 +943,7 @@ function ConfirmStep({ -