決済フロー
このドキュメントは、ユーザーが料金プランを購入する際の完全なフローを詳しく説明します。フロントエンドの相互作用、API呼び出し、決済処理、結果の検証を含みます。
フロー概要
ユーザーがプランを選択
↓
購入ボタンをクリック
↓
チェックアウトセッションを作成(POST /api/payment/checkout-session)
↓
決済プロバイダーページにリダイレクト
↓
ユーザーが決済を完了
↓
アプリにリダイレクト(GET /payment/success)
↓
決済ステータスを検証(GET /api/payment/verify-success)
↓
Webhook処理(POST /api/stripe/webhook または /api/creem/webhook)
↓
注文作成とクレジット配分ステップ1: ユーザーがプランを選択
ユーザーは料金ページで利用可能なすべての料金プランを確認します。各プランカードには以下が表示されます:
- プランタイトルと説明
- 価格情報
- 機能リスト
- 購入ボタン
フロントエンドコンポーネント
PricingCardDisplayコンポーネントはプランカードの表示を処理します:
<PricingCardDisplay
plan={plan}
localizedPlan={localizedPlan}
/>PricingCTAコンポーネントは購入ボタンのクリックイベントを処理します:
<PricingCTA plan={plan} localizedPlan={localizedPlan} />ステップ2: チェックアウトセッションの作成
ユーザーが購入ボタンをクリックすると、フロントエンドは/api/payment/checkout-session APIを呼び出してチェックアウトセッションを作成します。
APIリクエスト
エンドポイント: POST /api/payment/checkout-session
リクエストボディ(Stripe):
{
"provider": "stripe",
"stripePriceId": "price_xxx",
"couponCode": "DISCOUNT10" // オプション
}リクエストボディ(Creem):
{
"provider": "creem",
"creemProductId": "prod_xxx",
"couponCode": "DISCOUNT10" // オプション
}API処理ロジック
Stripeチェックアウトセッションの作成
// 1. ユーザー認証を確認
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
// 2. プラン情報を取得
const plan = await db
.select()
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.stripePriceId, stripePriceId))
.limit(1);
// 3. Stripe Customerを作成または取得
const customerId = await getOrCreateStripeCustomer(user.id);
// 4. Stripeチェックアウトセッションを作成
const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [{ price: stripePriceId, quantity: 1 }],
mode: isSubscription ? 'subscription' : 'payment',
success_url: getURL('payment/success?session_id={CHECKOUT_SESSION_ID}&provider=stripe'),
cancel_url: getURL(process.env.NEXT_PUBLIC_PRICING_PATH!),
metadata: {
userId: user.id,
planId: plan.id,
planName: plan.cardTitle,
priceId: stripePriceId
}
});
// 5. セッションURLを返す
return apiResponse.success({
sessionId: checkoutSession.id,
url: checkoutSession.url
});Creemチェックアウトセッションの作成
// 1. ユーザー認証を確認
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
// 2. プラン情報を取得
const plan = await db
.select()
.from(pricingPlansSchema)
.where(eq(pricingPlansSchema.creemProductId, creemProductId))
.limit(1);
// 3. Creemチェックアウトセッションを作成
const checkoutSession = await createCreemCheckoutSession({
product_id: creemProductId,
units: 1,
discount_code: couponCode,
customer: {
email: user.email
},
success_url: getURL('payment/success?provider=creem'),
metadata: {
userId: user.id,
planId: plan.id,
planName: plan.cardTitle,
productId: creemProductId
}
});
// 4. セッションURLを返す
return apiResponse.success({
sessionId: checkoutSession.id,
url: checkoutSession.checkout_url
});フロントエンド処理
const handleCheckout = async () => {
setIsLoading(true);
try {
const response = await fetch('/api/payment/checkout-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept-Language': locale
},
body: JSON.stringify({
provider: plan.provider,
stripePriceId: plan.stripePriceId, // Stripe
creemProductId: plan.creemProductId, // Creem
couponCode: plan.stripeCouponId || plan.creemDiscountCode // 最大1つが存在
})
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error || 'チェックアウトセッションの作成に失敗しました');
}
if (result.data.url) {
// 決済ページにリダイレクト
router.push(result.data.url);
}
} catch (error) {
toast.error(error.message);
} finally {
setIsLoading(false);
}
};ステップ3: ユーザーが決済を完了
ユーザーは決済プロバイダーの決済ページ(Stripe CheckoutまたはCreem Checkout)にリダイレクトされ、決済フローを完了します。
Stripe Checkout
- ユーザーが決済情報を入力
- Stripeが決済を処理
- 決済成功後、
success_urlにリダイレクト
Creem Checkout
- ユーザーが決済情報を入力
- Creemが決済を処理
- 決済成功後、
success_urlにリダイレクト
ステップ4: 決済成功コールバック
決済成功後、ユーザーはアプリケーションのpayment/successページにリダイレクトされます。
ページルート
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
export default function PaymentSuccessPage({
searchParams
}: {
searchParams: { session_id?: string; checkout_id?: string; provider?: string }
}) {
const provider = searchParams.provider;
if (!provider) {
redirect('/pricing');
}
return (
<div className="container mx-auto py-12">
<Suspense fallback={<div>決済を確認中...</div>}>
<PaymentVerification
provider={provider}
sessionId={searchParams.session_id}
checkoutId={searchParams.checkout_id}
/>
</Suspense>
</div>
);
}決済検証コンポーネント
async function PaymentVerification({
provider,
sessionId,
checkoutId
}: {
provider: string;
sessionId?: string;
checkoutId?: string;
}) {
const session = await getSession();
if (!session?.user) {
redirect('/login');
}
// 検証APIを呼び出す
const verifyUrl = new URL('/api/payment/verify-success', process.env.NEXT_PUBLIC_SITE_URL);
verifyUrl.searchParams.set('provider', provider);
if (provider === 'stripe' && sessionId) {
verifyUrl.searchParams.set('session_id', sessionId);
}
if (provider === 'creem' && checkoutId) {
verifyUrl.searchParams.set('checkout_id', checkoutId);
}
const response = await fetch(verifyUrl.toString(), {
headers: {
Cookie: cookies().toString()
}
});
const result = await response.json();
if (!result.success) {
return (
<div className="text-center">
<h1 className="text-2xl font-bold text-red-600">決済検証に失敗しました</h1>
<p>{result.error}</p>
</div>
);
}
// 成功メッセージを表示
return (
<div className="text-center">
<h1 className="text-2xl font-bold text-green-600">決済が成功しました!</h1>
<p>注文が正常に処理されました。</p>
{result.data.order && (
<div className="mt-4">
<p>注文ID: {result.data.order.id}</p>
</div>
)}
</div>
);
}ステップ5: 決済ステータスの検証
/api/payment/verify-success APIは、決済ステータスを検証し、注文情報を返す責任があります。
API処理フロー
Stripe検証
export async function verifyStripePayment(req: NextRequest, userId: string) {
const sessionId = req.nextUrl.searchParams.get('session_id');
// 1. チェックアウトセッションを取得
const session = await stripe.checkout.sessions.retrieve(sessionId, {
expand: ['line_items', 'payment_intent', 'subscription']
});
// 2. ユーザーIDを確認
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('ユーザーIDが一致しません');
}
// 3. セッションステータスを確認
if (session.status !== 'complete') {
return apiResponse.badRequest(`セッションステータスが完了ではありません: ${session.status}`);
}
// 4. サブスクリプションまたは一回限りの決済を処理
if (session.mode === 'subscription') {
return handleStripeSubscription(session, userId, sessionId);
} else if (session.mode === 'payment') {
return handleStripePayment(session, userId);
}
}Creem検証
export async function verifyCreemPayment(req: NextRequest, userId: string) {
const checkoutId = req.nextUrl.searchParams.get('checkout_id');
// 1. チェックアウトセッションを取得
const session = await retrieveCreemCheckoutSession(checkoutId);
// 2. ユーザーIDを確認
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('ユーザーIDが一致しません');
}
// 3. 決済ステータスを確認
if (session.status !== 'completed') {
return apiResponse.badRequest(`チェックアウトステータスが完了ではありません: ${session.status}`);
}
// 4. サブスクリプションまたは一回限りの決済を処理
if (session.order.type === 'recurring') {
return handleCreemSubscription(session, userId, checkoutId);
} else {
return handleCreemPayment(session, userId);
}
}レスポンスデータ構造
一回限りの決済:
{
"success": true,
"data": {
"order": {
"id": "order-id",
"status": "succeeded",
"amountTotal": "29.00",
"currency": "USD"
}
}
}サブスクリプション決済:
{
"success": true,
"data": {
"subscription": {
"id": "subscription-id",
"status": "active",
"currentPeriodEnd": "2024-02-01T00:00:00Z"
}
}
}ステップ6: Webhook処理
決済プロバイダーは決済完了後にWebhookイベントを送信します。Webhook処理は決済フローの重要な部分であり、以下を担当します:
- 注文記録の作成
- ユーザークレジットの付与
- サブスクリプションステータスの同期
Webhookイベント
Stripe Webhookイベント
checkout.session.completed- チェックアウト完了(一回限りの決済)invoice.paid- インボイス決済成功(サブスクリプション更新)customer.subscription.created- サブスクリプション作成customer.subscription.updated- サブスクリプション更新customer.subscription.deleted- サブスクリプションキャンセルcharge.refunded- 返金
Creem Webhookイベント
checkout.completed- チェックアウト完了(一回限りの決済)subscription.paid- サブスクリプション決済成功subscription.active- サブスクリプション有効化subscription.update- サブスクリプション更新subscription.canceled- サブスクリプションキャンセルsubscription.expired- サブスクリプション期限切れrefund.created- 返金
詳細なWebhook処理ロジックについては、Webhook処理メカニズムを参照してください。
決済フローシーケンス図
ユーザーブラウザ フロントエンドアプリ バックエンドAPI 決済プロバイダー
| | | |
|--購入をクリック---->| | |
| |--セッション作成-->| |
| | |--セッション作成---->|
| | |<--URLを返す-----|
| |<--URLを返す---| |
|<--リダイレクト-----| | |
| | | |
|--決済ページへ移動->| | |
| | | |
|--決済完了----->| | |
| | | |
|<--リダイレクト戻る-| | |
| | | |
|--決済検証--->|--決済検証--->| |
| | |--注文をクエリ---->|
| | |<--注文を返す---|
| |<--結果を返す-| |
|<--結果を表示--| | |
| | | |
| | |<--Webhookイベント--|
| | |--イベントを処理-->|
| | |--注文を作成----|
| | |--クレジットを付与---|セキュリティに関する考慮事項
1. ユーザー認証
すべての決済関連APIはユーザーログインを必要とします:
const session = await getSession();
if (!session?.user) {
return apiResponse.unauthorized();
}2. Webhook署名検証
すべてのWebhookリクエストは署名を検証する必要があります:
Stripe:
const sig = headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);Creem:
const signature = headers.get('creem-signature');
if (!verifySignature(body, signature, webhookSecret)) {
return apiResponse.badRequest('署名検証に失敗しました');
}3. べき等性の処理
注文作成はべき等性チェックを使用して、重複作成を回避します:
const { order, existed } = await createOrderWithIdempotency(
provider,
orderData,
providerOrderId
);
if (existed) {
return; // 注文が既に存在するため、処理をスキップ
}4. ユーザーID検証
決済セッション内のユーザーIDが現在ログインしているユーザーと一致することを確認します:
if (session.metadata?.userId !== userId) {
return apiResponse.unauthorized('ユーザーIDが一致しません');
}決済フローのテスト
Stripeテストモードの使用
- StripeダッシュボードからテストAPIキーを取得
- テスト製品と価格を作成
- テストカード番号を使用:
4242 4242 4242 4242 - Webhook URLをアプリケーションを指すように設定するか、Stripe CLIを使用してWebhookを転送:
stripe listen --forward-to localhost:3000/api/stripe/webhook
Creemテストモードの使用
- CreemダッシュボードからテストAPIキーを取得
- テスト製品を作成
- テストカード番号を使用:
4242 4242 4242 4242 - Webhook URLをアプリケーションを指すように設定