kua-money-trace/web/engine.js

362 lines
18 KiB
JavaScript

/* money-trace · data engine
Loads ledger.json, normalizes counterparties, categorizes spend,
resolves internal-flow endpoints, and builds a layered node/link graph.
Exposes window.MT. Pure vanilla JS — no deps. */
(function () {
'use strict';
// ---------- formatting ----------
const CLP = n => '$' + Math.round(n).toLocaleString('es-CL');
const CLPk = n => {
const a = Math.abs(n);
if (a >= 1e6) return '$' + (n / 1e6).toLocaleString('es-CL', { maximumFractionDigits: 1 }) + 'M';
if (a >= 1e3) return '$' + Math.round(n / 1e3).toLocaleString('es-CL') + 'k';
return CLP(n);
};
const MONTH_LABEL = ym => {
const [y, m] = ym.split('-');
const names = ['Ene', 'Feb', 'Mar', 'Abr', 'May', 'Jun', 'Jul', 'Ago', 'Sep', 'Oct', 'Nov', 'Dic'];
return names[+m - 1] + " '" + y.slice(2);
};
// ---------- flow categories (visual spine) ----------
const CAT = {
income: { key: 'income', kind: 'real', label: 'Income' },
inter_in: { key: 'inter_in', kind: 'real', label: 'From a person' },
inter_out: { key: 'inter_out', kind: 'real', label: 'To a person' },
expense: { key: 'expense', kind: 'real', label: 'Spending' },
fee: { key: 'fee', kind: 'real', label: 'Fees & interest' },
internal_card: { key: 'internal_card', kind: 'internal', label: 'Card payment' },
internal_line: { key: 'internal_line', kind: 'internal', label: 'Credit-line sweep' },
internal_self: { key: 'internal_self', kind: 'internal', label: 'Between my accounts' }
};
// ---------- counterparty normalization (income + people) ----------
function normIncome(cp) {
if (!cp) return 'Other income';
const u = cp.toUpperCase();
if (u.includes('VINOS LABERINTO') || u.includes('LABERINTO')) return 'Vinos Laberinto';
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
if (u.includes('MURALLA')) return 'Muralla SPA';
if (u.startsWith('SDE ') || u.includes('MERCADO PAGO') || u.includes('MERCADOPAGO')) return 'Deposits & checks';
if (u.includes('EMA BLANCA') || u.includes('EMA MULLER')) return 'Ema Muller';
if (u.includes('RETAMAL')) return 'Bryan Retamal';
if (u.includes('MARCELINO')) return 'Marcelino Fuentes';
// title-case fallback, trimmed
return titleCase(cp);
}
function normPerson(cp) {
if (!cp) return 'Unknown person';
const u = cp.toUpperCase();
if (u.includes('VICENTE') && u.includes('TIRAD')) return 'Vicente (self)';
if (u.includes('MURALLA')) return 'Muralla SPA';
if (u.includes('FINTOC')) return 'Fintoc';
if (u.includes('ARISMENDI')) return 'Cristian Arismendi';
if (u.includes('BRUNA')) return 'Darwin Bruna';
if (u.includes('HEIDI') || u.includes('ANDRESEN MULLER HEIDI')) return 'Heidi Andresen';
if (u.includes('BUSTOS')) return 'G. Bustos';
if (u === 'RUT' || u.includes('MI CUENTA')) return 'Other transfer';
return titleCase(cp);
}
function titleCase(s) {
return String(s).toLowerCase().replace(/\b\w/g, c => c.toUpperCase()).trim().slice(0, 26);
}
// ---------- spend categorization (expense has no counterparty) ----------
const SPEND_RULES = [
['Debt & loans', /DEUDA|CIERRE DE CUENTA|REPACTAC|PRESTAMO|AVANCE (EN |DE )?EFECTIVO|CUOTA CREDITO/i],
['Cash withdrawals', /RETIRO ATM|RETIRO (DE )?EFECTIVO|GIRO (EN )?CAJERO|RETIRO CAJERO|CARGA DE TRANSFERENCIA/i],
['Utilities & bills',/SERVIPAG|SENCILLITO|\bENEL\b|COMPRAQUI|AGUAS ANDINA|METROGAS|GASCO|CHILECTRA|ESVAL|PAGO DE CUENTA|CUENTA DE LUZ|VTR|MOVISTAR|ENTEL|CLARO|WOM/i],
['Groceries', /SANTA ISABEL|STA ISABEL|JUMBO|\bLIDER\b|HIP LIDER|TOTTUS|UNIMARC|OXXO|MINIMARKET|MAYORISTA|NORTE VERDE|COMERCIAL|ABARROTE|MERCADO\b/i],
['Food & dining', /KFC|MC ?DONALD|BURGER|FORK|DELICIA|RESTAU|CABROS|NARESH|TENDERINO|PIZZA|SUSHI|CAFE|DONDE|COMIDA|EMPANAD|MACKENNA|SENLE|FABRICA|CALETA|IRIS|UBER ?EATS|OISHII|RAPPI|PEDIDOSYA/i],
['Fuel & transport',/COPEC|SHELL|PETROBRAS|PETROBA|\bUBER\b|CABIFY|DIDI|METRO\b|\bBIP\b|ESTACION|PEAJE|PARKING|AUTOPISTA/i],
['Health & pharmacy',/FARMACIA|CRUZ VERDE|SALCOBRAND|AHUMADA|NUTRAFIT|CLINICA|DENTAL|DENTIMAGEN|MEDIC|ULTRA SOLIDAR/i],
['Subscriptions & web',/HETZNER|SITEGROUND|GOOGLE|SPOTIFY|NETFLIX|APPLE\.COM|OPENAI|ANTHROPIC|MICROSOFT|\bAWS\b|HOSTING|GODADDY|NAMECHEAP|CLOUD|GITHUB|VERCEL|NOTION|FIGMA|ADOBE/i],
['Shopping & retail',/FALABELLA|RIPLEY|PARIS|ALIEXPRESS|AMAZON|FOTOGRAFO|TIENDA|SODIMAC|EASY|SHEIN|LOKAL|ELECTRONICA|BACKSTAGE|RETAIL|\bDP\b/i],
['Government & docs',/LICENCIA|IMPUESTO|REGISTRO CIVIL|MUNICIPAL|TESORERIA|NOTARIA|PERMISO/i],
['Wallets & online', /MERCADO ?PAGO|MERPAGO|\bMACH\b|MERCADOPAGO|WEBPAY|\bPAYU\b|\bTUU\b|SUMUP|PAYSCAN|COMPRA POR INTERNET|COMPRA NACIONAL|TARJETA (DIGITAL|VIRTUAL)|\bMP\b|\bCV\b|CUOTAS|ONECLICK|KUSHKI|YAPO|TARJETA ·/i]
];
function spendCategory(desc) {
const d = desc || '';
for (const [name, re] of SPEND_RULES) if (re.test(d)) return name;
return 'Other purchases';
}
// clean a merchant/desc for tooltip display
function cleanDesc(desc) {
if (!desc) return '—';
return desc.replace(/^(MERPAGO|MERCADOPAGO|PAYSCAN|SUMUP|TUU|MACH)\*?/i, '').trim() || desc;
}
// ---------- bank display ----------
function bankName(b) {
return ({
'Tarjeta_Spin': 'Spin', 'Tarjeta Spin': 'Spin',
'Banco_de_Chile': 'Banco de Chile', 'Ita': 'Itaú'
})[b] || b;
}
const isCardType = dt => dt === 'tarjeta_credito' || dt === 'linea_credito';
function acctShort(dt) {
return ({ cuenta_corriente: 'Cta Cte', cuenta_vista: 'Cta Vista', linea_credito: 'Línea de crédito', tarjeta_credito: 'Tarjeta' })[dt] || dt;
}
// ---------- load ----------
let RAW = null, TX = [], MONTHS = [], BANKS = [], ACCT_LAST4 = new Set();
async function load() {
const res = await fetch('ledger.json');
RAW = await res.json();
TX = [];
for (const s of RAW.statements) {
for (const t of (s.transactions || [])) {
if (!t.date) continue;
TX.push({
date: t.date,
ym: t.date.slice(0, 7),
amount: t.amount,
direction: t.direction,
description: t.description || '',
counterparty: t.counterparty || null,
counterparty_rut: t.counterparty_rut || null,
platform: t.platform || null,
balance: (t.balance === 0 || t.balance == null) ? null : t.balance,
flow_type: t.flow_type,
internal: !!t.internal,
bank: bankName(s.bank),
rawBank: s.bank,
doc_type: s.doc_type,
last4: s.account_last4 || null,
pdf: s.pdf_url || null
});
if (s.account_last4) ACCT_LAST4.add(s.account_last4);
}
}
// Reclassify transactions that the raw parser tagged as 'expense' but are
// really internal movements — debt repayments to cards (the purchases already
// happened on those cards; only interest/fees are new costs) and known
// card-payment intermediaries (MACH→Servipag for Rappi card, etc.)
const DEBT_RE = /TRASPASO\s+(A\s+DEUDA|POR\s+CIERRE\s+DE\s+CUENTA|DE\s+DEUDA\b|DEUDA\s*(NACIONAL|INTERNACIONAL)?$)/i;
const CPAY_RE = /PAGO\s+EN\s+SERVIPAG\.COM|COMPRA\s+MACH\s+COMERCIOS.*SERVIPAG/i;
TX = TX.map(t => {
if (t.flow_type !== 'expense') return t;
if (DEBT_RE.test(t.description) || CPAY_RE.test(t.description)) {
return { ...t, flow_type: 'card_payment', internal: true };
}
return t;
});
MONTHS = [...new Set(TX.map(t => t.ym))].sort();
BANKS = [...new Set(TX.map(t => t.bank))].sort();
return { months: MONTHS, banks: BANKS, totals: RAW.real_totals };
}
// node id helpers
const acctId = t => 'acc:' + t.bank + ':' + (t.last4 || t.doc_type);
const cardId = t => 'card:' + t.bank + ':' + (t.last4 || t.doc_type);
function acctLabel(t) { return { id: acctId(t), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
function cardLabel(t) { return { id: cardId(t), col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: acctShort(t.doc_type) + (t.last4 ? ' ··' + t.last4 : '') }; }
// find a same-bank node from the live node map, preferring a real (non-synthetic) one
function pickSameBank(nodes, bank, col) {
let synth = null;
for (const n of nodes.values()) {
if (n.bank === bank && n.col === col) {
if (!/:(pago|linea|cta)$/.test(n.id)) return n;
synth = synth || n;
}
}
return synth;
}
// parse trailing account digits, match to my known last4 set
function matchOwnAccount(desc, exclude) {
const digits = (desc.match(/\d{3,}/g) || []);
for (const d of digits) {
for (const l4 of ACCT_LAST4) {
if (l4 && l4 !== exclude && d.endsWith(l4)) return l4;
}
}
return null;
}
// ---------- build graph for a filter set ----------
function build(opts) {
opts = opts || {};
const months = opts.months ? new Set(opts.months) : null; // null = all
const banks = opts.banks ? new Set(opts.banks) : null;
const hideInternal = !!opts.hideInternal;
let nodes = new Map(); // id -> node
let links = new Map(); // key -> link
const ensure = (desc) => { if (!nodes.has(desc.id)) nodes.set(desc.id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, desc)); return nodes.get(desc.id); };
function addLink(srcId, tgtId, amount, cat, tx) {
const key = srcId + '→' + tgtId + '#' + cat;
let L = links.get(key);
if (!L) { L = { source: srcId, target: tgtId, value: 0, cat, kind: CAT[cat].kind, txns: [] }; links.set(key, L); }
L.value += amount; L.txns.push(tx);
}
for (const t of TX) {
if (months && !months.has(t.ym)) continue;
if (banks && !banks.has(t.bank)) continue;
const ft = t.flow_type;
const internal = ['self_transfer', 'credit_line', 'card_payment'].includes(ft);
if (hideInternal && internal) continue;
const isCard = isCardType(t.doc_type);
const selfNodeDesc = isCard ? cardLabel(t) : acctLabel(t);
if (ft === 'income') {
const src = ensure({ id: 'inc:' + normIncome(t.counterparty), col: 0, kind: 'source', label: normIncome(t.counterparty), sub: 'income' });
const acc = ensure(selfNodeDesc);
addLink(src.id, acc.id, t.amount, 'income', t);
} else if (ft === 'inter_person') {
const person = { id: 'per:' + normPerson(t.counterparty), kind: 'person' };
if (t.direction === 'credit') {
const src = ensure({ id: person.id, col: 0, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer in' });
const acc = ensure(selfNodeDesc);
addLink(src.id, acc.id, t.amount, 'inter_in', t);
} else {
const acc = ensure(selfNodeDesc);
const tgt = ensure({ id: person.id, col: 3, kind: 'person', label: normPerson(t.counterparty), sub: 'transfer out' });
addLink(acc.id, tgt.id, t.amount, 'inter_out', t);
}
} else if (ft === 'expense') {
const acc = ensure(selfNodeDesc);
const cat = spendCategory(t.description);
const tgt = ensure({ id: 'spend:' + cat, col: 3, kind: 'spend', label: cat, sub: 'spending' });
addLink(acc.id, tgt.id, t.amount, 'expense', t);
} else if (ft === 'fee') {
const acc = ensure(selfNodeDesc);
const tgt = ensure({ id: 'spend:Fees & interest', col: 3, kind: 'fee', label: 'Fees & interest', sub: 'bank fees' });
addLink(acc.id, tgt.id, t.amount, 'fee', t);
} else if (ft === 'card_payment') {
// resolve account (col1) -> card (col2), within bank where possible
let accDesc, cardDesc;
if (isCard) {
cardDesc = cardLabel(t);
const peer = pickSameBank(nodes, t.bank, 1);
accDesc = peer || { id: 'acc:' + t.bank + ':pago', col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: 'account' };
} else {
accDesc = acctLabel(t);
const peer = pickSameBank(nodes, t.bank, 2);
cardDesc = peer || { id: 'card:' + t.bank + ':pago', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Tarjeta' };
}
const a = ensure(accDesc), c = ensure(cardDesc);
addLink(a.id, c.id, t.amount, 'internal_card', t);
} else if (ft === 'credit_line') {
// cuenta (col1) <-> linea (col2)
let accDesc, lineDesc;
if (t.doc_type === 'linea_credito') {
lineDesc = cardLabel(t);
const l4 = matchOwnAccount(t.description, t.last4);
accDesc = pickSameBank(nodes, t.bank, 1) || { id: 'acc:' + t.bank + ':' + (l4 || 'cta'), col: 1, kind: 'account', bank: t.bank, label: t.bank, sub: l4 ? 'Cta ··' + l4 : 'Cta Cte' };
} else {
accDesc = acctLabel(t);
lineDesc = pickSameBank(nodes, t.bank, 2) || { id: 'card:' + t.bank + ':linea', col: 2, kind: 'card', bank: t.bank, label: t.bank, sub: 'Línea de crédito' };
}
const a = ensure(accDesc), l = ensure(lineDesc);
addLink(a.id, l.id, t.amount, 'internal_line', t);
} else if (ft === 'self_transfer') {
const anchor = ensure(acctLabel(t));
const l4 = matchOwnAccount(t.description, t.last4);
let other = anchor;
if (l4) {
let found = null;
for (const n of nodes.values()) if (n.kind === 'account' && n.id.endsWith(':' + l4)) { found = n; break; }
other = found || ensure({ id: 'acc:own:' + l4, col: 1, kind: 'account', bank: 'My account', label: 'My account', sub: '··' + l4 });
}
// unmatched transfers loop back on the same account (visible self-cycle)
if (t.direction === 'debit') addLink(anchor.id, other.id, t.amount, 'internal_self', t);
else addLink(other.id, anchor.id, t.amount, 'internal_self', t);
}
}
// ---- collapse small tail nodes (col0 sources/people, col3 people) into "Other" ----
const COLLAPSE_T = 220000;
const prelim = {};
for (const L of links.values()) {
(prelim[L.source] = prelim[L.source] || { in: 0, out: 0 }).out += L.value;
(prelim[L.target] = prelim[L.target] || { in: 0, out: 0 }).in += L.value;
}
const idMap = {}; const otherDescs = {};
const OTHER_ACC = { id: 'acc:Other accounts', col: 1, kind: 'account', bank: 'Other', label: 'Other accounts', sub: 'unresolved / small' };
const OTHER_CARD = { id: 'card:Other cards', col: 2, kind: 'card', bank: 'Other', label: 'Other cards', sub: 'small' };
for (const [id, node] of nodes) {
const pv = prelim[id] || { in: 0, out: 0 };
const val = Math.max(pv.in, pv.out);
// unresolved / phantom accounts created by one-sided internal records — merge regardless of size
if (node.col === 1 && node.kind === 'account' && /:(pago|cta)$/.test(id)) { idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC; continue; }
if (val >= COLLAPSE_T) continue;
if (node.col === 0 && (node.kind === 'source' || node.kind === 'person')) {
idMap[id] = 'inc:Other income';
otherDescs['inc:Other income'] = { id: 'inc:Other income', col: 0, kind: 'source', label: 'Other income', sub: 'small sources' };
} else if (node.col === 3 && node.kind === 'person') {
idMap[id] = 'per:Other people';
otherDescs['per:Other people'] = { id: 'per:Other people', col: 3, kind: 'person', label: 'Other people', sub: 'small transfers' };
} else if (node.col === 3 && node.kind === 'spend') {
idMap[id] = 'spend:Other purchases';
otherDescs['spend:Other purchases'] = { id: 'spend:Other purchases', col: 3, kind: 'spend', label: 'Other purchases', sub: 'spending' };
} else if (node.col === 1 && node.kind === 'account' && val < 150000) {
idMap[id] = OTHER_ACC.id; otherDescs[OTHER_ACC.id] = OTHER_ACC;
} else if (node.col === 2 && node.kind === 'card' && val < 110000) {
idMap[id] = OTHER_CARD.id; otherDescs[OTHER_CARD.id] = OTHER_CARD;
}
}
if (Object.keys(idMap).length) {
const remap = id => idMap[id] || id;
const links2 = new Map();
for (const L of links.values()) {
const s = remap(L.source), t = remap(L.target);
if (s === t && CAT[L.cat].kind !== 'internal') continue;
const key = s + '→' + t + '#' + L.cat;
let n = links2.get(key);
if (!n) { n = { source: s, target: t, value: 0, cat: L.cat, kind: L.kind, txns: [] }; links2.set(key, n); }
n.value += L.value; n.txns.push(...L.txns);
}
const nodes2 = new Map();
const descOf = id => otherDescs[id] || nodes.get(id);
for (const L of links2.values()) for (const id of [L.source, L.target]) {
if (!nodes2.has(id)) nodes2.set(id, Object.assign({ value: 0, inLinks: [], outLinks: [] }, descOf(id)));
}
nodes = nodes2; links = links2;
}
// wire link refs onto nodes, compute values
const linkArr = [...links.values()];
for (const L of linkArr) {
const s = nodes.get(L.source), tg = nodes.get(L.target);
if (!s || !tg) continue;
s.outLinks.push(L); tg.inLinks.push(L);
}
for (const n of nodes.values()) {
const inSum = n.inLinks.reduce((a, l) => a + l.value, 0);
const outSum = n.outLinks.reduce((a, l) => a + l.value, 0);
n.value = Math.max(inSum, outSum);
n.inSum = inSum; n.outSum = outSum;
}
// totals (real only)
let realIn = 0, realOut = 0, internalAmt = 0;
for (const L of linkArr) {
if (L.cat === 'income' || L.cat === 'inter_in') realIn += L.value;
else if (L.cat === 'expense' || L.cat === 'fee' || L.cat === 'inter_out') realOut += L.value;
else internalAmt += L.value;
}
return {
nodes: [...nodes.values()].filter(n => n.value > 0),
links: linkArr.filter(l => nodes.has(l.source) && nodes.has(l.target)),
totals: { realIn, realOut, internal: internalAmt, net: realIn - realOut }
};
}
window.MT = {
load, build,
CLP, CLPk, MONTH_LABEL, cleanDesc, CAT,
spendCategory, normIncome, normPerson, bankName, isCardType, acctShort,
get months() { return MONTHS; },
get banks() { return BANKS; },
get tx() { return TX; },
get raw() { return RAW; }
};
})();