跨境收款金额差 100 倍:Stripe 零小数币种、PayPal 两位小数与「分」的换算坑
同一笔订单,Stripe 收到的是 100 倍金额,PayPal 直接返回 400。问题不在业务逻辑,而在「金额」这个字段:Stripe 要的是币种最小计价单位的整数,PayPal 要两位小数字符串,微信支付 v3 是整数「分」,支付宝是两位小数字符串的「元」。本文按渠道逐一对照金额格式与零小数币种,并给出 ISK/UGX/HUF/TWD 特例、排查日志字段和基于 Decimal 的换算实现。
现象
踩坑后的表现分两类:
- 反向放大:代码无脑
×100,遇到 JPY、KRW 这类零小数币种,金额被放大 100 倍。应收 500 JPY,实收 50000 JPY。 - 直接拒绝 / 对不上:PayPal 因格式问题返回 400,或购物项金额与汇总金额对不上。
INVALID_CURRENCY_AMOUNT_FORMAT # 400,Amount 构造失败
ITEM_TOTAL_MISMATCH # unit_amount × quantity 与 amount 汇总对不上
根因:最小计价单位不统一
每个渠道对 amount 的定义不是同一个东西:
- 有的用币种最小计价单位(minor unit),必须是整数,不带小数点;
- 有的用主单位小数字符串,比如
"10.00"。
而「最小计价单位」本身又随币种变化。Stripe 的规则是:除非文档另有标注,币种默认两位小数,×100;被标成 zero-decimal 的币种不乘。收 10 USD 传 1000,收 500 JPY 传 500,收 10 JPY 传 10。
一旦把这两套规则混在一个 amount * 100 里,零小数币种必然差 100 倍。
逐渠道对照
| 渠道 | 字段 | 类型 | 单位 |
|---|---|---|---|
| Stripe | amount | 整数 | 币种最小计价单位 |
| PayPal | amount.value | 字符串 | 元,两位小数 |
| 微信支付 v3 | amount.total | 整数 | 分 |
| 支付宝 | total_amount | 字符串 | 元,两位小数 |
Stripe:minor unit 整数,外加四个特例
大部分币种是 ×100,zero-decimal 币种不乘。完整的币种清单以官方文档标注为准:Stripe 货币与最小单位、零小数货币锚点。
官方文档 "Special cases" 里还有四个容易漏的:
- ISK 冰岛克朗:已转为零小数,但为向后兼容,必须按两位小数表示,小数位恒为
00。收 5 ISK 传500,且不能收 ISK 小数。 - UGX 乌干达先令:同上,收 5 UGX 传
500。 - HUF 匈牙利福林:收款可以是两位小数,但提现(payout) 被当作零小数处理,手动 payout 必须是能被 100 整除的整数。余额 HUF 10.45 只能提
1000(即 HUF 10),传1045不行。 - TWD 新台币:同 HUF,payout 必须能被 100 整除。
金额上下限也要注意:每笔有最低额,按结算币种,例如 0.50 USD、50 JPY、50 KRW、10 THB、175.00 HUF、4.00 HKD,完整表见官方文档;iDEAL 这类支付方式可以低到 amount=1。最高额按位数限制:多数卡 12 位(最多 999,999,999,999 minor unit),JPY 是 10 位(最大 9,999,999,999)。
PayPal:字符串,且必须是两位小数
PayPal 的金额是字符串,必须写成两位小数形式,例如 "100.00"。传 "12" 或 "12.0" 会被拒;即使不被拒,参与求和时也会对不上。
它还有一层校验:subtotal + tax + shipping 必须等于 total,社区里大量 400 都来自这里。购物项的 unit_amount × quantity 也必须与 amount 汇总一致,否则报 ITEM_TOTAL_MISMATCH。
金额小数位的要求随币种类型而定,以当期 REST v2 文档为准:PayPal Orders v2(amount 字段)。
微信支付 v3
amount.total 是整数,单位「分」。人民币是两位小数币种,1 元 = 100 分,所以这里可以放心 ×100。
支付宝
total_amount 是字符串,单位「元」,两位小数。它和微信走的是同一个币种规则,但字段类型完全不同。
定位排查
出问题时先看日志。每条发往渠道的请求,至少同时打印四样:
- 业务金额(
Decimal对象); - 币种代码;
- 发往渠道的原始
amount字符串或整数; - 渠道返回的原始响应。
这四样凑齐,「是换算错还是格式错」一眼能分辨:第 3 项多两个 0 就是单位问题,第 3 项是 12 而不是 12.00 就是格式问题。
可复用换算实现
换算不要用 float,0.1 + 0.2 的坑在金额上会直接变成对账差异。用 Decimal 或整数分,并且维护一张显式的币种表,而不是把判断散在一堆 if 里。
from decimal import Decimal, ROUND_HALF_UP
# 文档中标记为 zero-decimal 的币种:不乘 100
# 完整清单以 https://docs.stripe.com/currencies#zero-decimal 为准
ZERO_DECIMAL = {"jpy", "krw"}
# 已转零小数、但为向后兼容仍须按两位小数表示的币种
TWO_DECIMAL_COMPAT = {"isk", "ugx"}
# payout 必须能被 100 整除的币种
PAYOUT_MULTIPLE_100 = {"huf", "twd"}
def to_stripe_minor(amount: Decimal, currency: str) -> int:
"""Stripe:币种最小计价单位的整数,币种代码用小写"""
cur = currency.lower()
if cur in ZERO_DECIMAL:
if amount != amount.to_integral_value():
raise ValueError(f"{currency} 不支持小数金额")
return int(amount)
# ISK / UGX 虽为零小数,仍按两位小数传入
return int((amount * 100).to_integral_value(rounding=ROUND_HALF_UP))
def to_paypal_value(amount: Decimal, currency: str) -> str:
"""PayPal:两位小数字符串,币种代码用大写 ISO"""
return str(amount.quantize(Decimal("0.01")))
def to_wechat_total(amount: Decimal) -> int:
"""微信支付 v3:整数,单位分"""
return int((amount * 100).to_integral_value(rounding=ROUND_HALF_UP))
def to_alipay_total(amount: Decimal) -> str:
"""支付宝:字符串,单位元,两位小数"""
return str(amount.quantize(Decimal("0.01")))
币种代码的大小写也要按渠道来:Stripe 要求小写,PayPal 用大写 ISO 代码("USD")。把「是否零小数」「是否有特例(ISK/UGX/HUF/TWD)」做成上面这种显式集合,比散在业务代码里的 if currency == "JPY" 好排查得多。
不适用与边界
- 币种的小数位规则会调整,ISK、UGX 就是从两位小数转成零小数的。清单要跟着当期文档走,不能一次性写死了事。
- ISK、UGX 属于「名义零小数、API 仍按两位」的兼容处理,不能直接扔进
ZERO_DECIMAL。 - HUF、TWD 的限制只作用在 payout 上,收款路径不受「必须能被 100 整除」的约束;这两条链路要用不同分支。
- PayPal 的金额小数位要求随币种类型而定,本文没有逐币种实测,接入时以当期 REST v2 文档为准。
- 最低、最高金额限制按币种和支付方式各不相同,上线前要按实际结算币种核对。
自检清单
- 每个渠道的
amount字段类型确认过:整数还是字符串。 - 零小数币种集合显式列出,换算时先查表再决定是否
×100。 - ISK/UGX 走「两位小数兼容」分支,HUF/TWD 的 payout 单独校验能否被 100 整除。
- 全程
Decimal或整数分,结算路径上没有float。 - 发往渠道的币种代码大小写符合该渠道要求。
- PayPal 侧
subtotal + tax + shipping == total,购物项乘积与汇总一致。 - 日志同时记录业务金额、币种、原始
amount、渠道原始返回。