Tài Liệu Tham Khảo Stripe Webhook Events

Mô Tả

Tài liệu này cung cấp tài liệu tham khảo toàn diện cho tất cả các Stripe webhook event mà hệ thống xử lý, bao gồm mục đích, cấu trúc dữ liệu, logic xử lý và kết quả mong đợi. Sử dụng làm hướng dẫn tham khảo nhanh khi debug hoặc mở rộng chức năng webhook.

Tổng Quan Xử Lý Event

graph TD
    A[Stripe Event] --> B{Loại Event}
    
    B -->|checkout.session.*| C[SubscriptionSessionHandler]
    B -->|customer.subscription.*| D[SubscriptionLifeCycleHandler]
    B -->|invoice.*| E[SubscriptionInvoiceHandler]
    B -->|payment_intent.succeeded| F[PaymentIntentHandler]
    
    C --> G[(Database)]
    D --> G
    E --> G
    F --> G
    
    style A fill:#fcd9d9,stroke:#cc3333
    style C fill:#f0f8e6,stroke:#339933
    style D fill:#f0f8e6,stroke:#339933
    style E fill:#f0f8e6,stroke:#339933
    style F fill:#f0f8e6,stroke:#339933
    style G fill:#ffe6cc,stroke:#ff9900

Checkout Events

checkout.session.completed

Mục đích: Kích hoạt subscription mới sau thanh toán thành công qua Stripe Checkout.

Khi nào trigger:

  • User hoàn tất thanh toán trong Stripe Checkout
  • Cho cả gói miễn phí và trả phí

Handler: SubscriptionSessionHandler::handleSessionCompleted()

Các trường dữ liệu chính:

{
  "id": "cs_test_...",
  "object": "checkout.session",
  "mode": "subscription",
  "subscription": "sub_...",
  "invoice": "in_...",
  "customer": "cus_...",
  "metadata": {
    "subscription_slug": "sub_abc123"
  },
  "amount_total": 1000,
  "currency": "jpy",
  "payment_status": "paid"
}

Logic xử lý:

  1. Tìm subscription local bằng metadata.subscription_slug
  2. Xác minh subscription status là unpaid
  3. Lock subscription record
  4. Cập nhật subscription:
    • status: unpaidactive
    • payment_provider_subscription_id: Stripe subscription ID
    • deadline_at: Thời điểm kết thúc từ Stripe
  5. Cập nhật subscription_histories:
    • payment_status: pendingpaid
    • invoice_id, payment_intent_id, paid_at

Kết quả mong đợi:

  • Subscription được kích hoạt
  • User có quyền truy cập dịch vụ
  • History record được đánh dấu là đã thanh toán

Event liên quan:

  • invoice.paid (bỏ qua cho subscription mới để tránh xử lý trùng lặp)

Subscription Lifecycle Events

customer.subscription.updated

Mục đích: Xử lý tất cả các event thay đổi subscription (đổi gói, hủy, thay đổi trạng thái).

Khi nào trigger:

  • Thay đổi gói (upgrade/downgrade)
  • Lên lịch hủy
  • Khôi phục hủy
  • Thay đổi trạng thái (active → past_due, v.v.)
  • Gia hạn chu kỳ
  • Tạm dừng/tiếp tục subscription

Handler: SubscriptionLifeCycleHandler::handleSubscriptionUpdated()

Các trường dữ liệu chính:

{
  "id": "sub_...",
  "object": "subscription",
  "status": "active",
  "items": {
    "data": [{
      "plan": {
        "id": "price_...",
        "product": "prod_...",
        "interval": "month"
      }
    }]
  },
  "current_period_start": 1702857600,
  "current_period_end": 1705536000,
  "cancel_at_period_end": false,
  "canceled_at": null,
  "previous_attributes": {
    "items": { /* gói cũ */ },
    "cancel_at_period_end": true
  }
}

Logic xử lý (Cây quyết định):

1. Là incomplete_expired? → handleIncompleteExpired()
2. Là kích hoạt miễn phí? → handleFreeSubscriptionActivation()
3. Là hủy đã lên lịch? → handleScheduledCancellation()
4. Là quá hạn? → handlePastDue()
5. Là tạm dừng? → handleSubscriptionPause()
6. Là tiếp tục từ tạm dừng? → handleSubscriptionResumeFromPause()
7. Là tiếp tục từ hủy? → handleSubscriptionResume()
8. Là áp dụng thay đổi gói đã lên lịch? → handleScheduledPlanChangeApplication()
9. Là thay đổi gói? → handlePlanChange()
   - Thay đổi ngay? → handleImmediatePlanChange()
   - Thay đổi đã lên lịch? → handleScheduledPlanChange()
10. Là tự động gia hạn? → handleAutoRenewal()
11. Là reset chu kỳ thanh toán? → handleBillingCycleReset()
12. Mặc định: handleRegularUpdate()

Các tình huống:

Tình huống Tiêu chí phát hiện Hành động
Hủy đã lên lịch cancel_at_period_end: true Cập nhật status thành pending_cancellation, tạo history hủy
Khôi phục hủy previous: cancel_at_period_end=true, current: false Trở về active, xóa history hủy
Thay đổi gói ngay Plan ID thay đổi + period start gần đây (<2 phút) Tạo change history, cập nhật package_plan_id
Áp dụng thay đổi gói đã lên lịch scheduled_plan_id khớp gói hiện tại + period thay đổi Hoàn tất change history
Tự động gia hạn Period thay đổi, plan không đổi, status=active Tạo renewal history (pending, hoàn tất bởi invoice.paid)
Quá hạn status: 'past_due' Cập nhật status, kéo dài grace period

Kết quả mong đợi:

  • Subscription status được cập nhật trong database
  • History record được tạo hoặc cập nhật
  • Quyền truy cập của user được điều chỉnh phù hợp

customer.subscription.deleted

Mục đích: Xử lý hủy subscription cuối cùng.

Khi nào trigger:

  • Subscription bị hủy ngay lập tức
  • Chu kỳ hủy đã lên lịch kết thúc
  • Thanh toán thất bại hết số lần retry

Handler: SubscriptionLifeCycleHandler::handleSubscriptionDeleted()

Các trường dữ liệu chính:

{
  "id": "sub_...",
  "object": "subscription",
  "status": "canceled",
  "canceled_at": 1702857600,
  "current_period_end": 1705536000,
  "cancellation_details": {
    "reason": "cancellation_requested",
    "comment": "User yêu cầu hủy",
    "feedback": "too_expensive"
  }
}

Logic xử lý:

  1. Tìm subscription local bằng provider_subscription_id
  2. Cập nhật subscription:
    • status: → canceled
    • canceled_at: Timestamp từ Stripe
    • canceled_reason: Từ cancellation_details
  3. Tạo history record immediate_cancellation
  4. Xóa pending invoice items (nếu có)

Kết quả mong đợi:

  • Subscription bị hủy trong database
  • Quyền truy cập của user bị chấm dứt (hoặc tiếp tục đến grace_period_end_at)
  • History hủy được tạo

Invoice Events

invoice.paid

Mục đích: Xử lý thanh toán invoice thành công (gia hạn, đổi gói).

Khi nào trigger:

  • Thanh toán định kỳ thành công
  • Thanh toán đổi gói thành công
  • Thanh toán subscription ban đầu thành công (bỏ qua, xử lý bởi checkout.session.completed)

Handler: SubscriptionInvoiceHandler::handleInvoicePaid()

Các trường dữ liệu chính:

{
  "id": "in_...",
  "object": "invoice",
  "subscription": "sub_...",
  "billing_reason": "subscription_cycle",
  "amount_paid": 1000,
  "amount_due": 1000,
  "currency": "jpy",
  "payment_intent": "pi_...",
  "period_start": 1702857600,
  "period_end": 1705536000,
  "lines": {
    "data": [{
      "plan": { "id": "price_...", "interval": "month" },
      "amount": 1000
    }]
  }
}

Logic xử lý (Dựa trên billing_reason):

1. subscription_create:

  • Bỏ qua - Đã xử lý bởi checkout.session.completed
  • Ngăn tạo history trùng lặp

2. subscription_cycle (Tự động gia hạn):

  • Tìm subscription bằng provider_subscription_id
  • Tạo history record mới:
    • type: renewal
    • payment_status: paid
    • invoice_id, payment_intent_id, paid_at
  • Cập nhật deadline_at của subscription

3. subscription_update (Đổi gói):

  • Kiểm tra change history đang pending
  • Nếu tìm thấy (luồng bình thường):
    • Cập nhật history với chi tiết thanh toán
    • Đặt payment_status: paid
  • Nếu KHÔNG tìm thấy (race condition - invoice.paid đến trước):
    • Gọi createChangeHistoryFromInvoice()
    • Trích xuất old_plan_id từ credit line (amount < 0)
    • Trích xuất new_plan_id từ charge line (amount > 0) hoặc Stripe API
    • Tạo history record đầy đủ

Kết quả mong đợi:

  • Gia hạn: Chu kỳ history mới được tạo, deadline được kéo dài
  • Đổi gói: Thanh toán được ghi nhận, thay đổi được hoàn tất

invoice.payment_failed

Mục đích: Xử lý thanh toán định kỳ thất bại.

Khi nào trigger:

  • Thanh toán tự động thất bại
  • Không đủ tiền, thẻ hết hạn, v.v.

Handler: SubscriptionInvoiceHandler::handleInvoicePaymentFailed()

Các trường dữ liệu chính:

{
  "id": "in_...",
  "object": "invoice",
  "subscription": "sub_...",
  "billing_reason": "subscription_cycle",
  "attempt_count": 1,
  "amount_due": 1000,
  "next_payment_attempt": 1703030400
}

Logic xử lý:

  1. Tìm subscription bằng provider_subscription_id
  2. Tìm history record mới nhất
  3. Nếu history mới nhất là paid (lần thất bại đầu tiên):
    • Tạo history mới:
      • type: renewal
      • payment_status: failed
      • payment_attempt: 1
  4. Nếu history mới nhất là failed (retry thất bại):
    • Cập nhật history:
      • payment_attempt: + 1

Lưu ý:

  • Thay đổi subscription status (activepast_duecanceled) được xử lý bởi event customer.subscription.updated / deleted
  • Event này chỉ theo dõi các lần thử thanh toán

Kết quả mong đợi:

  • Thanh toán thất bại được ghi log
  • Số lần thử thanh toán được tăng
  • Chờ logic retry của Stripe hoặc thay đổi status cuối cùng

Payment Intent Events

payment_intent.succeeded

Mục đích: Cập nhật history record với payment intent ID để theo dõi.

Khi nào trigger:

  • Bất kỳ thanh toán nào được xử lý thành công

Handler: PaymentIntentHandler::handlePaymentIntentSucceeded()

Các trường dữ liệu chính:

{
  "id": "pi_...",
  "object": "payment_intent",
  "invoice": "in_...",
  "amount": 1000,
  "status": "succeeded"
}

Logic xử lý:

  1. Tìm history bằng invoice_id với payment_intent_id IS NULL
  2. Cập nhật history: payment_intent_id = pi_...

Lưu ý: Chủ yếu để đảm bảo tính toàn vẹn dữ liệu, vì invoice.paid đã điền hầu hết thông tin thanh toán.


Các Pattern Xử Lý Event

Pattern 1: Idempotency

Tất cả event đều được kiểm tra với bảng stripe_webhook_events:

if ($webhookEvent->status === 'completed') {
    return '200 OK - Đã xử lý rồi';
}
if ($webhookEvent->status === 'processing') {
    return '200 OK - Đang xử lý';
}
// Chỉ xử lý nếu mới hoặc thất bại

Pattern 2: Xử Lý Race Condition

Event đổi gói có thể đến theo bất kỳ thứ tự nào:

Tình huống A:
1. customer.subscription.updated → Tạo history pending
2. invoice.paid → Hoàn tất history với thanh toán

Tình huống B:
1. invoice.paid → Tạo history đầy đủ từ dữ liệu invoice
2. customer.subscription.updated → Phát hiện history đã có, bỏ qua tạo

Triển khai:

  • Kiểm tra history hiện có bằng subscription_id + type + started_at (dung sai ±5 giây)
  • Nếu tìm thấy thì cập nhật, nếu không thì tạo từ dữ liệu có sẵn

Pattern 3: An Toàn Transaction

Tất cả thay đổi database được wrap trong transaction:

return $this->wrapTransaction(function () use (...) {
    // Tất cả thao tác DB là atomic
    $this->updateSubscription(...);
    $this->createHistory(...);
});

Pattern 4: An Toàn Null

Tất cả truy cập dữ liệu webhook sử dụng phương thức an toàn:

// Tốt
$planId = data_get($session, 'items.data.0.plan.id');
$billingReason = $session->billing_reason ?? null;

// Không tốt
$planId = $session->items['data'][0]['plan']['id']; // Có thể crash

Debug Vấn Đề Webhook

Kiểm Tra Trạng Thái Xử Lý

SELECT 
    stripe_event_id,
    event_type,
    status,
    error,
    created_at,
    processed_at
FROM stripe_webhook_events
WHERE event_type LIKE '%subscription%'
ORDER BY created_at DESC
LIMIT 20;

Tìm Event Thất Bại

SELECT * FROM stripe_webhook_events
WHERE status = 'failed'
ORDER BY created_at DESC;

Xác Minh Tạo History

SELECT 
    sh.id,
    sh.type,
    sh.payment_status,
    sh.invoice_id,
    sh.started_at,
    sh.created_at
FROM subscription_histories sh
WHERE sh.subscription_id = :subscription_id
ORDER BY sh.created_at DESC;

Log Cần Kiểm Tra

  • storage/logs/stripe-{date}.log - Tất cả xử lý webhook
  • storage/logs/laravel.log - Lỗi ứng dụng chung

Bảng Tham Khảo Nhanh

Event Billing Reason Phương thức Handler Tạo History? Cập nhật Subscription?
checkout.session.completed N/A handleSessionCompleted() Không (cập nhật có sẵn)
invoice.paid subscription_create (bỏ qua) Không Không
invoice.paid subscription_cycle handleAutoRenewalInvoice() Có (renewal) Có (deadline)
invoice.paid subscription_update handlePlanChangeInvoice() Có/Cập nhật (change) Có (deadline)
invoice.payment_failed Bất kỳ handleInvoicePaymentFailed() Có/Cập nhật (failed) Không
customer.subscription.updated N/A handleSubscriptionUpdated() Thay đổi
customer.subscription.deleted N/A handleSubscriptionDeleted() Có (cancellation)
payment_intent.succeeded N/A handlePaymentIntentSucceeded() Không (cập nhật có sẵn) Không

Trạng thái tài liệu: Tài liệu tham khảo đầy đủ cho tất cả webhook event