Thay Đổi Gói Ngay Lập Tức (Upgrade/Downgrade)
Mô Tả
Tài liệu này mô tả quy trình thay đổi gói subscription với hiệu lực ngay lập tức. Không giống như thay đổi gói đã lên lịch, thay đổi ngay lập tức được áp dụng ngay, có thể giữa chu kỳ thanh toán, và liên quan đến tính toán tỷ lệ (proration) phức tạp được Stripe quản lý hoàn toàn. Hệ thống backend xử lý điều này thông qua các webhook event, đảm bảo tính nhất quán dữ liệu và theo dõi đúng cách cả upgrade (lên gói đắt hơn) và downgrade (xuống gói rẻ hơn hoặc miễn phí).
Thách thức chính được giải quyết ở đây là race condition giữa các event customer.subscription.updated và invoice.paid, có thể đến theo bất kỳ thứ tự nào. Hệ thống được thiết kế để xử lý cả hai tình huống một cách graceful.
Điều kiện tiên quyết:
- User có subscription trả phí đang hoạt động
- User có quyền quản lý subscription cho nhóm của họ
- Thay đổi gói được cấu hình cho thanh toán ngay lập tức (không lên lịch cho chu kỳ tiếp theo)
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"]
WebhookController[WebhookController]
end
subgraph ApiServiceLayer["Lớp API Service"]
SubscriptionService(SubscriptionService)
LifecycleHandler(SubscriptionLifeCycleHandler)
InvoiceHandler(SubscriptionInvoiceHandler)
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 / API))
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'>Thay đổi gói ngay</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/>(Gói đã thay đổi)</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/>(Phí tính theo tỷ lệ)</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'>Tạo/Cập nhật History Thay đổi</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'>Hoàn tất với Chi tiết Thanh toán</p></div>"]
%% == KẾT NỐI ==
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
Các Tình Huống Sử Dụng
Tình Huống 1: Upgrade Ngay Lập Tức (subscription.updated đến TRƯỚC)
Mô tả
Khi user upgrade lên gói đắt hơn ngay lập tức, Stripe:
- Tính tỷ lệ thời gian còn lại trên gói cũ (tạo credit)
- Tính phí cho gói mới (tạo charge)
- Gửi webhook
customer.subscription.updated(gói đã thay đổi) - Gửi webhook
invoice.paid(vớibilling_reason: subscription_update)
Trong tình huống này, event subscription.updated đến trước. Hệ thống tạo history record pending, sau đó được hoàn tất bởi event invoice.paid tiếp theo.
Các bước
Bước 1: Nhận Webhook customer.subscription.updated
- Stripe gửi cái này đầu tiên khi thay đổi gói được áp dụng
- Event chứa chi tiết gói mới trong
items.data[0].plan previous_attributeschứa chi tiết gói cũ
Bước 2: Tạo History Thay đổi Pending
LifecycleHandlerphát hiện đây là thay đổi gói ngay lập tức (không được lên lịch)- Tạo record
subscription_historiesmới:type:changepayment_status:pendingstarted_at: bắt đầu chu kỳ mớiexpires_at: kết thúc chu kỳ mớiold_plan_id: từprevious_attributes.items.data[0].plan.id
- Cập nhật bảng
subscriptionsvớipackage_plan_idmới
Bước 3: Nhận Webhook invoice.paid
- Stripe gửi cái này sau khi xử lý phí tính theo tỷ lệ
- Chứa
billing_reason: 'subscription_update' - Các dòng invoice hiển thị cả credit (gói cũ) và charge (gói mới)
Bước 4: Hoàn Tất Chi Tiết Thanh Toán
InvoiceHandlertìm history thay đổi đang pending- Cập nhật history với thông tin thanh toán:
payment_status:paidpaid_at: timestamp thanh toáninvoice_id: Stripe invoice IDpayment_intent_id: Payment Intent IDamount: số tiền phí tính theo tỷ lệold_plan_id: trích xuất từ credit line item
Tình Huống 2: Downgrade Ngay Lập Tức Xuống Gói Miễn Phí (invoice.paid đến TRƯỚC)
Mô tả
Khi downgrade xuống gói miễn phí, Stripe tạo invoice $0 chỉ với credit line (số tiền âm) đại diện cho thời gian chưa sử dụng từ gói cũ. Event invoice.paid có thể đến trước subscription.updated.
Trong tình huống race condition này, InvoiceHandler tạo history thay đổi trực tiếp từ dữ liệu invoice.
Các bước
Bước 1: Nhận Webhook invoice.paid (Trước)
- Chứa
billing_reason: 'subscription_update' amount_due: 0 (gói miễn phí)- Các dòng invoice: Một credit line (số tiền âm) cho gói cũ
Bước 2: Tạo History từ Dữ Liệu Invoice (Race Condition Handler)
InvoiceHandler.createChangeHistoryFromInvoice()được gọi- Phát hiện đây là downgrade xuống gói miễn phí:
amount_paid== 0- Chỉ tồn tại credit line (amount < 0)
- Trích xuất
old_plan_idtừ credit line item - Lấy thông tin gói mới từ Stripe Subscription API
- Tạo record
subscription_histories:type:changepayment_status:n/a(giao dịch không có phí)old_plan_id: từ credit lineamount: 0
Bước 3: Nhận Webhook customer.subscription.updated (Sau)
- Đến sau khi xử lý invoice
Bước 4: Bỏ Qua Tạo History Trùng Lặp
LifecycleHandlerkiểm tra history thay đổi hiện có:- Khớp theo
subscription_id,type:change, vàstarted_at(dung sai 5 giây)
- Khớp theo
- Tìm thấy history đã được tạo bởi
invoice.paid - Chỉ cập nhật bảng
subscriptionsvới thông tin gói mới - KHÔNG tạo history record trùng lặp
Cấu Trúc Database Liên Quan
erDiagram
subscriptions {
bigint id PK
bigint package_id FK
bigint package_plan_id FK "Cập nhật ngay lên gói mới"
string status "Vẫn là 'active'"
string scheduled_plan_id "NULL cho thay đổi ngay"
timestamp deadline_at "Cập nhật lên kết thúc chu kỳ mới"
timestamp grace_period_end_at "Cập nhật tương ứng"
}
subscription_histories {
bigint id PK
bigint subscription_id FK
string type "Đặt thành 'change'"
string payment_status "pending → paid (hoặc n/a cho miễn phí)"
string old_plan_id "ID gói trước đó"
string invoice_id "Stripe invoice ID"
string payment_intent_id "Payment intent (nếu đã trả)"
decimal amount "Phí tính theo tỷ lệ (có thể là 0 cho miễn phí)"
timestamp started_at "Bắt đầu chu kỳ mới"
timestamp expires_at "Kết thúc chu kỳ mới"
timestamp paid_at "Timestamp thanh toán (nếu đã trả)"
}
stripe_webhook_events {
bigint id PK
string stripe_event_id "Ngăn xử lý trùng lặp"
string request_id "Request ID từ Stripe cho idempotency"
string event_type
enum status "pending, processing, completed, failed"
}
subscriptions ||--o{ subscription_histories : has
API Endpoint Liên Quan
| Phương thức | Endpoint | Controller | Mô tả |
|---|---|---|---|
| POST | /api/v1/admin/stripe/webhook | WebhookController@handleWebhook | Xử lý tất cả webhook event, bao gồm thay đổi gói |
Xử Lý Lỗi
| Mã Trạng Thái | Thông Báo Lỗi | Mô Tả |
|---|---|---|
| 400 | Payload webhook không hợp lệ | Dữ liệu webhook bị lỗi |
| 500 | Xử lý thay đổi gói thất bại | Lỗi database hoặc logic trong quá trình xử lý |
Ghi Chú Bổ Sung
Xử Lý Race Condition
Hệ thống được thiết kế để xử lý webhook event đến theo bất kỳ thứ tự nào:
- Kiểm Tra Idempotency: Sử dụng bảng
stripe_webhook_eventsvới kết hợpstripe_event_id + request_id + event_type - Ngăn Chặn History Trùng Lặp: Kiểm tra history record hiện có bằng:
subscription_idtype(ví dụ:change)started_at(dung sai ±5 giây để xử lý sự khác biệt timestamp)
- Logic Tạo-từ-Invoice:
InvoiceHandler.createChangeHistoryFromInvoice()có thể tái tạo history đầy đủ chỉ từ dữ liệu invoice
Tính Toán Proration
- Hoàn toàn được Stripe quản lý
- Credit cho thời gian chưa sử dụng trên gói cũ
- Charge cho gói mới từ ngày thay đổi
- Hệ thống chỉ ghi lại số tiền cuối cùng
Logic Payment Status
- Gói trả phí → Gói trả phí (upgrade/downgrade):
payment_status = paid, amount > 0 - Gói trả phí → Gói miễn phí:
payment_status = n/a, amount = 0 - Gói miễn phí → Gói trả phí:
payment_status = paid, charge toàn bộ số tiền
Cập Nhật Package ID
- Hệ thống sử dụng
packagePlan->package_id(từ relationship) thay vìpackageToProvider->package_id - Ngăn chặn vi phạm ràng buộc foreign key
- Đảm bảo tính toàn vẹn dữ liệu
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 ngay lập tức và race condition