erlang_pay 系列第三篇,也是写给所有后端的一篇------这两条规矩跟语言无关,跟钱有关。 第一篇:erlang_pay:为 Erlang 补上支付这块拼图 · 第二篇:erlang_pay 先验章,再拆包:支付回调的验签到底在防什么
先做一道题:
ini
0.1 + 0.2 = ?
在人教版数学课本里等于 0.3。在绝大多数编程语言的浮点数里,等于 0.30000000000000004。
这不是哪个语言的 bug,是二进制表示十进制小数的先天局限。平时无所谓,但这段算式如果出现在收钱的代码里,0.00000000000000004 的误差乘上每天的订单量,对账就能对出悬案:数据库里 12.34,支付机构账上 12.34000000000003,谁也说不清哪笔错了。
erlang_pay 的应对是把一件事定成铁律,再把另一件容易想当然的事写成显式的表。这篇讲这两件事。
规矩一:金额一律用"最小单位"的整数
erlang_pay 的所有接口,金额只收整数,单位是最小货币单位------人民币和美元是"分",1234 就是 12.34 元。库内部没有一个浮点数。
打比方:裁缝量布用毫米,不用"1.234 米"。整数没有小数点,就没有二进制小数的误差;加减乘除全部是整数运算,一分钱都不会凭空多出来或消失。
epay_money 模块负责单位换算,主单位(人看的"12.34 元")以字符串进出,内部全程整数:
erlang
epay_money:to_minor(<<"12.34">>, <<"USD">>).
%% => {ok, 1234}
epay_money:to_major(1234, <<"USD">>).
%% => {ok, <<"12.34">>}
注意输入是字符串 <<"12.34">>,不是浮点数 12.34------从你的业务代码入口处就把浮点挡在门外。
规矩一的隐藏陷阱:别把"分"想当然
到这里很多人会说:懂了,金额×100 存分。
这正是要纠正的第二个误区:"最小单位 = 分 = ×100"不成立。
ISO 4217(货币代码国际标准)给每种货币规定了一个"小数位数"(exponent),它不是恒等于 2:
- 美元、欧元、人民币、港币......小数 2 位:1 元 = 100 分,×100 没错
- 日元、韩元:小数 0 位。日元没有"分",1 日元就是最小单位。×100 会把人家的钱放大一百倍
- 巴林第纳尔、科威特第纳尔:小数 3 位。1 第纳尔 = 1000 费尔,×100 反而缩小了十倍
erlang_pay 没有把"×100"写死在代码里,而是维护了一张显式的表(src/epay_money.erl):
erlang
-define(EXPONENTS, #{
<<"USD">> => 2, <<"EUR">> => 2, <<"GBP">> => 2, <<"CNY">> => 2,
<<"AUD">> => 2, <<"CAD">> => 2, <<"HKD">> => 2, <<"SGD">> => 2,
<<"JPY">> => 0, <<"KRW">> => 0,
<<"BHD">> => 3, <<"KWD">> => 3, <<"JOD">> => 3, <<"OMR">> => 3
}).
表里没有的币种,换算接口直接报 unsupported_currency------宁可拒绝,也不替你猜 。金额传了负数,报 negative_amount。这些错误在本地就拦下,请求根本不会发到支付机构。
Stripe 接口之所以多一个 currency 参数,就是因为换算规则跟着币种走,库需要知道你在收哪种钱。
规矩二:退款必须带流水号
第二件事跟退钱有关。
场景:你调用退款接口,网络抖了一下,请求超时。此刻你不知道钱退没退------可能没发出去,可能发出去了、机构也处理完了、只是响应没传回来。于是你重试一次。
如果支付机构把重试当成一笔新的 退款请求,同一笔订单就被退了两次钱。这在支付行业有个专门的名字要防:非幂等。幂等(idempotent)是个数学词,翻译成人话:同一个操作执行一次和执行一百次,结果一样。
Stripe 给的解法是"幂等键"(Idempotency-Key):请求带上一个你自己定的唯一编号,重试时带同一个编号,Stripe 就认得"这笔我处理过了",返回上次的结果,不会重复执行。
erlang_pay 把这个机制定成了硬规矩,写在 README 里:
- 退款必须 带稳定退款号
out_refund_no;缺号直接返回bad_request,请求根本不发送 - 幂等键由退款号生成:
Idempotency-Key = "rf_" + 退款号------同一个号重发一百次,Stripe 只执行一次 - 明确禁止"用支付单号派生幂等键"。原因:同一笔支付可以多次部分退款(先退 1 元、再退 2 元),如果用支付单号当幂等键,第二次合法退款会被误判成"重复"而拒绝
打个比方:退款号是取件码。凭同一张取件码去取件,柜子不会吐两份;但一个快递柜里有多个包裹(多次部分退款)时,每个包裹各有各的取件码。
配套军规:超时了,先查后重
顺着上面的场景说下去:超时之后到底该干什么?erlang_pay 的错误码表里有一行原话:
http_error传输错误;超时 = 结果未知,先查后重。
超时不等于失败。正确动作是先调 query 查单,确认这笔退款的真实状态,再决定要不要重试。跳过"先查"直接重发,就是把钱交给运气------幂等键是保险绳,不是免死金牌,两条一起用才保险。
这些规矩不是嘴上说说
erlang_pay 现在有 213 个测试用例(0.3.0 里从 113 个增加而来),全部通过;dialyzer 类型检查零警告。
更值得说的是测试的做派:每个用例的签名/验签,用的都是测试运行时即时生成的 RSA 密钥对,真的签名、真的验签、真的加密解密,mock 只打在 HTTP 边界上(不会真的把请求发给支付机构)。也就是说,"×100 陷阱"防住了没有、缺退款号会不会拦下、超时错误码对不对,都是有代码兜底的合同,不是文档里的愿望。
当然也要再说一次丑话:本地测试全绿 ≠ 生产验证过,跟三家真实沙箱的联调还在计划里(这是 0.3.0 的诚实声明,也是你试用前该知道的事)。
仓库信息
- GitHub: github.com/imboy-pub/e...
- Gitee: gitee.com/imboy-pub/e...
- Gitcode: gitcode.com/imboy/erlan...
- 协议:Apache-2.0
- 版本:0.3.0(2026-09-23),21 个提交,从 2026-06-14 到 2026-09-23
- 自检命令:
rebar3 eunit(213 用例)、rebar3 dialyzer(零警告)、bash scripts/gate.sh(清洁编译+单测+类型+打包全门)
本文事实可复核:src/epay_money.erl(exponent 表与 to_minor/to_major)、README「退款(Stripe 幂等)」「错误码」、CHANGELOG 0.3.0(测试 113→213)。
小结成三句话,够你带去任何语言的支付代码评审:
- 金额只用最小单位整数,浮点别进门;
- 别假定"分"等于 ×100,币种的小数位数要查表;
- 退款必带稳定流水号,超时先查单再重试。
IMBoy(一个开源可私有化的 IM 平台,Erlang/OTP 后端)要收钱,就有了这个库。erlang_pay 从 IMBoy 项目里长出来,然后独立成仓回馈社区------它不绑定 IMBoy,任何需要接支付渠道的 Erlang/OTP 项目都可以直接拿去用。