Immediate Plan Change (Upgrade/Downgrade)

Description

This document outlines the process for changing a subscription plan with immediate effect. Unlike scheduled plan changes, immediate changes apply right away, potentially mid-billing cycle, and involve complex proration calculations managed entirely by Stripe. The backend system handles this through webhook events, ensuring data consistency and proper tracking of both upgrades (to more expensive plans) and downgrades (to cheaper or free plans).

The key challenge addressed here is the race condition between customer.subscription.updated and invoice.paid events, which can arrive in any order. The system is designed to handle both scenarios gracefully.

Prerequisites:

  • User has an active, paid subscription
  • User is authorized to manage the subscription for their group
  • Plan change is configured for immediate billing (not scheduled for next period)

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"]
        WebhookController[WebhookController]
    end
    
    subgraph ApiServiceLayer["API Service Layer"]
        SubscriptionService(SubscriptionService)
        LifecycleHandler(SubscriptionLifeCycleHandler)
        InvoiceHandler(SubscriptionInvoiceHandler)
    end
    
    subgraph DatabaseLayer["Database Layer"]
        SubscriptionDB[(subscriptions)]
        HistoryDB[(subscription_histories)]
        WebhookEventsDB[(stripe_webhook_events)]
    end
    
    subgraph ExternalServices["External Services"]
        StripePortal((Stripe Billing Portal / API))
        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'>Change Plan Immediately</p></div>"]
    
    StepW1A["<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'>W1A</span><p style='margin-top: 8px'>Webhook: subscription.updated<br/>(Plan Changed)</p></div>"]
    StepW1B["<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'>W1B</span><p style='margin-top: 8px'>Webhook: invoice.paid<br/>(Prorated Charge)</p></div>"]
    
    StepW2["<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'>W2</span><p style='margin-top: 8px'>Create/Update Change History</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'>Finalize with Payment Details</p></div>"]

    %% == CONNECTIONS ==
    Client --- Step1 --> StripePortal
    StripePortal --> StripeAPI
    
    StripeAPI --- StepW1A --> WebhookController
    StripeAPI --- StepW1B --> WebhookController
    
    WebhookController --> LifecycleHandler
    WebhookController --> InvoiceHandler
    
    LifecycleHandler --- StepW2
    InvoiceHandler --- StepW2
    StepW2 --> HistoryDB
    
    InvoiceHandler --- StepW3
    StepW3 --> HistoryDB
    StepW3 --> SubscriptionDB

    %% == 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 StepW1A fill:transparent,stroke:transparent,stroke-width:1px
    style StepW1B 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

Use Cases

Case 1: Immediate Upgrade (subscription.updated arrives FIRST)

Description

When a user upgrades to a more expensive plan immediately, Stripe:

  1. Prorates the remaining time on the old plan (creates a credit)
  2. Charges for the new plan (creates a charge)
  3. Sends customer.subscription.updated webhook (plan changed)
  4. Sends invoice.paid webhook (with billing_reason: subscription_update)

In this scenario, the subscription.updated event arrives first. The system creates a pending change history record, which is then finalized by the subsequent invoice.paid event.

Sequence Diagram

sequenceDiagram
    participant StripeAPI as Stripe API
    participant Webhook as WebhookController
    participant SubSvc as SubscriptionService
    participant LifecycleHandler as LifeCycleHandler
    participant InvoiceHandler as InvoiceHandler
    participant DB as Database

    Note over StripeAPI, DB: Immediate Upgrade Flow (subscription.updated first)

    StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
    Note right of StripeAPI: Old plan → New plan (higher price)
    
    rect rgb(200, 255, 255)
    Note right of Webhook: Process Subscription Update
    Webhook->>SubSvc: handleSubscriptionUpdated(data, previous)
    SubSvc->>LifecycleHandler: handlePlanChange()
    
    LifecycleHandler->>DB: Begin Transaction
    LifecycleHandler->>DB: Create subscription_histories<br/>(type: change, payment_status: pending)
    LifecycleHandler->>DB: Update subscriptions<br/>(package_plan_id = new_plan)
    LifecycleHandler->>DB: Commit
    end
    
    Note over StripeAPI: Shortly after...
    
    StripeAPI->>Webhook: POST /webhook (invoice.paid, billing_reason: subscription_update)
    
    rect rgb(255, 255, 200)
    Note right of Webhook: Process Invoice Payment
    Webhook->>SubSvc: handleInvoicePaid(invoice)
    SubSvc->>InvoiceHandler: handlePlanChangeInvoice()
    
    InvoiceHandler->>DB: Find pending change history by subscription_id + type:change
    
    alt Pending history found
        InvoiceHandler->>DB: Update subscription_histories<br/>(payment_status: paid, paid_at, invoice_id,<br/>payment_intent_id, old_plan_id)
        InvoiceHandler->>DB: Update subscriptions<br/>(deadline_at, grace_period_end_at)
    end
    end

Steps

Step 1: Receive customer.subscription.updated Webhook

  • Stripe sends this first when the plan change is applied
  • The event contains the new plan details in items.data[0].plan
  • previous_attributes contains the old plan details

Step 2: Create Pending Change History

  • LifecycleHandler detects this is an immediate plan change (not scheduled)
  • Creates a new subscription_histories record:
    • type: change
    • payment_status: pending
    • started_at: new period start
    • expires_at: new period end
    • old_plan_id: from previous_attributes.items.data[0].plan.id
  • Updates subscriptions table with new package_plan_id

Step 3: Receive invoice.paid Webhook

  • Stripe sends this after processing the prorated charge
  • Contains billing_reason: 'subscription_update'
  • Invoice lines show both credit (old plan) and charge (new plan)

Step 4: Finalize Payment Details

  • InvoiceHandler finds the pending change history
  • Updates the history with payment information:
    • payment_status: paid
    • paid_at: payment timestamp
    • invoice_id: Stripe invoice ID
    • payment_intent_id: Payment intent ID
    • amount: prorated charge amount
    • old_plan_id: extracted from credit line item

Case 2: Immediate Downgrade to Free Plan (invoice.paid arrives FIRST)

Description

When downgrading to a free plan, Stripe generates a $0 invoice with only a credit line (negative amount) representing the unused time from the old plan. The invoice.paid event may arrive before subscription.updated.

In this race condition scenario, the InvoiceHandler creates the change history directly from invoice data.

Sequence Diagram

sequenceDiagram
    participant StripeAPI as Stripe API
    participant Webhook as WebhookController
    participant SubSvc as SubscriptionService
    participant InvoiceHandler as InvoiceHandler
    participant LifecycleHandler as LifeCycleHandler
    participant DB as Database

    Note over StripeAPI, DB: Downgrade to Free Flow (invoice.paid arrives first)

    StripeAPI->>Webhook: POST /webhook (invoice.paid, billing_reason: subscription_update, amount_due: 0)
    Note right of StripeAPI: Invoice has only credit line (amount < 0)
    
    rect rgb(255, 230, 200)
    Note right of Webhook: Process Invoice Payment (Race Condition Handler)
    Webhook->>SubSvc: handleInvoicePaid(invoice)
    SubSvc->>InvoiceHandler: handlePlanChangeInvoice()
    
    InvoiceHandler->>DB: Find pending change history
    
    alt No pending history found (race condition!)
        Note over InvoiceHandler: Create history from invoice data
        InvoiceHandler->>InvoiceHandler: Extract old_plan_id from credit line
        InvoiceHandler->>InvoiceHandler: Get new_plan from Stripe subscription API
        
        InvoiceHandler->>DB: Begin Transaction
        InvoiceHandler->>DB: Create subscription_histories<br/>(type: change, payment_status: n/a,<br/>old_plan_id, amount: 0)
        InvoiceHandler->>DB: Commit
    end
    end
    
    Note over StripeAPI: Shortly after...
    
    StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
    
    rect rgb(200, 255, 255)
    Note right of Webhook: Process Subscription Update
    Webhook->>SubSvc: handleSubscriptionUpdated(data, previous)
    SubSvc->>LifecycleHandler: handlePlanChange()
    
    LifecycleHandler->>DB: Check for existing change history
    
    alt History already exists (created by invoice.paid)
        Note over LifecycleHandler: Skip creation, just update subscription
        LifecycleHandler->>DB: Update subscriptions<br/>(package_plan_id, deadline_at)
    end
    end

Steps

Step 1: Receive invoice.paid Webhook (First)

  • Contains billing_reason: 'subscription_update'
  • amount_due: 0 (free plan)
  • Invoice lines: One credit line (negative amount) for old plan

Step 2: Create History from Invoice Data (Race Condition Handler)

  • InvoiceHandler.createChangeHistoryFromInvoice() is called
  • Detects this is a downgrade to free plan:
    • amount_paid == 0
    • Only credit line exists (amount < 0)
  • Extracts old_plan_id from the credit line item
  • Fetches new plan info from Stripe Subscription API
  • Creates subscription_histories record:
    • type: change
    • payment_status: n/a (zero-amount transaction)
    • old_plan_id: from credit line
    • amount: 0

Step 3: Receive customer.subscription.updated Webhook (Later)

  • Arrives after invoice processing

Step 4: Skip Duplicate History Creation

  • LifecycleHandler checks for existing change history:
    • Matches by subscription_id, type:change, and started_at (within 5-second tolerance)
  • Finds the history already created by invoice.paid
  • Only updates the subscriptions table with new plan info
  • Does NOT create a duplicate history record

Case 3: Immediate Upgrade with Race Condition (invoice.paid arrives FIRST)

Description

Similar to Case 1, but the webhook order is reversed. The system must handle creating the history from invoice data and then skip duplicate creation when subscription.updated arrives.

Key Points

  • InvoiceHandler.createChangeHistoryFromInvoice() creates history with:
    • old_plan_id: extracted from credit line (amount < 0)
    • new_plan_id: extracted from charge line (amount > 0)
    • payment_status: paid
    • amount: total amount paid
  • When subscription.updated arrives, LifecycleHandler finds existing history and skips creation

Case 4: Immediate Change Cancels a Scheduled Downgrade

Description

If a user already has a scheduled downgrade and then changes to a more expensive plan immediately, the immediate change takes precedence and the scheduled downgrade is cleared.

Behavior

  • The scheduled fields (scheduled_plan_id, scheduled_plan_change_at) are cleared during the immediate change handling.
  • Entitlement switches to the new plan immediately.
  • Any previously scheduled downgrade is no longer applied at period end.

Related Database Structure

erDiagram
    subscriptions {
        bigint id PK
        bigint package_id FK
        bigint package_plan_id FK "Updated to new plan immediately"
        string status "Remains 'active'"
        string scheduled_plan_id "NULL for immediate changes"
        timestamp deadline_at "Updated to new period end"
        timestamp grace_period_end_at "Updated accordingly"
    }
    subscription_histories {
        bigint id PK
        bigint subscription_id FK
        string type "Set to 'change'"
        string payment_status "pending → paid (or n/a for free)"
        string old_plan_id "Previous plan ID"
        string invoice_id "Stripe invoice ID"
        string payment_intent_id "Payment intent (if paid)"
        decimal amount "Prorated charge (can be 0 for free)"
        timestamp started_at "New period start"
        timestamp expires_at "New period end"
        timestamp paid_at "Payment timestamp (if paid)"
    }
    stripe_webhook_events {
        bigint id PK
        string stripe_event_id "Prevents duplicate processing"
        string request_id "Stripe request ID for idempotency"
        string event_type
        enum status "pending, processing, completed, failed"
    }
    subscriptions ||--o{ subscription_histories : has

Related API Endpoints

Method Endpoint Controller Description
POST /api/v1/admin/stripe/webhook WebhookController@handleWebhook Processes all webhook events, including plan changes

Error Handling

Status Code Error Message Description
400 Invalid webhook payload Webhook data is malformed
401 Webhook signature verification failure Stripe signature validation failed
500 Failed to process plan change Database or logic error during processing
500 Stripe API call failure Failed to fetch subscription data from Stripe
500 Database transaction deadlock Concurrent webhook processing conflict
422 Malformed or missing plan IDs Invoice line items missing required plan data
409 Idempotency violation Duplicate event processing detected

Critical Failure Scenarios

Webhook Signature Verification Failure

  • System rejects webhook immediately with 401
  • Prevents unauthorized/malicious webhook processing
  • Check Stripe webhook secret configuration

Stripe API Call Failure

  • Occurs when fetching subscription data for downgrade-to-free scenarios
  • System logs error and marks event as failed
  • Webhook will be retried by Stripe automatically

Database Transaction Deadlocks

  • Can occur with concurrent webhook processing
  • System automatically rolls back transaction
  • Event status remains pending for retry

Malformed Plan IDs in Invoice

  • Cannot determine plan change details
  • System logs detailed error with invoice data
  • Returns null from createChangeHistoryFromInvoice()
  • Event marked as failed with error details

Idempotency Violations

  • Multiple processing attempts of same event detected
  • System returns success (200) without reprocessing
  • Ensures consistent final state

Additional Notes

Race Condition Handling

The system is designed to handle webhook events arriving in any order:

  1. Idempotency Check: Uses stripe_webhook_events table with combination of stripe_event_id + request_id + event_type
  2. Duplicate History Prevention: Checks for existing history records by:
    • subscription_id
    • type (e.g., change)
    • started_at (within ±5 seconds tolerance to handle timestamp differences)
  3. Create-from-Invoice Logic: InvoiceHandler.createChangeHistoryFromInvoice() can reconstruct full history from invoice data alone

Proration Calculation

  • Entirely managed by Stripe
  • Credit for unused time on old plan
  • Charge for new plan from change date
  • System only records the final amount

Payment Status Logic

  • Paid plan → Paid plan (upgrade/downgrade): payment_status = paid, amount > 0
  • Paid plan → Free plan: payment_status = n/a, amount = 0
  • Free plan → Paid plan: payment_status = paid, full amount charged

Package ID Updates

  • System uses packagePlan->package_id (from relationship) instead of packageToProvider->package_id
  • Prevents foreign key constraint violations
  • Ensures data integrity

Document Status: Complete - Covers all immediate plan change scenarios and race conditions