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_failedwebhook is received - Subscription status changes to
past_due grace_period_end_atis 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:
- Attempt 1: Immediate (on due date)
- Attempt 2: 3 days after due date
- Attempt 3: 5 days after due date
- Attempt 4: 7 days after due date
After Final Retry Failure:
- Stripe automatically updates subscription status to
canceledorunpaid - Stripe sends
customer.subscription.updatedorcustomer.subscription.deletedwebhook - 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
-
Independent Systems:
- Grace period is a local application feature
- Stripe Smart Retries is a Stripe platform feature
- They operate independently
-
Access Control:
- Application can restrict access after grace period expires
- Subscription remains in
past_duestatus until Stripe cancels it - Final cancellation happens only after all Stripe retries fail
-
Configuration:
- Grace period: Configurable via
config/stripe.php - Stripe retries: Managed by Stripe dashboard settings (cannot be customized via config file)
- Grace period: Configurable via
-
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