結論:5ファイルで従量課金が動く
| ファイル | 役割 |
|---|---|
| Stripe Dashboard | Meter と従量課金 Price を設定 |
supabase/migrations/add_usage_events.sql |
使用量を記録するテーブル |
app/api/usage/record/route.ts |
使用イベントを Stripe と Supabase 両方に送信 |
app/api/stripe/webhook/route.ts |
請求確定イベントを Supabase に反映 |
components/UsageMeter.tsx |
ダッシュボードに現在の使用量を表示 |
詳細な設計思想・「で、どう稼ぐ?」セクションは下記の masatoman.net 記事で解説しています。
👉 個人開発SaaSの従量課金設計2026(masatoman.net)
Step 0:Stripe Dashboard で Meter を作成する
Billing → Meters → Create meter で以下を設定します。
| 項目 | 設定例 |
|---|---|
| Meter name | ai_tokens |
| Event name | ai_token_consumed |
| Aggregation | SUM |
次に Products から従量課金 Price を作成します。
-
Billing type:
Per unit - Unit: 上で作った Meter に紐付け
-
Price: 例
¥0.05 / 1,000トークン
Step 1:Supabase に使用量テーブルを追加する
-- supabase/migrations/add_usage_events.sql
CREATE TABLE IF NOT EXISTS usage_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
event_type text NOT NULL,
quantity integer NOT NULL DEFAULT 1,
metadata jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON usage_events (user_id, event_type, created_at DESC);
CREATE OR REPLACE VIEW usage_monthly AS
SELECT
user_id,
event_type,
date_trunc('month', created_at AT TIME ZONE 'Asia/Tokyo') AS month,
SUM(quantity) AS total_quantity
FROM usage_events
GROUP BY user_id, event_type, date_trunc('month', created_at AT TIME ZONE 'Asia/Tokyo');
Step 2:使用イベントを Stripe と Supabase 両方に記録する
// app/api/usage/record/route.ts
import { stripe } from '@/lib/stripe'
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
export async function POST(req: Request) {
const supabase = createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
const { eventType, quantity, metadata } = await req.json()
const { data: profile } = await supabase
.from('profiles')
.select('stripe_customer_id')
.eq('id', user.id)
.single()
await Promise.all([
supabase.from('usage_events').insert({
user_id: user.id,
event_type: eventType,
quantity,
metadata,
}),
profile?.stripe_customer_id
? stripe.billing.meterEvents.create({
event_name: eventType,
payload: {
stripe_customer_id: profile.stripe_customer_id,
value: String(quantity),
},
})
: Promise.resolve(),
])
return NextResponse.json({ ok: true })
}
AI機能のルートから呼び出す例:
// app/api/ai/generate/route.ts(抜粋)
const completion = await openai.chat.completions.create({ ... })
const tokensUsed = completion.usage?.total_tokens ?? 0
fetch('/api/usage/record', {
method: 'POST',
body: JSON.stringify({
eventType: 'ai_token_consumed',
quantity: tokensUsed,
metadata: { model: 'gpt-4o-mini' },
}),
})
Step 3:Webhook で請求確定イベントを処理する
// app/api/stripe/webhook/route.ts(従量課金関連部分)
case 'invoice.payment_succeeded': {
const invoice = event.data.object as Stripe.Invoice
const customerId = invoice.customer as string
const { data: profile } = await supabase
.from('profiles')
.select('id')
.eq('stripe_customer_id', customerId)
.single()
if (!profile) break
await supabase.from('billing_cycles').upsert({
user_id: profile.id,
period_start: new Date(invoice.period_start! * 1000).toISOString(),
period_end: new Date(invoice.period_end! * 1000).toISOString(),
amount_paid: invoice.amount_paid,
})
break
}
Step 4:ダッシュボードに使用量メーターを表示する
// components/UsageMeter.tsx
import { createClient } from '@/lib/supabase/client'
import { useEffect, useState } from 'react'
type Props = {
userId: string
eventType: string
limit?: number
label?: string
}
export function UsageMeter({ userId, eventType, limit, label }: Props) {
const [used, setUsed] = useState(0)
const supabase = createClient()
useEffect(() => {
const now = new Date()
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toISOString()
supabase
.from('usage_events')
.select('quantity')
.eq('user_id', userId)
.eq('event_type', eventType)
.gte('created_at', monthStart)
.then(({ data }) => {
setUsed((data ?? []).reduce((sum, r) => sum + r.quantity, 0))
})
}, [userId, eventType, supabase])
const pct = limit ? Math.min((used / limit) * 100, 100) : null
return (
<div className="space-y-1">
<div className="flex justify-between text-sm text-gray-600">
<span>{label ?? eventType}</span>
<span>{used.toLocaleString()}{limit ? ` / ${limit.toLocaleString()}` : ''}</span>
</div>
{pct !== null && (
<div className="h-2 w-full rounded bg-gray-200">
<div
className={`h-2 rounded transition-all ${pct >= 90 ? 'bg-red-500' : 'bg-blue-500'}`}
style={{ width: `${pct}%` }}
/>
</div>
)}
</div>
)
}
従量課金が向くプロダクト・向かないプロダクト
| プロダクト特性 | 相性 |
|---|---|
| AI機能に使用量の差がある | ◎ |
| ストレージ・帯域依存 | ◎ |
| 利用頻度が均一 | △ 月額固定のほうがシンプル |
| B2C 個人ユーザー | △ 請求額の予測できなさを嫌うケースがある |
| B2B チーム利用 | ○ |
詳細な設計思想・収益化の考え方は masatoman.net の全文記事でまとめています。