全部文章

用户少付、分几次付、付多了,订单怎么处理

稳定币收款里最常见的几种“金额不对”场景,以及 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 } }

注意三点:

  1. paidAmount 是累计值,不是单笔金额;每一笔的明细在 transactions[] 里。
  2. 每次变化 version 都加 1。回调可能乱序送达,以 version 大的为准。
  3. 最后一笔到账时收到的是 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 作废

完整的状态和事件说明见 接入指南 · 订单状态。