サブスクリプションの再開

説明

このドキュメントでは、以前にキャンセルが予約されていたサブスクリプションの再開処理について説明します。このフローは、請求期間終了時にサブスクリプションをキャンセルすることを選択していたユーザーが、それを継続することを決定したときに開始されます。このアクションは通常、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_endfalse(または null)になり、イベントペイロードの previous_attributes にその前の状態が true であったことが示されることです。

ステップ2: Webhookの処理

  • WebhookController はイベントを検証し、SubscriptionService にルーティングし、それが SubscriptionLifeCycleHandler を呼び出します。
  • ハンドラの isSubscriptionResumed() メソッドは、イベントの現在と以前の属性を比較し、true を返して、再開イベントとして識別します。

ステップ3: サブスクリプションの再有効化と履歴のクリーンアップ

  • handleSubscriptionResume() メソッドが実行されます。
  • データベーストランザクション内で、2つの重要なアクションを実行します。
    • 対応する subscriptions レコードを更新し、subscription.canceled_at フィールドを null に設定します。
    • キャンセルはもはや行われないため、subscription_histories テーブルから pendingscheduled_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 イベントが複数回受信された場合でも、データの不整合を防ぐために一度だけ処理されます。