Skip to main content

多币种金额怎么处理

跨境场景里,金额是最容易出错、也最不容易被发现的一类 bug。它不会抛异常,只是 quietly 差几分钱——直到某个用户对账时发现购物车总价和商品单价之和对不上。

根子在两处:一是浮点数,二是各币种的小数位数不一样。

存最小单位整数算整数或 decimal舍按币种位数显Intl 格式化位数不是都等于 2日元 0 位、科威特第纳尔 3 位,按 ISO 4217 exponent 取汇率要存下来订单上记录当时用的汇率与时间戳,否则半年后无法对账展示前才转浮点除以 10 的 exponent 次方那一刻才碰浮点,且立刻转成字符串toFixed 不能替代这套流程:它既没解决精度,也不管千分位与符号位置
图:存、算、舍、显四步,顺序不能乱

1. 浮点不能用​

0.1 + 0.2 !== 0.3 这件事在《JS 数字精度丢失及处理》里已经展开过。对金额来说结论更绝对:不要用浮点存储或计算金额。

不是"小心一点就行",而是根本不该用。因为金额要做的操作——累加、拆分、按比例分配、对账——都会把微小的误差放大成可见的差异。

0.1 + 0.2              // 0.30000000000000004
1.255.toFixed(2) // '1.25'(不是 '1.26')

第二行尤其值得注意:toFixed 不是精度问题的解法,它只是把问题格式化掉了。它既不解决底层表示,也不管千分位和符号位置。

2. 正确顺序:存、算、舍、显​

2.1 存:最小货币单位的整数​

$10.50 存成 1050(分)。数据库列用 BIGINT 或 DECIMAL(19,4),不用 FLOAT。

这么做还有一个附带好处:支付渠道通常就要求最小单位整数,所以你在接口边界上不用再做转换。

2.2 算:整数运算或 decimal 库​

加法、减法、比较都用整数。只有涉及比例(税率、折扣百分比、汇率)时才需要小数,这时用 decimal 库而不是原生浮点。

JS 里 Number.MAX_SAFE_INTEGER 是 2^53-1 ≈ 9.007×10^15,对"分"来说约 9007 万亿分(折合约 90 万亿元),日常业务足够。但如果处理的是印尼盾、越南盾这类面额极大的货币的最小单位,仍要注意范围——必要时用 BigInt。

2.3 舍:按目标币种的位数​

这是最容易被写死的地方。

不是所有币种都是两位小数:

  • 多数货币:2 位(USD、EUR、CNY)
  • 约十七八种:0 位(JPY、KRW、VND、CLP、ISK…)
  • 七种:3 位(KWD、BHD、OMR、JOD、TND、IQD、LYD)

权威来源是 ISO 4217 的 exponent 字段。代码里应该查这个表,而不是 currency === 'JPY' ? 0 : 2——后者会漏掉另外十几种零位货币(0 位约十七八种、2 位是主流、3 位有 7 种如 BHD/IQD/JOD/KWD/LYD/OMR/TND、还有唯一的 4 位 CLF——智利 Unidad de Fomento)。注意维度是两头都有的:有比 2 小的,也有比 2 大的。

未知币种要抛错,不要静默按两位处理。

舍入模式也要明确。金融计算常用银行家舍入(四舍六入五取偶),它不会在大量交易中产生系统性偏差。而"总是向上取整"保护毛利但用户会觉得不划算,"总是向下"相反——这是产品决策,不是技术决策,但要显式选定而不是用语言默认值。

2.4 显:用 Intl.NumberFormat​

只在最后一步把最小单位转成展示值:

const fmt = new Intl.NumberFormat(locale, { style: 'currency', currency })
fmt.format(amountMinor / 10 ** exponentFor(currency))

Intl.NumberFormat 知道每种货币的小数位、符号位置、分隔符规则:

formatMinor(1999, 'USD')           // $19.99
formatMinor(2943, 'JPY', 'ja-JP') // ¥2,943
formatMinor(6115, 'KWD') // KWD 6.115
formatMinor(1847, 'EUR', 'de-DE') // 18,47 €

两个实用提醒:

  • 实例很贵,要缓存。按 locale + currency 缓存,不要每行数据 new 一个
  • 除法是唯一允许碰浮点的地方,且结果立刻变成字符串

3. 汇率处理​

3.1 存下当时用的汇率​

这是跨境场景特有的、也是最容易被漏的一条:订单上要记录当时用的汇率和时间戳。

{
amount_minor: 2943,
currency: 'JPY',
rate: 147.215,
rate_timestamp: 1755244800
}

不存的话,半年后用户 dispute 一笔订单,你无法解释这个数字是怎么来的。存了,这笔账才可对。

3.2 展示汇率和结算汇率要一致​

用户在商品页看到的价格,和最终扣款的价格,应该用同一个汇率。市场在这期间动了,差价的归属要事先定义清楚。

常见做法是锁汇:加购或进入结算时锁定汇率,设定有效期(比如 15–30 分钟),过期后重新取并告知用户价格变化。

3.3 缓存策略​

展示用途的汇率缓存 1–4 小时通常可接受;结算用途要更接近实时。无论哪种,都要有过期阈值——缓存超期就重新取,不要用旧值。

另外要显示汇率与取值时间给用户("约合 EUR 44.99,按 2026-10-15 汇率")。这既是透明度要求,也避免了后续争议。

4. 拆分要保证总和相等​

把一个订单金额按比例拆给多个子项(分摊运费、拆分退款),每份单独取整之后加起来必须等于总额。

天真做法会差几分:三份各 33.33,加起来 99.99,而总额是 100。

标准解法是最大余额法:先按整数分配,再把余数按小数部分从大到小依次补给前几份。

function allocate(total: number, weights: number[]): number[] {
const sum = weights.reduce((a, b) => a + b, 0)
const raw = weights.map(w => (total * w) / sum)
const base = raw.map(Math.floor)
let rest = total - base.reduce((a, b) => a + b, 0)
const order = raw
.map((v, i) => ({ i, frac: v - Math.floor(v) }))
.sort((a, b) => b.frac - a.frac)
for (let k = 0; rest > 0; k++, rest--) base[order[k].i]++
return base
}

这条要在测试里覆盖:1 到 100 份的所有拆分,总和都必须等于总额。

5. 与 AI 场景的交叉​

一个具体的交叉点:如果 AI 客服要回答"这笔订单退款多少"这类问题,金额不能由模型生成。

模型会算错,也会把 1999 显示成 19.99 或反过来。正确做法是:

  • 模型只负责识别意图和提取订单号
  • 金额由确定性代码从订单服务取
  • 展示时走上面这套格式化

这其实是《结构化输出与自愈》那条原则的延伸:涉及精确值的部分应该是确定性的代码路径,模型只处理不确定的部分。

另一个交叉点:多语言展示时,金额格式要按地区而不是按界面语言——见《多语言要分开语言和地区》里关于地区那一节,ui_locale 和地区是两个维度。

6. 测试清单​

金额 bug 都藏在没人写测试的地方。至少覆盖:

  • 零位币种:转成 JPY/KRW,断言结果没有小数
  • 三位币种:转成 KWD/BHD,断言三位小数保留
  • 正好一半:断言你选定的舍入模式,正反两个方向,含负数
  • 往返漂移:USD → EUR → USD,断言误差在一个最小单位内(不是断言相等)
  • 拆分不变性:1 到 100 份,各部分之和恒等于总额
  • 未知币种:断言抛错而不是默认两位
  • 极大金额:断言在 MAX_SAFE_INTEGER 内不丢精度

前两条最容易漏,因为它们需要你知道"还有零位和三位币种这回事"。

7. 一句话​

金额的正确处理顺序是固定的:用最小单位整数存储,用整数或 decimal 计算,按目标币种位数舍入,最后才用 Intl 格式化展示。

把顺序搞反(比如先格式化再计算、或者用浮点存了再补救),后面每一个环节都会变成补丁。