金融アプリにおける通貨の丸め:お金を安全に保存する
金融アプリの通貨丸めは1セントの誤差を生みます。マイナー単位の整数保存、税の丸めポリシー、Web とモバイルでの一貫した表示方法を学びましょう。

1セントの不具合が起きる理由
1セントの不具合はユーザーがすぐに気づくようなミスです。商品一覧では合計が $19.99 と表示されていたのに、チェックアウトで $20.00 になる。返金が $14.38 になるはずが $14.37 になっている。請求書の明細に「Tax: $1.45」とあるのに、合計が別の税計算で足されたように見える。
これらの問題は、積み重なる小さな丸め差から生じることが多いです。お金は単なる「数」ではありません。通貨が何桁の小数を使うか、いつ丸めるか、行ごとに丸めるか最終合計で丸めるかといったルールがあります。アプリの一箇所で別の選択をすると、1セントが現れたり消えたりします。
また、これらは時々しか現れないことが多く、デバッグを難しくします。同じ入力でも、デバイスやロケール設定、演算の順序、型変換の仕方によって結果のセントが変わります。
よくあるきっかけは、float で計算して「最後に丸める」ことを前提にする(だが「最後」が場所によって違う)、ある画面では行ごとに税を適用し別の画面では小計に対して税を適用する、通貨や為替レートを混在させて不一致な手順で丸める、表示用にフォーマットした値を誤って数値として再解析する、などです。
ダメージは信頼が壊れやすい箇所で特に大きく、チェックアウト合計、返金、請求書、サブスクリプション、チップ、支払い、経費精算などで顕在化します。1セントの不一致が支払い失敗や照合の手間、そして「アプリにお金を盗まれた」といったサポートチケットを生みます。
目標は簡単です:同じ入力はどこでも同じセントを生むこと。商品が同じ、税が同じ、割引が同じ、丸めルールが同じであれば、画面やデバイス、言語、エクスポート先が違っても結果が同じであるべきです。
例:$9.99 の商品が2つあり税率が7.25%なら、税を行ごとに丸めするか小計で丸めするかを決め、それをバックエンド、Web UI、モバイルアプリで同じように実装します。一貫性が「ここだけ違うのはなぜ?」という瞬間を防ぎます。
なぜ float はお金に危険か
多くのプログラミング言語は float や double を2進数で格納します。多くの10進の価格は2進で正確に表現できないため、保存したつもりの値が実際にはわずかに大きいか小さい場合があります。
古典的な例は 0.1 + 0.2 です。多くの環境で 0.30000000000000004 になります。これ自体は無害に見えますが、お金のロジックは通常チェーンです:商品価格、割引、税、手数料、最終丸め。小さな誤差が丸めの判断を変え、1セントの差を生むことがあります。
丸めがうまくいっていないときに人が気づく症状:
- ログや API レスポンスに 9.989999 や 19.9000001 のような値が出る。
- 多数の商品を足すと合計がずれるが、各行は問題なさそうに見える。
- 返金合計が元の請求と $0.01 合わない。
- 同じカート合計が Web、モバイル、バックエンドで異なる。
表示のためのフォーマットが問題を隠すこともあります。9.989999 を小数2桁で表示すると 9.99 なので一見正しく見えます。しかし多数の値を合算したり、合計を比較したり、税の後で丸めたりするときにバグが露呈します。だからチームがこれを出荷して、決済プロバイダや会計エクスポートで照合したときに発見することがよくあります。
簡単な経験則:お金を浮動小数点数として保存・合算しないこと。通貨の最小単位(セントなど)の整数として扱うか、正確な10進演算を保証する decimal 型を使ってください。
バックエンド、Web、モバイル(ノーコードプラットフォームを含む)を作るなら、どこでも同じ原則を守ってください:正確な値を保存し、正確な値で計算し、表示のためにだけ最終的にフォーマットすること。
実際の通貨に合ったマネーモデルを選ぶ
多くの金額バグは算術より前、データモデルが通貨の実態に合っていないところから始まります。最初にモデルを正しくすることで、丸めは推測の問題ではなくルールの問題になります。
最も安全なデフォルトは通貨の最小単位を整数で保存することです。USD ならセント、EUR ならユーロセント。データベースとコードは正確な整数を扱い、人間向けに表示するときだけ小数を付けます。
すべての通貨が小数2桁というわけではないため、モデルは通貨に依存する必要があります。JPY は小数0桁(1円が最小単位)、BHD は一般に小数3桁(1ディナール = 1000 フィルス)です。「どこでも小数2桁」とハードコードすると、知らずに過剰請求や不足請求をしてしまいます。
実用的な金額レコードには通常以下が必要です:
amount_minor(整数、$19.99 は 1999 のように)currency_code(USD、EUR、JPY のような文字列)- システムで参照できない場合は任意で
minor_unitやscale(0、2、3 など)
各金額に通貨コードを保存してください。テーブル内であっても通貨コードを持っていると、後で多通貨価格や返金、レポートを追加した際のミスを防げます。
また、どこで丸めを許すか、どこで禁止するかを決めてください。よく成立する方針の一例は:内部の合計や振り分け、台帳、進行中の換算では丸めしない。丸めは定義された境界(税のステップ、割引ステップ、最終請求行など)でのみ行う。さらに、使用した丸めモード(half up、half even、切り捨てなど)をログに残して再現可能にすることです。
ステップバイステップ:マイナー単位整数で金額を実装する
驚きが少ない設計にするには、内部で使う金額の形を一つに決めて壊さないこと:通貨のマイナー単位(しばしばセント)を整数で保存します。
つまり $10.99 は 1099、通貨は USD。小数単位のない通貨(JPY)の場合、1,500 円は 1500 のままです。
スケールする簡単な実装パス:
- データベース:
amount_minorを 64 ビット整数で保存し、通貨コードも持たせます。カラム名は誰も小数と間違えないように明確に。 - API 契約:
{ amount_minor: 1099, currency: "USD" }のように送受信します。"$10.99" のようなフォーマット済み文字列や JSON の浮動小数点は避けます。 - UI 入力: ユーザーが入力したものは数値ではなく文字列として扱い、正規化(空白除去、1つの小数区切りの許容など)してから通貨の小数桁に従って変換します。
- すべての算術を整数で: 合計、行合計、割引、手数料、税はすべて整数で行います。「パーセント割引は計算してからマイナー単位に丸める」などのルールを定義し、常に同じ方法で適用します。
- 表示は最後にだけ: 金額を表示する際に
amount_minorをロケールと通貨ルールに従って文字列に変換します。フォーマットした出力を再度数値として解析してはなりません。
実用的な解析例:USD で "12.3" が与えられたら "12.30" と扱ってから 1230 に変換します。JPY ではそもそも小数を弾く(受け付けない)方がよいでしょう。
税金、割引、手数料の丸めルール
多くの1セント論争は算術ミスではなく方針ミスです。二つのシステムがどちらも“正しい”場合でも、丸めのタイミングが違えば結果が異なります。
丸めポリシーを書き出して全てに適用してください:計算、領収書、エクスポート、返金。一般的な選択肢には round half-up(0.5 を切り上げ)や round half-even(0.5 を最も近い偶数に丸め)があります。ある種の手数料は常に切り上げ(天井)にする必要がある場合もあります。
合計がどう変わるかは、行ごとに丸めるか請求書ごとに丸めるか、ルールを混在させていないか(例えば税は行ごと、手数料は請求書単位)や価格が税込か税別かによって変わります。税込価格だと正味額と税を逆算する必要があり、税別だと正味から税を計算します。
割引はさらに別の分岐点を生みます。"10% オフ" を税前に適用すると課税対象が減り、税後に割引を適用すると顧客が支払う額は減りますが報告される税額が変わるかどうかは法域や契約によります。
小さな例で厳密さの重要性を示します。$9.99 の商品が2つ、税率 7.5% の場合。税を行ごとに丸めすると、各行の税は $0.75(9.99 x 0.075 = 0.74925)で合計税は $1.50。請求書合計で税を計算してもここでは $1.50 になりますが、価格を少し変えると 1 セントの違いが出ます。
サポートや経理が説明できるように、平易な言葉でルールを書いてください。そして税、手数料、割引、返金のために同じヘルパーを再利用しましょう。
合計がずれない通貨換算
多通貨の計算は小さな丸めの選択が徐々に合計を変えてしまう場所です。目標は明確です:一度だけ換算し、意図して丸めを行い、元の事実を保持すること。
為替レートは明示的な精度で保存してください。よくあるパターンはスケールされた整数(例:1.234567 を scale 1,000,000 で 1234567 として保存)です。別の選択肢は固定小数点型ですが、いずれにせよフィールドにスケールを書き残して推測できないようにします。
報告や会計のベース通貨(通常は会社通貨)を決め、受け取った金額は台帳や分析用にベース通貨に変換して保存しますが、元の通貨と金額も併せて保持します。これで後からすべての数値を説明できます。
ドリフトを防ぐルール:
- 会計用には一方向(外貨→ベース)でのみ換算し、往復の換算を避ける。
- 丸めのタイミングを決める:行合計で丸めする必要がある場合は行ごとに、総合計のみ表示するなら最後に丸める。
- 丸めモードを一つに決めて文書化する。
- 取引に使った元の金額、通貨、正確なレートを保持する。
例:顧客が 19.99 EUR を支払ったら、それをマイナー単位 1999(currency=EUR)として保存し、チェックアウト時に使用したレート(例:EUR→USD をマイクロ単位で)も保存します。台帳には選んだルールで換算した USD 金額を保存しますが、返金は保存してある元の EUR 金額を使い、USD から再換算しないようにします。これで「なぜ 19.98 EUR が返金されたのか?」という問い合わせを防げます。
デバイス間でのフォーマットと表示
最後の一歩は画面上での表示です。ストレージ上は正しくても、フォーマットが Web とモバイルで異なると見た目が間違っているように見えます。
ロケールによって区切り文字やシンボルの位置が異なります。例えば米国では $1,234.50、ヨーロッパの多くでは 1.234,50 € のように(同じ値でも区切りとシンボル位置が違う)。フォーマットをハードコードすると混乱を招き、サポート対応が増えます。
守るべき一つのルール:中心部(コア)でフォーマットせずエッジでフォーマットする。真のソースオブトゥルースは (currency code, minor units integer) であり、文字列化は表示のためだけに行います。フォーマット済み文字列を再パースするのは丸めや区切りで驚きを生む場所です。
返金など負の金額については一貫した表示スタイルを選んで全画面で使ってください。システムによっては -$12.34 を使い、別のシステムは ($12.34) を使います。どちらでも構いませんが、画面間で切り替えるとエラーに見えます。
デバイス横断で有効な単純な契約:
- 通貨はシンボルではなく ISO コード(USD、EUR)のままで運ぶ。
- デフォルトはデバイスのロケールに従ってフォーマットし、アプリ内でオーバーライド可能にする。
- マルチ通貨画面では金額の横に通貨コードを表示する(例:12.34 USD)。
- 入力のフォーマットは表示のフォーマットと別に扱う。
- 丸めはマネールールに基づいて一度だけ行ってから表示する。
例:顧客がモバイルで 10,00 EUR の返金を見てデスクトップで -€10 と見たとき、コードも表示して(10,00 EUR)かつ負数表記を統一していれば変更があったのかと疑問に思いません。
例:驚きのないチェックアウト、税、返金
簡単なカート:
- Item A: $4.99 (499 cents)
- Item B: $2.50 (250 cents)
- Item C: $1.20 (120 cents)
小計 = 869 cents ($8.69)。まず10%割引を適用:869 x 10% = 86.9 cents、87 cents に丸め。割引後小計 = 782 cents ($7.82)。次に税率 8.875% を適用。
ここで丸めルールが最終的な1セントを変え得ます。
請求書合計で税を計算すると:782 x 8.875% = 69.4025 cents、69 cents に丸め。
行ごとに(割引後)税を計算して各行を丸めすると:
- Item A: $4.49 の税 = 39.84875 cents → 40
- Item B: $2.25 の税 = 19.96875 cents → 20
- Item C: $1.08 の税 = 9.585 cents → 10
行税合計 = 70 cents。同じカート、同じ税率でも別の妥当なルールで 1 セント違います。
税の後に送料を追加すると、例えば送料 399 cents ($3.99) を加えると、請求書レベル税なら合計は $12.50、行レベル税なら $12.51 になります。一つのルールを選んで文書化し、一貫して使ってください。
次に Item B のみを返金するとします。割引後価格(225 cents)とそれに対応する税を返金します。行レベル税なら 225 + 20 = 245 cents ($2.45)。残りの合計は依然として正しく突合します。
後で差異を説明するために、各請求と返金で以下をログに残すとよいでしょう:
- 行ごとの正味 cents、行ごとの税 cents、及び丸めモード
- 請求書割引 cents とその配分方法
- 使用した税率と課税ベース cents
- 送料/手数料 cents とそれが課税対象かどうか
- 最終合計 cents と返金 cents
お金の計算をどうテストするか
多くの金額バグは「算術のバグ」ではなく、丸め、順序、フォーマットのバグで、特定のカートや日付でしか出ません。良いテストはそうしたケースを平凡にします。
まずはゴールデンテスト:厳密な入力とマイナー単位での期待出力を固定します。アサーションは厳密に。アイテムが 199 cents で税が 15 cents なら、テストは整数値をチェックし、フォーマット済み文字列ではチェックしないでください。
少数のゴールデン事例で多くをカバーできます:
- 単一行アイテムと税、割引、手数料(各中間丸めをチェック)
- 行ごとの税丸めと小計での丸めを比較する多数行アイテム
- 返金と部分返金(符号や丸め方向を検証)
- 換算の往復(A→B→A)とどこで丸めるかのポリシー
- エッジ値(1セントアイテム、多数数量、非常に大きな合計)
次にプロパティベースのチェック(または単純なランダムテスト)を追加して驚きを捕まえます。単一の期待値を比較する代わりに不変条件をアサートします:合計は行合計の和と等しい、マイナー単位に小数が出現しない、"total = subtotal + tax + fees - discounts" が常に成り立つ、など。
クロスプラットフォームのテストは重要です。結果がバックエンドとクライアント間でずれることがあるため、同じテストベクタを各層で実行し、比較するときは文字列ではなく整数出力を比較してください。
最後に、時間依存のケースをテストしてください。請求書で使った税率を保存し、税率変更後でも古い請求書の再計算が同じ結果になることを検証します。ここが「前は合っていたのに」バグが生まれる場所です。
避けるべきよくある落とし穴
多くの1セントバグはコードがあなたの期待とは違うことを正確にやっている“方針ミス”です。
守るべき落とし穴:
- 早すぎる丸め: 各行を丸め、次に小計を丸め、さらに税を丸めると合計がずれます。方針(例:税は行ごと VS 請求書合計)を決め、方針で許されるところだけ丸めてください。
- 異なる通貨を一つの合計に混ぜる: USD と EUR を同じ "total" フィールドに足すのは見かけ上は無害ですが、返金、レポート、照合で問題になります。通貨をタグ付けし、クロス通貨合計の前に合意されたレートで換算してください。
- ユーザー入力を誤ってパースする: ユーザーは "1,000.50"、"1 000,50"、"10.0" のように入力します。パーサが一つの形式を仮定すると 100050 を請求してしまったり、末尾のゼロを落としたりします。入力を正規化し、検証し、マイナー単位で保存してください。
- API や DB にフォーマット済み文字列を使う: "$1,234.56" は表示専用です。API がそれを受け付けると他のシステムが異なる解釈をする可能性があります。整数(マイナー単位)と通貨コードを渡し、各クライアントでローカルにフォーマットさせてください。
- 税率やレート表をバージョン管理しない: 税率や免除、丸めルールは変わります。古いレートを上書きすると過去の請求書が再現不能になります。計算ごとにバージョンまたは有効日を保存してください。
現実的なチェック例:月曜に作成したチェックアウトは先月の税率を使い、金曜に返金が発生して税率が変わっていたとします。古い税ルールのバージョンや元の丸めポリシーを保存していなければ、返金が元の領収書と合わなくなります。
早見チェックリストと次のステップ
驚きを減らすには、お金を小さなシステムと考え、入力・ルール・出力を明確に扱ってください。多くの1セントバグはどこで丸めを許すかを書き留めていないために生き残ります。
出荷前チェックリスト:
- データベース、ビジネスロジック、API で金額をマイナー単位(整数)で保存する。
- すべての計算を整数で行い、表示用にだけ小数表現に変換する。
- 計算(税、割引、手数料、FX)ごとに丸めのポイントを一つ選び、1箇所で強制する。
- Web とモバイルで正しい通貨ルール(小数桁、区切り、負数表記)を一貫してフォーマットする。
- エッジケース(0.01、変換で繰り返す小数、返金、部分キャプチャ、大きなバスケット)をテストに加える。
計算タイプごとに一つの丸めポリシーを書きましょう。例:"割引は行ごとに最も近いセントに丸める。税は請求書合計で丸める。返金は元の丸め経路を再現する。" これらのポリシーをコードの近くとチームドキュメントに置き、乖離しないようにします。
重要なステップごとの軽量なログを追加してください。入力値、使用したポリシー名、マイナー単位での出力を記録します。顧客が「1セント多く請求された」と報告したとき、なぜそうなったかを説明する単一行が欲しいはずです。
本番でロジックを切り替える前に小さな監査を計画してください。過去の注文のサンプルで合計を再計算し、旧結果と新結果を比較して不一致を数えます。不一致をいくつか手動で確認し、新ポリシーに合致するかレビューします。
同じルールを三度書かずにエンドツーエンドのフローを構築したい場合、AppMaster (appmaster.io) は共有バックエンドロジックを備えた完全なアプリ向けに設計されています。PostgreSQL の Data Designer で金額をマイナー単位の整数としてモデル化し、丸めや税のステップを Business Process に一度実装すれば、Web とネイティブモバイル UI で同じロジックを再利用できます。
よくある質問
アプリの別の部分が別のタイミングや別の方法で丸めを行っている場合に起きることが多いです。商品一覧での丸めと、チェックアウトでの丸めが違えば、同じカートでもセントが異なる結果になることがあります。
多くの float は一般的な10進の価格を正確に表現できないため、小さな誤差が蓄積します。その微小な差が後の丸め処理で決定を変え、1セントの不一致を生むことがあります。
金額は通貨の最小単位(USDならセント)を整数で保存し、通貨コードを付けるのが最も安全です。例:$19.99 は 1999(セント)と USD。計算は整数で行い、表示時だけ小数表現に変換します。
JPY(小数0桁)や BHD(小数3桁)のような通貨には小数桁が異なります。小数点2桁で固定すると誤請求の原因になるので、必ず通貨コードを保存し、入力解析と表示時に正しい小数桁を適用してください。
明確なルールを決めて全ての層に適用するのが重要です。たとえば「税は行ごとに丸めする」か「請求書合計で丸めする」かを選び、バックエンド、Web、モバイル、エクスポート、返金で同じ丸めモードを使ってください。
順序は方針であり実装の細部ではありません。一般的なデフォルトは「割引を先に適用して課税対象を減らし、次に税を計算する」ですが、事業や法域の要件に従い、画面やサービス間で一貫させてください。
一度だけ変換し、明示的な精度でレートを保存し、返金時には元の通貨と金額を使うことです。往復で変換すると丸めが繰り返され、差分が生じます。取引時のレートとスケールを必ず保存しましょう。
表示用にフォーマットした文字列を再度パースして数値に戻すと、ロケールの区切り文字や丸めの違いで値が変わることが原因です。(amount_minor, currency_code) のような構造化された値を渡し、表示は UI の端で行ってください。
各ステップでの中間結果(マイナー単位で)を期待値として固定する“ゴールデン”テストを用意します。さらに不変量(合計が行合計の和に等しい、マイナー単位に小数がない等)をチェックするプロパティベースのテストを追加すると効果的です。
お金まわりの計算は共通化して一箇所に置き、すべてのクライアントが同じ入力から同じマイナー単位出力を得られるようにします。AppMaster では amount_minor を PostgreSQL に整数で持ち、丸めや税ロジックを Business Process にまとめて再利用する方法が実践的です。


