BillTracker/services/csvTransactionImportService...

568 lines
19 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use strict';
import type { Db } from '../types/db';
const crypto = require('crypto');
const { getDb } = require('../db/database.cts');
const { decorateTransaction, ensureManualDataSource } = require('./transactionService.cts');
type Row = Record<string, any>;
const SESSION_TTL_MS = 24 * 60 * 60 * 1000;
const MAX_ROWS = 25000;
const SAMPLE_SIZE = 10;
const FIELD_LABELS: Record<string, string> = {
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',
};
function importError(status: number, message: string, code: string, details: any[] = []): Error {
const err = new Error(message) as any;
err.status = status;
err.code = code;
err.details = details;
return err;
}
function makeSessionId(): string {
return crypto.randomBytes(16).toString('hex');
}
function cleanFilename(value: any): string | null {
return value
? String(value).replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255)
: null;
}
function normalizeHeader(value: any, index: number): string {
const text = String(value ?? '').trim();
return text || `Column ${index + 1}`;
}
function parseCsv(text: string): any[] {
const rows: any[] = [];
let row: any[] = [];
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);
}
return rows.filter((r: any[]) => r.some((c: any) => String(c ?? '').trim() !== ''));
}
function csvBufferToText(buffer: any): string {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw importError(400, 'CSV file is required.', 'CSV_REQUIRED');
}
return buffer.toString('utf8').replace(/^/, '');
}
function rowToObject(headers: string[], row: any[]): Row {
const out: Row = {};
headers.forEach((header: string, index: number) => {
out[header] = String(row[index] ?? '').trim();
});
return out;
}
function normalizeHeaderToken(value: any): string {
return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
}
function headerMatches(header: any, patterns: any[]): boolean {
const token = normalizeHeaderToken(header);
return patterns.some((pattern: any) => (
pattern instanceof RegExp ? pattern.test(token) : token === pattern
));
}
function suggestMapping(headers: string[]): Row {
const mapping: Row = {};
const candidates: [string, any[]][] = [
['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']],
['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) {
const found = headers.find((header: string) => !Object.values(mapping).includes(header) && headerMatches(header, patterns));
if (found) mapping[field] = found;
}
if (!mapping.payee && mapping.description) {
const payee = headers.find((header: string) => header !== mapping.description && headerMatches(header, ['name', 'merchant name']));
if (payee) mapping.payee = payee;
}
return mapping;
}
function parseCsvPreview(buffer: any, options: any = {}): any {
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);
const seenHeaders = new Set<string>();
const duplicateHeaders: any[] = [];
for (const header of headers) {
const key = header.toLowerCase();
if (seenHeaders.has(key)) duplicateHeaders.push(header);
seenHeaders.add(key);
}
if (duplicateHeaders.length > 0) {
throw importError(400, `CSV contains duplicate headers: ${duplicateHeaders.join(', ')}`, 'CSV_DUPLICATE_HEADERS');
}
const dataRows = parsed.slice(1).slice(0, MAX_ROWS).map((row: any[]) => rowToObject(headers, row));
const truncated = parsed.length - 1 > MAX_ROWS;
const suggestedMapping = suggestMapping(headers);
const errors: any[] = [];
if (!suggestedMapping.posted_date) {
errors.push({ field: 'posted_date', message: 'Could not detect a posted date column.' });
}
if (!suggestedMapping.amount && !(suggestedMapping.debit_amount || suggestedMapping.credit_amount)) {
errors.push({ field: 'amount', message: 'Could not detect an amount column.' });
}
if (!suggestedMapping.description && !suggestedMapping.payee && !suggestedMapping.memo) {
errors.push({ field: 'description', message: 'No description, payee, or memo column was detected. Dedupe will be less useful.' });
}
if (truncated) {
errors.push({ field: 'file', message: `Only the first ${MAX_ROWS} rows will be imported from this CSV.` });
}
return {
headers,
rows: dataRows,
rowCount: dataRows.length,
sampleRows: dataRows.slice(0, SAMPLE_SIZE),
suggestedMapping,
errors,
original_filename: cleanFilename(options.original_filename),
};
}
function pruneExpiredSessions(db: Db): void {
db.prepare('DELETE FROM import_sessions WHERE expires_at <= ?').run(new Date().toISOString());
}
function saveImportSession(db: Db, userId: number, sessionData: any): string {
const id = makeSessionId();
const now = new Date().toISOString();
const expiresAt = new Date(Date.now() + SESSION_TTL_MS).toISOString();
db.prepare(`
INSERT INTO import_sessions (id, user_id, created_at, expires_at, preview_json)
VALUES (?, ?, ?, ?, ?)
`).run(id, userId, now, expiresAt, JSON.stringify(sessionData));
return id;
}
function loadImportSession(db: Db, userId: number, sessionId: any): any {
const row = db.prepare(`
SELECT preview_json
FROM import_sessions
WHERE id = ? AND user_id = ? AND expires_at > ?
`).get(sessionId, userId, new Date().toISOString());
if (!row) {
throw importError(404, 'CSV import session not found or expired. Please re-upload the file.', 'CSV_SESSION_NOT_FOUND');
}
return JSON.parse(row.preview_json);
}
function deleteImportSession(db: Db, sessionId: any): void {
db.prepare('DELETE FROM import_sessions WHERE id = ?').run(sessionId);
}
function previewCsvTransactions(userId: number, buffer: any, options: any = {}): any {
const db: Db = getDb();
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,
};
}
function headerValue(row: Row, mapping: any, field: string): string {
const header = mapping?.[field];
if (!header) return '';
return String(row[header] ?? '').trim();
}
function parseDateValue(value: any, field: string, rowNumber: number): string {
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;
}
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` },
]);
}
function isRealDate(value: any): boolean {
const [year, month, day] = String(value).split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
return date.getUTCFullYear() === year
&& date.getUTCMonth() === month - 1
&& date.getUTCDate() === day;
}
function parseCents(value: any, { negative = false }: { negative?: boolean } = {}): number | null {
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;
}
function parseMappedAmount(row: Row, mapping: any): number | null {
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;
}
function stableHash(parts: any[]): string {
return crypto
.createHash('sha256')
.update(parts.map((part: any) => String(part || '').trim().toLowerCase()).join(''))
.digest('hex')
.slice(0, 48);
}
function getOrCreateCsvDataSource(db: Db, userId: number): Row {
ensureManualDataSource(db, userId);
const existing = db.prepare(`
SELECT *
FROM data_sources
WHERE user_id = ? AND type = 'file_import' AND provider = 'csv' AND name = 'CSV Import'
ORDER BY id ASC
LIMIT 1
`).get(userId);
if (existing) return existing;
const result = db.prepare(`
INSERT INTO data_sources (user_id, type, provider, name, status)
VALUES (?, 'file_import', 'csv', 'CSV Import', 'active')
`).run(userId);
return db.prepare('SELECT * FROM data_sources WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId);
}
function getOrCreateAccount(db: Db, userId: number, dataSourceId: any, name: any): Row | null {
const accountName = String(name || '').trim();
if (!accountName) return null;
const providerAccountId = stableHash([accountName]).slice(0, 32);
const existing = db.prepare(`
SELECT *
FROM financial_accounts
WHERE user_id = ? AND data_source_id = ? AND provider_account_id = ?
`).get(userId, dataSourceId, providerAccountId);
if (existing) return existing;
const result = db.prepare(`
INSERT INTO financial_accounts
(user_id, data_source_id, provider_account_id, name, account_type, currency)
VALUES (?, ?, ?, ?, 'csv', 'USD')
`).run(userId, dataSourceId, providerAccountId, accountName);
return db.prepare('SELECT * FROM financial_accounts WHERE id = ? AND user_id = ?').get(result.lastInsertRowid, userId);
}
function validateMapping(headers: any[], mapping: any = {}): void {
const headerSet = new Set(headers);
const required: any[] = [];
if (!mapping.posted_date) required.push('posted_date');
if (!mapping.amount && !(mapping.debit_amount || mapping.credit_amount)) required.push('amount');
if (required.length) {
throw importError(400, `Missing required mapping: ${required.map((f: string) => FIELD_LABELS[f] || f).join(', ')}`, 'CSV_MAPPING_REQUIRED');
}
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)) {
throw importError(400, `Mapped column "${header}" for ${field} was not found in the CSV headers.`, 'CSV_MAPPING_INVALID');
}
}
}
function parseOptionalDateTimeValue(value: any, field: string, rowNumber: number): string | null {
const text = String(value || '').trim();
if (!text) return null;
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);
if (!match || !isRealDate(match[1])) {
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` },
]);
}
return text;
}
function normalizeCsvTransaction(row: Row, mapping: any, rowNumber: number): Row {
const postedDate = parseDateValue(headerValue(row, mapping, 'posted_date'), 'posted_date', rowNumber);
const transactedAt = parseOptionalDateTimeValue(headerValue(row, mapping, 'transacted_at'), 'transacted_at', rowNumber);
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),
};
}
function commitCsvTransactions(userId: number, importSessionId: any, mapping: any, options: any = {}): any {
const db: Db = getDb();
const session = loadImportSession(db, userId, importSessionId);
if (session.kind !== 'csv_transactions') {
throw importError(400, 'Import session is not a CSV transaction preview.', 'CSV_SESSION_INVALID');
}
validateMapping(session.headers, mapping);
const dataSource = getOrCreateCsvDataSource(db, userId);
const details: any[] = [];
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(() => {
session.rows.forEach((row: Row, index: number) => {
const rowNumber = index + 2;
try {
const tx = normalizeCsvTransaction(row, mapping, rowNumber);
if (existing.get(userId, dataSource.id, tx.provider_transaction_id)) {
counts.skipped++;
details.push({ row: rowNumber, result: 'skipped_duplicate', provider_transaction_id: tx.provider_transaction_id });
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,
}),
});
} catch (err: any) {
counts.failed++;
details.push({ row: rowNumber, result: 'failed', message: err.message, details: err.details || [] });
}
});
db.prepare(`
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
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,
saveImportSession,
loadImportSession,
deleteImportSession,
pruneExpiredSessions,
getOrCreateAccount,
stableHash,
parseCents,
};