返回博客

开发者货币舍入规则:小数位、最小单位与安全的换算运算

V
Vlado Grigirov
August 21, 2026
Currency API Exchange Rates Currency Rounding Minor Units ISO 4217 Finexly Developer Guide

东京的一位客户购买了 19.99 美元的订阅。你的代码乘以 USD/JPY 汇率,得到 2942.82785,写入数据库,然后发送给支付服务商。服务商拒绝了它——或者更糟,接受了它并多收了 100 倍的钱。日元没有小数位,而你的代码从未过问。

货币舍入属于那类看似微不足道、直到进入生产环境才暴露的问题。它不是格式问题,而是正确性问题。每种货币都有自己的小数位数,浮点运算会悄悄地破坏金额,而当你在货币之间转换的那一刻,就引入了一个必须刻意做出的舍入决策。本指南涵盖真正重要的规则:每种货币有多少位小数、为什么应该以整数存储金额、该选择哪种舍入模式,以及如何对外汇换算进行舍入,让你的账簿在月末仍然能对上。

最小单位:每种货币有多少位小数?

一种货币的最小单位是它可交易的最小细分。对美元来说是分,所以 USD 有两位小数,19.99 美元就是 1999 分。这正是支付服务商期望的表示方式,也是你的数据库应该使用的方式。

ISO 4217——就是那个给你 USDJPY 等三字母代码的标准——同时也为每种货币指定了一个最小单位指数。大多数开发者假定这个指数永远是 2。它不是,而这个假设正是这一类别中代价最高的 bug。

零小数位货币

这些货币没有流通中的辅币单位,所以你发送的金额就是整数单位数:

  • JPY — 日元
  • KRW — 韩元
  • VND — 越南盾
  • CLP — 智利比索
  • ISK — 冰岛克朗
  • XAF / XOF / XPF — 中非法郎与太平洋法郎
  • UGX — 乌干达先令
  • PYG — 巴拉圭瓜拉尼
  • RWFGNFKMFDJFVUV — 以及其他若干小面额货币

如果你把 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(),你会得到一个偏差一个最小单位的金额,而这足以让对账失败。

两条规则覆盖几乎所有情况:

  1. 以最小单位的整数存储金额。 一个 amount_minor BIGINT 列加一个 currency CHAR(3) 列。{ amount_minor: 1999, currency: "USD" } 毫不含糊,并且与 Stripe、Adyen 及大多数服务商的期望一致。
  2. 用整数或十进制类型做运算。 Python 的 decimal.Decimal、Java 的 BigDecimal、PostgreSQL 的 NUMERIC,或封装整数运算的 JavaScript 货币库。把浮点数留给汇率本身,而且也只用到相乘那一步为止。

如果你正在从零设计这一层,我们的多币种账簿设计指南更深入地讨论了表结构决策。

换算流程:最小单位进,最小单位出

货币换算恰好有四个步骤,而舍入属于第三步——只做一次,在最后。

  1. 把源金额从最小单位转换为十进制数值。
  2. 乘以全精度汇率。
  3. 舍入到目标货币的最小单位指数。
  4. 转换回整数最小单位。

下面是 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)34−3消费者定价、结账总额
银行家舍入(half even)24−2会计、利息、税务、报表
half down23−2少见;偶见于遗留金融代码
向上取整(ceiling)34−2绝不能少收的手续费
向下取整(floor / 截断)23−3绝不能多付的结算款
Half up 就是多数人所说的"四舍五入",也是 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 }

raterate_timestamp 存在交易行上,才能让六个月后的争议有据可查。完整的端点与参数细节见 Finexly API 文档;如果你在请求之间缓存汇率,我们关于缓存与错误处理的笔记讨论了数据新鲜度的权衡。

测试清单

金额 bug 藏在没人写测试的角落里。至少要覆盖:

  1. 零小数目标货币 — 换算到 JPY 或 KRW,断言结果没有小数部分。
  2. 三小数目标货币 — 换算到 KWD 或 BHD,断言三位小数得以保留。
  3. 恰好一半的值 — 断言你选定的舍入模式,双向都测,含负数。
  4. 往返漂移 — 换算 USD → EUR → USD,断言结果落在一个最小单位以内,而非完全相等。
  5. 分配不变量 — 断言拆分后的各部分始终恰好加出总额,从 1 份到 100 份。
  6. 未知货币代码 — 断言代码抛出异常,而不是悄悄默认两位小数。
  7. 超大金额 — 断言在 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+ 种货币开始,用货币转换器核对你的计算,并在业务量增长时查看价格方案

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 →