💳

Next.js × PostgreSQLでStripe単発決済を実装してみた!

に公開

本記事のサマリ

前回のStripe調査記事を受けて、今度は実際にNext.jsとPostgreSQLを使って単発決済システムを構築しました!Stripe CLIを活用したローカル開発環境の構築から、テストカードでの動作確認まで、実装の過程で詰まったポイントと解決策を含めて記録してます。

今回の実装は下記のリポジトリにありますので、ご参照ください。

https://github.com/toto-inu/lab-202511-stripe

なぜ単発決済から始めたのか

前回の調査記事では、サブスクリプション決済についても調べましたが、実装の第一歩としては単発決済の方がシンプルで理解しやすいと判断しました。サブスクリプションは顧客管理や継続的な状態同期など、考慮すべき要素が多いですが、単発決済であればPayment Intentの作成から完了まで、一連の流れを追いやすいんですよね。

また、個人開発のプロダクトでも、最初は買い切り型のプランから始めて、後からサブスクリプションを追加するというアプローチも考えられます。基本的な決済フローを理解してから、より複雑な仕組みに進む方が確実だと考えたので今回の記事では「単発決済」に絞っています🎯

実装の全体構成

今回構築したシステムの構成は以下のような感じです:

  • フロントエンド: Next.jsでStripe Checkout Sessionを使った決済UI
  • バックエンド: Next.js API RoutesでCheckout Session作成とデータベース操作
  • データベース: PostgreSQL + Prismaで商品、注文、決済情報の管理
  • Webhook: Stripe CLIを使ってローカル環境で受信

技術スタックをNext.jsで統一したのは、フロントエンドとバックエンドを同一プロジェクトで管理でき、開発効率が良いからです。API Routesを使えば、別途Express.jsサーバーを立てる必要もありませんね。

なお、全体のフローとしては下記のイメージです。
詳細は後述します。

単発決済のフロー

データベース設計のポイント

単発決済とサブスクリプションでは、データベース設計の考え方が少し異なります。今回は単発決済なので、ordersテーブルとpaymentsテーブルの両方を用意しました。

サブスクリプション課金の場合、Stripe側が「請求と請求履歴」を自動で管理してくれます。InvoiceやPaymentIntentが自動生成されるため、アプリ側は「現在アクティブかどうか」さえ分かれば良く、subscriptionsテーブルだけで十分なんですね。

一方、単発決済では個別の注文と決済を明確に追跡する必要があるため、以下のようなテーブル設計にしました:

model Product {
  id              String   @id @default(cuid())
  name            String
  description     String?
  price           Int      // 価格(円単位)
  currency        String   @default("jpy")
  stripeProductId String?  @unique
  stripePriceId   String?  @unique
  active          Boolean  @default(true)
  createdAt       DateTime @default(now())
  updatedAt       DateTime @updatedAt
  orders          Order[]
}

model Order {
  id              String      @id @default(cuid())
  productId       String
  product         Product     @relation(fields: [productId], references: [id])
  customerEmail   String
  amount          Int         // 金額(円単位)
  currency        String      @default("jpy")
  status          OrderStatus @default(PENDING)
  stripeSessionId String?     @unique
  payment         Payment?
  createdAt       DateTime    @default(now())
  updatedAt       DateTime    @updatedAt
}

model Payment {
  id                    String        @id @default(cuid())
  orderId               String        @unique
  order                 Order         @relation(fields: [orderId], references: [id])
  stripePaymentIntentId String        @unique
  status                PaymentStatus @default(PENDING)
  amount                Int           // 金額(円単位)
  currency              String        @default("jpy")
  paidAt                DateTime?
  createdAt             DateTime      @default(now())
  updatedAt             DateTime      @updatedAt
}

enum OrderStatus {
  PENDING
  COMPLETED
  CANCELLED
}

enum PaymentStatus {
  PENDING
  SUCCEEDED
  FAILED
  CANCELLED
}

ポイントは、OrderとPaymentを分けて管理していることです。Orderは「注文そのもの」を表し、Paymentは「実際の決済処理」を表します。これにより、決済が失敗した場合でも注文情報は残り、ユーザーが再度決済を試みることができます。

フロントエンドの実装

まずは商品選択画面を実装しました。今回はテスト用に3つのプランを用意しています:

商品選択画面

  • Enterprise Plan: ¥5,000(大規模組織向け)
  • Pro Plan: ¥3,000(プロフェッショナル向け)
  • Basic Plan: ¥1,000(入門者向け)

ユーザーはメールアドレスを入力し、希望するプランの「Buy Now」ボタンをクリックすると、Checkout Sessionが作成されてStripeの決済画面にリダイレクトされます。

※ 💡Checkout Sessionとは...アプリ側でカード情報を扱わずに、Stripeの用意した画面に飛ばしてそちらで入力&アプリに戻った時に必要な情報だけ取得できる仕組みのこと。

フロントエンドのコードはシンプルで、APIを呼び出してCheckout Session URLを取得し、リダイレクトするだけです:

const handlePurchase = async (productId: string) => {
  const response = await fetch('/api/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      productId,
      customerEmail: email,
    }),
  });

  const { url } = await response.json();
  window.location.href = url; // Stripeの決済画面にリダイレクト
};

Checkout Session方式を選んだ理由

今回はStripe Checkout Sessionを使った実装にしました。Payment IntentとElementsを使ったカスタムフォームも検討したのですが、Checkout Sessionの方が以下の点で優れていると判断したんですね:

  • Stripe側でホストされた決済画面を使うため、PCI DSS対応が不要
  • Stripe Linkやデジタルウォレット(Apple Pay、Google Pay)に標準対応
  • 決済フォームのUIをStripeが最適化してくれる
  • セキュリティ面での負担が大幅に軽減される

個人開発では、セキュリティとUIの品質を保ちながら開発コストを抑えることが重要なので、現状の考えではこの選択がベターだなと考えています。

バックエンド(API Routes)の実装

Next.js API Routesでは、Checkout Sessionの作成とデータベースへの注文情報保存を行います。

// app/api/checkout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stripe } from '@/lib/stripe';
import { prisma } from '@/lib/prisma';

export async function POST(request: NextRequest) {
  try {
    const { productId, customerEmail } = await request.json();

    if (!productId || !customerEmail) {
      return NextResponse.json(
        { error: 'Product ID and customer email are required' },
        { status: 400 }
      );
    }

    // データベースから商品情報を取得
    const product = await prisma.product.findUnique({
      where: { id: productId },
    });

    if (!product || !product.active) {
      return NextResponse.json(
        { error: 'Product not found or inactive' },
        { status: 404 }
      );
    }

    // 注文をデータベースに作成(PENDING状態)
    const order = await prisma.order.create({
      data: {
        productId: product.id,
        customerEmail,
        amount: product.price,
        currency: product.currency,
        status: 'PENDING',
      },
    });

    // Stripe Checkout Sessionを作成
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: product.currency,
            product_data: {
              name: product.name,
              description: product.description || undefined,
            },
            unit_amount: product.price,
          },
          quantity: 1,
        },
      ],
      mode: 'payment',
      success_url: `${request.headers.get('origin')}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${request.headers.get('origin')}/cancel`,
      customer_email: customerEmail,
      metadata: {
        orderId: order.id,
      },
    });

    // 注文にSession IDを紐付け
    await prisma.order.update({
      where: { id: order.id },
      data: { stripeSessionId: session.id },
    });

    return NextResponse.json({ sessionId: session.id, url: session.url });
  } catch (error) {
    console.error('Checkout error:', error);
    return NextResponse.json(
      { error: 'Failed to create checkout session' },
      { status: 500 }
    );
  }
}

実装のポイントは、Checkout Sessionのmetadataにorder IDを含めていることです。これにより、webhookで決済完了通知を受け取った際に、どの注文に対する決済なのかを特定できます。

この時点では、データベースの注文ステータスは「PENDING」のままです。実際の決済完了はwebhookで受信するまで確定しません。これが重要なポイントで、ユーザーが決済画面を開いただけでは完了せず、実際に支払いが成功してStripeから通知が来るまでは pending 状態を維持します。

Stripe Webhookのローカル開発環境構築

webhookの実装で最初に困ったのが、ローカル環境での検証方法でした。Stripeからのwebhook通知は公開URLに送信されるため、通常はlocalhost環境では受信できません。

ここでStripe CLIが大活躍します。まず、Stripe CLIをインストールして認証を行います:

# Stripe CLIインストール後
stripe login

ログイン時に「Please note: this key will expire after 90 days, at which point you'll need to re-authenticate.」と表示されるように、認証キーは90日間有効です。これは開発環境としては十分な期間ですね。

次に、ローカル環境へのwebhook転送を開始します:

stripe listen --forward-to localhost:3000/api/webhook

このコマンドを実行すると、Stripeからのwebhookイベントがローカルの開発サーバーに転送されるようになります。コンソールには転送されるイベントがリアルタイムで表示されるため、デバッグにも便利です。(ここで出るwhsec_...のkeyを.envには設定しましょう)

Stripe CLIのログ出力

実際に決済を行うと、上記のような形で様々なイベントがリアルタイムで流れてきます。charge.succeededpayment_intent.succeededcheckout.session.completedなど、一つの決済に対して複数のイベントが発生することが分かります。この中から必要なイベントだけを処理するようにコードを書くのがポイントですね。

webhook処理のAPI実装は以下のような形になります:

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

export async function POST(request: NextRequest) {
  const body = await request.text();
  const signature = request.headers.get('stripe-signature');

  if (!signature) {
    return NextResponse.json(
      { error: 'Missing stripe-signature header' },
      { status: 400 }
    );
  }

  let event: Stripe.Event;

  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    return NextResponse.json(
      { error: 'Invalid signature' },
      { status: 400 }
    );
  }

  try {
    switch (event.type) {
      case 'checkout.session.completed': {
        const session = event.data.object as Stripe.Checkout.Session;
        const orderId = session.metadata?.orderId;

        if (orderId) {
          // 注文ステータスを更新
          await prisma.order.update({
            where: { id: orderId },
            data: { status: 'COMPLETED' },
          });

          // 決済レコードを作成
          if (session.payment_intent) {
            await prisma.payment.create({
              data: {
                orderId,
                stripePaymentIntentId: session.payment_intent as string,
                status: 'SUCCEEDED',
                amount: session.amount_total || 0,
                currency: session.currency || 'jpy',
                paidAt: new Date(),
              },
            });
          }
        }
        break;
      }

      case 'checkout.session.expired': {
        const session = event.data.object as Stripe.Checkout.Session;
        const orderId = session.metadata?.orderId;

        if (orderId) {
          await prisma.order.update({
            where: { id: orderId },
            data: { status: 'CANCELLED' },
          });
        }
        break;
      }

      case 'payment_intent.payment_failed': {
        const paymentIntent = event.data.object as Stripe.PaymentIntent;

        // Payment Intent IDから決済情報を検索して更新
        const payment = await prisma.payment.findUnique({
          where: { stripePaymentIntentId: paymentIntent.id },
        });

        if (payment) {
          await prisma.payment.update({
            where: { id: payment.id },
            data: { status: 'FAILED' },
          });
        }
        break;
      }

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

    return NextResponse.json({ received: true });
  } catch (error) {
    console.error('Webhook handler error:', error);
    return NextResponse.json(
      { error: 'Webhook handler failed' },
      { status: 500 }
    );
  }
}

重要なのは、webhook署名の検証を必ず行うことです。stripe.webhooks.constructEventで署名を検証することで、実際にStripeから送信されたイベントであることを確認できます。

また、Checkout Session方式では、主に以下のイベントを処理します:

  • checkout.session.completed: 決済が成功した時
  • checkout.session.expired: セッションが期限切れになった時
  • payment_intent.payment_failed: 決済が失敗した時

テストカードでの動作確認

実装が完了したら、様々なシナリオでテストを行います。Stripeが用意しているテストカード番号を使って、成功と失敗の両方のパターンを確認しました。

残高不足エラーのテスト

まず、4000 0000 0000 9995を使って残高不足エラーのテストを行いました。このカード番号を入力すると、「ご利用のカードは、残高不足のため拒否されました。デビットカードでの支払いをお試しください。」というエラーメッセージが表示されます。

残高不足エラーの画面

この際、データベースの注文ステータスは「PENDING」のままです。決済が完了しなければCheckout Sessionも終わらず、webhookも送信されないためにDBが更新されないのは当然ですね。期待通りの動作です。

成功パターンのテスト

次に、4242 4242 4242 4242を使って正常な決済フローを確認しました。このカード番号では決済が正常に完了し、以下の流れで処理が進みます:

  1. フロントエンドで決済確認が完了
  2. Stripeからwebhookが送信される
  3. webhook処理でデータベースのステータスが「SUCCESS」に更新
  4. ユーザーに成功画面が表示

決済成功画面

実際にテストしてみると、webhook処理のタイミングとフロントエンドでの成功判定にわずかな時間差があることがわかりました。フロントエンドでは決済確認が完了した時点で成功画面に遷移させていますが、webhook処理は少し遅れて実行されます。

この時間差は通常問題になりませんが、決済完了直後にデータベースの状態に依存する処理(例:購入商品の即座な提供など)を行う場合は注意が必要です。

実装時に詰まったポイントと解決策

App RouterでのWebhook処理

Next.js 13以降のApp Routerを使う場合、webhookのbody処理が少し異なります。Pages Routerのように bodyParserの設定は不要で、代わりに request.text()でraw bodyを取得する必要があります。

// App Routerの場合
export async function POST(request: NextRequest) {
  const body = await request.text(); // raw bodyを取得
  const signature = request.headers.get('stripe-signature');
  
  const event = stripe.webhooks.constructEvent(
    body,
    signature,
    process.env.STRIPE_WEBHOOK_SECRET!
  );
  // ...
}

最初は request.json()を使おうとしてエラーになりました。webhook署名の検証にはraw bodyが必須なので、必ず text()で取得する必要があります。

環境変数の管理

開発環境と本番環境で異なるStripeキーを使い分ける必要があります。特に、webhook用のシークレットキーは、Stripe CLI使用時とデプロイ環境で異なる値になります。

Stripe CLIを起動すると、コンソールに whsec_で始まるシークレットキーが表示されます。これを .env.localに設定します:

# .env.local
STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxx

本番環境では、Stripeダッシュボードで設定したwebhookのシークレットを使います。これを環境変数として設定することで、開発と本番で自動的に使い分けられます。

Prismaでのトランザクション管理

注文と決済の情報を確実に同期させるため、データベース更新時にはトランザクションを使うべきケースもあります。ただ、今回はwebhook内での処理が比較的シンプルだったため、個別にupdate/createを実行する形にしました。

より複雑な処理(例:在庫の減算や複数テーブルの更新)が必要な場合は、Prismaのトランザクション機能を使うと安全です:

await prisma.$transaction([
  prisma.order.update({ /* ... */ }),
  prisma.payment.create({ /* ... */ }),
  prisma.inventory.update({ /* ... */ }),
]);

metadataを使った注文との紐付け

Checkout Session作成時に metadataに注文IDを含めるのがポイントです。これを忘れると、webhookで受信した際にどの注文に対する決済なのか分からなくなります。

const session = await stripe.checkout.sessions.create({
  // ...
  metadata: {
    orderId: order.id, // これが重要!
  },
});

webhookで受信する際は、この metadataから注文IDを取り出してデータベースを更新します。

Stripe CLIのログが見やすすぎて助かった

実装中、どのイベントがどのタイミングで発火するのか分からず困っていたのですが、Stripe CLIのログがめちゃくちゃ分かりやすくて助かりました。リアルタイムでイベントが流れてくるので、「あ、このタイミングで checkout.session.completedが来るんだ」といった理解がすぐにできました。

ローカル開発でのデバッグ体験が良いと、スピード感が全然違いますね!👍

今後の改善予定

今回は単発決済の基本実装に重点を置きましたが、実際の運用に向けてはいくつか改善したい点があります。

決済履歴の管理画面

現在はデータベースに情報を保存しているだけですが、ユーザーが過去の購入履歴を確認できる画面を実装したいですね。Stripeダッシュボードでも確認できますが、アプリ内で完結できた方がユーザー体験は良くなります。

Webhookの冪等性担保

現在の実装では、同じwebhookイベントが複数回送信された場合の対策が不十分です...
Stripeからは idempotency keyが提供されているので、これを使って重複処理を防ぐ仕組みを追加する必要がありそうです。

// 冪等性を担保する例
const existingPayment = await prisma.payment.findUnique({
  where: { stripePaymentIntentId: paymentIntent.id }
});

if (existingPayment) {
  return; // 既に処理済み
}

サブスクリプション決済への拡張

今回の単発決済実装で基本的な流れは掴めたので、次はサブスクリプション決済に挑戦する予定です。前回の調査記事で学んだ CustomerSubscriptionの概念を実装に落とし込んでいきます。

サブスクリプションでは継続的な状態管理が重要になるので、subscriptionsテーブルを追加し、プラン変更やキャンセルにも対応できる設計にしたいと考えています。

まとめ

Next.jsとPostgreSQLを使ったStripe単発決済の実装は、思っていたよりもスムーズに進めることができました。特に、Stripe CLIを使ったローカル開発環境の構築により、webhook処理も含めた完全なテストができたのは助かりました!

前回の調査記事で理解した概念が、実際の実装でそのまま活用できたことも良かったです。データベース設計やwebhookの重要性など、事前に理解していたポイントが実装時に迷わずに進められた要因だと思います。

次回はサブスクリプション決済の実装に挑戦する予定です。顧客管理や継続的な状態同期など、より複雑な要素が含まれますが、今回の経験を活かして進めていきたいと思います。

個人開発での決済機能実装を検討している方の参考になれば幸いです!一緒に頑張っていきましょう〜!

株式会社StellarCreate | Tech blog📚

Discussion