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
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:
@@ -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/,
|
||||
),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user