用 Claude Code 实现货币格式化:Intl.NumberFormat 实战指南
用Intl.NumberFormat实现多币种显示、舍入、会计负数、minor unit和JPY/USD/EUR等测试。
货币格式化不是 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构造函数、formatToParts和ECMA-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"直接存进数据库。应该保存amountMinor和currency,例如1999 USD、1980 JPY、123456 IDR。这样做能避免浮点误差,也方便排序、求和、退款、审计和多语言展示。
| 方案 | 示例 | 优点 | 风险 |
|---|---|---|---|
| 保存格式化字符串 | "$19.99" | 静态页面很快 | 报表、退款、翻译都会变麻烦 |
| 保存小数 number | 19.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_minor、currency、amount_display三列。这样人能读,BI 工具也能正确计算 MRR、LTV、退款率和广告回收。
在实际项目里,我还会让 Claude Code 顺手搜索toLocaleString、replace("$"、formattedPrice、priceLabel这类关键词。很多旧代码不是集中在一个 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 数据可能让最终字符串略有差异,所以生产测试应验证规则,而不是只依赖一个固定快照。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。