Chi Tiết Xử Lý Stripe Webhook

Mô Tả

Tài liệu này cung cấp chi tiết toàn diện về cách hệ thống xử lý webhook từ Stripe, đảm bảo tính idempotency (không trùng lặp), tính nhất quán dữ liệu và xử lý lỗi phù hợp. Nó bao gồm kiến trúc, xác minh webhook, định tuyến sự kiện và các best practices được triển khai trong codebase.

Hệ thống webhook là xương sống của quản lý subscription, vì tất cả các thay đổi trạng thái quan trọng đều bắt nguồn từ Stripe và được truyền đạt thông qua các sự kiện này. Xử lý đúng cách là điều cần thiết để duy trì đồng bộ giữa Stripe và database local.

Kiến Trúc Webhook

---
config:
  theme: base
  layout: dagre
  flowchart:
    curve: linear
    htmlLabels: true
  themeVariables:
    edgeLabelBackground: "transparent"
---
flowchart TD
    %% == CÁC NÚT ==
    Stripe((Stripe API))
    
    subgraph WebhookLayer["Lớp Webhook Controller"]
        WebhookController[WebhookController]
        Verification{Xác Minh<br/>Chữ Ký}
        Idempotency{Kiểm Tra<br/>Idempotency}
        Router{Định Tuyến<br/>Sự Kiện}
    end
    
    subgraph HandlerLayer["Lớp Event Handler"]
        SessionHandler[SubscriptionSessionHandler]
        LifecycleHandler[SubscriptionLifeCycleHandler]
        InvoiceHandler[SubscriptionInvoiceHandler]
        PaymentIntentHandler[PaymentIntentHandler]
    end
    
    subgraph Database["Lớp Database"]
        WebhookEventsDB[(stripe_webhook_events)]
        SubscriptionDB[(subscriptions)]
        HistoryDB[(subscription_histories)]
    end
    
    %% == KẾT NỐI ==
    Stripe -->|1. Gửi Event| WebhookController
    WebhookController --> Verification
    
    Verification -->|Hợp lệ| Idempotency
    Verification -->|Không hợp lệ| Error403[403 Forbidden]
    
    Idempotency -->|Kiểm tra| WebhookEventsDB
    WebhookEventsDB -->|Trạng thái| Idempotency
    
    Idempotency -->|Mới/Thất bại| Router
    Idempotency -->|Hoàn thành/Đang xử lý| Return200[200 OK]
    
    Router -->|checkout.session.completed| SessionHandler
    Router -->|customer.subscription.*| LifecycleHandler
    Router -->|invoice.*| InvoiceHandler
    Router -->|payment_intent.succeeded| PaymentIntentHandler
    
    SessionHandler --> SubscriptionDB
    SessionHandler --> HistoryDB
    LifecycleHandler --> SubscriptionDB
    LifecycleHandler --> HistoryDB
    InvoiceHandler --> SubscriptionDB
    InvoiceHandler --> HistoryDB
    PaymentIntentHandler --> HistoryDB
    
    SessionHandler --> UpdateWebhook[Cập Nhật Trạng Thái Event]
    LifecycleHandler --> UpdateWebhook
    InvoiceHandler --> UpdateWebhook
    PaymentIntentHandler --> UpdateWebhook
    
    UpdateWebhook --> WebhookEventsDB
    UpdateWebhook --> FinalResponse[200 OK]
    
    %% == STYLING ==
    style Stripe fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
    style WebhookLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
    style HandlerLayer fill:#f0f8e6,stroke:#339933,stroke-width:2px
    style Database fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
    style Error403 fill:#ffcccc,stroke:#cc0000,stroke-width:2px
    style Return200 fill:#ccffcc,stroke:#00cc00,stroke-width:2px
    style FinalResponse fill:#ccffcc,stroke:#00cc00,stroke-width:2px

Luồng Xử Lý Webhook Event

1. Xác Minh Chữ Ký

Mục đích: Đảm bảo request webhook thực sự đến từ Stripe và không bị giả mạo.

Triển khai:

// WebhookController.php
private function getLiveEventObject(Request $request): object
{
    $payload = $request->getContent();
    $sigHeader = $request->header('Stripe-Signature');
    $webhookSecret = config('stripe.config.webhook_secret');

    if (empty($sigHeader) || empty($webhookSecret)) {
        throw new \UnexpectedValueException('Thiếu chữ ký hoặc webhook secret');
    }

    // Stripe SDK xác minh chữ ký HMAC
    return Webhook::constructEvent($payload, $sigHeader, $webhookSecret);
}

Xử lý lỗi:

  • Thiếu chữ ký → 403 Forbidden
  • Chữ ký không hợp lệ → 403 Forbidden (ngăn chặn replay attacks)
  • Payload không đúng định dạng → 400 Bad Request

2. Kiểm Tra Idempotency

Mục đích: Ngăn chặn xử lý cùng một event nhiều lần (Stripe có thể retry webhook).

Triển khai:

// WebhookController.php
private function getOrCreateWebhookEvent(object $event, ?string $requestId): object
{
    $webhookEvent = $this->stripeWebhookEventRepository->findExistingEvent(
        $event->data->object['id'],
        $requestId,
        $event->type
    );

    $eventStatus = ($webhookEvent && $webhookEvent->status === StripeWebhookEventStatus::Failed->value)
        ? StripeWebhookEventStatus::Failed->value
        : StripeWebhookEventStatus::Pending->value;

    return $this->stripeWebhookEventRepository->updateOrCreate(
        [
            'stripe_event_id' => $event->data->object['id'],
            'request_id' => $requestId,
            'event_type' => $event->type
        ],
        ['status' => $eventStatus]
    );
}

Database Schema:

CREATE TABLE stripe_webhook_events (
    id BIGINT PRIMARY KEY AUTO_INCREMENT,
    stripe_event_id VARCHAR(255) NOT NULL,
    request_id VARCHAR(255),
    event_type VARCHAR(255) NOT NULL,
    status ENUM('pending', 'processing', 'completed', 'failed') NOT NULL DEFAULT 'pending',
    error TEXT,
    processed_at TIMESTAMP NULL,
    created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    
    -- Indexes for efficient filtering and lookup
    INDEX idx_status (status),
    INDEX idx_event_type (event_type),
    INDEX idx_stripe_event_id (stripe_event_id),
    INDEX idx_request_id (request_id),
    
    -- Unique constraint for idempotency
    UNIQUE KEY unique_event (stripe_event_id, request_id, event_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

Chuyển đổi trạng thái:

  • pending → Event đã tạo, chưa xử lý
  • processing → Đang xử lý
  • completed → Xử lý thành công
  • failed → Xử lý thất bại, sẽ retry

3. Định Tuyến Event

Các loại event được xử lý:

Loại Event Handler Mục đích
checkout.session.completed SubscriptionSessionHandler Kích hoạt subscription mới sau thanh toán
checkout.session.async_payment_succeeded SubscriptionSessionHandler Xử lý thanh toán async hoàn tất
customer.subscription.created (Bỏ qua) Đã xử lý qua checkout.session.completed
customer.subscription.updated SubscriptionLifeCycleHandler Thay đổi gói, hủy, gia hạn
customer.subscription.deleted SubscriptionLifeCycleHandler Hủy cuối cùng
invoice.paid SubscriptionInvoiceHandler Thanh toán thành công (gia hạn, đổi gói)
invoice.payment_failed SubscriptionInvoiceHandler Xử lý thanh toán thất bại
payment_intent.succeeded PaymentIntentHandler Cập nhật history với payment intent ID

Logic định tuyến:

// WebhookController.php
private array $eventHandlers = [
    WEBHOOK_EVENT_CHECKOUT_SESSION_COMPLETED => 'handleCheckoutSession',
    WEBHOOK_EVENT_SUBSCRIPTION_UPDATED => 'handleSubscriptionUpdated',
    WEBHOOK_EVENT_SUBSCRIPTION_DELETED => 'handleSubscriptionDeleted',
    WEBHOOK_EVENT_INVOICE_PAID => 'handleInvoicePaid',
    WEBHOOK_EVENT_INVOICE_PAYMENT_FAILED => 'handleInvoicePaymentFailed',
    WEBHOOK_EVENT_PAYMENT_INTENT_SUCCEEDED => 'handlePaymentIntentSucceeded'
];

4. Thực Thi Handler

An toàn Transaction: Tất cả handler đều wrap các thao tác database trong transaction:

// Ví dụ từ SubscriptionLifeCycleHandler
private function wrapTransaction($callback)
{
    return $this->subscriptionRepository->transaction($callback);
}

public function handleSubscriptionUpdated($session, $previousAttributes)
{
    return $this->wrapTransaction(function () use ($session, $previousAttributes) {
        // Tất cả thao tác DB ở đây là atomic
        $this->updateSubscription(...);
        $this->createHistory(...);
    });
}

Ví Dụ Thực Tế Từ Log (stripe-2026-01-06.log)

Các ví dụ dưới đây được trích từ stripe-2026-01-06.log để minh họa những case phổ biến. Log chỉ hiển thị các trường trọng yếu (ID, amount, invoice trạng thái). Interval thực tế (daily/monthly/yearly) được xác định từ price.recurring.interval trong payload Stripe.

1) Upgrade (Free -> Basic) — Đổi gói ngay, có invoice trả tiền

Luồng chính:

  • customer.subscription.updated -> SubscriptionLifeCycleHandler
  • invoice.paid + payment_intent.succeeded -> SubscriptionInvoiceHandler

Log tiêu biểu:

[2026-01-06 16:25:25] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO2IC6W0lx7trg9iz1f9Rn"}
[2026-01-06 16:25:26] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3417,"invoice_id":"in_1SmUdmC6W0lx7trg6wkk44yS","amount_due":2000,"invoice_status":"paid","payment_intent_status":"succeeded"}

Kết quả:

  • subscription_histories: type = change, payment_status = paid, có invoice_id, payment_intent_id.

2) Downgrade (Paid -> Free) — Đổi gói ngay, không phát sinh invoice

Luồng chính:

  • customer.subscription.updated -> SubscriptionLifeCycleHandler

Log tiêu biểu:

[2026-01-06 15:23:02] local.INFO: Immediate plan change detected {"subscription_id":231,"new_plan_id":"price_1RnD3yC6W0lx7trgicZwdJbN"}
[2026-01-06 15:23:02] local.INFO: Immediate plan change finalized with no invoice {"subscription_id":231,"change_history_id":3407,"new_plan_amount":0,"payment_status":"n/a"}

Kết quả:

  • subscription_histories: type = change, payment_status = n/a, không có invoice_id.

3) Change plan theo chu kỳ (daily/monthly/yearly)

Trong log có nhiều lần đổi plan ngay (immediate) với các price khác nhau; interval được đọc từ Stripe price:

[2026-01-06 16:27:36] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QZO4IC6W0lx7trg01Mh3Z5a"}
[2026-01-06 16:27:38] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3419,"invoice_id":"in_1SmUfvC6W0lx7trgvuZ48LUf","amount_due":999,"invoice_status":"paid","payment_intent_status":"succeeded"}

[2026-01-06 16:39:47] local.INFO: Immediate plan change detected {"subscription_id":234,"new_plan_id":"price_1QMoGlC6W0lx7trgnOM4q2YW"}
[2026-01-06 16:39:48] local.INFO: Immediate plan change finalized from Stripe latest_invoice (payment already settled) {"subscription_id":234,"change_history_id":3420,"invoice_id":"in_1SmUrgC6W0lx7trgAIdMSUN7","amount_due":56789,"invoice_status":"paid","payment_intent_status":"succeeded"}

Kết quả:

  • subscription_histories: type = change, payment_status = paid, interval lấy từ price.recurring.interval.

4) Cancel theo lịch (scheduled cancellation)

Luồng chính:

  • customer.subscription.updated -> SubscriptionLifeCycleHandler

Log tiêu biểu:

[2026-01-06 16:43:57] local.INFO: Subscription updated for scheduled cancellation {"subscription_id":234,"deadline_at":"2026-02-06 16:39:40","grace_period_end_at":"2026-02-13 16:39:40"}
[2026-01-06 16:43:57] local.INFO: Subscription marked for scheduled cancellation, and pending histories invalidated {"subscription_id":234,"cancellation_requested_at":"2026-01-06 16:43:55","cancellation_effective_at":"2026-02-06 16:39:40","invalidated_histories":[3421]}

Kết quả:

  • subscriptions.status chuyển sang pending_cancellation.
  • Các subscription_histories pending bị vô hiệu hóa để tránh tính quota sai.

5) Resume sau khi cancel theo lịch

Luồng chính:

  • customer.subscription.updated -> SubscriptionLifeCycleHandler (resume)

Log tiêu biểu:

[2026-01-06 16:50:26] local.INFO: Subscription status transition {"subscription_id":234,"from":"pending_cancellation","to":"active","context":{"event":"subscription.updated","stripe_subscription_id":"sub_1SmUd3C6W0lx7trg06YbgX1Y","action":"resume"}}
[2026-01-06 16:50:26] local.INFO: Subscription resumed from scheduled cancellation {"subscription_id":234}

Kết quả:

  • subscriptions.status quay lại active.
  • Lịch hủy bị xóa và history pending hủy được dọn.

Xử lý lỗi:

// WebhookController.php
private function processStripeEvent(object $event, object $webhookEvent): void
{
    try {
        $this->logStripe("Đang xử lý Stripe event: {$event->type}");

        $handler = $this->eventHandlers[$event->type];
        $item = (object)$event->data->object;
        $previousAttributes = isset($event->data->previous_attributes)
            ? (object)$event->data->previous_attributes
            : null;

        $this->{$handler}($item, $previousAttributes);

        $webhookEvent->update(['status' => StripeWebhookEventStatus::Completed->value]);
    } catch (\Throwable $th) {
        $webhookEvent->update([
            'status' => StripeWebhookEventStatus::Failed->value,
            'error' => $th->getMessage()
        ]);
        $this->logStripe("Lỗi xử lý event {$event->type}: " . $th->getMessage());
        throw $th; // Sẽ trả về 500, trigger Stripe retry
    }
}

Các Biện Pháp Đảm Bảo Tính Toàn Vẹn Dữ Liệu

1. Toàn vẹn Foreign Key

Vấn đề: packageToProvider->package_id có thể tham chiếu đến package không tồn tại.

Giải pháp: Luôn sử dụng chuỗi relationship:

// SAI
'package_id' => $packageToProvider->package_id,

// ĐÚNG
$packagePlan = $packagePlanToProvider->packagePlan;
'package_id' => $packagePlan->package_id,

2. Ngăn Chặn History Trùng Lặp

Cơ chế kiểm tra:

// Kiểm tra history hiện có trong khoảng thời gian cho phép (±5 giây)
$periodStart = format_timestamp($session->current_period_start);
$periodStartCarbon = \Carbon\Carbon::parse($periodStart);

$existingHistory = $this->subscriptionHistoryRepository
    ->findWhere([
        'subscription_id' => $subscription->id,
        'type' => $type->value,
    ])
    ->filter(function($history) use ($periodStartCarbon) {
        $historyStart = \Carbon\Carbon::parse($history->started_at);
        return abs($historyStart->diffInSeconds($periodStartCarbon)) <= 5;
    })
    ->first();

if ($existingHistory) {
    return $existingHistory; // Bỏ qua tạo mới
}

Tại sao dung sai 5 giây?

  • Stripe có thể có sự khác biệt nhỏ về timestamp giữa các webhook event
  • period_start của Invoice vs current_period_start của Subscription có thể chênh vài giây

3. An Toàn Null

Tất cả truy cập array/object đều sử dụng accessor an toàn:

  • data_get() cho nested array
  • Null coalescing (??) cho object property
  • Kiểm tra null tường minh trước thao tác

Xử Lý Lỗi và Ghi Log

Các Mức Log

Info: Thao tác bình thường

$this->logStripe('Subscription cập nhật thành công', [
    'subscription_id' => $subscription->id
]);

Warning: Tình huống không mong đợi nhưng có thể phục hồi

$this->logStripeWarning('Invoice không có billing_reason', [
    'invoice_id' => $session->id
]);

Error: Lỗi nghiêm trọng

$this->logStripeError('Tạo change history thất bại', [
    'error' => $th->getMessage(),
    'trace' => $th->getTraceAsString(),
]);

Thông Báo Slack

Lỗi nghiêm trọng cũng gửi alert Slack:

$this->sendSlack('Nghiêm trọng: Kích hoạt subscription thất bại', $context, NOTIFICATION_ERROR);

Testing và Debug

Test Webhook Local

Sử dụng Stripe CLI:

stripe listen --forward-to http://localhost/api/v1/admin/stripe/webhook

Test thủ công với Postman:

  • Sử dụng header: X-Test-Event: customer.subscription.updated
  • Xác minh chữ ký được bypass trong môi trường local

Log Webhook

Kiểm tra lịch sử xử lý trong bảng stripe_webhook_events:

SELECT * FROM stripe_webhook_events 
WHERE event_type = 'customer.subscription.updated'
ORDER BY created_at DESC
LIMIT 10;

Log Ứng Dụng

Tất cả xử lý webhook được ghi vào storage/logs/stripe-{date}.log:

[2025-12-17 17:20:43] local.INFO: Đang xử lý Stripe event: customer.subscription.updated
[2025-12-17 17:20:43] local.INFO: Subscription cập nhật thành công {"subscription_id":196}

Best Practices

  1. Luôn xác minh chữ ký webhook trong production
  2. Triển khai idempotency sử dụng kiểm tra database-level
  3. Sử dụng transaction cho cập nhật nhiều bảng
  4. Ghi log toàn diện với context
  5. Xử lý null/dữ liệu thiếu một cách graceful
  6. Trả về 200 OK nhanh chóng để tránh Stripe retry
  7. Xử lý event bất đồng bộ nếu thao tác chậm (dùng queue)
  8. Giám sát event thất bại và triển khai logic retry
  9. Test kỹ race condition
  10. Giữ handler tập trung vào single responsibility

Trạng thái tài liệu: Hoàn thành - Hệ thống xử lý webhook production-ready