Scheduled Plan Change (Upgrade/Downgrade)

Description

This document outlines the process for changing a subscription plan, scheduled to take effect at the next billing cycle. The feature allows users to upgrade or downgrade their plan seamlessly, typically via the Stripe Billing Portal. The backend system is responsible for generating the portal session and then reacting to a series of webhooks from Stripe to track the scheduled change and finalize the new plan upon renewal.

This flow ensures that billing changes are applied correctly at the start of a new period, avoiding complex proration calculations. It covers both upgrades to a more expensive plan and downgrades to a cheaper or free plan.

Prerequisites:

  • User has an active, paid subscription.
  • User is authorized to manage the subscription for their group.

Process Flow Diagram

---
config:
  theme: base
  layout: dagre
  flowchart:
    curve: linear
    htmlLabels: true
  themeVariables:
    edgeLabelBackground: "transparent"
---
flowchart TD
    %% == NODES DEFINITION ==
    Client[User]
    
    %% Layer components
    subgraph ApiControllerLayer["API Controller Layer"]
        SubscriptionController[SubscriptionController]
        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"]
        StripePortal((Stripe Billing Portal))
        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'>Request Portal Session</p></div>"]
    Step2["<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'>2</span><p style='margin-top: 8px'>Change Plan in Portal</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<br/>(subscription_schedule.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'>Record Scheduled Change in DB</p></div>"]
    StepW3["<div style='text-align: center'><span style='display: inline-block; background-color: #99cc66 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W3</span><p style='margin-top: 8px'>On Renewal Date, Send Webhooks<br/>(invoice.paid, sub.updated)</p></div>"]
    StepW4["<div style='text-align: center'><span style='display: inline-block; background-color: #99cc66 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W4</span><p style='margin-top: 8px'>Finalize Plan Change in DB</p></div>"]

    %% == CONNECTIONS ==
    Client --- Step1 --> SubscriptionController
    SubscriptionController --> StripeAPI -- "Portal URL" --> Client
    
    Client --- Step2 --> StripePortal
    
    StripePortal --> StripeAPI --- StepW1 --> WebhookController
    
    WebhookController --- StepW2
    StepW2 --> SubscriptionService --> LifecycleHandler
    LifecycleHandler --> SubscriptionDB
    LifecycleHandler --> HistoryDB
    
    StripeAPI --- StepW3 --> WebhookController
    WebhookController --- StepW4
    StepW4 --> SubscriptionService --> LifecycleHandler
    
    %% == STYLING ==
    style Client 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 Step2 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: Initiating and Recording a Scheduled Plan Change

Description

The user initiates a plan change (either upgrade or downgrade) from the application. The system generates a Stripe Billing Portal session. After the user confirms the change in the portal, Stripe sends a webhook to the application, which then records the pending change.

Sequence Diagram

sequenceDiagram
    participant Client
    participant App as SubscriptionController
    participant Stripe as Stripe API
    participant Webhook as WebhookController
    participant Handler as SubscriptionLifeCycleHandler
    participant DB as Database

    Note over Client, Stripe: 1. User Initiates Plan Change
    Client->>App: POST /api/v1/general/subscription/billing-portal
    App->>Stripe: Create billing portal session
    Stripe-->>App: Return portal session URL
    App-->>Client: Return portal URL
    
    Client->>Stripe: Redirect to Stripe Billing Portal
    Note right of Client: User selects new plan and confirms.<br/>The change is scheduled for the end of the billing period.

    Note over Stripe, DB: 2. System Records Scheduled Change
    Stripe->>Webhook: POST /webhook (customer.subscription.updated)
    Note right of Stripe: Payload contains the new plan details.

    Webhook->>Handler: handleSubscriptionUpdated(data)
    Note right of Handler: isPlanChanged() returns true
    Handler->>Handler: handlePlanChange(subscription, data)

    rect rgb(200, 255, 255)
    Note right of Handler: Record Pending Change
    Handler->>DB: Begin Transaction
    Handler->>DB: Update `subscriptions` (scheduled_plan_id, scheduled_plan_change_at)
    Handler->>DB: Create `subscription_histories` (type: change, status: pending, payment_status: pending)
    Handler->>DB: Commit Transaction
    end

    Webhook-->>Stripe: 200 OK

Steps

Step 1: User Initiates Change

  • The user requests to change their plan via the application's UI.
  • The backend SubscriptionController creates a Stripe Billing Portal session and returns the URL to the client.
  • The user is redirected to the Stripe portal, where they select a new plan and confirm the change, which is scheduled to occur at the end of the current billing cycle.

Step 2: System Receives Webhook

  • After confirmation, Stripe sends a customer.subscription.updated webhook.

Step 3: Record Scheduled Change

  • The WebhookController routes the event to the SubscriptionLifeCycleHandler.
  • The handler's isPlanChanged() method returns true.
  • The handlePlanChange() method is called, which performs two actions within a database transaction:
    • It updates the main subscriptions record, setting the subscriptions.scheduled_plan_id to the ID of the new plan and subscriptions.scheduled_plan_change_at to the renewal date.
    • It creates a new subscription_histories record to represent the upcoming change with:
      • type: change
      • status: pending
      • payment_status: pending
      • old_plan_id: The ID of the current plan.

Case 2: Plan Change Execution (Upgrade or Downgrade to Paid Plan)

Description

At the start of the new billing cycle, Stripe automatically attempts to charge the user for the new, scheduled plan. Upon successful payment, it sends webhooks (invoice.paid, customer.subscription.updated) that the system uses to finalize the plan change.

Sequence Diagram

sequenceDiagram
    participant StripeAPI as Stripe API
    participant Webhook as WebhookController
    participant Handler as SubscriptionLifeCycleHandler
    participant DB as Database
    
    Note over StripeAPI, DB: Plan Change Finalization on Renewal

    StripeAPI->>Webhook: POST /webhook (invoice.paid)
    Webhook->>Handler: handleInvoicePaid(data)
    Handler->>DB: Update `subscription_histories` (payment_status: paid)

    StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
    Note right of StripeAPI: The subscription object now<br>reflects the new plan as active.

    Webhook->>Handler: handleSubscriptionUpdated(data)
    
    rect rgb(200, 255, 200)
    Note right of Handler: Finalize Plan Change
    Handler->>DB: Begin Transaction
    Handler->>DB: Update `subscriptions` table:<br/>- set `package_plan_id` to new plan<br/>- clear `scheduled_plan_id`<br/>- clear `scheduled_plan_change_at`<br/>- update `deadline_at`
    Handler->>DB: Update `subscription_histories` (status: active)
    Handler->>DB: Commit Transaction
    end

    Webhook-->>StripeAPI: 200 OK

Steps

Step 1: Receive invoice.paid Webhook

  • Stripe sends this webhook to confirm the renewal payment for the new plan was successful.
  • The system finds the pending change history record and updates its subscription_histories.payment_status to paid.

Step 2: Receive customer.subscription.updated Webhook

  • Immediately after, Stripe sends an update webhook showing the subscription object now officially reflects the new plan.

Step 3: Finalize Plan Change

  • The SubscriptionLifeCycleHandler processes this update. It updates the main subscriptions record:
    • The subscriptions.package_plan_id is updated to the new plan's ID.
    • The subscriptions.scheduled_plan_id and scheduled_plan_change_at fields are cleared.
    • The subscriptions.deadline_at is updated to the end of the new billing cycle.
  • The subscription_histories record for the change is marked as status: active.

Case 3: Plan Change Execution (Downgrade to Free Plan)

Description

If a user downgrades to a free plan, no payment is made at the renewal date. Stripe simply updates the subscription to reflect the new free plan and sends a customer.subscription.updated webhook.

Sequence Diagram

sequenceDiagram
    participant StripeAPI as Stripe API
    participant Webhook as WebhookController
    participant Handler as SubscriptionLifeCycleHandler
    participant DB as Database

    Note over StripeAPI, DB: Downgrade to Free Plan Finalization

    StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
    Note right of StripeAPI: The subscription object now<br>reflects the free plan as active.

    Webhook->>Handler: handleSubscriptionUpdated(data)

    rect rgb(200, 255, 200)
    Note right of Handler: Finalize Plan Change
    Handler->>DB: Begin Transaction
    Handler->>DB: Update `subscriptions` table:<br/>- set `package_plan_id` to new free plan<br/>- clear `scheduled_plan_id`<br/>- update `deadline_at`
    Handler->>DB: Update `subscription_histories` (status: active, payment_status: N/A)
    Handler->>DB: Commit Transaction
    end

    Webhook-->>StripeAPI: 200 OK

Steps

Step 1: Receive customer.subscription.updated Webhook

  • At the end of the billing period, Stripe updates the subscription to the free plan and sends the webhook. No invoice.paid event is generated.

Step 2: Finalize Plan Change

  • The SubscriptionLifeCycleHandler processes this update.
  • It updates the main subscriptions record as in the paid scenario (new plan ID, cleared schedule fields, new deadline).
  • It updates the pending change history record, setting its status to active and its payment_status to N/A, as no payment was applicable.

Case 4: Plan Change Execution (Failed Payment on Upgrade)

Description

If the renewal payment for a new, more expensive plan fails, Stripe sends an invoice.payment_failed webhook. The subscription will typically enter a past_due state.

Steps

Step 1: Receive invoice.payment_failed Webhook

  • Stripe sends this event when the renewal payment for the upgraded plan fails.

Step 2: Update Status to Past Due

  • The handler finds the pending change history record and updates its subscription_histories.payment_status to failed.
  • It updates the main subscriptions.status to past_due.

Step 3: Stripe Retries and Final Cancellation

  • Stripe's Smart Retries feature will attempt to collect the payment again.
  • If all retry attempts fail, Stripe will automatically cancel the subscription and send a customer.subscription.deleted webhook, which the system processes to mark the subscription as Canceled.

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
        bigint package_id FK
        bigint package_plan_id FK
        bigint group_id FK
        bigint user_id FK
        string payment_provider_subscription_id "Stripe subscription ID"
        string status "active, past_due, canceled"
        string scheduled_plan_id "ID of plan to change to (nullable)"
        timestamp scheduled_plan_change_at "Timestamp when plan change takes effect (nullable)"
        timestamp deadline_at "Current billing period end"
        timestamp canceled_at "Cancellation timestamp (nullable)"
        timestamp created_at
        timestamp updated_at
    }
    subscription_histories {
        bigint id PK
        bigint subscription_id FK
        bigint package_plan_id FK
        string old_plan_id "Previous plan ID, used for 'change' type"
        string type "new_contract, renewal, change"
        string status "pending, active, inactive"
        string payment_status "pending, paid, failed, N/A"
        timestamp created_at
        timestamp updated_at
    }
    stripe_webhook_events {
        bigint id PK
        string stripe_event_id "Stripe event ID"
        string event_type "Event type"
        enum status "pending, processing, completed, failed"
        text error "Error message if processing fails (nullable)"
        timestamp created_at
        timestamp updated_at
    }

    users ||--o{ subscriptions : registers
    subscriptions ||--o{ subscription_histories : has

Related API Endpoints

Method Endpoint Controller Description
POST /api/v1/general/subscription/billing-portal SubscriptionController Generates a Stripe Billing Portal session for the user to manage their subscription.
POST /api/v1/admin/stripe/webhook WebhookController Listens for and processes all incoming events from Stripe related to subscriptions.

Error Handling

  • Log:

    • All API failures (e.g., creating a portal session) are logged.
    • All webhook processing failures are logged and stored in the error column of the stripe_webhook_events table.
  • Error Detail:

Status Code Error Message Description
403 "User is not authorized to manage this subscription." The user is not the owner or an admin of the group.
404 "Active subscription not found." The user does not have a subscription to change.
500 "Failed to create Stripe Billing Portal session." An error occurred while communicating with the Stripe API.

Additional Notes

  • Source of Truth: Stripe remains the source of truth for billing and subscription status. The application's database is a local mirror updated via webhooks.
  • Proration: By scheduling changes to the end of the billing cycle, this flow avoids complex proration calculations. The user is charged the full price for the new plan on the renewal date.
  • User Experience: The use of the Stripe Billing Portal provides a secure and consistent user experience for managing payment methods and subscription changes.