サブスクリプションのキャンセルと再開
説明
このドキュメントでは、サブスクリプションのキャンセルと再開の処理プロセスについて説明します。このフローはリアクティブに設計されており、主に外部インターフェース(Stripe請求ポータルなど)でのユーザーのアクションによって開始され、それがトリガーとなってアプリケーションがリッスンするWebhookが送信されます。
システムは「期間終了時にキャンセル」戦略をサポートしています。ユーザーがキャンセルをリクエストすると、サブスクリプションは現在の請求サイクルが完了するまでアクティブなままです。また、以前にキャンセルをスケジュールしたサブスクリプションをユーザーが再開する場合も処理します。主要なWebhookは、キャンセルのスケジュール/再開のための customer.subscription.updated と、キャンセルが最終的に確定した際の customer.subscription.deleted です。
前提条件:
- ローカルデータベースとStripeの両方にアクティブなユーザーサブスクリプションが存在すること。
- ユーザーがStripeと統合されたインターフェースを介してキャンセルまたは再開のアクションを開始すること。
プロセスフロー図
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == NODES DEFINITION ==
ClientAction["ユーザーアクション (Stripeポータル/フロントエンド経由)"]
%% Layer components
subgraph ApiControllerLayer["APIコントローラー層"]
WebhookController[WebhookController]
end
subgraph ApiServiceLayer["APIサービス層"]
SubscriptionService(SubscriptionService)
LifecycleHandler(SubscriptionLifeCycleHandler)
end
subgraph DatabaseLayer["データベース層"]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
WebhookEventsDB[(stripe_webhook_events)]
end
subgraph ExternalServices["外部サービス"]
StripeAPI((Stripe API))
end
%% == STEPS DEFINITION ==
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'>Stripeでキャンセル/再開</p></div>"]
StepW1["<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'>W1</span><p style='margin-top: 8px'>Webhookイベントを送信<br/>(例: customer.subscription.updated)</p></div>"]
StepW2["<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'>W2</span><p style='margin-top: 8px'>Webhookを検証・ログ記録</p></div>"]
StepW3["<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'>W3</span><p style='margin-top: 8px'>ライフサイクルハンドラでイベントを処理</p></div>"]
StepW4["<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'>W4</span><p style='margin-top: 8px'>DB更新 & 履歴作成</p></div>"]
%% == CONNECTIONS ==
ClientAction --- Step1 --> StripeAPI
StripeAPI --- StepW1 --> WebhookController
WebhookController --- StepW2 --> WebhookEventsDB
WebhookController --- StepW3 --> SubscriptionService
SubscriptionService --> LifecycleHandler
LifecycleHandler --- StepW4
StepW4 --> SubscriptionDB
StepW4 --> HistoryDB
%% == STYLING ==
style ClientAction 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 StepW1 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
style StepW4 fill:transparent,stroke:transparent,stroke-width:1px
ユースケース
ケース1: サブスクリプションのキャンセル予約
説明
ユーザーが現在の請求期間の終了時にサブスクリプションをキャンセルするようリクエストします。Stripeはサブスクリプションオブジェクトを更新し、customer.subscription.updated Webhookを送信します。システムはこのイベントをリッスンして、サブスクリプションをキャンセル保留中としてマークし、対応する履歴レコードを作成します。
シーケンス図
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant SubSvc as SubscriptionService
participant LifecycleHandler as SubscriptionLifeCycleHandler
participant DB as Database
Note over StripeAPI, DB: キャンセル予約フロー
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: ペイロードに `cancel_at_period_end: true` が含まれる
Webhook->>SubSvc: handleSubscriptionUpdated(data, previous_attributes)
SubSvc->>LifecycleHandler: handleSubscriptionUpdated(data, previous_attributes)
Note right of LifecycleHandler: isScheduledCancellation() が true を返す
LifecycleHandler->>LifecycleHandler: handleScheduledCancellation(subscription, session)
rect rgb(200, 255, 255)
Note right of LifecycleHandler: DBの更新
LifecycleHandler->>DB: トランザクション開始
LifecycleHandler->>DB: subscriptions を更新 (canceled_at)
LifecycleHandler->>DB: subscription_histories を作成 (type: scheduled_cancellation, status: pending)
LifecycleHandler->>DB: トランザクションコミット
end
Webhook-->>StripeAPI: 200 OK
ステップ
ステップ1: customer.subscription.updated Webhookの受信
- ユーザーがキャンセルを予約した後、StripeはWebhookを送信します。ペイロードには
cancel_at_period_end: trueが含まれます。
ステップ2: Webhookの処理
WebhookControllerはイベントをSubscriptionLifeCycleHandlerにルーティングします。- ハンドラの
isScheduledCancellation()メソッドがtrueを返します。
ステップ3: サブスクリプションの更新と履歴の作成
handleScheduledCancellation()メソッドが呼び出されます。- データベーストランザクション内で、2つのアクションを実行します。
- メインの
subscriptionsレコードを更新し、subscription.canceled_atタイムスタンプをサブスクリプションが失効する日付に設定します。 - 新しい
subscription_historiesレコードを作成し、subscription_histories.typeをscheduled_cancellationに、subscription_histories.statusをactiveに、subscription_histories.payment_statusをN/Aに設定します。このレコードは、キャンセルが予約されたがまだ確定していないことを示します。
- メインの
ケース2: 予約されたキャンセルの再開
説明
ユーザーがキャンセル日より前にサブスクリプションを継続することを決定した場合、再開できます。このアクションも customer.subscription.updated Webhookをトリガーしますが、今回は cancel_at_period_end が false であり、previous_attributes は以前は true であったことを示します。
シーケンス図
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant SubSvc as SubscriptionService
participant LifecycleHandler as SubscriptionLifeCycleHandler
participant DB as Database
Note over StripeAPI, DB: サブスクリプション再開フロー
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: ペイロードの cancel_at_period_end = false<br/>以前の属性では cancel_at_period_end = true
Webhook->>SubSvc: handleSubscriptionUpdated(data, previous_attributes)
SubSvc->>LifecycleHandler: handleSubscriptionUpdated(data, previous_attributes)
Note right of LifecycleHandler: isSubscriptionResumed() が true を返す
LifecycleHandler->>LifecycleHandler: handleSubscriptionResume(subscription)
rect rgb(200, 255, 200)
Note right of LifecycleHandler: DBでサブスクリプションを再有効化
LifecycleHandler->>DB: トランザクション開始
LifecycleHandler->>DB: subscriptions を更新 (canceled_at: null)
LifecycleHandler->>DB: subscription_histories から `scheduled_cancellation` レコードを削除
LifecycleHandler->>DB: トランザクションコミット
end
Webhook-->>StripeAPI: 200 OK
ステップ
ステップ1: customer.subscription.updated Webhookの受信
- ユーザーがサブスクリプションを再開した後、StripeはWebhookを送信します。重要な変更点は
cancel_at_period_endがfalseになることです。
ステップ2: Webhookの処理
WebhookControllerはイベントをSubscriptionLifeCycleHandlerにルーティングします。- ハンドラの
isSubscriptionResumed()メソッドがtrueを返します。
ステップ3: DBでサブスクリプションを再有効化
handleSubscriptionResume()メソッドが呼び出されます。subscriptionsレコードを更新し、subscription.canceled_atをnullに戻します。- 前のステップで作成された
pending状態のscheduled_cancellation履歴レコードを見つけて削除します。これはもはや無関係だからです。
ケース3: キャンセルの確定
説明
キャンセルが予約されたサブスクリプションが請求期間の終わりに達すると、Stripeは customer.subscription.deleted Webhookを送信します。このイベントにより、ローカルシステムでキャンセルが確定します。
シーケンス図
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant SubSvc as SubscriptionService
participant LifecycleHandler as SubscriptionLifeCycleHandler
participant DB as Database
Note over StripeAPI, DB: 確定キャンセルフロー
StripeAPI->>Webhook: POST /webhook (customer.subscription.deleted)
Webhook->>SubSvc: handleSubscriptionDeleted(data)
SubSvc->>LifecycleHandler: handleSubscriptionDeleted(data)
rect rgb(255, 200, 200)
Note right of LifecycleHandler: ローカルサブスクリプションの最終処理
LifecycleHandler->>DB: トランザクション開始
LifecycleHandler->>DB: subscriptions を更新 (status: Canceled)
LifecycleHandler->>DB: `scheduled_cancellation` 履歴レコードを更新 (status: canceled)
LifecycleHandler->>DB: トランザクションコミット
end
Webhook-->>StripeAPI: 200 OK
ステップ
ステップ1: customer.subscription.deleted Webhookの受信
- Stripeは、請求期間の終了時にサブスクリプションが正式に終了したときにこのイベントを送信します。
ステップ2: Webhookの処理
WebhookControllerはイベントをSubscriptionLifeCycleHandlerにルーティングします。
ステップ3: キャンセルの確定
handleSubscriptionDeleted()メソッドが呼び出されます。- メインの
subscription.statusをCanceledに更新します。 pending状態のscheduled_cancellation履歴レコードを見つけ、そのsubscription_histories.statusをcanceledに更新し、ライフサイクルを完了させます。
関連するデータベース構造
erDiagram
users {
bigint id PK
string name "ユーザーのフルネーム"
string email "ユーザーのメールアドレス(ユニーク)"
string payment_provider_customer_id "Stripeの顧客ID(nullable)"
timestamp created_at
timestamp updated_at
}
subscriptions {
bigint id PK
string slug "ユニークな識別子"
bigint user_id FK
bigint group_id FK
string status "active, past_due, Canceled"
string payment_provider_subscription_id "StripeサブスクリプションID"
boolean auto_renew
timestamp deadline_at "現在の請求期間終了日時"
timestamp canceled_at "キャンセルが有効になる日時"
string canceled_reason "キャンセルの理由"
timestamp created_at
timestamp updated_at
}
subscription_histories {
bigint id PK
bigint subscription_id FK
string status "pending, active, inactive, canceled"
string payment_status "pending, paid, failed"
string type "new_contract, renewal, change, scheduled_cancellation"
timestamp created_at
timestamp updated_at
}
stripe_webhook_events {
bigint id PK
string stripe_event_id "重複防止のためのStripeイベントID"
string event_type
enum status "pending, processing, completed, failed"
text error "処理失敗時のエラーメッセージ"
timestamp created_at
timestamp updated_at
}
users ||--o{ subscriptions : has
subscriptions ||--o{ subscription_histories : has
関連APIエンドポイント
| メソッド | エンドポイント | コントローラー | 説明 |
|---|---|---|---|
| POST | /api/v1/admin/stripe/webhook | WebhookController@handleWebhook | Stripeからのサブスクリプション関連のすべての受信イベントをリッスンし、処理します。 |
エラーハンドリング
-
ログ:
- すべてのWebhook処理の失敗はアプリケーションログに記録されます。
- エラーは
stripe_webhook_eventsテーブルのerrorカラムに保存されます。
-
エラー詳細:
| ステータスコード | エラーメッセージ | 説明 |
|---|---|---|
| 400 | Invalid webhook signature. | リクエストがStripeから発信されたものではありません。 |
| 404 | Subscription not found for webhook. | WebhookからのサブスクリプションIDがデータベース内のどのレコードとも一致しません。 |
| 500 | Database error: {message} | Webhook処理中にデータベース操作が失敗しました。 |
追加ノート
- 信頼できる情報源(Source of Truth): サブスクリプションの状態に関する信頼できる情報源はStripeです。アプリケーションはWebhookを介して変更に反応します。
- ユーザーインターフェース: キャンセルや再開といったユーザー向けの操作は、StripeのAPIや請求ポータルと直接やり取りするフロントエンドアプリケーションによって処理されることを想定しており、それがバックエンドのWebhookをトリガーします。
- 冪等性(Idempotency): Webhook処理は冪等性があり、同じイベントが複数回受信されても、データの重複やエラーは発生しません。
- 即時キャンセル: 主要なフローは「期間終了時にキャンセル」ですが、
handleSubscriptionDeletedロジックは、即時キャンセルがトリガーされた場合にも対応できます。