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 subscription and permission to manage it.
    • Stripe schedule event must contain a next phase (next plan + start date). If Stripe sends only the current phase (no next phase), we log and skip creating a pending change until a later subscription_schedule.updated or subscription.updated provides the new plan.

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 (subscription_schedule.created/updated)
    Note right of Stripe: Payload must contain next phase (next plan + start_date).<br/>If not present (only one phase), we log and wait for a later event.

    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

  • Stripe sends subscription_schedule.created (and later subscription_schedule.updated if the schedule changes). We only record a pending change when the payload has a next phase with next plan id and start_date.
  • If the event has no next plan (only current phase), we log and wait for the next schedule update or the eventual subscription.updated/invoice.paid to create a fallback history.
  • If user cancels/releases scheduled change (subscription_schedule.released), we clear scheduled_plan_id/scheduled_plan_change_at and inactivate pending change histories (payment_status → NA).

Step 3: Record Scheduled Change (when next phase exists)

  • Handler persists within a transaction:
    • Update subscriptions.scheduled_plan_id to the next plan id and subscriptions.scheduled_plan_change_at to the phase start date.
    • Create a subscription_histories record:
      • type: change
      • status: pending
      • payment_status: pending (even if amount=0; will become N/A at application if zero-amount)
      • old_plan_id: current plan id.
  • If price/product metadata is missing, handler falls back to mapping plan via repository to avoid losing history; warnings are logged when metadata cannot be fetched.

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.

Fallback when pending history is missing

  • If a scheduled change is applied but no pending history exists (e.g., schedule event lacked next plan, or metadata fetch failed), the handler creates a fallback change history from the subscription.updated payload (plan id in items[0].plan/price), maps to package plan via repository, sets payment_status to pending or N/A (amount=0), then finalizes and clears schedule fields. This prevents entitlement gaps even when the original schedule event was incomplete.

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, 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.

Handling Concurrent Scheduled Plan Changes

Scenario: User Already Has a Pending Scheduled Change

Problem: User has already scheduled a plan change (e.g., Basic → Pro on Feb 1st), then attempts to schedule another change (e.g., Basic → Enterprise on the same Feb 1st) before the first scheduled change is executed.

System Behavior:

  1. Stripe Behavior:

    • Stripe ONLY allows one scheduled change at a time
    • When user schedules a new change, Stripe will replace the old scheduled change
    • The new change completely overrides the old one
  2. Database Behavior:

    Before:
    - subscriptions.scheduled_plan_id = Premium Plan ID
    - subscriptions.scheduled_plan_change_at = 2024-02-01
    - subscription_histories: type='change', status='pending' (Premium Plan)
    
    After scheduling new change:
    - subscriptions.scheduled_plan_id = Enterprise Plan ID (UPDATED)
    - subscriptions.scheduled_plan_change_at = 2024-02-01 (SAME)
    - subscription_histories: type='change', status='pending' (Enterprise Plan - NEW)
    
  3. History Record Handling:

    • System will invalidate old pending change history:
      UPDATE subscription_histories 
      SET status = 'inactive' 
      WHERE subscription_id = :id 
        AND type = 'change' 
        AND status = 'pending'
        AND old_plan_id IS NOT NULL;
      
    • Create new pending change history for the new scheduled change

Edge Cases:

Scenario Action Result
Multiple changes, queue User attempts 3+ changes Only last change is kept
Multiple changes, rejection System could reject new change Currently NOT implemented - new change always replaces
Race condition 2 concurrent changes from different UIs Last write wins (handled by Stripe)

Recommendations for Frontend/API Consumers:

⚠️ Warn user when they already have a pending scheduled change:

if (subscription.scheduled_plan_id) {
  confirm(
    `You already have a scheduled plan change to ${scheduledPlan.name}. ` +
    `The new change will replace the old one. Continue?`
  );
}

⚠️ Display pending change clearly in UI so user knows current state

Sequential Downgrades Before Period End

Scenario: User Schedules a Downgrade, Then Chooses Another Cheaper Plan

Example: Enterprise → Free (scheduled) → Basic (before period end)

Key Rule: The effective plan does not change until period end. Any new selection is evaluated against the current effective plan, not the previously scheduled plan.

Scenario: Free → Enterprise → Free → Premium (mix immediate + scheduled)

  • Start: Subscription is on Free.
  • Upgrade to Enterprise: Free → paid is usually immediate (Stripe charges now). Flow follows Immediate Plan Change (see 07.immediate_plan_change.md), not scheduled. scheduled_plan_id stays null; history is created/finalized via subscription.updated/invoice.paid.
  • Later schedule downgrade to Free: If done via portal for end-of-period, Stripe creates schedule with next phase Free. System sets scheduled_plan_id = Free, creates pending change history (payment_status = N/A).
  • Before effective date, schedule upgrade to Premium: New schedule replaces previous. System inactivates pending Free change, sets scheduled_plan_id = Premium, creates pending history (payment_status = pending).
  • At period boundary: subscription.updated/invoice.* arrive for Premium. Handler applies scheduled change, finalizes history (paid or NA if 0-amount), clears scheduled_plan_id. If pending history is missing (e.g., schedule event lacked next phase), fallback creation from subscription.updated prevents entitlement gaps.

State changes (simplified)

Step subscriptions subscription_histories
Start (Free active) package_plan_id = Free, scheduled_plan_id = null Latest history: type=new, status=active, payment_status=NA, amount=0
Immediate upgrade to Enterprise package_plan_id = Enterprise, scheduled_plan_id = null, deadline_at set from Stripe New history: type=change, status=active, payment_status=paid, old_plan_id=Free, amount>0
Schedule downgrade to Free scheduled_plan_id = Free, scheduled_plan_change_at = period_end (no change to current package_plan_id) Pending history: type=change, status=pending, payment_status=pending, package_plan_id=Free, old_plan_id=Enterprise, amount=0, started_at = next period start
Schedule upgrade to Premium (replaces Free schedule) scheduled_plan_id = Premium, scheduled_plan_change_at updated Prior pending history (to Free) set status=inactive, payment_status=NA; new pending history: type=change, payment_status=pending, package_plan_id=Premium, old_plan_id=Enterprise
Period boundary applies Premium package_plan_id = Premium, scheduled_plan_id = null, deadline_at updated Pending Premium history updated to status=active, payment_status=paid (or NA if amount=0), paid_at/invoice_id set if paid

Behavior:

  • Enterprise → Free is a downgrade, so it is scheduled.
  • User then selects Basic, but effective plan is still Enterprise.
  • Enterprise → Basic is also a downgrade, so it is scheduled.
  • The new scheduled plan replaces the old one (Free is overwritten by Basic).
  • User continues using Enterprise until period end, then moves to Basic.

Outcome:

  • Current period: Enterprise (unchanged)
  • Next period: Basic (latest scheduled plan)

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.