如何在 Rails 中集成支持平台分账的 Payment Gateway?
结论
支持,但要使用面向平台或 Marketplace 的支付产品,不能使用普通商户收款接口。
常见方案有 Stripe Connect、Adyen for Platforms、PayPal 的多方支付能力等。能否使用,要看平台注册地区、活动组织者所在地区、业务类型和支付机构的合规审核结果。
以 Stripe Connect 为例,可以使用 Separate charges and transfers 模式:
- 赞助者向平台支付全部款项。
- 款项进入平台的支付账户余额。
- 平台通过 Webhook 确认支付成功。
- 业务审核通过后,平台创建 Transfer。
- 总金额的 80% 转入活动组织者的 Connected Account。
- 剩余 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 编排,身份验证、资金账本及付款则由具备相应平台能力的支付服务商处理。
备注:内容仅供参考。
