kua-money-trace/src/moneyGraph.js

237 lines
7.4 KiB
JavaScript

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