サブスクリプションのキャンセルと再開

説明

このドキュメントでは、サブスクリプションのキャンセルと再開の処理プロセスについて説明します。このフローはリアクティブに設計されており、主に外部インターフェース(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.typescheduled_cancellation に、subscription_histories.statusactive に、subscription_histories.payment_statusN/A に設定します。このレコードは、キャンセルが予約されたがまだ確定していないことを示します。

ケース2: 予約されたキャンセルの再開

説明

ユーザーがキャンセル日より前にサブスクリプションを継続することを決定した場合、再開できます。このアクションも customer.subscription.updated Webhookをトリガーしますが、今回は cancel_at_period_endfalse であり、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_endfalse になることです。

ステップ2: Webhookの処理

  • WebhookController はイベントを SubscriptionLifeCycleHandler にルーティングします。
  • ハンドラの isSubscriptionResumed() メソッドが true を返します。

ステップ3: DBでサブスクリプションを再有効化

  • handleSubscriptionResume() メソッドが呼び出されます。
  • subscriptions レコードを更新し、subscription.canceled_atnull に戻します。
  • 前のステップで作成された 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.statusCanceled に更新します。
  • pending 状態の scheduled_cancellation 履歴レコードを見つけ、その subscription_histories.statuscanceled に更新し、ライフサイクルを完了させます。

関連するデータベース構造

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 ロジックは、即時キャンセルがトリガーされた場合にも対応できます。