プラン変更の予約(アップグレード/ダウングレード)
説明
このドキュメントでは、次の請求サイクルで有効になるサブスクリプションプランの変更プロセスについて概説します。この機能により、ユーザーは通常Stripe請求ポータルを介して、プランをシームレスにアップグレードまたはダウングレードできます。バックエンドシステムは、ポータルセッションを生成し、その後Stripeからの一連のWebhookに反応して、予約された変更を追跡し、更新時に新しいプランを確定する責任を負います。
このフローにより、請求の変更が新しい期間の開始時に正しく適用され、複雑な按分計算を回避できます。高価なプランへのアップグレードと、安価または無料プランへのダウングレードの両方を対象としています。
前提条件:
- ユーザーがアクティブな有料サブスクリプションを持っていること。
- ユーザーがグループのサブスクリプションを管理する権限を持っていること。
プロセスフロー図
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == NODES DEFINITION ==
Client[ユーザー]
%% Layer components
subgraph ApiControllerLayer["APIコントローラー層"]
SubscriptionController[SubscriptionController]
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["外部サービス"]
StripePortal((Stripe請求ポータル))
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'>ポータルセッションのリクエスト</p></div>"]
Step2["<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'>2</span><p style='margin-top: 8px'>ポータルでプランを変更</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/>(subscription_schedule.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'>予約された変更をDBに記録</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'>更新日にWebhookを送信<br/>(invoice.paid, sub.updated)</p></div>"]
StepW4["<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'>W4</span><p style='margin-top: 8px'>DBでプラン変更を確定</p></div>"]
%% == CONNECTIONS ==
Client --- Step1 --> SubscriptionController
SubscriptionController --> StripeAPI -- "ポータルURL" --> Client
Client --- Step2 --> StripePortal
StripePortal --> StripeAPI --- StepW1 --> WebhookController
WebhookController --- StepW2
StepW2 --> SubscriptionService --> LifecycleHandler
LifecycleHandler --> SubscriptionDB
LifecycleHandler --> HistoryDB
StripeAPI --- StepW3 --> WebhookController
WebhookController --- StepW4
StepW4 --> SubscriptionService --> LifecycleHandler
%% == STYLING ==
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 Step2 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請求ポータルセッションを生成します。ユーザーがポータルで変更を確認した後、StripeはアプリケーションにWebhookを送信し、アプリケーションは保留中の変更を記録します。
シーケンス図
sequenceDiagram
participant Client as クライアント
participant App as SubscriptionController
participant Stripe as Stripe API
participant Webhook as WebhookController
participant Handler as SubscriptionLifeCycleHandler
participant DB as データベース
Note over Client, Stripe: 1. ユーザーがプラン変更を開始
Client->>App: POST /api/v1/general/subscription/billing-portal
App->>Stripe: 請求ポータルセッションを作成
Stripe-->>App: ポータルセッションURLを返す
App-->>Client: ポータルURLを返す
Client->>Stripe: Stripe請求ポータルにリダイレクト
Note right of Client: ユーザーが新しいプランを選択して確認。<br/>変更は請求期間の終了時に予約される。
Note over Stripe, DB: 2. システムが予約された変更を記録
Stripe->>Webhook: POST /webhook (customer.subscription.updated)
Note right of Stripe: ペイロードに新しいプランの詳細が含まれる。
Webhook->>Handler: handleSubscriptionUpdated(data)
Note right of Handler: isPlanChanged() が true を返す
Handler->>Handler: handlePlanChange(subscription, data)
rect rgb(200, 255, 255)
Note right of Handler: 保留中の変更を記録
Handler->>DB: トランザクション開始
Handler->>DB: `subscriptions` を更新 (scheduled_plan_id, scheduled_plan_change_at)
Handler->>DB: `subscription_histories` を作成 (type: change, status: pending, payment_status: pending)
Handler->>DB: トランザクションコミット
end
Webhook-->>Stripe: 200 OK
ステップ
ステップ1: ユーザーが変更を開始
- ユーザーはアプリケーションのUIを介してプランの変更を要求します。
- バックエンドの
SubscriptionControllerはStripe請求ポータルセッションを作成し、URLをクライアントに返します。 - ユーザーはStripeポータルにリダイレクトされ、そこで新しいプランを選択して変更を確認します。変更は現在の請求サイクルの終了時に行われるように予約されます。
ステップ2: システムがWebhookを受信
- 確認後、Stripeは
customer.subscription.updatedWebhookを送信します。
ステップ3: 予約された変更を記録
WebhookControllerはイベントをSubscriptionLifeCycleHandlerにルーティングします。- ハンドラの
isPlanChanged()メソッドがtrueを返します。 handlePlanChange()メソッドが呼び出され、データベーストランザクション内で2つのアクションを実行します。- メインの
subscriptionsレコードを更新し、subscriptions.scheduled_plan_idを新しいプランのIDに、subscriptions.scheduled_plan_change_atを更新日に設定します。 - 今後の変更を表す新しい
subscription_historiesレコードを作成します。type:changestatus:pendingpayment_status:pendingold_plan_id: 現在のプランのID。
- メインの
ケース2: プラン変更の実行(有料プランへのアップグレードまたはダウングレード)
説明
新しい請求サイクルの開始時に、Stripeは予約された新しいプランに対してユーザーに自動的に請求を試みます。支払いが成功すると、システムがプラン変更を確定するために使用するWebhook(invoice.paid、customer.subscription.updated)を送信します。
シーケンス図
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant Handler as SubscriptionLifeCycleHandler
participant DB as データベース
Note over StripeAPI, DB: 更新時のプラン変更確定
StripeAPI->>Webhook: POST /webhook (invoice.paid)
Webhook->>Handler: handleInvoicePaid(data)
Handler->>DB: `subscription_histories` を更新 (payment_status: paid)
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: サブスクリプションオブジェクトは<br>新しいプランがアクティブであることを反映。
Webhook->>Handler: handleSubscriptionUpdated(data)
rect rgb(200, 255, 200)
Note right of Handler: プラン変更の確定
Handler->>DB: トランザクション開始
Handler->>DB: `subscriptions` テーブルを更新:<br/>- `package_plan_id` を新しいプランに設定<br/>- `scheduled_plan_id` をクリア<br/>- `scheduled_plan_change_at` をクリア<br/>- `deadline_at` を更新
Handler->>DB: `subscription_histories` を更新 (status: active)
Handler->>DB: トランザクションコミット
end
Webhook-->>StripeAPI: 200 OK
ステップ
ステップ1: invoice.paid Webhookの受信
- StripeはこのWebhookを送信して、新しいプランの更新支払いが成功したことを確認します。
- システムは
pendingのchange履歴レコードを見つけ、そのsubscription_histories.payment_statusをpaidに更新します。
ステップ2: customer.subscription.updated Webhookの受信
- 直後に、Stripeはサブスクリプションオブジェクトが正式に新しいプランを反映していることを示す更新Webhookを送信します。
ステップ3: プラン変更の確定
SubscriptionLifeCycleHandlerはこの更新を処理します。メインのsubscriptionsレコードを更新します。subscriptions.package_plan_idは新しいプランのIDに更新されます。subscriptions.scheduled_plan_idおよびscheduled_plan_change_atフィールドはクリアされます。subscriptions.deadline_atは新しい請求サイクルの終了日に更新されます。
- 変更のための
subscription_historiesレコードはstatus:activeとマークされます。
ケース3: プラン変更の実行(無料プランへのダウングレード)
説明
ユーザーが無料プランにダウングレードする場合、更新日には支払いは行われません。Stripeは単にサブスクリプションを更新して新しい無料プランを反映させ、customer.subscription.updated Webhookを送信します。
シーケンス図
sequenceDiagram
participant StripeAPI as Stripe API
participant Webhook as WebhookController
participant Handler as SubscriptionLifeCycleHandler
participant DB as データベース
Note over StripeAPI, DB: 無料プランへのダウングレード確定
StripeAPI->>Webhook: POST /webhook (customer.subscription.updated)
Note right of StripeAPI: サブスクリプションオブジェクトは<br>無料プランがアクティブであることを反映。
Webhook->>Handler: handleSubscriptionUpdated(data)
rect rgb(200, 255, 200)
Note right of Handler: プラン変更の確定
Handler->>DB: トランザクション開始
Handler->>DB: `subscriptions` テーブルを更新:<br/>- `package_plan_id` を新しい無料プランに設定<br/>- `scheduled_plan_id` をクリア<br/>- `deadline_at` を更新
Handler->>DB: `subscription_histories` を更新 (status: active, payment_status: N/A)
Handler->>DB: トランザクションコミット
end
Webhook-->>StripeAPI: 200 OK
ステップ
ステップ1: customer.subscription.updated Webhookの受信
- 請求期間の終了時に、Stripeはサブスクリプションを無料プランに更新し、Webhookを送信します。
invoice.paidイベントは生成されません。
ステップ2: プラン変更の確定
SubscriptionLifeCycleHandlerはこの更新を処理します。- 有料シナリオと同様にメインの
subscriptionsレコードを更新します(新しいプランID、クリアされたスケジュールフィールド、新しい期限)。 pendingのchange履歴レコードを更新し、そのstatusをactiveに、payment_statusをN/Aに設定します。支払いは適用されないためです。
ケース4: プラン変更の実行(アップグレード時の支払い失敗)
説明
新しい、より高価なプランへの更新支払いが失敗した場合、Stripeは invoice.payment_failed Webhookを送信します。サブスクリプションは通常 past_due 状態になります。
ステップ
ステップ1: invoice.payment_failed Webhookの受信
- Stripeは、アップグレードされたプランの更新支払いが失敗したときにこのイベントを送信します。
ステップ2: ステータスを支払い遅延に更新
- ハンドラは
pendingのchange履歴レコードを見つけ、そのsubscription_histories.payment_statusをfailedに更新します。 - メインの
subscriptions.statusをpast_dueに更新します。
ステップ3: Stripeの再試行と最終的なキャンセル
- Stripeのスマートリトライ機能が再度支払いを試みます。
- すべての再試行が失敗した場合、Stripeは自動的にサブスクリプションをキャンセルし、
customer.subscription.deletedWebhookを送信します。システムはこれを処理してサブスクリプションをCanceledとしてマークします。
関連するデータベース構造
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
bigint package_id FK
bigint package_plan_id FK
bigint group_id FK
bigint user_id FK
string payment_provider_subscription_id "StripeサブスクリプションID"
string status "active, past_due, canceled"
string scheduled_plan_id "変更先プランのID(nullable)"
timestamp scheduled_plan_change_at "プラン変更が有効になる日時(nullable)"
timestamp deadline_at "現在の請求期間終了日時"
timestamp canceled_at "キャンセル日時(nullable)"
timestamp created_at
timestamp updated_at
}
subscription_histories {
bigint id PK
bigint subscription_id FK
bigint package_plan_id FK
string old_plan_id "'change'タイプで使用される以前のプランID"
string type "new_contract, renewal, change"
string status "pending, active, inactive"
string payment_status "pending, paid, failed, N/A"
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 "処理失敗時のエラーメッセージ(nullable)"
timestamp created_at
timestamp updated_at
}
users ||--o{ subscriptions : registers
subscriptions ||--o{ subscription_histories : has
関連APIエンドポイント
| メソッド | エンドポイント | コントローラー | 説明 |
|---|---|---|---|
| POST | /api/v1/general/subscription/billing-portal | SubscriptionController | ユーザーがサブスクリプションを管理するためのStripe請求ポータルセッションを生成します。 |
| POST | /api/v1/admin/stripe/webhook | WebhookController | Stripeからのサブスクリプション関連のすべての受信イベントをリッスンし、処理します。 |
エラーハンドリング
-
ログ:
- すべてのAPIの失敗(例:ポータルセッションの作成)はログに記録されます。
- すべてのWebhook処理の失敗はログに記録され、
stripe_webhook_eventsテーブルのerrorカラムに保存されます。
-
エラー詳細:
| ステータスコード | エラーメッセージ | 説明 |
|---|---|---|
| 403 | "User is not authorized to manage this subscription." | ユーザーがグループの所有者または管理者ではありません。 |
| 404 | "Active subscription not found." | ユーザーには変更するサブスクリプションがありません。 |
| 500 | "Failed to create Stripe Billing Portal session." | Stripe APIとの通信中にエラーが発生しました。 |
追加ノート
- 信頼できる情報源(Source of Truth): 請求およびサブスクリプションの状態に関する信頼できる情報源はStripeです。アプリケーションのデータベースは、Webhookを介して更新されるローカルミラーです。
- 按分計算: 請求サイクルの終了時に変更を予約することにより、このフローは複雑な按分計算を回避します。ユーザーは更新日に新しいプランの全額が請求されます。
- ユーザーエクスペリエンス: Stripe請求ポータルを使用することで、支払い方法やサブスクリプション変更の管理において、安全で一貫したユーザーエクスペリエンスが提供されます。