マルチ通貨のサブスクリプション請求に関する実践的な丸めと最小テーブル手法。Web、モバイル、会計エクスポートで合計が一致するようにする方法を解説します。

よくある頭痛のタネ:ウェブのチェックアウトではある合計が表示され、モバイルアプリではわずかに違う合計が表示され、会計エクスポートではまた別の数値になる。各システムは「合理的な」計算をしているが、同じ計算をしていない。
サブスクリプションはこれを悪化させます。計算を何度も繰り返すからです。小さな差分は更新、サイクル途中でのアップグレード時のプロレーション、クレジットや返金、支払い失敗後の再請求、開始や終了時の部分期間などで累積します。
ずれはたいていほんの小さな選択から始まりますが、それが見えなくなるまで気づきません:丸めのタイミング(行ごとか最終合計か)、税のベース(netかgrossか)、小数桁数のない通貨や3桁の小数単位をどう扱うか、どのFXレートを適用するか(どのタイムスタンプ、どのソース、どの精度)。もしウェブが各行を小数2桁で丸め、モバイルが最終合計のみ丸めると、同じ入力でも0.01の差が生じます。
目標は地味ですが重要です:同じ請求書はどこでもいつでも同じ合計を出すこと。これにより顧客の不安を減らし、サポートチケットが減り、監査でも問題になりません。
「一貫性」とは、ある請求書IDとバージョンについて:
例:顧客がEUR 19.99からEUR 29.99へ月の途中でアップグレードし、プロレーション請求を受け、ダウンタイムの小さなクレジットが付与されたとします。一方のシステムが各プロレーション行を丸めし、もう一方が最終合計のみ丸めると、顧客が見た金額とエクスポートの金額が食い違うことがあります。どの数値も「十分近い」ように見えてもです。
FXレートや税の丸めルールを議論する前に、基本を固めてください。これが曖昧だと、ウェブ、モバイル、会計エクスポート間で請求書がずれます。
各請求書行と請求書合計は明確に3つの金額を持つべきです:net(税抜)、tax(税額)、gross(net + tax)。どれか一つを保存と計算の真実のソースにして、他はすべて同じ方法で派生させます。多くのチームはnetとtaxを保存し、grossをnet + taxで計算する方法を取ります。これにより監査や返金が容易になります。
各数値がどの通貨であるかを明示してください。チームはしばしば次の3つを混同します:
これらは同じでもかまいませんが、異なることもあります。請求がEURであってカードがUSDで決済される場合でも、請求書はEURで一貫した計算である必要があり、銀行の入金が異なっても請求書が「誤っている」わけではありません。
次に、金額はマイナー単位(例えばセント)で整数として扱ってください。9.99を浮動小数点で保存するのは、後で税や割引、プロレーション、複数のアイテムを足すときに9.989999のような問題を生みやすい一般的な間違いです。999(セント)と通貨コードを保存し、表示時にのみフォーマットしてください。
最後に、価格の税モードを決定します:
具体的なチェック:10.00と表示されるプランが税込(20% VAT)の場合、webとモバイルで同じマイナー単位のgrossが保存され、そこから一つのルールでnetとtaxを派生させるべきです。
FXの差は税や丸めの前に始まることが多いです。2つのシステムが両方とも「正しい」ことはあり得ますが、異なるソース、異なるタイムスタンプ、異なる精度を使ったために一致しないことがあります。
レート提供者はめったに完全に一致しません。ある提供者はミッドマーケットレートを引用し、別はスプレッドを含みます。あるものは毎分更新し、別は毎時または毎日更新します。同じ提供者でも一方がレートを小数4桁に丸め、もう一方が8桁以上を保持すると、サブスクリプション金額や税に掛け合わせた結果が変わります。
最も重要な決定は「レートのタイムスタンプが何を意味するか」です。請求がEURで顧客がUSDで支払う場合、請求書発行時にFXを固定するのか、決済成立時に固定するのか。どちらも一般的ですが、web・モバイル・会計エクスポートで混在させると不一致が確実に発生します。
ルールを決めたら、請求書に実際に使った正確なレートを保存してください。後で「当時の」レートを参照して再計算しないでください。プロバイダの修正、タイムゾーン差、小さな精度の違いで古い請求書がエクスポート時にずれる原因になります。
簡単な例:請求を23:59に発行し、決済が00:02に成功したとします。これらのタイムスタンプはしばしば異なるプロバイダの「日」にまたがるため、日次レート表では異なる数値が出ます。
FXの詳細は文書化して決めてください:
特別扱いが必要なケース:小数が0の通貨(JPYなど)、非常に高精度なレート、返金。返金は一般に元の請求書に保存されたFXレートを再利用すべきです。そうしないと返金額が顧客の期待や会計エクスポートと異なる場合があります。
ウェブ、モバイル、会計エクスポートで一致する請求書にするには、データモデルは入力だけでなく結果を保存する必要があります。目標は単純:同じ請求書がどこでも同じマイナー単位を再現できることです。
通常は小さなエンティティ群で十分です:
重要ルール:金額フィールドはマイナー単位の整数であること。単価と計算済みの行合計の両方を保存してください。これにより異なる丸めルールやFXソースで再計算されるのを防げます。
FXは請求書にキャプチャする必要があります。共有FXテーブルを保存していたとしても、請求書は最終化時に使った正確な fx_rate_value(および出所)を保持し、エクスポートが同じ数値を再現できるようにしてください。
一つの請求書に複数の税率や管轄が混在する場合のみ、tax breakdown テーブルを分けて持つ必要があります(例:混合アイテム、EU VAT+地方税、住所ベースの税率変更など)。その場合は税率ごとに1行、taxable_base_minorとtax_amount_minorを保存します。
最後に、確定した請求書は不変として扱ってください。計算スナップショットを保存し、請求が確定した後に合計を再計算しないでください。この1つの選択で多くの「なぜセントが変わった?」バグが消えます。
丸めは数学の細部ではなく、プロダクトルールです。ウェブがある方法で丸め、モバイルが別の方法で丸め、会計エクスポートが第三の方法で丸めると、入力が同一でも合計が異なります。
一般的に使われる3つの戦略:
サブスクリプションでは、デフォルトとして行ごとに丸めるのがよいことが多いです。顧客にとって予測可能で(各行の表示が納得しやすい)、監査もしやすく、更新をまたいで安定します。単位ごとの丸めは数量が変わるとずれることがあり、請求書合計でのみ丸めると「行の合計が合わないのはなぜ?」という問い合わせが増えます。
多数の小さなアイテムや分数の税があると、古典的なペニー問題が出ます。例:20行それぞれが0.004の端数を生み、行ごとに丸めると最終で0.08の差になることがあります。FX変換が絡むとこうした端数は頻繁に現れ、エクスポートや収益レポートで累積します。
何を選ぶにせよ、決定論的にしてください。同じ入力は常に同じ出力を生むように:
Webとモバイルの請求フローを両方作る場合、丸めルールをUIの挙動ではなくテスト可能な仕様として文書化してください。
同じ数値をウェブ、モバイル、会計エクスポートで保つには、計算をレシピとして扱ってください。鍵となる考え:高精度で計算しつつ、請求通貨では整数(マイナー単位)だけを保存して合算すること。
各行のnet金額を高精度で開始します。数量を掛け、割引を適用し、必要なら通貨変換を行う間は余分な小数を保持します。その後、選んだルールで請求通貨のマイナー単位に一度丸めます。その整数を行のnetとして保存します。
保存された行net(または税率ごとにグループ化するルールがあるならグループ小計)から税を計算します。同じ丸めルールを適用し、税をマイナー単位の整数として保存します。ここでシステムがよくずれます:一方は税の前に丸め、もう一方は税の後に丸めることがあります。
各行のgrossは(保存されたnet + 保存されたtax)として計算します。請求書合計は保存されたマイナー単位の合計です。表示のために浮動小数点で合計を再計算しないでください。表示やエクスポートは保存された整数を読み取りフォーマットするだけにします。
ローカルルールで請求書レベルの税合計が必要な場合、端数を配分する必要が出ます。例:3行でそれぞれ0.01の税が合計0.03になるが請求書レベルの丸めで0.02になる場合。決定論的なタイブレーク(例えば課税ベースが大きい行から1マイナー単位ずつ加減する、行IDで安定ソートする等)を採り、影響を受ける行に小さな税調整として保存して全システムが再現できるようにします。
請求書をロックします。最終丸めと端数配分が終わったら請求書を不変と扱います。サブスクリプション価格が後で変わった場合は新しい請求書やクレジットノートを作り、古い数値を書き換えないでください。
具体的なチェック:EUR 9.99 プランに19% VATがある場合、保存されるnetは999(セント)、taxは190、grossは1189になるかもしれません。各クライアントはこれら保存された整数から11.89 EURを表示し、VATをその場で再計算してはいけません。
税の丸めは正しい数学が請求の不一致に変わる場所です。核心は単純:早く丸めるほど最終合計が変わるということ。
行ごと(または数量ごと)に税を丸めて合計すると、請求書レベルで丸めた結果と異なることがあります。行が多いと差は大きくなります。小数単位やFX変換があると隙間はさらに広がります。
具体例(小数2桁):2行それぞれ課税対象額が0.05で税率10%だとします。行ごとの未丸め税は0.005です。行ごとに丸めると各行0.01になり合計0.02。一方請求書レベルで丸めると課税合計は0.10で税は0.01。どちらも正当化できますが、結果が異なります。
行ごとの税を表示しつつ請求書合計も厳密に一致させる必要がある場合、端数を決定論的に配分してください:
エクスポートが行を会計上のグループ(製品別、税率別、管轄別)にまとめる場合でもずれが出ることがあります。請求書合計と一致させるためには、まず必要な各グループ内で端数を配分し、それらの合計が請求書の税・grossと一致することを検証してください。
会計が税率や管轄で分割を要求するがUIが一つの税額だけを表示する場合でも、内訳を保存しておきましょう(税率・管轄ごとの小計と監査向けの配分ルール)。UIは単一の合計を表示し、エクスポートは詳細バケットを持っていても請求書の総額を変えてはいけません。
多くの請求不一致はコーナーケースで起きます。ルールを先に決めれば驚きが減ります。
小数が0の通貨は特別な注意が必要です。JPYやKRWはマイナー単位がないため、「セント」を前提にした処理は静かに差を生みます。どのタイミングで丸めるか(行ごと、税レベル、または最終合計)を決め、全クライアントで同じ通貨設定を使ってください。
越境VATやGSTは顧客の所在地や受け入れる証拠(請求先住所、IP、税ID)によって税率が変わります。難しいのは税率自体ではなく、いつそれを固定するかです。チェックアウト時、請求書発行日、サービス期間開始のいずれで固定するかを選んで守ってください。
プロレーションは分数を増やす場所です。サイクル途中のアップグレードは1日あたり9.3333...のような値を作ることがあります。netを先に按分するかgrossを先にするか、どの順序で計算するかを決めてください。順序を変えると最後のマイナー単位が変わります。
次のルールを文書化しておくとよいでしょう:
返金は最終的な罠です。元の請求書に0.01の端数がある場合、返金はその正確な配分を逆向きに再現するべきです。そうしないと顧客が見る合計と台帳のエクスポートが食い違います。
ほとんどの請求不一致は「難しい数学」ではなく、スタックの異なる箇所での小さな一貫性の欠如から生じます。
大きなミスの一つは金額を浮動小数点で保存することです。19.99のような値は多くのシステムで正確表現できないため、行の合計や税、割引を足すと誤差が累積します。金額はマイナー単位の整数と通貨コード、精度を合わせて保存してください。
別の一般的な問題はエクスポート時にFXを再計算することです。顧客は特定の時点のレートで支払っているのに、会計エクスポートが「今日の」レートを使うと合計が変わります。請求書はスナップショットとして扱い、使用したFXレート、変換後の金額、丸め結果を保存してください。
UIとバックエンドで丸めのタイミングが異なると、丸め差が出ます。例えばバックエンドが行ごとに税を丸めし、ウェブUIが最終でしか丸めないと一致しません。
よくある5つの原因:
実用的な修正は地味ですが効果的です:バックエンドで一度だけ計算し、請求書スナップショットを保存し、Webとモバイルはその保存値を正確に表示すること。
ウェブ、モバイル、会計エクスポートで数値が異なる場合、たいていは数学の問題ではなく保存と丸めの問題です。
クライアントは請求書が保存しているものを表示するべきで、再計算してはなりません。バックエンドを唯一の真実の源とし、すべてのチャネルが同じ保存値を読むようにします。
返金とクレジットは元の請求書の丸め結果を鏡映するようにすべきです。元の請求書で行ごとに税を丸めていたなら、返金も同じ方法で行い、同じ通貨精度と保存されたFXレートを使ってください。さもないと小さな端数が生まれ累積します。
これを強制する実用的な方法は、各請求書に計算スナップショットを明確に保存することです:通貨、小数精度、丸めモード、FXレートとタイムスタンプ、確定した行マイナー。
ここにどこでも一貫する請求書の例を示します。
請求書はEUR(小数2桁)で発行、VATは20%、顧客はUSDで請求されると仮定します。バックエンドはFXのスナップショットを保存します:1 EUR = 1.0857 USD。
| Item | Net (EUR) |
|---|---|
| Pro plan (monthly) | 19.99 |
| Extra seats | 10.00 |
| Discount (10% of 29.99, rounded) | -3.00 |
Net total (EUR) = 26.99
VAT 20% (EUR) = 5.40 (26.99 x 0.20 = 5.398 を 5.40 に丸め)
Gross total (EUR) = 32.39
バックエンドは保存されたEUR合計とFXスナップショットから決済通貨の合計を導出します:
行ごとにUSDに変換して丸めし合計すると0.01の差が出ることがよくあります。ここが請求書がずれる典型です。
決定論的にするには:各行を変換して丸めし、それでも合計が保存された総USDと一致しない場合は(正負問わず)固定の順序(例:line_id昇順)で残りのセントを配分し、行合計が保存された総額と一致するまで調整します。
Webとモバイルはバックエンドに保存された行合計、税額、FXレート、総額を表示し、再計算してはいけません。会計エクスポートは保存された数値とFXスナップショット(レート、タイムスタンプやソース)を出力して、台帳が顧客の見たものと一致するようにします。
実用的な次のステップは、計算を出力して単一の請求スナップショット(行、税、合計、FX、丸め調整)を生成する共有サービスを実装し、すべてのチャネルがそれを描画することです。もしこれらのフローを Koder.ai (koder.ai) 上で構築しているなら、このスナップショットモデルを中心に据えることで、Web、モバイル、エクスポートが同じ保存値を読めるため整合が取りやすくなります。
各システムがいつ丸めるか、何を丸めるか(netかgrossか)、税やFXの精度をどこまで保つかなどでわずかに異なる選択をするからです。これらの小さな違いが、特にプロレーションやクレジット、再試行が繰り返されると0.01〜0.02程度の差として現れます。
金額はマイナー単位(例:セント)で整数として保存し、通貨コードを付けて表示時のみフォーマットしてください。浮動小数点は多くの小数を正確に表現できないため、行の合計や税、割引を足すと小さな誤差が生じます。
どれか一つを保管して他は派生させるという方針を決めてください。一般的なデフォルトはnetとtaxをマイナー単位で保存し、gross = net + tax とすることです。返金や監査がやりやすくなり、合計が安定します。
請求通貨は請求書上の法的な通貨で、照合対象となるものです。表示通貨はプラン閲覧時に見せる通貨、決済通貨は決済プロバイダが銀行に入金する通貨です。これらは異なっても構いませんが、請求通貨内の計算は一貫している必要があります。
エクスポートやPDF再生成時にレートを取り直さないでください。請求書に使用した正確なFXレート(値、精度、プロバイダ、有効時刻)を保存し、常にそれを再利用すると古い請求書が数か月後でも同じ数値を再現できます。
どちらか一つのルールを固定してください:「請求発行時のレート」か「決済成功時のレート」。どちらを採るかを決めて全システムでそれを適用しないと、特に深夜やタイムゾーン境界で不一致が起きやすくなります。
サブスクリプション請求では、行ごとに丸める(per line) をデフォルトにするのが安全です。各行が説明しやすく、サポート問い合わせを避けやすく、更新時にも安定しやすいからです。重要なのは全チャンネルで同じルールを使うことです。
行ごとの税丸めと請求書レベルでの丸めのどちらかを明確に選び、それを決定論的に実行してください。請求レベルの目標に合わせる必要がある場合は、端数を決まった方法で配分し、その結果の行ごとの税マイナー単位を保存して全システムで同じ結果を表示できるようにします。
プロレーションは日割りなどで循環小数が生じやすく、計算順序で最終の端数が変わります。どの方法でプロレーションするか(netを先に割るか、grossを先に割るか)、どこで丸めるかを決め、最終的な行のマイナー単位を保存することで差分を防げます。
最も単純で確実なのは、バックエンドで一度だけ請求書スナップショット(行、税、合計、通貨の小数精度、FXスナップショット、丸めモード)を出力し、それを確定(immutable)として扱うことです。Web、モバイル、PDF、エクスポートはその保存された整数をレンダリングし再計算しないようにします。Koder.ai でこうしたフローを構築する場合も同じパターンが有効です。