通貨フォーマットで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 文で手当てすると、通貨を追加するたびに分岐が増え、レビューで表示規則を追えなくなります。
ブラウザにも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に出す一歩手前で初めて作る。これが鉄則です。
3つのUse case
Use case 1:多通貨SaaSの料金表
入力:プランID、amountMinor、currency、表示する locale。
出力:料金ページ、管理画面、メールで同じ金額を同じルールで表示するコード。
人の確認:売上に直結する価格そのもの、税抜・税込の扱い、A/Bテストで比較する指標、公開前の料金表スクリーンショット。
日本向けは ¥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" を引数に取る関数を書いてきます。
Use case 2:請求書・返金・会計表記
入力:返金行、値引き行、未収金の amountMinor と currency。
出力:PDF、領収メール、会計レビュー用の表で、負数を読み間違えない表示。
人の確認:会計表記を括弧にするか、マイナス記号にするか、顧客に送るPDFの固定表示要件。
返金・値引き・未収金のようなマイナス金額は、国や会計慣習で見せ方が変わります。英語圏の請求書では -$1,299.00 より ($1,299.00) のほうが自然なことがある。これを自前で書くと括弧の付け外しでまた事故りますが、Intl.NumberFormat なら currencySign: "accounting" の一行で済みます。
formatMoney(
{ minor: -129900, currency: "USD" },
{ locale: "en-US", accounting: true },
); // => "($1,299.00)"
ただし会計表記が「すべてのロケールで括弧になる」わけではありません。請求書PDFで固定の表記が必要なら、そこは要件として明文化して、スナップショットテストで固めてください。仕様任せにしていい部分と、自分で握るべき部分の線引きが大事です。
Use case 3:税・割引・日割りの丸め
入力:月額料金、利用日数、割引率、税率、丸め位置の仕様。
出力:請求行ごとの金額、合計金額、テスト名に残る丸めポリシー。
人の確認:請求行単位で丸めるか、合計後に丸めるか、決済サービスと請求書の合計が一致するか。
ここが冒頭の「1円ずれ」の震源地です。月額料金を日割りすると、1999 * 10 / 31 のように割り切れない値が出ます。この小数のまま次の処理へ渡すと、画面ごと・帳票ごとに合計が1円ズレる。
ポイントは、丸める場所を1か所に決めて、テスト名にそれを書くことです。
| 丸めのタイミング | 計算方法 | 向いている場面 |
|---|---|---|
| 請求行ごとに丸める | 各行で Math.round → 合計 | 明細を1行ずつ見せる請求書 |
| 合計後に丸める | 小数のまま合計 → 最後に丸め | 内部の集計、概算表示 |
どちらが正解かは業務要件次第で、技術的にどちらかが優れているわけではありません。だから「決めて、固定する」しかない。Claude Codeには「丸め位置(行単位か合計単位か)をテスト名に含めて」と指示すると、後から見たときに意図が一目でわかるテストを書いてくれます。
Use case 4:管理画面とCSVエクスポート
入力:管理画面の表、CSVの列設計、広告費や売上の集計に使う金額。
出力:人が読む amount_display と、計算に使う amount_minor / currency の分離。
人の確認:CSVを受け取る経理・広告・CSチームが、どの列を集計し、どの列を目視確認に使うか。
管理画面では $19.99 のような読みやすい表示が要ります。でもCSVやBIツールに渡すなら、計算できる数値列も要る。formattedAmount だけ出力すると、ExcelやBigQueryで合計すらできません。
| 出力列 | 例 | 用途 |
|---|---|---|
amount_minor | 1999 | 正確な集計・監査 |
currency | USD | 通貨別の集計 |
amount_display | $19.99 | 人間が読む一覧 |
この3列に分けておけば、広告費・LTV・MRRを後から数値として分析できます。表示文字列1列だけでは、再集計のたびに通貨記号と区切り文字の再解析が必要になります。
Pitfall: 実装レビューで見つける失敗と落とし穴5つ
ここでは、保存・計算・表示の責務を混ぜたときに起きる代表的な失敗を5つに分けます。
原因はだいたい同じで、保存・計算・表示の責務を混ぜたことです。直し方は、金額の正本を整数と通貨コードに戻し、表示文字列を最後の出口だけで作ること。修正後は、請求行、合計、CSV、PDF、メールの金額を同じテストデータで確認して、再発を防ぐのが対策になります。
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に任せる範囲と人が決める範囲
料金表、フォーム、CSV、PDFに出る金額表示のコード差分はClaude Codeに任せやすい範囲です。amountMinor と currency を受け取り、Intl.NumberFormat で表示し、Nodeで動くテストを追加するところまでなら、レビューの観点もはっきりしています。
人が決める範囲は、売上・請求・セキュリティ・顧客データに関わる判断です。価格改定、税率、返金条件、丸め位置、決済サービスの仕様、請求書PDFの法務要件、顧客に送信するメールは、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回だけ作る。 この境界をテストで固定します。
上の currency-format-demo.mjs はNode.js 24で実際に実行し、JPY/USD/EUR/BRL/INR/IDRの小数桁・通貨パーツの存在・USDの会計表記・通貨不一致エラーまで、全アサーションが通ることを確認しています。ロケールごとの文字列そのものは環境で細部が変わるので、テストは「完全一致」に頼らず、桁数・通貨コード・負数ルール・保存形式を検証します。賢い丸め関数を探す前にデータの持ち方を整えるほうが、実務では再発を防ぎやすく、修正範囲も小さくなります。
今日まず確認するなら、リポジトリで amount、price、currency、formatted を検索し、表示済み文字列をDBやAPIへ置いている箇所を洗い出してください。次に、このファイルのコードを合成データだけで動かし、CTAリンク、内部リンク、外部リンク、frontmatterの updatedDate、ファイル名のslug、コードフェンスを確認してから本番の請求コードへ移します。
次に読む記事
next-intlでWebアプリを多言語化:複数形・通貨・hreflangの実装
Reactアプリのi18nを実装で学ぶ。next-intlのメッセージ管理、複数形・日付・通貨の地域化、言語切替、hreflangとロケール検出まで。10言語運用の経験つき。
宿泊施設のキャンセル返信をClaude Codeで整える: 予約番号と個人情報を渡さない確認表
宿泊施設のキャンセル返信で予約番号、氏名、返金条件をAIに渡さず、返信前チェック表を作る手順。
保険代理店の事故受付書類をClaude Codeで守る: 証券番号と診断書を渡さない入力境界
保険代理店向けに、事故受付CSV、メール、添付書類を分け、AIへ渡す前の匿名化と権限を整える手順です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。