← back to AbramsOS
lib/csv-parser.js
150 lines
// Minimal statement-style CSV parser.
// Supports: any CSV with a header row that includes (case-insensitive)
// date column — one of: date, transaction date, posted date, post date
// amount column — one of: amount, debit, charge, total
// merchant col — one of: description, merchant, payee, memo, name
//
// Returns { rows: [{ date, merchant, amount, raw }], skipped: N, errors: [] }.
// Robust to:
// - quoted fields with commas inside
// - $ / commas in amounts
// - negative amounts (refunds — caller can filter)
// - blank lines
function parseLine(line) {
// Tiny CSV tokenizer that respects double-quoted fields with embedded commas + ""
const fields = [];
let cur = '';
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const c = line[i];
if (inQuotes) {
if (c === '"' && line[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') { inQuotes = false; }
else cur += c;
} else {
if (c === '"') inQuotes = true;
else if (c === ',') { fields.push(cur); cur = ''; }
else cur += c;
}
}
fields.push(cur);
return fields;
}
const DATE_HEADERS = ['date', 'transaction date', 'posted date', 'post date', 'trans date', 'transaction_date'];
const AMOUNT_HEADERS = ['amount', 'debit', 'charge', 'total', 'transaction amount', 'amt'];
const MERCHANT_HEADERS = ['description', 'merchant', 'payee', 'memo', 'name', 'details', 'particulars'];
function findColumn(header, candidates) {
const lc = header.map(h => h.trim().toLowerCase());
for (const cand of candidates) {
const i = lc.indexOf(cand);
if (i >= 0) return i;
}
return -1;
}
function parseAmount(s) {
if (s == null) return null;
const cleaned = String(s).replace(/[$,\s]/g, '').replace(/[()]/g, (c) => c === '(' ? '-' : '');
if (!cleaned || cleaned === '-' || isNaN(Number(cleaned))) return null;
return Number(cleaned);
}
function parseDate(s) {
if (!s) return null;
const t = Date.parse(s);
if (!isNaN(t)) return new Date(t);
// Common US "MM/DD/YYYY" / "MM-DD-YYYY"
const m = String(s).match(/^(\d{1,2})[-\/](\d{1,2})[-\/](\d{2,4})$/);
if (m) {
let [, mo, d, y] = m;
if (y.length === 2) y = (Number(y) > 70 ? '19' : '20') + y;
const t2 = Date.parse(`${y}-${mo.padStart(2,'0')}-${d.padStart(2,'0')}`);
if (!isNaN(t2)) return new Date(t2);
}
return null;
}
/**
* @param csv raw CSV string
* @returns { rows, skipped, errors, columns }
*/
function parseStatement(csv) {
const lines = String(csv || '')
.replace(/\r\n/g, '\n')
.split('\n')
.filter(l => l.trim().length > 0);
if (lines.length < 2) {
return { rows: [], skipped: 0, errors: ['empty or header-only csv'], columns: null };
}
const header = parseLine(lines[0]);
let dateIdx = findColumn(header, DATE_HEADERS);
let amountIdx = findColumn(header, AMOUNT_HEADERS);
let merchantIdx = findColumn(header, MERCHANT_HEADERS);
let startRow = 1;
// 'positive' = a debit/charge is a positive number (typical header CSVs).
// 'negative' = money out is a negative number (Wells Fargo / Quicken headerless export).
let signMode = 'positive';
// Headerless fallback (Wells Fargo etc.): first line is already data — field 0 is a
// date and some later field is numeric. Money-out is negative in these exports.
if (dateIdx < 0 || amountIdx < 0) {
const f0 = parseLine(lines[0]);
if (parseDate(f0[0]) && f0.some((v, i) => i > 0 && parseAmount(v) != null)) {
dateIdx = 0;
amountIdx = f0.findIndex((v, i) => i > 0 && parseAmount(v) != null);
// description = the longest mostly-alphabetic field
let best = -1, bestLen = 0;
f0.forEach((v, i) => { const t = String(v || '').trim(); if (/[a-z]/i.test(t) && t.length > bestLen) { best = i; bestLen = t.length; } });
merchantIdx = best;
startRow = 0; // no header row to skip
signMode = 'negative'; // WF/Quicken: purchases are negative
} else {
return {
rows: [], skipped: 0,
errors: [`missing required columns (date=${dateIdx}, amount=${amountIdx}); found: ${header.join(', ')}`],
columns: { dateIdx, amountIdx, merchantIdx },
};
}
}
const rows = [];
const errors = [];
let skipped = 0;
for (let i = startRow; i < lines.length; i++) {
const f = parseLine(lines[i]);
const date = parseDate(f[dateIdx]);
let amount = parseAmount(f[amountIdx]);
if (!date || amount == null) {
skipped += 1;
if (errors.length < 5) errors.push(`line ${i + 1}: bad row (date=${f[dateIdx]}, amount=${f[amountIdx]})`);
continue;
}
// Normalize to positive spend; drop inflows (deposits/refunds/payments).
if (signMode === 'negative') {
if (amount >= 0) { skipped += 1; continue; } // deposit/credit
amount = Math.abs(amount);
} else if (amount <= 0) {
skipped += 1;
continue;
}
const merchantRaw = merchantIdx >= 0 ? (f[merchantIdx] || '').trim() : '';
const merchant = merchantRaw.replace(/\s+/g, ' ').slice(0, 200) || 'Unknown';
rows.push({
date,
amount,
merchant,
raw: { line: i + 1, fields: f, header: startRow ? header : null },
});
}
return { rows, skipped, errors, columns: { dateIdx, amountIdx, merchantIdx, signMode } };
}
module.exports = { parseStatement, parseLine, parseAmount, parseDate };