dimesum

ホーム / ブログ / お金

お金

多通貨の割り勘に潜む6つの金額バグ

· 読了 約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倍に膨らませます。現在この関数は、代わりにリクエスト通貨の指数を参照します。

ハードコードされた乗数100と、ISO 4217の指数表によって換算された1,200円のレシート ¥1,200 currency: JPY 変更前 minor = major x 100 モジュール内の定数 120000 マイナー単位 ¥120,000 と表示される 変更後 minor = major x 10^exp JPY exponent = 0 1200 マイナー単位 ¥1,200 と表示される
指数の落とし穴を一枚の絵で示したものです。乗数の定数100はINRでは正しく、マイナー単位を持たないJPYでは100倍ずれます。同じ定数は、小数3桁を持つKWDでは10倍ずれます。

監査は、人が残高を受け入れるかどうかを判断するページでも同じ定数を見つけました。/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エンジンによって振り分けられた、同じ4つの残高 残高 Asha +¥300,000 Bhavna -¥300,000 Chetan +₹500 Dev -₹500 simplify: Balance{MemberID, Minor} 構造体に通貨がないため、4つすべてが平坦に合算される 300000 + (-300000) + 500 + (-500) = 0 計画: BhavnaがChetanに支払う 円の債務者がルピーの債権者に振り向けられる settle: Balance{MemberID, money.Amount} 通貨で分割し、それぞれの中で振り分ける JPY: BhavnaがAshaに¥300,000を支払う INR: DevがChetanに₹500を支払う 精算は、それが清算する通貨を明示する simplifyは修正ではなく削除される
4つの残高、2つのエンジン。通貨ごとのゼロサムは合算値がゼロであることを意味するため、通貨を無視するエンジンは釣り合ったグループだと見なし、互いに何の貸し借りもない2人の間で自信を持って支払いを振り分けます。

simplifyは削除され、/settle-planは廃止されました。現在はinternal/platform/settleが唯一のエンジンです。これは通貨を換算可能なものと換算不能なものに分割し、正味残高を一度だけ換算し、換算不能な通貨をそれぞれ自身の単位で振り分けます。精算はそれが清算する通貨を明示し、グループが複数の通貨を持つようになった時点で、そのフィールドは必須になります。

上限ゼロが、上限なしと解釈された

過払いガードは、清算する負債よりも大きい支払いを拒否します。このガードはoutstanding > 0 && amount > outstandingと読んでいたため、上限がゼロのときは比較を完全にスキップしていました。存在しない負債に対して支払いを記録するというのは、このルールが存在する唯一のケースであり、そしてそれこそがすり抜けたケースでした。

修正は条件ではなく型です。OutstandingMinorは現在*int64であり、「誰もこれを計算していない」を「答えはゼロである」と同じ形で表せなくなりました。Nilはチェックをスキップし、本当に不明であることを意味します。台帳にアクセスできる呼び出し元は、必ず実際の数値を渡します。

緑のテストスイートが何も証明しなかった理由

これらの経路のフィクスチャはすべてINRを使っていました。通貨を無視した比較は単一通貨のテストでは見えません。通貨が1つしかなければ、取り違えるものが何もないからです。テストスイートは弱かったのではなく、狭かったのです。そして、それを広げていたはずの監査は、A17によって追い返されていました。

エンドツーエンドの台帳検証器も同じ盲点を共有しており、そこが心に留めておくべき点です。この検証器は通貨でグループ化せずにメンバーごとに仕訳を合算していたため、健全な2通貨のグループに対して誤って警報を鳴らし、二重に壊れたグループでは合計をゼロにしていました。危険な方向は偽陰性です。コードと同じ前提の上に作られたチェッカーは、つねにコードに同意します。

2026-08-21の多通貨監査が見つけたすべての欠陥、円のグループに起きたこと、そしてリリースされた内容。
欠陥場所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つの金額経路をスキップしました。