Menu

決済フロー

このドキュメントは、ユーザーが料金プランを購入する際の完全なフローを詳しく説明します。フロントエンドの相互作用、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チェックアウトセッションの作成

app/api/payment/checkout-session/route.ts
// 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チェックアウトセッションの作成

app/api/payment/checkout-session/route.ts
// 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
});

フロントエンド処理

components/home/PricingCTA.tsx
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ページにリダイレクトされます。

ページルート

app/[locale]/payment/success/page.tsx
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検証

app/api/payment/verify-success/stripe-handler.ts
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検証

app/api/payment/verify-success/creem-handler.ts
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処理は決済フローの重要な部分であり、以下を担当します:

  1. 注文記録の作成
  2. ユーザークレジットの付与
  3. サブスクリプションステータスの同期

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テストモードの使用

  1. StripeダッシュボードからテストAPIキーを取得
  2. テスト製品と価格を作成
  3. テストカード番号を使用:4242 4242 4242 4242
  4. Webhook URLをアプリケーションを指すように設定するか、Stripe CLIを使用してWebhookを転送:stripe listen --forward-to localhost:3000/api/stripe/webhook

Creemテストモードの使用

  1. CreemダッシュボードからテストAPIキーを取得
  2. テスト製品を作成
  3. テストカード番号を使用:4242 4242 4242 4242
  4. Webhook URLをアプリケーションを指すように設定