Menu

サブスクリプション変更の実装方法

プロダクトと価格の関係

サブスクリプションのアップグレード/ダウングレード機能の開発を始める前に、重要な前提条件を明確にする必要があります。Stripeのサブスクリプションプラン変更は、異なるプロダクト間の切り替えに基づいています。つまり:

  • サポート対象: プロダクトA → プロダクトBの変更
  • サポート対象外: プロダクトAの価格1 → プロダクトAの価格2の変更

そのため、すべての価格プランを同じプロダクト配下に配置するのではなく、機能階層ごとにプロダクトを整理することを推奨します。例えば:

Product1:
├── Price: $10/月
└── Price: $100/年
 
Product2:
├── Price: $20/月
└── Price: $200/年
 
Product3:
├── Price: $50/月
└── Price: $500/年

サブスクリプション変更エントリーポイントの有効化

Stripe Customer Portalに移動します

Stripe Customer Portal

Subscriptionsで変更エントリーポイントを有効にし、順番にサブスクリプション変更オプションを設定します

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

設定が完了すると、ユーザーは /dashboard/subscription のポータルページからサブスクリプション変更機能にアクセスできます

Stripe Customer portal subscription
Stripe Customer portal subscription
Stripe Customer portal subscription

ユーザーがプランを変更すると、Stripeが自動的に価格差を計算します。

この時点で、プロダクトに複数のプランがある場合、サブスクリプション変更は単純なアップグレード/ダウングレードの関係ではなく、価格構造が複雑になるほど、サブスクリプション変更の実装ロジックも複雑になることに気づいたかもしれません。

例えば、プロダクトに複数の月間プランと複数の年間プランがあり、すべてが変更をサポートする場合、少なくとも以下の6つのシナリオを処理する必要があります:

月間プランの変更

  • アップグレード: 月間プランA → 月間プランB(価格上昇)
  • ダウングレード: 月間プランB → 月間プランA(価格下降)

年間プランの変更

  • アップグレード: 年間プランC → 年間プランD(価格上昇)
  • ダウングレード: 年間プランD → 年間プランC(価格下降)

請求サイクルの変更

  • 月間から年間へ: 任意の月間プラン → 対応する年間プラン
  • 年間から月間へ: 任意の年間プラン → 対応する月間プラン

Nextyはサブスクリプション変更の検出とエントリーポイントのメソッドを提供していますが、実際の実装はこのドキュメントで概説されているアプローチに従って開発する必要があります。ロジックを理解すれば、AIが作業の大部分を完了するのを手伝ってくれます。

実装アプローチ

処理エントリーポイントの確認

オンラインでは、customer.subscription.updated イベントを使用してサブスクリプションのアップグレード/ダウングレードを判断する方法が見つかります。ロジックは大体このようになります:

case 'customer.subscription.updated':
  const subscription = event.data.object as Stripe.Subscription;
  const previousAttributes = (event as any).data?.previous_attributes;
 
  const currentPriceId = subscription.items.data[0]?.price.id;
  const previousItems = previousAttributes.items as Stripe.ApiList<Stripe.SubscriptionItem>;
  const previousPriceId = previousItems?.data?.[0]?.price?.id;
 
  if (!currentPriceId || !previousPriceId || currentPriceId === previousPriceId) {
    return defaultResult;
  }
 
  // 次にcurrentPriceIdとpreviousPriceIdを通じて2つのプランを取得して比較し、アップグレードまたはダウングレードを判断します

このアプローチは間違いではなく、最も迅速ですが、重要な問題を見落としています。コード内で、サブスクリプション後のユーザー特典のアップグレードを処理するイベントはどれでしょうか?ユーザー特典のアップグレードが customer.subscription.updated で処理されていない場合、ここでユーザーサブスクリプションの変更を判断しても無意味です。他のイベントが依然として元のロジックに従ってユーザー特典を処理するからです。

Nexty.devのボイラープレートを例にすると、すべてのサブスクリプション支払い処理は invoice.paid イベントで処理されるため、サブスクリプション変更も invoice.paid イベントで処理する必要があります。

コード実装

注意

まず package.json ファイルの version フィールドを確認してください。Nextyのバージョンがv3.2.2未満の場合は、まずこの変更記録に従って planId のクエリタイミングを修正してください。

以前のコードは subscription.metadata?.planIdplanId 取得の主要な方法として使用し、データベースクエリをフォールバックとしていました。これを逆にして、サブスクリプション変更時に変更後のプランを取得できるようにする必要があります。

サブスクリプション変更検出を実装する一般的なアプローチは以下の通りです:

  • invoice.paid イベント情報から変更前後の price_id を読み取る
  • price_id に基づいて詳細なプラン情報を取得し、変更前後のプランの価格、サイクルなどを判断し、変更タイプ識別子を生成する
  • 変更タイプ識別子と異なるプランで定義された特典に基づいて、ユーザー特典を具体的に処理する

既存のコードでは、app/api/stripe/webhook/webhook-handlers.tshandleInvoicePaid メソッドに自動処理ロジックエントリーポイントがあります。これをリファクタリングする必要があります:

app/api/stripe/webhook/webhook-handlers.ts
    if (planId && userId && subscription) {
      // --- [custom] Upgrade ---
      const orderId = insertedOrder.id;
      try {
        await upgradeSubscriptionCredits(userId, planId, orderId, subscription);
      } catch (error) {
        console.error(`CRITICAL: Failed to upgrade subscription credits for user ${userId}, order ${orderId}:`, error);
        await sendCreditUpgradeFailedEmail({ userId, orderId, planId, error });
        throw error;
      }
      // --- End: [custom] Upgrade ---
    }

これを以下に変更します:

app/api/stripe/webhook/webhook-handlers.ts
    if (planId && userId && subscription) {
      // --- [custom] Upgrade ---
      const orderId = insertedOrder.id;
      try {
        // これがサブスクリプション変更(アップグレード/ダウングレード)の請求書かどうかをチェック
        const isSubscriptionUpdate = invoice.billing_reason === 'subscription_update' && invoice.lines.data.length === 2;
 
        if (isSubscriptionUpdate) {
          // 変更前後のprice_idを取得
          const currentPriceId = invoice.lines.data[0].pricing.price_details.price
          const previousPriceId = invoice.lines.data[1].pricing.price_details.price
          if (currentPriceId && previousPriceId) {
            // 変更前後の価格情報とchangeTypeを取得
            const changeResult = await detectSubscriptionChange(currentPriceId, previousPriceId);
            if (changeResult.changeType !== 'none') {
              // サブスクリプション変更を処理
              // 変更シナリオの複雑さにより、ボイラープレートはすべてのシナリオをカバーできないため、エントリーポイントと処理の提案のみを提供します
              // エントリメソッドhandleSubscriptionChangeも `app/api/stripe/webhook/subscription-change.ts` ファイルにあります
              await handleSubscriptionChange(subscription, changeResult);
              return;
            }
          }
        } else {
          // サブスクリプション変更ではない、変更なし
          await upgradeSubscriptionCredits(userId, planId, orderId, subscription);
        }
      } catch (error) {
        console.error(`CRITICAL: Failed to upgrade subscription credits for user ${userId}, order ${orderId}:`, error);
        await sendCreditUpgradeFailedEmail({ userId, orderId, planId, error });
        throw error;
      }
      // --- End: [custom] Upgrade ---
    }

detectSubscriptionChange を実装して変更前後のサブスクリプション情報を取得し、変更識別子 changeType を返します。このメソッドは既に実装されており、コードは app/api/stripe/webhook/subscription-change.ts にあります

app/api/stripe/webhook/subscription-change.ts
// import ...
 
/**
 * Subscription change type details
 * 订阅变更类型详情
 * サブスクリプション変更タイプの詳細
 */
export type SubscriptionChangeType =
  | 'monthly_to_monthly_upgrade'
  | 'monthly_to_monthly_downgrade'
  | 'yearly_to_yearly_upgrade'
  | 'yearly_to_yearly_downgrade'
  | 'monthly_to_yearly_change'
  | 'yearly_to_monthly_change'
  | 'none'; // 変更なし
 
/**
 * Subscription change detection result
 * 订阅变更检测结果
 * サブスクリプション変更検出結果
 */
export interface SubscriptionChangeResult {
  changeType: SubscriptionChangeType;
  previousPriceId?: string;
  currentPriceId?: string;
  previousPlanId?: string;
  currentPlanId?: string;
  previousInterval?: string;
  currentInterval?: string;
  previousPrice?: string;
  currentPrice?: string;
}
 
const getChangeType = (prevInterval: string, currInterval: string, prevAmount: number, currAmount: number) => {
  if (prevInterval !== currInterval) {
    return `${prevInterval}_to_${currInterval}_change`
  }
 
  if (prevAmount === currAmount) return 'none';
 
  const direction = currAmount > prevAmount ? 'upgrade' : 'downgrade';
  return `${currInterval}_to_${currInterval}_${direction}`
};
 
/**
 * Detects if a subscription update is an upgrade, downgrade, or interval change
 * 检测订阅更新是升级、降级还是周期变更
 * サブスクリプションの更新がアップグレード、ダウングレード、または期間変更かを検出
 */
export async function detectSubscriptionChange(
  currentPriceId: string,
  previousPriceId: string
) {
  const defaultResult: SubscriptionChangeResult = {
    changeType: 'none',
  };
 
  if (!currentPriceId || !previousPriceId) {
    return defaultResult;
  }
 
  // データベースからプラン情報を取得して比較
  const [currentPlanResults, previousPlanResults] = await Promise.all([
    db
      .select({
        id: pricingPlansSchema.id,
        price: pricingPlansSchema.price,
        recurringInterval: pricingPlansSchema.recurringInterval,
        benefitsJsonb: pricingPlansSchema.benefitsJsonb,
      })
      .from(pricingPlansSchema)
      .where(eq(pricingPlansSchema.stripePriceId, currentPriceId))
      .limit(1),
    db
      .select({
        id: pricingPlansSchema.id,
        price: pricingPlansSchema.price,
        recurringInterval: pricingPlansSchema.recurringInterval,
        benefitsJsonb: pricingPlansSchema.benefitsJsonb,
      })
      .from(pricingPlansSchema)
      .where(eq(pricingPlansSchema.stripePriceId, previousPriceId))
      .limit(1),
  ]);
 
  const currentPlan = currentPlanResults[0];
  const previousPlan = previousPlanResults[0];
 
  if (!currentPlan || !previousPlan) {
    console.warn(`Could not find plan data for price comparison. Current: ${currentPriceId}, Previous: ${previousPriceId}`);
    return defaultResult;
  }
 
  const currentInterval = currentPlan.recurringInterval?.toLowerCase();
  const previousInterval = previousPlan.recurringInterval?.toLowerCase();
  const currentAmount = parseFloat(currentPlan.price || '0');
  const previousAmount = parseFloat(previousPlan.price || '0');
 
  // 期間と価格に基づいて変更タイプを判断
  const changeType = getChangeType(previousInterval as string, currentInterval as string, previousAmount, currentAmount);
 
  return {
    changeType: changeType || 'none',
    previousPriceId,
    currentPriceId,
    previousPlanId: previousPlan.id,
    currentPlanId: currentPlan.id,
    previousInterval: previousInterval || undefined,
    currentInterval: currentInterval || undefined,
    previousPrice: previousPlan.price || undefined,
    currentPrice: currentPlan.price || undefined,
  };
}
 
/**
 * Main router function for handling subscription changes
 * 订阅变更的主路由函数
 * サブスクリプション変更のメインルーター関数
 */
export async function handleSubscriptionChange(
  subscription: Stripe.Subscription,
  changeResult: SubscriptionChangeResult
) {
  // ...
}

完全なサブスクリプション変更ロジックの完成方法

  1. 開発前に、Stripeのサブスクリプション変更エントリーポイントにある各設定オプションの目的を完全に理解してください。そうすることで初めて、どのプランが変更をサポートする必要があるかを判断でき、実際の実装の複雑さに影響します。

  2. 要件を決定した後、app/api/stripe/webhook/subscription-change.ts で提供されている実装の提案がニーズに合っているかを確認してください。相違がある場合は、ファイル内のコメントを修正してください。

  3. 以下のファイルを同時に選択し、app/api/stripe/webhook/subscription-change.ts のコメントに基づいてAIにロジック開発を完了させます:

  • lib/db/schema.ts
  • app/api/stripe/webhook/
  1. 厳密なテスト。すべての可能な切り替えシナリオを徹底的にテストする必要があります。Stripeのテストクロック機能を使用して、時間を進めてサブスクリプション更新をシミュレートし、以下のページからバグをトラブルシューティングします:
  • /dashboard/credit-usage-example(テストページ、テスト環境でのみアクセス可能)
  • /dashboard/credit-history
  • /dashboard/subscription
  • /dashboard/my-orders