PAYATHON 2026

Automated Recurring Billing 真的是全自动的吗?

支付阿杰

结论

Automated Recurring Billing(ARB)会按照订阅计划,由支付网关自动发起扣款。正常情况下,Rails 应用无须使用 cron job 定期检查到期日期。使用 ARB 后,也不应再由应用主动扣款,否则可能造成重复收费。

这里的“自动”仅指周期性扣款。应用仍要负责:

  • 创建、修改和取消订阅
  • 保存网关返回的 customer、subscription 等标识
  • 接收 webhook 或 Silent Post 等异步通知
  • 记录每笔交易及其最终状态
  • 处理扣款失败、退款、拒付和订阅到期
  • 定期核对本地数据与支付网关数据

简单来说,支付网关负责安排扣款,应用负责维护业务状态和账务记录。

推荐的处理流程

用户购买订阅时,Rails 应用先在支付网关中创建 recurring billing subscription,并保存网关返回的订阅 ID。此后的扣款由支付网关按照设定的金额和周期自动执行。

每次扣款成功或失败后,应用通过 webhook 接收结果:

  1. 验证通知是否来自支付网关。
  2. 使用网关的 transaction ID 进行幂等检查。
  3. 创建或更新本地交易记录。
  4. 根据扣款结果更新订阅状态。
  5. 确认支付成功后,再延长用户的服务期限。

不要仅根据“预计扣款日”提前更新 paid_through。扣款可能因余额不足、银行卡过期、风控或网络问题而失败。

Rails 中的数据设计

本地至少要保存网关标识、订阅状态和交易记录。例如:

# subscriptions
gateway_customer_id
gateway_subscription_id
status
amount_cents
currency
current_period_end

# payment_transactions
subscription_id
gateway_transaction_id
amount_cents
currency
status
paid_at
raw_event_id

金额适合用最小货币单位的整数保存。例如,1000 表示 10.00,这样可以避免浮点数精度问题。

current_period_end 表示当前已经确认付款的服务期终点,不应作为下一次主动扣款的唯一依据。实际扣款计划仍以支付网关中的订阅配置为准。

创建订阅示例

不同支付网关使用的 SDK 和参数并不相同。下面的代码只说明 Rails 应用需要承担哪些职责,并非某个网关的完整 API:

class SubscriptionsController < ApplicationController
  def create
    result = PaymentGateway.create_subscription(
      customer_token: current_user.gateway_customer_token,
      amount_cents: 2_900,
      currency: "USD",
      interval: "month"
    )

    subscription = current_user.subscriptions.create!(
      gateway_subscription_id: result.subscription_id,
      amount_cents: 2_900,
      currency: "USD",
      status: "pending"
    )

    render json: {
      id: subscription.id,
      status: subscription.status
    }, status: :created
  end
end

订阅只需创建一次。后续每月扣款交给网关调度,Rails 不需要定时重复调用单次付款接口。

接收扣款结果

支付网关发起周期扣款后,应用应通过异步事件更新本地记录。下面仍是通用结构,事件名称和签名验证方式需要替换为实际网关的实现:

class PaymentWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

  def create
    payload = request.raw_post
    signature = request.headers["Payment-Signature"]

    event = PaymentGateway.verify_webhook!(
      payload: payload,
      signature: signature
    )

    case event.type
    when "recurring_payment.succeeded"
      record_successful_payment(event)
    when "recurring_payment.failed"
      record_failed_payment(event)
    when "subscription.cancelled"
      mark_subscription_cancelled(event)
    end

    head :ok
  end

  private

  def record_successful_payment(event)
    subscription = Subscription.find_by!(
      gateway_subscription_id: event.subscription_id
    )

    transaction = subscription.payment_transactions.find_or_initialize_by(
      gateway_transaction_id: event.transaction_id
    )

    return if transaction.persisted? && transaction.status == "succeeded"

    Subscription.transaction do
      transaction.update!(
        amount_cents: event.amount_cents,
        currency: event.currency,
        status: "succeeded",
        paid_at: event.paid_at,
        raw_event_id: event.id
      )

      subscription.update!(
        status: "active",
        current_period_end: event.period_end
      )
    end
  end

  def record_failed_payment(event)
    subscription = Subscription.find_by!(
      gateway_subscription_id: event.subscription_id
    )

    subscription.payment_transactions.find_or_create_by!(
      gateway_transaction_id: event.transaction_id
    ) do |transaction|
      transaction.amount_cents = event.amount_cents
      transaction.currency = event.currency
      transaction.status = "failed"
      transaction.raw_event_id = event.id
    end

    subscription.update!(status: "past_due")
  end

  def mark_subscription_cancelled(event)
    Subscription.find_by!(
      gateway_subscription_id: event.subscription_id
    ).update!(status: "cancelled")
  end
end

应用必须验证 webhook 签名,不能因为 HTTP 请求的格式正确,就直接修改付款状态。处理过程也要具备幂等性,因为支付网关可能重复发送同一个事件。

cron job 是否还有必要

定时任务可以保留,但应当用于对账和修复状态,而不是发起常规的周期扣款。例如:

class ReconcileSubscriptionsJob < ApplicationJob
  queue_as :default

  def perform
    Subscription.where(status: %w[pending active past_due])
                .find_each do |subscription|
      remote = PaymentGateway.retrieve_subscription(
        subscription.gateway_subscription_id
      )

      subscription.update!(
        status: remote.status,
        current_period_end: remote.current_period_end
      )
    end
  end
end

这类任务可以补救 webhook 延迟、通知丢失或应用短暂不可用等问题。运行频率应根据网关的 API 限流、事件投递机制和业务时效要求来确定。

什么时候适合自行调度扣款

如果支付网关不提供 recurring billing,或者网关的订阅功能无法表达业务所需的计费模型,才需要自行调度扣款。例如:

  • 每个周期的金额由实时用量决定
  • 扣款日期受复杂的业务规则控制
  • 一个账期需要合并多项动态费用
  • 需要统一调度多个支付渠道
  • 网关只提供 tokenized payment,不提供订阅管理

在这些情况下,可以使用 cron job 或后台任务决定扣款时间,但应用还要处理幂等键、并发执行、失败重试、重复收费、银行卡失效、退款、拒付和审计记录。其复杂度远高于检查日期后调用一次付款接口。

注意事项

  • 不要同时启用网关自动扣款和本地 cron 主动扣款。
  • 不要把“已经发起扣款”当成“支付成功”。
  • 不要只保存 paid_through 和金额。还要保存订阅 ID、交易 ID、状态、币种及时间。
  • webhook 返回成功前,应尽快持久化事件。耗时的业务处理可以交给后台任务。
  • 使用 transaction ID 或 event ID,防止重复通知生成两笔本地交易。
  • 取消本地订阅时,也要同步取消支付网关中的订阅。
  • 对账任务只能用于补偿,不能取代 webhook 和完整的交易记录。
  • ARB 的通知能力、重试策略和状态定义因支付网关而异,应以所用网关的当前文档为准。

如果使用的支付网关支持 Automated Recurring Billing,应用应负责创建和管理订阅,网关按计划扣款。应用通过异步通知记录结果,并用定时对账任务补上遗漏的数据。

备注:内容仅供参考。