新規有料サブスクリプションの登録

説明

この機能により、ユーザーはベーシックプランやプレミアムプランなどの新しい有料サービスプランに登録できます。このプロセスでは、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: レコードの作成

  • データベーストランザクション内で、システムはstatusunpaidsubscriptionsレコードと、statuspendingsubscription_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テーブルをそれぞれactivepaidに更新します。
  • 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: イベントの処理

  • WebhookControllerSubscriptionServiceに委譲し、それがSubscriptionInvoiceHandlerを呼び出します。

ステップ3: サブスクリプション履歴の更新

  • ハンドラーは関連するサブスクリプションを検索します。
  • 請求サイクルで最初の失敗である場合、payment_status: failedで新しいsubscription_historiesレコードを作成します。
  • これが後続の失敗である場合、既存の失敗した履歴レコードのpayment_attemptカウンターをインクリメントします。
  • Stripeは、十分な再試行失敗の後、サブスクリプションステータスを自動的にpast_dueまたはcanceledに移行させます。システムは、それらの別のWebhook(customer.subscription.updatedcustomer.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_failed Webhookをリッスンしてこれらの試行を追跡し、customer.subscription.updated / deletedをリッスンして最終的な状態変更を処理します。