Thay Đổi Gói Theo Lịch (Upgrade/Downgrade)
Mô Tả
Tài liệu này mô tả quy trình thay đổi gói subscription, được lên lịch để có hiệu lực vào chu kỳ thanh toán tiếp theo. Tính năng này cho phép người dùng nâng cấp hoặc hạ cấp gói của họ một cách liền mạch, thường thông qua Stripe Billing Portal. Hệ thống backend chịu trách nhiệm tạo portal session và sau đó phản ứng với một loạt webhook từ Stripe để theo dõi thay đổi đã lên lịch và hoàn tất gói mới khi gia hạn.
Luồng này đảm bảo các thay đổi thanh toán được áp dụng đúng cách vào đầu chu kỳ mới, tránh các tính toán proration phức tạp. Nó bao gồm cả upgrade lên gói đắt hơn và downgrade xuống gói rẻ hơn hoặc miễn phí.
Điều kiện tiên quyết:
- User có subscription trả phí đang hoạt động
- User được ủy quyền quản lý subscription cho nhóm của họ
Sơ Đồ Quy Trình
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == ĐỊNH NGHĨA NÚT ==
Client[User]
%% Các thành phần lớp
subgraph ApiControllerLayer["Lớp API Controller"]
SubscriptionController[SubscriptionController]
WebhookController[WebhookController]
end
subgraph ApiServiceLayer["Lớp API Service"]
SubscriptionService(SubscriptionService)
LifecycleHandler(SubscriptionLifeCycleHandler)
end
subgraph DatabaseLayer["Lớp Database"]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
WebhookEventsDB[(stripe_webhook_events)]
end
subgraph ExternalServices["Dịch Vụ Bên Ngoài"]
StripePortal((Stripe Billing Portal))
StripeAPI((Stripe API))
end
%% == ĐỊNH NGHĨA BƯỚC ==
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'>Yêu cầu 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'>Thay đổi gói trong 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'>Gửi 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'>Ghi lại Thay đổi đã Lên lịch trong 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'>Vào Ngày Gia hạn, Gửi 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'>Hoàn tất Thay đổi Gói trong DB</p></div>"]
%% == KẾT NỐI ==
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
Các Tình Huống Sử Dụng
Tình Huống 1: Khởi Tạo và Ghi Lại Thay Đổi Gói Theo Lịch
Mô tả
User khởi tạo thay đổi gói (upgrade hoặc downgrade) từ ứng dụng. Hệ thống tạo Stripe Billing Portal session. Sau khi user xác nhận thay đổi trong portal, Stripe gửi webhook đến ứng dụng, sau đó ghi lại thay đổi đang chờ xử lý.
Các bước
Bước 1: User Khởi Tạo Thay Đổi Gói
- User yêu cầu thay đổi gói của họ qua UI của ứng dụng.
- Backend
SubscriptionControllertạo Stripe Billing Portal session và trả về URL cho client. - User được chuyển hướng đến Stripe portal, nơi họ chọn gói mới và xác nhận thay đổi, được lên lịch xảy ra vào cuối chu kỳ thanh toán hiện tại.
Bước 2: Hệ Thống Nhận Webhook
- Sau khi xác nhận, Stripe gửi webhook
customer.subscription.updated.
Bước 3: Ghi Lại Thay Đổi Theo Lịch
WebhookControllerđịnh tuyến event đếnSubscriptionLifeCycleHandler.- Phương thức
isPlanChanged()của handler trả vềtrue. - Phương thức
handlePlanChange()được gọi, thực hiện hai hành động trong một database transaction:- Nó cập nhật record
subscriptionschính, đặtsubscriptions.scheduled_plan_idthành ID của gói mới vàsubscriptions.scheduled_plan_change_atthành ngày gia hạn. - Nó tạo record
subscription_historiesmới để đại diện cho thay đổi sắp tới với:type:changestatus:pendingpayment_status:pendingold_plan_id: ID của gói hiện tại.
- Nó cập nhật record
Tình Huống 2: Thực Thi Thay Đổi Gói (Upgrade hoặc Downgrade sang Gói Trả Phí)
Mô tả
Vào đầu chu kỳ thanh toán mới, Stripe tự động cố gắng tính phí cho user cho gói mới đã lên lịch. Sau khi thanh toán thành công, nó gửi webhook (invoice.paid, customer.subscription.updated) mà hệ thống sử dụng để hoàn tất thay đổi gói.
Các bước
Bước 1: Nhận Webhook invoice.paid
- Stripe gửi webhook này để xác nhận thanh toán gia hạn cho gói mới đã thành công.
- Hệ thống tìm record history
changependingvà cập nhậtsubscription_histories.payment_statusthànhpaid.
Bước 2: Nhận Webhook customer.subscription.updated
- Ngay sau đó, Stripe gửi webhook cập nhật cho thấy đối tượng subscription hiện đã phản ánh chính thức gói mới.
Bước 3: Hoàn Tất Thay Đổi Gói
SubscriptionLifeCycleHandlerxử lý cập nhật này. Nó cập nhật recordsubscriptionschính:subscriptions.package_plan_idđược cập nhật thành ID của gói mới.- Các trường
subscriptions.scheduled_plan_idvàscheduled_plan_change_atđược xóa. subscriptions.deadline_atđược cập nhật đến cuối chu kỳ thanh toán mới.
- Record
subscription_historiescho thay đổi được đánh dấu làstatus:active.
Tình Huống 3: Thực Thi Thay Đổi Gói (Downgrade sang Gói Miễn Phí)
Mô tả
Nếu user downgrade xuống gói miễn phí, không có thanh toán nào được thực hiện vào ngày gia hạn. Stripe chỉ đơn giản cập nhật subscription để phản ánh gói miễn phí mới và gửi webhook customer.subscription.updated.
Các bước
Bước 1: Nhận Webhook customer.subscription.updated
- Vào cuối chu kỳ thanh toán, Stripe cập nhật subscription sang gói miễn phí và gửi webhook. Không có event
invoice.paidđược tạo.
Bước 2: Hoàn Tất Thay Đổi Gói
SubscriptionLifeCycleHandlerxử lý cập nhật này.- Nó cập nhật record
subscriptionschính như trong tình huống trả phí (ID gói mới, các trường lịch trình đã xóa, deadline mới). - Nó cập nhật record history
changepending, đặtstatusthànhactivevàpayment_statusthànhN/A, vì không có thanh toán nào được áp dụng.
Tình Huống 4: Thực Thi Thay Đổi Gói (Thanh Toán Thất Bại khi Upgrade)
Mô tả
Nếu thanh toán gia hạn cho gói mới đắt hơn thất bại, Stripe gửi webhook invoice.payment_failed. Subscription thường sẽ chuyển sang trạng thái past_due.
Các bước
Bước 1: Nhận Webhook invoice.payment_failed
- Stripe gửi event này khi thanh toán gia hạn cho gói đã nâng cấp thất bại.
Bước 2: Cập Nhật Trạng Thái sang Past Due
- Handler tìm record history
changependingvà cập nhậtsubscription_histories.payment_statusthànhfailed. - Nó cập nhật
subscriptions.statuschính thànhpast_due.
Bước 3: Stripe Retry và Hủy Cuối Cùng
- Tính năng Smart Retries của Stripe sẽ cố gắng thu tiền lại.
- Nếu tất cả các lần thử lại thất bại, Stripe sẽ tự động hủy subscription và gửi webhook
customer.subscription.deleted, mà hệ thống xử lý để đánh dấu subscription làCanceled.
Cấu Trúc Database Liên Quan
erDiagram
users {
bigint id PK
string name "Tên đầy đủ của user"
string email "Địa chỉ email của user (duy nhất)"
string payment_provider_customer_id "Customer ID từ 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 của gói sẽ thay đổi sang (nullable)"
timestamp scheduled_plan_change_at "Timestamp khi thay đổi gói có hiệu lực (nullable)"
timestamp deadline_at "Kết thúc chu kỳ thanh toán hiện tại"
timestamp canceled_at "Timestamp hủy (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 "ID gói trước đó, dùng cho type 'change'"
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 "Loại event"
enum status "pending, processing, completed, failed"
text error "Thông báo lỗi nếu xử lý thất bại (nullable)"
timestamp created_at
timestamp updated_at
}
users ||--o{ subscriptions : registers
subscriptions ||--o{ subscription_histories : has
API Endpoint Liên Quan
| Phương thức | Endpoint | Controller | Mô tả |
|---|---|---|---|
| POST | /api/v1/general/subscription/billing-portal | SubscriptionController | Tạo Stripe Billing Portal session để user quản lý subscription của họ. |
| POST | /api/v1/admin/stripe/webhook | WebhookController | Lắng nghe và xử lý tất cả các event đến từ Stripe liên quan đến subscription. |
Xử Lý Lỗi
-
Log:
- Tất cả lỗi API (ví dụ: tạo portal session) đều được ghi log.
- Tất cả lỗi xử lý webhook đều được ghi log và lưu trữ trong cột
errorcủa bảngstripe_webhook_events.
-
Chi Tiết Lỗi:
| Mã Trạng Thái | Thông Báo Lỗi | Mô Tả |
|---|---|---|
| 403 | "User không được ủy quyền quản lý subscription này." | User không phải là owner hoặc admin của nhóm. |
| 404 | "Không tìm thấy subscription đang hoạt động." | User không có subscription để thay đổi. |
| 500 | "Tạo Stripe Billing Portal session thất bại." | Đã xảy ra lỗi khi giao tiếp với Stripe API. |
Xử Lý Thay Đổi Gói Đồng Thời
Tình Huống: User Đã Có Pending Scheduled Change
Vấn đề: User đã lên lịch thay đổi gói (ví dụ: Basic → Pro vào ngày 1/2), sau đó attempts to schedule another change (ví dụ: Basic → Enterprise vào cùng ngày 1/2) trước khi scheduled change đầu tiên được thực thi.
Hành Vi Hệ Thống:
-
Stripe Behavior:
- Stripe CHỈ cho phép một scheduled change tại một thời điểm
- Khi user schedule change mới, Stripe sẽ replace scheduled change cũ
- Change mới sẽ override change cũ hoàn toàn
-
Database Behavior:
Trước: - subscriptions.scheduled_plan_id = Pro Plan ID - subscriptions.scheduled_plan_change_at = 2024-02-01 - subscription_histories: type='change', status='pending' (Pro Plan) Sau khi schedule change mới: - 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) -
Xử Lý History Records:
- System sẽ invalidate pending change history cũ:
UPDATE subscription_histories SET status = 'inactive' WHERE subscription_id = :id AND type = 'change' AND status = 'pending' AND old_plan_id IS NOT NULL; - Tạo new pending change history cho scheduled change mới
- System sẽ invalidate pending change history cũ:
Edge Cases:
| Scenario | Hành Động | Kết Quả |
|---|---|---|
| Multiple changes, queue | User attempts 3+ changes | Chỉ change cuối cùng được giữ lại |
| Multiple changes, rejection | System có thể reject change mới | Hiện tại KHÔNG implement - change mới luôn replace |
| Race condition | 2 changes đồng thời từ UI khác nhau | Last write wins (Stripe xử lý) |
Khuyến Nghị Cho Frontend/API Consumers:
⚠️ Cảnh báo user khi họ đã có pending scheduled change:
if (subscription.scheduled_plan_id) {
confirm(
`Bạn đã có thay đổi gói đã lên lịch sang ${scheduledPlan.name}. ` +
`Thay đổi mới sẽ thay thế thay đổi cũ. Tiếp tục?`
);
}
⚠️ Hiển thị pending change rõ ràng trong UI để user biết trạng thái hiện tại
Ghi Chú Bổ Sung
- Nguồn Chân Lý: Stripe vẫn là nguồn chân lý cho thanh toán và trạng thái subscription. Database của ứng dụng là bản sao local được cập nhật qua webhook.
- Proration: Bằng cách lên lịch thay đổi đến cuối chu kỳ thanh toán, luồng này tránh các tính toán proration phức tạp. User được tính phí đầy đủ cho gói mới vào ngày gia hạn.
- Trải Nghiệm User: Việc sử dụng Stripe Billing Portal cung cấp trải nghiệm user an toàn và nhất quán để quản lý phương thức thanh toán và thay đổi subscription.
Trạng thái tài liệu: Hoàn thành - Bao phủ tất cả tình huống thay đổi gói theo lịch