はじめに
個人開発の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.paidのbilling_reason === "subscription_create"は除外 -
Middleware で
/api/webhooks(.*)を認証除外 - カスタマーポータルを有効化
- 本番環境用の Webhook Endpoint をダッシュボードに登録
-
テストカード(
4242 4242 4242 4242)で動作確認
まとめ
- 都度購入 + 月額サブスクを同一のCheckoutフローで処理できる
- Webhookの冪等性は必須。Stripeは同じイベントを再送することがある
- Middleware の認証除外を忘れると決済が反映されない
- 環境変数でテスト/本番を切り替え、Price IDもハードコードしない
この実装パターンは、クレジット課金型のSaaS全般に応用できます。
この実装を使ったサービス: トルカ 振込アシスト — 請求書から全銀フォーマットの振込データをAIで自動生成。都度購入・月額プランに対応。