💳

Next.js + Stripe で実装するサブスクリプション機能 - SaaS開発の実践ガイド

に公開

はじめに

SaaSアプリケーションを開発する上で、サブスクリプション機能は欠かせない要素です。しかし、決済処理やWebhookの実装、クレジット管理など、考慮すべき点が多く、初めて実装する際は戸惑うことも多いでしょう。

この記事では、配送ルート最適化SaaS「Delivroute」で実装したStripeサブスクリプション機能を例に、実際のコードを交えながら解説します。

この記事で学べること

  • Stripeの基本設定とプラン管理
  • Next.js App RouterでのCheckout Session作成
  • Webhookによる決済イベントのハンドリング
  • 単発決済(クレジット購入)の実装
  • Customer Portalによるサブスク管理
  • FirestoreとStripeの連携パターン
  • エラーハンドリングとセキュリティ対策

技術スタック

  • フロントエンド: Next.js 15 (App Router), TypeScript
  • バックエンド: Next.js API Routes (Route Handlers)
  • 決済: Stripe API
  • データベース: Cloud Firestore
  • 認証: Firebase Authentication

実装する機能

  1. サブスクリプション機能: 無料プラン・Proプラン(月額¥1,980)
  2. クレジット購入: 単発決済で追加クレジット購入(例: 10クレジット ¥600)
  3. 顧客ポータル: サブスクの管理・キャンセル

1. Stripeの基本設定

1.1 環境変数の設定

まず、Stripe APIキーを環境変数に設定します。

# Stripe API Keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx

# Stripe Price IDs (Stripeダッシュボードで作成したPrice IDを設定)
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_xxxxx
NEXT_PUBLIC_STRIPE_PRICE_ID_CREDITS_10=price_xxxxx
# 他のクレジットパック (30/50/100) も同様に設定

1.2 プラン定義

lib/stripe/config.ts でプラン情報を定義します。

lib/stripe/config.ts
import { PlanInfo } from '@/types/subscription';

/**
 * プラン情報の定義
 */
export const PLANS: Record<string, PlanInfo> = {
  free: {
    id: 'free',
    name: '無料プラン',
    price: 0,
    currency: 'jpy',
    interval: 'month',
    features: [
      '月3クレジット',
      '簡易最適化のみ(1クレジット/回)',
      '最大5地点までのルート',
      '20件までのルート保存',
    ],
    limits: {
      maxWaypoints: 5,           // 最大地点数
      maxCalculationsPerMonth: 3, // 月間最適化回数
      maxSavedRoutes: 20,         // 保存可能なルート数
    },
  },
  pro: {
    id: 'pro',
    name: 'Proプラン',
    price: 1980,
    currency: 'jpy',
    interval: 'month',
    features: [
      '月60クレジット',
      '簡易最適化: 1クレジット/回',
      '厳密最適化: 2-20クレジット/回(地点数依存)',
      '最大10地点までのルート',
      '100件までのルート保存',
    ],
    limits: {
      maxWaypoints: 10,
      maxCalculationsPerMonth: null, // unlimited (クレジット制)
      maxSavedRoutes: 100,
    },
  },
};

/**
 * クレジットパック定義
 */
export const CREDIT_PACKS: Record<string, CreditPackInfo> = {
  CREDITS_10: {
    id: 'credits_10',
    credits: 10,
    price: 600,
    priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_CREDITS_10 || '',
    pricePerCredit: 60, // 1クレジットあたりの価格
  },
  // 他のパック (30/50/100クレジット) も同様に定義
};
型定義 (types/subscription.ts)
types/subscription.ts
export interface PlanInfo {
  id: string;
  name: string;
  price: number;
  currency: string;
  interval: 'month' | 'year';
  features: string[];
  limits: {
    maxWaypoints: number;
    maxCalculationsPerMonth: number | null;
    maxSavedRoutes: number;
  };
}

1.3 Stripe Client初期化

lib/stripe/server.ts でStripe Clientを初期化します。

lib/stripe/server.ts
import Stripe from 'stripe';

/**
 * Stripe Client (サーバーサイドのみ)
 */
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
  apiVersion: '2024-11-20.acacia', // 最新のAPIバージョンを指定
  typescript: true,
});

export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
export const STRIPE_PRICE_ID_PRO_MONTHLY =
  process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY || '';

2. サブスクリプション機能の実装

2.1 Checkout Sessionの作成

ユーザーがProプランにアップグレードする際、Stripe Checkoutページに遷移させる必要があります。

app/api/stripe/create-checkout-session/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe, STRIPE_PRICE_ID_PRO_MONTHLY } from '@/lib/stripe/server';

/**
 * POST /api/stripe/create-checkout-session
 * Stripe Checkoutセッションを作成
 */
export async function POST(request: NextRequest) {
  try {
    // 1. 認証確認
    const authHeader = request.headers.get('authorization');
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return NextResponse.json(
        { error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
        { status: 401 }
      );
    }

    const idToken = authHeader.split('Bearer ')[1];
    const decodedToken = await adminAuth.verifyIdToken(idToken);
    const userId = decodedToken.uid;

    // 2. リクエストボディ取得
    const body = await request.json();
    const { priceId, successUrl, cancelUrl } = body;

    // 3. バリデーション
    if (!priceId || !successUrl || !cancelUrl) {
      return NextResponse.json(
        {
          error: {
            code: 'VALIDATION_ERROR',
            message: '必須パラメータが不足しています',
          },
        },
        { status: 400 }
      );
    }

    // 4. ユーザー情報取得
    const userDoc = await adminDb.collection('users').doc(userId).get();
    if (!userDoc.exists) {
      return NextResponse.json(
        { error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
        { status: 404 }
      );
    }

    const userData = userDoc.data();
    let stripeCustomerId = userData?.stripeCustomerId;

    // 5. Stripe Customerが存在しない場合は作成
    if (!stripeCustomerId) {
      const customer = await stripe.customers.create({
        email: decodedToken.email,
        metadata: {
          firebaseUID: userId, // FirebaseとStripeを紐付けるために重要
        },
      });
      stripeCustomerId = customer.id;

      // FirestoreにStripe Customer IDを保存
      await adminDb.collection('users').doc(userId).update({
        stripeCustomerId: stripeCustomerId,
        updatedAt: new Date(),
      });
    }

    // 6. Checkout Sessionを作成
    const session = await stripe.checkout.sessions.create({
      customer: stripeCustomerId,
      mode: 'subscription',
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId, // Stripe Price ID
          quantity: 1,
        },
      ],
      success_url: successUrl,
      cancel_url: cancelUrl,
      allow_promotion_codes: true, // プロモーションコードを有効化
      billing_address_collection: 'auto',
      locale: 'ja', // 日本語表示
      metadata: {
        firebaseUID: userId, // Webhookで使用
      },
    });

    return NextResponse.json({
      sessionId: session.id,
      url: session.url, // CheckoutページのURL
    });
  } catch (error: any) {
    console.error('Create checkout session error:', error);
    return NextResponse.json(
      {
        error: {
          code: 'INTERNAL_ERROR',
          message: error.message || 'Checkoutセッションの作成に失敗しました',
        },
      },
      { status: 500 }
    );
  }
}

重要なポイント:

  1. Firebase UIDをmetadataに保存: Webhookで誰の決済かを識別するために必須
  2. Stripe Customer IDをFirestoreに保存: 次回以降のCheckout Sessionで再利用
  3. 認証確認: Firebase ID Tokenで認証を行う

2.2 フロントエンドからの呼び出し

フロントエンドからCheckout APIを呼び出す例です。

components/PricingCard.tsx
'use client';

import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';

export function PricingCard() {
  const { firebaseUser } = useAuth();
  const [isLoading, setIsLoading] = useState(false);

  const handleUpgrade = async () => {
    if (!firebaseUser) return;

    setIsLoading(true);

    try {
      // Firebase ID Tokenを取得
      const idToken = await firebaseUser.getIdToken();

      // Checkout Sessionを作成
      const response = await fetch('/api/stripe/create-checkout-session', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${idToken}`,
        },
        body: JSON.stringify({
          priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY,
          successUrl: `${window.location.origin}/dashboard?upgrade=success`,
          cancelUrl: `${window.location.origin}/pricing`,
        }),
      });

      if (!response.ok) {
        throw new Error('Checkout Sessionの作成に失敗しました');
      }

      const data = await response.json();

      // Stripe Checkoutページに遷移
      if (data.url) {
        window.location.href = data.url;
      }
    } catch (error) {
      console.error('Upgrade error:', error);
      alert('アップグレードに失敗しました');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <button onClick={handleUpgrade} disabled={isLoading}>
      {isLoading ? '処理中...' : 'Proプランにアップグレード'}
    </button>
  );
}

3. Webhookの実装

Stripeからの決済イベントをWebhookで受け取り、Firestoreを更新します。

3.1 Webhook Handlerの実装

app/api/stripe/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { stripe, STRIPE_WEBHOOK_SECRET } from '@/lib/stripe/server';
import Stripe from 'stripe';

/**
 * POST /api/stripe/webhook
 * StripeからのWebhookイベントを処理
 */
export async function POST(request: NextRequest) {
  try {
    const body = await request.text();
    const signature = request.headers.get('stripe-signature');

    if (!signature) {
      return NextResponse.json(
        { error: { code: 'BAD_REQUEST', message: 'Webhook署名がありません' } },
        { status: 400 }
      );
    }

    // Webhookイベントを検証
    let event: Stripe.Event;
    try {
      event = stripe.webhooks.constructEvent(
        body,
        signature,
        STRIPE_WEBHOOK_SECRET
      );
    } catch (err: any) {
      console.error('Webhook signature verification failed:', err.message);
      return NextResponse.json(
        {
          error: {
            code: 'BAD_REQUEST',
            message: `Webhook署名の検証に失敗しました: ${err.message}`,
          },
        },
        { status: 400 }
      );
    }

    console.log('Received Stripe webhook event:', event.type);

    // イベントタイプに応じて処理
    switch (event.type) {
      case 'checkout.session.completed':
        await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
        break;

      case 'customer.subscription.created':
      case 'customer.subscription.updated':
        await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
        break;

      case 'customer.subscription.deleted':
        await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
        break;

      case 'invoice.payment_succeeded':
        await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
        break;

      case 'invoice.payment_failed':
        await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
        break;

      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    return NextResponse.json({ received: true });
  } catch (error: any) {
    console.error('Webhook error:', error);
    return NextResponse.json(
      {
        error: {
          code: 'INTERNAL_ERROR',
          message: error.message || 'Webhookの処理に失敗しました',
        },
      },
      { status: 500 }
    );
  }
}

3.2 イベントハンドラの実装

3.2.1 Checkout Session完了時

/**
 * Checkoutセッション完了時の処理
 */
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
  console.log('Checkout session completed:', session.id, 'mode:', session.mode);

  const firebaseUID = session.metadata?.firebaseUID;
  const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;

  if (!firebaseUID) {
    console.error('Firebase UID not found in session metadata');
    return;
  }

  // mode によって処理を分岐
  if (session.mode === 'payment') {
    // クレジット購入の場合(後述)
    await handleCreditPurchase(session, firebaseUID);
  } else if (session.mode === 'subscription') {
    // サブスクリプション購入の場合
    if (!customerId) {
      console.error('Customer ID not found in session');
      return;
    }

    // Firestoreを更新
    await adminDb.collection('users').doc(firebaseUID).update({
      stripeCustomerId: customerId,
      updatedAt: new Date(),
    });

    console.log(`Updated user ${firebaseUID} with customer ID ${customerId}`);
  }
}

3.2.2 サブスクリプション更新時

/**
 * サブスクリプション作成・更新時の処理
 */
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
  console.log('Subscription updated:', subscription.id);

  const customerId = typeof subscription.customer === 'string'
    ? subscription.customer
    : subscription.customer?.id;

  if (!customerId) {
    console.error('Customer ID not found in subscription');
    return;
  }

  // Stripe Customer IDからFirebase UIDを取得
  const usersSnapshot = await adminDb
    .collection('users')
    .where('stripeCustomerId', '==', customerId)
    .limit(1)
    .get();

  if (usersSnapshot.empty) {
    console.error(`User not found for customer ID: ${customerId}`);
    return;
  }

  const userDoc = usersSnapshot.docs[0];
  const userId = userDoc.id;
  const userData = userDoc.data();

  // サブスクリプションデータを抽出
  const status = subscription.status;
  const priceId = subscription.items.data[0]?.price.id || null;
  const currentPeriodEnd = subscription.current_period_end
    ? new Date(subscription.current_period_end * 1000)
    : null;
  const canceledAt = subscription.canceled_at
    ? new Date(subscription.canceled_at * 1000)
    : null;

  // 更新するフィールド
  const updateData: any = {
    subscriptionStatus: status,
    subscriptionPlanId: priceId,
    subscriptionCurrentPeriodEnd: currentPeriodEnd,
    subscriptionCanceledAt: canceledAt,
    updatedAt: new Date(),
  };

  // PROプランへのアップグレード時、クレジットを付与
  const currentStatus = userData?.subscriptionStatus || 'free';
  const isPROPlan = status === 'active' && priceId === process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY;

  if (isPROPlan && (currentStatus === 'free' || currentStatus === 'canceled')) {
    // 無料プランからPROプランへのアップグレード
    updateData.monthlyCredits = 60;
    updateData.usedCredits = 0;
    updateData.rolloverCredits = 0;  // 繰り越しクリア
    updateData.creditResetDate = currentPeriodEnd;
    console.log(`User ${userId} upgraded to PRO plan, granted 60 credits`);
  } else if (isPROPlan && currentStatus === 'active') {
    // すでにPROプランで契約更新の場合
    updateData.creditResetDate = currentPeriodEnd;
    console.log(`User ${userId} PRO plan renewed, updated credit reset date`);
  }

  // Firestoreを更新
  await adminDb.collection('users').doc(userId).update(updateData);

  console.log(`Updated user ${userId} subscription to ${status}`);
}

ポイント:

  • Stripe Customer IDからFirebase UIDを逆引き: stripeCustomerId でクエリ
  • PROプランへのアップグレード時にクレジットをリセット: monthlyCredits: 60, usedCredits: 0
  • 契約更新日を creditResetDate に保存: 次回リセット日を管理

3.2.3 サブスクリプション削除時

/**
 * サブスクリプション削除時の処理
 */
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  console.log('Subscription deleted:', subscription.id);

  const customerId = typeof subscription.customer === 'string'
    ? subscription.customer
    : subscription.customer?.id;

  if (!customerId) {
    console.error('Customer ID not found in subscription');
    return;
  }

  // Stripe Customer IDからFirebase UIDを取得
  const usersSnapshot = await adminDb
    .collection('users')
    .where('stripeCustomerId', '==', customerId)
    .limit(1)
    .get();

  if (usersSnapshot.empty) {
    console.error(`User not found for customer ID: ${customerId}`);
    return;
  }

  const userDoc = usersSnapshot.docs[0];
  const userId = userDoc.id;
  const userData = userDoc.data();

  const canceledAt = subscription.canceled_at
    ? new Date(subscription.canceled_at * 1000)
    : null;

  // 次回クレジットリセット日を計算(翌月1日)
  const now = new Date();
  const nextResetDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);

  // 残クレジットを繰り越し
  const currentMonthlyCredits = userData?.monthlyCredits || 60;
  const currentUsedCredits = userData?.usedCredits || 0;
  const remainingCredits = Math.max(0, currentMonthlyCredits - currentUsedCredits);

  // Firestoreを更新(無料プランに戻す)
  await adminDb.collection('users').doc(userId).update({
    subscriptionStatus: 'canceled',
    subscriptionPlanId: null,
    subscriptionCurrentPeriodEnd: null,
    subscriptionCanceledAt: canceledAt,
    monthlyCredits: 3,              // 無料プランに戻す
    usedCredits: currentUsedCredits, // そのまま維持
    rolloverCredits: remainingCredits, // 残クレジットを繰り越し
    creditResetDate: nextResetDate,
    updatedAt: new Date(),
  });

  console.log(`User ${userId} subscription canceled, reverted to free plan (rollover: ${remainingCredits} credits)`);
}

ポイント:

  • 残クレジットを繰り越し: rolloverCredits に保存
  • 無料プランに戻す: monthlyCredits: 3
  • usedCredits はそのまま維持: 制限せずに、次回リセット日まで使用可能

3.3 Webhookのローカルテスト

Stripe CLIを使ってWebhookをローカルでテストできます。

# Stripe CLIをインストール
brew install stripe/stripe-cli/stripe

# Stripeにログイン
stripe login

# Webhookをローカルにフォワード
stripe listen --forward-to localhost:3000/api/stripe/webhook

# 別のターミナルでイベントをトリガー
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated

4. クレジット購入機能(単発決済)

サブスクリプションとは別に、単発決済でクレジットを購入できる機能を実装します。

4.1 クレジット購入Checkout Sessionの作成

app/api/credits/purchase-checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe } from '@/lib/stripe/server';
import { getPriceIdByCreditAmount } from '@/lib/stripe/config';

/**
 * POST /api/credits/purchase-checkout
 * クレジット購入用のStripe Checkoutセッションを作成
 */
export async function POST(request: NextRequest) {
  try {
    // 1. 認証確認
    const authHeader = request.headers.get('authorization');
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return NextResponse.json(
        { error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
        { status: 401 }
      );
    }

    const idToken = authHeader.split('Bearer ')[1];
    const decodedToken = await adminAuth.verifyIdToken(idToken);
    const userId = decodedToken.uid;

    // 2. リクエストバリデーション
    const body = await request.json();
    const { creditAmount, successUrl, cancelUrl } = body;

    if (!creditAmount || !successUrl || !cancelUrl) {
      return NextResponse.json(
        {
          error: {
            code: 'VALIDATION_ERROR',
            message: '必須パラメータが不足しています',
          },
        },
        { status: 400 }
      );
    }

    const priceId = getPriceIdByCreditAmount(creditAmount);
    if (!priceId) {
      return NextResponse.json(
        {
          error: {
            code: 'VALIDATION_ERROR',
            message: '無効なクレジット数です',
          },
        },
        { status: 400 }
      );
    }

    // 3. ユーザー情報取得
    const userDoc = await adminDb.collection('users').doc(userId).get();
    if (!userDoc.exists) {
      return NextResponse.json(
        { error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
        { status: 404 }
      );
    }

    const userData = userDoc.data();
    let stripeCustomerId = userData?.stripeCustomerId;

    // 4. Stripe Customer取得または作成
    if (!stripeCustomerId) {
      const customer = await stripe.customers.create({
        email: decodedToken.email,
        metadata: {
          firebaseUID: userId,
        },
      });
      stripeCustomerId = customer.id;

      await adminDb.collection('users').doc(userId).update({
        stripeCustomerId: stripeCustomerId,
        updatedAt: new Date(),
      });
    }

    // 5. Checkoutセッション作成
    const session = await stripe.checkout.sessions.create({
      customer: stripeCustomerId,
      mode: 'payment', // サブスクリプションではなく単発決済
      payment_method_types: ['card'],
      line_items: [
        {
          price: priceId,
          quantity: 1,
        },
      ],
      success_url: successUrl,
      cancel_url: cancelUrl,
      locale: 'ja',
      metadata: {
        firebaseUID: userId,
        creditAmount: creditAmount.toString(), // クレジット数をメタデータに保存
      },
    });

    return NextResponse.json({
      sessionId: session.id,
      url: session.url,
    });
  } catch (error: any) {
    console.error('Create credit purchase checkout session error:', error);

    return NextResponse.json(
      {
        error: {
          code: 'INTERNAL_ERROR',
          message: error.message || 'Checkoutセッションの作成に失敗しました',
        },
      },
      { status: 500 }
    );
  }
}

重要なポイント:

  1. mode: 'payment': サブスクリプションではなく単発決済
  2. metadata にクレジット数を保存: Webhookで使用

4.2 クレジット購入時のWebhook処理

Webhook Handlerの handleCheckoutSessionCompleted 内で、mode === 'payment' の場合にクレジット購入処理を実行します。

/**
 * クレジット購入処理(Firestore トランザクション使用)
 */
async function handleCreditPurchase(
  session: Stripe.Checkout.Session,
  firebaseUID: string
) {
  const creditAmountStr = session.metadata?.creditAmount;

  if (!creditAmountStr) {
    console.error('Invalid or missing credit amount in session metadata');
    return;
  }

  const creditAmount = parseInt(creditAmountStr, 10);
  console.log(`Adding ${creditAmount} credits to user ${firebaseUID}`);

  // Firestoreトランザクションでクレジットを加算
  const userRef = adminDb.collection('users').doc(firebaseUID);

  try {
    await adminDb.runTransaction(async (transaction) => {
      const userDoc = await transaction.get(userRef);
      if (!userDoc.exists) {
        throw new Error(`User ${firebaseUID} not found`);
      }

      const userData = userDoc.data();
      const currentPurchasedCredits = userData?.purchasedCredits || 0;
      const newPurchasedCredits = currentPurchasedCredits + creditAmount;

      transaction.update(userRef, {
        purchasedCredits: newPurchasedCredits,
        updatedAt: new Date(),
      });

      console.log(
        `User ${firebaseUID} purchased credits updated: ${currentPurchasedCredits}${newPurchasedCredits}`
      );
    });
  } catch (error) {
    console.error('Failed to add purchased credits:', error);
    throw error;
  }
}

ポイント:

  • Firestoreトランザクション: クレジット加算を原子的に実行
  • purchasedCredits フィールドに加算: 購入クレジットは別管理

4.3 クレジット購入モーダル(フロントエンド)

components/credits/CreditPurchaseModal.tsx
'use client';

import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { getAllCreditPacks } from '@/lib/stripe/config';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';

interface CreditPurchaseModalProps {
  open: boolean;
  onOpenChange: (open: boolean) => void;
}

export function CreditPurchaseModal({ open, onOpenChange }: CreditPurchaseModalProps) {
  const { user, firebaseUser } = useAuth();
  const creditPacks = getAllCreditPacks();

  const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState('');

  const selectedPack = creditPacks.find((pack) => pack.id === selectedPackId);

  const handlePurchase = async () => {
    if (!selectedPack || !firebaseUser) return;

    setIsLoading(true);
    setError('');

    try {
      const idToken = await firebaseUser.getIdToken();

      const response = await fetch('/api/credits/purchase-checkout', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${idToken}`,
        },
        body: JSON.stringify({
          creditAmount: selectedPack.credits,
          successUrl: `${window.location.origin}/dashboard?purchase=success`,
          cancelUrl: `${window.location.origin}/dashboard`,
        }),
      });

      if (!response.ok) {
        const errorData = await response.json();
        throw new Error(errorData.error?.message || 'Checkoutセッションの作成に失敗しました');
      }

      const data = await response.json();

      if (data.url) {
        window.location.href = data.url;
      } else {
        throw new Error('CheckoutURLが取得できませんでした');
      }
    } catch (err: any) {
      setError(err.message || 'クレジット購入の開始に失敗しました');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Dialog open={open} onOpenChange={onOpenChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>クレジット購入</DialogTitle>
        </DialogHeader>

        <div className="space-y-6">
          {/* クレジットパック選択 */}
          <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
            {creditPacks.map((pack) => (
              <Card
                key={pack.id}
                className={`cursor-pointer transition-all ${
                  selectedPackId === pack.id
                    ? 'border-primary ring-2 ring-primary'
                    : 'hover:border-primary/50'
                }`}
                onClick={() => setSelectedPackId(pack.id)}
              >
                <CardHeader>
                  <CardTitle className="text-2xl">{pack.credits}</CardTitle>
                  <p className="text-sm text-muted-foreground">クレジット</p>
                </CardHeader>
                <CardContent>
                  <div className="text-2xl font-bold">¥{pack.price.toLocaleString()}</div>
                  <div className="text-xs text-muted-foreground">
                    ¥{pack.pricePerCredit}/クレジット
                  </div>
                </CardContent>
              </Card>
            ))}
          </div>

          {/* エラー表示 */}
          {error && (
            <div className="text-sm text-destructive">{error}</div>
          )}

          {/* 注意事項 */}
          <div className="text-sm text-muted-foreground">
            <ul className="list-inside list-disc space-y-1">
              <li>購入したクレジットは無期限で有効です</li>
              <li>月間クレジットを優先的に消費します</li>
            </ul>
          </div>

          {/* アクション */}
          <div className="flex justify-end gap-3">
            <Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
              キャンセル
            </Button>
            <Button onClick={handlePurchase} disabled={!selectedPack || isLoading}>
              {isLoading
                ? '処理中...'
                : selectedPack
                ? `¥${selectedPack.price.toLocaleString()}で購入`
                : 'クレジットパックを選択'}
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}

5. 顧客ポータル(サブスク管理)

ユーザーがサブスクリプションを管理できるCustomer Portalを実装します。

5.1 Customer Portal Sessionの作成

app/api/stripe/create-portal-session/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe } from '@/lib/stripe/server';

/**
 * POST /api/stripe/create-portal-session
 * Stripe Customer Portalセッションを作成
 */
export async function POST(request: NextRequest) {
  try {
    // 1. 認証確認
    const authHeader = request.headers.get('authorization');
    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      return NextResponse.json(
        { error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
        { status: 401 }
      );
    }

    const idToken = authHeader.split('Bearer ')[1];
    const decodedToken = await adminAuth.verifyIdToken(idToken);
    const userId = decodedToken.uid;

    // 2. リクエストボディ取得
    const body = await request.json();
    const { returnUrl } = body;

    if (!returnUrl) {
      return NextResponse.json(
        {
          error: {
            code: 'VALIDATION_ERROR',
            message: 'リターンURLが指定されていません',
          },
        },
        { status: 400 }
      );
    }

    // 3. ユーザー情報取得
    const userDoc = await adminDb.collection('users').doc(userId).get();
    if (!userDoc.exists) {
      return NextResponse.json(
        { error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
        { status: 404 }
      );
    }

    const userData = userDoc.data();
    const stripeCustomerId = userData?.stripeCustomerId;

    if (!stripeCustomerId) {
      return NextResponse.json(
        {
          error: {
            code: 'NOT_FOUND',
            message: 'Stripe Customerが見つかりません',
          },
        },
        { status: 404 }
      );
    }

    // 4. Customer Portal Sessionを作成
    const session = await stripe.billingPortal.sessions.create({
      customer: stripeCustomerId,
      return_url: returnUrl,
    });

    return NextResponse.json({
      url: session.url, // Customer PortalのURL
    });
  } catch (error: any) {
    console.error('Create portal session error:', error);
    return NextResponse.json(
      {
        error: {
          code: 'INTERNAL_ERROR',
          message: error.message || 'Portalセッションの作成に失敗しました',
        },
      },
      { status: 500 }
    );
  }
}

5.2 フロントエンドからの呼び出し

components/ManageSubscriptionButton.tsx
'use client';

import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';

export function ManageSubscriptionButton() {
  const { firebaseUser } = useAuth();
  const [isLoading, setIsLoading] = useState(false);

  const handleManageSubscription = async () => {
    if (!firebaseUser) return;

    setIsLoading(true);

    try {
      const idToken = await firebaseUser.getIdToken();

      const response = await fetch('/api/stripe/create-portal-session', {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json',
          Authorization: `Bearer ${idToken}`,
        },
        body: JSON.stringify({
          returnUrl: `${window.location.origin}/dashboard`,
        }),
      });

      if (!response.ok) {
        throw new Error('Customer Portalの作成に失敗しました');
      }

      const data = await response.json();

      if (data.url) {
        // Customer Portalに遷移
        window.location.href = data.url;
      }
    } catch (error) {
      console.error('Manage subscription error:', error);
      alert('サブスクリプション管理ページの表示に失敗しました');
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <Button onClick={handleManageSubscription} disabled={isLoading}>
      {isLoading ? '処理中...' : 'サブスクリプションを管理'}
    </Button>
  );
}

Customer Portalで可能な操作:

  • サブスクリプションのキャンセル
  • 支払い方法の変更
  • 請求書の確認・ダウンロード
  • プラン変更(Stripeダッシュボードで設定可能)

6. エラーハンドリング・セキュリティ

6.1 Webhook署名検証

Webhookの署名検証は必須です。検証しないと、第三者が偽のイベントを送信できてしまいます。

// Webhookイベントを検証
event = stripe.webhooks.constructEvent(
  body,
  signature,
  STRIPE_WEBHOOK_SECRET
);

6.2 認証の徹底

すべてのAPIエンドポイントでFirebase ID Tokenによる認証を行います。

const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
  return NextResponse.json(
    { error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
    { status: 401 }
  );
}

const idToken = authHeader.split('Bearer ')[1];
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;

6.3 環境変数の管理

.env.local.gitignore に追加し、Gitにコミットしないようにします。

.env*.local
.env

本番環境では、Vercelの環境変数設定画面で設定します。

6.4 エラーログの記録

本番環境では、Sentryなどのエラートラッキングツールを使うことを推奨します。

try {
  // 処理
} catch (error: any) {
  console.error('Error:', error);
  // Sentryに送信
  // Sentry.captureException(error);

  return NextResponse.json(
    {
      error: {
        code: 'INTERNAL_ERROR',
        message: error.message || '処理に失敗しました',
      },
    },
    { status: 500 }
  );
}

7. テスト

7.1 Stripe Checkoutのテスト

Stripe CLIでテストカードを使用してCheckoutをテストできます。

テストカード番号:

  • 成功: 4242 4242 4242 4242
  • 失敗: 4000 0000 0000 0002
  • 3Dセキュア: 4000 0025 0000 3155

7.2 Webhookのテスト

Stripe CLIでWebhookイベントをトリガーしてテストできます。

stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed

まとめ

この記事では、Delivrouteで実装したStripeサブスクリプション機能を詳しく解説しました。

重要なポイント

  1. Stripe Customer IDとFirebase UIDの紐付け: metadata を活用してWebhookで識別
  2. Webhookの署名検証: セキュリティのために必須
  3. Firestoreトランザクション: クレジット加算などの原子性が重要な処理で使用
  4. Customer Portal: サブスク管理をStripeにお任せ
  5. テスト環境の構築: Stripe CLIでローカルテストを実施

さらに学びたい方へ

Delivrouteについて

Delivrouteは、配送ルート最適化を簡単に実現できるSaaSです。Google Maps Routes APIを活用し、最大10地点のルートを自動で最適化します。

この記事が、SaaS開発でStripeサブスクリプションを実装する際の参考になれば幸いです!

Discussion