即時プラン変更(アップグレード/ダウングレード)
説明
このドキュメントは、即時効果でサブスクリプションプランを変更するプロセスを概説します。スケジュールされたプラン変更とは異なり、即時変更はすぐに適用され、請求サイクルの途中で発生する可能性があり、Stripeによって完全に管理される複雑な日割り計算が含まれます。バックエンドシステムはwebhookイベントを通じてこれを処理し、データの整合性と、アップグレード(より高価なプランへ)とダウングレード(より安価または無料プランへ)の両方の適切な追跡を保証します。
ここで対処される主な課題は、customer.subscription.updatedとinvoice.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は:
- 旧プランの残り時間を日割り計算(クレジットを作成)
- 新プランの料金を請求(チャージを作成)
customer.subscription.updatedwebhookを送信(プラン変更)invoice.paidwebhookを送信(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:changepayment_status:pendingstarted_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:paidpaid_at: 支払いタイムスタンプinvoice_id: Stripeインボイス IDpayment_intent_id: Payment Intent IDamount: 日割り請求額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:changepayment_status:n/a(ゼロ金額トランザクション)old_plan_id: クレジット明細からamount: 0
ステップ3: customer.subscription.updated Webhookを受信(後で)
- インボイス処理後に到着
ステップ4: 重複履歴作成をスキップ
LifecycleHandlerが既存の変更履歴をチェック:subscription_id、type: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イベントが任意の順序で到着することを処理するように設計されています:
- 冪等性チェック:
stripe_event_id + request_id + event_typeの組み合わせでstripe_webhook_eventsテーブルを使用 - 重複履歴防止: 既存の履歴レコードをチェック:
subscription_idtype(例:change)started_at(タイムスタンプの差異を処理するための±5秒許容範囲)
- インボイスからの作成ロジック:
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.