/* 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); } } 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; } }; })();