用户少付、分几次付、付多了,订单怎么处理
稳定币收款里最常见的几种“金额不对”场景,以及 CNIXPay 的累计到账、宽限期、underpaid 状态和回调事件分别是怎么兜住它们的。
在链上收款,你不能像刷卡那样“扣一笔精确的钱”。用户手动填金额、交易所提币扣手续费、看错小数位、分两次转——最后到账的数字经常和应付不一样。这篇文章讲清楚每种情况下订单会发生什么,以及你的系统该怎么应对。
先记住一个原则
CNIXPay 判断订单只看一件事:这笔订单累计到账的金额,是否达到了应付金额。
- 达到 → 订单变为
paid,结算入你的余额,推送order.paid。 - 没达到 → 订单保持
pending,继续累计,每收到一笔就推送一次order.partially_paid。 - 到了过期时间还没付足 → 再等一段宽限期(默认 60 分钟);宽限期结束仍没付足,按实收入账,订单变为
underpaid,推送order.underpaid。
订单一共只有 4 个状态,对你来说规则也只有一条:pending 就等,其他状态就处理。
status |
你该做什么 |
|---|---|
pending |
等待;paidAmount > 0 时可以提示用户补足 |
paid |
发货 |
underpaid |
人工处理:补发部分商品、联系用户补款或退款 |
closed |
作废订单 |
场景一:少付了
应付 100 USD,用户转了 99.5 USDT。
订单仍是 pending,你会收到一条 order.partially_paid:
{
"event": "order.partially_paid",
"data": {
"orderNo": "20261017105142a1b2c3d4e5f6",
"status": "pending",
"amount": "100.00",
"paidAmount": "99.50",
"version": 2
}
}
订单没有失败,也没有关闭——它在等剩下的 0.5。
- 用户在过期前或宽限期内补一笔 0.5,累计到 100,订单变为
paid,你收到order.paid。 - 宽限期结束还是 99.5,订单变为
underpaid,99.5 按实收入账到你的余额(扣除手续费),你收到order.underpaid。
你该做什么: pending 时可以在页面上提示“还差 0.5 USDT”(amount − paidAmount),但不要发货。收到 underpaid 再决定是补发部分、找用户补款,还是退款。
场景二:分几次付
应付 10,用户先付 3,再付 4,最后付 3。你会依次收到:
{ "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "3.00", "txCount": 1, "version": 2 } }
{ "event": "order.partially_paid", "data": { "status": "pending", "paidAmount": "7.00", "txCount": 2, "version": 3 } }
{ "event": "order.paid", "data": { "status": "paid", "paidAmount": "10.00", "txCount": 3, "version": 4 } }
注意三点:
paidAmount是累计值,不是单笔金额;每一笔的明细在transactions[]里。- 每次变化
version都加 1。回调可能乱序送达,以version大的为准。 - 最后一笔到账时收到的是
order.paid,而不是第三条order.partially_paid。
你该做什么: 只在 status == "paid" 时发货。order.partially_paid 适合用来更新进度条或提醒用户补款。
场景三:付多了
应付 100,用户转了 120。
累计 120 ≥ 100,订单直接变为 paid,paidAmount 是 "120.00"。多出的 20 同样结算进你的商户余额——系统入账的是实际到账金额。
如果订单 paid 之后用户又重复付了一笔,这笔钱也会照常入账:状态保持 paid,paidAmount 和 version 更新,但不会再推送回调。
你该做什么: 按你的业务规则决定是否退还差额(paidAmount − amount)。CNIXPay 的商户 API 不包含退款接口,退款需要你从自己的钱包转给用户。
场景四:订单过期后才到账
订单有过期时间(创建时返回的 expireTime,默认 30 分钟)。链上转账有延迟,用户可能在倒计时结束前点了发送、区块确认时订单已经过期。
这就是宽限期的作用:过期后的一段时间内,订单对外仍是 pending,期间到账、补足都正常计入。所以你不用自己判断“过期了没”——只要还是 pending,就继续等。
宽限期结束后:
- 一分钱都没收到 → 订单变为
closed(closeReason = timeout),推送order.closed; - 收到过一部分 → 订单变为
underpaid,推送order.underpaid。
宽限期之后才到的钱不会自动入账,由平台人工处理:退回付款人,或核实后补单。补单后订单会按实收重新判定,可能从 closed 变成 paid,并推送 order.paid。
你该做什么: 需要更早释放库存,可以按 expireTime 自己处理,但不要把订单“彻底删除”——收到 order.paid 时仍要能找到它。
把这些落到代码里
一个健壮的处理器大致是这样(验签与去重的写法见 回调验签与幂等):
async function onOrderChanged(order) {
// 只保留版本最新的订单:UPDATE ... WHERE order_no = ? AND version < ?
if (!(await orders.saveIfNewer(order))) return
switch (order.status) {
case 'pending':
// 可选:paidAmount > 0 时提示用户还差 amount − paidAmount
break
case 'paid':
await fulfillOnce(order.orderNo) // 发货,按订单号幂等
break
case 'underpaid':
await createManualReview(order.orderNo, order.paidAmount)
break
case 'closed':
await voidOrder(order.orderNo)
break
}
}
回调和主动查询返回的是同一个订单对象,这个函数两边都能用。再加上定时按 merchantOrderNo 批量查询未完结订单的兜底,就不会漏单。
小结
| 场景 | 订单状态 | 回调事件 | 你要做的 |
|---|---|---|---|
| 少付,后来补足 | pending → paid |
partially_paid → paid |
等待,paid 时发货 |
| 少付,没补 | pending → underpaid |
partially_paid → underpaid |
人工处理 |
| 分次付 | pending → paid |
多次 partially_paid,最后 paid |
只在 paid 发货 |
| 多付 | paid |
paid,paidAmount 为实际值 |
按业务规则处理差额 |
| 过期后宽限期内到账 | pending → paid |
paid |
别过早判死 |
| 一直没付 | closed(timeout) |
closed |
作废 |
完整的状态和事件说明见 接入指南 · 订单状态。