Subscription Cancellation and Resumption
Description
This document describes the process for handling subscription cancellations and resumptions. The flow is designed to be reactive, primarily initiated by user actions on an external interface (like the Stripe Billing Portal) which then trigger webhooks that the application listens to.
The system supports a "cancel at period end" strategy. When a user requests to cancel, the subscription remains active until the current billing cycle completes. The system also handles the case where a user decides to resume a subscription they had previously scheduled for cancellation. The key webhooks are customer.subscription.updated for scheduling/resuming cancellations, and customer.subscription.deleted for when the cancellation is finalized.
Prerequisites:
- An active user subscription exists in both the local database and on Stripe.
- The user initiates a cancellation or resumption action via a Stripe-integrated interface.
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'>Cancel/Resume 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/>(e.g., 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'>Update DB & Create History</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: Scheduled Subscription Cancellation
Description
A user requests to cancel their subscription at the end of the current billing period. Stripe updates the subscription object and sends a customer.subscription.updated webhook. The system listens for this event to mark the subscription for pending cancellation and creates a corresponding history record.
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: Scheduled Cancellation Flow
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: Payload contains `cancel_at_period_end: true`
Webhook->>SubSvc: handleSubscriptionUpdated(data, previous_attributes)
SubSvc->>LifecycleHandler: handleSubscriptionUpdated(data, previous_attributes)
Note right of LifecycleHandler: isScheduledCancellation() returns true
LifecycleHandler->>LifecycleHandler: handleScheduledCancellation(subscription, session)
rect rgb(200, 255, 255)
Note right of LifecycleHandler: Update DB
LifecycleHandler->>DB: Begin Transaction
LifecycleHandler->>DB: Update subscriptions (canceled_at)
LifecycleHandler->>DB: Create subscription_histories (type: scheduled_cancellation, status: pending)
LifecycleHandler->>DB: Commit Transaction
end
Webhook-->>StripeAPI: 200 OK
Steps
Step 1: Receive customer.subscription.updated Webhook
- After the user schedules a cancellation, Stripe sends a webhook. The payload will contain
cancel_at_period_end: true.
Step 2: Process Webhook
- The
WebhookControllerroutes the event to theSubscriptionLifeCycleHandler. - The handler's
isScheduledCancellation()method returnstrue.
Step 3: Update Subscription and Create History
- The
handleScheduledCancellation()method is called. - Within a database transaction, it performs two actions:
- It updates the main
subscriptionsrecord, setting thesubscription.canceled_attimestamp to the date the subscription will expire. - It creates a new
subscription_historiesrecord withsubscription_histories.typeset toscheduled_cancellation,subscription_histories.statusset toactive, andsubscription_histories.payment_statusset toN/A. This record indicates that a cancellation is scheduled but not yet finalized.
- It updates the main
Case 2: Resuming a Scheduled Cancellation
Description
If a user decides to keep their subscription before the cancellation date, they can resume it. This action also triggers a customer.subscription.updated webhook, but this time cancel_at_period_end is false, and the previous_attributes show that it was previously true.
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, Stripe sends a webhook. The key change is
cancel_at_period_endis nowfalse.
Step 2: Process Webhook
- The
WebhookControllerroutes the event to theSubscriptionLifeCycleHandler. - The handler's
isSubscriptionResumed()method returnstrue.
Step 3: Reactivate Subscription in DB
- The
handleSubscriptionResume()method is called. - It updates the
subscriptionsrecord, settingsubscription.canceled_atback tonull. - It finds and deletes the
pendingscheduled_cancellationhistory record created in the previous step, as it is no longer relevant.
Case 3: Finalized Cancellation
Description
When a subscription with a scheduled cancellation reaches the end of its billing period, Stripe sends a customer.subscription.deleted webhook. This event finalizes the cancellation in the local system.
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: Finalized Cancellation Flow
StripeAPI->>Webhook: POST /webhook (customer.subscription.deleted)
Webhook->>SubSvc: handleSubscriptionDeleted(data)
SubSvc->>LifecycleHandler: handleSubscriptionDeleted(data)
rect rgb(255, 200, 200)
Note right of LifecycleHandler: Finalize Local Subscription
LifecycleHandler->>DB: Begin Transaction
LifecycleHandler->>DB: Update subscriptions (status: Canceled)
LifecycleHandler->>DB: Update `scheduled_cancellation` history record (status: canceled)
LifecycleHandler->>DB: Commit Transaction
end
Webhook-->>StripeAPI: 200 OK
Steps
Step 1: Receive customer.subscription.deleted Webhook
- Stripe sends this event when the subscription is officially terminated at the end of the billing period.
Step 2: Process Webhook
- The
WebhookControllerroutes the event to theSubscriptionLifeCycleHandler.
Step 3: Finalize Cancellation
- The
handleSubscriptionDeleted()method is called. - It updates the main
subscription.statustoCanceled. - It finds the
pendingscheduled_cancellationhistory record and updates itssubscription_histories.statustocanceled, completing the lifecycle.
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"
string canceled_reason "Reason for cancellation"
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"
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 actions of canceling and resuming are 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: The webhook processing is idempotent, meaning if the same event is received multiple times, it will not cause duplicate data or errors.
- Immediate Cancellation: While the primary flow is "cancel at period end," the
handleSubscriptionDeletedlogic can also handle immediate cancellations if they are triggered.