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

用 Claude Code 实现货币格式化:Intl.NumberFormat 实战指南

用Intl.NumberFormat实现多币种显示、舍入、会计负数、minor unit和JPY/USD/EUR等测试。

用 Claude Code 实现货币格式化:Intl.NumberFormat 实战指南

货币格式化不是 UI 小装饰,而是计费边界

在 SaaS 订阅、跨境电商、发票和后台报表里,金额显示不能只靠字符串拼接。日元通常没有小数位,美元和欧元有两位小数,印度英语的分组是12,34,567,巴西葡萄牙语会把小数点和千分位写法反过来。退款金额还可能需要会计格式,例如($10.00)

更稳的做法是:数据库保存 minor unit(最小货币单位)的整数,业务计算也使用整数,只在展示前用Intl.NumberFormat格式化。minor unit 可以理解为“用于计算的最小金额单位”:美元是 cent,日元是 1 日元。

本文给出一套适合让 Claude Code 实现和审查的方案:多币种 SaaS 价格、舍入、会计负数、不要保存格式化字符串、JPY/USD/EUR/BRL/INR/IDR 的测试,以及可直接粘贴的 review prompt。官方参考请看 MDN 的Intl.NumberFormat构造函数formatToPartsECMA-402 NumberFormat 规范

flowchart LR
  A[DB: minor unit integer] --> B[Domain math]
  B --> C[Tax, discount, refund]
  C --> D[Intl.NumberFormat]
  D --> E[UI, invoice, email]

先分清保存值和显示值

不要把"$19.99""¥1,980"直接存进数据库。应该保存amountMinorcurrency,例如1999 USD1980 JPY123456 IDR。这样做能避免浮点误差,也方便排序、求和、退款、审计和多语言展示。

方案示例优点风险
保存格式化字符串"$19.99"静态页面很快报表、退款、翻译都会变麻烦
保存小数 number19.99入门简单浮点误差和币种小数位会扩散
保存 minor unit 整数1999计算和测试稳定需要在输入输出边界转换

Intl.NumberFormat只负责显示。汇率、税率、支付服务的最小单位规则、发票在哪一步舍入,都需要产品和后端明确决定。

可直接运行的实现

把下面内容保存为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");

真实用例:SaaS 价格、发票和导出

多币种价格表应该传结构化数据,而不是翻译好的价格文案。

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 },
];

第二个用例是发票和退款。负数金额可以用currencySign: "accounting"交给运行时按地区规则显示,但不要假设所有语言都会用括号。发票 PDF 如果有固定规则,必须写成测试。

第三个用例是税、折扣和按日计费。1999 * 10 / 31不会刚好整除,所以要决定“按行舍入”还是“合计后舍入”。把这个策略写进测试名,比只让 Claude Code “修一下舍入”可靠得多。

第四个用例是后台和 CSV。建议同时输出amount_minorcurrencyamount_display三列。这样人能读,BI 工具也能正确计算 MRR、LTV、退款率和广告回收。

在实际项目里,我还会让 Claude Code 顺手搜索toLocaleStringreplace("$"formattedPricepriceLabel这类关键词。很多旧代码不是集中在一个 money helper 里,而是散在 React component、邮件模板、PDF 生成器和 CSV 导出脚本中。先列出调用点,再逐个替换成统一的formatMoney,比一次性大改更容易 review,也更适合多人同时维护内容和代码的仓库。

常见失败

不要保存格式化后的金额字符串。不要把 locale 和 currency 混为一谈:中文界面的用户也可能用 USD 支付,美国团队也可能审核 EUR 发票。不要假设每种货币都是两位小数。不要把roundingMode当成完整的计费策略,它只是显示阶段的舍入选项。需要拆分符号和数字时,不要用正则解析货币字符串,应该使用formatToParts

Claude Code 审查 Prompt

Review this repository's money formatting and billing math.
Requirements:
- Check that DB/API models do not store formatted currency strings
- Make JPY/USD/EUR/BRL/INR/IDR minor units explicit
- Use Intl.NumberFormat while keeping locale and currency separate
- Decide whether refunds and discounts need currencySign: "accounting"
- Make rounding policy visible in test names
- Add Node-runnable tests for currency formatting behavior

相关链接和变现提示

多语言 URL 和翻译文件请继续看Claude Code i18n 实现,日期和时区看日期时间处理,订阅付费流程看Stripe 订阅实现

ClaudeCodeLab 会整理这类“AI 容易写出能跑但不够可审计”的实现清单。做收费产品前,请先确认金额数据已经拆成amountMinor + currency + locale,再让 Claude Code 改价格页或发票。

我在本地 Node.js 24 环境运行了示例脚本,确认 JPY/USD/EUR/BRL/INR/IDR 的小数位、货币 part、USD 会计负数、整数计算和币种不匹配错误。不同 ICU 数据可能让最终字符串略有差异,所以生产测试应验证规则,而不是只依赖一个固定快照。

#Claude Code #货币 #格式化 #Intl #国际化
免费

免费 PDF: Claude Code 速查表

输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。

我们会妥善保护你的信息,不发送垃圾邮件。

把 Claude Code 变成真正能带来结果的工作流

先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。

Masa

关于作者

Masa

专注 Claude Code 实务流程、团队导入和内容转化的工程师。