PAYATHON 2026

如何使用Payment gateway实现类似escrow的资金转账

支付阿杰

结论

这不是普通的“支付网关收款”,而是平台型支付(marketplace/platform payments)场景。所选产品需要同时支持:

  • Customer 在线付款;
  • 平台暂时控制资金的流转时间;
  • 向 Service Provider 分账或付款;
  • 服务失败时向 Customer 原路退款;
  • 对 Service Provider 进行身份验证、收款账户绑定和合规审核。

常见的候选产品有:

  • Stripe Connect;
  • Adyen for Platforms;
  • PayPal Commerce Platform;
  • Mangopay;
  • 部分地区可用的 Airwallex、Checkout.com 等平台支付产品。

产品是否可用,要看平台主体、Customer 和 Service Provider 分别位于哪里,还要考虑币种、业务类型及当地监管要求。这些条件必须向支付服务商确认,不能只看产品是否提供 Mobile SDK。

另一个需要确认的问题是,支付服务商提供的“延迟分账”通常不等于法律意义上的 escrow(第三方资金托管)。如果业务对外宣称“托管资金”,或需要长期持有客户资金,就可能涉及支付牌照、资金托管或反洗钱监管。

推荐的资金流程

较稳妥的资金流程如下:

Customer
   │
   │ 支付
   ▼
平台支付账户
   │
   ├── 服务成功 ──► 转账/分账给 Service Provider
   │
   └── 服务失败 ──► 原路退款给 Customer

支付状态和业务订单状态需要分开管理。例如:

订单状态:
PENDING_PAYMENT
PAID
SERVICE_IN_PROGRESS
COMPLETED
FAILED
REFUNDED

资金状态:
UNPAID
AUTHORIZED
CAPTURED
TRANSFER_PENDING
TRANSFERRED
REFUND_PENDING
REFUNDED

不能看到客户端返回“支付成功”页面,就立即发起服务或转账。服务端应主动查询支付平台,或通过经过签名验证的 Webhook,确认支付、退款和转账结果并更新状态。

两种实现方式

方案一:先授权,服务完成后再扣款

如果服务能在银行卡授权有效期内完成,可以先授权支付而不立即扣款。服务成功后执行 capture,服务失败则取消授权。

创建支付并授权
        │
        ├── 服务成功:capture → 向 Service Provider 分账
        └── 服务失败:cancel authorization

这种方式通常可以避免在服务失败时先扣款再退款。不过,银行卡授权有有效期,具体规则会因卡组织、支付方式、地区和行业而异。如果服务周期较长,不能假定授权会一直有效。

下面以 Stripe PaymentIntent 的服务端调用为例。实际参数应以当前账户地区及所用 API 版本的官方文档为准:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  capture_method: "manual",
  metadata: {
    order_id: "order_123"
  }
});

服务成功后扣款:

const captured = await stripe.paymentIntents.capture(
  paymentIntent.id
);

服务失败且尚未扣款时取消:

const canceled = await stripe.paymentIntents.cancel(
  paymentIntent.id
);

这种模式适合预约、即时服务,以及其他能在短时间内确认结果的订单。

方案二:立即扣款,确认服务后再分账

如果服务周期可能超过授权有效期,可以先完成扣款,等服务结果确认后再向 Service Provider 转账。

Customer 完成付款
        │
        ▼
平台记录已收款
        │
        ├── 服务成功:向 Service Provider 转账
        └── 服务失败:退款给 Customer

以 Stripe Connect 的 separate charges and transfers 模式为例,先由平台收款:

const paymentIntent = await stripe.paymentIntents.create({
  amount: 10000,
  currency: "usd",
  automatic_payment_methods: {
    enabled: true
  },
  transfer_group: "ORDER_order_123",
  metadata: {
    order_id: "order_123"
  }
});

确认服务成功后,再向已经完成 Connect onboarding 的 Service Provider 账户转账:

const transfer = await stripe.transfers.create({
  amount: 9000,
  currency: "usd",
  destination: "acct_provider123",
  transfer_group: "ORDER_order_123",
  metadata: {
    order_id: "order_123"
  }
});

这个例子中,平台收取 1000 个最小货币单位作为佣金,剩余 9000 转给 Service Provider。金额应始终使用整数形式的最小货币单位,不能使用浮点数计算:

const grossAmount = 10000;
const platformFee = 1000;
const providerAmount = grossAmount - platformFee;

如果服务失败,可以根据原始 PaymentIntent 对应的 charge 发起退款:

const refund = await stripe.refunds.create({
  payment_intent: paymentIntent.id,
  metadata: {
    order_id: "order_123",
    reason_code: "SERVICE_FAILED"
  }
});

如果平台已经向 Service Provider 转账,退款通常不会自动撤销这笔独立转账。此时需要撤销转账,或由平台余额承担退款:

const reversal = await stripe.transfers.createReversal(
  transfer.id,
  {
    amount: 9000,
    metadata: {
      order_id: "order_123"
    }
  }
);

转账能否撤销、可撤销多少,以及余额不足时该如何处理,都取决于支付平台、账户类型和资金状态。因此,在服务结果确认前,最好不要让 Service Provider 提走对应资金。

服务端应如何组织流程

客户端 SDK 主要用于收集付款信息、完成身份验证和展示支付页面。创建支付、确认订单、分账、退款及 Webhook 验签都必须在服务端完成。

下面是一个简化的 TypeScript 流程:

type OrderStatus =
  | "PENDING_PAYMENT"
  | "PAID"
  | "COMPLETED"
  | "FAILED"
  | "REFUND_PENDING"
  | "REFUNDED";

interface Order {
  id: string;
  customerId: string;
  providerId: string;
  amount: number;
  currency: string;
  status: OrderStatus;
  paymentId?: string;
  transferId?: string;
  refundId?: string;
}

async function completeService(order: Order): Promise<void> {
  if (order.status !== "PAID") {
    throw new Error("Order is not eligible for transfer");
  }

  // 使用数据库事务或原子条件更新锁定订单,
  // 避免重复请求导致多次转账。
  const transfer = await paymentGateway.transfer({
    amount: calculateProviderAmount(order.amount),
    currency: order.currency,
    recipientId: order.providerId,
    reference: order.id,
    idempotencyKey: `transfer:${order.id}`
  });

  await updateOrder(order.id, {
    status: "COMPLETED",
    transferId: transfer.id
  });
}

async function failService(order: Order): Promise<void> {
  if (order.status !== "PAID") {
    throw new Error("Order is not eligible for refund");
  }

  await updateOrder(order.id, {
    status: "REFUND_PENDING"
  });

  const refund = await paymentGateway.refund({
    paymentId: order.paymentId!,
    amount: order.amount,
    reference: order.id,
    idempotencyKey: `refund:${order.id}`
  });

  await updateOrder(order.id, {
    status: "REFUNDED",
    refundId: refund.id
  });
}

paymentGateway 是业务自己的适配层。订单系统不必直接依赖某一家支付服务商:

interface PaymentGateway {
  transfer(input: {
    amount: number;
    currency: string;
    recipientId: string;
    reference: string;
    idempotencyKey: string;
  }): Promise<{ id: string }>;

  refund(input: {
    paymentId: string;
    amount: number;
    reference: string;
    idempotencyKey: string;
  }): Promise<{ id: string }>;
}

以后更换支付服务商,或针对不同国家接入不同渠道时,就不必整体重写订单状态机。

Webhook 处理

支付、退款和转账通常是异步完成的。服务端至少应处理以下事件:

  • 支付成功;
  • 支付失败;
  • 授权即将失效;
  • 退款成功或失败;
  • 转账成功或失败;
  • Service Provider 账户审核状态变化;
  • 拒付和争议。

Webhook 必须验证签名,并使用事件 ID 去重:

async function handleWebhook(rawBody: Buffer, signature: string) {
  const event = verifyWebhookSignature(rawBody, signature);

  const inserted = await saveEventIfAbsent(event.id);
  if (!inserted) {
    return;
  }

  switch (event.type) {
    case "payment.succeeded":
      await markOrderAsPaid(event.data);
      break;

    case "refund.succeeded":
      await markOrderAsRefunded(event.data);
      break;

    case "transfer.failed":
      await markTransferAsFailed(event.data);
      break;

    default:
      break;
  }
}

处理逻辑不能依赖事件的到达顺序。Webhook 可能重复、延迟或乱序,因此所有处理都要具备幂等性。

如何选择支付平台

评估支付服务商时,需要确认以下问题:

能力需要确认的问题
平台账户是否支持 marketplace 或 platform 模式
收款方入驻是否提供 KYC/KYB、银行卡绑定和身份审核
分账是否支持延迟转账、部分转账和多个收款方
退款是否支持全额、部分和重复退款保护
转账撤销已分账资金能否追回,余额不足如何处理
地区平台和收款方所在国家是否都受支持
币种是否支持目标币种和跨境结算
提现控制能否设置 payout schedule 或延迟提现
风险责任拒付、退款和负余额由谁承担
SDK是否提供目标移动平台的支付组件
合规该资金路径是否允许平台暂时控制资金

Mobile SDK 并不是最重要的筛选条件。只要支付服务商提供安全的托管支付页面、浏览器组件或 tokenization 接口,移动应用通常就能通过 WebView、系统浏览器或平台支付组件完成付款。分账和退款原本就应该通过服务端 API 执行。

关键注意事项

不要把平台数据库当作资金托管系统

数据库里的“冻结余额”只是业务账本记录,无法代替支付服务商的真实账户余额。每笔收款、平台佣金、服务商应收款、退款和转账,都应有可供核对的外部交易 ID。

资金变化可以使用不可变账本记录:

+100.00  Customer payment
 -10.00  Platform fee
 -90.00  Provider payable
+90.00   Provider payable reversal
-100.00  Customer refund

订单表用于保存当前状态,账本用于保存资金变化历史。不要直接覆盖一个 balance 字段来表示所有交易。

防止重复转账和重复退款

所有会引起资金变化的接口都要使用 idempotency key。数据库层也需要添加唯一约束,例如:

CREATE UNIQUE INDEX uq_transfer_order
ON payment_transfers(order_id);

CREATE UNIQUE INDEX uq_refund_order
ON payment_refunds(order_id);

只在前端禁用按钮,无法阻止重复请求。

考虑退款、拒付和负余额

即使已经向 Service Provider 付款,Customer 仍可能在之后发起拒付。如果拒付责任由平台承担,就要准备风险准备金、延迟提现或负余额追偿机制。

业务规则至少需要明确:

  • 由谁确认服务成功;
  • 多久后自动确认;
  • 发生争议时是否暂停付款;
  • 服务只完成一部分时,如何计算部分退款;
  • Service Provider 已提现后,退款由谁承担;
  • 支付手续费是否退还;
  • 汇率变化和跨境费用由谁承担。

不要长期持有资金

“先收款,几个月后再转给服务商”的做法未必会被支付服务商允许。部分产品会限制延迟分账时间,或要求平台在一定期限内处理资金。长期持有、混同存放或自由调配用户资金,也可能改变业务的监管性质。

实际落地建议

如果业务属于标准的双边平台,可以先评估 Stripe Connect、Adyen for Platforms 或 PayPal Commerce Platform。这些产品比普通收单网关更符合所需的资金模式。

可以按以下顺序实施:

  1. 根据平台和 Service Provider 所在国家,筛选可用的平台支付产品。
  2. 请支付服务商书面确认业务模式、资金停留时间和退款责任。
  3. 为 Service Provider 建立受管理的收款账户,并完成 KYC/KYB。
  4. 在服务端实现订单状态机、幂等控制和内部账本。
  5. 短周期服务采用“授权后延迟扣款”。
  6. 长周期服务采用“先扣款、确认后分账”,同时确认支付服务商允许的资金处理期限。
  7. 分账前发生取消或退款时直接处理;分账后退款时,还要撤销转账或由平台垫付。
  8. 使用经过签名验证的 Webhook 对账,不把客户端回调作为最终依据。
  9. 上线前测试重复通知、部分退款、转账失败、余额不足和拒付等异常路径。

如果目标国家没有任何平台支付产品支持这种模式,就不能通过普通 merchant account 加自行转账来绕过限制。通常需要接入当地具备代收代付或资金托管资质的持牌机构,或者调整业务模型,让 Customer 直接向 Service Provider 付款,再由平台另行收取服务费。

备注:内容仅供参考。