PAYATHON 2026

如何在一个订单中组合 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 之间同时承担两套业务状态。

备注:内容仅供参考。