dimesum

Accueil / Blog / Argent

Argent

Six bugs de devises dans le partage de dépenses

· 10 min de lecture ·

Un audit du 2026-08-21 a trouvé six défauts de devises que nos tests laissaient passer, causés par une phrase de doc discrètement devenue fausse.

Une phrase de doc périmée a coûté six bugs monétaires. Un audit multidevises de Dimesum le 2026-08-21 les a trouvés dans du code que nos suites de tests validaient, dont une page de réclamation qui affichait une dette de ¥6,000 comme -₹60.00. La phrase était « les groupes sont épinglés sur l'INR ». Vraie jusqu'à W7, fausse dès sa livraison, et toujours présente dans FOLLOWUPS.md une semaine plus tard.

Une phrase périmée est plus dangereuse que pas de documentation du tout. Un audit ultérieur y croit et saute les chemins qu'elle couvre, donc une ligne erronée fait des dégâts que le silence ne pourrait jamais faire. Un doc vide vous renvoie à la source. Un doc erroné vous envoie ailleurs, entièrement.

Une entrée de suivi devenue vraie est pire qu'une entrée ouverte

Dimesum gare le travail différé dans FOLLOWUPS.md, une ligne par décision avec la raison de son report. L'entrée A17 disait que les groupes étaient épinglés sur l'INR, qu'un voyage à l'étranger ne pouvait pas être enregistré dans la devise où il avait lieu, et que le correctif attendait une décision d'un fondateur. W7 a quand même livré le multidevises : une dépense peut être dans n'importe quelle devise, et les soldes sont indexés (group_id, member_id, currency) par la migration ledger/00005. Personne n'a rayé la ligne.

L'audit suivant a lu A17, conclu que la zone n'était pas construite, et n'a jamais ouvert le code de règlement, de réclamations ou de la landing page. Trois chemins monétaires aveugles à la devise ont continué d'être livrés sur la foi d'une seule phrase. Aucun problème difficile ne se dressait sur le chemin. La docstring de app/amount.py portait la même affirmation, si bien qu'un lecteur se le faisait dire deux fois.

La règle que nous avons adoptée

Rayez une entrée de suivi dans le même commit qui la clôt. Une ligne décrivant du code qui a cessé de fonctionner ainsi est une redirection loin du fichier que vous deviez lire, ce qui coûte plus cher que de ne rien dire.

Le même 100 codé en dur est apparu dans trois fichiers différents

Ici l'argent, ce sont des unités mineures int64 plus un code ISO 4217, et les flottants n'y touchent jamais. Les entiers sont exacts par construction, donc la seule arithmétique risquée qui reste est la conversion entre unités majeures et mineures. L'unité mineure n'est pas toujours un centième. Le JPY n'en a aucune, le KWD a trois décimales, et chaque 100 codé en dur est un pari que l'utilisateur est resté chez lui.

Le app/amount.py du sidecar Python détenait la première apparition, retirée avant l'audit comme une mine plutôt qu'un défaut actif. Il multipliait par 100 tout chiffre lu dans une phrase, si bien qu'un reçu de ¥1,200 devenait 120,000 unités mineures. Cela se lit comme ¥120,000. Sur une devise sans unité mineure, la constante gonfle la facture de quelqu'un au centuple, et la fonction cherche désormais l'exposant de la devise de la requête au lieu de cela.

Un reçu de 1,200 yens converti par un multiplicateur codé en dur de 100 et par la table des exposants ISO 4217 ¥1,200 devise : JPY AVANT minor = major x 100 constante dans le module 120000 unités mineures se lit comme ¥120,000 APRÈS minor = major x 10^exp exposant JPY = 0 1200 unités mineures se lit comme ¥1,200
Le piège de l'exposant en une image. Un multiplicateur constant de 100 est correct pour l'INR et faux d'un facteur 100 pour le JPY, qui n'a pas d'unité mineure ; la même constante se trompe d'un facteur dix pour le KWD, qui a trois décimales.

L'audit a trouvé la même constante sur la page où une personne décide d'accepter ou non un solde. formatMinor, derrière la réclamation affichée à /m/{token}, divisait par 100 et collait un signe roupie devant. Une dette de ¥6,000 s'imprimait comme -₹60.00 : mauvais symbole, un centième du montant. Son jumeau JSON /v1/claims échouait à ses côtés, aplatissant les lignes par devise d'un membre en une seule et gardant la devise que la map avait écrite en dernier.

Le troisième fichier est internal/ingestion/store.go, et la W8 l'a trouvé en étendant la détection de doublons plutôt que par l'audit. Sa tolérance « plus ou moins une roupie » était la constante 100, une roupie comptée en paise, une bande de plus ou moins ¥100 là où le JPY n'a aucune unité mineure. Les trois lisent l'exposant désormais.

Comparer des entiers nus faisait passer un yen pour une roupie

La couche de déduplication de Dimesum vérifie une nouvelle capture par rapport aux dépenses récentes pour qu'un reçu importé ne puisse pas facturer en double une commande saisie à la main. La requête à côté de cette bande de tolérance n'avait aucun filtre de devise. Ainsi ¥1,000 correspondait à ₹1,000, et un import proposait de remplacer une dépense avec laquelle il n'avait rien à voir. Les groupes étaient multidevises depuis ledger/00005, donc le cas était atteignable plutôt que théorique.

Les unités mineures nues ne sont pas comparables entre devises, et les tolérances non plus. La projection filtre désormais sur la devise, et dérive sa bande d'une unité majeure de la devise comparée.

Deux moteurs répondaient à « qui paie qui », et le second était aveugle à la devise

Le défaut coûteux est structurel, pas arithmétique. Le chemin d'écriture du règlement planifiait via internal/platform/simplify, un second moteur de flux de trésorerie minimal dont la structure Balance n'avait pas de champ devise. La somme nulle par devise rend la somme à plat nulle aussi, donc une dette de ¥300,000 et une dette de ₹500 s'annulaient à néant et aucune garde ne refusait le plan. Le plan associait alors un créancier en yens à un débiteur en roupies.

L'écriture aggravait les choses. Le service estampillait Currency: g.DefaultCurrency sur chaque règlement, si bien qu'une dette de ¥300,000 autorisait une écriture INR, et la dette en yens ne pouvait pas être soldée du tout.

Les quatre mêmes soldes routés par le moteur simplify aveugle à la devise et par le moteur settle conscient de la devise SOLDES Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} aucune devise sur la structure, donc les quatre se soldent à plat 300000 + (-300000) + 500 + (-500) = 0 plan : Bhavna paie Chetan un débiteur en yens envoyé à un créancier en roupies settle: Balance{MemberID, money.Amount} partition par devise, puis routage à l'intérieur de chacune JPY : Bhavna paie Asha ¥300,000 INR : Dev paie Chetan ₹500 le règlement indique la devise qu'il solde simplify est supprimé, pas corrigé
Quatre soldes, deux moteurs. La somme nulle par devise implique une somme à plat de zéro, donc un moteur aveugle à la devise voit un groupe équilibré et route avec assurance un paiement entre deux personnes qui ne se doivent rien.

simplify est supprimé et /settle-plan est retiré. internal/platform/settle est le seul moteur désormais : il partitionne les devises en convertibles et non convertibles, convertit les soldes nets une fois, et route chaque devise non convertible dans sa propre dénomination. Un règlement indique la devise qu'il solde, et ce champ est requis dès qu'un groupe détient plus d'une devise.

Un plafond de zéro se lisait comme aucun plafond

La garde contre le trop-payé refuse un paiement plus grand que la dette qu'il solde. La garde lisait outstanding > 0 && amount > outstanding, donc un plafond de zéro sautait entièrement la comparaison. Quelqu'un qui enregistre un paiement contre une dette qui n'existe pas est le seul cas pour lequel la règle existe, et c'était le cas qui passait sans encombre.

Le correctif est un type, pas une condition. OutstandingMinor est désormais un *int64, si bien que « personne n'a calculé ceci » ne peut plus s'écrire de la même façon que « la réponse est zéro ». Nil saute la vérification et signifie vraiment inconnu ; tout appelant ayant accès au grand livre passe un vrai nombre.

Pourquoi une suite verte ne prouvait rien

Chaque fixture de ces chemins utilisait l'INR. Une comparaison aveugle à la devise est invisible sous un test à devise unique, car avec une seule devise il n'y a rien à confondre. Les suites n'étaient pas faibles, elles étaient étroites, et l'audit qui les aurait élargies avait été renvoyé par A17.

Le vérificateur de grand livre de bout en bout partageait cet aveuglement, et c'est la partie qui vaut d'être retenue. Le vérificateur additionnait les écritures par membre sans regrouper par devise, si bien qu'il criait au loup sur un groupe sain à deux devises et sommait à zéro sur un groupe cassé deux fois. Un faux négatif est la direction dangereuse. Un vérificateur bâti sur la même hypothèse que le code sera toujours d'accord avec le code.

Chaque défaut trouvé par l'audit multidevises du 2026-08-21, ce qu'un groupe en yens a obtenu, et ce qui a été livré.
DéfautCe qu'un groupe JPY a obtenuCorrectif
Une structure Balance sans devise dessusplatform/simplifyun créancier en yens associé à un débiteur en roupiessimplify supprimé ; settle partitionne par devise
Le défaut du groupe estampillé sur chaque règlementservice de règlementune dette de ¥300,000 autorisait une écriture INRun règlement indique la devise qu'il solde
outstanding > 0 dans la garde contre le trop-payéservice de règlementun plafond de zéro devenait aucun plafond du tout*int64 : nil signifie inconnu, zéro signifie zéro
Division par 100 avec un signe roupiegateway formatMinorune dette de ¥6,000 s'imprimait comme -₹60.00symbole et décimales selon la devise
Soldes par devise aplatis en une ligne/v1/claimsla devise que la map écrivait en dernierune entrée par devise, correspondant à GET /balances
Écritures sommées par membre, devise perduevérificateur de grand livre de bout en boutun groupe doublement cassé déclaré équilibréregrouper par membre et devise

Cherchez le littéral 100 dans votre code monétaire

Cherchez-y cette constante, et chaque comparaison qui met deux montants côte à côte sans devise à leurs côtés. Puis corrigez la chose la moins chère, celle qui prévient les six suivants : rayez une entrée de suivi dans le même commit qui la clôt. Et supprimez un moteur en double plutôt que de le réparer. Deux réponses à « qui paie qui » est la façon dont l'une d'elles reste dans l'erreur.

Questions fréquentes

Qu'est-ce que le partage de dépenses multidevises ?

Le partage de dépenses multidevises enregistre chaque dépense dans la devise où elle a eu lieu et conserve un solde distinct par devise au lieu de tout convertir en une seule. Dimesum indexe les soldes par groupe, membre et devise, si bien qu'une dette en yens et une dette en roupies ne fusionnent jamais en un seul nombre. La conversion est une vue utilisée pour un plan de règlement, jamais un montant stocké.

Pourquoi un 100 codé en dur est-il dangereux dans du code monétaire ?

Un 100 codé en dur suppose que chaque devise a deux décimales, ce qui est faux pour plusieurs. Le JPY n'a pas d'unité mineure, donc multiplier par 100 transforme un reçu de ¥1,200 en 120,000 unités mineures, une inflation au centuple plutôt qu'une erreur d'arrondi. Le KWD a trois décimales, donc la même constante se trompe d'un facteur dix. Lisez plutôt l'exposant ISO 4217.

Comment éviter que deux moteurs de règlement se contredisent ?

Supprimez l'un des deux moteurs au lieu de les réconcilier, car une seconde réponse à qui paie qui est ce qui maintient la première dans l'erreur. Dimesum a fait tourner simplify et settle côte à côte jusqu'à ce qu'un audit découvre que le premier n'avait aucune devise sur son type de solde, ce qui permettait à un plan d'associer un créancier en yens à un débiteur en roupies. simplify a été supprimé et /settle-plan retiré plutôt que corrigé.

Pourquoi la suite de tests restait-elle verte malgré six bugs monétaires ?

La suite de tests restait verte parce que chaque fixture des chemins concernés utilisait une seule devise, et une comparaison aveugle à la devise ne peut pas échouer quand il n'y a qu'une devise. Le vérificateur de grand livre de bout en bout partageait la même hypothèse : il additionnait les écritures par membre sans regrouper par devise, si bien qu'il déclarait équilibré un groupe doublement cassé. Un vérificateur bâti sur l'hypothèse du code lui-même est d'accord avec le code.

Que faire d'une entrée de suivi une fois la fonctionnalité livrée ?

Rayez une entrée de suivi dans le même commit qui clôt le travail qu'elle décrit. Une entrée de suivi devenue vraie en silence est pire qu'une entrée ouverte, car elle éloigne un lecteur ultérieur du code qu'elle décrivait autrefois. Dimesum a laissé une entrée disant que les groupes étaient épinglés sur l'INR pendant une semaine après la livraison du multidevises, et l'audit suivant a sauté trois chemins monétaires sur cette parole.