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 WebhookController routes the event to the SubscriptionLifeCycleHandler.
  • The handler's isScheduledCancellation() method returns true.

Step 3: Update Subscription and Create History

  • The handleScheduledCancellation() method is called.
  • Within a database transaction, it performs two actions:
    • It updates the main subscriptions record, setting the subscription.canceled_at timestamp to the date the subscription will expire.
    • It creates a new subscription_histories record with subscription_histories.type set to scheduled_cancellation, subscription_histories.status set to active, and subscription_histories.payment_status set to N/A. This record indicates that a cancellation is scheduled but not yet finalized.

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_end is now false.

Step 2: Process Webhook

  • The WebhookController routes the event to the SubscriptionLifeCycleHandler.
  • The handler's isSubscriptionResumed() method returns true.

Step 3: Reactivate Subscription in DB

  • The handleSubscriptionResume() method is called.
  • It updates the subscriptions record, setting subscription.canceled_at back to null.
  • It finds and deletes the pending scheduled_cancellation history 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 WebhookController routes the event to the SubscriptionLifeCycleHandler.

Step 3: Finalize Cancellation

  • The handleSubscriptionDeleted() method is called.
  • It updates the main subscription.status to Canceled.
  • It finds the pending scheduled_cancellation history record and updates its subscription_histories.status to canceled, 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 error column of the stripe_webhook_events table.
  • 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 handleSubscriptionDeleted logic can also handle immediate cancellations if they are triggered.