Use Cases (更新: 2026/6/2)

Claude Codeで通貨フォーマットを実装する:Intl.NumberFormat実践ガイド

Intl.NumberFormatでJPY/USD/EURなどの通貨表示、丸め、会計表記、テストまで実装するClaude Code実践ガイド。

Claude Codeで通貨フォーマットを実装する:Intl.NumberFormat実践ガイド

通貨フォーマットは「見た目」ではなく請求ロジックの一部

月額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"currencyDisplaysignDisplayroundingModeの意味を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.991999、JPYの¥1,9801980、IDRのRp 123.456123456のように扱います。

比較するとこうなります。

方針保存例良い点失敗しやすい点
表示済み文字列を保存"¥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には「表示済み文字列を渡さず、amountMinorcurrencyを渡す」と明示してください。

実例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_minor1999正確な集計、監査
currencyUSD通貨別集計
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を使わずに記号を正規表現で抜くことです。通貨記号の位置、空白、マイナス記号はロケールで変わります。入力欄や強調表示を作るときは、formatToPartscurrencyintegerfractionを分解してください。

Claude Codeに投げるレビュー用プロンプト

次のプロンプトを、既存アプリの料金表や請求書周りに対して使うと、実装漏れを拾いやすくなります。

このリポジトリの金額表示と請求計算をレビューしてください。
条件:
- DBやAPIに表示済み通貨文字列を保存していないか確認する
- JPY/USD/EUR/BRL/INR/IDRのminor unitを明示する
- Intl.NumberFormatでlocaleとcurrencyを分けて扱う
- 返金・割引・負数にcurrencySign: "accounting"が必要か判断する
- 丸め位置が請求行単位か合計単位かをテスト名に出す
- 変更後にNodeで実行できるテストを追加する

MDNのIntl.NumberFormatオプションformatToPartsECMA-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データで細部が変わるため、テストでは「完全一致」だけに頼らず、桁数、通貨コード、負数ルール、保存形式を検証するのが実務では安全です。

#Claude Code #通貨 #フォーマット #Intl #国際化
無料

無料PDF: Claude Code はじめてのチートシート

まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。

スパムは送りません。登録情報は厳重に管理します。

Claude Codeを仕事で使える形にしませんか?

無料PDFで基礎を固めたあと、すぐ使えるテンプレート集で試し、必要なら業務自動化や導入相談まで進められます。

Masa

この記事を書いた人

Masa

Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。

PR

関連書籍・参考図書

この記事のテーマに関連する書籍を楽天ブックスで探せます。

※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。