Protocolo: reconcile mensual de gastos
Protocolo: reconcile mensual de gastos
Cómo se cierra el mes de gastos: importar, reconciliar contra las fuentes, capturar lo que no pasa por la tarjeta, clasificar, resolver la cola, emitir el split, leer el reporte y liquidar el saldo. Implementa la operación reconcile de la constitución con una regla rectora: taggear solo lo que es juicio, derivar lo que es patrón. El split (¿de quién es este gasto?) es juicio humano y se declara; la recurrencia y la atipicidad son patrones y se detectan. Cada declaración se hace una sola vez por comercio, no por transacción: la fricción se amortiza y la cola se encoge sola con el uso.
Los dos ejes de todo gasto
| Eje | Valores | Cómo se determina |
|---|---|---|
| Split (¿de quién?) | personal · shared (con my_share) · unsure | Declarado. _merchants.json recuerda el default por comercio; solo lo nuevo o atípico pregunta |
| Cadencia (¿qué ritmo?) | recurrente · variable · one-off | Derivado al reportar: nunca se taggea a mano. cadence_override existe para lo que el detector no puede ver (gastos anuales, fiscales) |
my_share es la fracción del monto que es costo propio: personal = 1.0, compartido al 50% = 0.5. El costo propio de cualquier reporte es siempre amount_eur × my_share, pague quien pague.
El ciclo (mensual)
- Importar: descargar el export Chase del ciclo a
raw/assets/, correrbin/reconcile import. Normaliza descriptores a handles, convierte a EUR con tasa BCE del día, deduplica por id y escribeledger/expenses/YYYY-MM.jsonl. Un mes que ya existe no se toca: import solo estrena archivos. - Reconciliar:
bin/reconcile syncpara el caso normal, que es el mes ya escrito y la fuente que cambió (la sheet re-exportada con más filas y con shares que antes estaban vacíos). Recalcula el conjunto deseado con la misma tubería del import y lo compara contra el ledger vigente: anexa lo que falta, corrige con una línea más cuando la fuente cambió una decisión explícita, y nunca borra. Lo que ya no aparece en la fuente solo se reporta, y correrlo dos veces seguidas deja el segundo run en cero. Cuatro cosas se reportan y no se escriben: las decisionesmanual-*(le ganan a la sheet siempre), lo que ya entró porcapturey ahora también aparece en la sheet (mismo gasto por la otra puerta: anexarlo lo contaría dos veces), la deriva de reglas (eso esresolve --audit) y los comercios nuevos sin entrada en_merchants.json. - Capturar lo que no pasa por la tarjeta: Bizum, efectivo y gastos suyos que no están en la sheet entran con
bin/reconcile capture(una operación por opciones, o un lote por--from-json -, siempre por STDIN: un archivo con montos en claro en el disco es justo lo que este corpus evita). Quedan consource: manual, que es territorio humano:syncno los propone, no los corrige ni los reporta como ausentes, y si la sheet acaba listando ese mismo movimiento lo señala en vez de anexarlo. Si se omite, el baseline miente por abajo. Dos movimientos idénticos del mismo día (dos cafés en efectivo, dos Bizums iguales) se numeran solos dentro del lote; capturar el tercero en otra corrida choca con el primero a propósito, y ahí va--occurrence 2: repetir un lote tiene que ser inofensivo, y esa garantía se paga con un flag explícito para el caso legítimo. - Clasificar: automático contra
_merchants.json. Cae aunsuresolo: (a) comercio nuevo, (b) histórico mezclado sin default, (c) monto fuera del rango típico del comercio aunque sea conocido (3x arriba del máximo histórico, con 3+ decisiones): cuatrocientos donde sueles gastar treinta casi nunca es el mismo tipo de gasto. - Resolver: dos niveles.
bin/reconcile queuea nivel comercio (declarar qué es, categoría, split default; permanente), y ahí mismo la cola de categorías: comercios que ya gastan en el ledger sin entrada propia, con cuánto de su gasto está cayendo dentro deotros.bin/reconcile resolvea nivel transacción: lista losunsure, se deciden por id o en modo interactivo, y cada decisión queda como línea de corrección (manual-<fecha>). Ununsuresin resolver cuenta 100% como propio (conservador) y el reporte lo grita.resolve --auditenseña qué registros históricos clasificarían distinto bajo las reglas de hoy, sin escribir nada. - Cruzar el lado compartido: hoy, el lado de entity:pareja entra desde la sheet; el destino es que cada instancia del corpus emita un statement mensual con solo sus gastos compartidos (fecha, concepto, monto, share) y el saldo se calcule cruzando ambos. Los movimientos personales de cada quien jamás cruzan.
- Reporte:
bin/reconcile report YYYY-MM. Tres capas más el split del mes. - Liquidar: cuando el saldo se paga, el cierre se declara a mano en
ledger/expenses/_settlements.json(ventana, neto, pagos, con qué se movieron, qué documento lo respalda) ybin/reconcile settle <id>estampasettled_inen los registros que esa ventana cubre.settle --listlos enumera con su estado. El script no abre cierres: cuánto se pagó y cuándo se verifica contra el banco, no se deduce de la sheet. Lo que sí hace es recalcular el neto de la ventana desde el ledger y avisar si no cuadra con el neto declarado, que es la señal de que a uno de los dos le falta algo. Estampar es una línea de corrección más por registro cubierto. - Pendientes: disputas y reembolsos esperados (fraudes reportados, fianzas) viven en
ledger/expenses/_pending.jsony el reporte los imprime hasta cerrarlos. El import sugiere el cierre cuando entra un crédito que matchea; cerrar es decisión humana, nunca automática.
Lo que propone un cambio a partir de una fuente (sync, capture, settle) es dry-run por default y solo toca disco con --apply: primero se lee el plan. resolve escribe en el acto, porque ahí la decisión la acabas de teclear tú, e import no pregunta porque solo estrena archivos: un mes que ya existe se reconcilia, no se reescribe.
Reglas de clasificación
- Histórico consistente (3+ decisiones iguales) clasifica automático. Comercio nuevo o mezclado cae en
unsure. - Regla por monto (
amount_rulesen_merchants.json): para comercios donde el monto delata el contexto. El caso parking: ticket chico es estacionamiento corto en solitario y vapersonalsolo; ticket grande suele ser salida en pareja y pregunta. La zona que pregunta es juicio a propósito, no fricción por resolver. - Sin default a propósito: los comercios de viaje (hoteles, restaurantes y súpers de ruta) preguntan por transacción siempre. A veces el viaje es de dos, a veces con amigos y el split corre por splitwise; un default aquí produciría doble conteo.
- Registros excluidos (
resolve <id> --exclude, que escribe una línea de corrección): cargos que no son gasto propio (fraudes en disputa). Salen de todos los totales pero el reporte los enumera para que no se vuelvan invisibles. Un fraude tiene dos actos y los dos se excluyen: el cargo cuando se reclama y el abono cuando el banco lo revierte, porque contar solo el abono convierte un fraude resuelto en un ingreso y abarata el mes en que cae. El import y el sync detectan ese abono (mismo comercio, monto opuesto) y lo dicen con el comando escrito; excluirlo sigue siendo humano. settled_inapunta a un cierre, no a un mes. Guarda el id de_settlements.json(S-001, S-002…) que ya cubrió ese registro;nulles pendiente de liquidar. Un mes no identifica una liquidación: una ronda cruza varios meses y un mes puede quedar partido entre dos cierres, así que “liquidado en 2026-06” no dice contra qué saldo ni con qué pago. Un cierre cubre lo que cruza saldo en su ventana (payer: partnerentero, más losharedque pagué yo) y se salta losexcluded, losunsurey lo que otro cierre ya estampó.- Recurrente = presente en 3+ meses y monto estable (IQR/mediana < 0.4), u override declarado. Presencia mensual sin estabilidad de monto (viajes, súper) no es baseline: es hábito variable.
- One-off grande = neto mensual por comercio ≥ 150 EUR (umbral fijado en frío; se calibra tras 2-3 ciclos si la lista sale muy larga o corta). Neto, no bruto: una compra devuelta no es un pico.
- Moneda canónica EUR. El monto original y su moneda se preservan; la conversión usa la tasa BCE del día de la transacción y queda escrita en el registro (
fx_rate,fx_date). - Append-only. Corregir un registro es añadir una línea de corrección con el mismo
id, nunca editar la historia.
Cómo se lee la sheet compartida
Mientras el lado de entity:pareja entre por sheet y no por statement, la sheet es una fuente con semántica propia. Las reglas de arriba deciden de quién es un gasto; estas deciden qué fila es un gasto.
- El share del bloque derecho es mi parte, y decide el split de la fila. Entre 0 y 1 es
sharedcon esemy_share; exactamente 1 espersonalmío que ella pagó (escribirlosharedle atribuiría una parte que no le toca y el reporte mentiría en la dirección cómoda); 0 no cruza y no genera registro. - Share 0 no significa “no es mío”, significa “ya se cobró”. Cuando una ronda re-lista un gasto de la ronda anterior en 0 con una nota tipo
already charged, sigue siendo gasto compartido mío: lo que cambió no es la fracción, es que ese saldo ya se liquidó, y eso se registra consettled_in. De ahí salen dos casos. Si el registro ya está en el ledger,synclo reporta como fantasma (la fuente vigente ya no lo lista) y eso no es una instrucción de borrarlo: es el gasto de su mes, y su lugar es el cierre que lo cubrió. Si nunca entró (la ronda vieja lo dejó fuera de su ventana y la nueva lo pone en 0), la puerta escapture … --settled S-00X. El script no adivina cuál cierre lo cubrió: eso lo declaras tú. - Un monto negativo no es gasto. Es una transferencia (un Bizum prestado) o un reembolso: mueve el saldo del cierre, no el gasto del mes.
importysynclos enumeran aparte y no entran al ledger. El orden importa: primero el share, después el signo, porque un negativo con share 0 es un reembolso suyo y no es ni gasto ni crédito. - Una sheet por ronda, y una ronda no habla de fechas que la siguiente ya cubre. Cuando el portal re-exporta un periodo, el archivo viejo se queda al lado del nuevo: gana el que dice
final(y si empatan, el más reciente), y el descartado se nombra en voz alta en vez de desaparecer. De la ronda vieja se tiran además las filas fechadas a partir del inicio de la siguiente, porque la nueva las re-lista y leerlas de las dos las contaría dos veces con dos shares distintos. Una fila sin fecha hereda la de la fila anterior del bloque, se avisa y queda anotado en el registro.
Cómo se lee el reporte
- Baseline recurrente: se compara contra el mes anterior. Su delta es la señal más importante del reporte: una suscripción nueva o una renta que sube se delatan solas.
- Variable por categoría: contra la mediana de los 3 meses previos.
- One-offs: lista corta y nombrada. Cada uno se internaliza: ¿fue decisión (candidata a página
decision), evento externo (impuestos), o fuga? - Split del mes: lo que debe cada quien y el neto. El costo de vida propio incluye la parte propia de lo compartido, lo haya pagado quien lo haya pagado.
Límites conocidos de v1
- Los
iddel ledger no los reproduce la receta actual detxn_id. Ninguno de los registros escritos por el import original vuelve a salir con el mismo hash hoy: los escribió una versión del script anterior al primer commit. Por esosynccompara por identidad natural (origen, fecha, descriptor, monto, ocurrencia: la misma terna que el id hashea, pero en claro) y reusa el id que el registro ya tiene, en vez de comparar ids. Consecuencia viva: regenerar un mes desde cero duplicaría todo ese mes, no solo el lado pareja. - Gastos fuera de la tarjeta: cerrado de un lado, abierto del otro. Lo que pasa por la sheet de entity:pareja (luz, agua, internet, limpieza, club) ya entra solo: son filas del bloque derecho y
synclas anexa como cualquier otra, así que ese hueco del baseline dejó de existir. Lo propio fuera de Chase (Bizum, efectivo) tiene puerta (capture) pero no automatismo: nada lo detecta si no lo capturas, no hay conciliación contra la cuenta en euros, y un movimiento olvidado no deja rastro en ningún reporte. - Los créditos no viven en el ledger (no son consumo de nadie), así que el neto que
settlerecalcula desde los registros los ignora. El neto que declaras en_settlements.jsonsale de la sheet y sí los trae dentro, así que cuadrar los dos números es a mano: por eso el comando los compara y avisa en vez de creerle a uno de los dos. - Un comercio que estrena la sheet clasifica bien el split y mal la categoría. El split viene explícito en la fila, pero sin entrada en
_merchants.jsonla categoría cae enotroshasta que lo declares, y ahí un recurrente de servicios se vuelve invisible.synclista los que estrena yqueuelos que ya están gastando en el ledger; declararlos es humano y no lo hace nadie más. - Splits con terceros (splitwise de viajes con amigos) no se modelan: un gasto reembolsado por fuera infla el costo propio de ese mes.
- La detección de recurrencia necesita 3+ meses de historia: un gasto anual solo se captura vía
cadence_override. - Chase reporta en USD comercios que cobran en EUR: la cifra EUR reconstruida difiere centavos del ticket real (doble conversión). Se asume.
Páginas relacionadas
- expenses.md: el hub del dominio, con el estado del sistema y los pendientes.
- finanzas.md: el mapa de dinero del que gastos es spoke.
- CLAUDE.md: la operación
reconcileen la constitución, de la que este protocolo es la implementación detallada.