Le grand livre append-only qui garde les soldes exacts
Un solde doit être une projection que l'on peut jeter et reconstruire, et voici la mécanique qui rend cela sûr : écritures à somme nulle, modifications qui inscrivent des contre-passations, et un outbox qui expédie dans l'ordre du commit.
Un solde que l'on incrémente est un solde qui dérive. Dimesum n'en incrémente jamais. Chaque dépense inscrit une écriture en partie double dont les lignes totalisent exactement zéro, et le nombre affiché sur l'écran de votre groupe est une projection de ces lignes que nous pouvons supprimer et reconstruire avec une seule commande.
Cette affirmation est vérifiable. Lorsque l'argent ne bouge que par une écriture append-only, un solde faux cesse d'être un mystère et devient une requête que nous pouvons exécuter.
Les soldes sont une projection, pas un nombre que l'on incrémente
Le grand livre de Dimesum possède trois tables : journals, postings et une projection balances indexée par groupe, membre et devise. Les lignes portent la vérité. Une ligne est positive quand un membre a mis de l'argent dans le groupe et négative quand il a consommé de la valeur, si bien qu'un solde est un simple SUM(amount_minor). La projection existe pour la vitesse, pas pour l'autorité : elle est écrite dans la transaction de l'écriture elle-même, et le fait que les lignes soient complètes la garde jetable.
Deux couches affirment le même zéro, et aucune ne suffit seule
En Go, buildPostings refuse d'ouvrir une transaction tant que l'ensemble des lignes ne totalise pas zéro. Dans Postgres, un trigger de contrainte différée revérifie SUM(amount_minor) = 0 par écriture au commit, parce que les lignes s'insèrent une à une et qu'une vérification ligne par ligne rejetterait la première ligne de chaque écriture. L'assertion attrape un bug dans le calculateur. Le trigger attrape un writer qui ne l'a jamais appelé.
Une troisième couche est un privilège. UPDATE, DELETE et TRUNCATE sont révoqués du rôle ledger_app sur les deux tables, de sorte qu'un code défectueux ne peut pas réécrire l'histoire même quand il essaie.
| Classe de bug | Table de soldes incrémentée | Grand livre append-only |
|---|---|---|
| Débiteur débité, payeur jamais crédité | Faux en silence pour toujours | La vérification à somme nulle échoue, écriture rejetée |
| Une part change, le total non | Les soldes dérivent en silence | L'écriture ne peut pas être validée si elle est déséquilibrée |
| « Pourquoi mon solde est-il de 412 € ? » | Sans réponse possible | Chaque centime remonte à une écriture |
| Un correctif à chaud en production | Mutation non tracée | Le seul chemin est une nouvelle écriture auditée |
Une modification inscrit une contre-passation, jamais un UPDATE
Modifier une dépense n'écrit rien par-dessus l'ancienne version. Le grand livre consomme un événement expense.amended et inscrit deux écritures dans une seule transaction : un EXPENSE_REVERSAL dont les lignes sont la négation exacte, ligne pour ligne, de la version remplacée, puis un nouvel EXPENSE pour la nouvelle. Une suppression s'arrête après la contre-passation.
La transaction unique compte autant que les deux écritures. Si la contre-passation était validée seule, un groupe ne devrait brièvement rien pour une dépense qu'il doit encore.
Les deux clés d'idempotence sont dérivées de la version plutôt que forgées : expense:<id>:v<n> pour la version, et la clé de la version précédente suivie de :reversal pour la négation. journals.idempotency_key est UNIQUE, si bien qu'un événement redélivré retrouve ses écritures déjà présentes et ne fait rien. C'est la dérivation qui rend la livraison au moins une fois sûre : une nouvelle tentative calcule la clé qu'a utilisée la première.
Un filigrane d'ordre de commit garde une modification derrière sa dépense
Chaque événement que Dimesum publie est écrit dans une table outbox dans la même transaction que l'écriture métier. Si la dépense est validée, l'annonce existe. Si elle est annulée, l'annonce l'est aussi. Le grand livre n'entend jamais parler d'une dépense qui n'existe pas.
L'ordre d'expédition est la moitié la plus difficile. Les identifiants sont des UUIDv7 et se trient par le temps, mais ils encodent le moment où l'identifiant a été forgé, pas le moment où sa transaction a été validée. Un relais qui lit dans l'ordre des identifiants peut dépasser une transaction qui a pris un identifiant antérieur et validé plus tard, de sorte qu'une modification double la dépense qu'elle modifie.
Le correctif tient en une colonne et un prédicat. Chaque ligne d'outbox porte inserted_xid xid8 DEFAULT pg_current_xact_id(), et le relais ne lit que les lignes WHERE inserted_xid < pg_snapshot_xmin(pg_current_snapshot()), triées par ce xid (voir les fonctions d'identifiant de transaction de PostgreSQL). Un événement causalement plus tardif porte toujours un xid plus tardif, car il a dû lire la ligne antérieure pour exister.
Le consommateur de modifications ne prend pas cet ordre pour argent comptant. Une modification dont le prédécesseur n'a pas d'écriture est redélivrée tant qu'elle est jeune, et mise de côté pour un humain une fois passé un délai de grâce de deux minutes.
L'argent est en unités mineures int64, et l'exposant n'est pas toujours deux
Chaque montant est un décompte int64 d'unités mineures plus un code ISO 4217, si bien que 1 234,56 roupies s'écrit {Minor: 123456, Currency: "INR"}. Les entiers sont exacts par construction, pas par discipline : aucune valeur représentable ne vaut un demi-centime, donc aucune opération ne peut en produire un en silence. Le sidecar Python lit son propre AST et fait échouer un test si le mot float apparaît dans son module de montants.
Supposer que l'unité mineure vaut un centième est le piège sous-jacent. Le JPY n'en a aucune et le KWD a trois décimales, donc un taux coté entre unités majeures et appliqué à des unités mineures se trompe d'une puissance de dix. Prenez 2 000 yens à 0,58 : le produit naïf est 1160, qui se lit 11,60 roupies, alors que la réponse est 1 160.
WRITE_OFF est un cinquième type d'écriture parce que ses lignes correspondent à celles d'un règlement
Une remise inscrit les deux mêmes lignes qu'un règlement : le débiteur monte, le créancier descend, du même montant. La fondre dans SETTLEMENT a été rejeté pour la raison précise qui fait que les formes coïncident. « Asha vous a payé 500 euros » et « vous avez remis 500 euros à Asha » sont des faits différents, et un flux qui les confondrait dirait que quelqu'un a payé alors que personne ne l'a fait.
C'est ainsi que WRITE_OFF a rejoint le CHECK des types d'écriture le 2026-08-21, avec ses propres lignes. Les nommer était la moitié de l'enjeu, car une remise réutilisant SETTLE_PAY rendrait fausse en silence toute requête « combien a réellement été payé ».
| Type d'écriture | Inscrit quand | Lignes |
|---|---|---|
EXPENSE | Créée, ou une nouvelle version en remplace une | PAID, SHARE |
EXPENSE_REVERSAL | Modifiée ou supprimée | Les lignes de l'écriture précédente, annulées |
SETTLEMENT | Un remboursement est déclaré | SETTLE_PAY, SETTLE_RECV |
SETTLEMENT_REVERSAL | La contrepartie le conteste | Les deux lignes de règlement, annulées |
WRITE_OFF | Un créancier renonce à une créance | WRITE_OFF_FORGIVEN, WRITE_OFF_GRANTED |
Deux sous-commandes transforment les invariants en tâche cron
evenly verify-ledger reprouve les invariants sur tout le schéma et sort avec un code non nul à la moindre anomalie. Son rapport a quatre champs, et chacun doit être vide : les écritures qui ne totalisent pas zéro, les groupes qui ne totalisent pas zéro, les lignes de projection qui divergent d'un SUM(postings) recalculé, et les événements mis de côté en attente d'un humain. Tous les balayages partagent un même snapshot repeatable-read, de sorte qu'une écriture arrivant en plein verify ne peut pas fabriquer un écart.
Chaque balayage regroupe par devise autant que par identifiant, et l'échec qu'il évite est un faux négatif. Une ligne de 500 roupies et une de moins 500 yens totalisent zéro quand une requête ignore la colonne de devise, si bien qu'un grand livre doublement corrompu paraîtrait sain.
evenly rebuild-balances [group-id] est la réparation et l'exercice. Elle vide la projection et recalcule chaque ligne à partir des lignes d'écriture, estampillant chacune avec la dernière écriture qui a bougé ce membre, comme le fait l'écriture en direct. L'égalité ligne pour ligne est la propriété : quand une reconstruction et la projection en direct divergent, c'est le grand livre qui a raison.
Vérifiez, puis reconstruisez. Le rapport nomme le solde stocké et le solde recalculé pour chaque ligne qui dérive, et une reconstruction écrase la valeur stockée, si bien que reconstruire d'abord détruit la preuve.
Rendez le solde dérivable et la dérive devient une requête
Un grand livre append-only ne mérite sa deuxième écriture que si vous pouvez le prouver, alors construisez le vérificateur et la reconstruction avant la fonctionnalité qui en a besoin. Une reconstruction que personne n'a jamais exécutée est un espoir, pas une issue de secours. Choisissez cette semaine votre projection d'argent la plus risquée, écrivez la requête qui la recalcule depuis la source, et faites-vous alerter quand les deux divergent.
Questions fréquentes
Qu'est-ce qu'un grand livre append-only dans une appli de partage de dépenses ?
Un grand livre append-only enregistre chaque événement d'argent comme une écriture de lignes qui totalisent zéro, et n'en met jamais à jour ni ne le supprime. Dans Dimesum, une dépense, une modification, un règlement, une contestation et une remise ajoutent chacun une nouvelle écriture. Les soldes sont ensuite dérivés en sommant les lignes, si bien que chaque centime remonte à l'événement qui l'a déplacé.
Comment un grand livre append-only gère-t-il une dépense modifiée ?
Une modification inscrit deux écritures dans une transaction : un EXPENSE_REVERSAL qui annule l'original ligne pour ligne, puis un nouvel EXPENSE portant la version de remplacement. Rien n'est réécrit, et une suppression n'inscrit que la contre-passation. Les deux écritures prennent des clés d'idempotence dérivées de la version de la dépense, si bien qu'un événement redélivré les retrouve déjà présentes et ne change rien.
Pourquoi stocker l'argent en entiers plutôt qu'en flottants ?
Les unités mineures entières sont exactes par construction, alors que le flottant binaire ne peut pas représenter 0,1 exactement et dérive au fil de l'historique d'un groupe. Dimesum stocke chaque montant comme un décompte int64 d'unités mineures plus un code ISO 4217. L'exposant vient de la devise : le JPY n'a aucune unité mineure, donc supposer un centième est une erreur d'un facteur 100.
Comment savoir si les soldes de dépenses partagées n'ont pas dérivé ?
Exécutez evenly verify-ledger, qui reprouve les invariants sur tout le schéma et sort avec un code non nul à la moindre anomalie. Il signale les écritures qui ne totalisent pas zéro, les groupes qui ne totalisent pas zéro, les lignes de projection en désaccord avec un SUM(postings) recalculé, et les événements mis de côté. Mettez-le en cron chaque nuit, et réparez une projection erronée avec evenly rebuild-balances.
Pourquoi une remise a-t-elle besoin de son propre type d'écriture ?
Une remise inscrit des lignes identiques à celles d'un règlement, ce qui est précisément pourquoi le type d'écriture devait différer. Seul ce mot sépare « Asha vous a payé 500 euros » de « vous avez remis 500 euros à Asha », et un flux qui les confondrait dirait que quelqu'un a payé alors que personne ne l'a fait. Ses lignes s'appellent WRITE_OFF_FORGIVEN et WRITE_OFF_GRANTED pour que les requêtes de paiement restent correctes.
Articles populaires
- Modifier une dépense partagée doit redéfinir le partage9 min de lecture
- Six bugs de devises dans le partage de dépenses10 min de lecture
- Solder les comptes d'un groupe en moins de virements5 min de lecture
- Partager une addition quand un plat n'a pas été partagé10 min de lecture
- Partager le loyer équitablement entre colocataires6 min de lecture