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

EjeValoresCómo se determina
Split (¿de quién?)personal · shared (con my_share) · unsureDeclarado. _merchants.json recuerda el default por comercio; solo lo nuevo o atípico pregunta
Cadencia (¿qué ritmo?)recurrente · variable · one-offDerivado 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)

  1. Importar: descargar el export Chase del ciclo a raw/assets/, correr bin/reconcile import. Normaliza descriptores a handles, convierte a EUR con tasa BCE del día, deduplica por id y escribe ledger/expenses/YYYY-MM.jsonl. Un mes que ya existe no se toca: import solo estrena archivos.
  2. Reconciliar: bin/reconcile sync para 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 decisiones manual-* (le ganan a la sheet siempre), lo que ya entró por capture y ahora también aparece en la sheet (mismo gasto por la otra puerta: anexarlo lo contaría dos veces), la deriva de reglas (eso es resolve --audit) y los comercios nuevos sin entrada en _merchants.json.
  3. 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 con source: manual, que es territorio humano: sync no 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.
  4. Clasificar: automático contra _merchants.json. Cae a unsure solo: (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.
  5. Resolver: dos niveles. bin/reconcile queue a 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 de otros. bin/reconcile resolve a nivel transacción: lista los unsure, se deciden por id o en modo interactivo, y cada decisión queda como línea de corrección (manual-<fecha>). Un unsure sin resolver cuenta 100% como propio (conservador) y el reporte lo grita. resolve --audit enseña qué registros históricos clasificarían distinto bajo las reglas de hoy, sin escribir nada.
  6. 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.
  7. Reporte: bin/reconcile report YYYY-MM. Tres capas más el split del mes.
  8. 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) y bin/reconcile settle <id> estampa settled_in en los registros que esa ventana cubre. settle --list los 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.
  9. Pendientes: disputas y reembolsos esperados (fraudes reportados, fianzas) viven en ledger/expenses/_pending.json y 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_rules en _merchants.json): para comercios donde el monto delata el contexto. El caso parking: ticket chico es estacionamiento corto en solitario y va personal solo; 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_in apunta a un cierre, no a un mes. Guarda el id de _settlements.json (S-001, S-002…) que ya cubrió ese registro; null es 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: partner entero, más lo shared que pagué yo) y se salta los excluded, los unsure y 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 shared con ese my_share; exactamente 1 es personal mío que ella pagó (escribirlo shared le 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 con settled_in. De ahí salen dos casos. Si el registro ya está en el ledger, sync lo 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 es capture … --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. import y sync los 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 id del ledger no los reproduce la receta actual de txn_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 eso sync compara 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 sync las 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 settle recalcula desde los registros los ignora. El neto que declaras en _settlements.json sale 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.json la categoría cae en otros hasta que lo declares, y ahí un recurrente de servicios se vuelve invisible. sync lista los que estrena y queue los 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 reconcile en la constitución, de la que este protocolo es la implementación detallada.