手机网站支付多商户分账与付款路由如何对接?
结论
多商户手机网站支付不能只靠前端传入商户编号,再由支付页面选择收款账户。后端需要先根据订单确认实际收款商户,然后使用该商户对应的支付商户号、签约主体和密钥创建支付订单。
常见做法有两种:
- 各商户独立收款:每家商户使用自己的支付账户。后端根据订单选择对应的商户号,资金直接结算给该商户。这种方案实现简单,资金路径也很清楚。
- 平台统一收款后分账:平台接入支付机构提供的服务商、平台商户或电商平台能力,将订单与子商户绑定,支付成功后调用官方分账接口。这种方式适合一笔订单涉及多个收款方,或者平台需要收取佣金的场景。
如果不同订单分别属于不同商户,通常不需要分账。只有一笔付款需要分给多个主体时,才应使用分账。
先明确订单和资金模型
对接支付前,先要分清两种业务关系。
一个订单只属于一家商户
例如,用户在商户 A 的店铺下单,全部款项都应进入商户 A 的账户。后端应使用商户 A 的支付参数创建订单:
业务订单 -> merchant_id=A -> 使用 A 的支付商户号创建支付单 -> 资金结算给 A
商户 B 的订单也按相同方式处理。不同商户的支付配置、退款权限和对账数据需要相互隔离。
一个订单需要分给多个商户
例如,一笔订单金额为 100 元,其中商户获得 90 元,平台收取 10 元服务费。此时需要使用支付机构提供的正式分账能力:
用户支付 100 元
-> 支付成功
-> 平台发起分账
-> 商户 90 元
-> 平台 10 元
是否支持分账、最多可以设置多少个接收方、分账比例、手续费由谁承担以及结算周期,都取决于支付机构、签约产品和商户资质,不能用普通转账接口模拟。
各商户独立收款的对接步骤
1. 建立商户配置表
每个业务商户都应绑定自己的支付配置。密钥、证书等敏感信息必须加密保存,也不能返回给手机网页。
CREATE TABLE merchant_payment_config (
merchant_id VARCHAR(64) PRIMARY KEY,
channel VARCHAR(32) NOT NULL,
payment_account_id VARCHAR(128) NOT NULL,
encrypted_secret TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
payment_account_id 是业务层的抽象字段。实际接入时,它可能对应支付渠道的商户号、子商户号或其他账户标识,具体名称以所接支付产品的接口文档为准。
2. 在后端确认订单归属
商户身份必须来自可信的订单记录,不能直接采用前端提交的 merchantId。
async function createMobileWebPayment(
userId: string,
orderId: string
) {
const order = await orderRepository.findById(orderId);
if (!order || order.userId !== userId) {
throw new Error("Order not found");
}
if (order.status !== "UNPAID") {
throw new Error("Order cannot be paid");
}
const config = await paymentConfigRepository.findEnabled(
order.merchantId,
order.paymentChannel
);
if (!config) {
throw new Error("Merchant payment configuration is unavailable");
}
const paymentRequest = {
outTradeNo: order.paymentNo,
amount: order.payableAmount,
currency: "CNY",
subject: order.subject,
notifyUrl: PAYMENT_NOTIFY_URL,
returnUrl: PAYMENT_RETURN_URL
};
return paymentGateway.createMobileWebPayment(
config,
paymentRequest
);
}
示例中的 paymentGateway.createMobileWebPayment 是应用内部的封装方法,不是某家支付机构的固定 API 名称。
3. 保存支付渠道快照
创建支付单时,需要保存本次实际使用的商户配置标识。这样即使商户配置后来发生变化,处理回调和退款时仍能找到原支付账户。
建议至少记录以下信息:
业务订单号
支付订单号
业务商户 ID
支付渠道
支付账户 ID
支付金额
支付状态
渠道交易号
创建时间
同一业务订单应使用固定的支付单号,并通过唯一约束或幂等控制,避免重复创建互相冲突的支付记录。
4. 验证异步通知
支付结果应以服务端异步通知和主动查询结果为准,不能只依赖手机浏览器的跳转页面。
async function handlePaymentNotify(rawBody: Buffer, headers: Headers) {
const accountId = resolveAccountId(headers, rawBody);
const config = await paymentConfigRepository.findByAccountId(accountId);
const event = paymentGateway.verifyAndParseNotify(
config,
rawBody,
headers
);
const payment = await paymentRepository.findByPaymentNo(
event.outTradeNo
);
if (!payment) {
throw new Error("Unknown payment");
}
if (
payment.paymentAccountId !== accountId ||
payment.amount !== event.amount
) {
throw new Error("Payment data mismatch");
}
await paymentService.markPaidIdempotently(
payment.id,
event.channelTradeNo
);
}
各支付渠道使用的通知报文、签名算法和商户号获取方式并不相同。实现时必须遵循对应渠道的官方协议,不能直接照搬示例字段。
需要分账时的推荐流程
需要分账时,应优先使用支付机构提供的平台型产品。不要先将资金收进个人账户或普通商户账户,再自行转账分配。
标准流程通常如下:
- 平台完成服务商或平台主体签约。
- 商户完成进件、实名和结算账户绑定,取得子商户标识。
- 创建支付订单时声明订单归属,必要时将订单标记为允许分账。
- 支付成功后校验可分账金额。
- 根据业务订单生成不可篡改的分账明细。
- 调用支付机构的分账接口。
- 保存每个接收方的分账状态,并处理重试、退回和关闭分账。
- 退款时按照渠道规则处理已分账资金的回退。
分账指令可以在业务系统中抽象为:
{
"paymentNo": "PAY202609130001",
"totalAmount": 10000,
"receivers": [
{
"receiverId": "SUB_MERCHANT_A",
"amount": 9000,
"description": "商品结算"
},
{
"receiverId": "PLATFORM",
"amount": 1000,
"description": "平台服务费"
}
]
}
金额建议用最小货币单位的整数表示。例如,人民币 100.00 元保存为 10000 分,以免产生浮点计算误差。提交前必须满足:
所有接收方金额之和 <= 渠道允许的可分账金额
有些渠道会扣除手续费、冻结部分金额,或者限制平台可留存的比例。因此,“可分账金额”不一定等于用户实际支付的金额。
关键注意事项
- 不要让前端决定收款商户。 前端提交的商户编号、金额和分账比例都可能被篡改,后端必须根据订单重新计算。
- 不要共用密钥。 各商户的密钥、证书和商户号应分别管理,并设置明确的启停机制。
- 支付和退款应使用同一账户上下文。 退款通常必须由原支付商户或原平台关系发起。
- 做好幂等处理。 支付回调和分账通知可能重复发送。更新状态时,应使用支付单号、分账单号和渠道交易号进行幂等控制。
- 区分支付成功和分账成功。 用户完成支付后,分账仍可能处于处理中或失败状态,不能用同一个状态字段表示两者。
- 不要用普通转账代替分账。 这种做法会增加资金合规、税务、退款和对账风险,也可能违反支付产品的签约规则。
- 提前确认资质。 平台能否代收、子商户如何进件以及平台是否可以收取佣金,都取决于实际业务关系和支付渠道的审核结果。
- 建立逐日对账机制。 至少要核对业务订单、支付流水、退款流水、分账流水和渠道账单,发现差异后转入人工处理队列。
如果每个用户订单只对应一家商户,应采用”后端判断订单归属+路由商户支付配置”的方式。如果一笔款项需要在平台与一个或多个商户之间分配,则应申请支付机构的正式分账产品,并围绕子商户、分账接收方、退款回退和对账建立完整流程。
备注:内容仅供参考。