この記事で得られること
SaaS開発で避けて通れない「課金システム」の実装方法を、実際のプロダクト開発経験から徹底解説します。
読者メリット:
- Stripe の商品・価格設定から Webhook 実装まで一気通貫で理解できる
- テスト環境と本番環境の分離で陥りがちな罠と解決策がわかる
- Customer Portal 連携で「自分で実装しなくていい機能」がわかる
- 機能制限(Feature Gate)の設計パターンを習得できる
- コピペで使える TypeScript コードサンプル付き
対象読者:
- Next.js で SaaS を開発中・検討中のエンジニア
- Stripe を使った課金実装が初めての方
- 「とりあえず動く」から「本番運用できる」にしたい方
前提知識:
- Next.js App Router の基本
- TypeScript の基本
- Supabase または他の DB の基本操作
目次
- アーキテクチャ概要
- 階層別課金モデルの設計
- Stripe 商品・価格の登録
- Webhook ハンドラーの実装
- Customer Portal 連携
- 機能制限(Feature Gate)の実装
- 解約フローの実装
- テスト環境と本番環境の分離
- トラブルシューティング
- まとめ
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 商品作成手順
- Stripe Dashboard にログイン
- 「商品カタログ」→「商品を追加」
- 商品情報を入力
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 エラー
確認項目:
- Price ID が正しく設定されているか
- 環境変数に改行文字が含まれていないか(
printfを使っているか) - ビルド時評価の問題が発生していないか
決済完了後にステータスが更新されない
原因: 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 を作成した
参考リンク
この記事が参考になったら、いいね・ストックお願いします!