PAYATHON 2026

Ruby on Rails 延迟扣款应使用 recurring payment 吗?

支付小周

结论

这不是典型的 recurring payment。

定期付款一般会按照固定周期或订阅规则自动扣款。这里则由“预订数量达到指定额度”这个业务事件触发,扣款时间并不固定。更合适的流程是:

  1. 用户预订时授权保存 payment method,暂不扣款。
  2. 系统保存支付服务商返回的支付方式标识,不能保存原始卡号或 CVV。
  3. 预订数量达到额度后,由服务端创建一笔 off-session 付款。
  4. 如果银行要求再次验证,通知用户返回应用完成验证。

这种模式通常称为 card-on-file、credential-on-file 或 merchant-initiated payment。具体名称和合规要求要看支付服务商、收单机构以及用户所在地区。

推荐的支付流程

1. 预订时收集并验证支付方式

不要创建一笔长期处于“授权但未捕获”状态的付款。银行卡预授权通常有有效期,而预订数量何时达到额度并不确定,真正扣款时授权可能已经失效。

应使用支付服务商提供的“仅保存支付方式”功能。以 Stripe 为例,可以使用 SetupIntent:

setup_intent = Stripe::SetupIntent.create(
  customer: current_user.stripe_customer_id,
  usage: "off_session",
  metadata: {
    user_id: current_user.id
  }
)

前端通过支付服务商提供的 SDK 收集卡信息,并完成必要的身份验证。卡号、有效期和 CVV 不应经过 Rails 应用服务器。

SetupIntent 成功后,只保存支付服务商返回的标识,例如:

current_user.update!(
  default_payment_method_id: setup_intent.payment_method
)

数据库中可以保存:

  • 支付服务商的 customer ID
  • payment method ID
  • 卡品牌和尾号等非敏感展示信息
  • 用户同意未来扣款的时间、协议版本和相关记录

不要保存:

  • 完整卡号
  • CVV/CVC
  • 磁道数据
  • 未经支付服务商令牌化处理的支付凭证

2. 创建预订记录

保存支付方式后,再创建预订。预订和实际付款应当是两个独立的业务对象:

reservation = current_user.reservations.create!(
  product: product,
  quantity: params[:quantity],
  unit_price_cents: product.price_cents,
  status: "pending"
)

最好在预订时固定价格,或者明确约定价格计算规则,否则以后扣款时容易产生争议。还需要向用户说明:

  • 什么条件会触发扣款
  • 最多扣多少钱
  • 是否可能拆单或调整数量
  • 取消预订的截止条件
  • 扣款前是否发送通知

“保存支付方式”不代表用户同意在任意时间扣取任意金额。授权页面和服务条款必须写清楚触发条件与金额规则。

3. 达到额度后执行扣款

有效预订总量达到阈值后,通过后台任务扣款。不要在普通 HTTP 请求中完成整个流程,因为支付请求可能超时、重试,也可能需要等待异步确认。

以 Stripe 为例:

class ChargeReservationJob < ApplicationJob
  queue_as :default

  def perform(reservation_id)
    reservation = Reservation.find(reservation_id)

    reservation.with_lock do
      return unless reservation.status == "pending"

      amount = reservation.quantity * reservation.unit_price_cents

      intent = Stripe::PaymentIntent.create(
        {
          amount: amount,
          currency: "cny",
          customer: reservation.user.stripe_customer_id,
          payment_method: reservation.user.default_payment_method_id,
          off_session: true,
          confirm: true,
          metadata: {
            reservation_id: reservation.id
          }
        },
        {
          idempotency_key: "reservation-charge-#{reservation.id}"
        }
      )

      reservation.update!(
        payment_intent_id: intent.id,
        status: payment_status(intent.status)
      )
    end
  end

  private

  def payment_status(intent_status)
    case intent_status
    when "succeeded"
      "paid"
    when "requires_action"
      "authentication_required"
    else
      "payment_pending"
    end
  end
end

示例中的状态映射仅用于说明业务逻辑。实际实现时,应覆盖支付服务商返回的完整状态集合,不能把未知状态直接当作付款失败。

处理需要再次验证的付款

即使用户已经保存支付方式,之后的扣款也不一定能在后台直接完成。发卡行可能因为 SCA、3-D Secure、风控规则或交易金额,要求持卡人重新验证。

遇到这种情况时,应当:

  1. 将预订标记为 authentication_required。
  2. 通过站内通知、邮件或短信联系用户。
  3. 让用户返回前端完成支付验证。
  4. 等待支付服务商的 webhook 确认最终结果。

一次后台扣款请求返回错误,并不一定表示用户拒绝付款,也不应该因此不断自动重试。系统需要区分:

  • 需要用户验证
  • 余额不足或被发卡行拒绝
  • 支付方式已过期
  • 临时网络或服务错误
  • 已经成功,但本地尚未收到通知

使用 webhook 更新最终状态

付款的最终结果应以经过签名验证的支付服务商 webhook 为准,不能只依赖创建付款时收到的同步响应:

class StripeWebhooksController < ApplicationController
  skip_before_action :verify_authenticity_token

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

    event = Stripe::Webhook.construct_event(
      payload,
      signature,
      ENV.fetch("STRIPE_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(intent)
    reservation = Reservation.find_by(
      payment_intent_id: intent.id
    )

    reservation&.update!(status: "paid")
  end

  def handle_payment_failed(intent)
    reservation = Reservation.find_by(
      payment_intent_id: intent.id
    )

    reservation&.update!(status: "payment_failed")
  end
end

生产环境还应保存 webhook event ID,并以幂等方式处理重复事件。支付服务商可能重复投递同一事件,事件到达的顺序也未必与创建顺序一致。

数据模型建议

状态可以拆分得更清楚:

class Reservation < ApplicationRecord
  enum :status, {
    pending: "pending",
    charging: "charging",
    authentication_required: "authentication_required",
    paid: "paid",
    payment_failed: "payment_failed",
    cancelled: "cancelled"
  }
end

建议分别记录:

  • reservation_id
  • user_id
  • product_id
  • quantity
  • unit_price_cents
  • currency
  • payment_method_id
  • payment_intent_id
  • status
  • charge_attempts
  • failure_code
  • failure_message
  • authorized_at
  • paid_at

如果允许用户更换默认支付方式,还要明确已有预订使用“预订时选定的支付方式”,还是“扣款时的默认支付方式”。通常,前一种方式更容易预测,因此可以把 payment_method_id 直接记录在预订上。

额度判断与并发控制

多个用户可能同时完成预订。不能先统计数量,再分别创建记录,否则可能重复触发成团和扣款。

额度检查需要通过数据库事务、行锁或唯一约束保护:

Campaign.transaction do
  campaign = Campaign.lock.find(campaign_id)

  total = campaign.reservations.pending.sum(:quantity)

  if total >= campaign.target_quantity && campaign.status == "collecting"
    campaign.update!(status: "charging")
    ChargeCampaignJob.perform_later(campaign.id)
  end
end

后台任务本身也必须具备幂等性,因为任务队列可能重复执行。每个预订应当只有一个确定的付款标识和幂等键。

其他注意事项

  • 不要把这种交易当成订阅。错误使用 subscription API 会带来不必要的周期、账单和取消语义。
  • 不要长期采用“先授权、以后捕获”的方式。只有确定扣款会发生在支付服务商允许的授权窗口内,才适合使用 authorize/capture。
  • 可以在扣款前发送提醒,但提醒不能代替用户最初的明确授权。
  • 用户删除支付方式或取消预订时,需要同步更新可扣款状态。
  • 需要事先确定部分用户扣款失败后的处理规则,例如是否继续履约、释放库存,或者允许补充支付。
  • 金额必须由服务端根据已经固定的价格计算,不能信任前端提交的金额。
  • 金额应使用整数形式的最小货币单位保存,例如分,以免产生浮点数误差。
  • PCI DSS、SCA、消费者保护和自动扣款规则会因地区、卡组织及支付服务商而异。上线前,应核对所用服务商对 off-session 交易的具体要求。

整体流程是:用户预订时保存支付方式,并授权未来按约定条件扣款;预订数量达到业务额度后,系统创建一笔独立的后台付款;同时为再次验证、扣款失败、重复通知和并发触发准备完整的状态处理。

备注:内容仅供参考。