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
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
- After confirmation, Stripe sends a
customer.subscription.updatedwebhook.
Step 3: Record Scheduled Change
- The
WebhookControllerroutes the event to theSubscriptionLifeCycleHandler. - The handler's
isPlanChanged()method returnstrue. - The
handlePlanChange()method is called, which performs two actions within a database transaction:- It updates the main
subscriptionsrecord, setting thesubscriptions.scheduled_plan_idto the ID of the new plan andsubscriptions.scheduled_plan_change_atto the renewal date. - It creates a new
subscription_historiesrecord to represent the upcoming change with:type:changestatus:pendingpayment_status:pendingold_plan_id: The ID of the current plan.
- It updates the main
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.
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_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
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. |
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.