多通貨の割り勘に潜む6つの金額バグ
2026-08-21の監査で、テストが緑のまま見逃していた6つの通貨関連の欠陥が見つかり、原因は静かに事実でなくなっていたドキュメントの一文でした
ドキュメントの古くなった一文が、6つの金額バグを生みました。2026-08-21に実施したDimesumの多通貨監査は、私たちのテストがずっと緑のまま通り続けていたコードの中からそれらを見つけ出しました。その中には、¥6,000の負債を-₹60.00と表示していた請求ページも含まれます。その一文とは「グループはINRに固定される」というものでした。W7までは正しく、リリースされた瞬間に誤りとなり、それでも1週間後までFOLLOWUPS.mdに残っていました。
古くなった一文は、ドキュメントが無いことよりも危険です。後の監査はそれを信じ、その一文が扱う経路を確認しないため、誤った記述は沈黙では決して生じない被害をもたらします。空のドキュメントは、あなたをソースコードへと向かわせます。誤ったドキュメントは、まったく別の場所へと向かわせます。
事実になったフォローアップは、未対応のものよりたちが悪い
Dimesumは、先送りにした作業をFOLLOWUPS.mdに置いており、判断ごとに1行、そして先送りにした理由を記します。項目A17には、グループはINRに固定されている、海外旅行はそれが発生した通貨で記録できない、そして修正は創業者の判断待ちである、と書かれていました。それでもW7は多通貨機能をリリースしました。経費はどの通貨でも記録でき、残高はマイグレーションledger/00005によって(group_id, member_id, currency)をキーとします。誰もその行を消しませんでした。
次の監査はA17を読み、この領域は未実装だと結論づけ、精算・請求・ランディングのコードを一度も開きませんでした。通貨を無視した3つの金額経路が、たった一文を根拠にリリースされ続けました。難しい問題が立ちはだかっていたわけではありません。app/amount.pyのドックストリングにも同じ記述があったため、読み手は二度同じことを告げられました。
フォローアップは、それを解消するのと同じコミットで消します。そのように動かなくなったコードを説明する行は、読むべきファイルから遠ざける道案内であり、何も言わないよりも高くつきます。
同じハードコードの100が、3つの異なるファイルに現れた
ここでの金額は、int64のマイナー単位とISO 4217コードの組み合わせであり、浮動小数点数が触れることはありません。整数は構造上つねに正確なので、残る危険な計算はメジャー単位とマイナー単位の換算だけです。マイナー単位はつねに100分の1とは限りません。JPYにはまったく存在せず、KWDは小数3桁を持ち、ハードコードされた100はどれも、ユーザーが自国にとどまったという賭けです。
Pythonサイドカーのapp/amount.pyが最初の発見箇所で、稼働中の欠陥としてではなく地雷として監査の前に取り除かれました。これは文章から読み取った数値を何であれ100倍していたため、¥1,200のレシートが120,000マイナー単位になっていました。それは¥120,000と表示されます。マイナー単位を持たない通貨では、この定数は誰かの請求額を100倍に膨らませます。現在この関数は、代わりにリクエスト通貨の指数を参照します。
監査は、人が残高を受け入れるかどうかを判断するページでも同じ定数を見つけました。/m/{token}の請求ランディングの裏にあるformatMinorは、100で割り、先頭にルピー記号を貼り付けていました。¥6,000の負債が-₹60.00と表示されました。記号は誤りで、金額は100分の1です。そのJSON版の兄弟である/v1/claimsも同時に失敗し、メンバーの通貨別の行を1つに潰し、マップが最後に書き込んだ通貨を保持していました。
3つ目のファイルはinternal/ingestion/store.goで、監査ではなくW8が重複検出の拡張中に見つけました。その「プラスマイナス1ルピー」の許容範囲は定数100、つまりpaiseで数えた1ルピーであり、マイナー単位をまったく持たないJPYではプラスマイナス¥100の幅でした。現在は3つとも指数を参照します。
素の整数を比較したことで、円がルピーのように見えた
Dimesumの重複排除レイヤーは、新しい取り込みを最近の経費と照合し、取り込んだレシートが誰かが手入力した注文に二重課金しないようにします。その許容範囲の隣にあるクエリには、通貨フィルターがまったくありませんでした。そのため¥1,000が₹1,000と一致し、取り込みは無関係な経費を置き換えようとしました。グループはledger/00005以降ずっと多通貨だったため、このケースは理論上ではなく実際に起こり得るものでした。
素のマイナー単位は通貨をまたいで比較できず、許容範囲も同様です。現在このプロジェクションは通貨でフィルターし、その幅を比較対象の通貨のメジャー単位1つから導き出します。
2つのエンジンが「誰が誰に支払うか」に答え、2つ目は通貨を無視していた
代償の大きい欠陥は、計算ではなく構造にあります。精算の書き込み経路はinternal/platform/simplifyを通じて計画を立てていました。これは2つ目の最小キャッシュフローエンジンで、そのBalance構造体には通貨フィールドがありませんでした。通貨ごとのゼロサムは合算値もゼロにするため、¥300,000の負債と₹500の負債が相殺されて何も残らず、どのガードもその計画を拒否しませんでした。その結果、計画は円の債権者とルピーの債務者を組み合わせました。
書き込みはそれをさらに悪化させました。サービスはすべての精算にCurrency: g.DefaultCurrencyを刻んでいたため、¥300,000の負債がINRの仕訳を承認し、円の負債はまったく清算できませんでした。
simplifyは削除され、/settle-planは廃止されました。現在はinternal/platform/settleが唯一のエンジンです。これは通貨を換算可能なものと換算不能なものに分割し、正味残高を一度だけ換算し、換算不能な通貨をそれぞれ自身の単位で振り分けます。精算はそれが清算する通貨を明示し、グループが複数の通貨を持つようになった時点で、そのフィールドは必須になります。
上限ゼロが、上限なしと解釈された
過払いガードは、清算する負債よりも大きい支払いを拒否します。このガードはoutstanding > 0 && amount > outstandingと読んでいたため、上限がゼロのときは比較を完全にスキップしていました。存在しない負債に対して支払いを記録するというのは、このルールが存在する唯一のケースであり、そしてそれこそがすり抜けたケースでした。
修正は条件ではなく型です。OutstandingMinorは現在*int64であり、「誰もこれを計算していない」を「答えはゼロである」と同じ形で表せなくなりました。Nilはチェックをスキップし、本当に不明であることを意味します。台帳にアクセスできる呼び出し元は、必ず実際の数値を渡します。
緑のテストスイートが何も証明しなかった理由
これらの経路のフィクスチャはすべてINRを使っていました。通貨を無視した比較は単一通貨のテストでは見えません。通貨が1つしかなければ、取り違えるものが何もないからです。テストスイートは弱かったのではなく、狭かったのです。そして、それを広げていたはずの監査は、A17によって追い返されていました。
エンドツーエンドの台帳検証器も同じ盲点を共有しており、そこが心に留めておくべき点です。この検証器は通貨でグループ化せずにメンバーごとに仕訳を合算していたため、健全な2通貨のグループに対して誤って警報を鳴らし、二重に壊れたグループでは合計をゼロにしていました。危険な方向は偽陰性です。コードと同じ前提の上に作られたチェッカーは、つねにコードに同意します。
| 欠陥 | 場所 | JPYグループに起きたこと | 修正 |
|---|---|---|---|
通貨を持たないBalance構造体 | platform/simplify | 円の債権者がルピーの債務者と組み合わされた | simplifyを削除。settleが通貨で分割する |
| グループの既定値がすべての精算に刻まれた | 精算サービス | ¥300,000の負債がINRの仕訳を承認した | 精算は清算する通貨を明示する |
過払いガードのoutstanding > 0 | 精算サービス | 上限ゼロが、まったく上限なしになった | *int64。nilは不明、ゼロはゼロを意味する |
| ルピー記号付きで100で割る | ゲートウェイのformatMinor | ¥6,000の負債が-₹60.00と表示された | 記号と小数桁を通貨から取得する |
| 通貨別の残高が1行に潰された | /v1/claims | マップが最後に書き込んだ通貨 | 通貨ごとに1エントリ、GET /balancesと一致させる |
| メンバーごとに仕訳を合算し、通貨を落とした | エンドツーエンドの台帳検証器 | 二重に壊れたグループが釣り合っていると報告された | メンバーと通貨でグループ化する |
金額コードをリテラルの100でgrepする
その定数を探し、通貨を添えずに2つの金額を並べて比較しているすべての箇所を探します。次に、より安く済む方、つまり次の6つを防ぐ方を直します。フォローアップは、それを解消するのと同じコミットで消します。そして重複したエンジンは、修理するのではなく削除します。「誰が誰に支払うか」に2つの答えがあることが、その一方が誤ったままになる原因です。
よくある質問
多通貨の割り勘とは何ですか?
多通貨の割り勘は、各経費をそれが発生した通貨で記録し、すべてを1つの通貨に換算するのではなく、通貨ごとに別々の残高を保持します。Dimesumはグループ・メンバー・通貨で残高をキー付けするため、円の負債とルピーの負債が1つの数値に統合されることはありません。換算は精算計画のためのビューであり、保存される金額ではありません。
金額計算でハードコードした100はなぜ危険なのですか?
ハードコードされた100は、すべての通貨が小数2桁を持つと想定しますが、そうでない通貨がいくつもあります。JPYはマイナー単位を持たないため、100を掛けると¥1,200のレシートが120,000マイナー単位になり、丸め誤差ではなく100倍の水増しになります。KWDは小数3桁なので、同じ定数は10倍ずれます。代わりにISO 4217の指数を参照してください。
精算エンジンの計算結果が食い違わないようにするには?
2つのエンジンを突き合わせるのではなく、一方を削除します。誰が誰に支払うかについて2つ目の答えがあることが、最初の答えが誤ったままになる原因だからです。Dimesumは simplify と settle を並行して動かしていましたが、監査によって前者の残高型に通貨がないことが判明し、それが計画に円の債権者とルピーの債務者を組み合わせさせていました。simplify は削除され、/settle-plan はパッチではなく廃止されました。
6つの金額バグでテストが通り続けたのはなぜですか?
テストが緑のままだったのは、影響を受けた経路のフィクスチャがすべて単一通貨を使っていたためで、通貨が1つしかなければ通貨を無視した比較は失敗しようがありません。エンドツーエンドの台帳検証器も同じ前提を持っていました。通貨でグループ化せずにメンバーごとに仕訳を合算していたため、二重に壊れたグループを釣り合っていると報告しました。コード自身の前提の上に作られたチェッカーは、コードに同意します。
機能リリース後にフォローアップ項目はどう扱うべきですか?
フォローアップ項目は、それが説明する作業を完了させるのと同じコミットで消します。静かに事実になってしまったフォローアップは、未対応のものよりたちが悪く、後の読み手を、かつて説明していたコードから遠ざけてしまうからです。Dimesumは、多通貨機能のリリース後も1週間、グループはINRに固定されているという項目を残していたため、次の監査はその記述を信じて3つの金額経路をスキップしました。
人気の記事
- 割り勘の残高を正確に保つ追記専用の台帳読了 約6分
- 共有費用を編集するときに分担をやり直す理由読了 約5分
- 精算:グループの費用を少ない送金で清算する方法読了 約6分
- 取り分けなかった一皿がある割り勘の精算方法読了 約6分
- ルームシェアの家賃を公平に分ける完全ガイド読了 約6分