Ruby on Rails 延迟扣款应使用 recurring payment 吗?
结论
这不是典型的 recurring payment。
定期付款一般会按照固定周期或订阅规则自动扣款。这里则由“预订数量达到指定额度”这个业务事件触发,扣款时间并不固定。更合适的流程是:
- 用户预订时授权保存
payment method,暂不扣款。 - 系统保存支付服务商返回的支付方式标识,不能保存原始卡号或 CVV。
- 预订数量达到额度后,由服务端创建一笔
off-session付款。 - 如果银行要求再次验证,通知用户返回应用完成验证。
这种模式通常称为 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、风控规则或交易金额,要求持卡人重新验证。
遇到这种情况时,应当:
- 将预订标记为
authentication_required。 - 通过站内通知、邮件或短信联系用户。
- 让用户返回前端完成支付验证。
- 等待支付服务商的 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_iduser_idproduct_idquantityunit_price_centscurrencypayment_method_idpayment_intent_idstatuscharge_attemptsfailure_codefailure_messageauthorized_atpaid_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交易的具体要求。
整体流程是:用户预订时保存支付方式,并授权未来按约定条件扣款;预订数量达到业务额度后,系统创建一笔独立的后台付款;同时为再次验证、扣款失败、重复通知和并发触发准备完整的状态处理。
备注:内容仅供参考。
