Ledger y movimientos
Saldos derivados, ingresos/gastos y transferencias entre cuentas (TAF-012, TAF-016).
Visión general
El dinero no se guarda como saldo mutable. Cada cuenta tiene initialBalance;
el saldo visible es esa cifra más la suma firmada de sus movimientos.
transactions[] + accounts[]
↓
logic/store/ledger.js (saldos, net worth, income/expense)
logic/store/transactions.js (alta / edición / baja / transfer pair)
↓
FinanceProvider → useFinance()logic/utils/calculations.js reexporta las funciones de ledger.js para no
duplicar fórmulas. Los tests viven en test/logic/store/ledger.test.js y
test/logic/store/transactions.test.js (node --test).
API de escritura (transactions.js)
El provider no calcula montos: llama a estas funciones puras y luego persiste el resultado.
| Función | Rol |
|---|---|
prepareAddTransaction(data, accounts) | Valida ingreso/gasto y crea la entidad. Rechaza type: "transfer". |
prepareAddTransfer(input, accounts, transactions) | Valida y crea dos piernas vinculadas. |
prepareUpdateTransaction(list, id, patch, accounts) | Ingreso/gasto: un patch. Transferencia: sincroniza ambas piernas. |
prepareRemoveTransaction(list, id) | Devuelve todos los ids a borrar (el par si es transfer). |
relatedTransactionIds(list, id) | Ids del movimiento y de su pareja. |
createTransferPair(input) | Factory de las dos piernas (out / in). |
transferEndpoints(tx) | { fromAccountId, toAccountId } desde cualquiera de las piernas. |
addTransfer en useFinance() acepta positional
(fromAccountId, toAccountId, amount, date, note) o un objeto con esas claves.
addTransfer({
fromAccountId: "bank",
toAccountId: "cash",
amount: 50,
date: "2026-08-29",
note: "cajero",
});addTransaction({ type: "transfer", accountId, transferAccountId, ... })
delega en addTransfer.
Transferencias (TAF-016)
Una transferencia es dos transacciones con el mismo transferId:
| Campo | Pierna out (origen) | Pierna in (destino) |
|---|---|---|
type | transfer | transfer |
accountId | cuenta origen | cuenta destino |
transferAccountId | cuenta destino | cuenta origen |
transferLeg | "out" | "in" |
categoryId | "" | "" |
Reglas de validateTransaction / validateTransfer (logic/models.js + logic/validation.js):
- Monto finito > 0 (cero, negativo y
NaNse rechazan). - Cuenta requerida; si se pasa
accounts, debe existir y no estar archivada. - Categoría requerida en ingreso/gasto (no en transfer).
- Fecha parseable.
Reglas adicionales de validateTransfer:
- Origen y destino requeridos y distintos.
- Ambas cuentas deben existir y no estar archivadas.
- Misma moneda (no hay FX).
- Fecha parseable.
Saldo insuficiente en origen: warning (El saldo de la cuenta origen es insuficiente.), no bloqueo. El Dashboard no cuenta transferencias como ingreso ni gasto.
Hay un fallback de un solo registro (accountId = origen, transferAccountId = destino, sin transferLeg) para no romper datos viejos. El alta nueva siempre escribe el par.
Reglas de saldo (ledger.js)
signedDeltaForAccount(tx, accountId):
incomeen esa cuenta →+amountexpenseen esa cuenta →−amount- transfer
out→−amount; transferin→+amount - registro único de transfer: origen
−, destino+ - monto no finito o
≤ 0→0(no mueve el saldo)
Funciones derivadas:
| Función | Resultado |
|---|---|
computeAccountBalance(account, txs) | initialBalance + deltas |
computeBalancesByAccount(accounts, txs) | mapa id → saldo |
computeNetWorthByCurrency / computeTotalBalanceByCurrency | totales por moneda (nunca se mezclan) |
computeIncomeExpense / computeMonthlyIncomeExpense | { income, expense, net } excluyendo transfer |
computeTopExpenseCategories | top gastos del mes (ignora transfers) |
getRecentTransactions | últimas N por fecha (incluye transfers) |
transactionTouchesAccount | accountId o transferAccountId |
categoriesForType / categoryHasTransactions | filtros de categoría usados por las páginas |
getAccountBalance y balanceByCurrency del store llaman a estas funciones.
Archivar una cuenta no borra historial; el KPI de dashboard usa activeAccounts().
Cableado en páginas
Solo handlers e imports; sin rediseño visual.
- Transactions: tipo
transferenTransactionForm→addTransaction/updateTransaction/removeTransaction(el remove borra las dos piernas). Filtros combinables víafilterTransactions(logic/utils/filterTransactions.js):hookState→filteredTransactions→listState(cadenauseMemo) →For({ each: () => listState().groups })en el DOM (no.mapdentro del hijo deShow, que monta una sola vez). Ver Transactions — reactividad de filtros y Chroma — Show vs For. - Dashboard:
monthlyTotalsignora transfers; filas recientes etiquetanTransferencia. - Accounts:
transactionTouchesAccountbloquea el hard-delete si hay movimientos. - Categories:
categoriesForTypeycategoryHasTransactions.