东京的一位客户购买了 19.99 美元的订阅。你的代码乘以 USD/JPY 汇率,得到 2942.82785,写入数据库,然后发送给支付服务商。服务商拒绝了它——或者更糟,接受了它并多收了 100 倍的钱。日元没有小数位,而你的代码从未过问。
货币舍入属于那类看似微不足道、直到进入生产环境才暴露的问题。它不是格式问题,而是正确性问题。每种货币都有自己的小数位数,浮点运算会悄悄地破坏金额,而当你在货币之间转换的那一刻,就引入了一个必须刻意做出的舍入决策。本指南涵盖真正重要的规则:每种货币有多少位小数、为什么应该以整数存储金额、该选择哪种舍入模式,以及如何对外汇换算进行舍入,让你的账簿在月末仍然能对上。
最小单位:每种货币有多少位小数?
一种货币的最小单位是它可交易的最小细分。对美元来说是分,所以 USD 有两位小数,19.99 美元就是 1999 分。这正是支付服务商期望的表示方式,也是你的数据库应该使用的方式。
ISO 4217——就是那个给你 USD、JPY 等三字母代码的标准——同时也为每种货币指定了一个最小单位指数。大多数开发者假定这个指数永远是 2。它不是,而这个假设正是这一类别中代价最高的 bug。
零小数位货币
这些货币没有流通中的辅币单位,所以你发送的金额就是整数单位数:
- JPY — 日元
- KRW — 韩元
- VND — 越南盾
- CLP — 智利比索
- ISK — 冰岛克朗
- XAF / XOF / XPF — 中非法郎与太平洋法郎
- UGX — 乌干达先令
- PYG — 巴拉圭瓜拉尼
- RWF、GNF、KMF、DJF、VUV — 以及其他若干小面额货币
如果你把 JPY 当作两位小数的货币,在发送给服务商之前乘以 100,你刚刚向客户多收了100 倍的金额。
三小数位货币
有七种货币细分为千分之一而非百分之一:
- KWD — 科威特第纳尔(1000 费尔)
- BHD — 巴林第纳尔(1000 费尔)
- OMR — 阿曼里亚尔(1000 拜萨)
- JOD — 约旦第纳尔(1000 费尔)
- TND — 突尼斯第纳尔(1000 米利姆)
- IQD — 伊拉克第纳尔(1000 费尔)
- LYD — 利比亚第纳尔(1000 迪尔汗)
这里的错误方向相反:把 KWD 当作两位小数,你只会收取本意金额的十分之一。一张 KWD 12.500 的发票变成了 KWD 1.250。
ISO 4217 中甚至还有四位小数的条目——智利的发展单位(CLF)和乌拉圭的养老单位(UYW)。它们是指数化的记账单位而非现金,但如果你的系统接受任意 ISO 代码,就必须能扛住它们。
当标准与你的服务商不一致时
这是那些其他事情都做对了的团队掉进去的陷阱。支付服务商有时会出于运营原因偏离 ISO 4217。例如 Adyen 明确记载,CLP、CVE、IDR 和 ISK 在其 API 中使用的小数位数与标准规定不同——ISK 在 ISO 4217 下是零小数位,但提交给 Adyen 时必须带两位小数。
规则是:你的舍入表是你所对接系统的属性,而不是一个普适常量。 为每个集成维护一张表,以 ISO 4217 为基础,并在其文档要求处按服务商覆盖。永远不要把 100 写死。
永远不要用浮点数存储金额
在讨论舍入之前,先说基础。二进制浮点无法精确表示大多数十进制小数:
0.1 + 0.2 // 0.30000000000000004
1.005 * 100 // 100.49999999999999
19.99 * 147.2150 // 2942.8278499999997这些尾部数字并非无关紧要。在错误的时机把它们送进 Math.round(),你会得到一个偏差一个最小单位的金额,而这足以让对账失败。
两条规则覆盖几乎所有情况:
- 以最小单位的整数存储金额。 一个
amount_minor BIGINT列加一个currency CHAR(3)列。{ amount_minor: 1999, currency: "USD" }毫不含糊,并且与 Stripe、Adyen 及大多数服务商的期望一致。 - 用整数或十进制类型做运算。 Python 的
decimal.Decimal、Java 的BigDecimal、PostgreSQL 的NUMERIC,或封装整数运算的 JavaScript 货币库。把浮点数留给汇率本身,而且也只用到相乘那一步为止。
如果你正在从零设计这一层,我们的多币种账簿设计指南更深入地讨论了表结构决策。
换算流程:最小单位进,最小单位出
货币换算恰好有四个步骤,而舍入属于第三步——只做一次,在最后。
- 把源金额从最小单位转换为十进制数值。
- 乘以全精度汇率。
- 舍入到目标货币的最小单位指数。
- 转换回整数最小单位。
下面是 JavaScript 实现,由按货币的表来承担工作:
// Minor unit exponents. Seed from ISO 4217, override per payment provider.
const MINOR_UNITS = {
USD: 2, EUR: 2, GBP: 2, CHF: 2, CAD: 2, AUD: 2, CNY: 2, INR: 2,
JPY: 0, KRW: 0, VND: 0, CLP: 0, ISK: 0, XAF: 0, XOF: 0, XPF: 0,
KWD: 3, BHD: 3, OMR: 3, JOD: 3, TND: 3, IQD: 3, LYD: 3,
};
function exponentFor(currency) {
const e = MINOR_UNITS[currency];
if (e === undefined) throw new Error(`Unknown minor unit for ${currency}`);
return e;
}
/**
* Convert an integer minor-unit amount from one currency to another.
* Returns an integer in the target currency's minor units.
*/
function convertMinor(amountMinor, from, to, rate) {
const fromExp = exponentFor(from);
const toExp = exponentFor(to);
const decimalAmount = amountMinor / 10 ** fromExp; // 1999 -> 19.99
const converted = decimalAmount * rate; // full precision, no rounding yet
return Math.round(converted * 10 ** toExp); // single rounding step
}
convertMinor(1999, "USD", "JPY", 147.2150); // 2943 (¥2,943)
convertMinor(1999, "USD", "KWD", 0.30590); // 6115 (KWD 6.115)
convertMinor(1999, "USD", "EUR", 0.9241); // 1847 (€18.47)注意这个函数没有做什么:它从不舍入汇率,从不舍入中间值,也从不假定两位小数。这里的 Math.round 对正数是"四舍五入进位"——用于结账没问题,但在用于任何受监管的场景之前,请先读下一节。
选择舍入模式
"保留两位小数"不是一份规格说明。打破平局至少有五种站得住脚的方式,而金融系统在意你选了哪一种。
| 模式 | 2.5 → | 3.5 → | −2.5 → | 典型用途 |
|---|---|---|---|---|
| 四舍五入(half up) | 3 | 4 | −3 | 消费者定价、结账总额 |
| 银行家舍入(half even) | 2 | 4 | −2 | 会计、利息、税务、报表 |
| half down | 2 | 3 | −2 | 少见;偶见于遗留金融代码 |
| 向上取整(ceiling) | 3 | 4 | −2 | 绝不能少收的手续费 |
| 向下取整(floor / 截断) | 2 | 3 | −3 | 绝不能多付的结算款 |
Math.round() 对正数的行为。它直观,适合客户即将看到的价格。Half even,即银行家舍入,把恰好一半的值送到最近的偶数位。在大量交易中,它抵消了 half-up 带来的系统性向上偏差,这也是它成为会计系统、Python decimal 模块乃至 IEEE 754 默认模式的原因。如果你要把成千上万笔换算金额汇总进营收报表,half-up 会悄悄抬高总额,half-even 不会。
Ceiling 与 floor 是为不对称风险准备的。向卖家结算的市场平台可能对每笔结算向下取整,以确保永不多付;差额进入舍入科目。
Python 把这个选择显式化,这是正确的人体工学:
from decimal import Decimal, ROUND_HALF_EVEN, ROUND_HALF_UP
MINOR_UNITS = {"USD": 2, "EUR": 2, "JPY": 0, "KWD": 3}
def convert_minor(amount_minor: int, src: str, dst: str,
rate: str, mode=ROUND_HALF_EVEN) -> int:
"""Convert integer minor units to integer minor units, exactly once."""
src_exp, dst_exp = MINOR_UNITS[src], MINOR_UNITS[dst]
amount = Decimal(amount_minor) / (Decimal(10) ** src_exp)
converted = amount * Decimal(rate) # rate passed as a string, not a float
quantum = Decimal(1).scaleb(-dst_exp) # 0.01, 1, or 0.001
rounded = converted.quantize(quantum, rounding=mode)
return int(rounded.scaleb(dst_exp))
convert_minor(1999, "USD", "JPY", "147.2150") # 2943
convert_minor(1999, "USD", "KWD", "0.30590") # 6115把汇率作为字符串传入 Decimal 很关键。Decimal(0.9241) 会继承浮点误差;Decimal("0.9241") 不会。
三个真正烧钱的舍入 bug
1. 相乘之前先舍入汇率
汇率通常带有四到六位有效小数,截断它们绝非无害。以 USD/JPY 147.2150 和一笔 10,000 美元的汇款为例:
- 完整汇率:
10000 × 147.2150 = ¥1,472,150 - 汇率舍入到两位小数(147.21):
10000 × 147.21 = ¥1,472,100
单笔交易就有 ¥50 的差异,纯粹因为在使用汇率之前先格式化了它。以服务商返回的精度存储汇率,只对结果金额舍入,并把你实际使用的确切汇率与交易一起持久化以备审计。我们关于汇率 API 的数据从哪里来的指南解释了这种精度为何一开始就有意义。
2. 多段换算中舍入两次
如果你走 USD → EUR → JPY 并在 EUR 这一步舍入,你就丢掉了精度,而第二次乘法会把它放大。用 USD/EUR 0.9241 与 EUR/JPY 159.3063 换算 12.34 美元:
- 直接换算:
12.34 × 147.2150 = 1816.63→ ¥1,817 - 经过舍入的 EUR 中间段:
12.34 × 0.9241 = 11.4034→ 舍入为 €11.40 →11.40 × 159.3063 = 1816.09→ ¥1,816
一日元之差,源自一次多余的舍入。在五万笔交易的结算批次上,这就是一张对账工单。只要有直接货币对可用就用它;必须三角换算时,请让中间值保持全精度。机制详见交叉汇率详解。
3. 明细行加不出总额
对发票的每一行独立舍入,各部分未必能加出舍入后的总额。经典案例是拆分:
$10.00 split three ways
10.00 / 3 = 3.3333...
→ 3.33 + 3.33 + 3.33 = 9.99 ✗ one cent missing解决办法是分配,而不是舍入。先把总额舍入一次,再分配到各部分,把余数按最小单位逐个发出去:
/**
* Split an integer minor-unit total into `n` parts whose sum is exactly the total.
* Remainder units are distributed to the earliest parts (largest-remainder method).
*/
function allocate(totalMinor, ratios) {
const sum = ratios.reduce((a, b) => a + b, 0);
const shares = ratios.map(r => Math.floor((totalMinor * r) / sum));
let remainder = totalMinor - shares.reduce((a, b) => a + b, 0);
for (let i = 0; remainder > 0; i = (i + 1) % shares.length, remainder--) {
shares[i] += 1;
}
return shares;
}
allocate(1000, [1, 1, 1]); // [334, 333, 333] → sums to exactly 1000
allocate(9247, [3, 2, 1]); // [4624, 3082, 1541] → sums to exactly 9247外汇换算之后套用同样的模式:换算并舍入发票总额,然后把该总额分配到各明细行。明细行永远能对上,因为它们是从总额派生的,而不是各自独立计算的。这在多币种开票和带按比例计费的 SaaS 结算中尤为重要,一分钱的偏差会出现在客户看到的 PDF 上。
现金舍入是另一套规则
货币的最小单位告诉你可以记录的最小金额。它并不总是告诉你可以用现金支付的最小金额。多个国家撤出了最小面额硬币,并在收银台对现金支付进行舍入:
- 瑞士 — 现金舍入到最接近的 0.05 CHF
- 加拿大 — 一分硬币于 2013 年退出流通;现金舍入到最接近的 5 分
- 瑞典 — 现金舍入到最接近的整克朗
- 荷兰 — 现金舍入到最接近的 5 欧分
关键在于:这适用于现金支付,而不是发票。一张 12.32 瑞郎的瑞士发票仍然记为 12.32;只有现金结算才舍入为 12.30,那 0.02 的差额记为舍入调整。如果你在做 POS 软件,请把现金舍入建模为作用于付款的独立的、靠后的一步——绝不要把它揉进存储金额,否则你的电子交易与现金交易会对不上。
格式化是最后一步,不是计算
运算完成后,把展示交给区域感知的格式化器。Intl.NumberFormat 已经知道每种货币的小数位数、符号位置和分隔符:
function formatMinor(amountMinor, currency, locale = "en-US") {
const exp = exponentFor(currency);
return new Intl.NumberFormat(locale, {
style: "currency",
currency,
}).format(amountMinor / 10 ** exp);
}
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 €"两点实务提示。第一,Intl.NumberFormat 实例构造开销大——按 locale-货币对缓存一个,而不是每行都新建。第二,最后一行里对 10 ** exp 的除法是浮点数唯一应该接触货币值的地方,而且仅仅因为结果会立刻变成字符串。
用 Finexly API 把它串起来
以全精度获取汇率,换算一次,舍入一次,并保存你使用的汇率:
curl "https://api.finexly.com/v1/latest?base=USD&symbols=JPY,KWD,EUR" \
-H "Authorization: Bearer YOUR_API_KEY"{
"success": true,
"base": "USD",
"timestamp": 1755244800,
"rates": {
"JPY": 147.2150,
"KWD": 0.30590,
"EUR": 0.9241
}
}async function quote(amountMinor, from, to) {
const res = await fetch(
`https://api.finexly.com/v1/latest?base=${from}&symbols=${to}`,
{ headers: { Authorization: `Bearer ${process.env.FINEXLY_API_KEY}` } }
);
const data = await res.json();
const rate = data.rates[to];
return {
amount_minor: convertMinor(amountMinor, from, to, rate),
currency: to,
rate, // persist the exact rate used
rate_timestamp: data.timestamp, // and when it was captured
};
}
await quote(1999, "USD", "JPY");
// { amount_minor: 2943, currency: "JPY", rate: 147.215, rate_timestamp: 1755244800 }把 rate 和 rate_timestamp 存在交易行上,才能让六个月后的争议有据可查。完整的端点与参数细节见 Finexly API 文档;如果你在请求之间缓存汇率,我们关于缓存与错误处理的笔记讨论了数据新鲜度的权衡。
测试清单
金额 bug 藏在没人写测试的角落里。至少要覆盖:
- 零小数目标货币 — 换算到 JPY 或 KRW,断言结果没有小数部分。
- 三小数目标货币 — 换算到 KWD 或 BHD,断言三位小数得以保留。
- 恰好一半的值 — 断言你选定的舍入模式,双向都测,含负数。
- 往返漂移 — 换算 USD → EUR → USD,断言结果落在一个最小单位以内,而非完全相等。
- 分配不变量 — 断言拆分后的各部分始终恰好加出总额,从 1 份到 100 份。
- 未知货币代码 — 断言代码抛出异常,而不是悄悄默认两位小数。
- 超大金额 — 断言在 JavaScript 中不会超过
Number.MAX_SAFE_INTEGER而丢失精度;如果你要大规模处理 IDR 或 VND,请使用BigInt。
常见问题
每种货币有多少位小数?
大多数是两位。约有二十多种一位小数都没有——包括 JPY、KRW、VND、CLP 和 ISK——还有七种是三位:KWD、BHD、OMR、JOD、TND、IQD 和 LYD。ISO 4217 是权威来源,但也要查你的支付服务商的表,因为有些会出于运营原因偏离标准。
我该舍入汇率还是舍入换算后的金额?
只舍入换算后的金额。以服务商返回的全精度保留汇率,先相乘,再把结果一次性舍入到目标货币的最小单位。在相乘前舍入汇率会引入与交易规模成正比的误差。
四舍五入与银行家舍入有什么区别?
四舍五入(half-up)总是把恰好一半的值推离零(2.5 → 3)。银行家舍入——一半取偶——把它送到最近的偶数位(2.5 → 2,3.5 → 4),从而在汇总大量金额时消除系统性向上偏差。给客户看的价格用 half-up,会计与报表用 half-even。
为什么我换算后的明细行加不出换算后的总额?
因为每一行都是独立舍入的,误差会累积。先把总额舍入一次,再用最大余数法把该总额分配到各行。这样各部分按构造就能加出总额。
我可以直接用带两位小数的浮点数存金额吗?
不行。二进制浮点无法精确表示 0.1 这样的值,误差会在加法和乘法中累积,最终翻转某个舍入决策。请以最小单位存整数,或使用精确的十进制类型。这不是理论上的顾虑——它是"差一分钱"对账失败最常见的根因。
获取全精度汇率
正确的舍入始于一个你可以信赖的汇率,以及你没有丢掉的精度。获取免费的 Finexly API 密钥——无需信用卡。从每月 1,000 次免费请求、覆盖 170+ 种货币开始,用货币转换器核对你的计算,并在业务量增长时查看价格方案。
Explore More
Vlado Grigirov
Senior Currency Markets Analyst & Financial Strategist
Vlado Grigirov is a senior currency markets analyst and financial strategist with over 14 years of experience in foreign exchange markets, cross-border finance, and currency risk management. He has wo...
View full profile →