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:
- Prorates the remaining time on the old plan (creates a credit)
- Charges for the new plan (creates a charge)
- Sends
customer.subscription.updatedwebhook (plan changed) - Sends
invoice.paidwebhook (withbilling_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_attributescontains the old plan details
Step 2: Create Pending Change History
LifecycleHandlerdetects this is an immediate plan change (not scheduled)- Creates a new
subscription_historiesrecord:type:changepayment_status:pendingstarted_at: new period startexpires_at: new period endold_plan_id: fromprevious_attributes.items.data[0].plan.id
- Updates
subscriptionstable with newpackage_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
InvoiceHandlerfinds the pending change history- Updates the history with payment information:
payment_status:paidpaid_at: payment timestampinvoice_id: Stripe invoice IDpayment_intent_id: Payment intent IDamount: prorated charge amountold_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_idfrom the credit line item - Fetches new plan info from Stripe Subscription API
- Creates
subscription_historiesrecord:type:changepayment_status:n/a(zero-amount transaction)old_plan_id: from credit lineamount: 0
Step 3: Receive customer.subscription.updated Webhook (Later)
- Arrives after invoice processing
Step 4: Skip Duplicate History Creation
LifecycleHandlerchecks for existing change history:- Matches by
subscription_id,type:change, andstarted_at(within 5-second tolerance)
- Matches by
- Finds the history already created by
invoice.paid - Only updates the
subscriptionstable 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:paidamount: total amount paid
- When
subscription.updatedarrives,LifecycleHandlerfinds 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
pendingfor retry
Malformed Plan IDs in Invoice
- Cannot determine plan change details
- System logs detailed error with invoice data
- Returns
nullfromcreateChangeHistoryFromInvoice() - Event marked as
failedwith 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:
- Idempotency Check: Uses
stripe_webhook_eventstable with combination ofstripe_event_id + request_id + event_type - Duplicate History Prevention: Checks for existing history records by:
subscription_idtype(e.g.,change)started_at(within ±5 seconds tolerance to handle timestamp differences)
- 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 ofpackageToProvider->package_id - Prevents foreign key constraint violations
- Ensures data integrity
Document Status: Complete - Covers all immediate plan change scenarios and race conditions