Rails Marketplace 全球 Escrow 支付处理方案
明确结论
没有哪个 RubyGem 安装后就能完成全球 Escrow。资金托管涉及卖家身份验证、反洗钱、牌照、退款和争议处理,这些核心能力必须由具备相应资质的支付平台提供。RubyGem 的作用只是接入 API。
Rails Marketplace 可以优先评估以下方案:
- Stripe Connect:Ruby SDK 成熟,适合 Marketplace 分账、卖家入驻和延迟转账。常用 Gem 是
stripe。不过,延迟向卖家转账不一定属于法律意义上的 Escrow。 - Mangopay:主要提供钱包、KYC 和 Marketplace 资金流能力,比较接近平台托管场景。Ruby 项目可以使用其官方 SDK(如果目前仍由官方维护),也可以直接调用 REST API。
- Adyen for Platforms:适合规模较大、覆盖多个国家或分账方式复杂的平台,通常需要经过商务审核。
- Lemonway:面向欧洲 Marketplace,提供账户、KYC 和资金流管理能力。接入前需要确认目标国家是否受支持。
- Escrow.com API:如果业务必须使用真正的第三方 Escrow,可以评估其 API,但需要单独确认支持的交易类型、国家、货币和平台接入资格。
- PayPal Commerce Platform:支持 Marketplace 收款和卖家结算,但”延迟放款”的能力、开放范围和接入资格会因地区及商户类型而异,不能默认把它当作通用 Escrow。
如果项目看重开发简单、文档完整,并且目标地区在服务范围内,通常可以先评估 Stripe Connect + stripe Gem。如果必须满足严格的法律意义上的 Escrow,则应选择持有相应许可的托管服务,不要自行持有买家资金。
为什么不能自己实现 Escrow
从技术上说,把付款状态记录为 held,等任务完成后再调用转账接口并不难。难点在于,平台是否有权持有这笔钱。
平台先收款、以后再支付给卖家,可能涉及:
- 支付机构或资金传输牌照
- KYC/KYB 身份验证
- AML 反洗钱审查
- 制裁名单筛查
- PCI DSS
- 拒付、退款和争议处理
- 卖家税务信息申报
- 不同国家的资金留存期限
- 负余额和平台垫资风险
业务文案也不应随意把”延迟结算”称为 Escrow。除非支付服务商和法律顾问确认该产品确实提供受监管的托管服务,否则更准确的说法是”平台延迟结算”或”任务完成后向卖家转账”。
推荐实现:Stripe Connect 延迟转账
常见的设计流程如下:
- 卖家通过 Stripe Connect 完成账户注册和身份验证。
- 买家付款时,资金先进入平台控制的支付流程。
- 系统等待支付成功的 Webhook,不根据前端跳转结果判断付款是否成功。
- 任务完成并满足放款条件后,平台创建 Transfer,把卖家应得的金额转入其 Connected Account。
- 如果任务取消,则按规则退款,不创建 Transfer。
这种模式通常使用 separate charges and transfers,把买家付款和卖家转账拆成两个动作。它是否适用于具体国家、货币和业务类型,仍需根据 Stripe 当前文档及账户合同确认。
安装 RubyGem
# Gemfile
gem "stripe"
然后执行:
bundle install
配置密钥:
# config/initializers/stripe.rb
Stripe.api_key = ENV.fetch("STRIPE_SECRET_KEY")
密钥应保存在环境变量或 Rails Credentials 中,不要提交到代码仓库。
数据模型建议
支付状态和任务状态应分别保存。例如:
# 示例字段,并非完整 migration
class Order < ApplicationRecord
enum :job_status, {
pending: 0,
in_progress: 1,
completed: 2,
disputed: 3,
cancelled: 4
}, prefix: true
enum :payment_status, {
unpaid: 0,
processing: 1,
paid: 2,
release_pending: 3,
released: 4,
refund_pending: 5,
refunded: 6,
payment_failed: 7
}, prefix: true
end
订单至少应记录:
amountcurrencyplatform_feeseller_amountstripe_payment_intent_idstripe_charge_idstripe_transfer_idseller_connected_account_idrelease_requested_atreleased_at
金额应以最小货币单位的整数保存。例如,USD 10.50 应保存为 1050,不要使用浮点数。
创建买家付款
下面是一个简化示例:
class PaymentsController < ApplicationController
def create
order = current_user.orders.find(params[:order_id])
payment_intent = Stripe::PaymentIntent.create(
{
amount: order.amount,
currency: order.currency,
automatic_payment_methods: { enabled: true },
metadata: {
order_id: order.id
}
},
{
idempotency_key: "order-#{order.id}-payment-v1"
}
)
order.update!(
stripe_payment_intent_id: payment_intent.id,
payment_status: :processing
)
render json: {
client_secret: payment_intent.client_secret
}
end
end
客户端确认付款后,不要立刻把订单标记为已付款。可靠的支付状态应来自 Stripe Webhook。
处理支付 Webhook
class StripeWebhooksController < ActionController::API
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(
payload,
signature,
ENV.fetch("STRIPE_WEBHOOK_SECRET")
)
case event.type
when "payment_intent.succeeded"
handle_payment_succeeded(event.data.object)
when "payment_intent.payment_failed"
handle_payment_failed(event.data.object)
when "charge.refunded"
handle_refund(event.data.object)
end
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request
end
private
def handle_payment_succeeded(payment_intent)
order = Order.find(payment_intent.metadata.order_id)
order.with_lock do
return if order.payment_status_paid? ||
order.payment_status_released?
order.update!(
stripe_charge_id: payment_intent.latest_charge,
payment_status: :paid
)
end
end
def handle_payment_failed(payment_intent)
order = Order.find_by(
stripe_payment_intent_id: payment_intent.id
)
order&.update!(payment_status: :payment_failed)
end
def handle_refund(charge)
Order.find_by(stripe_charge_id: charge.id)
&.update!(payment_status: :refunded)
end
end
生产环境还应保存 Webhook 事件 ID,并对事件去重。支付平台可能重复发送同一事件,也可能不按顺序发送事件。
任务完成后放款
只有在订单已付款、任务已完成、没有争议,并且此前从未放款的情况下,才允许创建 Transfer:
class ReleaseOrderPayment
def self.call(order)
order.with_lock do
raise "Job is not completed" unless order.job_status_completed?
raise "Payment is not available" unless order.payment_status_paid?
raise "Payment was already released" if order.stripe_transfer_id.present?
order.update!(
payment_status: :release_pending,
release_requested_at: Time.current
)
end
transfer = Stripe::Transfer.create(
{
amount: order.seller_amount,
currency: order.currency,
destination: order.seller_connected_account_id,
source_transaction: order.stripe_charge_id,
metadata: {
order_id: order.id
}
},
{
idempotency_key: "order-#{order.id}-release-v1"
}
)
order.update!(
stripe_transfer_id: transfer.id,
payment_status: :released,
released_at: Time.current
)
rescue StandardError
order.update!(payment_status: :paid) if order.payment_status_release_pending?
raise
end
end
实际项目中,放款操作更适合交给后台任务处理:
class ReleaseOrderPaymentJob < ApplicationJob
queue_as :payments
retry_on Stripe::APIConnectionError, wait: :polynomially_longer, attempts: 5
def perform(order_id)
ReleaseOrderPayment.call(Order.find(order_id))
end
end
数据库锁只能避免同一数据库内的并发操作。要防止支付 API 被重复执行,仍然需要稳定的 idempotency_key。
完成条件不要设计得过于简单
不应让任何一方点击”标记为完成”后立即触发放款。更稳妥的流程是:
卖家提交成果
↓
买家确认完成
↓
进入短暂争议窗口
↓
没有争议
↓
创建卖家转账
还需要考虑以下问题:
- 买家长期不确认时是否自动完成
- 买家提出争议后由谁仲裁
- 部分完成是否支持部分放款
- 平台服务费何时确认
- 退款发生在放款前还是放款后
- 放款后发生信用卡拒付时由谁承担
- 卖家账户尚未通过 KYC 时如何处理
- 转账失败后如何重试和告警
管理员直接修改数据库状态时,不应触发未经审计的放款。建议把状态变更封装成明确的领域操作,并记录操作者、时间、原因和支付平台返回的对象 ID。
不要使用长期银行卡预授权代替托管
另一种常见想法是先授权银行卡,等任务完成后再捕获:
Stripe::PaymentIntent.create(
amount: order.amount,
currency: order.currency,
capture_method: "manual"
)
这种方式只适合周期较短的交易。银行卡授权有有效期,具体期限会因卡组织、支付方式和交易类型而异。如果任务持续数周或数月,授权可能在任务完成前失效,因此不能把 capture_method: "manual" 当作无限期 Escrow。
如果项目的任务周期较长,通常应先完成收款,再通过 Marketplace 支付产品控制卖家结算时间。
外部 API 的选择标准
确定供应商前,应要求对方明确回答以下问题:
- 买家和卖家所在的国家是否都受支持?
- 支持哪些货币,是否需要换汇?
- 卖家需要提交哪些 KYC/KYB 材料?
- 等待任务完成期间,资金依法由谁持有?
- 最长可以延迟结算多久?
- 这种模式属于真正的 Escrow,还是普通延迟转账?
- 退款、争议和信用卡拒付由谁承担?
- 能否拆分平台佣金、卖家收入和税费?
- 是否提供 Webhook、幂等键和测试环境?
- 平台倒闭、卖家负余额或买家拒付时如何处理?
“全球支持”通常不代表所有国家都能同时作为买家和卖家。某种支付方式覆盖一个国家,也不代表该国卖家可以注册、收款或提现。
RubyGem 选择建议
Ruby 端应优先使用支付平台官方维护的 SDK:
gem "stripe"
如果供应商没有可靠的官方 Ruby SDK,直接调用 REST API 通常比依赖长期无人维护的第三方 Gem 更安全。例如,可以使用通用 HTTP 客户端:
gem "faraday"
简化调用示例:
connection = Faraday.new(
url: ENV.fetch("PAYMENT_API_BASE_URL"),
headers: {
"Authorization" => "Bearer #{ENV.fetch("PAYMENT_API_TOKEN")}",
"Content-Type" => "application/json"
}
)
response = connection.post("/transfers") do |request|
request.body = {
order_reference: order.id,
amount: order.seller_amount,
currency: order.currency,
recipient_id: order.external_seller_id
}.to_json
end
raise "Payment API error: #{response.status}" unless response.success?
具体路径、认证方式和参数必须以所选服务商的当前文档为准,不能直接照搬这个通用示例。
最终建议
中小型 Rails Marketplace 可以先确认 Stripe Connect + stripe Gem + separate charges and transfers 是否覆盖目标国家和业务类型。它的工程接入相对清晰,但应描述为延迟结算,不要默认宣称为 Escrow。
如果合同或监管要求必须使用正式 Escrow,则应评估 Escrow.com,或目标市场中持有相应许可的托管机构。面向欧洲且需要钱包式资金流时,还可以比较 Mangopay、Lemonway 和 Adyen for Platforms。
无论选择哪家服务商,方案都应以供应商书面确认的国家覆盖范围、资金持有方式和合规责任为依据。上线前,还应由熟悉目标司法辖区支付法规的律师审核。
备注:内容仅供参考。
