如何使用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。这些产品比普通收单网关更符合所需的资金模式。
可以按以下顺序实施:
- 根据平台和 Service Provider 所在国家,筛选可用的平台支付产品。
- 请支付服务商书面确认业务模式、资金停留时间和退款责任。
- 为 Service Provider 建立受管理的收款账户,并完成 KYC/KYB。
- 在服务端实现订单状态机、幂等控制和内部账本。
- 短周期服务采用“授权后延迟扣款”。
- 长周期服务采用“先扣款、确认后分账”,同时确认支付服务商允许的资金处理期限。
- 分账前发生取消或退款时直接处理;分账后退款时,还要撤销转账或由平台垫付。
- 使用经过签名验证的 Webhook 对账,不把客户端回调作为最终依据。
- 上线前测试重复通知、部分退款、转账失败、余额不足和拒付等异常路径。
如果目标国家没有任何平台支付产品支持这种模式,就不能通过普通 merchant account 加自行转账来绕过限制。通常需要接入当地具备代收代付或资金托管资质的持牌机构,或者调整业务模型,让 Customer 直接向 Service Provider 付款,再由平台另行收取服务费。
备注:内容仅供参考。
