Libro mayor de solo anexión para saldos exactos
Un saldo debería ser una proyección que puedes descartar y reconstruir; esta es la maquinaria que lo hace seguro: asientos de suma cero, ediciones que publican correcciones y un buzón que despacha en orden de commit.
Un saldo que se incrementa es un saldo que se desvía. Dimesum nunca incrementa ninguno. Cada gasto publica un asiento de doble entrada cuyas anotaciones suman exactamente cero, y el número en la pantalla de tu grupo es una proyección sobre esas anotaciones que podemos borrar y reconstruir con un solo comando.
Puedes comprobar esa afirmación. Cuando la única forma en que el dinero se mueve es un asiento de solo anexión, un saldo incorrecto deja de ser un misterio y se convierte en una consulta que podemos ejecutar.
Los saldos son una proyección, no un número que incrementas
El libro mayor de Dimesum tiene tres tablas: journals, postings y una proyección balances indexada por grupo, miembro y moneda. Las anotaciones guardan la verdad. Una anotación es positiva cuando un miembro aportó dinero al grupo y negativa cuando consumió valor, de modo que un saldo es un simple SUM(amount_minor). La proyección existe por velocidad, no por autoridad: se escribe dentro de la propia transacción del asiento, y que las anotaciones estén completas la mantiene desechable.
Dos capas afirman el mismo cero, y ninguna basta por sí sola
En Go, buildPostings se niega a abrir una transacción salvo que el conjunto de anotaciones sume cero. En Postgres, un trigger de restricción diferida vuelve a comprobar SUM(amount_minor) = 0 por asiento en el commit, porque las anotaciones se insertan fila a fila y una comprobación por fila rechazaría la primera pata de cada asiento. La aserción detecta un error en el calculador. El trigger detecta a un escritor que nunca lo llamó.
Una tercera capa es un permiso. UPDATE, DELETE y TRUNCATE están revocados para el rol ledger_app en ambas tablas, de modo que el código con errores no puede reescribir el historial aunque lo intente.
| Tipo de error | Tabla de saldos incrementada | Libro mayor de solo anexión |
|---|---|---|
| Se cobra al deudor, nunca se abona al pagador | Erróneo en silencio para siempre | La comprobación de suma cero falla, escritura rechazada |
| Una parte cambia, el total no | Los saldos se desvían en silencio | El asiento no puede confirmarse descuadrado |
| «¿Por qué mi saldo es de 412 €?» | Sin respuesta posible | Cada céntimo se rastrea hasta un asiento |
| Un hotfix en producción | Mutación sin rastro | El único camino es un asiento nuevo y auditado |
Una edición publica una reversión, nunca un UPDATE
Editar un gasto no sobrescribe nada de la versión antigua. El libro mayor consume un evento expense.amended y publica dos asientos en una sola transacción: un EXPENSE_REVERSAL cuyas anotaciones son la negación exacta, pata por pata, de la versión que se reemplaza, y luego un EXPENSE nuevo para la nueva. Un borrado se detiene tras la reversión.
Una sola transacción importa tanto como los dos asientos. Si la reversión se confirmara sola, un grupo no debería nada por un instante por un gasto que sigue debiendo.
Ambas claves de idempotencia se derivan de la versión en lugar de acuñarse: expense:<id>:v<n> para la versión, y la clave de la versión anterior más :reversal para la negación. journals.idempotency_key es UNIQUE, de modo que un evento reentregado encuentra sus asientos ya presentes y no hace nada. La derivación es lo que hace segura la entrega al menos una vez: un reintento calcula la misma clave que usó el primer intento.
Una marca de agua por orden de commit mantiene una enmienda detrás de su gasto
Cada evento que Dimesum publica se escribe en una tabla outbox en la misma transacción que la escritura de negocio. Si el gasto se confirma, el anuncio existe. Si se revierte, el anuncio también. El libro mayor nunca se entera de un gasto que no existe.
El orden de despacho es la mitad más difícil. Los ids son UUIDv7 y se ordenan por tiempo, pero codifican cuándo se acuñó el id, no cuándo se confirmó su transacción. Un relay que lee en orden de id puede saltarse una transacción que tomó un id anterior y se confirmó después, de modo que una enmienda adelanta al gasto que enmienda.
La solución es una columna y un predicado. Cada fila de outbox lleva inserted_xid xid8 DEFAULT pg_current_xact_id(), y el relay lee solo las filas WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), ordenadas por ese xid (consulta las funciones de id de transacción de PostgreSQL). Un evento causalmente posterior siempre lleva un xid posterior, porque tuvo que leer la fila anterior para existir.
El consumidor de enmiendas no da ese orden por sentado. Una enmienda cuyo predecesor no tiene asiento se reentrega mientras es joven, y se aparta para una persona una vez pasado un margen de dos minutos.
El dinero es unidades menores int64, y el exponente no siempre es dos
Cada importe es un recuento int64 de unidades menores más un código ISO 4217, de modo que 1.234,56 rupias es {Minor: 123456, Currency: "INR"}. Los enteros son exactos por construcción, no por disciplina: ningún valor representable es medio céntimo, de modo que ninguna operación puede producir uno sin que se note. El sidecar de Python lee su propio AST y falla una prueba si la palabra float aparece en su módulo de importes.
Suponer que la unidad menor es una centésima es la trampa que hay debajo. JPY no tiene ninguna y KWD tiene tres decimales, de modo que una tasa cotizada entre unidades mayores y aplicada a unidades menores se equivoca por una potencia de diez. Toma 2.000 yenes a 0,58: el producto ingenuo es 1160, que se lee como 11,60 euros, cuando la respuesta es 1.160.
WRITE_OFF es un quinto tipo de asiento porque sus anotaciones coinciden con las de una liquidación
Una condonación publica las mismas dos patas que una liquidación: el deudor sube, el acreedor baja, por el mismo importe. Integrarla en SETTLEMENT se rechazó precisamente por la razón de que las formas coinciden. «Asha te pagó 500 euros» y «le perdonaste a Asha 500 euros» son hechos distintos, y un feed que los confundiera diría que alguien pagó cuando nadie lo hizo.
Así que WRITE_OFF se sumó al CHECK de tipo de asiento el 2026-08-21, con patas propias. Nombrarlas era la mitad del asunto, porque una condonación que reutilizara SETTLE_PAY haría que cada consulta de «cuánto se ha pagado realmente» fuera errónea en silencio.
| Tipo de asiento | Se publica cuando | Patas |
|---|---|---|
EXPENSE | Se crea, o una versión nueva reemplaza a otra | PAID, SHARE |
EXPENSE_REVERSAL | Se edita o se borra | Las patas del asiento anterior, negadas |
SETTLEMENT | Se afirma un reembolso | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | La contraparte lo disputa | Ambas patas de liquidación, negadas |
WRITE_OFF | Un acreedor renuncia a un cobro | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Dos subcomandos convierten los invariantes en una tarea cron
evenly verify-ledger vuelve a demostrar los invariantes sobre todo el esquema y sale con código distinto de cero ante cualquier incidencia. Su informe tiene cuatro campos, y se espera que todos estén vacíos: asientos que no suman cero, grupos que no suman cero, filas de proyección que divergen de un SUM(postings) recalculado, y eventos apartados a la espera de una persona. Todos los escaneos comparten una única instantánea repeatable-read, de modo que un asiento que llega a mitad de la verificación no puede fabricar una discrepancia.
Cada escaneo agrupa por moneda además de por id, y el fallo que evita es un falso negativo. Una anotación de 500 rupias y otra de menos 500 yenes suman cero cuando una consulta ignora la columna de moneda, de modo que un libro mayor doblemente corrupto se leería limpio.
evenly rebuild-balances [group-id] es la reparación y el simulacro. Vacía la proyección y recalcula cada fila a partir de las anotaciones, sellando cada una con el último asiento que movió a ese miembro, igual que hace la escritura en vivo. La igualdad fila por fila es la propiedad: cuando una reconstrucción y la proyección en vivo no coinciden, el libro mayor tiene razón.
Verifica, luego reconstruye. El informe nombra el saldo almacenado y el recalculado para cada fila desviada, y una reconstrucción sobrescribe el valor almacenado, de modo que reconstruir primero destruye la evidencia.
Haz que el saldo sea derivable y la desviación se convierte en una consulta
Un libro mayor de solo anexión se gana su segundo asiento solo si puedes demostrarlo, así que construye el verificador y la reconstrucción antes que la función que los necesita. Una reconstrucción que nadie ha ejecutado es una esperanza, no una salida de emergencia. Elige tu proyección de dinero más arriesgada esta semana, escribe la consulta que la recalcula desde el origen, y avísate a ti mismo cuando ambas no coincidan.
Preguntas frecuentes
¿Qué es un libro mayor de solo anexión en una app para dividir gastos?
Un libro mayor de solo anexión registra cada evento de dinero como un asiento de anotaciones que suman cero, y nunca actualiza ni borra ninguno. En Dimesum, un gasto, una edición, una liquidación, una disputa y una condonación anexan cada uno un asiento nuevo. Los saldos se derivan luego sumando las anotaciones, de modo que cada céntimo se rastrea hasta el evento que lo movió.
¿Cómo se maneja un gasto editado en un libro mayor de solo anexión?
Una edición publica dos asientos en una sola transacción: un EXPENSE_REVERSAL que niega el original pata por pata, y luego un EXPENSE nuevo con la versión de reemplazo. No se reescribe nada, y un borrado publica solo la reversión. Ambos asientos toman claves de idempotencia derivadas de la versión del gasto, de modo que un evento reentregado los encuentra ya presentes y no cambia nada.
¿Por qué guardar el dinero como enteros en lugar de floats?
Las unidades menores enteras son exactas por construcción, mientras que el punto flotante binario no puede representar 0,1 con exactitud y se desvía a lo largo del historial de un grupo. Dimesum guarda cada importe como un recuento int64 de unidades menores más un código ISO 4217. El exponente viene de la moneda: JPY no tiene unidad menor alguna, de modo que suponer una centésima es un error de 100x.
¿Cómo saber si los saldos de gastos compartidos se han desviado?
Ejecuta evenly verify-ledger, que vuelve a demostrar los invariantes sobre todo el esquema y sale con código distinto de cero ante cualquier incidencia. Informa de asientos que no suman cero, grupos que no suman cero, filas de proyección que no concuerdan con un SUM(postings) recalculado, y eventos apartados. Prográmalo con cron cada noche, y repara una proyección defectuosa con evenly rebuild-balances.
¿Por qué una condonación necesita su propio tipo de asiento?
Una condonación publica patas idénticas a las de una liquidación, que es exactamente por lo que el tipo de asiento tenía que diferir. Solo esa palabra separa «Asha te pagó 500 euros» de «le perdonaste a Asha 500 euros», y un feed que los confundiera diría que alguien pagó cuando nadie lo hizo. Sus patas se llaman WRITE_OFF_FORGIVEN y WRITE_OFF_GRANTED para que las consultas de pagos sigan siendo correctas.
Artículos populares
- Por qué editar un gasto debe redefinir el reparto9 min de lectura
- Seis errores de dinero en gastos multidivisa10 min de lectura
- Cómo saldar cuentas de grupo en pocas transferencias5 min de lectura
- Dividir la cuenta cuando un plato no se compartió10 min de lectura
- Dividir el alquiler entre compañeros con justicia6 min de lectura