代码 Stripe 订阅续费扣款失败:催缴锚点选 invoice.payment_failed,不是 charge.failed

2026-09-23 09:01:19

Stripe 订阅续费扣款失败:催缴锚点选 invoice.payment_failed,不是 charge.failed

问题场景

SaaS 订阅制,用户信用卡到期或额度不足,续费扣款失败。Stripe 不会替你通知用户,也不会自动降权。

如果系统只监听 payment_intent.succeeded / charge.failed,结果通常是两种:扣款失败但用户 access 照旧保留,等于白嫖;或者通知重复发、access 状态错乱。这两种都不好排查,因为问题不在支付本身,而在你选的催缴锚点。

前提:下面讨论的是 Stripe Billing 的订阅模式(invoice 驱动),订阅的催缴、重试、恢复都围绕 invoice 事件走。如果你只是用 PaymentIntent 收一次性款,或者自己维护周期扣款逻辑、不走 Billing 的 invoice,本文的事件与字段不适用。

事件锚点

以下事件口径来自官方文档(docs.stripe.com/billing/subscriptions/webhooks 与 smart-retries):

  • invoice.payment_failed:订阅催缴/recovery 的正确锚点。data.object 是 invoice,带 invoice + subscription 上下文,以及 attempt_countnext_payment_attempt
  • invoice.paid:付款成功后恢复访问权限、清掉催缴状态的事件。
  • invoice.updated:付款成功或失败都会发;成功时 paid=truestatus=paid;失败时 paid=falsestatus 仍为 open。失败同时会触发 invoice.payment_failed
  • charge.failed:更底层的产物,不是订阅 recovery 的推荐触发点。社区经验(rexautomaton.com)明确建议不要拿 charge.failed 当订阅催缴锚点,会缺 subscription 上下文。
  • invoice.finalization_failed:如果账单无法定稿,会发这个事件。账单未定稿就无法收款,但订阅仍是 active——会出现用户还能用、你却收不到钱的空窗,必须处理。
  • invoice.payment_action_required:需要用户 3DS 验证时发。要取 PaymentIntent 的 client_secretconfirmCardPayment 让用户补验证。

关键字段

  • attempt_countinvoice.payment_failed 里,表示到目前为止已尝试的次数。
  • next_payment_attempt:invoice 上表示下次收款时间。这里有个坑(官方 Warning):使用 automations 的用户,next_payment_attempt 不再出现在 invoice.payment_failed,而是出现在 invoice.updated。所以调度催缴要同时订阅 invoice.payment_failedinvoice.updated
  • payment_intent.last_payment_error / invoice.last_payment_error:拿发卡行的 decline_codetype,据此区分硬拒绝/软拒绝。

硬拒绝 vs 软拒绝

官方口径。硬拒绝码(Stripe 不会自动重试):

incorrect_number
lost_card
pickup_card
stolen_card
revocation_of_authorization
revocation_of_all_authorizations
authentication_required
highest_risk_level
transaction_not_allowed

硬拒绝时的行为:排期的重试仍会继续、attempt_count 仍会递增,但只有在检测到新的支付方式后重试才会真正执行;未执行的重试不会产生新的 Charge。

另外这些情况 Stripe 也不会重试:没有可用支付方式;发卡行返回硬拒绝码;卡是印度发行的(India-issued);Stripe Connect 账户已断开。

软拒绝(如余额不足 insufficient_funds):可以自动重试。

重试策略

官方口径:

  • Smart Retries:用 AI 选最佳重试时间;按次数 + 最长时长配置,周期可选 1 周 / 2 周 / 3 周 / 1 个月 / 2 个月;官方推荐默认 8 tries within 2 weeks(2 周内 8 次)。
  • 自定义重试:最多配置 3 次重试,每次指定距上次的重试天数。
  • 本地支付方式重试(默认不开,需显式开启;开启也可能失败,Stripe 不承担损失):
    • ACH Direct Debit:仅 insufficient_funds 可重试,最多 2 次,最长 40 天。
    • ACSS Direct Debit:最多 1 次,30 天。
    • Australia BECS Direct Debit:最多 4 次,30 天。
    • Bacs Direct Debit:最多 2 次,30 天。
    • New Zealand BECS Direct Debit:最多 1 次,30 天。
    • SEPA Direct Debit:最多 2 次,30 天。

恢复失败后订阅怎么走

官方口径,三选一:

  • Cancel the subscription:达到重试计划最大天数后变 canceled
  • Mark as unpaid:变 unpaid,之后仍继续生成 invoice 但保持 draft
  • Leave past-due:保持 past_due,继续生成 invoice 并按重试设置扣客户。

最终一次尝试之后,Stripe 不再做任何支付尝试;改订阅设置只影响未来的重试。

支付方式选择顺序

官方口径,重试时按此顺序取第一个可用支付方式:

  1. subscription.default_payment_method
  2. subscription.default_source
  3. customer.invoice_settings.default_payment_method(Customer 对象)/ configuration.customer.billing.default_payment_method(customer-configured Account 对象)
  4. legacy customer.default_source

坑:扣款失败后更新支付方式,必须更新当初失败的那个字段。例如订阅有 default_payment_method,你只更新了 customer.invoice_settings.default_payment_method,Stripe 仍会继续用订阅的 default_payment_method 重试。

Webhook 投递语义

官方口径:

  • live 模式:端点响应不对,Stripe 以指数退避持续重试最长 3 天;sandbox 模式几次几小时内重试 3 次。
  • 至少一次投递:会有重复,也会乱序(官方明说不保证顺序)。必须先验签,再按 event.id 幂等去重,只做持久化写入后再回 2xx,重活丢异步队列。官方要求:在执行任何可能超时的复杂逻辑之前,先快速返回 2xx。

站内已有经验(#7362 用户付一次钱,系统入账两次):去重记录要持久化、唯一约束要防 NULL、并发靠 DB 约束兜底,别先查后插。

落地清单

  1. 订阅催缴锚点用 invoice.payment_failed;恢复访问用 invoice.paid
  2. 同时订阅 invoice.payment_failed + invoice.updatednext_payment_attempt
  3. 每次失败读 attempt_count,按它决定通知节奏,别自己猜重试时间(与 Smart Retries 冲突)。
  4. decline_code 分硬/软拒绝;硬拒绝别硬等重试,直接引导换卡。
  5. 门控:sub status 为 past_due / unpaid 时降权;invoice.paid 后恢复。注意 past_dueunpaid 的语义差异(unpaid 之后 invoice 只进 draft)。
  6. 处理 invoice.finalization_failedinvoice.payment_action_required 两个易漏事件。
  7. 验签用原始 body(见站内 #7416 Stripe Webhook 验签失败),幂等按 event.id

官方文档链接

注:本文未在真实生产环境逐项实测,字段与限制以官方文档口径为准;Stripe 侧配置项(automations/Retry 设置)随账号与 API 版本可能不同,动手前请以自己 Dashboard 的文案为准。

推荐文章

程序员茄子在线接单