237 lines
7.4 KiB
JavaScript
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()),
|
|
};
|
|
}
|