dimesum

Inicio / Blog / Ingeniería

Ingeniería

Libro mayor de solo anexión para saldos exactos

· 9 min de lectura ·

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.

Lo que un libro mayor de solo anexión hace imposible, frente a una tabla de saldos incrementada
Tipo de errorTabla de saldos incrementadaLibro mayor de solo anexión
Se cobra al deudor, nunca se abona al pagadorErróneo en silencio para siempreLa comprobación de suma cero falla, escritura rechazada
Una parte cambia, el total noLos saldos se desvían en silencioEl asiento no puede confirmarse descuadrado
«¿Por qué mi saldo es de 412 €?»Sin respuesta posibleCada céntimo se rastrea hasta un asiento
Un hotfix en producciónMutación sin rastroEl ú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.

La edición de un gasto publica un EXPENSE_REVERSAL que niega la versión uno, más un EXPENSE nuevo para la versión dos, en una sola transacción; la proyección de saldos es una suma sobre todas las anotaciones. una transacción EXPENSE expense:7c1:v1 4 anotaciones suma = 0 EXPENSE_REVERSAL expense:7c1:v1:reversal cada pata negada suma = 0 EXPENSE expense:7c1:v2 5 anotaciones suma = 0 proyección de saldos = SUM(postings) por miembro, por moneda desechable: evenly rebuild-balances la vacía y reproduce cada anotación
Una edición anexa: una reversión nombra la versión que niega, y el reemplazo se publica junto a ella.

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 escritura de negocio y su fila de outbox se confirman en una sola transacción; el relay entonces despacha solo las filas de outbox cuyo id de transacción insertada está por debajo de la marca de agua pg_snapshot_xmin, en orden de id de transacción. una transacción INSERT fila de gasto INSERT fila de outbox topic id (uuidv7) inserted_xid expense.created 019a-7f3 4101 expense.amended 019a-4c1 4102 expense.created 019a-1a8 4103 escritor en vuelo bus de eventos ledger consumidor pg_snapshot_xmin(pg_current_snapshot()). Las filas por encima fueron escritas por transacciones que se han confirmado sin que ninguna más antigua siga en curso. La fila de abajo espera al siguiente pase. Ordenar por id despacharía 019a-1a8 primero, de modo que una enmienda podría llegar antes del gasto que enmienda. Ordenar por inserted_xid no puede: un evento posterior tiene un xid posterior.
La fila de outbox se confirma con la escritura de negocio, y el relay despacha por debajo de la marca de agua en orden de id de transacción.

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.

Los cinco tipos de asiento del libro mayor de Dimesum y las patas que publica cada uno
Tipo de asientoSe publica cuandoPatas
EXPENSESe crea, o una versión nueva reemplaza a otraPAID, SHARE
EXPENSE_REVERSALSe edita o se borraLas patas del asiento anterior, negadas
SETTLEMENTSe afirma un reembolsoSETTLE_PAY, SETTLE_RECV
SETTLEMENT_REVERSALLa contraparte lo disputaAmbas patas de liquidación, negadas
WRITE_OFFUn acreedor renuncia a un cobroWRITE_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.

Ejecútalos en este orden

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.