🐙

Stripeを活用したサブスクリプションのプラン変更機能の実装

2025/01/24に公開

はじめに

オンラインサロンでは、ユーザーのニーズに応じて柔軟にプラン変更ができることが重要です。今回は、Stripeを活用して以下の要件を実現した実装について解説します:

  • 上位プランへの変更(5,000円 → 10,000円):即時反映+日割り計算での請求
  • 下位プランへの変更(10,000円 → 5,000円):次回更新時からの変更予約

実装の特徴

本実装の特徴は、ほぼ全ての課金管理をStripeに委ねることで、自社でのデータ管理を最小限に抑えている点です。プラン変更に関する複雑な処理(日割り計算、次回更新日からの変更など)をStripeの機能を最大限活用することで実現しています。

プラン変更処理の実装詳細

1. アップグレード処理(5,000円 → 10,000円)

アップグレードの場合、ユーザーが即座に上位プランの機能を利用できるようにする必要があります。同時に、プラン変更時点から次回更新日までの差額を日割りで請求する必要があります。

// Stripe APIを使用した即時アップグレード処理
const subscription = await stripe.subscriptions.update(subscriptionId, {
  items: [{ price: newPriceId }],
  proration_behavior: 'always_invoice'  // 日割り計算を有効化
});

この実装のポイント:

  • proration_behavior: 'always_invoice'を指定することで、Stripeが自動的に日割り計算を行い、差額の請求書を発行
  • 即座にプランが更新され、ユーザーは新プランの機能にアクセス可能
  • 差額が即時に請求・集金される(ユーザーの意思決定が明確な時点での課金)
  • 請求金額の計算をStripeに委ねることで、複雑な計算ロジックを自前で実装する必要がない

2. ダウングレード処理(10,000円 → 5,000円)

ダウングレードの実装は、一見シンプルに見えて実は複雑な要件を含んでいます。

なぜ単純な更新では対応できないのか?

単純にsubscriptions.updateを使用した場合、以下の問題が発生します:

  1. 即座にプランが変更され、現在の請求期間の返金が発生する
  2. ユーザーが既に支払済みの期間について、予期せぬ返金処理が行われる
  3. 次回更新日を正確に指定した変更予約ができない
  4. 「この顧客はいつまでは上位プランを利用可能」という情報を自社のデータベースで管理する必要が出てくる
  5. 即時にプランダウンしてしまうと、上位プランの料金を支払っているにも関わらずサービスが利用できなくなり、ユーザーの不信感に繋がる

Subscription Scheduleを活用した解決策

この問題を解決するため、StripeのSubscription Schedule機能を活用します:

// [cancel.post.ts:L72-L89] 現在のサブスクリプションから新しいスケジュールを作成
const schedule = await stripe.subscriptionSchedules.create({
  from_subscription: subscriptionId
});

// 次回更新日からの価格変更をスケジュール
await stripe.subscriptionSchedules.update(schedule.id, {
  phases: [
    {
      start_date: currentPhaseEndDate,  // 現在の請求期間の終了日
      items: [{ price: newPriceId }],   // 新しい価格ID
      proration_behavior: 'none'         // 日割り計算を無効化
    }
  ]
});

この実装のポイント:

  • Subscription Scheduleを使用することで、将来の特定の日時でのプラン変更を予約可能
  • 現在の請求期間は維持されたまま、次回更新日からの変更を確実に実行
  • 返金処理や中途解約などの複雑な処理を回避
  • スケジュール情報もStripe側で管理されるため、自社でのデータ管理が不要
  • 無料トライアル期間中のユーザーに対する特別な処理も実装

特に無料トライアル期間中のダウングレード処理は重要なポイントです:

// 無料トライアル中かどうかのフラグを設定
{
  items: [
    {
      price: currentPhaseItem.price as string,
      quantity: currentPhaseItem.quantity,
    },
  ],
  start_date: currentPhase.start_date,
  end_date: currentPhase.end_date,
  trial: subscription.status === 'trialing', // この設定が重要
},

このフラグを設定しないと、無料トライアル期間中にダウングレードした場合に、10,000円の即時請求が発生してしまう問題がありました。これは、トライアル状態が正しく引き継がれず、システムが通常の有料プランとして処理してしまうためです。trial: subscription.status === 'trialing'を適切に設定することで、以下を実現しています:

  • トライアル期間中のダウングレード時に10,000円請求が発生することを防止
  • トライアル状態を確実に維持したままプラン変更を実行
  • 予期せぬ課金を防ぎつつ、ユーザーの意図通りのプラン変更を実現

この実装により、無料トライアル期間中のユーザーも安全にプラン変更を行えるようになりました。

重要なエッジケースとその対応

以下のような複雑なシナリオでも、Stripeのサブスクリプションスケジュールを活用することで適切に対応しています:

  1. 無料トライアル中のダウングレード予約後のキャンセル

    • システムは既存のスケジュールを確認
    • トライアル状態を維持したまま現在のフェーズのみを保持
    • 予期せぬ課金を防止しつつ、適切なタイミングでの終了を保証
  2. 有料期間中でプラン変更予約がある状態でのキャンセル

    • 現在の利用期間は上位プランの利用を保証
    • スケジュールを修正して現在のフェーズのみを維持
    • 期間終了時に確実にキャンセルされるようend_behavior: 'cancel'を設定
  3. アップグレード直後のキャンセル

    • 即時アップグレードと日割り課金は維持
    • 次回更新日までは新プランの利用を保証
    • 返金処理は発生させない設計

各シナリオにおいて、システムは必ずサブスクリプションスケジュールの存在を確認し、存在する場合は適切に修正して予期せぬ課金を防止します。これにより、複雑な状態遷移でも一貫した動作を実現しています。

エラーハンドリング

プラン変更処理では、以下のようなエラーケースに対応する必要があります:

  1. 無効なプランIDの指定
  2. 既に同じプランへの変更がスケジュールされている場合
  3. 支払い方法が無効になっている場合
// [cancel.post.ts:L95-L109] エラーハンドリングの実装
try {
  // プラン変更処理
  if (isDowngrade) {
    await handleDowngrade(subscriptionId, newPriceId);
  } else {
    await handleUpgrade(subscriptionId, newPriceId);
  }
} catch (error) {
  if (error.type === 'StripeInvalidRequestError') {
    // Stripe APIのエラーハンドリング
    handleStripeError(error);
  }
  throw error;
}

まとめ

本実装の特徴は以下の通りです:

  1. Stripeの機能を最大限活用することで、複雑なプラン変更ロジックを実現

    • アップグレード時の日割り計算
    • ダウングレード時の次回更新日からの変更予約
  2. 自社でのデータ管理を最小限に抑制

    • 課金状態はStripeで完結
    • スケジュール管理もStripeに委譲
  3. エッジケースへの対応

    • 適切なエラーハンドリング
    • ユーザー体験を損なわない安全な実装

この実装により、保守性の高い、かつユーザーにとって使いやすいプラン変更機能を実現することができました。

サブスクリプションのキャンセル処理

サブスクリプションのキャンセル処理は、ユーザーの状態(無料トライアル中か有料期間中か)やプラン変更のスケジュールの有無によって、適切に処理を分岐させる必要があります。以下では、各シナリオでの実装の詳細を説明します。

キャンセルシナリオのマトリックス

1. 無料トライアル期間中のケース

  1. 基本プランのトライアル → キャンセル(スケジュールなし)

    • 標準的なキャンセル処理(cancel_at_period_end: true
    • トライアル期間終了までサービスを利用可能
    • データベース管理不要:Stripeの状態のみで制御
  2. 基本プランのトライアル → ダウングレード予約 → キャンセル(スケジュールあり)

    • サブスクリプションスケジュールを取得
    • 現在のフェーズのみを維持するようにスケジュールを更新
    • trial: subscription.status === 'trialing' でトライアル状態を維持
    • 予期せぬ課金を防止
  3. 基本プランのトライアル → アップグレード → キャンセル

    • 即時アップグレード処理
    • トライアル状態を維持したままキャンセル
    • トライアル期間終了までサービスを利用可能

2. 有料期間中のケース

  1. 基本プラン → キャンセル(スケジュールなし)

    • 標準的なキャンセル処理
    • cancel_at_period_end: true を設定
    • 期間終了までサービスを利用可能
    • 返金処理は発生させない
  2. 基本プラン → ダウングレード予約 → キャンセル(スケジュールあり)

    • 既存のスケジュールを取得して更新
    • 現在のフェーズのみを維持(上位プランの利用を保証)
    • 終了日を current_period_end に設定
    • end_behavior: 'cancel' で確実な終了を保証
  3. 基本プラン → アップグレード → キャンセル

    • 即時アップグレードと日割り請求
    • 標準的なキャンセル処理
    • 期間終了までサービスを利用可能

実装の特徴

キャンセル処理の実装では、以下の点に特に注意を払いました:

  1. キャンセル処理の基本的な流れ

    // [cancel.post.ts:L43-L45] 通常のキャンセル処理(スケジュールなし)
    const result = await stripeHandler.cancelSubscription(subscriptionId, {
      cancel_at_period_end: true // 次回更新日に解約するように設定
    });
    
    • cancel_at_period_end: trueを設定することで、次回更新日まではサービスを利用可能
    • キャンセル予定日をStripeが管理するため、自社DBでの管理が不要
    • 返金処理やプロレーション計算も自動的に処理
  2. スケジュール付きキャンセルの処理

    // [cancel.post.ts:L56-L64] スケジュール付きキャンセルの処理
    if (subscription.schedule) {
      await this.stripe.subscriptionSchedules.update(schedule.id, {
        phases: [
          {
            items: [
              {
                price: subscription.items.data[0].price.id,
                quantity: subscription.items.data[0].quantity,
              }
            ],
            start_date: currentPhase.start_date,
            end_date: subscription.current_period_end,
            trial: subscription.status === 'trialing',
            proration_behavior: 'none',
          }
        ],
        end_behavior: 'cancel', // 期間終了後に自動的にキャンセル
      });
    }
    
    • プラン変更予約がある場合は、現在のフェーズのみを維持
    • subscription.current_period_endで確実に終了日を設定
    • end_behavior: 'cancel'で自動キャンセルを保証

    特に重要なのがend_behavior: 'cancel'の設定です:

    • 無料トライアル期間中の場合:

      • トライアル終了時に自動的にサブスクリプションを終了
      • 課金が発生することなく確実にキャンセル
      • trial: subscription.status === 'trialing'との組み合わせで安全な終了を保証
    • 有料期間中の場合:

      • 現在の請求期間終了時に自動的にサブスクリプション終了
      • 追加の課金や返金処理が発生しない
      • プラン変更のスケジュールがあっても確実に現在の期間で終了

    この実装により、キャンセル処理の確実性を担保しつつ、ユーザーの利用権限を適切に維持することができます。

  3. データベース管理の最小化とその重要性

    • サブスクリプション状態はStripeに完全委譲
    • 終了日やプラン変更予定日を自社で保持しない理由:
      • データの整合性維持が困難(Stripeの状態と自社DBの同期ズレのリスク)
      • 「この顧客はいつまでは上位プランを利用可能」という情報を自社DBで管理すると以下の問題が発生:
        • プラン変更時に複数のテーブルを更新する必要性
        • キャンセル時のロールバック処理の複雑化
        • データの不整合が発生した場合のトラブルシューティングが困難
      • プラン変更やキャンセルのスケジュール変更時に:
        • DBのトランザクション管理が複雑化
        • 予期せぬエラー時のリカバリが困難
        • システムの可用性に影響を与えるリスク
    • Stripeのスケジュール機能で全て一元管理することで:
      • データの整合性を確実に保証:
        • プラン変更とキャンセルの状態を単一の信頼できるソースで管理
        • 複雑なスケジュール管理を安全に実現
        • システムの保守性を向上
      • ビジネス要件への柔軟な対応:
        • トライアル期間中のプラン変更
        • 期間途中でのアップグレード/ダウングレード
        • キャンセル時の返金処理
      • 運用負荷の軽減:
        • 課金トラブル時の原因特定が容易
        • カスタマーサポートの効率化
        • システム変更時のリスク低減
  4. トライアル状態の維持

    if (subscription.schedule) {
      await this.stripe.subscriptionSchedules.update(subscription.schedule as string, {
        phases: [
          {
            // 現在のフェーズの詳細を維持
            start_date: currentPhase.start_date,
            end_date: subscription.current_period_end,
            trial: subscription.status === 'trialing', // トライアル状態を明示的に設定
          }
        ],
        end_behavior: 'cancel',
      });
    }
    
  5. スケジュール管理

    • プラン変更予約がある場合は適切に処理
    • 現在のフェーズのみを維持
    • 以降のスケジュールを確実にキャンセル
  6. エラー防止

    • トライアル中の予期せぬ課金を防止
    • 返金処理が発生しないよう制御
    • ユーザー体験を損なわない安全な実装

最終的なコード

  async cancelSubscription(
    subscriptionId: string,
    options?: { cancel_at_period_end?: boolean },
  ) {
    try {
      const subscription = await this.stripe.subscriptions.retrieve(subscriptionId);

      // スケジュールがある場合
      if (subscription.schedule) {
        // スケジュールを取得
        const schedule = await this.stripe.subscriptionSchedules.retrieve(
          subscription.schedule as string
        );

        // 現在のフェーズの情報を取得
        const currentPhase = schedule.phases[0];

        // スケジュールを更新(現在のフェーズのみ維持)
        await this.stripe.subscriptionSchedules.update(subscription.schedule as string, {
          phases: [
            {
              items: [
                {
                  price: subscription.items.data[0].price.id,
                  quantity: subscription.items.data[0].quantity,
                }
              ],
              start_date: currentPhase.start_date,
              end_date: subscription.current_period_end,
              proration_behavior: 'none',
              trial: subscription.status === 'trialing',
            }
          ],
          end_behavior: 'cancel', // 期間終了後に自動的にキャンセル
        });
        return await this.stripe.subscriptions.retrieve(subscriptionId);
        ;
      }

      // スケジュールがない場合は通常の解約処理
      return await this.stripe.subscriptions.update(subscriptionId, {
        cancel_at_period_end: options?.cancel_at_period_end ?? true,
      });
    } catch (error: any) {
      console.error('Error canceling subscription:', error);
      throw createError({
        statusCode: 400,
        message: error.message || 'Subscription cancellation failed',
      });
    }
  }

この実装により、複雑なキャンセルシナリオも、自社でのデータベース管理を最小限に抑えながら、安全かつ確実に処理することができます。特に、プラン変更のスケジュールが存在する場合でも、ユーザーの現在の利用権限(トライアルや上位プラン)を適切に維持したまま、確実にキャンセル処理を完了させることができます。

Discussion