0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Next.js App Router + Stripe で都度課金+月額サブスクを実装する(Webhook冪等性対応)

0
Posted at

はじめに

個人開発のSaaSでStripe決済を導入しました。都度購入(クレジットパック)と月額サブスクリプションの2種類を同一アプリで提供する構成です。

Next.js 16 App Router(Route Handlers)での実装パターンを、Webhook の冪等性対応を含めて解説します。

料金体系の設計

タイプ プラン Stripe mode
都度購入 10件 ¥980 / 50件 ¥3,980 / 100件 ¥6,980 payment
月額 30件/月 ¥1,980 / 80件/月 ¥3,980 subscription

都度購入はクレジット(利用回数)を即時付与、月額は毎月自動でクレジットを補充する仕組みです。

1. プラン定義

// lib/stripe/plans.ts

export interface StripePlan {
    id: string;
    name: string;
    credits: number;
    price: number;
    priceId: string;
    type: "one_time" | "subscription";
}

export const PLANS: StripePlan[] = [
    {
        id: "standard",
        name: "スタンダード(10件)",
        credits: 10,
        price: 980,
        priceId: process.env.STRIPE_PRICE_STANDARD!,
        type: "one_time",
    },
    {
        id: "pro",
        name: "プロ(50件)",
        credits: 50,
        price: 3980,
        priceId: process.env.STRIPE_PRICE_PRO!,
        type: "one_time",
    },
    // ... 他のプランも同様
];

export function getPlanByPriceId(priceId: string): StripePlan | undefined {
    return PLANS.find(p => p.priceId === priceId);
}

ポイント: Price IDは環境変数で管理。テスト環境と本番環境で異なるため、ハードコードしない。

2. Checkout Session 作成

// app/api/checkout/route.ts

import Stripe from "stripe";
import { auth } from "@clerk/nextjs/server";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(request: Request) {
    const { userId } = await auth();
    if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

    const { planId } = await request.json();
    const plan = PLANS.find(p => p.id === planId);
    if (!plan) return NextResponse.json({ error: "Invalid plan" }, { status: 400 });

    // DBからユーザー取得、Stripeカスタマー作成/取得
    const user = await getOrCreateUser();
    let stripeCustomerId = user.stripeCustomerId;

    if (!stripeCustomerId) {
        const customer = await stripe.customers.create({
            email: user.email || undefined,
            metadata: { userId: user.id },
        });
        stripeCustomerId = customer.id;
        await prisma.user.update({
            where: { id: user.id },
            data: { stripeCustomerId },
        });
    }

    const session = await stripe.checkout.sessions.create({
        customer: stripeCustomerId,
        line_items: [{ price: plan.priceId, quantity: 1 }],
        mode: plan.type === "one_time" ? "payment" : "subscription",
        success_url: `${baseUrl}/billing/success?session_id={CHECKOUT_SESSION_ID}`,
        cancel_url: `${baseUrl}/billing/cancel`,
        metadata: {
            userId: user.id,
            planId: plan.id,
            credits: String(plan.credits),
        },
    });

    return NextResponse.json({ url: session.url });
}

ポイント

  • modeの分岐: 都度購入は payment、月額は subscription。同一エンドポイントで処理
  • metadata: Webhook受信時にプラン情報を参照するため、Session に埋め込む
  • Stripeカスタマー自動作成: 初回購入時にカスタマーを作成し、DBに紐付け

3. Webhook 受信(最重要)

Stripeの決済結果は Webhook で受信します。Checkout のリダイレクトだけでは不十分です(ユーザーがブラウザを閉じた場合など)。

// app/api/webhooks/stripe/route.ts

import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

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

    let event: Stripe.Event;
    try {
        event = stripe.webhooks.constructEvent(
            body,
            signature,
            process.env.STRIPE_WEBHOOK_SECRET!
        );
    } catch {
        return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
    }

    switch (event.type) {
        case "checkout.session.completed":
            await handleCheckoutCompleted(event.data.object as Stripe.Checkout.Session);
            break;
        case "invoice.paid":
            await handleInvoicePaid(event.data.object as Stripe.Invoice);
            break;
    }

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

冪等性対応(二重付与防止)

Stripeは同じイベントを複数回送信することがあります。 これを考慮しないと、クレジットが二重付与されます。

async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
    const { userId, planId, credits } = session.metadata!;
    const creditAmount = parseInt(credits, 10);

    // ★ 冪等性チェック: 同じSessionIDで既に処理済みなら何もしない
    const existing = await prisma.creditTransaction.findFirst({
        where: { stripeSessionId: session.id },
    });
    if (existing) {
        console.log(`Already processed session: ${session.id}`);
        return;
    }

    // クレジット付与(トランザクション)
    await prisma.$transaction([
        prisma.user.update({
            where: { id: userId },
            data: { creditBalance: { increment: creditAmount } },
        }),
        prisma.creditTransaction.create({
            data: {
                userId,
                amount: creditAmount,
                type: "purchase",
                stripeSessionId: session.id,
                description: `${planId} プラン購入`,
            },
        }),
    ]);
}

月額サブスクの継続課金

invoice.paid イベントで毎月のクレジット補充を処理:

async function handleInvoicePaid(invoice: Stripe.Invoice) {
    // 初回課金はcheckout.session.completedで処理済み
    if (invoice.billing_reason === "subscription_create") return;

    const customerId = invoice.customer as string;
    const user = await prisma.user.findFirst({
        where: { stripeCustomerId: customerId },
    });
    if (!user) return;

    // Price IDからプランを特定
    const lineItem = invoice.lines.data[0];
    const plan = getPlanByPriceId(lineItem.price?.id || "");
    if (!plan) return;

    // 冪等性チェック
    const existing = await prisma.creditTransaction.findFirst({
        where: { stripeSessionId: invoice.id },
    });
    if (existing) return;

    await prisma.$transaction([
        prisma.user.update({
            where: { id: user.id },
            data: { creditBalance: { increment: plan.credits } },
        }),
        prisma.creditTransaction.create({
            data: {
                userId: user.id,
                amount: plan.credits,
                type: "subscription_renewal",
                stripeSessionId: invoice.id,
            },
        }),
    ]);
}

4. Middleware で Webhook を認証除外

Webhook エンドポイントはStripeから直接呼ばれるため、認証チェックから除外する必要があります:

// middleware.ts
const publicRoutes = [
    "/",
    "/api/webhooks(.*)",  // ★ Webhookは認証不要
    "/sign-in(.*)",
    "/sign-up(.*)",
];

これを忘れると、Webhook が 401 で失敗し続けて決済が反映されないという深刻なバグになります。

5. カスタマーポータル

サブスクリプションの解約・プラン変更は、Stripeのカスタマーポータルに任せます:

// app/api/billing/portal/route.ts

export async function POST() {
    const user = await getOrCreateUser();
    if (!user.stripeCustomerId) {
        return NextResponse.json({ error: "No Stripe customer" }, { status: 400 });
    }

    const session = await stripe.billingPortal.sessions.create({
        customer: user.stripeCustomerId,
        return_url: `${baseUrl}/billing`,
    });

    return NextResponse.json({ url: session.url });
}

Stripe ダッシュボードでカスタマーポータルを有効化する必要があります(Settings → Billing → Customer portal)。

6. テスト環境と本番環境の切り替え

環境変数 テスト 本番
STRIPE_SECRET_KEY sk_test_... sk_live_...
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY pk_test_... pk_live_...
STRIPE_WEBHOOK_SECRET whsec_...(CLI用) whsec_...(ダッシュボード用)
STRIPE_PRICE_* テスト用Price ID 本番用Price ID

Webhook Secret が環境ごとに異なることに注意。ローカル開発では stripe listen --forward-to で取得したSecret、本番では Stripe ダッシュボードで Webhook Endpoint を作成した際のSecretを使います。

実装チェックリスト

  • Checkout Session で metadata にユーザーIDとプラン情報を含める
  • Webhook で stripe.webhooks.constructEvent で署名検証
  • stripeSessionId による冪等性チェック(二重付与防止)
  • invoice.paidbilling_reason === "subscription_create" は除外
  • Middleware で /api/webhooks(.*) を認証除外
  • カスタマーポータルを有効化
  • 本番環境用の Webhook Endpoint をダッシュボードに登録
  • テストカード(4242 4242 4242 4242)で動作確認

まとめ

  • 都度購入 + 月額サブスクを同一のCheckoutフローで処理できる
  • Webhookの冪等性は必須。Stripeは同じイベントを再送することがある
  • Middleware の認証除外を忘れると決済が反映されない
  • 環境変数でテスト/本番を切り替え、Price IDもハードコードしない

この実装パターンは、クレジット課金型のSaaS全般に応用できます。


この実装を使ったサービス: トルカ 振込アシスト — 請求書から全銀フォーマットの振込データをAIで自動生成。都度購入・月額プランに対応。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?