Subscription Resumption
Description
This document describes the process for handling the resumption of a subscription that was previously scheduled for cancellation. This flow is initiated when a user, who had previously chosen to cancel their subscription at the end of the billing period, decides to keep it. The action is typically performed via an external interface like the Stripe billing portal.
The backend system detects this change through the customer.subscription.updated webhook from Stripe. When this event indicates that the cancel_at_period_end flag has been unset, the system reactivates the subscription locally by reverting the cancellation status and removing the pending cancellation record, ensuring it will continue to auto-renew as normal.
Prerequisites:
- A user subscription has been scheduled for cancellation (e.g.,
canceled_atis not null). - A
scheduled_cancellationrecord exists in thesubscription_historiestable. - The user initiates a "resume" or "reactivate" action via a Stripe-integrated interface before the billing period ends.
Process Flow Diagram
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == NODES DEFINITION ==
ClientAction[User Action via Stripe Portal / Frontend]
%% Layer components
subgraph ApiControllerLayer["API Controller Layer"]
WebhookController[WebhookController]
end
subgraph ApiServiceLayer["API Service Layer"]
SubscriptionService(SubscriptionService)
LifecycleHandler(SubscriptionLifeCycleHandler)
end
subgraph DatabaseLayer["Database Layer"]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
WebhookEventsDB[(stripe_webhook_events)]
end
subgraph ExternalServices["External Services"]
StripeAPI((Stripe API))
end
%% == STEPS DEFINITION ==
Step1["<div style='text-align: center'><span style='display: inline-block; background-color: #6699cc !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>1</span><p style='margin-top: 8px'>Resume Subscription on Stripe</p></div>"]
StepW1["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W1</span><p style='margin-top: 8px'>Send Webhook Event<br/>(customer.subscription.updated)</p></div>"]
StepW2["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W2</span><p style='margin-top: 8px'>Verify & Log Webhook</p></div>"]
StepW3["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W3</span><p style='margin-top: 8px'>Process Event with Lifecycle Handler</p></div>"]
StepW4["<div style='text-align: center'><span style='display: inline-block; background-color: #ff9966 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W4</span><p style='margin-top: 8px'>Revert Cancellation in DB</p></div>"]
%% == CONNECTIONS ==
ClientAction --- Step1 --> StripeAPI
StripeAPI --- StepW1 --> WebhookController
WebhookController --- StepW2 --> WebhookEventsDB
WebhookController --- StepW3 --> SubscriptionService
SubscriptionService --> LifecycleHandler
LifecycleHandler --- StepW4
StepW4 --> SubscriptionDB
StepW4 --> HistoryDB
%% == STYLING ==
style ClientAction fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style ApiControllerLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style ApiServiceLayer fill:#f0f8e6,stroke:#339933,stroke-width:2px
style DatabaseLayer fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
style ExternalServices fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
style Step1 fill:transparent,stroke:transparent,stroke-width:1px
style StepW1 fill:transparent,stroke:transparent,stroke-width:1px
style StepW2 fill:transparent,stroke:transparent,stroke-width:1px
style StepW3 fill:transparent,stroke:transparent,stroke-width:1px
style StepW4 fill:transparent,stroke:transparent,stroke-width:1px
Use Cases
Case 1: Resuming a Scheduled Cancellation
Description
If a user decides to keep their subscription before the cancellation date, they can resume it. This action triggers a customer.subscription.updated webhook where the cancel_at_period_end property is changed from true to false. The system listens for this specific change to reactivate the subscription and clean up the now-irrelevant cancellation records.
Sequence Diagram
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant SubSvc as SubscriptionService
participant LifecycleHandler as SubscriptionLifeCycleHandler
participant DB as Database
Note over StripeAPI, DB: Subscription Resumption Flow
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: Payload has cancel_at_period_end = false<br/>Previous attributes had cancel_at_period_end = true
Webhook->>SubSvc: handleSubscriptionUpdated(data, previous_attributes)
SubSvc->>LifecycleHandler: handleSubscriptionUpdated(data, previous_attributes)
Note right of LifecycleHandler: isSubscriptionResumed() returns true
LifecycleHandler->>LifecycleHandler: handleSubscriptionResume(subscription)
rect rgb(200, 255, 200)
Note right of LifecycleHandler: Reactivate Subscription in DB
LifecycleHandler->>DB: Begin Transaction
LifecycleHandler->>DB: Update subscriptions (canceled_at: null)
LifecycleHandler->>DB: Delete `scheduled_cancellation` record from subscription_histories
LifecycleHandler->>DB: Commit Transaction
end
Webhook-->>StripeAPI: 200 OK
Steps
Step 1: Receive customer.subscription.updated Webhook
- After the user resumes their subscription via Stripe, Stripe sends a webhook. The key change is that
cancel_at_period_endis nowfalse(ornull), whereas theprevious_attributesin the event payload show its prior state wastrue.
Step 2: Process Webhook
- The
WebhookControllerverifies the event and routes it to theSubscriptionService, which in turn calls theSubscriptionLifeCycleHandler. - The handler's
isSubscriptionResumed()method compares the current and previous attributes of the event and returnstrue, identifying it as a resumption event.
Step 3: Reactivate Subscription and Clean Up History
- The
handleSubscriptionResume()method is executed. - Within a database transaction, it performs two critical actions:
- It updates the corresponding
subscriptionsrecord, setting thesubscription.canceled_atfield tonull. - It finds and deletes the
pendingscheduled_cancellationhistory record from thesubscription_historiestable, as the cancellation is no longer going to occur.
- It updates the corresponding
- This ensures the subscription will continue to renew at the end of the billing cycle and that there are no confusing, obsolete history records.
Related Database Structure
erDiagram
users {
bigint id PK
string name "User's full name"
string email "User's email address (unique)"
string payment_provider_customer_id "Customer ID from Stripe (nullable)"
timestamp created_at
timestamp updated_at
}
subscriptions {
bigint id PK
string slug "Unique identifier"
bigint user_id FK
bigint group_id FK
string status "active, past_due, Canceled"
string payment_provider_subscription_id "Stripe subscription ID"
boolean auto_renew
timestamp deadline_at "Current billing period end"
timestamp canceled_at "Timestamp when cancellation takes effect (nullable)"
string canceled_reason "Reason for cancellation (nullable)"
timestamp created_at
timestamp updated_at
}
subscription_histories {
bigint id PK
bigint subscription_id FK
string status "pending, active, inactive, canceled"
string payment_status "pending, paid, failed, N/A"
string type "new_contract, renewal, change, scheduled_cancellation"
timestamp created_at
timestamp updated_at
}
stripe_webhook_events {
bigint id PK
string stripe_event_id "Stripe event ID to prevent duplicates"
string event_type
enum status "pending, processing, completed, failed"
text error "Error message if processing fails"
timestamp created_at
timestamp updated_at
}
users ||--o{ subscriptions : has
subscriptions ||--o{ subscription_histories : has
Related API Endpoints
| Method | Endpoint | Controller | Description |
|---|---|---|---|
| POST | /api/v1/admin/stripe/webhook | WebhookController@handleWebhook | Listens for and processes all incoming events from Stripe related to subscriptions. |
Error Handling
-
Log:
- All webhook processing failures are logged to the application log.
- Errors are stored in the
errorcolumn of thestripe_webhook_eventstable.
-
Error Detail:
| Status Code | Error Message | Description |
|---|---|---|
| 400 | Invalid webhook signature. | The request did not originate from Stripe. |
| 404 | Subscription not found for webhook. | The subscription ID from the webhook does not match any record in the database. |
| 500 | Database error: {message} | A database operation failed during webhook processing. |
Additional Notes
- Source of Truth: Stripe is the source of truth for the subscription's state. The application reacts to changes via webhooks.
- User Interface: The user-facing action of resuming is assumed to be handled by a frontend application that interacts directly with Stripe's API or Billing Portal, which in turn triggers the backend webhooks.
- Idempotency: Webhook handling is designed to be idempotent. If the same
customer.subscription.updatedevent indicating resumption is received multiple times, it will be processed only once to prevent data inconsistencies.