💳

Next.js + PostgreSQL + Stripe でサブスクリプション決済を実装する

に公開

はじめに

これまでに以下の記事でStripeの概要と単発決済について検証してきました。

https://zenn.dev/stellarcreate/articles/stripe-payment-personal-development-survey

https://zenn.dev/stellarcreate/articles/nextjs-postgresql-stripe-onetime-payment

今回は、Stripeを使ったサブスクリプション決済の実装について解説します。単発決済とは違い、継続課金の管理やWebhookでの状態管理が重要になってきます。実際にコードを書いて動作確認までやってみたので、その過程で気づいたポイントも含めて紹介していきます。

流れはほとんど単発決済とは変わりません!サクサクみていきましょう〜!

今回のリポジトリは下記になります。
https://github.com/toto-inu/lab-202511-stripe/tree/subscription

サブスクリプション決済のフロー

サブスクリプション決済は以下の5つのステップで動作します:

  1. プラン選択 - ユーザーが月額・年額プランから選択
  2. チェックアウト作成 - Stripe Checkoutセッションを作成(mode: 'subscription'
  3. 決済処理 - ユーザーがクレジットカード情報を入力して決済
  4. Webhook受信 - 決済完了やサブスクリプション作成のイベントを受信
  5. ステータス更新 - データベースのサブスクリプション状態を更新

特に重要なのが4番目のWebhookです。サブスクリプションでは初回決済だけでなく、継続課金の成功・失敗、プラン変更、キャンセルなど様々なイベントが発生するため、Webhookによる状態管理が必須となります。

データベース設計

サブスクリプション用のモデル

サブスクリプション決済では、主に2つのモデルを使います:

SubscriptionPlan(料金プラン管理)

  • price: 料金
  • interval: 課金間隔(MONTH または YEAR)
  • stripeProductId/stripePriceId: Stripeとの紐付け用ID
  • active: プランの有効/無効状態

Subscription(個別のサブスクリプション契約)

  • status: サブスクリプションの状態(PENDING, ACTIVE, CANCELEDなど)
  • stripeCustomerId/stripeSubscriptionId: Stripeとの紐付け用ID
  • currentPeriodStart/currentPeriodEnd: 現在の課金期間
  • cancelAtPeriodEnd: 期間終了時にキャンセルするかどうか

なお、既存のProductとPaymentモデルは単発決済用なので、今回のサブスクリプション実装では使用しません。

Prismaスキーマ差分

// 今回のサブスクリプション実装で使うのは SubscriptionPlan と Subscription モデル
// Product, Order, Payment モデルは単発決済用

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

model SubscriptionPlan {
  id          String   @id @default(cuid())
  name        String
  description String?
  price       Int      // Price in cents
  currency    String   @default("jpy")
  interval    BillingInterval @default(MONTH)
  stripeProductId String? @unique
  stripePriceId   String? @unique
  active      Boolean  @default(true)
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
  subscriptions Subscription[]

  @@map("subscription_plans")
}

model Subscription {
  id                    String   @id @default(cuid())
  planId                String
  plan                  SubscriptionPlan @relation(fields: [planId], references: [id])
  customerEmail         String
  stripeCustomerId      String?
  stripeSubscriptionId  String?  @unique
  status                SubscriptionStatus @default(PENDING)
  currentPeriodStart    DateTime?
  currentPeriodEnd      DateTime?
  cancelAtPeriodEnd     Boolean  @default(false)
  createdAt             DateTime @default(now())
  updatedAt             DateTime @updatedAt

  @@map("subscriptions")
}

enum BillingInterval {
  MONTH
  YEAR
}

enum SubscriptionStatus {
  PENDING
  ACTIVE
  PAST_DUE
  CANCELED
  UNPAID
  INCOMPLETE
  INCOMPLETE_EXPIRED
  TRIALING
}

実装の流れ

1. データベースのセットアップ

まずはサンプルプランをシードデータで作成します:

// prisma/seed.ts
import { PrismaClient } from '@prisma/client'

const prisma = new PrismaClient()

async function main() {
  // Create subscription plans
  await prisma.subscriptionPlan.createMany({
    data: [
      {
        name: 'Basic Monthly',
        description: 'Perfect for getting started',
        price: 100000, // ¥1,000 in cents
        currency: 'jpy',
        interval: 'MONTH',
      },
      {
        name: 'Pro Monthly',
        description: 'Best for professionals',
        price: 300000, // ¥3,000 in cents
        currency: 'jpy',
        interval: 'MONTH',
      },
      {
        name: 'Basic Yearly',
        description: 'Perfect for getting started (save 20%)',
        price: 960000, // ¥9,600 in cents
        currency: 'jpy',
        interval: 'YEAR',
      },
    ],
  })
}

main()
  .catch((e) => {
    console.error(e)
    process.exit(1)
  })
  .finally(async () => {
    await prisma.$disconnect()
  })

2. サブスクリプションプラン一覧画面

実際の画面はこんな感じになります:

サブスクリプションプラン選択画面

月額・年額プランを分けて表示し、メールアドレス入力フィールドを配置しています。

'use client';

import { useEffect, useState } from 'react';

interface SubscriptionPlan {
  id: string;
  name: string;
  description: string | null;
  price: number;
  currency: string;
  interval: 'MONTH' | 'YEAR';
}

export default function Subscriptions() {
  const [plans, setPlans] = useState<SubscriptionPlan[]>([]);
  const [email, setEmail] = useState('');
  const [checkoutLoading, setCheckoutLoading] = useState<string | null>(null);

  const handleCheckout = async (planId: string) => {
    if (!email || !email.includes('@')) {
      alert('Please enter a valid email address');
      return;
    }

    setCheckoutLoading(planId);

    try {
      const response = await fetch('/api/checkout-subscription', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ planId, customerEmail: email }),
      });

      const data = await response.json();
      if (data.url) {
        window.location.href = data.url;
      }
    } catch (error) {
      console.error('Checkout error:', error);
    } finally {
      setCheckoutLoading(null);
    }
  };

  // Group plans by interval
  const monthlyPlans = plans.filter((plan) => plan.interval === 'MONTH');
  const yearlyPlans = plans.filter((plan) => plan.interval === 'YEAR');

  // 省略:UIレンダリング部分
}

コードのポイント

  • プランを intervalでグループ化して月額・年額別に表示
  • formatPriceformatIntervalで料金と期間を適切に表示
  • メールアドレスの簡易バリデーション

3. プラン取得API

// app/api/subscription-plans/route.ts
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';

export async function GET() {
  try {
    const plans = await prisma.subscriptionPlan.findMany({
      where: { active: true },
      orderBy: { price: 'asc' },
    });

    return NextResponse.json(plans);
  } catch (error) {
    console.error('Failed to fetch subscription plans:', error);
    return NextResponse.json(
      { error: 'Failed to fetch subscription plans' },
      { status: 500 }
    );
  }
}

コードのポイント

  • active: trueで有効なプランのみ取得
  • price: 'asc'で価格の昇順でソート

4. チェックアウトセッション作成API

// app/api/checkout-subscription/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 { planId, customerEmail } = await request.json();

    const plan = await prisma.subscriptionPlan.findUnique({
      where: { id: planId },
    });

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

    // Create subscription record in database with PENDING status
    const subscription = await prisma.subscription.create({
      data: {
        planId: plan.id,
        customerEmail,
        status: 'PENDING',
      },
    });

    const stripeInterval = plan.interval === 'MONTH' ? 'month' : 'year';

    // Create Stripe Checkout Session for subscription
    const session = await stripe.checkout.sessions.create({
      payment_method_types: ['card'],
      line_items: [
        {
          price_data: {
            currency: plan.currency,
            product_data: {
              name: plan.name,
              description: plan.description || undefined,
            },
            unit_amount: plan.price,
            recurring: {
              interval: stripeInterval,
            },
          },
          quantity: 1,
        },
      ],
      mode: 'subscription',
      success_url: `${request.headers.get('origin')}/success?session_id={CHECKOUT_SESSION_ID}`,
      cancel_url: `${request.headers.get('origin')}/cancel`,
      customer_email: customerEmail,
      metadata: {
        subscriptionId: subscription.id,
      },
      subscription_data: {
        metadata: {
          subscriptionId: subscription.id,
        },
      },
    });

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

コードのポイント(重要!)

  • mode: 'subscription': サブスクリプションモードを指定(単発決済との最大の違い)
  • recurring設定: intervalでMONTHまたはYEARを指定して継続課金を有効化
  • metadata: データベースの subscriptionIdを設定し、Webhookで紐付けできるようにする
  • subscription_data.metadata: サブスクリプション自体にもメタデータを設定

5. Stripe Checkoutでの決済

プラン選択後、Stripeのチェックアウト画面に遷移します:

Stripeチェックアウト画面

カード情報を入力する画面では、開発環境用のテストカード(4242 4242 4242 4242)を使用できます:

カード情報入力画面

6. Webhookによる状態管理

サブスクリプション決済では、以下のイベントを処理する必要があります:

  • checkout.session.completed - 初回決済完了
  • customer.subscription.created - サブスクリプション作成
  • customer.subscription.updated - サブスクリプション更新(プラン変更等)
  • customer.subscription.deleted - サブスクリプション削除
  • invoice.payment_succeeded - 継続課金成功
  • invoice.payment_failed - 継続課金失敗

開発環境でのWebhook設定(重要!)

開発環境では、以下のコマンドでWebhookエンドポイントを立ち上げます:

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

コマンド実行時に表示されるsigning secretを .envに設定:

STRIPE_WEBHOOK_SECRET=whsec_xxxxxxxxxxxxxxxxxxxxx

基本的なWebhookハンドラーの構造:

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

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

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

  switch (event.type) {
    case 'checkout.session.completed':
      const session = event.data.object;
      const subscriptionId = session.metadata?.subscriptionId;
    
      if (subscriptionId) {
        await prisma.subscription.update({
          where: { id: subscriptionId },
          data: {
            status: 'ACTIVE',
            stripeCustomerId: session.customer,
            stripeSubscriptionId: session.subscription,
          },
        });
      }
      break;

    // 他のイベント処理...
  }

  return NextResponse.json({ received: true });
}

7. Stripeダッシュボードでの確認

決済が完了すると、Stripeダッシュボードのサブスクリプション一覧で確認できます:

Stripeダッシュボードのサブスクリプション一覧

顧客のメールアドレス、プラン名、ステータス、次回請求日などが表示され、サブスクリプションが正常に作成されていることが確認できます。

データの流れを整理

実際のデータの流れをまとめると:

  1. プランデータ作成: SubscriptionPlanテーブルにシードデータでプランを作成
  2. プラン選択: ユーザーがプラン選択 → APIで Subscriptionレコード作成(status: PENDING
  3. 決済処理: Stripe Checkoutで決済実行
  4. Webhook受信: checkout.session.completedイベントでWebhook呼び出し
  5. 状態更新: Webhookで Subscriptionレコード更新(status: ACTIVEstripeSubscriptionId等を保存)
  6. 継続課金: 次回課金時は invoice.payment_succeededイベントで通知

この流れで、データベースとStripe間の状態が適切に同期されます。

まとめ

今回はStripeを使ったサブスクリプション決済を実装しました。単発決済と比較して重要な違いは:

  • mode: 'subscription'recurring設定: 継続課金の設定
  • Webhookの必須実装: 状態管理のためのイベント処理
  • メタデータの活用: データベースとStripeの紐付け

特にWebhookの設定は忘れがちですが、サブスクリプションでは必須です。開発時は必ず stripe listenコマンドを実行し、signing secretを正しく設定してください。

次回は、Webhookハンドラーの詳細実装やサブスクリプションの管理機能(キャンセル、プラン変更など)について詳しく解説していく予定です。

株式会社StellarCreate | Tech blog📚

Discussion