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_at is not null).
  • A scheduled_cancellation record exists in the subscription_histories table.
  • 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_end is now false (or null), whereas the previous_attributes in the event payload show its prior state was true.

Step 2: Process Webhook

  • The WebhookController verifies the event and routes it to the SubscriptionService, which in turn calls the SubscriptionLifeCycleHandler.
  • The handler's isSubscriptionResumed() method compares the current and previous attributes of the event and returns true, 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 subscriptions record, setting the subscription.canceled_at field to null.
    • It finds and deletes the pending scheduled_cancellation history record from the subscription_histories table, as the cancellation is no longer going to occur.
  • 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 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 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.updated event indicating resumption is received multiple times, it will be processed only once to prevent data inconsistencies.