Grace Period and Payment Retry Mechanism

Overview

This document explains how the system handles payment failures, grace periods, and automatic retry mechanisms when subscription payments fail.

Key Concepts

Grace Period

The grace period is a local application-level buffer time that allows users to maintain service access after a payment failure. This is configurable via config/stripe.php:

'grace_period' => 1, // Grace period in days (default: 1 day)

When Grace Period is Set:

  • Triggered when the first invoice.payment_failed webhook is received
  • Subscription status changes to past_due
  • grace_period_end_at is set to: current_time + grace_period_days
  • Users maintain full service access during this period

Purpose:

  • Provides a buffer for temporary payment issues (expired card, insufficient funds, etc.)
  • Allows time for users to update payment methods
  • Prevents immediate service disruption

Stripe Smart Retries

Stripe Smart Retries is Stripe's built-in automatic payment retry mechanism. This is completely independent of the local grace period and is managed entirely by Stripe.

Default Retry Schedule:

  1. Attempt 1: Immediate (on due date)
  2. Attempt 2: 3 days after due date
  3. Attempt 3: 5 days after due date
  4. Attempt 4: 7 days after due date

After Final Retry Failure:

  • Stripe automatically updates subscription status to canceled or unpaid
  • Stripe sends customer.subscription.updated or customer.subscription.deleted webhook
  • Local system updates subscription status to canceled

System Behavior

Timeline Example

Day 0: Payment Due Date
├─ Stripe attempts payment (Attempt 1)
├─ Payment fails
├─ Stripe sends: invoice.payment_failed webhook
└─ System Action:
   ├─ Set subscription.status = 'past_due'
   ├─ Set grace_period_end_at = Day 0 + 1 day (config)
   ├─ Create subscription_histories (type: renewal, payment_status: failed, payment_attempt: 1)
   └─ User maintains service access

Day 1: Grace Period Ends (Local)
└─ Note: User access could be restricted by application logic at this point
   └─ However, Stripe retries continue...

Day 3: Stripe Retry 2
├─ Stripe attempts payment again (Attempt 2)
├─ Payment fails
├─ Stripe sends: invoice.payment_failed webhook
└─ System Action:
   └─ Update subscription_histories (payment_attempt: 2)

Day 5: Stripe Retry 3
├─ Stripe attempts payment again (Attempt 3)
├─ Payment fails
├─ Stripe sends: invoice.payment_failed webhook
└─ System Action:
   └─ Update subscription_histories (payment_attempt: 3)

Day 7: Stripe Retry 4 (Final)
├─ Stripe attempts payment again (Attempt 4)
├─ Payment fails
├─ Stripe automatically cancels subscription
├─ Stripe sends: customer.subscription.updated (status: canceled)
└─ System Action:
   ├─ Update subscription.status = 'canceled'
   ├─ Set canceled_at timestamp
   └─ User access terminated

Implementation Details

When Payment Fails (First Time)

Handler: SubscriptionInvoiceHandler::handleInvoicePaymentFailed()

private function updateSubscriptionToPastDue($subscription, $subscriptionHistory, $session): void
{
    if ($subscription->status->value !== SubscriptionStatus::PastDue->value) {
        // Calculate grace period end based on config
        $gracePeriodDays = config('stripe.grace_period', 1);
        $gracePeriodEnd = Carbon::now()->addDays($gracePeriodDays);
        
        // Update subscription status to PastDue
        $this->statusManager->updateStatus($subscription, SubscriptionStatus::PastDue->value, []);
        
        // Set grace_period_end_at if not already set
        if (!$subscription->grace_period_end_at) {
            $subscription->update([
                'grace_period_end_at' => $gracePeriodEnd->format('Y-m-d H:i:s'),
            ]);
        }
    }
}

Configuration

File: config/stripe.php

return [
    'slug' => 'stripe',
    'config' => [
        'key' => env('STRIPE_KEY'),
        'secret' => env('STRIPE_SECRET'),
        'webhook_secret' => env('STRIPE_WEBHOOK_SECRET'),
    ],
    'currency' => env('CURRENCY'),
    'currency_locale' => env('CURRENCY_LOCALE'),
    'logger' => env('STRIPE_LOGGER'),
    
    // Payment retry configuration (for reference/documentation)
    'payment_retry' => [
        'attempts' => 3, // Stripe retries 3 times after initial attempt
        'interval' => 1, // Documented intervals: 3, 5, 7 days
    ],
    
    // Local grace period before restricting service access
    'grace_period' => 1, // Days (default: 1)
];

Important Notes

Grace Period vs Smart Retries

Feature Grace Period Stripe Smart Retries
Managed By Local Application Stripe
Purpose Service access buffer Automatic payment recovery
Duration Configurable (default: 1 day) Fixed (7 days total)
Can be Disabled Yes (config) No (Stripe feature)
Triggers First payment failure Every payment failure
User Impact Controls local access Controls subscription status

Key Considerations

  1. Independent Systems:

    • Grace period is a local application feature
    • Stripe Smart Retries is a Stripe platform feature
    • They operate independently
  2. Access Control:

    • Application can restrict access after grace period expires
    • Subscription remains in past_due status until Stripe cancels it
    • Final cancellation happens only after all Stripe retries fail
  3. Configuration:

    • Grace period: Configurable via config/stripe.php
    • Stripe retries: Managed by Stripe dashboard settings (cannot be customized via config file)
  4. Best Practice:

    • Set grace period shorter than Stripe's retry window (1 day < 7 days)
    • This gives users time to fix payment issues while Stripe continues retrying
    • If grace period is too long, users might get free access beyond intended buffer

Related Webhooks

Webhook Event When Triggered System Action
invoice.payment_failed Each retry failure - Set/update past_due status
- Set grace_period_end_at (first time)
- Increment payment_attempt counter
customer.subscription.updated Final status change - Update subscription status to canceled
- Set canceled_at timestamp
customer.subscription.deleted Subscription deleted - Update subscription status to canceled
- Set canceled_at timestamp

Database Schema

subscriptions table

CREATE TABLE subscriptions (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    -- ... other fields ...
    status ENUM('unpaid', 'active', 'past_due', 'canceled') NOT NULL,
    deadline_at TIMESTAMP NULL,
    grace_period_end_at TIMESTAMP NULL COMMENT 'Set when status changes to past_due',
    canceled_at TIMESTAMP NULL,
    -- ... other fields ...
);

subscription_histories table

CREATE TABLE subscription_histories (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    subscription_id BIGINT NOT NULL,
    type ENUM('new', 'renewal', 'change', 'cancel', 'resume') NOT NULL,
    payment_status ENUM('pending', 'paid', 'failed', 'refunded', 'na') NOT NULL,
    payment_attempt INT DEFAULT 0 COMMENT 'Number of failed payment attempts',
    -- ... other fields ...
);

Testing Scenarios

Scenario 1: Temporary Payment Failure (Resolves Before Grace Period Ends)

1. Payment fails on Day 0 → grace_period_end_at set to Day 1
2. Stripe retries on Day 3 → Payment succeeds
3. invoice.paid webhook received
4. System updates subscription to 'active' status
5. User maintains uninterrupted access

Scenario 2: Payment Failure Beyond Grace Period

1. Payment fails on Day 0 → grace_period_end_at set to Day 1
2. Grace period expires on Day 1 → Application restricts user access
3. Stripe continues retrying (Day 3, 5, 7)
4. All retries fail by Day 7
5. Stripe cancels subscription
6. System updates to 'canceled' status

Scenario 3: User Updates Payment Method During Grace Period

1. Payment fails on Day 0 → grace_period_end_at set to Day 1
2. User updates payment method on Day 1
3. Stripe retry on Day 3 succeeds with new payment method
4. Subscription returns to 'active' status
5. Grace period cleared

See Also