Rails 有哪些生产可用的 SaaS 订阅计费方案?
结论
在 Rails 生态中,更稳妥的生产方案通常不是依靠某个 Rails 插件独立完成扣款、续费和信用卡管理,而是采用以下分工:
- 由 Stripe Billing、Braintree、Paddle、Recurly 或 Chargebee 等计费平台处理支付和自动续费。
- Rails 保存客户、套餐、订阅状态及平台对象 ID。
- 通过官方 Ruby SDK 或仍在积极维护的开源封装接入。
- 使用 webhook 同步订阅状态,不把浏览器回调作为最终依据。
如果更看重开源、Rails 集成体验和控制能力,可以先考虑 pay gem 加 Stripe Billing。pay 是开源项目,但实际的支付和订阅服务仍由 Stripe 等平台提供,支付处理依然会产生服务费。
ActiveMerchant 适合用统一接口连接不同支付网关,但它主要处理支付调用,并不是完整的 SaaS 订阅生命周期系统。处于 alpha 阶段或维护情况不明的项目,也不适合直接承担生产环境的计费核心。
可选方案
pay gem
pay 是 Rails 应用中常用的开源支付封装,可以连接 Stripe、Braintree、Paddle 等处理器。具体支持哪些功能以及如何调用,取决于安装的版本,应以对应版本的文档为准。
它适用于这些场景:
- 希望在 Rails 模型中统一管理支付客户。
- 需要处理订阅、单次支付、退款和 webhook。
- 将来可能更换支付处理器,或同时接入多个处理器。
- 可以接受由第三方平台保管支付信息并执行扣款。
pay 能减少 Rails 端的重复代码,但套餐设计、权限控制、财务对账和税务合规仍需自行处理。
直接使用 Stripe Billing
如果业务主要依赖 Stripe,直接使用官方 stripe gem 通常更清晰,也能使用 Stripe 提供的完整功能,包括:
- 固定周期订阅和按量订阅。
- 免费试用。
- 套餐升降级和按比例计费。
- 优惠券与折扣。
- 发票及支付失败重试。
- Customer Portal。
- 税费相关功能。
相应的代价是,应用会更深地绑定 Stripe 的对象模型。
Paddle
Paddle 适合希望采用 Merchant of Record 模式的 SaaS。在这种模式下,平台通常会承担更多与销售税、付款和开票有关的工作,但实际责任范围仍取决于合同、销售地区和当前产品条款。
Rails 端仍需处理 webhook、账号绑定、权限启停和内部订阅状态。
Braintree
Braintree 支持客户、支付方式和周期性订阅。如果现有业务已经在使用 PayPal 或 Braintree,它会是一个合理的选择。接入前需要确认目标国家、币种、支付方式和订阅功能能否满足业务需求。
Recurly、Chargebee 等订阅管理平台
这类平台位于应用和支付网关之间,一般提供较完整的订阅管理、账单、催收和运营后台。它们更适合计费规则复杂,或需要财务和运营人员直接管理订阅的团队。
缺点是成本较高,数据模型也会与平台形成较强的绑定。它们是托管服务,不属于纯开源方案。
推荐的实现结构
不要只在 users 表中增加一个 subscribed 布尔字段。订阅记录至少应包含:
- 本地用户或组织 ID。
- 支付平台名称。
- 平台 customer ID。
- 平台 subscription ID。
- 套餐或 price ID。
- 当前状态。
- 当前计费周期结束时间。
- 是否在周期结束时取消。
- 最近一次 webhook 事件信息。
例如:
class CreateBillingSubscriptions < ActiveRecord::Migration[7.1]
def change
create_table :billing_subscriptions do |t|
t.references :account, null: false, foreign_key: true
t.string :provider, null: false
t.string :provider_customer_id, null: false
t.string :provider_subscription_id, null: false
t.string :provider_price_id, null: false
t.string :status, null: false
t.datetime :current_period_end
t.boolean :cancel_at_period_end, null: false, default: false
t.timestamps
end
add_index :billing_subscriptions,
[:provider, :provider_subscription_id],
unique: true,
name: "idx_billing_subscriptions_provider_id"
end
end
状态字段应保留支付平台返回的真实状态,例如 trialing、active、past_due、canceled。应用是否开放功能,可以根据这些状态另行计算:
class BillingSubscription < ApplicationRecord
belongs_to :account
ACCESSIBLE_STATUSES = %w[trialing active].freeze
def access_allowed?
status.in?(ACCESSIBLE_STATUSES)
end
end
是否允许处于 past_due 状态的用户继续使用一段时间,是业务层面的决定,不应交给支付 SDK 隐式处理。
使用 Stripe 官方 SDK 创建订阅
安装 SDK:
# Gemfile
gem "stripe"
配置密钥:
# config/initializers/stripe.rb
Stripe.api_key = Rails.application.credentials.dig(:stripe, :secret_key)
创建客户和订阅:
customer = Stripe::Customer.create(
{
email: account.owner.email,
metadata: {
account_id: account.id.to_s
}
},
{
idempotency_key: "account-#{account.id}-customer"
}
)
subscription = Stripe::Subscription.create(
{
customer: customer.id,
items: [
{ price: params.require(:price_id) }
],
metadata: {
account_id: account.id.to_s
}
},
{
idempotency_key: "account-#{account.id}-subscription-#{request.request_id}"
}
)
生产代码不能直接信任客户端提交的任意 price_id。应先将套餐映射到服务端允许销售的价格:
ALLOWED_PRICES = {
"starter_monthly" => ENV.fetch("STRIPE_STARTER_MONTHLY_PRICE_ID"),
"pro_monthly" => ENV.fetch("STRIPE_PRO_MONTHLY_PRICE_ID")
}.freeze
price_id = ALLOWED_PRICES.fetch(params.require(:plan))
采集支付信息时,更适合使用 Stripe Checkout 或类似的托管支付页面,避免银行卡数据经过 Rails 服务器。
webhook 必须作为订阅状态依据
用户支付后可能直接关闭页面,浏览器回调也可能被伪造。因此,订阅状态应通过经过签名验证的 webhook 更新。
下面是一个简化示例:
class StripeWebhooksController < ActionController::API
def create
payload = request.raw_post
signature = request.headers["Stripe-Signature"]
event = Stripe::Webhook.construct_event(
payload,
signature,
Rails.application.credentials.dig(:stripe, :webhook_secret)
)
case event.type
when "customer.subscription.created",
"customer.subscription.updated",
"customer.subscription.deleted"
sync_subscription(event.data.object)
end
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request
end
private
def sync_subscription(remote)
account_id = remote.metadata["account_id"]
return if account_id.blank?
subscription = BillingSubscription.find_or_initialize_by(
provider: "stripe",
provider_subscription_id: remote.id
)
subscription.update!(
account_id: account_id,
provider_customer_id: remote.customer,
provider_price_id: remote.items.data.first&.price&.id,
status: remote.status,
current_period_end: period_end_for(remote),
cancel_at_period_end: remote.cancel_at_period_end
)
end
def period_end_for(remote)
timestamp = remote.items.data.map(&:current_period_end).compact.max
Time.zone.at(timestamp) if timestamp
end
end
Stripe API 对象的字段结构可能随着 API 版本和计费模式变化,周期结束时间所在的位置尤其需要注意。上面的代码只用于说明同步方式。部署前,必须根据账号锁定的 Stripe API 版本和实际收到的 webhook JSON 校验字段。
还应记录已经处理过的 event.id,保证 webhook 处理具有幂等性。支付平台可能重复发送事件,事件也不一定按顺序到达。
生产环境检查清单
不保存银行卡信息
除非团队有能力承担相应的 PCI DSS 范围,否则不要让原始卡号、CVC 或完整支付凭据经过 Rails 服务器。应优先使用托管结账页,或支付平台提供的前端组件。
区分订阅状态和功能权限
订阅状态由支付系统提供,功能权限由业务规则决定,不要把两者合并成一个布尔字段。例如,订阅已经取消但尚未到期时,用户可能仍应继续获得服务。
处理重复请求
创建客户、订阅和退款时都应设置幂等键,否则网络重试可能造成重复订阅或重复扣款。
考虑 webhook 乱序
不能假定事件会按照发生顺序到达。同步时可以重新查询支付平台上的当前订阅对象,也可以比较事件创建时间和已经处理的版本。
建立对账任务
即使已经接入 webhook,也建议定期从支付平台拉取当前订阅状态,与本地数据进行比较。这样能发现 webhook 配置错误、处理失败或人工修改后台数据造成的偏差。
测试完整生命周期
至少需要覆盖以下情况:
- 首次订阅成功。
- 身份验证或支付确认未完成。
- 试用开始与结束。
- 自动续费成功。
- 扣款失败和恢复。
- 套餐升级、降级及按比例计费。
- 周期结束时取消。
- 立即取消。
- 退款和争议。
- webhook 重复、延迟及乱序。
核对税务与发票要求
税率、发票格式、退款规则和消费者保护要求会因销售地区而异。支付平台提供相关功能,并不代表应用自然满足所有法律和会计要求。必要时,应请税务或法律专业人员确认。
如何选择
新的 Rails SaaS 可以按以下顺序选择方案:
- 如果希望快速上线,同时保留符合 Rails 使用习惯的接口,可以考虑
paygem 加受支持的支付平台。 - 如果主要使用 Stripe,并希望完整控制计费行为,可以直接使用 Stripe Billing 官方 SDK。
- 如果希望平台承担更多跨地区税务及销售主体责任,可以评估 Paddle 等 Merchant of Record 服务。
- 如果计费规则复杂,运营和财务还需要独立后台,可以评估 Recurly、Chargebee 等订阅管理平台。
- 如果已经深度使用 PayPal 或 Braintree,应优先评估 Braintree,避免不必要的系统迁移。
无论采用哪种方案,都要先确认项目近期的维护情况、支付平台目前的支持范围、升级策略和 webhook 机制。开源许可证只能说明代码可以如何使用,不能证明项目已经适合生产环境。对于计费核心,维护活跃度、故障恢复能力、幂等设计和对账能力,比它是否属于 “Rails plugin” 更值得关注。
备注:内容仅供参考。
