Rails 应用的 Billing plugins 该如何选择?
结论
对于新项目,不建议直接在 Service Merchant 和早期版本的 SaaS Rails Kit 之间选择。两者都是 Rails 生态中出现较早的方案,能否适配当前的 Rails 版本、支付网关 API、强客户认证(SCA)以及 Webhook 安全要求,需要逐项确认。
更稳妥的方案是:
- 使用支付服务商官方维护的 Ruby SDK。
- 在 Rails 中自行实现订阅、账单状态和 Webhook 处理层。
- 如果想减少开发工作,再考虑仍在持续维护、测试覆盖完整的 billing gem 或商业模板。
如果必须维护老项目,可以根据现有需求判断:
- 如果只需要支付网关抽象,团队也愿意自行实现订阅流程,可以评估 Service Merchant。
- 如果需要账户、套餐、订阅等较完整的 SaaS 基础结构,并且已经确认代码适配现有 Rails 版本,可以评估 SaaS Rails Kit。
- 不要只根据“免费”或“付费”做决定。维护状态、升级成本和支付合规风险通常更值得关注。
两类方案的区别
这两个方案处理的并不是同一层问题。
Service Merchant
这类插件更接近支付基础设施层,主要负责封装支付网关调用。应用仍要处理许多业务逻辑,包括:
- 套餐与价格管理
- 客户和支付平台账号的关联
- 订阅状态同步
- 试用期和优惠
- 升级、降级与取消
- 支付失败后的重试与限制
- Webhook 验签和幂等处理
- 发票、退款及对账
它的好处是控制空间大,也不会受到模板结构的强约束,但开发和长期维护工作更多。
SaaS Rails Kit
商业 Rails kit 通常是 SaaS 应用的起步模板,不只封装支付网关调用。它可能已经提供账户、套餐和订阅等结构,但实际功能取决于购买的版本,不能默认每个版本都支持当前的支付接口。
商业模板主要用于节省初期开发时间,并不意味着它天然更安全或可靠。评估时需要确认:
- 最近一次更新日期
- 支持的 Rails 和 Ruby 版本
- 支持哪些支付服务商及 API 版本
- 是否包含数据库迁移和升级说明
- 是否处理 Webhook 验签与重复投递
- 是否有自动化测试
- 许可证是否允许当前部署方式
- 停止维护后能否继续修改源代码
如果无法确认这些信息,购买模板可能会给项目带来难以升级的遗留代码。
推荐的实现方式
以第三方支付平台托管的支付页面为例,Rails 应用最好只保存支付平台标识符和本地业务状态,不直接接触银行卡号。
简化后的数据模型可以这样设计:
# app/models/account.rb
class Account < ApplicationRecord
has_one :subscription, dependent: :destroy
end
# app/models/subscription.rb
class Subscription < ApplicationRecord
belongs_to :account
enum :status, {
incomplete: "incomplete",
trialing: "trialing",
active: "active",
past_due: "past_due",
canceled: "canceled"
}
validates :provider, presence: true
validates :provider_subscription_id, uniqueness: true, allow_nil: true
end
建议至少保存这些字段:
create_table :subscriptions do |t|
t.references :account, null: false, foreign_key: true
t.string :provider, null: false
t.string :provider_customer_id
t.string :provider_subscription_id
t.string :provider_price_id
t.string :status, null: false, default: "incomplete"
t.datetime :current_period_ends_at
t.datetime :canceled_at
t.timestamps
end
add_index :subscriptions, :provider_subscription_id, unique: true
不要只在前端保存套餐价格,也不要允许客户端直接提交任意 price_id。服务端应根据内部套餐代码完成映射:
class Billing::Plans
PRICE_IDS = {
"starter" => ENV.fetch("BILLING_STARTER_PRICE_ID"),
"pro" => ENV.fetch("BILLING_PRO_PRICE_ID")
}.freeze
def self.price_id_for(plan)
PRICE_IDS.fetch(plan)
end
end
控制器只接收系统支持的套餐名称:
class CheckoutsController < ApplicationController
def create
price_id = Billing::Plans.price_id_for(params.require(:plan))
session = PaymentProvider::Checkout.create(
customer_id: current_account.subscription&.provider_customer_id,
price_id: price_id,
success_url: billing_success_url,
cancel_url: billing_cancel_url
)
redirect_to session.url, allow_other_host: true
rescue KeyError
render plain: "Unknown billing plan", status: :unprocessable_entity
end
end
PaymentProvider::Checkout.create 只是用于说明系统边界的示例接口,并不是某个 gem 的真实 API。实际项目中,需要将它替换为支付服务商官方 SDK 提供的方法和参数。
Webhook 才是订阅状态的依据
用户从支付页面返回应用,不代表付款已经成功。最终状态应由经过验签的 Webhook 更新。
class BillingWebhooksController < ActionController::API
def create
event = Billing::WebhookVerifier.verify!(
payload: request.raw_post,
signature: request.headers["HTTP_PAYMENT_SIGNATURE"]
)
Billing::ProcessEvent.call(event)
head :ok
rescue Billing::InvalidSignature
head :bad_request
end
end
这里的 Billing::WebhookVerifier 同样是应用内部抽象。验签必须按照具体支付服务商的官方文档实现,不能只解析 JSON 就直接信任其中的状态。
事件处理还需要保证幂等,因为支付平台可能重复投递同一个事件:
class Billing::ProcessEvent
def self.call(event)
BillingEvent.transaction do
record = BillingEvent.find_or_initialize_by(provider_event_id: event.id)
return if record.processed_at?
update_subscription(event)
record.processed_at = Time.current
record.save!
end
end
def self.update_subscription(event)
# 根据经过验签的事件类型更新本地订阅状态。
end
end
数据库还应为 provider_event_id 建立唯一索引,避免并发请求重复执行:
add_index :billing_events, :provider_event_id, unique: true
实际选型检查表
决定使用插件、gem 或商业 kit 之前,建议先做一个小型验证项目,至少测试以下场景:
- 创建客户和订阅。
- 首次付款成功与失败。
- Webhook 重复投递。
- Webhook 乱序到达。
- 套餐升级和降级。
- 周期结束时取消。
- 支付失败后的状态变化。
- 退款和争议处理。
- 测试环境与生产环境的配置隔离。
- Rails、Ruby 或支付 API 升级后的兼容性。
还要检查依赖是否锁定在过旧的版本:
bundle outdated
bundle audit
运行 bundle audit 前,项目需要安装相应工具。它只能发现已经收录到漏洞数据库中的问题,不能证明依赖绝对安全。
注意事项
不要在 Rails 数据库、日志或错误追踪系统中保存完整银行卡号、CVC 等敏感信息。优先使用支付平台托管页面或其安全组件,缩小 PCI DSS 合规范围。
金额应以最小货币单位的整数保存,例如人民币分或美元美分,避免浮点数误差:
amount_cents = 1999
订阅权限也不能只看 canceled_at 是否为空。trialing、active、past_due 等状态如何影响访问权限,需要写成明确的业务规则。
支付代码最好放在 Billing 命名空间中,避免控制器和业务模型直接依赖某个 SDK。以后更换服务商、升级 API 或补充测试时,需要修改的范围会更小。
老项目可以结合现有代码评估 Service Merchant 或 SaaS Rails Kit。新项目则应优先采用仍在维护的支付 SDK 和方案,并重点考察 Webhook、幂等处理、状态同步和升级能力。
备注:内容仅供参考。
