import { ECONOMIC_TYPES, LINK_METHODS, LINK_TYPES, buildNodeIndex, compareDateThenId, } from './domain.js'; function makeLink({ from, to, type, amount, method, confidence = 'rule', state = 'proposed', note }) { return { from, to, type, amount, method, confidence, state, note }; } export function buildMoneyGraph(ledger) { const nodes = buildNodeIndex(ledger); const links = [...ledger.links]; links.push(...inferAccountFifoFundingLinks(ledger, links)); links.push(...inferCardSettlementLinks(ledger, links)); return { nodes, links, incoming: indexLinks(links, 'to'), outgoing: indexLinks(links, 'from'), }; } function indexLinks(links, field) { const index = new Map(); for (const link of links) { if (!index.has(link[field])) index.set(link[field], []); index.get(link[field]).push(link); } return index; } function inferAccountFifoFundingLinks(ledger, existingLinks) { const movementsByAccountCurrency = new Map(); const hasIncomingFunding = new Set( existingLinks .filter((link) => [LINK_TYPES.FUNDS, LINK_TYPES.INTERNAL_TRANSFER, LINK_TYPES.FX_CONVERSION].includes(link.type)) .map((link) => link.to) ); for (const movement of ledger.movements) { const key = `${movement.accountId}|${movement.amount.currency}`; if (!movementsByAccountCurrency.has(key)) movementsByAccountCurrency.set(key, []); movementsByAccountCurrency.get(key).push(movement); } const inferred = []; for (const movements of movementsByAccountCurrency.values()) { movements.sort(compareDateThenId); const lots = []; for (const movement of movements) { if (movement.direction === 'in') { if (isFundingLot(movement)) { lots.push({ movement, remaining: movement.amount.value }); } continue; } if (movement.direction !== 'out') continue; if (!isCashOutflowNeedingOrigin(movement)) continue; if (hasIncomingFunding.has(movement.id)) continue; let needed = movement.amount.value; for (const lot of lots) { if (needed <= 0) break; if (lot.remaining <= 0) continue; const used = Math.min(lot.remaining, needed); inferred.push(makeLink({ from: lot.movement.id, to: movement.id, type: LINK_TYPES.FUNDS, amount: { currency: movement.amount.currency, value: used }, method: LINK_METHODS.RULE_FIFO, note: 'FIFO por cuenta y moneda: ingreso previo financia salida posterior.', })); lot.remaining -= used; needed -= used; } } } return inferred; } function isFundingLot(movement) { return [ ECONOMIC_TYPES.PURE_INCOME, ECONOMIC_TYPES.OPERATING_INCOME, ECONOMIC_TYPES.REFUND, ECONOMIC_TYPES.REIMBURSEMENT, ECONOMIC_TYPES.PARTNER_LOAN, ECONOMIC_TYPES.INTERNAL_TRANSFER, ECONOMIC_TYPES.FOREIGN_ACCOUNT_FUNDING, ECONOMIC_TYPES.WALLET_FUNDING, ].includes(movement.economicType); } function isCashOutflowNeedingOrigin(movement) { return [ ECONOMIC_TYPES.REAL_EXPENSE, ECONOMIC_TYPES.CARD_PAYMENT, ECONOMIC_TYPES.WALLET_FUNDING, ECONOMIC_TYPES.FOREIGN_ACCOUNT_FUNDING, ECONOMIC_TYPES.INTERNAL_TRANSFER, ECONOMIC_TYPES.REIMBURSEMENT, ECONOMIC_TYPES.PARTNER_WITHDRAWAL, ].includes(movement.economicType); } function inferCardSettlementLinks(ledger, existingLinks) { const movementsByAccountCurrency = new Map(); const manuallySettledCharges = new Set( existingLinks .filter((link) => link.type === LINK_TYPES.SETTLES_CARD_CHARGE) .map((link) => link.to) ); for (const movement of ledger.movements) { if (!movement.cardAccountId && movement.economicType !== ECONOMIC_TYPES.CARD_CHARGE) continue; const accountId = movement.cardAccountId || movement.accountId; const key = `${accountId}|${movement.amount.currency}`; if (!movementsByAccountCurrency.has(key)) movementsByAccountCurrency.set(key, []); movementsByAccountCurrency.get(key).push(movement); } const inferred = []; for (const movements of movementsByAccountCurrency.values()) { const charges = movements .filter((movement) => movement.economicType === ECONOMIC_TYPES.CARD_CHARGE && !manuallySettledCharges.has(movement.id)) .sort(compareDateThenId) .map((movement) => ({ movement, remaining: movement.amount.value })); const payments = movements .filter((movement) => movement.economicType === ECONOMIC_TYPES.CARD_PAYMENT) .sort(compareDateThenId); for (const payment of payments) { let remainingPayment = payment.amount.value; for (const charge of charges) { if (remainingPayment <= 0) break; if (charge.remaining <= 0) continue; if (String(charge.movement.date) > String(payment.date)) continue; const settled = Math.min(charge.remaining, remainingPayment); inferred.push(makeLink({ from: payment.id, to: charge.movement.id, type: LINK_TYPES.SETTLES_CARD_CHARGE, amount: { currency: payment.amount.currency, value: settled }, method: LINK_METHODS.RULE_FIFO, note: 'Pago de tarjeta liquida cargos anteriores por FIFO dentro de la misma tarjeta y moneda.', })); charge.remaining -= settled; remainingPayment -= settled; } } } return inferred; } export function originTree(graph, nodeId, options = {}) { const maxDepth = options.maxDepth ?? 20; const seen = new Set(); function walk(id, depth) { const node = graph.nodes.get(id) || { id, kind: 'unknown', description: 'Nodo no encontrado' }; if (depth >= maxDepth) return { node, truncated: true, incoming: [] }; if (seen.has(id)) return { node, cycle: true, incoming: [] }; seen.add(id); const incoming = (graph.incoming.get(id) || []).map((link) => ({ link, source: walk(link.from, depth + 1), })); seen.delete(id); return { node, incoming }; } return walk(nodeId, 0); } export function destinationTree(graph, nodeId, options = {}) { const maxDepth = options.maxDepth ?? 20; const seen = new Set(); function walk(id, depth) { const node = graph.nodes.get(id) || { id, kind: 'unknown', description: 'Nodo no encontrado' }; if (depth >= maxDepth) return { node, truncated: true, outgoing: [] }; if (seen.has(id)) return { node, cycle: true, outgoing: [] }; seen.add(id); const outgoing = (graph.outgoing.get(id) || []).map((link) => ({ link, target: walk(link.to, depth + 1), })); seen.delete(id); return { node, outgoing }; } return walk(nodeId, 0); } export function summarizeLedger(ledger, graph) { const byType = new Map(); const byEntity = new Map(); for (const movement of ledger.movements) { const key = movement.economicType || ECONOMIC_TYPES.UNKNOWN; byType.set(key, (byType.get(key) || 0) + movement.amount.value); const entityKey = movement.beneficiaryEntityId || movement.ownerEntityId || 'sin_entidad'; byEntity.set(entityKey, (byEntity.get(entityKey) || 0) + movement.amount.value); } return { counts: { entities: ledger.entities.length, accounts: ledger.accounts.length, movements: ledger.movements.length, documents: ledger.documents.length, events: ledger.events.length, links: graph.links.length, }, movementAmountByType: Object.fromEntries([...byType.entries()].sort()), movementAmountByEntityHint: Object.fromEntries([...byEntity.entries()].sort()), }; }