# Payment failure handling with grace period

- **Pattern:** `ab-002261` (`saas-billing.subscription-mgmt.payment-failure-grace`)
- **Severity:** medium
- **Lifecycle:** active
- **Last modified:** 2026-04-18
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002261
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002261)

## Why it matters

Immediately locking out a user on first payment failure destroys retention for what is often a recoverable situation — expired cards, temporary bank blocks, and 3DS challenges account for a large share of first-failure declines. Stripe's Smart Retries window is typically 4 attempts over 7–28 days. Without handling `invoice.payment_failed`, you either lose customers unnecessarily on first failure, or retain them indefinitely on final failure because you never processed the cancellation event. ISO 25010 fault-tolerance requires the system to degrade gracefully during partial failures.

## Severity rationale

Medium because the failure degrades revenue recovery and user experience significantly but does not create a security vulnerability or data loss.

## Remediation

Handle `invoice.payment_failed` to notify the user without revoking access immediately. Only revoke when Stripe moves the subscription to `past_due` or `canceled` after exhausting retries.

```ts
case 'invoice.payment_failed': {
  const invoice = event.data.object
  await db.user.update({
    where: { stripe_customer_id: invoice.customer as string },
    data: { payment_failed_at: new Date() }
  })
  // Trigger an email: "Your payment failed — please update your card"
  // Do NOT revoke access here — Stripe will retry
  break
}
```

Also handle `customer.subscription.updated` where `status === 'past_due'` to show an in-app banner, and revoke access only when `status === 'canceled'`. Enable Stripe Smart Retries and dunning emails in your Stripe Dashboard to handle the retry cadence automatically.

## Detection

- **ID:** `payment-failure-grace`
- **Severity:** `medium`
- **What to look for:** Look for handling of Stripe's `invoice.payment_failed` or `customer.subscription.updated` events where `status` changes to `past_due`. Check whether the application handles the grace period between first payment failure and final subscription cancellation. Look for user notifications (email triggers, in-app banners) when payment fails. Examine whether the application immediately locks out users on first payment failure or allows continued access during Stripe's retry window.
- **Pass criteria:** Count every payment failure notification channel. The application handles `invoice.payment_failed` (or equivalent) webhook events AND notifies users of payment failure through at least one channel: email (via Stripe email settings or custom webhook-triggered email), in-app notification/banner, or a visual indicator on the billing page showing payment status. Access continues during Stripe's configured retry window (typically 4 attempts over 7-28 days) and is revoked only when the subscription status moves to `past_due` → `canceled`.
- **Fail criteria:** No handling of payment failure events — users are either permanently locked out on first failure or retain access after final cancellation because the event is never processed. Relying solely on Stripe Smart Retries without any user notification mechanism is also a FAIL. A grace period that silently retries without ever informing the user does not satisfy this check.
- **Skip (N/A) when:** No payment integration or no subscription billing (one-time payments only do not have this lifecycle).
- **Detail on fail:** `"No handler for invoice.payment_failed — users are not notified of failed payments"` or `"customer.subscription.updated handler for past_due status immediately revokes access without allowing Stripe's retry window"`
- **Remediation:** Handle the payment failure lifecycle gracefully:

  ```ts
  case 'invoice.payment_failed': {
    const invoice = event.data.object
    // Notify user their payment failed — send email, set in-app flag
    await db.user.update({
      where: { stripe_customer_id: invoice.customer as string },
      data: { payment_failed_at: new Date() }
    })
    // Don't revoke access yet — Stripe will retry
    // Access revokes when subscription status moves to 'past_due' or 'canceled'
    break
  }
  ```

  Configure Stripe's automatic collection settings (Smart Retries + dunning emails) in your Stripe Dashboard to handle the retry cadence.

## External references

- iso-25010 fault-tolerance

Taxons: error-resilience, user-experience

HTML version: https://auditbuffet.com/patterns/ab-002261
