PAYATHON 2026

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 可以按以下顺序选择方案:

  1. 如果希望快速上线,同时保留符合 Rails 使用习惯的接口,可以考虑 pay gem 加受支持的支付平台。
  2. 如果主要使用 Stripe,并希望完整控制计费行为,可以直接使用 Stripe Billing 官方 SDK。
  3. 如果希望平台承担更多跨地区税务及销售主体责任,可以评估 Paddle 等 Merchant of Record 服务。
  4. 如果计费规则复杂,运营和财务还需要独立后台,可以评估 Recurly、Chargebee 等订阅管理平台。
  5. 如果已经深度使用 PayPal 或 Braintree,应优先评估 Braintree,避免不必要的系统迁移。

无论采用哪种方案,都要先确认项目近期的维护情况、支付平台目前的支持范围、升级策略和 webhook 机制。开源许可证只能说明代码可以如何使用,不能证明项目已经适合生产环境。对于计费核心,维护活跃度、故障恢复能力、幂等设计和对账能力,比它是否属于 “Rails plugin” 更值得关注。

备注:内容仅供参考。