erlang_pay 日元没有“分“,退款要带流水号:支付代码里两条要命的规矩

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 的诚实声明,也是你试用前该知道的事)。


仓库信息

本文事实可复核:src/epay_money.erl(exponent 表与 to_minor/to_major)、README「退款(Stripe 幂等)」「错误码」、CHANGELOG 0.3.0(测试 113→213)。

小结成三句话,够你带去任何语言的支付代码评审:

  1. 金额只用最小单位整数,浮点别进门;
  2. 别假定"分"等于 ×100,币种的小数位数要查表;
  3. 退款必带稳定流水号,超时先查单再重试。

IMBoy(一个开源可私有化的 IM 平台,Erlang/OTP 后端)要收钱,就有了这个库。erlang_pay 从 IMBoy 项目里长出来,然后独立成仓回馈社区------它不绑定 IMBoy,任何需要接支付渠道的 Erlang/OTP 项目都可以直接拿去用。

相关推荐
leeyi2 小时前
erlang_pay 先验章,再拆包:支付回调的验签到底在防什么
微信·erlang·支付宝
leeyi2 小时前
erlang_pay 为 Erlang 补上支付这块拼图:一个库接支付宝、微信、Stripe
后端·erlang·支付宝
微信开发api6 天前
微信iPad协议怎么用?基于协议的二次开发实践
微信·机器人·ipad
wechatbot8886 天前
极客互动企业微信 SCRM 自动运营平台效果实测
java·微信·企业微信·rpa
懂软件的胡子个哥6 天前
微信机器人为什么需要“回复候选”而不是所有 AI 内容直接发送
运维·微信·自动化·wechatapi·个人微信号二次开发
微信开发api6 天前
微信机器人接口:REST API vs WebSocket 选型建议
微信·机器人·ipad
极客互动API6 天前
极客互动-企业微信基于外部API接口实现AI客服自动接管外部联系人消息收发
java·微信·企业微信·ai编程·rpa
随性而行3606 天前
企业微信二次开发实战:基于企业微信API构建自动化营销触达系统
微信
前端小白乘风7 天前
VS Code 终于能连微信了!发现一个硬核开源神仙插件:WeChat AHP
微信·visual studio code·deepseek