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.updatedorsubscription.updatedprovides 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
SubscriptionControllercreates 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 latersubscription_schedule.updatedif 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.paidto create a fallback history. - If user cancels/releases scheduled change (
subscription_schedule.released), we clearscheduled_plan_id/scheduled_plan_change_atand inactivate pendingchangehistories (payment_status → NA).
Step 3: Record Scheduled Change (when next phase exists)
- Handler persists within a transaction:
- Update
subscriptions.scheduled_plan_idto the next plan id andsubscriptions.scheduled_plan_change_atto the phase start date. - Create a
subscription_historiesrecord:type:changestatus:pendingpayment_status:pending(even if amount=0; will becomeN/Aat application if zero-amount)old_plan_id: current plan id.
- Update
- 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
pendingchangehistory record and updates itssubscription_histories.payment_statustopaid.
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
SubscriptionLifeCycleHandlerprocesses this update. It updates the mainsubscriptionsrecord:- The
subscriptions.package_plan_idis updated to the new plan's ID. - The
subscriptions.scheduled_plan_idandscheduled_plan_change_atfields are cleared. - The
subscriptions.deadline_atis updated to the end of the new billing cycle.
- The
- The
subscription_historiesrecord for the change is marked asstatus: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
changehistory from thesubscription.updatedpayload (plan id initems[0].plan/price), maps to package plan via repository, setspayment_statustopendingorN/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.paidevent is generated.
Step 2: Finalize Plan Change
- The
SubscriptionLifeCycleHandlerprocesses this update. - It updates the main
subscriptionsrecord as in the paid scenario (new plan ID, cleared schedule fields, new deadline). - It updates the
pendingchangehistory record, setting itsstatustoactiveand itspayment_statustoN/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
pendingchangehistory record and updates itssubscription_histories.payment_statustofailed. - It updates the main
subscriptions.statustopast_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.deletedwebhook, which the system processes to mark the subscription asCanceled.
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
errorcolumn of thestripe_webhook_eventstable.
-
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:
-
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
-
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) -
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
- System will invalidate old pending change history:
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_idstays 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 pendingchangehistory (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), clearsscheduled_plan_id. If pending history is missing (e.g., schedule event lacked next phase), fallback creation fromsubscription.updatedprevents 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.