2026-05-16 20:26:09 -05:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
import type { Db } from '../types/db';
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const crypto = require('crypto');
|
refactor(server): migrate middleware, workers, and db layer to TypeScript (.cts)
- middleware/ (5): requireAuth, securityHeaders, errorFormatter, csrf,
rateLimiter — fully typed against the shared http Req/Res/Next types.
- workers/dailyWorker — fully typed.
- db/ (4): database, subscriptionCatalogSeed, migrations/versionedMigrations,
migrations/legacyReconcileMigrations — large dynamic schema/migration infra,
converted with @ts-nocheck (typing deferred). All requires updated to .cts.
Behavior-preserving. Verified: typecheck:server 0, check:server 0, suite 226/226.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 11:34:08 -05:00
|
|
|
|
const { getDb } = require('../db/database.cts');
|
2026-07-06 10:54:10 -05:00
|
|
|
|
const { decorateTransaction, ensureManualDataSource } = require('./transactionService.cts');
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
type Row = Record<string, any>;
|
|
|
|
|
|
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
|
|
|
|
|
|
const MAX_ROWS = 25000;
|
|
|
|
|
|
const SAMPLE_SIZE = 10;
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const FIELD_LABELS: Record<string, string> = {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
posted_date: 'Posted date',
|
|
|
|
|
|
transacted_at: 'Transaction date/time',
|
|
|
|
|
|
amount: 'Amount',
|
|
|
|
|
|
debit_amount: 'Debit amount',
|
|
|
|
|
|
credit_amount: 'Credit amount',
|
|
|
|
|
|
description: 'Description',
|
|
|
|
|
|
payee: 'Payee',
|
|
|
|
|
|
memo: 'Memo',
|
|
|
|
|
|
category: 'Category',
|
|
|
|
|
|
account: 'Account',
|
|
|
|
|
|
transaction_id: 'Transaction ID',
|
|
|
|
|
|
transaction_type: 'Transaction type',
|
|
|
|
|
|
currency: 'Currency',
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function importError(status: number, message: string, code: string, details: any[] = []): Error {
|
|
|
|
|
|
const err = new Error(message) as any;
|
2026-05-16 20:26:09 -05:00
|
|
|
|
err.status = status;
|
|
|
|
|
|
err.code = code;
|
|
|
|
|
|
err.details = details;
|
|
|
|
|
|
return err;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function makeSessionId(): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return crypto.randomBytes(16).toString('hex');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function cleanFilename(value: any): string | null {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return value
|
2026-07-06 14:23:53 -05:00
|
|
|
|
? String(value)
|
|
|
|
|
|
.replace(/[^a-zA-Z0-9._\-\s]/g, '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.slice(0, 255)
|
2026-05-16 20:26:09 -05:00
|
|
|
|
: null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function normalizeHeader(value: any, index: number): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const text = String(value ?? '').trim();
|
|
|
|
|
|
return text || `Column ${index + 1}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseCsv(text: string): any[] {
|
|
|
|
|
|
const rows: any[] = [];
|
|
|
|
|
|
let row: any[] = [];
|
2026-05-16 20:26:09 -05:00
|
|
|
|
let cell = '';
|
|
|
|
|
|
let inQuotes = false;
|
|
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
|
|
|
|
const ch = text[i];
|
|
|
|
|
|
const next = text[i + 1];
|
|
|
|
|
|
|
|
|
|
|
|
if (inQuotes) {
|
|
|
|
|
|
if (ch === '"' && next === '"') {
|
|
|
|
|
|
cell += '"';
|
|
|
|
|
|
i++;
|
|
|
|
|
|
} else if (ch === '"') {
|
|
|
|
|
|
inQuotes = false;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cell += ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (ch === '"') {
|
|
|
|
|
|
inQuotes = true;
|
|
|
|
|
|
} else if (ch === ',') {
|
|
|
|
|
|
row.push(cell);
|
|
|
|
|
|
cell = '';
|
|
|
|
|
|
} else if (ch === '\n') {
|
|
|
|
|
|
row.push(cell);
|
|
|
|
|
|
rows.push(row);
|
|
|
|
|
|
row = [];
|
|
|
|
|
|
cell = '';
|
|
|
|
|
|
} else if (ch === '\r') {
|
|
|
|
|
|
if (next === '\n') continue;
|
|
|
|
|
|
row.push(cell);
|
|
|
|
|
|
rows.push(row);
|
|
|
|
|
|
row = [];
|
|
|
|
|
|
cell = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
cell += ch;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (inQuotes) {
|
|
|
|
|
|
throw importError(400, 'CSV has an unterminated quoted field.', 'CSV_PARSE_ERROR');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (cell !== '' || row.length > 0) {
|
|
|
|
|
|
row.push(cell);
|
|
|
|
|
|
rows.push(row);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
return rows.filter((r: any[]) => r.some((c: any) => String(c ?? '').trim() !== ''));
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function csvBufferToText(buffer: any): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
|
|
|
|
throw importError(400, 'CSV file is required.', 'CSV_REQUIRED');
|
|
|
|
|
|
}
|
2026-07-06 11:20:29 -05:00
|
|
|
|
return buffer.toString('utf8').replace(/^/, '');
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function rowToObject(headers: string[], row: any[]): Row {
|
|
|
|
|
|
const out: Row = {};
|
|
|
|
|
|
headers.forEach((header: string, index: number) => {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
out[header] = String(row[index] ?? '').trim();
|
|
|
|
|
|
});
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function normalizeHeaderToken(value: any): string {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return String(value || '')
|
|
|
|
|
|
.toLowerCase()
|
|
|
|
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
|
|
|
|
.trim();
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function headerMatches(header: any, patterns: any[]): boolean {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const token = normalizeHeaderToken(header);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return patterns.some((pattern: any) =>
|
|
|
|
|
|
pattern instanceof RegExp ? pattern.test(token) : token === pattern,
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function suggestMapping(headers: string[]): Row {
|
|
|
|
|
|
const mapping: Row = {};
|
|
|
|
|
|
const candidates: [string, any[]][] = [
|
2026-07-06 14:23:53 -05:00
|
|
|
|
[
|
|
|
|
|
|
'posted_date',
|
|
|
|
|
|
[/^date$/, 'posted date', 'post date', 'posting date', 'transaction date', 'trans date'],
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
'transacted_at',
|
|
|
|
|
|
['authorized date', 'authorization date', 'datetime', 'date time', 'timestamp'],
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
'transaction_id',
|
|
|
|
|
|
[
|
|
|
|
|
|
'transaction id',
|
|
|
|
|
|
'transaction number',
|
|
|
|
|
|
'trans id',
|
|
|
|
|
|
'id',
|
|
|
|
|
|
'fitid',
|
|
|
|
|
|
'reference',
|
|
|
|
|
|
'reference number',
|
|
|
|
|
|
],
|
|
|
|
|
|
],
|
|
|
|
|
|
[
|
|
|
|
|
|
'description',
|
|
|
|
|
|
['description', 'transaction description', 'name', 'details', 'details description'],
|
|
|
|
|
|
],
|
2026-05-16 20:26:09 -05:00
|
|
|
|
['payee', ['payee', 'merchant', 'merchant name', 'vendor', 'name']],
|
|
|
|
|
|
['memo', ['memo', 'notes', 'note']],
|
|
|
|
|
|
['amount', [/^amount$/, 'transaction amount', 'net amount']],
|
|
|
|
|
|
['debit_amount', ['debit', 'debits', 'withdrawal', 'withdrawals', 'charge', 'charges']],
|
|
|
|
|
|
['credit_amount', ['credit', 'credits', 'deposit', 'deposits']],
|
|
|
|
|
|
['category', ['category', 'transaction category']],
|
|
|
|
|
|
['account', ['account', 'account name', 'account number']],
|
|
|
|
|
|
['transaction_type', ['type', 'transaction type']],
|
|
|
|
|
|
['currency', ['currency', 'currency code']],
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
for (const [field, patterns] of candidates) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const found = headers.find(
|
|
|
|
|
|
(header: string) =>
|
|
|
|
|
|
!Object.values(mapping).includes(header) && headerMatches(header, patterns),
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (found) mapping[field] = found;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!mapping.payee && mapping.description) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const payee = headers.find(
|
|
|
|
|
|
(header: string) =>
|
|
|
|
|
|
header !== mapping.description && headerMatches(header, ['name', 'merchant name']),
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (payee) mapping.payee = payee;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return mapping;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseCsvPreview(buffer: any, options: any = {}): any {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const text = csvBufferToText(buffer);
|
|
|
|
|
|
const parsed = parseCsv(text);
|
|
|
|
|
|
if (parsed.length < 2) {
|
|
|
|
|
|
throw importError(400, 'CSV must include a header row and at least one data row.', 'CSV_EMPTY');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const headers = parsed[0].map(normalizeHeader);
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const seenHeaders = new Set<string>();
|
|
|
|
|
|
const duplicateHeaders: any[] = [];
|
2026-05-16 20:26:09 -05:00
|
|
|
|
for (const header of headers) {
|
|
|
|
|
|
const key = header.toLowerCase();
|
|
|
|
|
|
if (seenHeaders.has(key)) duplicateHeaders.push(header);
|
|
|
|
|
|
seenHeaders.add(key);
|
|
|
|
|
|
}
|
|
|
|
|
|
if (duplicateHeaders.length > 0) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
`CSV contains duplicate headers: ${duplicateHeaders.join(', ')}`,
|
|
|
|
|
|
'CSV_DUPLICATE_HEADERS',
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const dataRows = parsed
|
|
|
|
|
|
.slice(1)
|
|
|
|
|
|
.slice(0, MAX_ROWS)
|
|
|
|
|
|
.map((row: any[]) => rowToObject(headers, row));
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const truncated = parsed.length - 1 > MAX_ROWS;
|
|
|
|
|
|
const suggestedMapping = suggestMapping(headers);
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const errors: any[] = [];
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
|
|
if (!suggestedMapping.posted_date) {
|
|
|
|
|
|
errors.push({ field: 'posted_date', message: 'Could not detect a posted date column.' });
|
|
|
|
|
|
}
|
2026-07-06 14:23:53 -05:00
|
|
|
|
if (
|
|
|
|
|
|
!suggestedMapping.amount &&
|
|
|
|
|
|
!(suggestedMapping.debit_amount || suggestedMapping.credit_amount)
|
|
|
|
|
|
) {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
errors.push({ field: 'amount', message: 'Could not detect an amount column.' });
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!suggestedMapping.description && !suggestedMapping.payee && !suggestedMapping.memo) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
errors.push({
|
|
|
|
|
|
field: 'description',
|
|
|
|
|
|
message: 'No description, payee, or memo column was detected. Dedupe will be less useful.',
|
|
|
|
|
|
});
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
if (truncated) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
errors.push({
|
|
|
|
|
|
field: 'file',
|
|
|
|
|
|
message: `Only the first ${MAX_ROWS} rows will be imported from this CSV.`,
|
|
|
|
|
|
});
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
headers,
|
|
|
|
|
|
rows: dataRows,
|
|
|
|
|
|
rowCount: dataRows.length,
|
|
|
|
|
|
sampleRows: dataRows.slice(0, SAMPLE_SIZE),
|
|
|
|
|
|
suggestedMapping,
|
|
|
|
|
|
errors,
|
|
|
|
|
|
original_filename: cleanFilename(options.original_filename),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function pruneExpiredSessions(db: Db): void {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
db.prepare('DELETE FROM import_sessions WHERE expires_at <= ?').run(new Date().toISOString());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function saveImportSession(db: Db, userId: number, sessionData: any): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const id = makeSessionId();
|
|
|
|
|
|
const now = new Date().toISOString();
|
|
|
|
|
|
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
|
2026-07-06 14:23:53 -05:00
|
|
|
|
db.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, ?)
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
).run(id, userId, now, expiresAt, JSON.stringify(sessionData));
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function loadImportSession(db: Db, userId: number, sessionId: any): any {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const row = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
SELECT preview_json
|
|
|
|
|
|
FROM import_sessions
|
|
|
|
|
|
WHERE id = ? AND user_id = ? AND expires_at > ?
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.get(sessionId, userId, new Date().toISOString());
|
2026-05-16 20:26:09 -05:00
|
|
|
|
|
|
|
|
|
|
if (!row) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
404,
|
|
|
|
|
|
'CSV import session not found or expired. Please re-upload the file.',
|
|
|
|
|
|
'CSV_SESSION_NOT_FOUND',
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
return JSON.parse(row.preview_json);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function deleteImportSession(db: Db, sessionId: any): void {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
db.prepare('DELETE FROM import_sessions WHERE id = ?').run(sessionId);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function previewCsvTransactions(userId: number, buffer: any, options: any = {}): any {
|
|
|
|
|
|
const db: Db = getDb();
|
2026-05-16 20:26:09 -05:00
|
|
|
|
pruneExpiredSessions(db);
|
|
|
|
|
|
const preview = parseCsvPreview(buffer, options);
|
|
|
|
|
|
const sessionId = saveImportSession(db, userId, {
|
|
|
|
|
|
kind: 'csv_transactions',
|
|
|
|
|
|
...preview,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
import_session_id: sessionId,
|
|
|
|
|
|
headers: preview.headers,
|
|
|
|
|
|
sampleRows: preview.sampleRows,
|
|
|
|
|
|
rowCount: preview.rowCount,
|
|
|
|
|
|
suggestedMapping: preview.suggestedMapping,
|
|
|
|
|
|
errors: preview.errors,
|
|
|
|
|
|
fields: FIELD_LABELS,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function headerValue(row: Row, mapping: any, field: string): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const header = mapping?.[field];
|
|
|
|
|
|
if (!header) return '';
|
|
|
|
|
|
return String(row[header] ?? '').trim();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseDateValue(value: any, field: string, rowNumber: number): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const text = String(value || '').trim();
|
|
|
|
|
|
if (!text) {
|
|
|
|
|
|
throw importError(400, `${FIELD_LABELS[field] || field} is required`, 'CSV_ROW_VALIDATION', [
|
|
|
|
|
|
{ row: rowNumber, field, message: `${FIELD_LABELS[field] || field} is required` },
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const iso = /^(\d{4})-(\d{1,2})-(\d{1,2})/.exec(text);
|
|
|
|
|
|
if (iso) {
|
|
|
|
|
|
const normalized = `${iso[1]}-${iso[2].padStart(2, '0')}-${iso[3].padStart(2, '0')}`;
|
|
|
|
|
|
if (isRealDate(normalized)) return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const slash = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/.exec(text);
|
|
|
|
|
|
if (slash) {
|
|
|
|
|
|
const year = slash[3].length === 2 ? `20${slash[3]}` : slash[3];
|
|
|
|
|
|
const normalized = `${year}-${slash[1].padStart(2, '0')}-${slash[2].padStart(2, '0')}`;
|
|
|
|
|
|
if (isRealDate(normalized)) return normalized;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
`${FIELD_LABELS[field] || field} must be a valid date`,
|
|
|
|
|
|
'CSV_ROW_VALIDATION',
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
row: rowNumber,
|
|
|
|
|
|
field,
|
|
|
|
|
|
value: text,
|
|
|
|
|
|
message: `${FIELD_LABELS[field] || field} must be YYYY-MM-DD or MM/DD/YYYY`,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function isRealDate(value: any): boolean {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const [year, month, day] = String(value).split('-').map(Number);
|
|
|
|
|
|
const date = new Date(Date.UTC(year, month - 1, day));
|
2026-07-06 14:23:53 -05:00
|
|
|
|
return (
|
|
|
|
|
|
date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseCents(value: any, { negative = false }: { negative?: boolean } = {}): number | null {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const text = String(value ?? '').trim();
|
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
const parenNegative = /^\(.*\)$/.test(text);
|
|
|
|
|
|
const cleaned = text.replace(/[,$\s]/g, '').replace(/^\((.*)\)$/, '$1');
|
|
|
|
|
|
if (!/^[+-]?\d+(?:\.\d{1,4})?$/.test(cleaned)) return null;
|
|
|
|
|
|
const number = Number(cleaned);
|
|
|
|
|
|
if (!Number.isFinite(number)) return null;
|
|
|
|
|
|
const explicitNegative = cleaned.startsWith('-') || parenNegative;
|
|
|
|
|
|
const sign = explicitNegative || negative ? -1 : 1;
|
|
|
|
|
|
return Math.round(Math.abs(number) * 100) * sign;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseMappedAmount(row: Row, mapping: any): number | null {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const amount = parseCents(headerValue(row, mapping, 'amount'));
|
|
|
|
|
|
if (amount !== null) return amount;
|
|
|
|
|
|
|
|
|
|
|
|
const debit = parseCents(headerValue(row, mapping, 'debit_amount'), { negative: true });
|
|
|
|
|
|
if (debit !== null) return debit;
|
|
|
|
|
|
|
|
|
|
|
|
const credit = parseCents(headerValue(row, mapping, 'credit_amount'));
|
|
|
|
|
|
if (credit !== null) return credit;
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function stableHash(parts: any[]): string {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return crypto
|
|
|
|
|
|
.createHash('sha256')
|
2026-07-06 14:23:53 -05:00
|
|
|
|
.update(
|
|
|
|
|
|
parts
|
|
|
|
|
|
.map((part: any) =>
|
|
|
|
|
|
String(part || '')
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.toLowerCase(),
|
|
|
|
|
|
)
|
|
|
|
|
|
.join(''),
|
|
|
|
|
|
)
|
2026-05-16 20:26:09 -05:00
|
|
|
|
.digest('hex')
|
|
|
|
|
|
.slice(0, 48);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function getOrCreateCsvDataSource(db: Db, userId: number): Row {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
ensureManualDataSource(db, userId);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const existing = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
SELECT *
|
|
|
|
|
|
FROM data_sources
|
|
|
|
|
|
WHERE user_id = ? AND type = 'file_import' AND provider = 'csv' AND name = 'CSV Import'
|
|
|
|
|
|
ORDER BY id ASC
|
|
|
|
|
|
LIMIT 1
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.get(userId);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const result = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
INSERT INTO data_sources (user_id, type, provider, name, status)
|
|
|
|
|
|
VALUES (?, 'file_import', 'csv', 'CSV Import', 'active')
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.run(userId);
|
|
|
|
|
|
return db
|
|
|
|
|
|
.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?')
|
|
|
|
|
|
.get(result.lastInsertRowid, userId);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function getOrCreateAccount(db: Db, userId: number, dataSourceId: any, name: any): Row | null {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const accountName = String(name || '').trim();
|
|
|
|
|
|
if (!accountName) return null;
|
|
|
|
|
|
const providerAccountId = stableHash([accountName]).slice(0, 32);
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const existing = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
SELECT *
|
|
|
|
|
|
FROM financial_accounts
|
|
|
|
|
|
WHERE user_id = ? AND data_source_id = ? AND provider_account_id = ?
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.get(userId, dataSourceId, providerAccountId);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (existing) return existing;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const result = db
|
|
|
|
|
|
.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
INSERT INTO financial_accounts
|
|
|
|
|
|
(user_id, data_source_id, provider_account_id, name, account_type, currency)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, 'csv', 'USD')
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
)
|
|
|
|
|
|
.run(userId, dataSourceId, providerAccountId, accountName);
|
|
|
|
|
|
return db
|
|
|
|
|
|
.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ?')
|
|
|
|
|
|
.get(result.lastInsertRowid, userId);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function validateMapping(headers: any[], mapping: any = {}): void {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const headerSet = new Set(headers);
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const required: any[] = [];
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (!mapping.posted_date) required.push('posted_date');
|
|
|
|
|
|
if (!mapping.amount && !(mapping.debit_amount || mapping.credit_amount)) required.push('amount');
|
|
|
|
|
|
if (required.length) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
`Missing required mapping: ${required.map((f: string) => FIELD_LABELS[f] || f).join(', ')}`,
|
|
|
|
|
|
'CSV_MAPPING_REQUIRED',
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for (const [field, header] of Object.entries(mapping)) {
|
|
|
|
|
|
if (!FIELD_LABELS[field]) {
|
|
|
|
|
|
throw importError(400, `Unsupported mapping field: ${field}`, 'CSV_MAPPING_INVALID');
|
|
|
|
|
|
}
|
|
|
|
|
|
if (header && !headerSet.has(header)) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
`Mapped column "${header}" for ${field} was not found in the CSV headers.`,
|
|
|
|
|
|
'CSV_MAPPING_INVALID',
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function parseOptionalDateTimeValue(value: any, field: string, rowNumber: number): string | null {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const text = String(value || '').trim();
|
|
|
|
|
|
if (!text) return null;
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const match =
|
|
|
|
|
|
/^(\d{4}-\d{2}-\d{2})(?:[T ]\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.exec(
|
|
|
|
|
|
text,
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
if (!match || !isRealDate(match[1])) {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
`${FIELD_LABELS[field] || field} must be a valid ISO date or date-time`,
|
|
|
|
|
|
'CSV_ROW_VALIDATION',
|
|
|
|
|
|
[
|
|
|
|
|
|
{
|
|
|
|
|
|
row: rowNumber,
|
|
|
|
|
|
field,
|
|
|
|
|
|
value: text,
|
|
|
|
|
|
message: `${FIELD_LABELS[field] || field} must be YYYY-MM-DD or an ISO date-time`,
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
return text;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 11:20:29 -05:00
|
|
|
|
function normalizeCsvTransaction(row: Row, mapping: any, rowNumber: number): Row {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
const postedDate = parseDateValue(
|
|
|
|
|
|
headerValue(row, mapping, 'posted_date'),
|
|
|
|
|
|
'posted_date',
|
|
|
|
|
|
rowNumber,
|
|
|
|
|
|
);
|
|
|
|
|
|
const transactedAt = parseOptionalDateTimeValue(
|
|
|
|
|
|
headerValue(row, mapping, 'transacted_at'),
|
|
|
|
|
|
'transacted_at',
|
|
|
|
|
|
rowNumber,
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const amount = parseMappedAmount(row, mapping);
|
|
|
|
|
|
if (!Number.isSafeInteger(amount) || amount === 0) {
|
|
|
|
|
|
throw importError(400, 'Amount must be a non-zero number.', 'CSV_ROW_VALIDATION', [
|
|
|
|
|
|
{ row: rowNumber, field: 'amount', message: 'Amount must be a non-zero number.' },
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const description = headerValue(row, mapping, 'description');
|
|
|
|
|
|
const payee = headerValue(row, mapping, 'payee');
|
|
|
|
|
|
const memo = headerValue(row, mapping, 'memo');
|
|
|
|
|
|
const accountName = headerValue(row, mapping, 'account');
|
|
|
|
|
|
const transactionId = headerValue(row, mapping, 'transaction_id');
|
|
|
|
|
|
const providerTransactionId = transactionId
|
|
|
|
|
|
? `csv:id:${transactionId}`
|
|
|
|
|
|
: `csv:hash:${stableHash([postedDate, amount, description, payee, accountName])}`;
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
provider_transaction_id: providerTransactionId,
|
|
|
|
|
|
transaction_type: headerValue(row, mapping, 'transaction_type') || null,
|
|
|
|
|
|
posted_date: postedDate,
|
|
|
|
|
|
transacted_at: transactedAt,
|
|
|
|
|
|
amount,
|
|
|
|
|
|
currency: headerValue(row, mapping, 'currency') || 'USD',
|
|
|
|
|
|
description: description || payee || memo || null,
|
|
|
|
|
|
payee: payee || null,
|
|
|
|
|
|
memo: memo || null,
|
|
|
|
|
|
category: headerValue(row, mapping, 'category') || null,
|
|
|
|
|
|
account_name: accountName || null,
|
|
|
|
|
|
raw_data: JSON.stringify(row),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
function commitCsvTransactions(
|
|
|
|
|
|
userId: number,
|
|
|
|
|
|
importSessionId: any,
|
|
|
|
|
|
mapping: any,
|
|
|
|
|
|
options: any = {},
|
|
|
|
|
|
): any {
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const db: Db = getDb();
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const session = loadImportSession(db, userId, importSessionId);
|
|
|
|
|
|
if (session.kind !== 'csv_transactions') {
|
2026-07-06 14:23:53 -05:00
|
|
|
|
throw importError(
|
|
|
|
|
|
400,
|
|
|
|
|
|
'Import session is not a CSV transaction preview.',
|
|
|
|
|
|
'CSV_SESSION_INVALID',
|
|
|
|
|
|
);
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
validateMapping(session.headers, mapping);
|
|
|
|
|
|
const dataSource = getOrCreateCsvDataSource(db, userId);
|
2026-07-06 11:20:29 -05:00
|
|
|
|
const details: any[] = [];
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const counts = { imported: 0, skipped: 0, failed: 0 };
|
|
|
|
|
|
const insert = db.prepare(`
|
|
|
|
|
|
INSERT INTO transactions
|
|
|
|
|
|
(user_id, data_source_id, account_id, provider_transaction_id, source_type,
|
|
|
|
|
|
transaction_type, posted_date, transacted_at, amount, currency, description,
|
|
|
|
|
|
payee, memo, category, raw_data, match_status, ignored)
|
|
|
|
|
|
VALUES (?, ?, ?, ?, 'file_import', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'unmatched', 0)
|
|
|
|
|
|
`);
|
|
|
|
|
|
const existing = db.prepare(`
|
|
|
|
|
|
SELECT id
|
|
|
|
|
|
FROM transactions
|
|
|
|
|
|
WHERE user_id = ? AND data_source_id = ? AND provider_transaction_id = ?
|
|
|
|
|
|
`);
|
|
|
|
|
|
|
|
|
|
|
|
const run = db.transaction(() => {
|
2026-07-06 11:20:29 -05:00
|
|
|
|
session.rows.forEach((row: Row, index: number) => {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
const rowNumber = index + 2;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const tx = normalizeCsvTransaction(row, mapping, rowNumber);
|
|
|
|
|
|
if (existing.get(userId, dataSource.id, tx.provider_transaction_id)) {
|
|
|
|
|
|
counts.skipped++;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
details.push({
|
|
|
|
|
|
row: rowNumber,
|
|
|
|
|
|
result: 'skipped_duplicate',
|
|
|
|
|
|
provider_transaction_id: tx.provider_transaction_id,
|
|
|
|
|
|
});
|
2026-05-16 20:26:09 -05:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const account = getOrCreateAccount(db, userId, dataSource.id, tx.account_name);
|
|
|
|
|
|
const result = insert.run(
|
|
|
|
|
|
userId,
|
|
|
|
|
|
dataSource.id,
|
|
|
|
|
|
account?.id ?? null,
|
|
|
|
|
|
tx.provider_transaction_id,
|
|
|
|
|
|
tx.transaction_type,
|
|
|
|
|
|
tx.posted_date,
|
|
|
|
|
|
tx.transacted_at,
|
|
|
|
|
|
tx.amount,
|
|
|
|
|
|
tx.currency,
|
|
|
|
|
|
tx.description,
|
|
|
|
|
|
tx.payee,
|
|
|
|
|
|
tx.memo,
|
|
|
|
|
|
tx.category,
|
|
|
|
|
|
tx.raw_data,
|
|
|
|
|
|
);
|
|
|
|
|
|
counts.imported++;
|
|
|
|
|
|
details.push({
|
|
|
|
|
|
row: rowNumber,
|
|
|
|
|
|
result: 'imported',
|
|
|
|
|
|
transaction: decorateTransaction({
|
|
|
|
|
|
...tx,
|
|
|
|
|
|
id: result.lastInsertRowid,
|
|
|
|
|
|
user_id: userId,
|
|
|
|
|
|
data_source_id: dataSource.id,
|
|
|
|
|
|
source_type: 'file_import',
|
|
|
|
|
|
data_source_type: dataSource.type,
|
|
|
|
|
|
data_source_provider: dataSource.provider,
|
|
|
|
|
|
data_source_name: dataSource.name,
|
|
|
|
|
|
data_source_status: dataSource.status,
|
|
|
|
|
|
account_id: account?.id ?? null,
|
|
|
|
|
|
account_name: account?.name ?? null,
|
|
|
|
|
|
match_status: 'unmatched',
|
|
|
|
|
|
ignored: 0,
|
|
|
|
|
|
}),
|
|
|
|
|
|
});
|
2026-07-06 11:20:29 -05:00
|
|
|
|
} catch (err: any) {
|
2026-05-16 20:26:09 -05:00
|
|
|
|
counts.failed++;
|
2026-07-06 14:23:53 -05:00
|
|
|
|
details.push({
|
|
|
|
|
|
row: rowNumber,
|
|
|
|
|
|
result: 'failed',
|
|
|
|
|
|
message: err.message,
|
|
|
|
|
|
details: err.details || [],
|
|
|
|
|
|
});
|
2026-05-16 20:26:09 -05:00
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-07-06 14:23:53 -05:00
|
|
|
|
db.prepare(
|
|
|
|
|
|
`
|
2026-05-16 20:26:09 -05:00
|
|
|
|
INSERT INTO import_history (
|
|
|
|
|
|
user_id, imported_at, source_filename, file_type, sheet_name,
|
|
|
|
|
|
rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous,
|
|
|
|
|
|
rows_errored, options_json, summary_json
|
|
|
|
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
2026-07-06 14:23:53 -05:00
|
|
|
|
`,
|
|
|
|
|
|
).run(
|
2026-05-16 20:26:09 -05:00
|
|
|
|
userId,
|
|
|
|
|
|
new Date().toISOString(),
|
|
|
|
|
|
session.original_filename,
|
|
|
|
|
|
'csv_transactions',
|
|
|
|
|
|
null,
|
|
|
|
|
|
session.rows.length,
|
|
|
|
|
|
counts.imported,
|
|
|
|
|
|
0,
|
|
|
|
|
|
counts.skipped,
|
|
|
|
|
|
0,
|
|
|
|
|
|
counts.failed,
|
|
|
|
|
|
JSON.stringify({ mapping, options }),
|
|
|
|
|
|
JSON.stringify(details.slice(0, 500)),
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
run();
|
|
|
|
|
|
deleteImportSession(db, importSessionId);
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
success: true,
|
|
|
|
|
|
imported: counts.imported,
|
|
|
|
|
|
skipped: counts.skipped,
|
|
|
|
|
|
failed: counts.failed,
|
|
|
|
|
|
details,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = {
|
|
|
|
|
|
FIELD_LABELS,
|
|
|
|
|
|
commitCsvTransactions,
|
|
|
|
|
|
previewCsvTransactions,
|
feat(import): OFX/QFX transaction import (Batch 3)
New services/ofxImportService.js parses OFX 1.x (SGML, unclosed leaf tags),
OFX 2.x (XML) and QFX (+ Intuit tags ignored) into the same normalized shape the
CSV path produces, then writes through the SAME shared primitives (session table,
(user_id, data_source_id, provider_transaction_id) dedupe, import_history) — now
exported from csvTransactionImportService (additive; CSV tests still pass).
- Routes POST /api/import/ofx/{preview,commit} mirror the CSV two-step (raw
upload → structured commit; no column mapping since OFX is structured).
- UI: ImportOfxSection (upload → preview list → import) in the Import pane;
amounts shown via formatCentsUSD; toasts on preview/commit/malformed.
- Gap handling: signed TRNAMT → signed cents; DTPOSTED → YYYY-MM-DD; FITID →
stable provider id (hash fallback); non-OFX / empty files rejected clearly.
Tests: tests/ofxImportService.test.js (SGML + XML/QFX parse, entity decode,
signed cents, preview→commit, re-import dedupe, import_history). Server 129 pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 15:11:59 -05:00
|
|
|
|
saveImportSession,
|
|
|
|
|
|
loadImportSession,
|
|
|
|
|
|
deleteImportSession,
|
|
|
|
|
|
pruneExpiredSessions,
|
|
|
|
|
|
getOrCreateAccount,
|
|
|
|
|
|
stableHash,
|
|
|
|
|
|
parseCents,
|
2026-05-16 20:26:09 -05:00
|
|
|
|
};
|