2
1

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の従量課金設計2026 — Stripe Meter API で「使った分だけ課金」を5ファイルで実装する

2
Last updated at Posted at 2026-05-06

結論: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 の全文記事でまとめています。

👉 個人開発SaaSの従量課金設計2026(masatoman.net)

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?