Next.jsとSupabaseでオンライ学習プラットフォーム（LMS）を構築する | エピソード6: Stripeによる決済システムの統合

こんにちは！前回のエピソードでは、Supabase Storageを使ってビデオレッスンを管理・配信し、React Playerで再生機能を実装しました。今回は、Stripeを統合してコース購入機能を実装します。チェックアウトページを構築し、Next.jsのAPIルートでStripeセッションを処理、購入情報をSupabaseの`purchases`テーブルに保存します。これで、ユーザーがコースを購入してアクセスできるようになります！

## このエピソードのゴール

- Stripe Checkoutを統合して決済処理を実装。
- Next.jsのAPIルートでStripeセッションを作成。
- チェックアウトページをReact Hook Formで構築。
- 購入情報をSupabaseの`purchases`テーブルに保存。

## 必要なもの

- 前回のプロジェクト（`next-lms`）がセットアップ済み。
- Supabaseプロジェクト（`courses`と`lessons`テーブル設定済み）。
- Stripeアカウント（テストモードで十分）。
- `stripe`と`@stripe/stripe-js`パッケージ。
- 基本的なTypeScript、React、Next.jsの知識。

---

## ステップ1: Stripeのセットアップ

Stripeアカウントを設定し、必要なパッケージをインストールします。

1. **Stripeアカウントの作成**：
   - [Stripeダッシュボード](https://dashboard.stripe.com/)でアカウントを作成。
   - テストモードでAPIキー（公開キーとシークレットキー）を取得。
   - 「Products」セクションでコースを登録（手動またはプログラムで同期）。

2. **パッケージのインストール**：
   Stripe関連のパッケージをインストール：

```bash
npm install stripe @stripe/stripe-js
```

3. **環境変数の設定**：
   `.env.local`にStripeのキーを追加：

```env
STRIPE_SECRET_KEY=あなたのStripeシークレットキー
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=あなたのStripe公開キー
```

---

## ステップ2: `purchases`テーブルの設計

購入情報を保存するテーブルを作成します。

1. **Supabaseダッシュボードでテーブル作成**：
   - Supabaseダッシュボードの「Table Editor」→「New Table」で`purchases`テーブルを作成。
   - 以下のカラムを定義：

| カラム名        | タイプ       | 説明                     |
|-----------------|--------------|--------------------------|
| id              | uuid         | 主キー（自動生成）       |
| user_id         | uuid         | 購入者のユーザーID       |
| course_id       | uuid         | 購入したコースID         |
| stripe_session_id | text       | StripeセッションID       |
| amount          | numeric      | 支払い金額               |
| status          | text         | 購入ステータス（例: completed） |
| created_at      | timestamptz  | 作成日時（自動生成）     |

   - 外部キー制約を追加：
     ```sql
     ALTER TABLE purchases
     ADD CONSTRAINT fk_user
     FOREIGN KEY (user_id)
     REFERENCES auth.users (id)
     ON DELETE CASCADE;

     ALTER TABLE purchases
     ADD CONSTRAINT fk_course
     FOREIGN KEY (course_id)
     REFERENCES courses (id)
     ON DELETE CASCADE;
     ```

   - 「Enable Row Level Security (RLS)」を有効化し、ポリシーを設定：
     - **Select Policy**: 自分の購入履歴のみ閲覧（`auth.uid() = user_id`）。
     - **Insert Policy**: 認証済みユーザーのみ（`auth.uid() = user_id`）。

---

## ステップ3: StripeクライアントとAPIルートの作成

Stripeセッションを処理するAPIルートを構築します。

1. **Stripeクライアントの初期化**：
   `src/lib/stripe.ts`を作成：

```ts
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2023-10-16',
});
```

2. **チェックアウトAPIルートの作成**：
   `src/app/api/checkout/route.ts`を作成：

```ts
import { NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

export async function POST(request: Request) {
  const { courseId } = await request.json();

  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return NextResponse.json({ error: '認証が必要です' }, { status: 401 });
  }

  const course = await supabase
    .from('courses')
    .select('id, title, price')
    .eq('id', courseId)
    .single();

  if (!course.data) {
    return NextResponse.json({ error: 'コースが見つかりません' }, { status: 404 });
  }

  try {
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: 'jpy',
            product_data: {
              name: course.data.title,
            },
            unit_amount: course.data.price,
          },
          quantity: 1,
        },
      ],
      mode: 'payment',
      success_url: `${request.headers.get('origin')}/courses/${courseId}?success=true`,
      cancel_url: `${request.headers.get('origin')}/courses/${courseId}?canceled=true`,
      metadata: {
        userId: user.id,
        courseId: course.data.id,
      },
    });

    await supabase
      .from('purchases')
      .insert({
        user_id: user.id,
        course_id: course.data.id,
        stripe_session_id: session.id,
        amount: course.data.price,
        status: 'pending',
      });

    return NextResponse.json({ url: session.url });
  } catch (error) {
    console.error('Stripeエラー:', error);
    return NextResponse.json({ error: '決済セッションの作成に失敗しました' }, { status: 500 });
  }
}
```

このコードは：
- 認証済みユーザーのみを許可。
- コース情報を取得し、Stripe Checkoutセッションを作成。
- 購入情報を`purchases`テーブルに保存（`pending`ステータス）。
- 成功/キャンセル時にCDPにリダイレクト。

---

## ステップ4: チェックアウトページの構築

チェックアウトページをReact Hook Formで構築します。`src/app/checkout/page.tsx`を作成：

```tsx
import { useForm } from 'react-hook-form';
import { useRouter, useSearchParams } from 'next/navigation';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

type CheckoutForm = {
  courseId: string;
};

export default async function CheckoutPage() {
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!user) {
    return (
      <main className="container mx-auto p-4">
        <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
        <p className="text-red-500">購入するにはログインしてください。</p>
        <a href="/login" className="text-primary hover:underline">ログイン</a>
      </main>
    );
  }

  return (
    <main className="container mx-auto p-4 max-w-md">
      <h1 className="text-3xl font-bold mb-6">チェックアウト</h1>
      <CheckoutForm />
    </main>
  );
}

function CheckoutForm() {
  const { register, handleSubmit, formState: { errors } } = useForm<CheckoutForm>();
  const router = useRouter();
  const searchParams = useSearchParams();
  const courseId = searchParams.get('courseId');

  const onSubmit = async (data: CheckoutForm) => {
    const response = await fetch('/api/checkout', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ courseId: data.courseId }),
    });

    const result = await response.json();
    if (result.url) {
      router.push(result.url);
    } else {
      alert(result.error || '決済処理に失敗しました');
    }
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
      <div>
        <label className="block text-sm font-medium">コースID</label>
        <input
          type="text"
          defaultValue={courseId || ''}
          {...register('courseId', { required: 'コースIDは必須です' })}
          className="w-full border p-2 rounded"
        />
        {errors.courseId && <p className="text-red-500 text-sm">{errors.courseId.message}</p>}
      </div>
      <button type="submit" className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 w-full">
        決済に進む
      </button>
    </form>
  );
}
```

このコードは：
- 認証済みユーザーのみを許可。
- React Hook FormでコースIDを入力（クエリパラメータから初期値設定）。
- `/api/checkout`にリクエストを送信し、Stripe Checkoutにリダイレクト。

---

## ステップ5: 購入ステータスの更新

StripeのWebhookを使って購入完了を処理します（簡略化のため、CDPで手動確認）。

1. **CDPの更新**：
   `src/app/courses/[id]/page.tsx`を更新して購入ステータスを確認：

```tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
import { getCourseById, getLessonsByCourseId, getSignedVideoUrl, Course, Lesson } from '@/lib/supabase';
import { Metadata } from 'next';
import ReactPlayer from 'react-player';
import { supabaseServer } from '@/lib/supabase';
import { cookies } from 'next/headers';

export async function generateStaticParams() {
  const courses = await getAllCourseIds();
  return courses.map((course) => ({
    id: course.id,
  }));
}

export async function generateMetadata({ params }: { params: { id: string } }): Promise<Metadata> {
  const course = await getCourseById(params.id);
  if (!course) return {};

  return {
    title: `${course.title} | Next.js LMS`,
    description: course.description,
    openGraph: {
      title: course.title,
      description: course.description,
      images: [{ url: course.image_url || 'https://via.placeholder.com/600x400' }],
    },
    twitter: {
      card: 'summary_large_image',
      title: course.title,
      description: course.description,
      images: [course.image_url || 'https://via.placeholder.com/600x400'],
    },
  };
}

export default async function CourseDetailPage({ params, searchParams }: { params: { id: string }, searchParams: { success?: string, canceled?: string } }) {
  const course = await getCourseById(params.id);
  const lessons = await getLessonsByCourseId(params.id);
  const supabase = supabaseServer();
  const { data: { user } } = await supabase.auth.getUser();

  if (!course) {
    notFound();
  }

  const { data: purchase } = await supabase
    .from('purchases')
    .select('*')
    .eq('user_id', user?.id || '')
    .eq('course_id', course.id)
    .single();

  const isPurchased = purchase && purchase.status === 'completed';

  const lessonsWithSignedUrls = isPurchased
    ? await Promise.all(
        lessons.map(async (lesson) => ({
          ...lesson,
          signedUrl: await getSignedVideoUrl(lesson.video_path),
        }))
      )
    : [];

  if (searchParams.success) {
    await supabase
      .from('purchases')
      .update({ status: 'completed' })
      .eq('course_id', course.id)
      .eq('user_id', user?.id || '');
  }

  return (
    <main className="container mx-auto p-4">
      <div className="bg-white rounded-lg shadow-lg p-6">
        <Image
          src={course.image_url || 'https://via.placeholder.com/600x400'}
          alt={course.title}
          width={600}
          height={400}
          className="w-full h-64 object-cover rounded-lg mb-6"
          priority
        />
        <h1 className="text-3xl font-bold mb-4">{course.title}</h1>
        <p className="text-gray-600 mb-4">{course.description}</p>
        <p className="text-2xl font-semibold text-primary mb-6">{course.price.toLocaleString()} 円</p>
        {isPurchased ? (
          <p className="text-green-500 mb-6">このコースは購入済みです！</p>
        ) : (
          <a
            href={`/checkout?courseId=${course.id}`}
            className="bg-primary text-white px-6 py-3 rounded hover:bg-opacity-90 mb-6 inline-block"
          >
            このコースを購入
          </a>
        )}
        {searchParams.success && <p className="text-green-500 mb-6">購入が完了しました！</p>}
        {searchParams.canceled && <p className="text-red-500 mb-6">購入がキャンセルされました。</p>}
        <h2 className="text-2xl font-semibold mb-4">レッスン一覧</h2>
        {isPurchased ? (
          <div className="space-y-6">
            {lessonsWithSignedUrls.map((lesson) => (
              <div key={lesson.id} className="border rounded-lg p-4">
                <h3 className="text-xl font-semibold">{lesson.title}</h3>
                <p className="text-gray-600 mb-4">{lesson.description}</p>
                <ReactPlayer
                  url={lesson.signedUrl}
                  controls
                  width="100%"
                  height="auto"
                  className="rounded-lg"
                />
              </div>
            ))}
          </div>
        ) : (
          <p className="text-gray-600">コースを購入するとレッスンを視聴できます。</p>
        )}
      </div>
    </main>
  );
}
```

このコードは：
- 購入ステータスを確認し、購入済みの場合のみビデオを表示。
- 決済成功時に`purchases`テーブルのステータスを`completed`に更新。
- 成功/キャンセルメッセージを表示。

---

## ステップ6: 動作確認

1. Supabaseダッシュボードで以下を確認：
   - `purchases`テーブルとRLSポリシーが正しい。
   - Stripeダッシュボードでテストモードが有効。
2. 開発サーバーを起動：

```bash
npm run dev
```

3. `http://localhost:3000/courses/[course-id]`にアクセスし、以下の点を確認：
   - 未購入の場合、「このコースを購入」ボタンが表示され、`/checkout?courseId=[id]`に遷移。
   - チェックアウトページでコースIDを入力し、Stripe Checkoutにリダイレクト。
   - テストカード（例: `4242 4242 4242 4242`）で決済を完了し、成功メッセージが表示。
   - 購入後、レッスンビデオが表示され、再生可能。
   - キャンセルした場合、キャンセルメッセージが表示。
4. Supabaseの`purchases`テーブルで、購入データ（`user_id`, `course_id`, `status`）が正しいことを確認。

エラーがあれば、StripeのAPIキー、SupabaseのRLS、またはAPIルートのログを確認してください。

---

## まとめと次のステップ

このエピソードでは、Stripeを使ってコース購入機能を実装しました。チェックアウトページを構築し、APIルートでStripeセッションを処理、購入情報をSupabaseに保存しました。これで、ユーザーがコースを購入してコンテンツにアクセスできるようになりました！

次回のエピソードでは、Supabase Realtimeを使ってリアルタイムコメント機能を実装します。`comments`テーブルを設計し、ユーザーがレッスンにコメントを投稿・閲覧できるUIを構築しますので、引き続きお楽しみに！

---

この記事が役に立ったと思ったら、ぜひ「いいね」を押して、ストックしていただければ嬉しいです！次回のエピソードもお楽しみに！