← back to AbramsEgo
lib/spend-reviews/adapters/csv.js
123 lines
'use strict';
/**
* CSV ingestion adapter — parse a bank/credit-card statement export into spend
* records. Merchant strings are messy ("SQ *BLUE BOTTLE OAKLAND CA", "AMZN
* Mktp US*2X4YZ"); we capture the raw description as `merchant` and let
* store.normalizeMerchant clean it downstream. Zero dependencies.
*
* interface: module.exports = { ingest }; ingest(opts) -> {ok, items, meta}
* opts.csv = raw CSV text (from an upload), OR
* (absent) = read every *.csv in data/csv-drop/
*/
const fs = require('fs');
const path = require('path');
const cfg = require('../config');
// --- tiny RFC-4180-ish CSV parser (quoted fields, embedded commas/newlines) ---
function parseCsv(text) {
const rows = [];
let row = [], field = '', i = 0, inQ = false;
const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
while (i < s.length) {
const c = s[i];
if (inQ) {
if (c === '"') { if (s[i + 1] === '"') { field += '"'; i += 2; continue; } inQ = false; i++; continue; }
field += c; i++; continue;
}
if (c === '"') { inQ = true; i++; continue; }
if (c === ',') { row.push(field); field = ''; i++; continue; }
if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
field += c; i++;
}
if (field.length || row.length) { row.push(field); rows.push(row); }
return rows.filter((r) => r.some((x) => (x || '').trim() !== ''));
}
function findCol(header, names) {
const lower = header.map((h) => (h || '').toLowerCase().trim());
for (const n of names) { const idx = lower.indexOf(n.toLowerCase()); if (idx >= 0) return idx; }
// loose contains-match fallback
for (const n of names) { const idx = lower.findIndex((h) => h.includes(n.toLowerCase())); if (idx >= 0) return idx; }
return -1;
}
function toISO(d) {
if (!d) return null;
const s = String(d).trim();
let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]}`;
m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
if (m) { let y = m[3]; if (y.length === 2) y = '20' + y; return `${y}-${String(m[1]).padStart(2, '0')}-${String(m[2]).padStart(2, '0')}`; }
const dt = new Date(s); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
}
function parseAmount(raw) {
if (raw == null) return NaN;
let s = String(raw).trim().replace(/[$,]/g, '');
let neg = false;
if (/^\(.*\)$/.test(s)) { neg = true; s = s.replace(/[()]/g, ''); }
const n = parseFloat(s);
if (!isFinite(n)) return NaN;
return neg ? -n : n;
}
function parseText(text, fname) {
const rows = parseCsv(text);
if (rows.length < 2) return { items: [], rows: rows.length, columns: null };
const header = rows[0];
const dateI = findCol(header, ['Transaction Date', 'Trans Date', 'Posted Date', 'Post Date', 'Date']);
const descI = findCol(header, ['Description', 'Merchant', 'Payee', 'Name', 'Details', 'Memo']);
const amtI = findCol(header, ['Amount', 'Transaction Amount', 'Debit', 'Charge']);
const creditI = findCol(header, ['Credit']);
const typeI = findCol(header, ['Type', 'Category', 'Transaction Type']);
const items = [];
for (let r = 1; r < rows.length; r++) {
const row = rows[r];
const desc = descI >= 0 ? (row[descI] || '').trim() : '';
if (!desc) continue;
let amt = amtI >= 0 ? parseAmount(row[amtI]) : NaN;
// split debit/credit columns: a value in the Credit column = money back → skip
if (creditI >= 0 && parseAmount(row[creditI]) > 0 && !(amt > 0)) continue;
if (!isFinite(amt)) continue;
// Convention normalization: many banks record purchases as NEGATIVE. Treat a
// negative as a purchase (flip to positive); a positive in an Amount-only file
// is also a purchase. A positive in a Debit column is a purchase. Skip clear
// payments/refunds by type/keyword.
const typ = typeI >= 0 ? (row[typeI] || '').toLowerCase() : '';
if (/payment|autopay|refund|return|interest|credit adjustment|reversal/.test(typ + ' ' + desc.toLowerCase())) continue;
let amount = amt < 0 ? -amt : amt;
if (amount <= 0) continue;
items.push({ merchant: desc, product: null, order_id: null, date: toISO(dateI >= 0 ? row[dateI] : null),
amount, currency: 'USD', url: null, raw: { file: fname || null, row } });
}
return { items, rows: rows.length, columns: { date: header[dateI], merchant: header[descI], amount: header[amtI] || header[creditI] } };
}
async function ingest(opts) {
try {
let sources = [];
if (opts && opts.csv && String(opts.csv).trim()) {
sources.push({ text: String(opts.csv), name: opts.filename || 'upload.csv' });
} else {
let files = [];
try { files = fs.readdirSync(cfg.CSV_DROP_DIR).filter((f) => /\.csv$/i.test(f)); } catch (e) {}
if (!files.length) {
return { ok: false, items: [], meta: { source: 'csv',
note: 'no CSV provided and data/csv-drop/ is empty — drop a statement export there or upload one',
dropDir: cfg.CSV_DROP_DIR } };
}
for (const f of files) sources.push({ text: fs.readFileSync(path.join(cfg.CSV_DROP_DIR, f), 'utf8'), name: f });
}
let items = [], rows = 0, columns = null, filesUsed = [];
for (const src of sources) {
const p = parseText(src.text, src.name);
items = items.concat(p.items); rows += p.rows; columns = columns || p.columns; filesUsed.push(src.name);
}
return { ok: true, items, meta: { source: 'csv', note: `parsed ${items.length} purchases from ${rows} rows`,
parsed: items.length, rows, columns, files: filesUsed } };
} catch (e) {
return { ok: false, items: [], meta: { source: 'csv', note: 'csv parse failed: ' + e.message } };
}
}
module.exports = { ingest, parseCsv, parseText };