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.updatedinvoice.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:

  1. Tính tỷ lệ thời gian còn lại trên gói cũ (tạo credit)
  2. Tính phí cho gói mới (tạo charge)
  3. Gửi webhook customer.subscription.updated (gói đã thay đổi)
  4. Gửi webhook invoice.paid (với billing_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_attributes chứa chi tiết gói cũ

Bước 2: Tạo History Thay đổi Pending

  • LifecycleHandler phá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_histories mới:
    • type: change
    • payment_status: pending
    • started_at: bắt đầu chu kỳ mới
    • expires_at: kết thúc chu kỳ mới
    • old_plan_id: từ previous_attributes.items.data[0].plan.id
  • Cập nhật bảng subscriptions với package_plan_id mớ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

  • InvoiceHandler tìm history thay đổi đang pending
  • Cập nhật history với thông tin thanh toán:
    • payment_status: paid
    • paid_at: timestamp thanh toán
    • invoice_id: Stripe invoice ID
    • payment_intent_id: Payment Intent ID
    • amount: 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_id từ credit line item
  • Lấy thông tin gói mới từ Stripe Subscription API
  • Tạo record subscription_histories:
    • type: change
    • payment_status: n/a (giao dịch không có phí)
    • old_plan_id: từ credit line
    • amount: 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

  • LifecycleHandler kiểm tra history thay đổi hiện có:
    • Khớp theo subscription_id, type:change, và started_at (dung sai 5 giây)
  • Tìm thấy history đã được tạo bởi invoice.paid
  • Chỉ cập nhật bảng subscriptions vớ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:

  1. Kiểm Tra Idempotency: Sử dụng bảng stripe_webhook_events với kết hợp stripe_event_id + request_id + event_type
  2. Ngăn Chặn History Trùng Lặp: Kiểm tra history record hiện có bằng:
    • subscription_id
    • type (ví dụ: change)
    • started_at (dung sai ±5 giây để xử lý sự khác biệt timestamp)
  3. 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