PAYATHON 2026

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 延迟转账

常见的设计流程如下:

  1. 卖家通过 Stripe Connect 完成账户注册和身份验证。
  2. 买家付款时,资金先进入平台控制的支付流程。
  3. 系统等待支付成功的 Webhook,不根据前端跳转结果判断付款是否成功。
  4. 任务完成并满足放款条件后,平台创建 Transfer,把卖家应得的金额转入其 Connected Account。
  5. 如果任务取消,则按规则退款,不创建 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

订单至少应记录:

  • amount
  • currency
  • platform_fee
  • seller_amount
  • stripe_payment_intent_id
  • stripe_charge_id
  • stripe_transfer_id
  • seller_connected_account_id
  • release_requested_at
  • released_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 的选择标准

确定供应商前,应要求对方明确回答以下问题:

  1. 买家和卖家所在的国家是否都受支持?
  2. 支持哪些货币,是否需要换汇?
  3. 卖家需要提交哪些 KYC/KYB 材料?
  4. 等待任务完成期间,资金依法由谁持有?
  5. 最长可以延迟结算多久?
  6. 这种模式属于真正的 Escrow,还是普通延迟转账?
  7. 退款、争议和信用卡拒付由谁承担?
  8. 能否拆分平台佣金、卖家收入和税费?
  9. 是否提供 Webhook、幂等键和测试环境?
  10. 平台倒闭、卖家负余额或买家拒付时如何处理?

“全球支持”通常不代表所有国家都能同时作为买家和卖家。某种支付方式覆盖一个国家,也不代表该国卖家可以注册、收款或提现。

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。

无论选择哪家服务商,方案都应以供应商书面确认的国家覆盖范围、资金持有方式和合规责任为依据。上线前,还应由熟悉目标司法辖区支付法规的律师审核。

备注:内容仅供参考。