Stripe Webhookイベントリファレンス

説明

このドキュメントは、システムが処理するすべてのStripe webhookイベントの包括的なリファレンスを提供し、その目的、データ構造、処理ロジック、および期待される結果を含みます。webhook機能をデバッグまたは拡張する際のクイックリファレンスガイドとして使用してください。

イベント処理概要

graph TD
    A[Stripeイベント] --> B{イベントタイプ}
    
    B -->|checkout.session.*| C[SubscriptionSessionHandler]
    B -->|customer.subscription.*| D[SubscriptionLifeCycleHandler]
    B -->|invoice.*| E[SubscriptionInvoiceHandler]
    B -->|payment_intent.succeeded| F[PaymentIntentHandler]
    
    C --> G[(データベース)]
    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.session.completed

目的: Stripeチェックアウトを介した支払い成功後に新規サブスクリプションを有効化します。

トリガー時:

  • ユーザーがStripeチェックアウトで支払いを完了
  • 有料プランと無料プランの両方

ハンドラー: SubscriptionSessionHandler::handleSessionCompleted()

主要データフィールド:

{
  "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"
}

処理ロジック:

  1. metadata.subscription_slugでローカルサブスクリプションを検索
  2. サブスクリプションステータスがunpaidであることを確認
  3. サブスクリプションレコードをロック
  4. サブスクリプションを更新:
    • status: unpaidactive
    • payment_provider_subscription_id: StripeサブスクリプションID
    • deadline_at: Stripeからの期間終了
  5. subscription_historiesを更新:
    • payment_status: pendingpaid
    • invoice_id, payment_intent_id, paid_at

期待される結果:

  • サブスクリプション有効化
  • ユーザーがサービスにアクセス可能
  • 履歴レコードが支払済みとしてマーク

関連イベント:

  • invoice.paid(重複処理を防ぐため、新規サブスクリプションでは無視)

サブスクリプションライフサイクルイベント

customer.subscription.updated

目的: すべてのサブスクリプション変更イベントを処理します(プラン変更、キャンセル、ステータス変更)。

トリガー時:

  • プラン変更(アップグレード/ダウングレード)
  • キャンセルスケジュール
  • キャンセル再開
  • ステータス変更(active → past_due など)
  • 期間更新
  • サブスクリプション一時停止/再開

ハンドラー: SubscriptionLifeCycleHandler::handleSubscriptionUpdated()

主要データフィールド:

{
  "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": { /* 旧プラン */ },
    "cancel_at_period_end": true
  }
}

処理ロジック(決定ツリー):

1. incomplete_expired? → handleIncompleteExpired()
2. 無料有効化? → handleFreeSubscriptionActivation()
3. スケジュールされたキャンセル? → handleScheduledCancellation()
4. 支払期限切れ? → handlePastDue()
5. 一時停止? → handleSubscriptionPause()
6. 一時停止から再開? → handleSubscriptionResumeFromPause()
7. キャンセルから再開? → handleSubscriptionResume()
8. スケジュールされたプラン変更適用? → handleScheduledPlanChangeApplication()
9. プラン変更? → handlePlanChange()
   - 即時変更? → handleImmediatePlanChange()
   - スケジュール変更? → handleScheduledPlanChange()
10. 自動更新? → handleAutoRenewal()
11. 請求サイクルリセット? → handleBillingCycleReset()
12. デフォルト: handleRegularUpdate()

シナリオ:

シナリオ 検出基準 アクション
スケジュールされたキャンセル cancel_at_period_end: true ステータスをpending_cancellationに更新、キャンセル履歴を作成
キャンセル再開 previous: cancel_at_period_end=true, current: false activeに戻す、キャンセル履歴を削除
即時プラン変更 プランID変更 + 期間開始が最近(<2分) 変更履歴を作成、package_plan_idを更新
スケジュールされたプラン変更適用 scheduled_plan_idが現在のプランと一致 + 期間変更 変更履歴を確定
自動更新 期間変更、プラン不変、status=active 更新履歴を作成(保留中、invoice.paidで確定)
支払期限切れ status: 'past_due' ステータスを更新、猶予期間を延長

期待される結果:

  • データベースでサブスクリプションステータスが更新
  • 履歴レコードが作成または更新
  • ユーザーアクセスが適切に調整

customer.subscription.deleted

目的: 最終的なサブスクリプションキャンセルを処理します。

トリガー時:

  • サブスクリプションが即座にキャンセル
  • スケジュールされたキャンセル期間が終了
  • 支払い失敗がすべての再試行を使い果たす

ハンドラー: SubscriptionLifeCycleHandler::handleSubscriptionDeleted()

主要データフィールド:

{
  "id": "sub_...",
  "object": "subscription",
  "status": "canceled",
  "canceled_at": 1702857600,
  "current_period_end": 1705536000,
  "cancellation_details": {
    "reason": "cancellation_requested",
    "comment": "ユーザーがキャンセルをリクエスト",
    "feedback": "too_expensive"
  }
}

処理ロジック:

  1. provider_subscription_idでローカルサブスクリプションを検索
  2. サブスクリプションを更新:
    • status: → canceled
    • canceled_at: Stripeからのタイムスタンプ
    • canceled_reason: cancellation_detailsから
  3. immediate_cancellation履歴レコードを作成
  4. 保留中のインボイスアイテムをクリア(ある場合)

期待される結果:

  • データベースでサブスクリプションがキャンセル
  • ユーザーアクセスが終了(またはgrace_period_end_atまで継続)
  • キャンセル履歴が作成

インボイスイベント

invoice.paid

目的: 成功したインボイス支払いを処理します(更新、プラン変更)。

トリガー時:

  • 定期支払いが成功
  • プラン変更支払いが成功
  • 初回サブスクリプション支払いが成功(無視、checkout.session.completedで処理)

ハンドラー: SubscriptionInvoiceHandler::handleInvoicePaid()

主要データフィールド:

{
  "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
    }]
  }
}

処理ロジックbilling_reasonに基づく):

1. subscription_create:

  • 無視 - 代わりにcheckout.session.completedで処理
  • 重複履歴作成を防止

2. subscription_cycle(自動更新):

  • provider_subscription_idでサブスクリプションを検索
  • 新しい履歴レコードを作成:
    • type: renewal
    • payment_status: paid
    • invoice_id, payment_intent_id, paid_at
  • サブスクリプションのdeadline_atを更新

3. subscription_update(プラン変更):

  • 保留中の変更履歴をチェック
  • 見つかった場合(通常フロー):
    • 支払い詳細で履歴を更新
    • payment_status: paidに設定
  • 見つからない場合(競合状態 - invoice.paidが先に到着):
    • createChangeHistoryFromInvoice()を呼び出し
    • クレジットライン(amount < 0)からold_plan_idを抽出
    • チャージライン(amount > 0)またはStripe APIからnew_plan_idを抽出
    • 完全な履歴レコードを作成

期待される結果:

  • 更新: 新しい履歴期間が作成され、期限が延長
  • プラン変更: 支払いが記録され、変更が確定

invoice.payment_failed

目的: 失敗した定期支払いを処理します。

トリガー時:

  • 自動支払いが失敗
  • 残高不足、カード期限切れなど

ハンドラー: SubscriptionInvoiceHandler::handleInvoicePaymentFailed()

主要データフィールド:

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

処理ロジック:

  1. provider_subscription_idでサブスクリプションを検索
  2. 最新の履歴レコードを検索
  3. 最新の履歴がpaidの場合(初回失敗):
    • 新しい履歴を作成:
      • type: renewal
      • payment_status: failed
      • payment_attempt: 1
  4. 最新の履歴がfailedの場合(再試行失敗):
    • 履歴を更新:
      • payment_attempt: + 1

注意:

  • サブスクリプションステータス変更(activepast_duecanceled)はcustomer.subscription.updated / deletedイベントで処理
  • このイベントは支払い試行のみを追跡

期待される結果:

  • 失敗した支払いが記録
  • 支払い試行カウントが増加
  • Stripeの再試行ロジックまたは最終ステータス変更を待機

支払いインテントイベント

payment_intent.succeeded

目的: 追跡のためにpayment intent IDで履歴レコードを更新します。

トリガー時:

  • 任意の支払いが正常に処理

ハンドラー: PaymentIntentHandler::handlePaymentIntentSucceeded()

主要データフィールド:

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

処理ロジック:

  1. payment_intent_id IS NULLinvoice_idで履歴を検索
  2. 履歴を更新: payment_intent_id = pi_...

注意: 主にデータ完全性のため、invoice.paidがすでにほとんどの支払い情報を入力しています。


イベント処理パターン

パターン1: 冪等性

すべてのイベントstripe_webhook_eventsテーブルに対してチェックされます:

if ($webhookEvent->status === 'completed') {
    return '200 OK - すでに処理済み';
}
if ($webhookEvent->status === 'processing') {
    return '200 OK - 現在処理中';
}
// 新規または失敗の場合のみ処理

パターン2: 競合状態の処理

プラン変更イベントはどちらの順序でも到着可能:

シナリオA:
1. customer.subscription.updated → 保留中の履歴を作成
2. invoice.paid → 支払いで履歴を確定

シナリオB:
1. invoice.paid → インボイスデータから完全な履歴を作成
2. customer.subscription.updated → 既存の履歴を検出、作成をスキップ

実装:

  • subscription_id + type + started_at(±5秒許容)で既存の履歴をチェック
  • 見つかった場合は更新、見つからない場合は利用可能なデータから作成

パターン3: トランザクション安全性

すべてのデータベース変更はトランザクションでラップされます:

return $this->wrapTransaction(function () use (...) {
    // すべてのDB操作がアトミック
    $this->updateSubscription(...);
    $this->createHistory(...);
});

パターン4: Null安全性

すべてのwebhookデータアクセスは安全なメソッドを使用します:

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

// 悪い
$planId = $session->items['data'][0]['plan']['id']; // クラッシュする可能性

Webhook問題のデバッグ

処理ステータスの確認

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;

失敗したイベントの検索

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

履歴作成の確認

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;

確認するログ

  • storage/logs/stripe-{date}.log - すべてのwebhook処理
  • storage/logs/laravel.log - 一般的なアプリケーションエラー

クイックリファレンステーブル

イベント Billing Reason ハンドラーメソッド 履歴作成? サブスクリプション更新?
checkout.session.completed N/A handleSessionCompleted() いいえ(既存を更新) はい
invoice.paid subscription_create (無視) いいえ いいえ
invoice.paid subscription_cycle handleAutoRenewalInvoice() はい(renewal) はい(deadline)
invoice.paid subscription_update handlePlanChangeInvoice() はい/更新(change) はい(deadline)
invoice.payment_failed Any handleInvoicePaymentFailed() はい/更新(failed) いいえ
customer.subscription.updated N/A handleSubscriptionUpdated() 変動 はい
customer.subscription.deleted N/A handleSubscriptionDeleted() はい(cancellation) はい
payment_intent.succeeded N/A handlePaymentIntentSucceeded() いいえ(既存を更新) いいえ

ドキュメントステータス: すべてのwebhookイベントの完全なリファレンス