1
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?

この記事で得られること

SaaS開発で避けて通れない「課金システム」の実装方法を、実際のプロダクト開発経験から徹底解説します。

読者メリット:

  • Stripe の商品・価格設定から Webhook 実装まで一気通貫で理解できる
  • テスト環境と本番環境の分離で陥りがちな罠と解決策がわかる
  • Customer Portal 連携で「自分で実装しなくていい機能」がわかる
  • 機能制限(Feature Gate)の設計パターンを習得できる
  • コピペで使える TypeScript コードサンプル付き

対象読者:

  • Next.js で SaaS を開発中・検討中のエンジニア
  • Stripe を使った課金実装が初めての方
  • 「とりあえず動く」から「本番運用できる」にしたい方

前提知識:

  • Next.js App Router の基本
  • TypeScript の基本
  • Supabase または他の DB の基本操作

目次

  1. アーキテクチャ概要
  2. 階層別課金モデルの設計
  3. Stripe 商品・価格の登録
  4. Webhook ハンドラーの実装
  5. Customer Portal 連携
  6. 機能制限(Feature Gate)の実装
  7. 解約フローの実装
  8. テスト環境と本番環境の分離
  9. トラブルシューティング
  10. まとめ

1. アーキテクチャ概要

全体フロー

┌─────────────────────────────────────────────────────────────┐
│ ユーザー: プラン選択 → Checkout ボタンクリック                 │
│   ↓                                                        │
│ POST /api/billing/checkout                                 │
│   - Stripe Checkout Session 作成                            │
│   ↓                                                        │
│ Stripe Checkout ページで決済                                 │
│   ↓                                                        │
│ 決済完了 → success_url にリダイレクト                         │
│   ↓                                                        │
│ Stripe → POST /api/billing/webhook                         │
│   (checkout.session.completed イベント)                     │
│   ↓                                                        │
│ Webhook ハンドラー処理:                                      │
│   - customers テーブル更新                                   │
│   - users テーブル更新                                       │
│   ↓                                                        │
│ ユーザーのステータス変更 → 機能解放                            │
└─────────────────────────────────────────────────────────────┘

技術スタック

役割 技術
フロントエンド Next.js 15+ (App Router)
バックエンド Next.js Route Handlers
データベース Supabase (PostgreSQL)
決済 Stripe
ホスティング Vercel

2. 階層別課金モデルの設計

B2B SaaS でよくある3階層構造

┌─────────────────────────────────────────────┐
│ 組織(Organization)                         │
│ - 法人単位の契約                              │
│ - カスタム価格設定                            │
│ - ボリュームディスカウント                     │
├─────────────────────────────────────────────┤
│ ワークスペース(Workspace)                   │
│ - チーム・部署単位                            │
│ - 組織の価格設定を継承                        │
├─────────────────────────────────────────────┤
│ 個人(User)                                 │
│ - 最終的な課金対象                            │
│ - 複数ワークスペースに所属可能                │
└─────────────────────────────────────────────┘

価格優先順位ロジックの実装

// lib/server/billing/pricing.ts

// 標準価格(円)
export const STANDARD_MONTHLY_PRICE = 50000;
export const STANDARD_HALF_YEARLY_PRICE = 300000;

/**
 * 有効な価格を取得
 * 優先順位: 1. WS個別価格 → 2. 組織カスタム価格 → 3. 標準価格
 */
export function getEffectivePrice(
  workspace: { custom_monthly_price: number | null },
  organization: { custom_monthly_price: number | null } | null,
  billingCycle: 'monthly' | 'half_yearly' = 'monthly'
): number {
  // ワークスペース個別価格を優先
  if (workspace.custom_monthly_price) {
    return billingCycle === 'half_yearly'
      ? workspace.custom_monthly_price * 6
      : workspace.custom_monthly_price;
  }

  // 組織のカスタム価格
  if (organization?.custom_monthly_price) {
    return billingCycle === 'half_yearly'
      ? organization.custom_monthly_price * 6
      : organization.custom_monthly_price;
  }

  // 標準価格
  return billingCycle === 'half_yearly'
    ? STANDARD_HALF_YEARLY_PRICE
    : STANDARD_MONTHLY_PRICE;
}

/**
 * ボリュームディスカウント計算
 */
export function applyVolumeDiscount(
  basePrice: number,
  userCount: number,
  organization: {
    volume_discount_percent: number;
    volume_discount_threshold: number;
  } | null
): number {
  if (!organization) return basePrice * userCount;

  const { volume_discount_percent, volume_discount_threshold } = organization;

  if (userCount >= volume_discount_threshold && volume_discount_percent > 0) {
    const discountRate = 1 - (volume_discount_percent / 100);
    return basePrice * userCount * discountRate;
  }

  return basePrice * userCount;
}

ポイント:

  • カスタム価格をサポートすることで、大口顧客への個別対応が可能
  • ボリュームディスカウントで「使えば使うほどお得」を実現

プラン設計例

// lib/config/pricing.ts

export const PRICING_PLANS = {
  starter: {
    name: 'Starter',
    monthlyPrice: 30000,
    limits: {
      users: 5,
      features: ['OKR', 'Action Map', 'TODO'],
      support: 'コミュニティサポート',
    },
  },
  team: {
    name: 'Team',
    monthlyPrice: 50000,
    limits: {
      users: 10,
      features: ['Starterの全機能', 'Googleカレンダー連携', 'AIアシスタント'],
      support: 'メールサポート',
    },
    isPopular: true, // 推奨プラン表示用
  },
  enterprise: {
    name: 'Enterprise',
    monthlyPrice: 100000,
    limits: {
      users: Infinity,
      features: ['Teamの全機能', 'ホワイトラベル', '専用環境'],
      support: '専任サポート',
    },
  },
} as const;

export type PlanType = keyof typeof PRICING_PLANS;

3. Stripe 商品・価格の登録

3.1 商品作成手順

  1. Stripe Dashboard にログイン
  2. 「商品カタログ」→「商品を追加」
  3. 商品情報を入力

3.2 価格設定のベストプラクティス

請求間隔 用途 設定値
毎月 標準プラン interval: 'month'
6ヶ月 年間契約(割引付き) interval: 'month', interval_count: 6
毎年 年間契約 interval: 'year'

3.3 環境変数の設定

# .env.local(開発環境)
STRIPE_SECRET_KEY="sk_test_xxx"
NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY="pk_test_xxx"
STRIPE_WEBHOOK_SECRET="whsec_xxx"

# Price IDs
STRIPE_PRICE_STARTER_MONTHLY="price_xxx"
STRIPE_PRICE_TEAM_MONTHLY="price_yyy"
STRIPE_PRICE_ENTERPRISE_MONTHLY="price_zzz"

重要: Vercel への環境変数設定時の注意

# ❌ 絶対禁止(末尾に改行が追加される)
echo "sk_test_xxx" | vercel env add STRIPE_SECRET_KEY production --force

# ✅ 正しい方法(printf を使う)
printf '%s' "sk_test_xxx" | vercel env add STRIPE_SECRET_KEY production --force

echo は末尾に \n を追加するため、API キーが壊れます。これは Preview 環境だけでなく Production 環境でも発生します。


4. Webhook ハンドラーの実装

4.1 エンドポイント作成

// app/api/billing/webhook/route.ts
import { NextRequest, NextResponse } from 'next/server';
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, {
  apiVersion: '2024-12-18.acacia',
});

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

  if (!signature) {
    return NextResponse.json({ error: 'No signature' }, { status: 400 });
  }

  let event: Stripe.Event;

  try {
    // 署名検証(必須!)
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!
    );
  } catch (err) {
    console.error('Webhook signature verification failed:', err);
    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 'customer.subscription.updated':
      await handleSubscriptionUpdated(event.data.object as Stripe.Subscription);
      break;
    case 'customer.subscription.deleted':
      await handleSubscriptionDeleted(event.data.object as Stripe.Subscription);
      break;
    case 'invoice.paid':
      await handleInvoicePaid(event.data.object as Stripe.Invoice);
      break;
    case 'invoice.payment_failed':
      await handlePaymentFailed(event.data.object as Stripe.Invoice);
      break;
    default:
      console.log('Unhandled event type:', event.type);
  }

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

4.2 イベントハンドラー実装

// lib/server/billing/webhook-handlers.ts
import Stripe from 'stripe';
import { createClient } from '@/lib/supabase/server';

/**
 * Checkout 完了時の処理
 */
export async function handleCheckoutCompleted(session: Stripe.Checkout.Session) {
  const supabase = await createClient();
  const customerId = session.customer as string;
  const subscriptionId = session.subscription as string;
  const metadata = session.metadata || {};

  // metadata.source で処理を分岐
  if (metadata.source === 'app') {
    await handleAppCheckout(supabase, session);
  }

  // customers テーブルを更新
  await supabase
    .from('customers')
    .update({
      stripe_subscription_id: subscriptionId,
      subscription_status: 'active',
      updated_at: new Date().toISOString(),
    })
    .eq('stripe_customer_id', customerId);
}

/**
 * サブスクリプション更新時の処理
 */
export async function handleSubscriptionUpdated(subscription: Stripe.Subscription) {
  const supabase = await createClient();

  await supabase
    .from('customers')
    .update({
      subscription_status: subscription.status,
      current_period_start: new Date(subscription.current_period_start * 1000).toISOString(),
      current_period_end: new Date(subscription.current_period_end * 1000).toISOString(),
      cancel_at_period_end: subscription.cancel_at_period_end,
      updated_at: new Date().toISOString(),
    })
    .eq('stripe_subscription_id', subscription.id);
}

/**
 * サブスクリプション削除時の処理
 */
export async function handleSubscriptionDeleted(subscription: Stripe.Subscription) {
  const supabase = await createClient();

  await supabase
    .from('customers')
    .update({
      subscription_status: 'canceled',
      stripe_subscription_id: null,
      updated_at: new Date().toISOString(),
    })
    .eq('stripe_subscription_id', subscription.id);
}

/**
 * 請求書支払い完了時の処理
 */
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const supabase = await createClient();

  await supabase
    .from('invoices')
    .upsert({
      stripe_invoice_id: invoice.id,
      stripe_customer_id: invoice.customer as string,
      amount: invoice.amount_paid,
      currency: invoice.currency,
      status: 'paid',
      paid_at: new Date().toISOString(),
    });
}

/**
 * 支払い失敗時の処理
 */
export async function handlePaymentFailed(invoice: Stripe.Invoice) {
  const supabase = await createClient();

  await supabase
    .from('invoices')
    .upsert({
      stripe_invoice_id: invoice.id,
      stripe_customer_id: invoice.customer as string,
      amount: invoice.amount_due,
      currency: invoice.currency,
      status: 'failed',
      failed_at: new Date().toISOString(),
    });

  // TODO: ユーザーへの通知メール送信
  console.log('Payment failed for customer:', invoice.customer);
}

4.3 ローカル開発での Webhook テスト

# Stripe CLI をインストール
brew install stripe/stripe-cli/stripe

# ログイン
stripe login

# Webhook 転送開始(ターミナル1)
stripe listen --forward-to localhost:3000/api/billing/webhook
# 出力: > Ready! Your webhook signing secret is whsec_xxxxx
# この whsec_xxx を .env.local の STRIPE_WEBHOOK_SECRET に設定

# テストイベント送信(ターミナル2)
stripe trigger checkout.session.completed
stripe trigger customer.subscription.updated
stripe trigger invoice.paid
stripe trigger invoice.payment_failed

5. Customer Portal 連携

Stripe Customer Portal を使えば、以下の機能を自分で実装する必要がありません:

  • 支払い方法の変更
  • 請求書の確認・ダウンロード
  • プラン変更(許可した場合)
  • サブスクリプションの解約

5.1 Portal Session API

// app/api/billing/portal/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth';
import { stripe } from '@/lib/server/stripe';
import { createClient } from '@/lib/supabase/server';

export async function POST() {
  const session = await getServerSession();
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const supabase = await createClient();

  // DB から Stripe Customer ID を取得
  const { data: customer, error } = await supabase
    .from('customers')
    .select('stripe_customer_id')
    .eq('user_id', session.user.id)
    .single();

  if (error || !customer?.stripe_customer_id) {
    return NextResponse.json(
      { error: 'No subscription found' },
      { status: 400 }
    );
  }

  try {
    // Portal Session 作成
    const portalSession = await stripe.billingPortal.sessions.create({
      customer: customer.stripe_customer_id,
      return_url: `${process.env.NEXT_PUBLIC_APP_URL}/settings/billing`,
    });

    return NextResponse.json({ url: portalSession.url });
  } catch (err) {
    console.error('Portal session creation failed:', err);
    return NextResponse.json(
      { error: 'Failed to create portal session' },
      { status: 500 }
    );
  }
}

5.2 ボタンコンポーネント

// components/billing/ManageSubscriptionButton.tsx
'use client';

import { useState } from 'react';
import { ExternalLink, Loader2 } from 'lucide-react';

export function ManageSubscriptionButton({ className }: { className?: string }) {
  const [loading, setLoading] = useState(false);

  const handleClick = async () => {
    setLoading(true);
    try {
      const res = await fetch('/api/billing/portal', { method: 'POST' });
      const data = await res.json();

      if (data.error) {
        alert('サブスクリプション管理画面を開けませんでした');
        return;
      }

      // Stripe Customer Portal にリダイレクト
      window.location.href = data.url;
    } catch (err) {
      console.error('Portal request failed:', err);
      alert('エラーが発生しました');
    } finally {
      setLoading(false);
    }
  };

  return (
    <button
      onClick={handleClick}
      disabled={loading}
      className={`inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50 ${className}`}
    >
      {loading ? (
        <Loader2 className="w-4 h-4 animate-spin" />
      ) : (
        <ExternalLink className="w-4 h-4" />
      )}
      サブスクリプション管理
    </button>
  );
}

5.3 Customer Portal の設定(Stripe Dashboard)

機能 推奨設定 理由
支払い方法の更新 有効 カード変更は必須
プラン変更 無効 最低契約期間がある場合
サブスクのキャンセル 有効 解約は許可(期間終了まで有効)
請求書の表示 有効 経理処理に必要

6. 機能制限(Feature Gate)の実装

6.1 プラン定義

// lib/billing/feature-gate.ts

export const PLAN_LIMITS = {
  free: {
    maxUsers: 1,
    maxProjects: 3,
    features: ['basic'],
  },
  starter: {
    maxUsers: 5,
    maxProjects: 10,
    features: ['basic', 'okr', 'actionmap'],
  },
  team: {
    maxUsers: 10,
    maxProjects: 50,
    features: ['basic', 'okr', 'actionmap', 'ai', 'calendar'],
  },
  enterprise: {
    maxUsers: Infinity,
    maxProjects: Infinity,
    features: ['basic', 'okr', 'actionmap', 'ai', 'calendar', 'whitelabel'],
  },
} as const;

export type PlanType = keyof typeof PLAN_LIMITS;

/**
 * 特定のプランで機能が利用可能かチェック
 */
export function canAccessFeature(plan: string, feature: string): boolean {
  const limits = PLAN_LIMITS[plan as PlanType];
  return limits?.features.includes(feature) ?? false;
}

/**
 * ユーザー数制限をチェック
 */
export function canAddUser(plan: string, currentUserCount: number): boolean {
  const limits = PLAN_LIMITS[plan as PlanType];
  return limits ? currentUserCount < limits.maxUsers : false;
}

6.2 API 保護ミドルウェア

// lib/server/billing/check-plan.ts
import { createClient } from '@/lib/supabase/server';
import { canAccessFeature } from '@/lib/billing/feature-gate';

const PLAN_ORDER = ['free', 'starter', 'team', 'enterprise'] as const;

/**
 * 特定機能へのアクセスをチェック
 */
export async function requireFeature(
  userId: string,
  feature: string
): Promise<void> {
  const supabase = await createClient();

  const { data: customer, error } = await supabase
    .from('customers')
    .select('subscription_status, plan')
    .eq('user_id', userId)
    .single();

  if (error || !customer) {
    throw new Error('Subscription required');
  }

  if (customer.subscription_status !== 'active') {
    throw new Error('Active subscription required');
  }

  if (!canAccessFeature(customer.plan, feature)) {
    throw new Error(`Feature "${feature}" not available in your plan`);
  }
}

6.3 API での使用例

// app/api/ai/chat/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth';
import { requireFeature } from '@/lib/server/billing/check-plan';

export async function POST(request: Request) {
  const session = await getServerSession();
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  try {
    // AI 機能は team プラン以上が必要
    await requireFeature(session.user.id, 'ai');
  } catch (err) {
    return NextResponse.json(
      { error: (err as Error).message },
      { status: 403 }
    );
  }

  // AI 処理を実行...
}

7. 解約フローの実装

7.1 期間終了時解約 API

// app/api/billing/cancel/route.ts
import { NextResponse } from 'next/server';
import { getServerSession } from '@/lib/auth';
import { stripe } from '@/lib/server/stripe';
import { createClient } from '@/lib/supabase/server';

export async function POST() {
  const session = await getServerSession();
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  const supabase = await createClient();

  const { data: customer, error } = await supabase
    .from('customers')
    .select('stripe_subscription_id')
    .eq('user_id', session.user.id)
    .single();

  if (error || !customer?.stripe_subscription_id) {
    return NextResponse.json(
      { error: 'No active subscription' },
      { status: 400 }
    );
  }

  try {
    // 期間終了時に解約(即時解約ではない)
    await stripe.subscriptions.update(customer.stripe_subscription_id, {
      cancel_at_period_end: true,
    });

    // DB も更新
    await supabase
      .from('customers')
      .update({
        cancel_at_period_end: true,
        updated_at: new Date().toISOString(),
      })
      .eq('user_id', session.user.id);

    return NextResponse.json({ success: true });
  } catch (err) {
    console.error('Cancel subscription failed:', err);
    return NextResponse.json(
      { error: 'Failed to cancel subscription' },
      { status: 500 }
    );
  }
}

7.2 解約取り消し API

// app/api/billing/cancel/reactivate/route.ts
export async function POST() {
  // ... 認証チェック

  try {
    // 解約をキャンセルして継続
    await stripe.subscriptions.update(customer.stripe_subscription_id, {
      cancel_at_period_end: false,
    });

    // DB も更新
    await supabase
      .from('customers')
      .update({
        cancel_at_period_end: false,
        updated_at: new Date().toISOString(),
      })
      .eq('user_id', session.user.id);

    return NextResponse.json({ success: true });
  } catch (err) {
    // エラーハンドリング
  }
}

8. テスト環境と本番環境の分離

8.1 環境構成

環境 Stripe モード API キー 用途
localhost テスト sk_test_xxx 開発
test.example.com テスト sk_test_xxx 検証
app.example.com 本番 sk_live_xxx 本番運用

8.2 Customer ID 不一致問題と解決策

問題:
テスト環境で作成した Customer ID(cus_test_xxx)は本番 Stripe には存在しない。同じ DB を共有している場合、本番で「Customer not found」エラーが発生。

解決策:

// Checkout API での Customer 検証
async function getOrCreateStripeCustomer(
  supabase: ReturnType<typeof createClient>,
  user: { id: string; email: string }
): Promise<string> {
  const { data: existingCustomer } = await supabase
    .from('customers')
    .select('stripe_customer_id')
    .eq('user_id', user.id)
    .single();

  if (existingCustomer?.stripe_customer_id) {
    try {
      // Stripe で有効か確認
      await stripe.customers.retrieve(existingCustomer.stripe_customer_id);
      return existingCustomer.stripe_customer_id;
    } catch {
      // テストモードの ID が本番に存在しない場合
      console.log('Customer not found in Stripe, creating new one');
    }
  }

  // 新規作成
  const customer = await stripe.customers.create({
    email: user.email,
    metadata: { user_id: user.id },
  });

  // DB 更新
  await supabase
    .from('customers')
    .upsert({
      user_id: user.id,
      stripe_customer_id: customer.id,
      updated_at: new Date().toISOString(),
    });

  return customer.id;
}

8.3 環境変数のビルド時評価問題

問題:
モジュールのトップレベルで環境変数を読み取ると、ビルド時に評価されてしまい、Vercel のサーバーレス環境では空になる。

// ❌ 問題のあるコード(ビルド時に評価される)
export const STRIPE_PRICE_IDS = {
  starter: process.env.STRIPE_PRICE_STARTER_MONTHLY || '',
};

const priceId = STRIPE_PRICE_IDS.starter; // ビルド時に空文字

解決策:

// ✅ 方法1: getter を使用
export const STRIPE_PRICE_IDS = {
  get starter() { return process.env.STRIPE_PRICE_STARTER_MONTHLY || ''; },
};

// ✅ 方法2: 関数を使用(推奨)
function getPriceIdForPlan(planId: string): string {
  const priceIds: Record<string, string> = {
    starter: process.env.STRIPE_PRICE_STARTER_MONTHLY || '',
    team: process.env.STRIPE_PRICE_TEAM_MONTHLY || '',
  };
  return priceIds[planId] || '';
}

// ✅ 方法3: API 関数内で直接参照
export async function POST(request: Request) {
  const priceId = process.env.STRIPE_PRICE_STARTER_MONTHLY || '';
  // ...
}

9. トラブルシューティング

Webhook 署名検証エラー

症状: Webhook signature verification failed

原因: Webhook Secret が一致しない

解決:

# ローカル: stripe listen の出力を確認
# 本番: Stripe Dashboard → Webhooks → エンドポイント → 署名のシークレット

printf '%s' "whsec_xxxxx" | vercel env add STRIPE_WEBHOOK_SECRET production --force

Checkout が 500 エラー

症状: ボタンをクリックしても反応がない、500 エラー

確認項目:

  1. Price ID が正しく設定されているか
  2. 環境変数に改行文字が含まれていないか(printf を使っているか)
  3. ビルド時評価の問題が発生していないか

決済完了後にステータスが更新されない

原因: Webhook が設定されていない、または動作していない

確認:

# Webhook エンドポイント一覧
stripe webhook_endpoints list

# 最近のイベント確認
stripe events list --limit 10

10. まとめ

実装チェックリスト

Stripe 設定:

  • 商品・価格を登録した
  • テスト/本番両方の Price ID を取得した
  • 環境変数を設定した(printf を使用)

Webhook:

  • エンドポイントを作成した
  • 署名検証を実装した
  • 5 つのイベントに対応した
  • 本番用 Webhook を登録した

Customer Portal:

  • Portal Session API を作成した
  • ボタンコンポーネントを作成した
  • Dashboard でカスタマイズした

機能制限:

  • プラン定義を作成した
  • API 保護ミドルウェアを実装した

解約フロー:

  • 期間終了時解約 API を作成した
  • 解約取り消し API を作成した

参考リンク


この記事が参考になったら、いいね・ストックお願いします!

1
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
1
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?