通貨フォーマットで1円ずれる事故を消す:Intl.NumberFormat実装
JPY/USD/EURの金額表示で1円ずれ・丸め事故を消す方法。Intl.NumberFormatと整数保存をClaude Codeで実装。コピペで動くコード付き。
請求書PDFを出した翌日、お客さんから「合計が1円合いません」とメールが来ました。
画面では ¥10,780、PDFでは ¥10,779。たった1円。でも金額がズレてる時点で、その請求書はもう信用してもらえません。原因は、消費税の計算結果を「途中で小数のまま」持ち回って、画面とPDFで丸めるタイミングが違っていたこと。
金額のフォーマットって、見た目の話だと思われがちです。でも実際は、ユーザーの信頼・返金問い合わせ・税計算・売上レポートに全部つながっている、れっきとした請求ロジックの一部なんですよね。
この記事の要点
- 金額は整数(最小通貨単位)で保存・計算する。
¥1,980は1980、$19.99は1999。小数のnumberで持つと丸め誤差が出る - 表示する直前だけ
Intl.NumberFormatに渡す。これだけで多通貨・多言語の表示差を標準仕様が吸収してくれる - DBに
"¥1,980"のような表示済み文字列を保存しない。集計・税計算・通貨変更で必ず壊れる - 通貨ごとに小数桁は違う。JPYやIDRは小数なし、インドの桁区切りは
12,34,567。思い込みが事故になる - Claude Codeに任せるなら「フォーマット関数を作って」ではなく「保存・計算・表示を分けて」と頼む
なぜ自前の文字列組み立ては必ず破綻するのか
最初は誰でもこう書きます。"¥" + amount.toLocaleString() とか、`$${price.toFixed(2)}` とか。日本円とドルだけなら、これで動いてしまう。だから余計にタチが悪いんです。
多通貨にした瞬間、こういう差が一気に噴き出します。
- 円は小数なし、ドルやユーロは小数2桁
- インドの桁区切りは3桁ずつじゃなくて
12,34,567(最初だけ3桁、あとは2桁ずつ) - ブラジルは小数点とカンマの意味が日本と逆(
R$ 1.234,56) - 返金や赤字は会計表記で
($10.00)と括弧で囲みたい - 通貨記号が前に来る言語と、後ろに来る言語がある
これを全部 replace と if 文で手当てし始めると、地獄が見えます。僕は一度やって、通貨を5個に増やしたあたりで自分のコードが読めなくなりました。
ブラウザにもNode.jsにも標準で入っている Intl.NumberFormat は、この差をまるごと肩代わりしてくれます。自分で書くのは「いつ、どの通貨で、どのロケールで」を渡すところまで。記号の位置や桁区切りのルールは仕様側の仕事です。
公式の仕様は、MDNの Intl.NumberFormat コンストラクター と ECMA-402 Intl仕様 を見るのが確実です。currencySign: "accounting"、currencyDisplay、signDisplay、roundingMode あたりの意味をClaude Codeに想像で説明させるより、このリンクをプロンプトに貼って実装させたほうが、レビューの精度が上がります。
設計の肝:「保存する値」と「見せる値」を最初に分ける
事故の9割は、ここを混ぜることから始まります。決めるべきは1つだけ。金額をどの単位で保存するかです。
おすすめは、通貨ごとの最小通貨単位(minor unit)の整数で保存すること。専門用語っぽいですが、要は「いちばん細かい単位での整数」のことです。ドルならセント、円なら1円。$19.99 は 1999、¥1,980 は 1980、インドネシアの Rp 123.456 は 123456。
3つの方針を並べると、違いがはっきりします。
| 保存方針 | 保存例 | 良い点 | 落とし穴 |
|---|---|---|---|
| 表示済み文字列を保存 | "¥1,980" | 画面に出すだけなら一瞬 | 検索・集計・税計算・多言語で全部壊れる |
| 小数の number を保存 | 19.99 | 入門時は書きやすい | 丸め誤差、通貨別の小数桁を管理しきれない |
| 最小単位の整数を保存 | 1999 | 計算・集計・テストが安定 | 入出力の境界で変換関数が必要 |
なぜ整数かというと、小数の number(浮動小数点数)は 0.1 + 0.2 ですら正確に 0.3 にならない世界だからです。お金をその上で足し引きすると、回数を重ねるほど誤差が溜まる。整数なら、足し算も掛け算も1円単位でピタリと合います。
ここで大事なのは、Intl.NumberFormat は表示の道具でしかないこと。為替換算も、税率の決定も、StripeやPaddleといった決済サービスの最小単位仕様も、代わりに決めてはくれません。だからClaude Codeに頼むときも、「フォーマット関数を作って」ではなく、「保存形式・計算形式・表示形式を分けて設計して」と伝えるのが効きます。
コピペで動く通貨ユーティリティ(Nodeで即確認できる)
論より証拠で、動くものを置きます。下を currency-format-demo.mjs として保存して、node currency-format-demo.mjs を実行するだけ。外部ライブラリは一切いりません。記事では6通貨に絞っていますが、実プロダクトでは決済サービスと会計要件に合わせて通貨テーブルを管理してください。
// currency-format-demo.mjs
import assert from "node:assert/strict";
// 通貨ごとの小数桁(最小通貨単位)。JPYとIDRは小数なし。
const minorUnitDigits = Object.freeze({
JPY: 0,
USD: 2,
EUR: 2,
BRL: 2,
INR: 2,
IDR: 0,
});
function assertCurrency(currency) {
if (!(currency in minorUnitDigits)) {
throw new Error(`未対応の通貨です: ${currency}`);
}
}
// 0.5は絶対値を大きくする方向に丸める(四捨五入)。負数も対称に扱う。
function roundHalfAwayFromZero(value) {
return value < 0 ? -Math.round(Math.abs(value)) : Math.round(value);
}
// 「1980円」のような表示単位 → 内部の整数(1980)に変換
export function moneyFromMajor(amount, currency) {
assertCurrency(currency);
if (!Number.isFinite(amount)) {
throw new Error(`不正な金額です: ${amount}`);
}
const digits = minorUnitDigits[currency];
return {
minor: roundHalfAwayFromZero(amount * 10 ** digits),
currency,
};
}
// 内部の整数 → 表示単位(小数)に戻す
export function toMajor(money) {
assertCurrency(money.currency);
return money.minor / 10 ** minorUnitDigits[money.currency];
}
// 足し算。通貨が違ったら即エラーで止める(混ざる事故を防ぐ門番)
export function addMoney(left, right) {
if (left.currency !== right.currency) {
throw new Error(`通貨が一致しません: ${left.currency} と ${right.currency}`);
}
return { minor: left.minor + right.minor, currency: left.currency };
}
// 掛け算(税率や割引率に使う)。結果はここで整数に丸める。
export function multiplyMoney(money, factor) {
if (!Number.isFinite(factor)) {
throw new Error(`不正な係数です: ${factor}`);
}
return {
minor: roundHalfAwayFromZero(money.minor * factor),
currency: money.currency,
};
}
// ここだけが文字列を返す。表示の最終出口。
export function formatMoney(
money,
{
locale = "en-US",
accounting = false, // true で会計表記(赤字を括弧に)
currencyDisplay = "symbol",
roundingMode = "halfExpand", // 表示時の丸め方
} = {},
) {
assertCurrency(money.currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
currencyDisplay,
currencySign: accounting ? "accounting" : "standard",
roundingMode,
}).format(toMajor(money));
}
// --- ここから動作確認 ---
const samples = [
["ja-JP", { minor: 123456, currency: "JPY" }],
["en-US", { minor: 123456, currency: "USD" }],
["de-DE", { minor: 123456, currency: "EUR" }],
["pt-BR", { minor: 123456, currency: "BRL" }],
["en-IN", { minor: 123456789, currency: "INR" }],
["id-ID", { minor: 123456, currency: "IDR" }],
];
for (const [locale, money] of samples) {
const formatter = new Intl.NumberFormat(locale, {
style: "currency",
currency: money.currency,
});
const options = formatter.resolvedOptions();
const parts = formatter.formatToParts(toMajor(money));
// 小数桁が通貨定義と一致しているか
assert.equal(options.maximumFractionDigits, minorUnitDigits[money.currency]);
// 通貨記号のパーツが存在するか
assert.ok(parts.some((part) => part.type === "currency"));
console.log(`${locale} ${money.currency}: ${formatMoney(money, { locale })}`);
}
// 19.99ドル + 5ドル = 24.99ドル(整数で2499)
assert.equal(
addMoney(moneyFromMajor(19.99, "USD"), moneyFromMajor(5, "USD")).minor,
2499,
);
// 1980円の10%増し = 2178円
assert.equal(multiplyMoney(moneyFromMajor(1980, "JPY"), 1.1).minor, 2178);
// マイナスのドルは会計表記で括弧始まり
assert.match(
formatMoney({ minor: -129900, currency: "USD" }, { locale: "en-US", accounting: true }),
/^\(\$/,
);
// 通貨をまたいだ足し算はエラーになる
assert.throws(
() => addMoney(moneyFromMajor(10, "USD"), moneyFromMajor(10, "JPY")),
/通貨が一致しません/,
);
console.log("通貨フォーマットのチェックを全部通過しました");
僕の手元(Node.js 24)で実行すると、こう出ます。
ja-JP JPY: ¥123,456
en-US USD: $1,234.56
de-DE EUR: 1.234,56 €
pt-BR BRL: R$ 1.234,56
en-IN INR: ₹12,34,567.89
id-ID IDR: Rp 123.456
通貨フォーマットのチェックを全部通過しました
注目してほしいのは、formatMoney だけが文字列を返すこと。税込価格・割引・日割り・返金を文字列で回し始めると、あとから集計しようとした瞬間に詰みます。文字列は最後の最後、画面やPDFに出す一歩手前で初めて作る。これが鉄則です。
効く場面1:多通貨SaaSの料金表
日本向けは ¥1,980、米国向けは $19.99、EU向けは €18.99 と見せたい。このとき料金マスタは、表示文字列ではなく通貨ごとの最小単位で持ちます。
type CurrencyCode = "JPY" | "USD" | "EUR" | "BRL" | "INR" | "IDR";
type PlanPrice = {
planId: "starter" | "pro" | "team";
currency: CurrencyCode;
amountMinor: number; // 最小単位の整数。JPYなら1980、USDなら1999
};
const prices: PlanPrice[] = [
{ planId: "pro", currency: "JPY", amountMinor: 1980 },
{ planId: "pro", currency: "USD", amountMinor: 1999 },
{ planId: "pro", currency: "EUR", amountMinor: 1899 },
];
価格改定もA/Bテストも amountMinor の数値だけを比べればいいので、差分レビューが一瞬で終わります。Claude Codeに任せるときは「表示済み文字列を渡さず、amountMinor と currency を渡して」と明示してください。これを言わないと、平気で "¥1,980" を引数に取る関数を書いてきます。
効く場面2:請求書・返金・会計表記
返金・値引き・未収金のようなマイナス金額は、国や会計慣習で見せ方が変わります。英語圏の請求書では -$1,299.00 より ($1,299.00) のほうが自然なことがある。これを自前で書くと括弧の付け外しでまた事故りますが、Intl.NumberFormat なら currencySign: "accounting" の一行で済みます。
formatMoney(
{ minor: -129900, currency: "USD" },
{ locale: "en-US", accounting: true },
); // => "($1,299.00)"
ただし会計表記が「すべてのロケールで括弧になる」わけではありません。請求書PDFで固定の表記が必要なら、そこは要件として明文化して、スナップショットテストで固めてください。仕様任せにしていい部分と、自分で握るべき部分の線引きが大事です。
効く場面3:税・割引・日割りの丸め
ここが冒頭の「1円ずれ」の震源地です。月額料金を日割りすると、1999 * 10 / 31 のように割り切れない値が出ます。この小数のまま次の処理へ渡すと、画面ごと・帳票ごとに合計が1円ズレる。
ポイントは、丸める場所を1か所に決めて、テスト名にそれを書くことです。
| 丸めのタイミング | 計算方法 | 向いている場面 |
|---|---|---|
| 請求行ごとに丸める | 各行で Math.round → 合計 | 明細を1行ずつ見せる請求書 |
| 合計後に丸める | 小数のまま合計 → 最後に丸め | 内部の集計、概算表示 |
どちらが正解かは業務要件次第で、技術的にどちらかが優れているわけではありません。だから「決めて、固定する」しかない。Claude Codeには「丸め位置(行単位か合計単位か)をテスト名に含めて」と指示すると、後から見たときに意図が一目でわかるテストを書いてくれます。
効く場面4:管理画面とCSVエクスポート
管理画面では $19.99 のような読みやすい表示が要ります。でもCSVやBIツールに渡すなら、計算できる数値列も要る。formattedAmount だけ出力すると、ExcelやBigQueryで合計すらできません。
| 出力列 | 例 | 用途 |
|---|---|---|
amount_minor | 1999 | 正確な集計・監査 |
currency | USD | 通貨別の集計 |
amount_display | $19.99 | 人間が読む一覧 |
この3列に分けておくだけで、広告費・LTV・MRRをあとから自由に分析できます。逆に表示文字列1列だけで出すと、半年後の自分が泣きます(泣きました)。
僕がやらかした失敗と落とし穴5つ
正直に書きます。全部、自分か案件で踏んだやつです。
1つ目は、DBに "$19.99" や "1,980円" を保存したこと。 最初は楽です。でも通貨変更・返金・税率変更・会計監査のどれかで必ず詰まる。文字列から数値を再パースするコードを書き始めたら、設計が間違っているサインです。
2つ目は、通貨とロケールを同じものだと思い込んだこと。 ja-JP だから必ずJPY、en-US だから必ずUSD、ではありません。日本在住の人がUSD請求を見ることも、米国チームがEUR請求書を確認することも普通にある。通貨(何で請求するか)とロケール(どう表示するか)は別の軸です。
3つ目は、全通貨が小数2桁だと決めてかかったこと。 JPYとIDRは Intl.NumberFormat の既定で小数なしになります。toFixed(2) を共通処理に入れていて、円が ¥1,980.00 と出て気づきました。対応通貨を増やすなら、minorUnitDigits を仕様としてレビュー対象にしてください。
4つ目は、roundingMode を入れれば請求の丸めまで自動で決まると勘違いしたこと。 MDNにある通り、Intl.NumberFormat の丸めは表示時の丸めです。請求金額そのものの丸め位置は、アプリ側で決めないといけない。表示の丸めと計算の丸めは、別レイヤーの話です。
5つ目は、通貨記号を正規表現で抜こうとしたこと。 記号の位置・空白・マイナス記号はロケールでバラバラです。入力欄を作ったり金額を強調表示したいときは、formatToParts で currency・integer・fraction に分解する。正規表現で記号を剥がすコードは、新しい通貨が来た日に壊れます。
Claude Codeに投げるレビュー用プロンプト
既存アプリの料金表や請求まわりに、このプロンプトをそのまま投げると、実装漏れを拾いやすくなります。
このリポジトリの金額表示と請求計算をレビューしてください。
条件:
- DBやAPIに表示済み通貨文字列を保存していないか確認する
- JPY/USD/EUR/BRL/INR/IDRの最小通貨単位(小数桁)を明示する
- Intl.NumberFormatでlocaleとcurrencyを分けて扱っているか確認する
- 返金・割引・負数にcurrencySign: "accounting"が必要か判断する
- 丸め位置が請求行単位か合計単位かをテスト名に出す
- 変更後にNodeで実行できるテストを追加する
MDNの Intl.NumberFormat オプション と formatToParts のリンクもプロンプトに貼っておくと、Claude Codeが独自実装に寄りにくくなります。公式仕様を「読ませる」のがコツです。
よくある質問
Q. toLocaleString() ではダメなんですか?
表示するだけなら toLocaleString() でもそれなりに動きます。ただ会計表記(accounting)や丸めモードの細かい制御、formatToParts での分解をやり始めると、結局 Intl.NumberFormat のインスタンスが欲しくなります。最初から Intl.NumberFormat で揃えておくほうが後がラクです。
Q. 為替の換算も Intl.NumberFormat でできますか?
できません。Intl.NumberFormat は「すでにある金額を表示する」道具です。USDをJPYに換算するレートの取得や計算は、別途自分で用意します。表示と換算は別の責務、と切り分けてください。
Q. 仮想通貨や、小数3桁以上の通貨はどう扱う?
クウェート・ディナール(KWD)のように小数3桁の通貨や、暗号資産のように桁が多いものもあります。minorUnitDigits にその桁数を足し、整数保存の方針を貫けば同じ考え方でいけます。ただ整数が大きくなりすぎる場合は、BigInt や専用ライブラリの検討も視野に入れてください。
Q. テストでロケールの文字列を完全一致で比較していい? おすすめしません。ロケールごとの表示はOSやICUデータのバージョンで細部が変わります(記号の前後の空白など)。完全一致に頼らず、小数桁・通貨コード・負数のルール・保存形式を検証するほうが、環境差で落ちにくいテストになります。
Q. Reactなどフロントで毎回 new Intl.NumberFormat() していい?
頻繁に呼ぶ画面ではインスタンス生成がコストになります。ロケールと通貨の組み合わせごとにフォーマッターをキャッシュ(メモ化)すると速くなります。まずは動かして、重ければ最適化、の順で十分です。
関連記事と次の一歩
請求まわりは、通貨単体では完結しません。多言語URLや翻訳ファイルの設計はClaude Codeでi18n実装:Next.js多言語対応の実務手順、日付やタイムゾーンの事故対策はClaude Codeで日付・時間処理を壊さない実装ガイド、決済の導線そのものはClaude CodeでStripeサブスクリプションを実装する実務ガイドも合わせて読むと、請求周りの抜け漏れがぐっと減ります。
ClaudeCodeLabでは、こういう「AIに丸投げしにくい実装境界」をレビューするためのチェックリストやプロンプト集を配布・販売しています。まずは教材一覧を覗いてみてください。その前に、自分のプロダクトの金額データが amountMinor + currency + locale の3つに分かれているか、ちょっと確認してみるのをおすすめします。
実際に試した結果
冒頭の「1円ずれ」事件のあと、僕は金額を触るコードのルールを1つに決めました。整数で持つ、計算も整数、文字列は表示の出口で1回だけ作る。 これだけです。
上の currency-format-demo.mjs はNode.js 24で実際に実行し、JPY/USD/EUR/BRL/INR/IDRの小数桁・通貨パーツの存在・USDの会計表記・通貨不一致エラーまで、全アサーションが通ることを確認しています。ロケールごとの文字列そのものは環境で細部が変わるので、テストは「完全一致」に頼らず、桁数・通貨コード・負数ルール・保存形式を検証する。地味ですが、これが実務でいちばん事故らないやり方でした。賢い丸め関数を探す前に、データの持ち方を整える。遠回りに見えて、これが一番速かったです。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。