Claude Codeで通貨フォーマットを実装する:Intl.NumberFormat実践ガイド
Intl.NumberFormatでJPY/USD/EURなどの通貨表示、丸め、会計表記、テストまで実装するClaude Code実践ガイド。
通貨フォーマットは「見た目」ではなく請求ロジックの一部
月額SaaS、EC、管理画面、請求書PDFで金額を扱うとき、¥1,980や$19.99をただ文字列として作るだけなら簡単に見えます。けれど実運用では、円は小数なし、ドルやユーロは小数2桁、インドは桁区切りが12,34,567、ブラジルでは小数点とカンマの意味が逆、返金は会計表記で($10.00)にしたい、という差が一気に出ます。
ここで自前のreplaceやテンプレート文字列に頼ると、最初は動いても多通貨化した瞬間に破綻します。通貨フォーマットは、ユーザーの信頼、返金問い合わせ、税計算、売上レポートに直結するため、UIの飾りではなく請求ロジックの境界として設計するべきです。
この記事では、Claude Codeに任せてもレビューしやすい形で、ブラウザ標準のIntl.NumberFormatを使った通貨表示、丸め、minor unit(最小通貨単位。ドルならセント、円なら1円)、会計表記、テストまでをまとめます。Masaの案件で効いた実践ルールは、「DBには表示済み文字列を保存しない」「計算は整数で行う」「表示直前だけIntl.NumberFormatに渡す」の3つでした。
公式仕様は、MDNのIntl.NumberFormatコンストラクターとECMA-402 Intl仕様を確認してください。currencySign: "accounting"、currencyDisplay、signDisplay、roundingModeの意味をClaude Codeに説明させるより、公式リンクをプロンプトに貼って実装させる方がレビューの精度が上がります。
flowchart LR
A[DB: minor unit integer] --> B[Domain math]
B --> C[Tax, discount, refund]
C --> D[Intl.NumberFormat]
D --> E[UI, invoice, email]
設計方針:保存する値と表示する値を分ける
最初に決めるべきことは、金額をどの単位で保存するかです。おすすめは、アプリで対応する通貨ごとにminor unitの整数を保存することです。USDの$19.99は1999、JPYの¥1,980は1980、IDRのRp 123.456は123456のように扱います。
比較するとこうなります。
| 方針 | 保存例 | 良い点 | 失敗しやすい点 |
|---|---|---|---|
| 表示済み文字列を保存 | "¥1,980" | 画面に出すだけなら早い | 検索、集計、税計算、多言語表示で壊れる |
| 小数のnumberを保存 | 19.99 | 入門時は書きやすい | 丸め誤差と通貨別小数桁の管理が曖昧 |
| minor unit整数を保存 | 1999 | 計算、集計、テストが安定 | 入出力境界で変換関数が必要 |
Intl.NumberFormatは表示の道具です。為替換算、税率決定、StripeやPaddleなど決済プロバイダーの最小単位仕様までは代わりに決めてくれません。Claude Codeに頼むときも「フォーマット関数を作って」ではなく、「保存形式、計算形式、表示形式を分けて」と伝えるのが重要です。
コピペで動く実装:Node.jsで確認できる通貨ユーティリティ
次のファイルはそのままcurrency-format-demo.mjsとして保存し、node currency-format-demo.mjsで実行できます。外部ライブラリは不要です。記事では6通貨に絞っていますが、実プロダクトでは決済プロバイダーと会計要件に合わせて通貨テーブルを管理してください。
// currency-format-demo.mjs
import assert from "node:assert/strict";
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(`Unsupported currency: ${currency}`);
}
}
function roundHalfAwayFromZero(value) {
return value < 0 ? -Math.round(Math.abs(value)) : Math.round(value);
}
export function moneyFromMajor(amount, currency) {
assertCurrency(currency);
if (!Number.isFinite(amount)) {
throw new Error(`Invalid amount: ${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(`Currency mismatch: ${left.currency} vs ${right.currency}`);
}
return { minor: left.minor + right.minor, currency: left.currency };
}
export function multiplyMoney(money, factor) {
if (!Number.isFinite(factor)) {
throw new Error(`Invalid factor: ${factor}`);
}
return {
minor: roundHalfAwayFromZero(money.minor * factor),
currency: money.currency,
};
}
export function formatMoney(
money,
{
locale = "en-US",
accounting = false,
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 })}`);
}
assert.equal(
addMoney(moneyFromMajor(19.99, "USD"), moneyFromMajor(5, "USD")).minor,
2499,
);
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")),
/Currency mismatch/,
);
console.log("currency formatting checks passed");
ポイントは、formatMoneyだけが文字列を返すことです。税込価格、割引、日割り、返金を文字列で回すと、あとから集計不能になります。
実例1:多通貨SaaSの料金表
日本向けは¥1,980、米国向けは$19.99、EU向けは€18.99のように見せたい場合、料金マスタは表示文字列ではなく、通貨ごとのminor unitで持ちます。
type CurrencyCode = "JPY" | "USD" | "EUR" | "BRL" | "INR" | "IDR";
type PlanPrice = {
planId: "starter" | "pro" | "team";
currency: CurrencyCode;
amountMinor: number;
};
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を渡す」と明示してください。
実例2:請求書・返金・会計表記
返金、値引き、未収金などの負の金額は、国や会計慣習によって表示が変わります。英語圏の請求書では-$1,299.00より($1,299.00)の方が自然なケースがあります。Intl.NumberFormatではcurrencySign: "accounting"を使います。
formatMoney(
{ minor: -129900, currency: "USD" },
{ locale: "en-US", accounting: true },
);
会計表記はすべてのロケールで括弧になるわけではありません。請求書PDFで固定表記が必要なら、要件化してスナップショットテストで確認してください。
実例3:税、割引、日割りの丸め
月額料金を日割りすると、1999 * 10 / 31のように割り切れない値が出ます。ここで小数のまま次の処理へ渡すと、画面ごとに合計が1円ずれることがあります。
請求行ごとに丸めるのか、合計後に丸めるのかで結果が変わります。Claude Codeには「丸め位置をテスト名に含める」と指示してください。
実例4:管理画面とCSVエクスポート
管理画面では見やすい通貨表示が必要ですが、CSVやBIツール向けには数値列も必要です。formattedAmountだけを出力すると、ExcelやBigQueryで集計できません。
| 出力列 | 例 | 用途 |
|---|---|---|
amount_minor | 1999 | 正確な集計、監査 |
currency | USD | 通貨別集計 |
amount_display | $19.99 | 人間が読む一覧 |
この3列を分けるだけで、広告費、LTV、MRRを後から分析しやすくなります。
よくある失敗と落とし穴
1つ目は、DBに"$19.99"や"1.980円"を保存することです。これは一見便利ですが、通貨変更、返金、税率変更、会計監査で詰まります。
2つ目は、通貨とロケールを混同することです。ja-JPだから必ずJPY、en-USだから必ずUSDとは限りません。日本在住のユーザーがUSD請求を見たり、米国チームがEUR請求書を確認したりするケースは普通にあります。
3つ目は、すべての通貨が小数2桁だと思い込むことです。JPYやIDRはIntl.NumberFormatの既定表示で小数なしになります。将来対応通貨を増やすなら、minorUnitDigitsを仕様としてレビュー対象にしてください。
4つ目は、roundingModeを使えば請求仕様が自動で決まると考えることです。MDNにある通り、Intl.NumberFormatの丸めは表示時の丸めです。請求金額そのものの丸め位置は、アプリ側で決める必要があります。
5つ目は、formatToPartsを使わずに記号を正規表現で抜くことです。通貨記号の位置、空白、マイナス記号はロケールで変わります。入力欄や強調表示を作るときは、formatToPartsでcurrency、integer、fractionを分解してください。
Claude Codeに投げるレビュー用プロンプト
次のプロンプトを、既存アプリの料金表や請求書周りに対して使うと、実装漏れを拾いやすくなります。
このリポジトリの金額表示と請求計算をレビューしてください。
条件:
- DBやAPIに表示済み通貨文字列を保存していないか確認する
- JPY/USD/EUR/BRL/INR/IDRのminor unitを明示する
- Intl.NumberFormatでlocaleとcurrencyを分けて扱う
- 返金・割引・負数にcurrencySign: "accounting"が必要か判断する
- 丸め位置が請求行単位か合計単位かをテスト名に出す
- 変更後にNodeで実行できるテストを追加する
MDNのIntl.NumberFormatオプション、formatToParts、ECMA-402 NumberFormat仕様もプロンプトに含めると、独自実装に寄りにくくなります。
関連記事と収益化の導線
多言語URLや翻訳ファイルの設計はClaude Codeでi18n実装、日付とタイムゾーンはClaude Codeで日付・時間処理、決済導線はClaude CodeでStripeサブスクリプション実装も合わせて読むと、請求周りの抜け漏れを減らせます。
ClaudeCodeLabでは、こうした「AIに丸投げしにくい実装境界」をレビューできるチェックリストやプロンプト集を販売・配布しています。料金表、請求書、返金処理をClaude Codeで直す前に、まず自分のプロダクトの金額データがamountMinor + currency + localeに分かれているかを確認してください。
この記事で紹介した内容は、手元のNode.js 24環境でcurrency-format-demo.mjsを実行し、JPY/USD/EUR/BRL/INR/IDRのresolvedOptions().maximumFractionDigits、通貨パーツの存在、USDの会計表記、通貨不一致エラーを確認しました。ロケールごとの文字列そのものはOSやICUデータで細部が変わるため、テストでは「完全一致」だけに頼らず、桁数、通貨コード、負数ルール、保存形式を検証するのが実務では安全です。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
ObsidianメモをCLAUDE.mdに変えるClaude Code運用: 文脈を毎回説明しない仕組み
Obsidianの作業メモからCLAUDE.md用の運用ノートを作り、Claude Codeに安定した文脈を渡す方法。
Claude Code Revenue CTA Routing: 記事からPDF、Gumroad、相談へ送る設計
PVだけで終わらせず、読者の状態に合わせて無料PDF、Gumroad教材、導入相談へ分岐するCTA設計です。
Claude Codeチーム引き継ぎルール: レビュー、権限、収益導線まで渡す実務手順
Claude Codeの作業をチームで渡すための証拠、権限、ロールバック、無料PDF/Gumroad/相談導線の実務ルール。