Issue-Axis の β 一般公開に向けて、 Stripe Subscriptions + Customer Portal + Webhook + Supabase で個人向け SaaS の課金基盤を 2 週間で組み上げた実装記録。 webhook race condition の踏み抜きとリカバリも含む。
Issue-Axis(戦略会議をリアルタイム支援する AI)の β 一般公開にあたり、 個人向け SaaS の課金基盤を Stripe + Supabase + Next.js App Router で 2 週間で組み上げました。
「初めて SaaS billing を作る」 場合、 押さえるべき設計ポイント・踏みやすい落とし穴・現実的なスケジュール感を、 実装記録ベースで共有します。
要件と制約
実装前に固めた要件は以下です。
- 個人向け 2 プラン + 月/年契約 = Light(月 ¥2,980 / 年 ¥29,800)と Pro(月 ¥9,800 / 年 ¥98,000)の合計 4 SKU
- 月額契約なしでもスポット購入で利用可能 = 1 時間 ¥1,000 のスポット購入経路を別途用意
- 解約・カード変更・領収書 DL は self-serve = サポート工数を最小化
- 規約同意は法的要件 = sign-up 時に checkbox 必須、 同意 timestamp を保存
- アカウント削除も self-serve = GDPR / 個人情報保護法対応として、 ユーザー本人がいつでも消せること
これを Stripe Subscriptions + Stripe Customer Portal + Supabase Auth + Supabase Postgres の 4 要素で構成しました。
アーキテクチャの全体像
| 役割 | 採用 | 理由 |
|---|---|---|
| 認証 | Supabase Auth(Magic Link + Email/Password) | OSS / 自前運用可、 RLS でデータ境界を強制 |
| データ DB | Supabase Postgres(with RLS) | 認証と同居、 service_role key でサーバ側からも操作可 |
| 決済 | Stripe Subscriptions | カード保管・PCI DSS は委ね、 自前で持たない |
| プラン変更・解約 UI | Stripe Customer Portal | self-serve UI を Stripe に委譲、 工数 0 |
| 規約同意 | profiles.tos_accepted_at 列 | 同意有無 + timestamp を素直に保存 |
| アカウント削除 | Postgres RPC(service_role 専用) | カスケード削除を 1 transaction で保証 |
「自前で書かないものを最大化する」 ことを意識しました。 とくにカード入力 UI と解約 UI は Stripe Customer Portal に丸投げしているため、 自前実装はゼロ時間です。
Supabase スキーマ(最小構成)
billing 関連は 2 テーブルだけにしました。
```sql -- ユーザーごとの Stripe customer 紐付け + 現状プラン create table billing_customers ( user_id uuid primary key references auth.users(id) on delete cascade, stripe_customer_id text unique not null, plan text not null default 'none', -- 'none' | 'light' | 'pro' billing_cycle text, -- 'monthly' | 'yearly' | null status text not null default 'inactive', -- 'active' | 'past_due' | 'canceled' | ... current_period_end timestamptz, updated_at timestamptz not null default now() );
-- webhook イベントの監査ログ(idempotency 担保 + デバッグ用) create table billing_subscription_events ( id uuid primary key default gen_random_uuid(), stripe_event_id text unique not null, -- idempotency key event_type text not null, payload jsonb not null, processed_at timestamptz not null default now() ); ```
ポイントは 3 つ。
billing_customers.user_idを PK に: 1 user = 1 customer の制約を DB レベルで保証billing_subscription_events.stripe_event_idを unique に: webhook 再送が来ても insert で conflict → 二重処理を防止auth.users(id)への on delete cascade: アカウント削除時に billing 行も自動消滅
RLS は すべて deny + 個別 allow の方針で、 ユーザーは自分の billing_customers 行を read 可、 write は service_role 経由でのみ可能としています。
API ルート 3 本だけで billing は回る
Next.js App Router の API ルートで実装したのは 3 本だけです。
/api/billing/checkout — Checkout Session 作成
ログイン済 user が /pricing で「Pro 月額」 ボタンを押した時に呼ばれます。
```ts // 簡略版 const session = await stripe.checkout.sessions.create({ mode: "subscription", customer: stripeCustomerId, // 既存があれば再利用 line_items: [{ price: PRICE_PRO_MONTHLY, quantity: 1 }], success_url: `${SITE_URL}/account?status=success`, cancel_url: `${SITE_URL}/pricing?status=canceled`, metadata: { user_id: user.id }, // webhook で紐付ける鍵 subscription_data: { metadata: { user_id: user.id }, // subscription にも複製 }, }); ```
metadata.user_id を Checkout Session と Subscription の両方に持たせるのが重要です。 webhook 受信時に Stripe customer から自分の DB の user_id を逆引きするより、 metadata で直接渡す方が単純で堅牢でした。
/api/billing/portal — Customer Portal 遷移
`/account` ページの「プラン変更 / 解約 / カード管理」 ボタンから呼ばれて、 Stripe Customer Portal の 1 回限りリンクを生成します。
```ts const portal = await stripe.billingPortal.sessions.create({ customer: stripeCustomerId, return_url: `${SITE_URL}/account`, }); return NextResponse.redirect(portal.url); ```
3 行で「解約 UI 自前実装ゼロ」 が達成できます。 Stripe Dashboard で Cancel / Switch plan / Update payment method を全て ON にしておくのが要点。
/api/billing/webhook — Stripe webhook 受信
これが billing 実装の心臓部です。 customer.subscription.{created, updated, deleted} と invoice.payment_{succeeded, failed} を捌きます。
```ts // 重要な部分だけ抜粋 export async function POST(req: NextRequest) { const sig = req.headers.get("stripe-signature"); const body = await req.text(); const event = stripe.webhooks.constructEvent(body, sig, WEBHOOK_SECRET);
// idempotency: 同じ stripe_event_id は 2 回処理しない const { error: dupError } = await supabase .from("billing_subscription_events") .insert({ stripe_event_id: event.id, event_type: event.type, payload: event }); if (dupError && dupError.code === "23505") { return NextResponse.json({ ok: true, deduped: true }); }
switch (event.type) { case "customer.subscription.created": case "customer.subscription.updated": await syncSubscriptionState(event.data.object.id); break; case "customer.subscription.deleted": await markCanceled(event.data.object); break; // ... } return NextResponse.json({ ok: true }); } ```
idempotency は billing_subscription_events.stripe_event_id の unique 制約で担保。 これで Stripe の webhook 再送が来ても二重処理されません。
踏み抜いた webhook race condition
リリース前テストで一度だけ踏み抜いたバグが、 subscription.created (incomplete) が subscription.updated (active) を上書きする 問題でした。
Checkout 完了時、 Stripe は 複数の webhook をほぼ同時に送ってきます。
customer.subscription.created— status: `incomplete` (3DS 認証待ち)invoice.payment_succeededcustomer.subscription.updated— status: `active`
実環境では順序が前後し、 たまに (1) が (3) より遅く到着して status を active → incomplete に逆戻りさせていました。 ユーザー視点では「決済完了したのに /account がフリープラン表示」 という地獄。
修正は単純で、 どの subscription 系 event でも、 payload の値を信頼せず、 必ず stripe.subscriptions.retrieve で最新 state を再 fetch すること。
```ts async function syncSubscriptionState(subscriptionId: string) { // payload ではなく、 必ず Stripe API で最新 state を取り直す const fresh = await stripe.subscriptions.retrieve(subscriptionId); await supabase.from("billing_customers").update({ plan: derivePlan(fresh.items.data[0].price.id), status: fresh.status, current_period_end: new Date(fresh.current_period_end * 1000).toISOString(), }).eq("stripe_customer_id", fresh.customer); } ```
webhook event の 配信順序は保証されない、 という前提で書く必要があります。 Stripe docs にも記載されていますが、 体感するまでは見落としやすい罠です。
アカウント削除のカスケード
GDPR / 個人情報保護法対応として、 self-serve のアカウント削除を実装しました。 削除トランザクションは Postgres RPC で 1 つにまとめています。
```sql create or replace function delete_user_cascade(p_user_id uuid) returns void language plpgsql security definer -- service_role のみ呼べる前提 as $$ begin -- 関連テーブルを順次削除(外部キー cascade で自動的に消えるものは省略) delete from meetings where user_id = p_user_id; delete from billing_customers where user_id = p_user_id; -- profiles, auth.users は cascade で消える delete from auth.users where id = p_user_id; end; $$; ```
API 経路は double type-to-confirm("削除" と入力させる + 確認 modal)+ Resend で削除確認メール送信、 で誤操作リスクを最小化しました。 Stripe 側の subscription も並行して cancel API を叩いて停止します。
スケジュール感
実装に費やした時間は概ね以下です(個人 1 名、 集中作業時間ベース)。
| 工程 | 期間 |
|---|---|
| Stripe Dashboard 上の Product / Price / Webhook / Customer Portal 設定 | 半日 |
| Supabase schema + RLS + RPC 実装 | 1 日 |
| /api/billing/{checkout,portal,webhook} 実装 | 2 日 |
| /pricing ページ + プラン選択 UI | 1 日 |
| /account マイページ + Portal 動線 + 削除 modal | 2 日 |
| 規約同意 checkbox + tos_accepted_at 列 + middleware 統合 | 半日 |
| アカウント削除確認メール(Resend) | 半日 |
| 法務 4 文書(terms / privacy / security / 特商法)の B2C 強化 | 1 日 |
| 統合テスト + webhook race fix + 細かい UX 修正 | 2 日 |
| Vercel env 切替 + 実カードでの最終疎通テスト | 半日 |
合計 11-12 営業日。 「スケジュールに 1 週間バッファを置いて 2 週間枠」 に収めた形です。
設計上の意思決定で良かった点
- Customer Portal を全面採用: 自前で解約 UI を作っていたら 1 週間は溶けていた。 Stripe Customer Portal は標準でカード変更・解約・領収書 DL・請求書情報編集まで揃う
- webhook の idempotency を最初から実装: `stripe_event_id` unique 制約は 5 分で書ける割に、 後から踏み抜く事故を確実に防ぐ
- payload を信頼せず常に retrieve: race condition 対策。 Stripe API の追加コールは数十 ms、 失う UX より圧倒的に得
- アカウント削除を self-serve に: B2C SaaS では「削除できないと不安」 という声が必ず出る。 サポート工数も 0 になる
- 規約同意 timestamp を保存: 後日「いつ同意したか」 が証跡として必要になる場面(DPA / 契約変更 etc.)で必ず効く
設計上の意思決定で迷った点
- トライアル期間を入れるか: 結論 = 入れない。 サインアップ時に 1 時間分のスポット利用を無料付与する設計にして、 Stripe 側のトライアルは未使用。 トライアル期限切れの離脱より、 「お試し → 良ければ Light 月額 ¥2,980」 の方が moat を築きやすい
- インボイス(適格請求書)対応: 結論 = β 期は対応しない。 適格請求書発行事業者の登録には条件があり、 個人事業主のスタートアップ初期では現実的でない。 FAQ と特商法ページで「適格請求書ではない」 旨を明記して回避
- 年契の途中解約日割り: 結論 = しない。 SaaS の慣例に倣い「契約期間終了日まで利用可能、 残期間返金なし」 を特商法ページに明記
まとめ
Stripe + Supabase + Next.js App Router は、 個人向け SaaS の billing 基盤を 2 週間で組むには現状ベストの組み合わせだと感じました。 Stripe Customer Portal の存在が決定的に大きく、 「self-serve 解約 UI を自分で書かない」 ことが工数圧縮の最大要因です。
webhook race condition は経験しないとなかなか想像できないので、 もしこれから billing を組む方の参考になれば。
Issue-Axis 自体に興味を持っていただけた方は、 料金プラン や 使い方ガイド もぜひご覧ください。
関連記事 / 参考リンク
関連