如何在一个订单中组合 recurring payment 和 single payment?
结论
不要让同一个 uCommerce 订单同时处理 recurring payment 和 single payment。更合适的做法是保留一次结账的用户体验,在确认购物车后,将其中的内容拆成两个独立的履约单元,并分别创建支付:
- 虚拟订阅子订单:不包含 shipment,使用 recurring payment。首次支付成功或订阅协议建立成功后,立即开通权益。
- 实物商品子订单:包含 shipment,使用 single payment。先授权或保持待处理,发货后再 capture,履约完成后转为 completed。
- 可选的父订单:用于展示、关联和汇总,不直接参与支付或履约。
如果当前 DIBS Payment Window 集成规定一次支付请求只能选择一种交易模式,修改订单状态并不能解决问题。recurring 和 single 使用不同的支付协议或交易生命周期,因此需要建立两笔支付交易,通常也需要创建两个 uCommerce 子订单。
为什么不能只使用一个订单
问题并非订单状态不同,而是支付、履约和订阅三个生命周期互相冲突。
支付生命周期不同
订阅需要保存用于后续扣款的协议、token 或 subscription identifier,实物商品通常只需要一次性授权和扣款。
DIBS Payment Window 能否在一次支付会话中既返回订阅协议,又处理普通交易,取决于具体产品版本和账户配置。按照问题中的前提,当前集成不支持这种组合,所以必须拆成两次支付操作。
履约时间不同
虚拟权益可以即时交付,实物商品还要经过备货和发货。如果系统只有一个订单级 OrderStatus,便无法同时准确表示以下状态:
- 订阅已经开通;
- 实物尚未发货;
- 实物款项尚未 capture。
提前把整个订单设为 completed,会让实物履约过早结束。一直保持 pending,又会延迟订阅开通。
shipment 不能代表订阅状态
虚拟商品不创建 shipment 是合理的,但没有 shipment 不等于订阅已经成功。是否开通订阅,应根据支付结果和订阅协议状态判断,不能只看订单是否包含 shipment。
推荐的数据结构
可以采用一个父订单关联两个子订单的结构:
Checkout / ParentOrder
├── SubscriptionOrder
│ ├── virtual products
│ ├── recurring payment
│ ├── no shipment
│ └── entitlement activation
└── PhysicalOrder
├── physical products
├── single payment
├── shipment
└── authorization -> capture
父订单保存用户看到的统一订单号、总金额,以及两个子订单之间的关联关系。每个子订单分别保存自己的:
- 订单状态;
- Payment;
- PaymentMethod;
- shipment;
- 金额、税费和折扣;
- 退款及取消记录。
如果现有 uCommerce 版本没有父子订单模型,可以添加 CheckoutGroupId 一类的自定义关联字段,把两个普通订单归入同一次结账。
处理步骤
1. 在结账前拆分商品
不要只根据商品是否需要配送来判断订阅类型。建议在商品或变体上设置明确字段,例如:
FulfillmentType = Virtual | Physical
PaymentType = Recurring | Single
然后按照支付和履约类型拆分订单行:
var subscriptionLines = basket.OrderLines
.Where(line => line.Product["PaymentType"] == "Recurring")
.ToList();
var physicalLines = basket.OrderLines
.Where(line => line.Product["PaymentType"] == "Single")
.ToList();
这里的 Product["PaymentType"] 仅作示意。具体如何读取属性,取决于使用的 uCommerce 版本和商品模型。
2. 分别计算金额
拆单后需要重新分配和校验以下金额:
- 商品金额;
- 运费;
- 税费;
- 订单级折扣;
- 优惠券;
- 礼品卡或账户余额;
- 舍入差额。
运费只计入实物子订单。订单级折扣也不能在两个子订单上各应用一次。可以按照订单行金额的比例分配折扣,再把舍入差额计入其中一个子订单。
decimal AllocateDiscount(
decimal totalDiscount,
decimal groupAmount,
decimal combinedAmount)
{
if (combinedAmount == 0m)
return 0m;
return Math.Round(
totalDiscount * groupAmount / combinedAmount,
2,
MidpointRounding.AwayFromZero);
}
货币小数位和舍入方式应以商店配置及支付提供商的要求为准,不能假定所有货币都有两位小数。
3. 创建订阅子订单
订阅子订单不创建 shipment,而是通过 DIBS 的 recurring 流程建立后续扣款所需的协议。
状态可以按以下顺序流转:
PendingPayment
-> SubscriptionAuthorized
-> EntitlementActive
-> Completed
只有服务器端确认支付回调有效,并取得可用的 recurring token 或 subscription identifier 后,才能开通订阅权益。不要根据浏览器返回页直接开通。用户可能伪造请求或重复刷新,也可能在付款成功后没有返回网站。
示意代码如下:
public async Task HandleSubscriptionPaymentAsync(
SubscriptionPaymentResult result)
{
if (!result.IsVerified)
return;
var order = await orderRepository.GetAsync(result.OrderId);
if (order.PaymentProcessed)
return; // 保证回调幂等
order.RecurringReference = result.RecurringReference;
order.PaymentProcessed = true;
await entitlementService.ActivateAsync(
order.CustomerId,
order.OrderLines);
order.Status = OrderStatus.Completed;
await orderRepository.SaveAsync(order);
}
SubscriptionPaymentResult、RecurringReference 和仓储接口都是架构示例,并非特定 uCommerce 或 DIBS SDK 的固定 API。
首期订阅费的处理规则也要明确:
- 如果建立 recurring 协议时会同时收取首期费用,支付成功后即可开通。
- 如果首次扣款稍后异步执行,应等待扣款成功后再开通,除非业务规则允许试用期。
- 如果订阅协议建立成功,但首次扣款失败,不能视为完整支付成功。
4. 创建实物子订单
实物子订单创建 shipment,并使用 single payment。
如果 DIBS 账户和当前 uCommerce provider 支持先 authorize、后 capture,可以采用以下流程:
PendingPayment
-> Authorized
-> PendingShipment
-> Shipped
-> Captured
-> Completed
发货时执行 capture:
public async Task ShipAndCaptureAsync(Guid orderId)
{
var order = await orderRepository.GetAsync(orderId);
if (order.Status != OrderStatus.PendingShipment)
throw new InvalidOperationException("Order is not ready for shipment.");
var result = await paymentGateway.CaptureAsync(
order.PaymentReference,
order.OutstandingAmount);
if (!result.Succeeded)
{
order.Status = OrderStatus.CaptureFailed;
await orderRepository.SaveAsync(order);
return;
}
await shipmentService.MarkAsShippedAsync(order.ShipmentId);
order.Status = OrderStatus.Completed;
await orderRepository.SaveAsync(order);
}
如果当前 DIBS 集成不支持延迟 capture,需要选择以下方案之一:
- 下单时立即扣款,发货流程只负责履约;
- 更换或扩展 payment provider,使其支持 authorize/capture;
- 准备发货时重新发起付款。这种方式的用户体验较差,也可能遇到库存已经预留、付款却失败的情况。
不能把订单保持为 pending 来模拟延迟扣款。订单状态不会自动改变支付网关中的资金状态。
用户只结账一次时如何处理两笔支付
一次结账不等于只有一笔支付。前端可以继续使用一个购物车和一个确认页,后台则创建两笔交易。
通常有两种实现方式。
连续完成两个支付会话
可以先支付订阅订单,再支付实物订单,也可以调换顺序。每笔支付成功后保存当前进度,全部完成后跳转到统一的结果页。
Create checkout group
-> Pay subscription order
-> Pay physical order
-> Show combined confirmation
这种方式更容易适配现有的 Payment Window,但用户可能需要完成两次支付交互。系统还必须处理第一笔成功、第二笔失败的部分成功状态。
使用已保存的支付凭证
如果 DIBS 产品、商户协议和合规要求允许,可以先建立可复用的 token,再分别创建 recurring 和 single 交易。这样前端可能只需要进行一次强认证,但后台仍会产生两笔独立交易。
仅凭 DIBS 这一通用名称,无法判断当前集成是否支持该能力。需要核对具体的 DIBS 产品、API 版本、uCommerce provider 实现、收单协议,以及 SCA/3-D Secure 要求。
部分成功不能回滚成“一切都没发生”
两笔支付可能产生以下结果:
| 订阅支付 | 实物支付 | 建议处理 |
|---|---|---|
| 成功 | 成功 | 开通订阅,实物进入待发货 |
| 成功 | 失败 | 保留或撤销订阅,并提示实物支付失败 |
| 失败 | 成功 | 不开通订阅;保留、void 或退款实物交易 |
| 失败 | 失败 | 整个结账失败 |
数据库事务无法回滚外部支付。支付网关中已经成功的交易,不会因为本地事务回滚而自动撤销。系统需要执行相应的补偿操作,例如:
- void 尚未 capture 的授权;
- refund 已 capture 的付款;
- 取消 recurring agreement;
- 撤销已经开通的订阅权益。
具体采用哪种补偿策略,应由业务规则决定。通常还应在付款前向用户说明,结账内容包括订阅协议和一次性购买两部分。
状态设计上的注意事项
不要让一个通用的 Completed 同时表示已付款、已发货和已开通。至少应分别记录以下状态:
PaymentStatus
FulfillmentStatus
SubscriptionStatus
OrderStatus
例如:
public sealed class CommerceState
{
public PaymentStatus Payment { get; init; }
public FulfillmentStatus Fulfillment { get; init; }
public SubscriptionStatus Subscription { get; init; }
}
即使受到现有 uCommerce 状态模型的限制,也可以通过子订单或自定义字段保存这些独立状态。这样在处理退款、续费失败、部分发货或订阅取消时,各个流程不会互相影响。
还需要特别检查的事项
- 所有支付回调都要验证签名,并支持幂等处理。
- recurring reference、transaction reference 和子订单号需要分别保存,不能共用同一个支付引用。
- 不要保存原始银行卡号或安全码。
- 实物授权可能在发货前过期,需要确认 DIBS 的授权有效期。
- 多次发货可能需要 partial capture。如果 provider 不支持,应限制拆分发货或调整扣款时机。
- 退款也要分开处理。订阅退款不应自动触发已发货实物的退款,反之亦然。
- 续费失败属于订阅生命周期,不应改变已经完成的实物订单。
- 混合优惠、税率和发票编号需要符合所在地的财税规则。能否生成一张发票,取决于实际的会计和税务要求。
购物体验可以统一,支付、状态和履约则需要分开处理。只要 DIBS 无法在一个 Payment Window 交易中表示两种付款模式,就应在 uCommerce 中把混合购物车建模为两个关联订单,不能让同一个订单在 pending 和 completed 之间同时承担两套业务状态。
备注:内容仅供参考。
