新規有料サブスクリプションの登録
説明
この機能により、ユーザーはベーシックプランやプレミアムプランなどの新しい有料サービスプランに登録できます。このプロセスでは、Stripe顧客が存在しない場合に作成し、ローカルデータベースとStripeの両方でサブスクリプションを設定し、Stripe Checkoutを介して支払いフローを処理します。システムは、StripeのWebhook、特に checkout.session.completed を利用して、支払いが成功した際に安全かつ確実にサブスクリプションを有効にし、データの一貫性を確保し、競合状態を防ぎます。
この機能は、プラットフォームの収益化にとって非常に重要であり、ユーザーが有料ティアにアップグレードしてプレミアム機能にアクセスするためのシームレスで安全な方法を提供します。
前提条件:
- ユーザーは認証済みであり、有料プランへの登録を決定していること。
- ユーザーに関連付けられたグループに、競合するアクティブなサブスクリプションがないこと。
- ユーザーがグループ内で請求を管理するために必要な権限を持っていること。
プロセスフロー図
---
config:
theme: base
layout: dagre
flowchart:
curve: linear
htmlLabels: true
themeVariables:
edgeLabelBackground: "transparent"
---
flowchart TD
%% == NODES DEFINITION ==
Client[ユーザー]
%% Components
SubscriptionController[SubscriptionController]
WebhookController[WebhookController]
SubscriptionService(SubscriptionService)
StripeService(StripeService)
UsersDB[(users)]
SubscriptionDB[(subscriptions)]
HistoryDB[(subscription_histories)]
WebhookEventsDB[(stripe_webhook_events)]
StripeAPI((Stripe API))
StripeCheckout((Stripe Checkoutページ))
%% Step nodes
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'>Stripe顧客の確認/作成</p></div>"]
Step3["<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'>3</span><p style='margin-top: 8px'>未払いサブスクリプションの作成</p></div>"]
Step4["<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'>4</span><p style='margin-top: 8px'>Stripeチェックアウトセッションの作成</p></div>"]
Step5["<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'>5</span><p style='margin-top: 8px'>チェックアウトURLの返却</p></div>"]
Step6(((支払い完了)))
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'>checkout.session.completedの送信</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'>SubscriptionSessionHandlerによるイベント処理</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'>サブスクリプションの有効化(Active/Paid)</p></div>"]
%% == SUBGRAPHS ==
subgraph ApplicationLayer["アプリケーション層"]
SubscriptionController
WebhookController
SubscriptionService
Step2
Step3
Step4
Step5
StepW2
StepW3
StepW4
end
subgraph BusinessServices["ビジネスロジックサービス"]
StripeService
end
subgraph DatabaseLayer["データベース層"]
UsersDB
SubscriptionDB
HistoryDB
WebhookEventsDB
end
subgraph ExternalServices["外部サービス"]
StripeAPI
StripeCheckout
end
%% == CONNECTIONS ==
%% Subscription Request Flow
Client --> Step1 --> SubscriptionController
SubscriptionController --- Step2
SubscriptionController --- Step3
SubscriptionController --- Step4
SubscriptionController --- Step5
Step2 --> StripeService
Step3 --> SubscriptionService
Step4 --> StripeService
Step5 --> Client
StripeService --> StripeAPI
StripeService --> UsersDB
SubscriptionService --> SubscriptionDB
SubscriptionService --> HistoryDB
Client --> Step6 --> StripeCheckout
%% Webhook Flow
StripeAPI --> StepW1 --> WebhookController
WebhookController --- StepW2
WebhookController --- StepW3
StepW2 --> WebhookEventsDB
StepW3 --> SubscriptionService
SubscriptionService --- StepW4
StepW4 --> SubscriptionDB
StepW4 --> HistoryDB
%% == STYLING ==
style Client fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style ApplicationLayer fill:#e6f3ff,stroke:#0066cc,stroke-width:2px
style BusinessServices fill:#f5f0ff,stroke:#9966cc,stroke-width:2px
style DatabaseLayer fill:#ffe6cc,stroke:#ff9900,stroke-width:2px
style ExternalServices fill:#fcd9d9,stroke:#cc3333,stroke-width:2px
ユースケース
ケース1: 有料プラン登録リクエスト
説明
ユーザーが有料プランを選択し、登録プロセスを開始します。システムはローカルに必要なレコードを作成し、Stripeチェックアウトセッションを生成して、ユーザーを支払いのためにStripeにリダイレクトします。
シーケンス図
sequenceDiagram
participant Client
participant SubscriptionController as SubController
participant SubscriptionService as SubService
participant StripeService
participant StripeAPI as Stripe API
participant DB as Database
Note over Client, DB: 有料プラン登録リクエストフロー
Client->>SubController: POST /api/v1/general/subscription/register
Note over Client, SubController: package_plan_idを送信
rect rgb(255, 255, 200)
Note right of SubController: Stripe顧客の確認/作成
SubController->>StripeService: getOrCreateCustomer(user)
StripeService->>DB: user.payment_provider_customer_idを検索
alt 顧客IDがない場合
StripeService->>StripeAPI: customers.create()
StripeAPI-->>StripeService: customerオブジェクト
StripeService->>DB: ユーザーにcustomer_idを更新
end
end
rect rgb(200, 255, 255)
Note right of SubController: ローカルサブスクリプションの作成
SubController->>SubService: createSubscription(user, group, plan)
SubService->>DB: トランザクション開始
SubService->>DB: subscriptionsレコードを作成(status: unpaid)
SubService->>DB: subscription_historiesレコードを作成(status: pending)
SubService->>DB: トランザクションコミット
end
rect rgb(255, 230, 200)
Note right of SubController: Stripeチェックアウトセッションの作成
SubController->>StripeService: createCheckoutSession(customer_id, price_id, subscription_slug)
StripeService->>StripeAPI: checkout.sessions.create()
StripeAPI-->>StripeService: URL付きのcheckout_sessionオブジェクト
StripeService-->>SubController: checkout_url
end
SubController-->>Client: 200 OK (checkout_url)
手順
ステップ1: 登録リクエスト
- ユーザーは有料プランを選択し、
package_plan_idをバックエンドに送信します。
ステップ2: Stripe顧客の確認または作成
- システムはユーザーがすでに
payment_provider_customer_idを持っているか確認します。 - 持っていない場合、Stripeで新しい顧客を作成し、そのIDを
usersテーブルに保存します。
ステップ3: レコードの作成
- データベーストランザクション内で、システムは
statusがunpaidのsubscriptionsレコードと、statusがpendingのsubscription_historiesレコードを作成します。これはユーザーの登録意図を事前に記録するものです。
ステップ4: Stripeチェックアウトセッションの作成
- システムはStripeに新しいチェックアウトセッションをリクエストし、顧客ID、価格ID、およびローカルサブスクリプションのユニークなスラッグをメタデータに渡します。
ステップ5: チェックアウトURLの返却
- バックエンドはStripeチェックアウトURLをクライアントに返します。クライアントはユーザーをこのURLにリダイレクトして支払いを完了させます。
ケース2: 支払い成功時のWebhook処理
説明
ユーザーがStripeで支払いを正常に完了すると, Stripeは checkout.session.completed Webhookを送信します。システムはこのイベントをリッスンしてユーザーのサブスクリプションを有効化します。 invoice.paid イベントは、重複処理を防ぐために無視されます。
シーケンス図
sequenceDiagram
participant StripeWebhook as Stripe API
participant WebhookController
participant SubscriptionService as SubService
participant SubscriptionSessionHandler as Handler
participant DB as Database
Note over StripeWebhook, DB: 支払い成功時のWebhookフロー
StripeWebhook->>WebhookController: POST /api/v1/admin/stripe/webhook (checkout.session.completed)
rect rgb(255, 255, 200)
Note right of WebhookController: イベントの検証と記録
WebhookController->>DB: イベントが既に処理済みか確認
WebhookController->>DB: stripe_webhook_eventsレコードを作成(status: processing)
end
WebhookController->>SubService: subscriptionSessionCompleted(session_data)
SubService->>Handler: handleSessionCompleted(session_data)
rect rgb(200, 255, 255)
Note right of Handler: データの取得と有効化
Handler->>DB: Webhookメタデータからスラッグで未払いサブスクリプションを検索
alt サブスクリプションが見つかり、未払いの場合
Handler->>DB: ロック付きでトランザクション開始
Handler->>DB: subscriptionsを更新(status: active, deadline_atなど)
Handler->>DB: subscription_historiesを更新(status: active, payment_status: paid, paid_atなど)
Handler->>DB: トランザクションコミット
Handler->>SubService: 成功を返す
else サブスクリプションが見つからないか、既に有効な場合
Handler->>SubService: エラーを記録してリターン
end
end
SubService-->>WebhookController: 成功
WebhookController->>DB: stripe_webhook_eventsを更新(status: completed)
WebhookController-->>StripeWebhook: 200 OK
手順
ステップ1: Webhook受信
- Stripeは
checkout.session.completedイベントをWebhookエンドポイントに送信します。
ステップ2: イベントの検証と記録
WebhookControllerはWebhookの署名を検証します。- 同じイベントを二度処理しないように
stripe_webhook_eventsテーブルを確認します。 - 新しいイベントレコードを
processingステータスで作成します。
ステップ3: イベントの処理
- コントローラーはイベントを
SubscriptionServiceに委譲し、それがSubscriptionSessionHandlerを呼び出します。
ステップ4: サブスクリプションの有効化
SubscriptionSessionHandlerはWebhookメタデータのsubscription_slugを使用してローカルのサブスクリプションレコードを検索します。- サブスクリプションがまだ
unpaid状態であることを確認します。 - ロックされたデータベーストランザクション内で、
subscriptionsテーブルとsubscription_historiesテーブルをそれぞれactiveとpaidに更新します。 - Webhookイベントのステータスを
completedに更新します。
ケース3: 支払い失敗時のWebhook処理
説明
有効なサブスクリプションの定期支払いが失敗した場合、Stripeはinvoice.payment_failed Webhookを送信します。システムはサブスクリプションのステータスと履歴を更新して失敗を反映し、再試行ロジックを処理します。
シーケンス図
sequenceDiagram
participant StripeWebhook
participant WebhookController
participant SubscriptionService as SubService
participant SubscriptionInvoiceHandler as Handler
participant DB as Database
Note over StripeWebhook, DB: 継続支払い失敗時のWebhookフロー
StripeWebhook->>WebhookController: POST /api/v1/admin/stripe/webhook (invoice.payment_failed)
WebhookController->>SubService: handleInvoicePaymentFailed(invoice_data)
SubService->>Handler: handleInvoicePaymentFailed(invoice_data)
Handler->>DB: provider_idでサブスクリプションを検索
alt サブスクリプションが有効な場合
Handler->>DB: 最新のsubscription_historyを検索
alt 最新の履歴が 'paid' (この期間で最初の失敗)
Handler->>DB: 新しいsubscription_historyを作成(status: inactive, payment_status: failed, payment_attempt: 1)
else 最新の履歴が 'failed' (再試行の失敗)
Handler->>DB: subscription_historyを更新(payment_attempt + 1)
end
else サブスクリプションがpast_dueまたはcanceledの場合
Note over Handler: アクションなし
end
手順
ステップ1: Webhook受信
- Stripeは定期的な請求試行に対して
invoice.payment_failedイベントを送信します。
ステップ2: イベントの処理
WebhookControllerはSubscriptionServiceに委譲し、それがSubscriptionInvoiceHandlerを呼び出します。
ステップ3: サブスクリプション履歴の更新
- ハンドラーは関連するサブスクリプションを検索します。
- 請求サイクルで最初の失敗である場合、
payment_status: failedで新しいsubscription_historiesレコードを作成します。 - これが後続の失敗である場合、既存の失敗した履歴レコードの
payment_attemptカウンターをインクリメントします。 - Stripeは、十分な再試行失敗の後、サブスクリプションステータスを自動的に
past_dueまたはcanceledに移行させます。システムは、それらの別のWebhook(customer.subscription.updated、customer.subscription.deleted)をリッスンして、メインのsubscriptionsテーブルのステータスを更新します。
関連するデータベース構造
erDiagram
users {
bigint id PK
string name "ユーザーの氏名"
string email "ユーザーのメールアドレス(ユニーク)"
string payment_provider_customer_id "Stripeからの顧客ID(null許容)"
timestamp created_at
timestamp updated_at
}
subscriptions {
bigint id PK
string slug "Stripeメタデータと連携するためのユニークな識別子"
bigint user_id FK
bigint group_id FK
bigint package_id FK
bigint package_plan_id FK
string status "unpaid, active, past_due, canceled"
string payment_provider_subscription_id "StripeサブスクリプションID"
timestamp first_register_at "初回登録のタイムスタンプ"
timestamp deadline_at "現在の請求期間の終了日"
timestamp cancelled_at "キャンセルのタイムスタンプ"
timestamp created_at
timestamp updated_at
}
subscription_history {
bigint id PK
bigint subscription_id FK
string status "active, inactive"
string payment_status "pending, paid, failed"
string type "register, renewal, change"
string invoice_id "StripeインボイスID"
string payment_intent_id "Stripe支払いインテントID"
timestamp started_at "期間開始のタイムスタンプ"
timestamp expires_at "期間終了のタイムスタンプ"
timestamp paid_at "支払い成功のタイムスタンプ"
int payment_attempt "このサイクルの失敗した支払い試行回数"
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_history : has
関連APIエンドポイント
| メソッド | エンドポイント | コントローラー | 説明 |
|---|---|---|---|
| POST | /api/v1/general/subscription/register | SubscriptionController@register | 新規有料サブスクリプションを開始し、StripeチェックアウトURLを返します。 |
| POST | /api/v1/admin/stripe/webhook | WebhookController@handleWebhook | Stripeからのすべての着信イベントをリッスンし、処理します。 |
エラーハンドリング
-
ログ:
- すべての登録、支払い、およびWebhook処理の失敗はアプリケーションログに記録されます。
- Stripe APIエラーは詳細なコンテキストとともに記録されます。
- Webhook処理エラーは
stripe_webhook_eventsテーブルのerror列に保存されます。
-
エラー詳細:
| ステータスコード | エラーメッセージ | 説明 |
|---|---|---|
| 400 | 無効なサブスクリプションリクエストです。 | 入力検証に失敗しました(例:package_plan_idの欠落)。 |
| 403 | ユーザーは認可されていません。 | ユーザーにはグループの請求を管理する権限がありません。 |
| 409 | 有効なサブスクリプションが既に存在します。 | グループには既に有効なサブスクリプションがあります。 |
| 500 | Stripe APIエラー: {message} | Stripe APIとの通信中にエラーが発生しました。 |
| 500 | データベースエラー: {message} | プロセス中にデータベース操作が失敗しました。 |
追加の注記
- Webhook処理戦略: 競合状態や重複処理を防ぐため、システムは
checkout.session.completedイベントを新規サブスクリプションを有効化するための唯一の信頼できる情報源として使用します。後続のinvoice.paidイベント(billing_reason: subscription_createを持つ)は、この初期登録フローでは意図的に無視されます。これにより、サブスクリプションの履行ロジックが信頼性の高いアトミックな方法で一度だけ実行されることが保証されます。 - トランザクションの安全性: サブスクリプションの状態を変更するすべてのデータベース操作は、データの一貫性を確保するためにデータベーストランザクションでラップされます。支払い成功のWebhookハンドラーは、複数のWebhookが同時に配信されることによる競合状態を防ぐために、データベースレベルのロックも使用します。
- 支払いの再試行: 定期的な支払いについては、Stripeの「スマートリトライ」機能によって再試行ロジックが管理されます。システムは
invoice.payment_failedWebhookをリッスンしてこれらの試行を追跡し、customer.subscription.updated/deletedをリッスンして最終的な状態変更を処理します。