サブスクリプションの再開
説明
このドキュメントでは、以前にキャンセルが予約されていたサブスクリプションの再開処理について説明します。このフローは、請求期間終了時にサブスクリプションをキャンセルすることを選択していたユーザーが、それを継続することを決定したときに開始されます。このアクションは通常、Stripe請求ポータルのような外部インターフェースを介して実行されます。
バックエンドシステムは、Stripeからの customer.subscription.updated Webhookを通じてこの変更を検出します。このイベントが cancel_at_period_end フラグの設定が解除されたことを示すと、システムはローカルでサブスクリプションを再有効化し、キャンセルの状態を元に戻し、保留中のキャンセルレコードを削除して、通常通り自動更新が継続されるようにします。
前提条件:
- ユーザーサブスクリプションのキャンセルが予約されていること(例:
canceled_atがnullでない)。 subscription_historiesテーブルにscheduled_cancellationレコードが存在すること。- ユーザーが請求期間終了前に、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: 予約されたキャンセルの再開
説明
ユーザーがキャンセル日より前にサブスクリプションを継続することを決定した場合、再開できます。このアクションは customer.subscription.updated Webhookをトリガーし、cancel_at_period_end プロパティが true から false に変更されます。システムはこの特定の変更をリッスンして、サブスクリプションを再有効化し、無関係になったキャンセルレコードをクリーンアップします。
シーケンス図
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経由でサブスクリプションを再開した後、StripeはWebhookを送信します。重要な変更点は、
cancel_at_period_endがfalse(またはnull)になり、イベントペイロードのprevious_attributesにその前の状態がtrueであったことが示されることです。
ステップ2: Webhookの処理
WebhookControllerはイベントを検証し、SubscriptionServiceにルーティングし、それがSubscriptionLifeCycleHandlerを呼び出します。- ハンドラの
isSubscriptionResumed()メソッドは、イベントの現在と以前の属性を比較し、trueを返して、再開イベントとして識別します。
ステップ3: サブスクリプションの再有効化と履歴のクリーンアップ
handleSubscriptionResume()メソッドが実行されます。- データベーストランザクション内で、2つの重要なアクションを実行します。
- 対応する
subscriptionsレコードを更新し、subscription.canceled_atフィールドをnullに設定します。 - キャンセルはもはや行われないため、
subscription_historiesテーブルからpendingのscheduled_cancellation履歴レコードを見つけて削除します。
- 対応する
- これにより、サブスクリプションは請求サイクルの終わりに更新を継続し、混乱を招くような古い履歴レコードが存在しないことが保証されます。
関連するデータベース構造
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 "キャンセルが有効になる日時(nullable)"
string canceled_reason "キャンセルの理由(nullable)"
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, N/A"
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の処理は冪等性を持つように設計されています。再開を示す同じ
customer.subscription.updatedイベントが複数回受信された場合でも、データの不整合を防ぐために一度だけ処理されます。