課金だけが動かなかった夜
土台は半日で立ち上がりました。認証画面もダッシュボードも、Antigravity のエージェントに指示を投げるたびに形になっていきます。ところが決済に差しかかった途端、テストモードの Stripe から届く Webhook が全部 400 で弾かれました。
原因は、生成されたハンドラが stripe-signature ヘッダを読まずに JSON をパースしていたことでした。テストコードは通っています。ローカルでも動いて見えます。署名検証を通していないので、Stripe 側からの本物のリクエストだけが落ちる、という形の失敗でした。
以来、私はこの領域を線で区切っています。画面と型とテストはエージェントに預ける。お金が動く経路は自分の手で書く。その線引きの根拠と、実際に使っている実装を残しておきます。
スタックを Next.js + Supabase + Stripe にした理由
エージェント主導の開発では、スタックの「調べやすさ」がそのまま生成品質になります。学習データに厚く載っている構成ほど、初回出力が実装に近い形で返ってきます。
| レイヤ | 採用 | この構成を選んだ理由 |
|---|---|---|
| フロント / API | Next.js(App Router) | 画面と API ルートが同じリポジトリに収まり、エージェントが参照すべき文脈が1箇所にまとまります |
| DB / 認証 | Supabase | 認証と行レベルセキュリティが DB 側に閉じるため、アプリ側の権限バグが本番データに届きにくくなります |
| 決済 | Stripe | サブスクリプションの状態遷移が API 側で完結し、こちらは反映だけを担えばよくなります |
逆に、社内独自ライブラリや採用事例の少ないフレームワークを混ぜると、エージェントの出力を人間が全部読み直す羽目になります。エージェントに任せる前提の設計では、枯れた構成を選ぶことがそのまま速度になりました。
設計を先に書かせて、実装より前に読む
いきなり実装を頼むと、テーブル定義とAPIの前提がずれたまま大量のコードが出てきます。私は必ず、設計ドキュメントだけを先に出させて、それを読んでから実装に進みます。
Antigravity 2.11.0 では AGENTS.md とカスタムルールファイルの中で @path/to/file 記法が使えるようになりました。規約を1枚の巨大ファイルに積み上げず、役割ごとに分割して参照できます。
# AGENTS.md
## このプロジェクトの前提
@docs/architecture.md
@docs/db-schema.md
## 決済に関する規約
@docs/rules/payment.md
## 生成時の制約
- API ルートは app/api/**/route.ts に置いてください
- Supabase へのサーバー側アクセスは service role キーを使うこと
- app/api/webhooks/ 配下は自動生成の対象外とします最後の1行が要点です。Webhook のディレクトリを明示的に対象外にしておくと、以降のタスクでエージェントがそこへ書き込もうとしなくなります。禁止領域をルールファイル側に置いておく形が、私の手元では最も安定しました。
エージェントの守備範囲をどう決めるかは、エージェントへの委譲境界を個人開発でどう引くか でも別の角度から扱っています。
課金状態をどこに置くか
サブスクリプションの真実は Stripe 側にあります。アプリ側の DB は、そのミラーでしかありません。この前提を崩すと、解約済みのユーザーが有料機能を使えたままになります。
create table public.subscriptions (
user_id uuid primary key references auth.users (id) on delete cascade,
stripe_customer_id text not null,
stripe_subscription_id text not null unique,
price_id text not null,
status text not null,
current_period_end timestamptz not null,
cancel_at_period_end boolean not null default false,
updated_at timestamptz not null default now()
);
alter table public.subscriptions enable row level security;
-- 読み取りのみ本人に開放する。書き込みポリシーは意図的に作らない
create policy "read own subscription"
on public.subscriptions
for select
using (auth.uid() = user_id);書き込み用のポリシーを作らないところが肝心です。RLS が有効なテーブルは、ポリシーの無い操作が既定で拒否されます。結果として、このテーブルを更新できるのは RLS を迂回する service role キー、つまり Webhook ハンドラだけになります。クライアント側のコードがどれだけ書き換わっても、課金状態は改竄できません。
権限判定は status の文字列一致ではなく、期限も併せて見ます。
create or replace function public.has_active_subscription(uid uuid)
returns boolean
language sql
stable
as $$
select exists (
select 1
from public.subscriptions
where user_id = uid
and status in ('active', 'trialing')
and current_period_end > now()
);
$$;past_due を許可に含めるかどうかは、事業判断として分かれるところです。私は支払い失敗から数日は利用を続けられる形にしたかったので、猶予はアプリ側の分岐ではなく Stripe の Smart Retries と current_period_end の余白で吸収する方針にしました。判定ロジックを増やすほど、後から挙動を追えなくなります。
Checkout — 後続イベントで迷子にならないための metadata
Checkout セッションの作成は素直な処理です。ただし1点だけ、後から効いてくる落とし穴があります。
// app/api/checkout/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
export async function POST(req: NextRequest) {
const { userId, priceId, customerId } = await req.json();
const origin = req.headers.get("origin") ?? process.env.NEXT_PUBLIC_SITE_URL!;
const session = await stripe.checkout.sessions.create({
mode: "subscription",
customer: customerId,
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${origin}/account?checkout=success`,
cancel_url: `${origin}/pricing`,
client_reference_id: userId,
metadata: { user_id: userId },
// ここを忘れると customer.subscription.* イベントに user_id が乗りません
subscription_data: {
metadata: { user_id: userId },
},
});
return NextResponse.json({ url: session.url });
}metadata はセッションに付くだけで、そこから作られるサブスクリプションには引き継がれません。subscription_data.metadata を渡していないと、checkout.session.completed では紐付けできても、その後の customer.subscription.updated や deleted で誰のサブスクか分からなくなります。
先ほどの Webhook 事故を調べていたときに、生成コードの多くがこの行を落としていることに気づきました。初回の購読は成功し、解約だけが反映されない。テストが緑のまま本番で壊れる典型的な形です。両方に載せておけば、どのイベントからでも同じキーで辿れます。
Webhook — 署名検証と冪等性は手で書く
ここがこの記事の中心です。App Router では req.text() が生のリクエストボディを返します。req.json() で受けてから再度文字列化したものは署名検証に通りません。バイト列が一致しないためです。
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";
import { createClient } from "@supabase/supabase-js";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const admin = createClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.SUPABASE_SERVICE_ROLE_KEY!,
{ auth: { persistSession: false } }
);
export async function POST(req: NextRequest) {
const signature = req.headers.get("stripe-signature");
if (!signature) {
return NextResponse.json({ error: "missing signature" }, { status: 400 });
}
// 生のボディをそのまま渡す。json() で受けると検証に失敗します
const rawBody = await req.text();
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(
rawBody,
signature,
process.env.STRIPE_WEBHOOK_SECRET!
);
} catch (err) {
console.error("[stripe] signature verification failed", err);
return NextResponse.json({ error: "invalid signature" }, { status: 400 });
}
// 主キー衝突を「処理済み」の判定に使う(アプリ側でロックを持たない)
const { error: claimError } = await admin
.from("processed_stripe_events")
.insert({ event_id: event.id, type: event.type });
if (claimError) {
if (claimError.code === "23505") {
return NextResponse.json({ received: true, duplicate: true });
}
console.error("[stripe] claim failed", claimError);
return NextResponse.json({ error: "claim failed" }, { status: 500 });
}
try {
await applyEvent(event);
} catch (err) {
// 失敗したら記録を戻し、Stripe のリトライで再処理させる
await admin
.from("processed_stripe_events")
.delete()
.eq("event_id", event.id);
console.error("[stripe] handler failed", event.type, err);
return NextResponse.json({ error: "handler failed" }, { status: 500 });
}
return NextResponse.json({ received: true });
}冪等性のテーブルは、主キー制約だけの簡素なものです。
create table public.processed_stripe_events (
event_id text primary key,
type text not null,
received_at timestamptz not null default now()
);Stripe は 2xx が返るまで同じイベントを再送します。ネットワークの揺れで応答だけが失われた場合、同じ event.id が再び届きます。主キー衝突(PostgreSQL のエラーコード 23505)を「既に処理済み」の合図として扱うと、アプリ側でロックを持たずに二重処理を防げます。
イベントの適用側は、状態を上書きする形に寄せました。
async function applyEvent(event: Stripe.Event) {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object as Stripe.Checkout.Session;
if (!session.subscription) return;
const sub = await stripe.subscriptions.retrieve(
session.subscription as string
);
await upsert(sub);
return;
}
case "customer.subscription.updated":
case "customer.subscription.deleted": {
await upsert(event.data.object as Stripe.Subscription);
return;
}
default:
return;
}
}
async function upsert(sub: Stripe.Subscription) {
const userId = sub.metadata?.user_id;
if (!userId) throw new Error(`missing user_id on ${sub.id}`);
const { error } = await admin.from("subscriptions").upsert(
{
user_id: userId,
stripe_customer_id: sub.customer as string,
stripe_subscription_id: sub.id,
price_id: sub.items.data[0]?.price.id ?? "",
status: sub.status,
current_period_end: new Date(
sub.items.data[0].current_period_end * 1000
).toISOString(),
cancel_at_period_end: sub.cancel_at_period_end,
updated_at: new Date().toISOString(),
},
{ onConflict: "user_id" }
);
if (error) throw error;
}イベントの順序は保証されません。差分を加算する実装にすると、到着順が入れ替わっただけで状態が壊れます。届いたイベントが指すサブスクリプションの現在値をそのまま上書きする形にしておけば、順序に依存しません。user_id が取れないときに例外を投げているのも意図的で、握り潰すより 500 を返して Stripe に再送させたほうが、取りこぼしに気づけます。
任せた作業と、任せなかった作業
十日ほど回してみて、線はかなりはっきりしました。個人開発では、レビューに回せる人手がありません。エージェントの出力量ではなく、読み直さずに済む範囲がどこまでかで役割を決めています。
| 作業 | 担当 | 判断の理由 |
|---|---|---|
| 画面コンポーネントとレイアウト | エージェント | 誤りが目で見えるので、検収コストが低く済みます |
| Supabase の型生成と DB クライアント | エージェント | 型が合わなければコンパイルが落ち、機械が検知できます |
| テーブル定義と RLS ポリシー | 下書きのみ委譲 | ポリシーの抜けは静かに通ってしまうため、最終判断は自分で持ちます |
| Checkout セッション作成 | 手で確認して採用 | metadata の受け渡し漏れが後段で効いてきます |
| Webhook ハンドラ | 人間 | 署名検証と冪等性は、テストが緑でも本番でだけ壊れます |
| 環境変数とキーの配置 | 人間 | service role キーがクライアント側へ漏れると取り返しがつきません |
判断の軸は「間違いに機械が気づけるか」です。型エラーやテスト失敗として現れる領域は、遠慮なく預けられます。署名検証の欠落のように、成功したように見えたまま通過する領域は、人間が読むしかありません。
本番に出す前に見ている項目
デプロイ前の確認は、毎回この順で行っています。
stripe listen --forward-to localhost:3000/api/webhooks/stripeでローカルに転送し、stripe trigger customer.subscription.deletedを流して DB のstatusが変わることを確認する- 同じイベントを2回流し、2回目のレスポンスが
duplicate: trueになることを確認する STRIPE_WEBHOOK_SECRETをテスト用と本番用で取り違えていないか、Stripe ダッシュボードのエンドポイント設定と突き合わせる- 匿名キーで
subscriptionsテーブルへupdateを投げ、RLS に拒否されることを確認する SUPABASE_SERVICE_ROLE_KEYがNEXT_PUBLIC_の接頭辞を持たないことを、ビルド成果物の検索で確かめる
2 と 4 は、エージェントが書いたテストには含まれていませんでした。生成されたテストは、書いた本人が想定した経路しか通りません。想定の外側を確かめる手順は、自分で足す必要があります。
Supabase 側の設定を詰める段取りは、Antigravity と Supabase で本番実装—Auth・RLS・Edge Functions・Realtime の要点 にまとめてあります。
手を離す場所を決めておく
エージェントを入れて変わったのは、書く速度よりも、どこを自分で見るべきかがはっきりしたことでした。全部を書いていた頃は、注意の総量が均等に薄く散っていました。土台を預けられるようになって、決済経路とデータ境界に時間を集めておけるようになりました。
次に同じ構成を組むときは、AGENTS.md の禁止領域を最初の一手として書きます。生成が始まってから止めるより、始まる前に線を引いておくほうが、結局は早く着きます。実装の参考になれば幸いです。