PAYATHON 2026

如何在 Rails 中集成支持平台分账的 Payment Gateway?

支付阿杰

结论

支持,但要使用面向平台或 Marketplace 的支付产品,不能使用普通商户收款接口。

常见方案有 Stripe Connect、Adyen for Platforms、PayPal 的多方支付能力等。能否使用,要看平台注册地区、活动组织者所在地区、业务类型和支付机构的合规审核结果。

以 Stripe Connect 为例,可以使用 Separate charges and transfers 模式:

  1. 赞助者向平台支付全部款项。
  2. 款项进入平台的支付账户余额。
  3. 平台通过 Webhook 确认支付成功。
  4. 业务审核通过后,平台创建 Transfer。
  5. 总金额的 80% 转入活动组织者的 Connected Account。
  6. 剩余 20% 留在平台余额中。

这里的”进入平台账户”通常是指款项进入平台在支付机构中的账户余额,不代表款项已经结算到平台的银行账户。如果款项先结算到银行,再由平台自行汇给组织者,可能涉及代收代付、资金托管或资金传输许可,不能直接当作普通转账业务实现。

推荐的账户结构

平台应为每个活动组织者创建 Connected Account,由支付机构完成身份认证、银行账户验证和后续付款。

账户关系如下:

Sponsor
   │ 100%
   ▼
Platform Payment Account
   │
   ├── 20% retained by platform
   │
   └── 80% transferred after approval
            ▼
Organizer Connected Account

平台数据库至少要保存以下信息:

  • 组织者的 Connected Account ID
  • PaymentIntent ID
  • Charge ID
  • Transfer ID
  • 支付总金额
  • 分账金额
  • 币种
  • 支付、审核、转账和退款状态

金额必须以最小货币单位的整数保存。例如,100.00 USD 应保存为 10000 cents,不能使用浮点数计算。

在 Rails 中安装 Stripe SDK

在 Gemfile 中加入:

gem "stripe"

安装依赖:

bundle install

使用 Rails Credentials 或部署环境变量保存密钥:

# config/initializers/stripe.rb
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)

Webhook Secret 要单独保存:

stripe:
  secret_key: sk_live_xxx
  webhook_secret: whsec_xxx

生产环境中不要把密钥写入代码仓库。

为活动组织者创建 Connected Account

下面使用 Express Account 作为示例。具体的账号类型和开户参数,要根据平台所在国家、组织者类型及 Stripe 当前要求确定。

account = Stripe::Account.create(
  type: "express",
  country: "US",
  email: organizer.email,
  capabilities: {
    transfers: { requested: true }
  },
  metadata: {
    organizer_id: organizer.id
  }
)

organizer.update!(
  stripe_account_id: account.id
)

接着创建开户注册链接:

account_link = Stripe::AccountLink.create(
  account: organizer.stripe_account_id,
  refresh_url: onboarding_refresh_url,
  return_url: onboarding_complete_url,
  type: "account_onboarding"
)

redirect_to account_link.url, allow_other_host: true

用户返回 return_url 并不代表开户已经完成。平台还要重新查询账号状态,检查相应能力是否启用:

account = Stripe::Account.retrieve(organizer.stripe_account_id)

ready_for_transfers =
  account.details_submitted &&
  account.capabilities.transfers == "active"

创建由平台收款的 PaymentIntent

假设赞助金额为 10000 cents,也就是 100.00 USD:

payment_intent = Stripe::PaymentIntent.create(
  {
    amount: 10_000,
    currency: "usd",
    automatic_payment_methods: {
      enabled: true
    },
    transfer_group: "event_#{event.id}",
    metadata: {
      sponsorship_id: sponsorship.id,
      event_id: event.id,
      organizer_id: event.organizer_id
    }
  },
  {
    idempotency_key: "sponsorship-payment-#{sponsorship.id}"
  }
)

sponsorship.update!(
  stripe_payment_intent_id: payment_intent.id,
  amount_cents: 10_000,
  currency: "usd",
  payment_status: "pending"
)

这里没有设置 transfer_data[destination],因此付款时不会立即把资金转给组织者。后续分账由平台的审核操作触发。

浏览器端应使用 Stripe.js 或 Payment Element 完成支付确认。Secret Key 只能在 Rails 服务端使用,不能发送到前端。

使用 Webhook 确认支付结果

不能根据前端跳转页面判断付款是否成功。支付结果应由经过签名验证的 Webhook 驱动。

# config/routes.rb
post "/webhooks/stripe", to: "stripe_webhooks#create"
# app/controllers/stripe_webhooks_controller.rb
class StripeWebhooksController < ApplicationController
  skip_forgery_protection

  def create
    payload = request.raw_post
    signature = request.headers["Stripe-Signature"]
    webhook_secret =
      Rails.application.credentials.dig(:stripe, :webhook_secret)

    event = Stripe::Webhook.construct_event(
      payload,
      signature,
      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)
    end

    head :ok
  rescue JSON::ParserError, Stripe::SignatureVerificationError
    head :bad_request
  end

  private

  def handle_payment_succeeded(payment_intent)
    sponsorship = Sponsorship.find_by!(
      stripe_payment_intent_id: payment_intent.id
    )

    sponsorship.update!(
      stripe_charge_id: payment_intent.latest_charge,
      payment_status: "paid"
    )
  end

  def handle_payment_failed(payment_intent)
    sponsorship = Sponsorship.find_by!(
      stripe_payment_intent_id: payment_intent.id
    )

    sponsorship.update!(payment_status: "failed")
  end
end

Webhook 可能重复投递,也可能不会按照预期顺序到达。生产环境应保存 event.id,保证同一个事件只处理一次。

审核后执行 80% 分账

支付成功并通过平台审核后,再创建 Transfer:

class ApproveSponsorship
  PLATFORM_RATE = 20
  ORGANIZER_RATE = 80

  def self.call(sponsorship)
    raise "Payment is not complete" unless sponsorship.payment_status == "paid"
    raise "Already transferred" if sponsorship.stripe_transfer_id.present?

    organizer = sponsorship.event.organizer

    unless organizer.stripe_account_id.present?
      raise "Organizer has no connected account"
    end

    organizer_amount =
      sponsorship.amount_cents * ORGANIZER_RATE / 100

    transfer = Stripe::Transfer.create(
      {
        amount: organizer_amount,
        currency: sponsorship.currency,
        destination: organizer.stripe_account_id,
        source_transaction: sponsorship.stripe_charge_id,
        transfer_group: "event_#{sponsorship.event_id}",
        metadata: {
          sponsorship_id: sponsorship.id
        }
      },
      {
        idempotency_key: "sponsorship-transfer-#{sponsorship.id}"
      }
    )

    sponsorship.update!(
      stripe_transfer_id: transfer.id,
      organizer_amount_cents: organizer_amount,
      platform_amount_cents:
        sponsorship.amount_cents - organizer_amount,
      review_status: "approved",
      transfer_status: "created"
    )
  end
end

对于 10000 cents:

organizer_amount = 10000 × 80 ÷ 100 = 8000
platform_amount  = 10000 - 8000       = 2000

source_transaction 用于关联原始 Charge。资金何时可以转出、是否需要等待结算,以及目标账户能否收款,仍然取决于支付状态、账户能力、币种和支付机构的规则。

审核和转账应放到后台任务中执行,同时记录失败原因,防止管理员重复点击造成重复转账。

手续费需要单独计算

“平台保留 20%“可能有两种含义:

  • 平台获得总付款金额的 20%,支付手续费由平台另行承担。
  • 先扣除支付手续费,再按净额进行 20%/80% 分配。

在 Separate charges and transfers 模式下,支付处理费通常由平台承担。因此,总付款为 100.00、平台向组织者转账 80.00 时,平台账面留下的 20.00 还要承担支付手续费,实际净收入会低于 20.00。

开发前要明确合同采用哪一种分成基数,并约定退款、拒付、汇率损失和税费分别由哪一方承担。

退款和撤销分账

如果还没有转账,通常只需退还原始 PaymentIntent:

Stripe::Refund.create(
  {
    payment_intent: sponsorship.stripe_payment_intent_id
  },
  {
    idempotency_key: "sponsorship-refund-#{sponsorship.id}"
  }
)

如果已经向组织者创建 Transfer,退还原始支付通常不会自动撤销这个独立 Transfer。平台需要根据实际退款金额,撤销全部或部分转账:

Stripe::Transfer.create_reversal(
  sponsorship.stripe_transfer_id,
  {
    amount: sponsorship.organizer_amount_cents,
    metadata: {
      sponsorship_id: sponsorship.id,
      reason: "sponsorship_refund"
    }
  },
  {
    idempotency_key: "sponsorship-transfer-reversal-#{sponsorship.id}"
  }
)

完成撤销后,再处理原始付款退款。发生部分退款时,组织者应承担多少、平台佣金是否退还,都要按照明确的业务规则计算,不能假定每次都按原比例处理。

如果组织者余额不足,撤销可能失败,也可能形成负余额。平台因此需要设计风险准备金、延迟付款或负余额追偿机制。

上线前需要确认的事项

  • 确认支付机构在平台及组织者所在的国家或地区支持 Connected Accounts。
  • 不要自行保存银行卡号。支付信息应由支付机构提供的托管组件采集。
  • 审核通过前不要创建 Transfer。如果审核周期较长,还要确认资金保留期限。
  • 配置平台自身的自动结算策略,防止待分账资金过早结算到银行账户。
  • 创建 PaymentIntent、Transfer 和 Refund 的所有请求都要设置稳定的幂等键。
  • 使用 Webhook 处理成功、失败、退款、争议和账户状态变化。
  • 为管理员的审核、转账、退款和撤销操作保留完整的审计日志。
  • 分别记录支付、审核、转账、退款和拒付状态,不能只用一个布尔字段。
  • 测试重复 Webhook、重复审核、部分退款、组织者未完成开户、余额不足和拒付等异常情况。
  • 上线前请支付服务商确认业务模型,并咨询当地的法律、税务和财务专业人士。

这个需求不能实现成”普通 Gateway 收款后,由 Rails 调用银行转账”,而应使用支付平台提供的 Marketplace/Connect 产品。Rails 负责订单状态、审核流程、比例计算和 API 编排,身份验证、资金账本及付款则由具备相应平台能力的支付服务商处理。

备注:内容仅供参考。