即時プラン変更(アップグレード/ダウングレード)

説明

このドキュメントは、即時効果でサブスクリプションプランを変更するプロセスを概説します。スケジュールされたプラン変更とは異なり、即時変更はすぐに適用され、請求サイクルの途中で発生する可能性があり、Stripeによって完全に管理される複雑な日割り計算が含まれます。バックエンドシステムはwebhookイベントを通じてこれを処理し、データの整合性と、アップグレード(より高価なプランへ)とダウングレード(より安価または無料プランへ)の両方の適切な追跡を保証します。

ここで対処される主な課題は、customer.subscription.updatedinvoice.paidイベント間の競合状態であり、これらは任意の順序で到着する可能性があります。システムは両方のシナリオを適切に処理するように設計されています。

前提条件:

  • ユーザーがアクティブな有料サブスクリプションを持っている
  • ユーザーがグループのサブスクリプションを管理する権限を持っている
  • プラン変更が即時請求用に構成されている(次の期間にスケジュールされていない)

プロセスフロー図

---
config:
  theme: base
  layout: dagre
  flowchart:
    curve: linear
    htmlLabels: true
  themeVariables:
    edgeLabelBackground: "transparent"
---
flowchart TD
    %% == ノード定義 ==
    Client[ユーザー]
    
    %% レイヤーコンポーネント
    subgraph ApiControllerLayer["APIコントローラー層"]
        WebhookController[WebhookController]
    end
    
    subgraph ApiServiceLayer["APIサービス層"]
        SubscriptionService(SubscriptionService)
        LifecycleHandler(SubscriptionLifeCycleHandler)
        InvoiceHandler(SubscriptionInvoiceHandler)
    end
    
    subgraph DatabaseLayer["データベース層"]
        SubscriptionDB[(subscriptions)]
        HistoryDB[(subscription_histories)]
        WebhookEventsDB[(stripe_webhook_events)]
    end
    
    subgraph ExternalServices["外部サービス"]
        StripePortal((Stripe請求ポータル / API))
        StripeAPI((Stripe API))
    end
    
    %% == ステップ定義 ==
    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'>プランを即座に変更</p></div>"]
    
    StepW1A["<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'>W1A</span><p style='margin-top: 8px'>Webhook: subscription.updated<br/>(プラン変更)</p></div>"]
    StepW1B["<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'>W1B</span><p style='margin-top: 8px'>Webhook: invoice.paid<br/>(日割り請求)</p></div>"]
    
    StepW2["<div style='text-align: center'><span style='display: inline-block; background-color: #99cc66 !important; color:white; width: 28px; height: 28px; line-height: 28px; border-radius: 50%; font-weight: bold'>W2</span><p style='margin-top: 8px'>変更履歴を作成/更新</p></div>"]
    
    StepW3["<div style='text-align: center'><span style='display: inline-block; background-color: #99cc66 !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>"]

    %% == 接続 ==
    Client --- Step1 --> StripePortal
    StripePortal --> StripeAPI
    
    StripeAPI --- StepW1A --> WebhookController
    StripeAPI --- StepW1B --> WebhookController
    
    WebhookController --> LifecycleHandler
    WebhookController --> InvoiceHandler
    
    LifecycleHandler --- StepW2
    InvoiceHandler --- StepW2
    StepW2 --> HistoryDB
    
    InvoiceHandler --- StepW3
    StepW3 --> HistoryDB
    StepW3 --> SubscriptionDB

    %% == スタイリング ==
    style Client 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 StepW1A fill:transparent,stroke:transparent,stroke-width:1px
    style StepW1B 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

ユースケース

ケース1: 即時アップグレード(subscription.updatedが最初に到着)

説明

ユーザーがより高価なプランに即座にアップグレードすると、Stripeは:

  1. 旧プランの残り時間を日割り計算(クレジットを作成)
  2. 新プランの料金を請求(チャージを作成)
  3. customer.subscription.updated webhookを送信(プラン変更)
  4. invoice.paid webhookを送信(billing_reason: subscription_update付き)

このシナリオでは、subscription.updatedイベントが最初に到着します。システムはpending変更履歴レコードを作成し、その後のinvoice.paidイベントによって確定されます。

ステップ

ステップ1: customer.subscription.updated Webhookを受信

  • プラン変更が適用されたときにStripeがこれを最初に送信
  • イベントにはitems.data[0].planに新しいプラン詳細が含まれる
  • previous_attributesには旧プラン詳細が含まれる

ステップ2: 保留中の変更履歴を作成

  • LifecycleHandlerがこれが即時プラン変更であることを検出(スケジュールされていない)
  • 新しいsubscription_historiesレコードを作成:
    • type: change
    • payment_status: pending
    • started_at: 新期間開始
    • expires_at: 新期間終了
    • old_plan_id: previous_attributes.items.data[0].plan.idから
  • subscriptionsテーブルを新しいpackage_plan_idで更新

ステップ3: invoice.paid Webhookを受信

  • Stripeが日割り請求を処理した後にこれを送信
  • billing_reason: 'subscription_update'を含む
  • インボイス明細に両方のクレジット(旧プラン)とチャージ(新プラン)が表示

ステップ4: 支払い詳細を確定

  • InvoiceHandlerが保留中の変更履歴を検索
  • 支払い情報で履歴を更新:
    • payment_status: paid
    • paid_at: 支払いタイムスタンプ
    • invoice_id: Stripeインボイス ID
    • payment_intent_id: Payment Intent ID
    • amount: 日割り請求額
    • old_plan_id: クレジット明細項目から抽出

ケース2: 無料プランへの即時ダウングレード(invoice.paidが最初に到着)

説明

無料プランにダウングレードする場合、Stripeは旧プランの未使用時間を表すクレジット明細(負の金額)のみを含む$0のインボイスを生成します。invoice.paidイベントがsubscription.updatedの前に到着する可能性があります。

この競合状態シナリオでは、InvoiceHandlerがインボイスデータから直接変更履歴を作成します。

ステップ

ステップ1: invoice.paid Webhookを受信(最初)

  • billing_reason: 'subscription_update'を含む
  • amount_due: 0(無料プラン)
  • インボイス明細: 旧プラン用の1つのクレジット明細(負の金額)

ステップ2: インボイスデータから履歴を作成(競合状態ハンドラー)

  • InvoiceHandler.createChangeHistoryFromInvoice()が呼び出される
  • これが無料プランへのダウングレードであることを検出:
    • amount_paid == 0
    • クレジット明細のみ存在(amount < 0)
  • クレジット明細項目からold_plan_idを抽出
  • Stripe Subscription APIから新プラン情報を取得
  • subscription_historiesレコードを作成:
    • type: change
    • payment_status: n/a(ゼロ金額トランザクション)
    • old_plan_id: クレジット明細から
    • amount: 0

ステップ3: customer.subscription.updated Webhookを受信(後で)

  • インボイス処理後に到着

ステップ4: 重複履歴作成をスキップ

  • LifecycleHandlerが既存の変更履歴をチェック:
    • subscription_idtype:change、およびstarted_at(5秒許容範囲内)で一致
  • invoice.paidによってすでに作成された履歴を検索
  • 新プラン情報でsubscriptionsテーブルのみを更新
  • 重複履歴レコードを作成しない

関連データベース構造

erDiagram
    subscriptions {
        bigint id PK
        bigint package_id FK
        bigint package_plan_id FK "即座に新プランに更新"
        string status "'active'のまま"
        string scheduled_plan_id "即時変更の場合はNULL"
        timestamp deadline_at "新期間終了に更新"
        timestamp grace_period_end_at "それに応じて更新"
    }
    subscription_histories {
        bigint id PK
        bigint subscription_id FK
        string type "'change'に設定"
        string payment_status "pending → paid(または無料の場合はn/a)"
        string old_plan_id "以前のプランID"
        string invoice_id "StripeインボイスID"
        string payment_intent_id "Payment intent(支払済みの場合)"
        decimal amount "日割り請求(無料の場合は0)"
        timestamp started_at "新期間開始"
        timestamp expires_at "新期間終了"
        timestamp paid_at "支払いタイムスタンプ(支払済みの場合)"
    }
    stripe_webhook_events {
        bigint id PK
        string stripe_event_id "重複処理を防止"
        string event_type
        enum status "pending, processing, completed, failed"
    }
    subscriptions ||--o{ subscription_histories : has

関連APIエンドポイント

メソッド エンドポイント コントローラー 説明
POST /api/v1/admin/stripe/webhook WebhookController@handleWebhook プラン変更を含むすべてのwebhookイベントを処理

エラー処理

ステータスコード エラーメッセージ 説明
400 無効なwebhookペイロード Webhookデータが不正
500 プラン変更の処理に失敗 処理中のデータベースまたはロジックエラー

追加注記

競合状態の処理

システムはwebhookイベントが任意の順序で到着することを処理するように設計されています:

  1. 冪等性チェック: stripe_event_id + request_id + event_typeの組み合わせでstripe_webhook_eventsテーブルを使用
  2. 重複履歴防止: 既存の履歴レコードをチェック:
    • subscription_id
    • type(例: change
    • started_at(タイムスタンプの差異を処理するための±5秒許容範囲)
  3. インボイスからの作成ロジック: InvoiceHandler.createChangeHistoryFromInvoice()はインボイスデータのみから完全な履歴を再構築可能

日割り計算

  • 完全にStripeによって管理
  • 旧プランの未使用時間のクレジット
  • 変更日からの新プランの請求
  • システムは最終金額のみを記録

支払いステータスロジック

  • 有料プラン → 有料プラン(アップグレード/ダウングレード): payment_status = paid、amount > 0
  • 有料プラン → 無料プラン: payment_status = n/a、amount = 0
  • 無料プラン → 有料プラン: payment_status = paid、全額請求

パッケージID更新

  • システムはpackageToProvider->package_idの代わりにpackagePlan->package_id(リレーションシップから)を使用
  • 外部キー制約違反を防止
  • データ整合性を保証

ドキュメントステータス: 完了 - すべての即時プラン変更シナリオと競合状態をカバー

Case 4: Immediate Change Cancels a Scheduled Downgrade

Description

If a user already has a scheduled downgrade and then changes to a more expensive plan immediately, the immediate change takes precedence and the scheduled downgrade is cleared.

Behavior

  • The scheduled fields (scheduled_plan_id, scheduled_plan_change_at) are cleared during the immediate change handling.
  • Entitlement switches to the new plan immediately.
  • Any previously scheduled downgrade is no longer applied at period end.