Next.js + Stripe で実装するサブスクリプション機能 - SaaS開発の実践ガイド
はじめに
SaaSアプリケーションを開発する上で、サブスクリプション機能は欠かせない要素です。しかし、決済処理やWebhookの実装、クレジット管理など、考慮すべき点が多く、初めて実装する際は戸惑うことも多いでしょう。
この記事では、配送ルート最適化SaaS「Delivroute」で実装したStripeサブスクリプション機能を例に、実際のコードを交えながら解説します。
この記事で学べること
- Stripeの基本設定とプラン管理
- Next.js App RouterでのCheckout Session作成
- Webhookによる決済イベントのハンドリング
- 単発決済(クレジット購入)の実装
- Customer Portalによるサブスク管理
- FirestoreとStripeの連携パターン
- エラーハンドリングとセキュリティ対策
技術スタック
- フロントエンド: Next.js 15 (App Router), TypeScript
- バックエンド: Next.js API Routes (Route Handlers)
- 決済: Stripe API
- データベース: Cloud Firestore
- 認証: Firebase Authentication
実装する機能
- サブスクリプション機能: 無料プラン・Proプラン(月額¥1,980)
- クレジット購入: 単発決済で追加クレジット購入(例: 10クレジット ¥600)
- 顧客ポータル: サブスクの管理・キャンセル
1. Stripeの基本設定
1.1 環境変数の設定
まず、Stripe APIキーを環境変数に設定します。
# Stripe API Keys
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=pk_test_xxxxx
STRIPE_SECRET_KEY=sk_test_xxxxx
STRIPE_WEBHOOK_SECRET=whsec_xxxxx
# Stripe Price IDs (Stripeダッシュボードで作成したPrice IDを設定)
NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY=price_xxxxx
NEXT_PUBLIC_STRIPE_PRICE_ID_CREDITS_10=price_xxxxx
# 他のクレジットパック (30/50/100) も同様に設定
1.2 プラン定義
lib/stripe/config.ts でプラン情報を定義します。
import { PlanInfo } from '@/types/subscription';
/**
* プラン情報の定義
*/
export const PLANS: Record<string, PlanInfo> = {
free: {
id: 'free',
name: '無料プラン',
price: 0,
currency: 'jpy',
interval: 'month',
features: [
'月3クレジット',
'簡易最適化のみ(1クレジット/回)',
'最大5地点までのルート',
'20件までのルート保存',
],
limits: {
maxWaypoints: 5, // 最大地点数
maxCalculationsPerMonth: 3, // 月間最適化回数
maxSavedRoutes: 20, // 保存可能なルート数
},
},
pro: {
id: 'pro',
name: 'Proプラン',
price: 1980,
currency: 'jpy',
interval: 'month',
features: [
'月60クレジット',
'簡易最適化: 1クレジット/回',
'厳密最適化: 2-20クレジット/回(地点数依存)',
'最大10地点までのルート',
'100件までのルート保存',
],
limits: {
maxWaypoints: 10,
maxCalculationsPerMonth: null, // unlimited (クレジット制)
maxSavedRoutes: 100,
},
},
};
/**
* クレジットパック定義
*/
export const CREDIT_PACKS: Record<string, CreditPackInfo> = {
CREDITS_10: {
id: 'credits_10',
credits: 10,
price: 600,
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_CREDITS_10 || '',
pricePerCredit: 60, // 1クレジットあたりの価格
},
// 他のパック (30/50/100クレジット) も同様に定義
};
型定義 (types/subscription.ts)
export interface PlanInfo {
id: string;
name: string;
price: number;
currency: string;
interval: 'month' | 'year';
features: string[];
limits: {
maxWaypoints: number;
maxCalculationsPerMonth: number | null;
maxSavedRoutes: number;
};
}
1.3 Stripe Client初期化
lib/stripe/server.ts でStripe Clientを初期化します。
import Stripe from 'stripe';
/**
* Stripe Client (サーバーサイドのみ)
*/
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
apiVersion: '2024-11-20.acacia', // 最新のAPIバージョンを指定
typescript: true,
});
export const STRIPE_WEBHOOK_SECRET = process.env.STRIPE_WEBHOOK_SECRET || '';
export const STRIPE_PRICE_ID_PRO_MONTHLY =
process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY || '';
2. サブスクリプション機能の実装
2.1 Checkout Sessionの作成
ユーザーがProプランにアップグレードする際、Stripe Checkoutページに遷移させる必要があります。
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe, STRIPE_PRICE_ID_PRO_MONTHLY } from '@/lib/stripe/server';
/**
* POST /api/stripe/create-checkout-session
* Stripe Checkoutセッションを作成
*/
export async function POST(request: NextRequest) {
try {
// 1. 認証確認
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
{ status: 401 }
);
}
const idToken = authHeader.split('Bearer ')[1];
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;
// 2. リクエストボディ取得
const body = await request.json();
const { priceId, successUrl, cancelUrl } = body;
// 3. バリデーション
if (!priceId || !successUrl || !cancelUrl) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: '必須パラメータが不足しています',
},
},
{ status: 400 }
);
}
// 4. ユーザー情報取得
const userDoc = await adminDb.collection('users').doc(userId).get();
if (!userDoc.exists) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
{ status: 404 }
);
}
const userData = userDoc.data();
let stripeCustomerId = userData?.stripeCustomerId;
// 5. Stripe Customerが存在しない場合は作成
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: decodedToken.email,
metadata: {
firebaseUID: userId, // FirebaseとStripeを紐付けるために重要
},
});
stripeCustomerId = customer.id;
// FirestoreにStripe Customer IDを保存
await adminDb.collection('users').doc(userId).update({
stripeCustomerId: stripeCustomerId,
updatedAt: new Date(),
});
}
// 6. Checkout Sessionを作成
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: 'subscription',
payment_method_types: ['card'],
line_items: [
{
price: priceId, // Stripe Price ID
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
allow_promotion_codes: true, // プロモーションコードを有効化
billing_address_collection: 'auto',
locale: 'ja', // 日本語表示
metadata: {
firebaseUID: userId, // Webhookで使用
},
});
return NextResponse.json({
sessionId: session.id,
url: session.url, // CheckoutページのURL
});
} catch (error: any) {
console.error('Create checkout session error:', error);
return NextResponse.json(
{
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Checkoutセッションの作成に失敗しました',
},
},
{ status: 500 }
);
}
}
重要なポイント:
- Firebase UIDをmetadataに保存: Webhookで誰の決済かを識別するために必須
- Stripe Customer IDをFirestoreに保存: 次回以降のCheckout Sessionで再利用
- 認証確認: Firebase ID Tokenで認証を行う
2.2 フロントエンドからの呼び出し
フロントエンドからCheckout APIを呼び出す例です。
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
export function PricingCard() {
const { firebaseUser } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const handleUpgrade = async () => {
if (!firebaseUser) return;
setIsLoading(true);
try {
// Firebase ID Tokenを取得
const idToken = await firebaseUser.getIdToken();
// Checkout Sessionを作成
const response = await fetch('/api/stripe/create-checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({
priceId: process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY,
successUrl: `${window.location.origin}/dashboard?upgrade=success`,
cancelUrl: `${window.location.origin}/pricing`,
}),
});
if (!response.ok) {
throw new Error('Checkout Sessionの作成に失敗しました');
}
const data = await response.json();
// Stripe Checkoutページに遷移
if (data.url) {
window.location.href = data.url;
}
} catch (error) {
console.error('Upgrade error:', error);
alert('アップグレードに失敗しました');
} finally {
setIsLoading(false);
}
};
return (
<button onClick={handleUpgrade} disabled={isLoading}>
{isLoading ? '処理中...' : 'Proプランにアップグレード'}
</button>
);
}
3. Webhookの実装
Stripeからの決済イベントをWebhookで受け取り、Firestoreを更新します。
3.1 Webhook Handlerの実装
import { NextRequest, NextResponse } from 'next/server';
import { adminDb } from '@/lib/firebase/admin';
import { stripe, STRIPE_WEBHOOK_SECRET } from '@/lib/stripe/server';
import Stripe from 'stripe';
/**
* POST /api/stripe/webhook
* StripeからのWebhookイベントを処理
*/
export async function POST(request: NextRequest) {
try {
const body = await request.text();
const signature = request.headers.get('stripe-signature');
if (!signature) {
return NextResponse.json(
{ error: { code: 'BAD_REQUEST', message: 'Webhook署名がありません' } },
{ status: 400 }
);
}
// Webhookイベントを検証
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_WEBHOOK_SECRET
);
} catch (err: any) {
console.error('Webhook signature verification failed:', err.message);
return NextResponse.json(
{
error: {
code: 'BAD_REQUEST',
message: `Webhook署名の検証に失敗しました: ${err.message}`,
},
},
{ status: 400 }
);
}
console.log('Received Stripe webhook event:', event.type);
// イベントタイプに応じて処理
switch (event.type) {
case 'checkout.session.completed':
await handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
await handleSubscriptionUpdate(event.data.object as Stripe.Subscription);
break;
case 'customer.subscription.deleted':
await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
break;
case 'invoice.payment_succeeded':
await handleInvoicePaymentSucceeded(event.data.object as Stripe.Invoice);
break;
case 'invoice.payment_failed':
await handleInvoicePaymentFailed(event.data.object as Stripe.Invoice);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
return NextResponse.json({ received: true });
} catch (error: any) {
console.error('Webhook error:', error);
return NextResponse.json(
{
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Webhookの処理に失敗しました',
},
},
{ status: 500 }
);
}
}
3.2 イベントハンドラの実装
3.2.1 Checkout Session完了時
/**
* Checkoutセッション完了時の処理
*/
async function handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) {
console.log('Checkout session completed:', session.id, 'mode:', session.mode);
const firebaseUID = session.metadata?.firebaseUID;
const customerId = typeof session.customer === 'string' ? session.customer : session.customer?.id;
if (!firebaseUID) {
console.error('Firebase UID not found in session metadata');
return;
}
// mode によって処理を分岐
if (session.mode === 'payment') {
// クレジット購入の場合(後述)
await handleCreditPurchase(session, firebaseUID);
} else if (session.mode === 'subscription') {
// サブスクリプション購入の場合
if (!customerId) {
console.error('Customer ID not found in session');
return;
}
// Firestoreを更新
await adminDb.collection('users').doc(firebaseUID).update({
stripeCustomerId: customerId,
updatedAt: new Date(),
});
console.log(`Updated user ${firebaseUID} with customer ID ${customerId}`);
}
}
3.2.2 サブスクリプション更新時
/**
* サブスクリプション作成・更新時の処理
*/
async function handleSubscriptionUpdate(subscription: Stripe.Subscription) {
console.log('Subscription updated:', subscription.id);
const customerId = typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer?.id;
if (!customerId) {
console.error('Customer ID not found in subscription');
return;
}
// Stripe Customer IDからFirebase UIDを取得
const usersSnapshot = await adminDb
.collection('users')
.where('stripeCustomerId', '==', customerId)
.limit(1)
.get();
if (usersSnapshot.empty) {
console.error(`User not found for customer ID: ${customerId}`);
return;
}
const userDoc = usersSnapshot.docs[0];
const userId = userDoc.id;
const userData = userDoc.data();
// サブスクリプションデータを抽出
const status = subscription.status;
const priceId = subscription.items.data[0]?.price.id || null;
const currentPeriodEnd = subscription.current_period_end
? new Date(subscription.current_period_end * 1000)
: null;
const canceledAt = subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null;
// 更新するフィールド
const updateData: any = {
subscriptionStatus: status,
subscriptionPlanId: priceId,
subscriptionCurrentPeriodEnd: currentPeriodEnd,
subscriptionCanceledAt: canceledAt,
updatedAt: new Date(),
};
// PROプランへのアップグレード時、クレジットを付与
const currentStatus = userData?.subscriptionStatus || 'free';
const isPROPlan = status === 'active' && priceId === process.env.NEXT_PUBLIC_STRIPE_PRICE_ID_PRO_MONTHLY;
if (isPROPlan && (currentStatus === 'free' || currentStatus === 'canceled')) {
// 無料プランからPROプランへのアップグレード
updateData.monthlyCredits = 60;
updateData.usedCredits = 0;
updateData.rolloverCredits = 0; // 繰り越しクリア
updateData.creditResetDate = currentPeriodEnd;
console.log(`User ${userId} upgraded to PRO plan, granted 60 credits`);
} else if (isPROPlan && currentStatus === 'active') {
// すでにPROプランで契約更新の場合
updateData.creditResetDate = currentPeriodEnd;
console.log(`User ${userId} PRO plan renewed, updated credit reset date`);
}
// Firestoreを更新
await adminDb.collection('users').doc(userId).update(updateData);
console.log(`Updated user ${userId} subscription to ${status}`);
}
ポイント:
-
Stripe Customer IDからFirebase UIDを逆引き:
stripeCustomerIdでクエリ -
PROプランへのアップグレード時にクレジットをリセット:
monthlyCredits: 60,usedCredits: 0 -
契約更新日を
creditResetDateに保存: 次回リセット日を管理
3.2.3 サブスクリプション削除時
/**
* サブスクリプション削除時の処理
*/
async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
console.log('Subscription deleted:', subscription.id);
const customerId = typeof subscription.customer === 'string'
? subscription.customer
: subscription.customer?.id;
if (!customerId) {
console.error('Customer ID not found in subscription');
return;
}
// Stripe Customer IDからFirebase UIDを取得
const usersSnapshot = await adminDb
.collection('users')
.where('stripeCustomerId', '==', customerId)
.limit(1)
.get();
if (usersSnapshot.empty) {
console.error(`User not found for customer ID: ${customerId}`);
return;
}
const userDoc = usersSnapshot.docs[0];
const userId = userDoc.id;
const userData = userDoc.data();
const canceledAt = subscription.canceled_at
? new Date(subscription.canceled_at * 1000)
: null;
// 次回クレジットリセット日を計算(翌月1日)
const now = new Date();
const nextResetDate = new Date(now.getFullYear(), now.getMonth() + 1, 1);
// 残クレジットを繰り越し
const currentMonthlyCredits = userData?.monthlyCredits || 60;
const currentUsedCredits = userData?.usedCredits || 0;
const remainingCredits = Math.max(0, currentMonthlyCredits - currentUsedCredits);
// Firestoreを更新(無料プランに戻す)
await adminDb.collection('users').doc(userId).update({
subscriptionStatus: 'canceled',
subscriptionPlanId: null,
subscriptionCurrentPeriodEnd: null,
subscriptionCanceledAt: canceledAt,
monthlyCredits: 3, // 無料プランに戻す
usedCredits: currentUsedCredits, // そのまま維持
rolloverCredits: remainingCredits, // 残クレジットを繰り越し
creditResetDate: nextResetDate,
updatedAt: new Date(),
});
console.log(`User ${userId} subscription canceled, reverted to free plan (rollover: ${remainingCredits} credits)`);
}
ポイント:
-
残クレジットを繰り越し:
rolloverCreditsに保存 -
無料プランに戻す:
monthlyCredits: 3 -
usedCreditsはそのまま維持: 制限せずに、次回リセット日まで使用可能
3.3 Webhookのローカルテスト
Stripe CLIを使ってWebhookをローカルでテストできます。
# Stripe CLIをインストール
brew install stripe/stripe-cli/stripe
# Stripeにログイン
stripe login
# Webhookをローカルにフォワード
stripe listen --forward-to localhost:3000/api/stripe/webhook
# 別のターミナルでイベントをトリガー
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
4. クレジット購入機能(単発決済)
サブスクリプションとは別に、単発決済でクレジットを購入できる機能を実装します。
4.1 クレジット購入Checkout Sessionの作成
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe } from '@/lib/stripe/server';
import { getPriceIdByCreditAmount } from '@/lib/stripe/config';
/**
* POST /api/credits/purchase-checkout
* クレジット購入用のStripe Checkoutセッションを作成
*/
export async function POST(request: NextRequest) {
try {
// 1. 認証確認
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
{ status: 401 }
);
}
const idToken = authHeader.split('Bearer ')[1];
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;
// 2. リクエストバリデーション
const body = await request.json();
const { creditAmount, successUrl, cancelUrl } = body;
if (!creditAmount || !successUrl || !cancelUrl) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: '必須パラメータが不足しています',
},
},
{ status: 400 }
);
}
const priceId = getPriceIdByCreditAmount(creditAmount);
if (!priceId) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: '無効なクレジット数です',
},
},
{ status: 400 }
);
}
// 3. ユーザー情報取得
const userDoc = await adminDb.collection('users').doc(userId).get();
if (!userDoc.exists) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
{ status: 404 }
);
}
const userData = userDoc.data();
let stripeCustomerId = userData?.stripeCustomerId;
// 4. Stripe Customer取得または作成
if (!stripeCustomerId) {
const customer = await stripe.customers.create({
email: decodedToken.email,
metadata: {
firebaseUID: userId,
},
});
stripeCustomerId = customer.id;
await adminDb.collection('users').doc(userId).update({
stripeCustomerId: stripeCustomerId,
updatedAt: new Date(),
});
}
// 5. Checkoutセッション作成
const session = await stripe.checkout.sessions.create({
customer: stripeCustomerId,
mode: 'payment', // サブスクリプションではなく単発決済
payment_method_types: ['card'],
line_items: [
{
price: priceId,
quantity: 1,
},
],
success_url: successUrl,
cancel_url: cancelUrl,
locale: 'ja',
metadata: {
firebaseUID: userId,
creditAmount: creditAmount.toString(), // クレジット数をメタデータに保存
},
});
return NextResponse.json({
sessionId: session.id,
url: session.url,
});
} catch (error: any) {
console.error('Create credit purchase checkout session error:', error);
return NextResponse.json(
{
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Checkoutセッションの作成に失敗しました',
},
},
{ status: 500 }
);
}
}
重要なポイント:
-
mode: 'payment': サブスクリプションではなく単発決済 -
metadataにクレジット数を保存: Webhookで使用
4.2 クレジット購入時のWebhook処理
Webhook Handlerの handleCheckoutSessionCompleted 内で、mode === 'payment' の場合にクレジット購入処理を実行します。
/**
* クレジット購入処理(Firestore トランザクション使用)
*/
async function handleCreditPurchase(
session: Stripe.Checkout.Session,
firebaseUID: string
) {
const creditAmountStr = session.metadata?.creditAmount;
if (!creditAmountStr) {
console.error('Invalid or missing credit amount in session metadata');
return;
}
const creditAmount = parseInt(creditAmountStr, 10);
console.log(`Adding ${creditAmount} credits to user ${firebaseUID}`);
// Firestoreトランザクションでクレジットを加算
const userRef = adminDb.collection('users').doc(firebaseUID);
try {
await adminDb.runTransaction(async (transaction) => {
const userDoc = await transaction.get(userRef);
if (!userDoc.exists) {
throw new Error(`User ${firebaseUID} not found`);
}
const userData = userDoc.data();
const currentPurchasedCredits = userData?.purchasedCredits || 0;
const newPurchasedCredits = currentPurchasedCredits + creditAmount;
transaction.update(userRef, {
purchasedCredits: newPurchasedCredits,
updatedAt: new Date(),
});
console.log(
`User ${firebaseUID} purchased credits updated: ${currentPurchasedCredits} → ${newPurchasedCredits}`
);
});
} catch (error) {
console.error('Failed to add purchased credits:', error);
throw error;
}
}
ポイント:
- Firestoreトランザクション: クレジット加算を原子的に実行
-
purchasedCreditsフィールドに加算: 購入クレジットは別管理
4.3 クレジット購入モーダル(フロントエンド)
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { getAllCreditPacks } from '@/lib/stripe/config';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
interface CreditPurchaseModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreditPurchaseModal({ open, onOpenChange }: CreditPurchaseModalProps) {
const { user, firebaseUser } = useAuth();
const creditPacks = getAllCreditPacks();
const [selectedPackId, setSelectedPackId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState('');
const selectedPack = creditPacks.find((pack) => pack.id === selectedPackId);
const handlePurchase = async () => {
if (!selectedPack || !firebaseUser) return;
setIsLoading(true);
setError('');
try {
const idToken = await firebaseUser.getIdToken();
const response = await fetch('/api/credits/purchase-checkout', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({
creditAmount: selectedPack.credits,
successUrl: `${window.location.origin}/dashboard?purchase=success`,
cancelUrl: `${window.location.origin}/dashboard`,
}),
});
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error?.message || 'Checkoutセッションの作成に失敗しました');
}
const data = await response.json();
if (data.url) {
window.location.href = data.url;
} else {
throw new Error('CheckoutURLが取得できませんでした');
}
} catch (err: any) {
setError(err.message || 'クレジット購入の開始に失敗しました');
} finally {
setIsLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>クレジット購入</DialogTitle>
</DialogHeader>
<div className="space-y-6">
{/* クレジットパック選択 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{creditPacks.map((pack) => (
<Card
key={pack.id}
className={`cursor-pointer transition-all ${
selectedPackId === pack.id
? 'border-primary ring-2 ring-primary'
: 'hover:border-primary/50'
}`}
onClick={() => setSelectedPackId(pack.id)}
>
<CardHeader>
<CardTitle className="text-2xl">{pack.credits}</CardTitle>
<p className="text-sm text-muted-foreground">クレジット</p>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">¥{pack.price.toLocaleString()}</div>
<div className="text-xs text-muted-foreground">
¥{pack.pricePerCredit}/クレジット
</div>
</CardContent>
</Card>
))}
</div>
{/* エラー表示 */}
{error && (
<div className="text-sm text-destructive">{error}</div>
)}
{/* 注意事項 */}
<div className="text-sm text-muted-foreground">
<ul className="list-inside list-disc space-y-1">
<li>購入したクレジットは無期限で有効です</li>
<li>月間クレジットを優先的に消費します</li>
</ul>
</div>
{/* アクション */}
<div className="flex justify-end gap-3">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
キャンセル
</Button>
<Button onClick={handlePurchase} disabled={!selectedPack || isLoading}>
{isLoading
? '処理中...'
: selectedPack
? `¥${selectedPack.price.toLocaleString()}で購入`
: 'クレジットパックを選択'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
5. 顧客ポータル(サブスク管理)
ユーザーがサブスクリプションを管理できるCustomer Portalを実装します。
5.1 Customer Portal Sessionの作成
import { NextRequest, NextResponse } from 'next/server';
import { adminAuth, adminDb } from '@/lib/firebase/admin';
import { stripe } from '@/lib/stripe/server';
/**
* POST /api/stripe/create-portal-session
* Stripe Customer Portalセッションを作成
*/
export async function POST(request: NextRequest) {
try {
// 1. 認証確認
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
{ status: 401 }
);
}
const idToken = authHeader.split('Bearer ')[1];
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;
// 2. リクエストボディ取得
const body = await request.json();
const { returnUrl } = body;
if (!returnUrl) {
return NextResponse.json(
{
error: {
code: 'VALIDATION_ERROR',
message: 'リターンURLが指定されていません',
},
},
{ status: 400 }
);
}
// 3. ユーザー情報取得
const userDoc = await adminDb.collection('users').doc(userId).get();
if (!userDoc.exists) {
return NextResponse.json(
{ error: { code: 'NOT_FOUND', message: 'ユーザーが見つかりません' } },
{ status: 404 }
);
}
const userData = userDoc.data();
const stripeCustomerId = userData?.stripeCustomerId;
if (!stripeCustomerId) {
return NextResponse.json(
{
error: {
code: 'NOT_FOUND',
message: 'Stripe Customerが見つかりません',
},
},
{ status: 404 }
);
}
// 4. Customer Portal Sessionを作成
const session = await stripe.billingPortal.sessions.create({
customer: stripeCustomerId,
return_url: returnUrl,
});
return NextResponse.json({
url: session.url, // Customer PortalのURL
});
} catch (error: any) {
console.error('Create portal session error:', error);
return NextResponse.json(
{
error: {
code: 'INTERNAL_ERROR',
message: error.message || 'Portalセッションの作成に失敗しました',
},
},
{ status: 500 }
);
}
}
5.2 フロントエンドからの呼び出し
'use client';
import { useState } from 'react';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
export function ManageSubscriptionButton() {
const { firebaseUser } = useAuth();
const [isLoading, setIsLoading] = useState(false);
const handleManageSubscription = async () => {
if (!firebaseUser) return;
setIsLoading(true);
try {
const idToken = await firebaseUser.getIdToken();
const response = await fetch('/api/stripe/create-portal-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({
returnUrl: `${window.location.origin}/dashboard`,
}),
});
if (!response.ok) {
throw new Error('Customer Portalの作成に失敗しました');
}
const data = await response.json();
if (data.url) {
// Customer Portalに遷移
window.location.href = data.url;
}
} catch (error) {
console.error('Manage subscription error:', error);
alert('サブスクリプション管理ページの表示に失敗しました');
} finally {
setIsLoading(false);
}
};
return (
<Button onClick={handleManageSubscription} disabled={isLoading}>
{isLoading ? '処理中...' : 'サブスクリプションを管理'}
</Button>
);
}
Customer Portalで可能な操作:
- サブスクリプションのキャンセル
- 支払い方法の変更
- 請求書の確認・ダウンロード
- プラン変更(Stripeダッシュボードで設定可能)
6. エラーハンドリング・セキュリティ
6.1 Webhook署名検証
Webhookの署名検証は必須です。検証しないと、第三者が偽のイベントを送信できてしまいます。
// Webhookイベントを検証
event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_WEBHOOK_SECRET
);
6.2 認証の徹底
すべてのAPIエンドポイントでFirebase ID Tokenによる認証を行います。
const authHeader = request.headers.get('authorization');
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return NextResponse.json(
{ error: { code: 'UNAUTHORIZED', message: '認証が必要です' } },
{ status: 401 }
);
}
const idToken = authHeader.split('Bearer ')[1];
const decodedToken = await adminAuth.verifyIdToken(idToken);
const userId = decodedToken.uid;
6.3 環境変数の管理
.env.local は .gitignore に追加し、Gitにコミットしないようにします。
.env*.local
.env
本番環境では、Vercelの環境変数設定画面で設定します。
6.4 エラーログの記録
本番環境では、Sentryなどのエラートラッキングツールを使うことを推奨します。
try {
// 処理
} catch (error: any) {
console.error('Error:', error);
// Sentryに送信
// Sentry.captureException(error);
return NextResponse.json(
{
error: {
code: 'INTERNAL_ERROR',
message: error.message || '処理に失敗しました',
},
},
{ status: 500 }
);
}
7. テスト
7.1 Stripe Checkoutのテスト
Stripe CLIでテストカードを使用してCheckoutをテストできます。
テストカード番号:
- 成功:
4242 4242 4242 4242 - 失敗:
4000 0000 0000 0002 - 3Dセキュア:
4000 0025 0000 3155
7.2 Webhookのテスト
Stripe CLIでWebhookイベントをトリガーしてテストできます。
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger customer.subscription.deleted
stripe trigger invoice.payment_succeeded
stripe trigger invoice.payment_failed
まとめ
この記事では、Delivrouteで実装したStripeサブスクリプション機能を詳しく解説しました。
重要なポイント
-
Stripe Customer IDとFirebase UIDの紐付け:
metadataを活用してWebhookで識別 - Webhookの署名検証: セキュリティのために必須
- Firestoreトランザクション: クレジット加算などの原子性が重要な処理で使用
- Customer Portal: サブスク管理をStripeにお任せ
- テスト環境の構築: Stripe CLIでローカルテストを実施
さらに学びたい方へ
Delivrouteについて
Delivrouteは、配送ルート最適化を簡単に実現できるSaaSです。Google Maps Routes APIを活用し、最大10地点のルートを自動で最適化します。
- 公式サイト: https://delivroute.com
- 無料で始める: https://delivroute.com/signup
この記事が、SaaS開発でStripeサブスクリプションを実装する際の参考になれば幸いです!
Discussion