370 lines
18 KiB
JavaScript
370 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 _lp = new URLSearchParams(location.search).get('ledger');
|
|
const res = await fetch(_lp && /^[A-Za-z0-9_.-]+\.json$/.test(_lp) ? _lp : '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.)
|
|
// Reclassify transactions that the raw parser tagged as 'expense' but are
|
|
// really internal movements.
|
|
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;
|
|
// Transactions where the counterparty is the account holder themselves
|
|
const SELF_RE = /VICENTETIRAD|VICENTE\s*TIRAD/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 };
|
|
}
|
|
if (SELF_RE.test(t.description) || SELF_RE.test(t.counterparty || '')) {
|
|
return { ...t, flow_type: 'self_transfer', 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; }
|
|
};
|
|
})();
|