Claude Media
Claude Codeで金額の丸めバグを洗い、通貨の最小単位に整数化する

Claude Codeで金額の丸めバグを洗い、通貨の最小単位に整数化する

浮動小数点の掛け算、JPYに100を掛ける誤り、端数処理の順序。決済コードの金額バグをgrepで拾い、最小単位の整数とテストに直す手順です。

決済コードの金額バグは、ほとんどが3つの型に分かれます。小数の掛け算で1円ずれる、JPYのような通貨に100を掛けて請求額が100倍になる、端数処理の順序で合計が合わない、の3つです。Claude Codeに「金額まわりを直して」と頼む前に、この3型を探し方と直し方まで言葉にしておくと、依頼の精度が上がります。

この記事は、金額の計算側を直す手順をまとめます。構成は、通貨ごとの最小単位の早見表、grepによる洗い出し、整数化の実装、テスト、Claude Codeへの頼み方の順です。金額の表示を整える話は、別記事のClaude CodeでIntlに寄せ、日付と通貨の表示ずれを直すが受け持ちます。

金額バグの3型と、決済APIの約束

最初に押さえるのは、Stripe APIが金額をどう受け取るかです。amount には、その通貨の最小単位を小数なしの整数で渡します。1099を渡せば10.99 USD、10を渡せば10 JPYです。つまり、USDなら元の額に100を掛けますが、JPYには掛けません。500 JPYを請求するなら、amount は 500 です。

これを踏まえると、バグは次の3型になります。

バグの型

決済コードで見つかる金額バグの3型

  • 浮動小数点の誤差

    19.99 * 100 が整数にならず、floor で1セント落ちます。税率や割引率を掛ける箇所でも同じことが起きます。

  • 最小単位の桁違い

    全通貨に一律で100を掛けていると、JPYの請求が100倍になります。通貨コードを見ずに桁を決めているコードが原因です。

  • 端数処理の順序

    行ごとに丸めてから足すか、足してから丸めるかで、合計が1円変わります。仕様として決めていないと、画面と請求書で食い違います。

1型目は桁を外から見ても気づきにくく、テストを書いて初めて表に出ます。2型目は本番で一度請求すると、返金処理まで巻き込みます。3型目は、どちらの順序が正しいかをコードから判断できません。業務側の取り決めを確認する必要があります。

通貨ごとの最小単位を早見表にする

洗い出しの前に、扱う通貨の桁を表にします。表がないまま依頼すると、Claude Codeは2桁を前提に直しがちです。表の中身は、Stripeの通貨ページにある記載に沿っています。

区分通貨の例amountの渡し方
小数点以下2桁(既定)通貨の例USD、EUR、GBPamountの渡し方10 USDなら 1000
小数点以下なし通貨の例JPY、KRW、VNDほかamountの渡し方10 JPYなら 10
小数なしだが2桁で渡す通貨の例ISK、UGXamountの渡し方5 ISKなら 500
請求は2桁、入金は小数なし扱い通貨の例HUF、TWDamountの渡し方入金額は100で割り切れる値

小数点以下のない通貨として、ページには次の16通貨が挙がっています。BIF、CLP、DJF、GNF、JPY、KMF、KRW、MGA、PYG、RWF、UGX、VND、VUV、XAF、XOF、XPFです。このうちUGXだけは、後述の特殊なケースの表でも別扱いになっています。扱う通貨が増えるたびに、この一覧と突き合わせる運用が必要です。

特殊なケースの読み方

ISKは小数点以下のない通貨へ移行しましたが、後方互換性のため、小数が常に 00 の2桁として表します。5 ISKの請求は 500 で、端数は請求できません。UGXも同じ形です。加えて、日割りやクーポン、税金の適用後に請求書の額が端数になると、Stripeが100で割り切れる最も近い値へ切り上げ、または切り捨てます。差額は顧客の残高に入金または引き落としされます。つまり、UGXの請求書では、自分で計算した額とStripeの確定額がずれる可能性があります。

HUFとTWDは、請求では小数点以下第2位まで扱えます。一方、入金は小数なしの通貨として扱われます。手動で入金を作るときの amount は100で割り切れる整数だけです。たとえば利用可能残高がTWD 800.45のとき、送れるのは 80000 までで、80045 では全額を入金できません。

最小請求額と上限も金額バグの種になる

請求できる最小額は通貨ごとに決まっています。USDは0.50、GBPは0.30、HUFは175.00、JPYとKRWは50です。最小額は入金先の銀行口座の売上処理通貨で変わり、別通貨の請求は換算後の額が最小額を満たす必要があります。サブスクリプションは、クーポンや無料トライアルによる金額0の支払いを扱えます。ただし、0以外の額には最小額が適用されます。

上限も最小単位の整数で定義されています。ほとんどのカード決済は12桁で、最大は 999999999999 です。日本のStripeアカウントでJCB、Diners Club、Discoverを処理するときは、8桁(99,999,999 JPY)になります。金額の検証関数を作るなら、通貨ごとの最小額と上限をここに置くのが自然です。

grepで金額まわりの怪しい箇所を拾う

次は洗い出しです。型ごとにgrepの手がかりを決めておきます。

# 1型目: 小数の掛け算と丸め
grep -rnE "toFixed|parseFloat|Math\.(round|floor|ceil)" src
 
# 2型目: 100の掛け算・割り算
grep -rnE "\* ?100\b|/ ?100\b" src
 
# Stripeに渡す箇所
grep -rnE "amount:|unit_amount|amount_off" src

3つのパターンは、完全には重なりません。1つ目は丸めの呼び出しを、2つ目は桁の固定を、3つ目は決済APIの境界を拾います。3つ目で出た行から呼び出し元をたどると、どの通貨がそこへ流れ込むかが分かります。通貨コードが近くにないのに100を掛けている行が、2型目の候補です。

grepの結果は候補の一覧にすぎません。/ 100 はパーセント計算にも使われるため、金額と無関係な行も混ざります。そこで、結果をClaude Codeに渡し、金額に関わる行だけを残して分類させます。

依頼文の例

Claude Codeのドキュメントにある不具合調査の流れは、エラーを共有し、直し方の案を求め、案を適用する3段階です。再現手順と、再現が安定するかどうかを伝えることも勧められています。金額バグでも同じ進め方が使えます。最初の依頼は、直す前の分類に絞ります。

src配下で金額を扱う箇所を洗い出してください。まだ修正はしません。
- toFixed / parseFloat / Math.round で金額を計算している行
- 通貨コードを見ずに100を掛けている、または割っている行
- Stripeのamountに渡している値の出どころ
結果は「ファイル:行 / 型(浮動小数点・桁違い・端数順序) / 対象通貨」の表にしてください。

分類の表を見て、人が修正の順序を決めます。続けて、1型ずつ直す依頼を出します。直す範囲を型で区切ると、差分が小さく保たれ、レビューしやすくなります。

整数化の実装

直し方の方針は単純です。金額は最初から最小単位の整数で持ちます。外部の入力が小数の文字列で届くなら、文字列のまま桁を読んで整数に変えます。浮動小数点の数値は経由しません。

JavaScriptで、19.99 * 100 が整数にならないことは手元のNodeで確かめられます。

node -e 'console.log(0.1+0.2, 19.99*100, 1.005*100, 0.57*100)'

Node.js v20.18.0で実行すると、次の値が出力されました。

0.30000000000000004 1998.9999999999998 100.49999999999999 56.99999999999999

Math.round で丸めれば19.99は1999になりますが、Math.floor を使うと1998です。端数処理の関数をうっかり切り捨てにしただけで、1セントがずれます。そもそも小数を経由しない形にすれば、この種のずれは起きません。

次のコードは、通貨ごとの桁数の表を持ち、小数の文字列を最小単位の整数へ変換します。税込額は整数同士の計算で出します。

const EXPONENT = {
  usd: 2, eur: 2, gbp: 2,
  jpy: 0, krw: 0, vnd: 0, clp: 0,
  isk: 2, ugx: 2, // 小数なしの通貨だが amount は2桁で渡す
};
// 小数部を受け付けない通貨(請求できる端数がない)
const NO_FRACTION = new Set(["isk", "ugx"]);
 
export function toMinorUnits(major, currency) {
  const code = currency.toLowerCase();
  const e = EXPONENT[code];
  if (e === undefined) throw new Error(`unknown currency: ${currency}`);
  const [int, frac = ""] = String(major).split(".");
  if (!/^\d+$/.test(int) || !/^\d*$/.test(frac)) {
    throw new Error(`bad amount: ${major}`);
  }
  const places = NO_FRACTION.has(code) ? 0 : e;
  if (/[1-9]/.test(frac.slice(places))) {
    throw new Error(`too many decimals for ${currency}: ${major}`);
  }
  return Number(int + (frac + "0".repeat(e)).slice(0, e));
}
 
// 税率は整数パーセント。端数は切り捨てと決めた場合の例
export function taxIncluded(netMinor, ratePercent) {
  return netMinor + Math.floor((netMinor * ratePercent) / 100);
}

通貨を表に持たせる設計の利点は、未知の通貨で例外を投げられる点です。表にない通貨を2桁とみなすと、2型目のバグが静かに復活します。また、小数部を持てない通貨に端数が来たら、丸めずに拒否します。丸めて通すと、請求額が顧客の見ている額とずれるからです。

端数処理の順序は仕様として決める

taxIncluded の Math.floor は、切り捨てと決めた場合の一例にすぎません。切り捨て、切り上げ、四捨五入のどれを使うか、行ごとに丸めるか合計に対して丸めるかは、コードを見ても決まりません。事業側の決まりを確認し、関数名かコメントに残します。Claude Codeには、この判断を勝手に補わせないでください。依頼文の末尾に「端数処理の方針が不明なら、コードを変えずに質問してください」と添えておけば、推測で丸め方を選ばれる事態を避けられます。

テストで丸めと桁を固定する

整数化の次はテストです。Claude Codeのドキュメントは、テスト追加を「テストされていないコードを探す」「足場を作る」「意味のあるケースを足す」「実行して失敗を直す」の流れで説明しています。さらに、境界値や想定外の入力といった見落としやすいケースを洗い出させることも勧めています。金額のテストでは、この境界値を通貨ごとに表にして渡します。

import test from "node:test";
import assert from "node:assert/strict";
import { toMinorUnits, taxIncluded } from "./money.mjs";
 
for (const [major, cur, want] of [
  ["10.99", "usd", 1099],
  ["19.99", "usd", 1999], // 浮動小数点だと 1998.99… になる値
  ["500", "jpy", 500],    // JPYは100を掛けない
  ["5", "isk", 500],      // 小数なしだが2桁で渡す
]) {
  test(`${major} ${cur} -> ${want}`, () => {
    assert.equal(toMinorUnits(major, cur), want);
  });
}
 
test("JPYに小数は渡せない", () => {
  assert.throws(() => toMinorUnits("500.5", "jpy"));
});
 
test("税込の端数は切り捨て", () => {
  assert.equal(taxIncluded(999, 10), 1098); // 99.9 を切り捨て
  assert.equal(taxIncluded(1000, 10), 1100);
});

このテストは、上のコードと合わせてNode.js v20.18.0で実行し、6件すべて通ることを確かめました。実行はテストランナー内蔵のコマンドです。

node --test

テストに載せる値は、通貨ごとに最低でも3種類を意識すると足ります。端数が出ない値、端数が出る値、最小額や上限ぎりぎりの値です。JPYなら500、USDなら10.99と19.99、最小額なら50 JPYや0.50 USDを入れます。

落とし穴

JPYに100を掛けると請求が100倍になる

USDで動いていたコードをそのままJPYへ広げると、500円の商品が 50000 として送られます。Stripeは 50000 を50,000 JPYと受け取るため、桁の誤りを自分から指摘してはくれません。通貨コードごとに桁を決める表と、JPYのテストを先に置いてから、対応通貨を増やしてください。

Claude Codeに頼む手順と、残す規約

ここまでの手順を、依頼の順に並べます。

手順

金額バグを洗って直す依頼の順序

  1. 1

    洗い出しと分類だけを頼む

    3型のgrepを実行させ、行ごとに型と対象通貨を表にさせます。この段階では修正させません。

  2. 2

    通貨の桁の表を確定する

    扱う通貨を人が決め、桁の表を渡します。Stripeの記載と合っているかを、人が目で確かめます。

  3. 3

    失敗するテストを先に書かせる

    表の境界値から、現状のコードで失敗するテストを作らせます。失敗を確認してから直す順序です。

  4. 4

    型ごとに直してテストを回す

    1型ずつ修正させ、新しいテストを実行して失敗を直させます。テストが通ったら、次の型へ進みます。

決済まわりは、再発を機械で止めるのが確実です。CLAUDE.mdに規約を残すと、以降のセッションでも同じ前提で直してもらえます。例えば次のような断片です。

## 金額の扱い
- 金額は最小単位の整数で持つ。小数・浮動小数点で計算しない
- 通貨ごとの桁は `src/money.ts` の表だけを使う。100の直書きはしない
- 端数処理は切り捨て。行ごとではなく請求書の合計に対して1回だけ行う
- 端数処理の方針が不明なときは、コードを変えず質問する

3行目は、あくまで例です。実際の方針は、事業の取り決めに合わせて書き換えてください。

禁止したい書き方は、grepでCIに置けます。金額を扱うディレクトリに限って、toFixed や * 100 を拒否する形です。

if grep -rnE "toFixed|\* ?100\b" src/billing; then
  echo "金額の計算は src/money.ts を使ってください" >&2
  exit 1
fi

テストをhookで自動実行する方法は、Claude Code hooksでテストを自動実行するにまとめています。金額の関数を触ったら必ず node --test が走る構成にしておくと、桁の表を壊す変更をその場で止められます。

つまずきやすい点

整数化しても、境界に別の落とし穴が残ります。

  • Stripe以外の決済基盤: 本記事の表はStripeのAPIの約束です。別の決済サービスでは、小数なしの通貨の扱いが違う場合があります。通貨ごとの桁は、使う基盤のドキュメントで確かめてください。
  • 返金や再請求の差額: UGXのように、Stripe側で端数が丸められる通貨があります。自分の計算額とStripeの確定額を比べ、差額を残高の調整として扱う設計が要ります。
  • webhookで受け取る額: 受信側で金額を比べるときも、最小単位の整数のまま扱います。受信処理の書き方はClaude CodeでStripe webhookの冪等受信を書くを参照してください。
  • サブスクリプションの更新: 日割りやクーポンの適用順で金額が変わります。状態遷移と金額を一緒に扱う実装は、Claude CodeでStripeのサブスクを同期する実装が詳しい内容です。

まとめ

金額バグの洗い出しは、型を3つに分け、grepで候補を拾い、分類してから直す順に進めると、差分が小さくなります。直す先は、通貨ごとの桁の表と、最小単位の整数です。JPYに100を掛けない、ISKとUGXは2桁で渡す、HUFとTWDの入金は100で割り切れる値にする、という通貨ごとの例外は、表とテストに残してこそ効きます。

端数処理の方針だけは、コードから導けません。仕様として人が決め、CLAUDE.mdとテストに書き残してください。Claude Codeに任せられるのは、洗い出しと機械的な置き換え、そしてテストの実行と修正の反復です。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. docs.stripe.com/currencies
  2. code.claude.com/docs/en/common-workflows
この記事を共有:XはてブLinkedIn