DEMO | 「ManaV(マナヴィー)」決済まわり(Stripe Connect)の実装イメージ ・ はちあ様の募集要項をもとに作成
コードレビュー

「AIで作った決済コード」を、こう読みます

実際のリポジトリを拝見する前に、Next.js × Supabase × Stripe の試作でよく見かける5つの落とし穴を、Before / After でまとめました。用語はできるだけかみくだいて書いています。レビューは「ダメ出し」ではなく、一緒に安全にしていく作業です。

進め方: ①GitHubの非公開リポジトリを共有 → ②決済まわりを中心に1〜2時間で通し読み → ③「危険度つきの指摘リスト」+「直し方メモ」をお渡し → ④画面共有しながら一緒に確認。ここまでを最初のステップにできます。

① Webhook の署名を検証していない

🔴 気になる例
export async function POST(req) {
  const event = await req.json();      // 生のJSONをそのまま信用
  if (event.type === 'checkout.session.completed') {
    await confirmBooking(event.data.object.metadata.lessonId);
  }
  return Response.json({ received: true });
}
🟢 直した例
const sig = req.headers.get('stripe-signature');
const body = await req.text();       // rawボディが必要
let event;
try {
  event = stripe.webhooks.constructEvent(
    body, sig, process.env.STRIPE_WEBHOOK_SECRET);
} catch {
  return new Response('bad signature', { status: 400 });
}

なぜ危険? 署名を確認しないと、誰でも「支払いが成功しました」という偽のリクエストを送って、無料でレッスンを予約できてしまいます。Stripe から来たことを暗号的に確認する constructEvent が必須です。Next.js の App Router では、ボディを req.text() で生のまま受け取る点にも注意します。

② 金額・講師IDをフロントから受け取っている

🔴 気になる例
// /api/checkout
const { amount, teacherId } = await req.json();
const session = await stripe.checkout.sessions.create({
  line_items: [{ price_data: {
    currency: 'jpy', unit_amount: amount,   // ← 画面から来た値
    product_data: { name: 'レッスン' } }, quantity: 1 }],
  mode: 'payment',
});
🟢 直した例
const { lessonId } = await req.json();
const lesson = await supabase.from('lessons')
  .select('price_jpy, teacher_id, status')
  .eq('id', lessonId).single();
if (lesson.status !== 'open') throw new Error('not bookable');
const amount = lesson.price_jpy;   // ← DBの値だけを使う

なぜ危険? フロントから来る値は改ざんできます。開発者ツールで amount1 に書き換えれば1円でレッスンが買えてしまう、という事故が起きます。お金に関わる値は必ずサーバ側でデータベースから取り直すのが鉄則です。

③ 冪等(べきとう)性がなく、二重予約・二重送金が起きる

🔴 気になる例
if (event.type === 'payment_intent.succeeded') {
  await supabase.from('bookings').insert({
    lesson_id: pi.metadata.lessonId, status: 'paid',
  });
  await sendConfirmationEmail(pi.metadata.lessonId);
}
🟢 直した例
// Stripeは同じイベントを複数回送ることがある
const ins = await supabase.from('webhook_events')
  .insert({ id: event.id })      // idにUNIQUE制約
  .select().maybeSingle();
if (!ins.data) return Response.json({ duplicate: true });
// ここから先は「初回だけ」実行される
await upsertBooking(pi.metadata.lessonId);

なぜ危険? Stripe は配信を保証するために、同じ Webhook を2回以上送ることがあります。対策がないと、予約が2件入ったり、講師へ80%が2回送金されたりします。イベントIDを記録して「処理済みならスキップ」する、送金・返金の API 呼び出しには idempotencyKey を必ず付けます。

④ Supabase の service_role キーがフロントに出ている/RLS が無効

🔴 気になる例
// components/BookButton.tsx (ブラウザで動くコード)
const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL,
  process.env.NEXT_PUBLIC_SERVICE_ROLE_KEY,  // ← 全権限キーが公開
);
await supabase.from('lessons').update({ status: 'paid' });
🟢 直した例
// ブラウザ側は anon キー + RLS で最小権限
const supabase = createClient(URL, ANON_KEY);

// 予約確定など重要な更新はサーバ側(API/Edge)だけ
// service_role キーは server 環境変数に置き、
// NEXT_PUBLIC_ を付けない
const admin = createClient(URL, process.env.SERVICE_ROLE_KEY);

なぜ危険? service_role キーは RLS(行レベルセキュリティ)を無視して何でもできるキーです。ブラウザに置くと、誰でも他人の予約やレッスン料金を書き換えられます。NEXT_PUBLIC_ が付いた環境変数はビルドに埋め込まれて公開される、という点も要チェックです。

⑤ 「決済は成功したのに予約が入らない」ずれに備えていない

🔴 気になる例
await stripe.paymentIntents.create({ ... });
await supabase.from('bookings').insert({ ... });
// ↑ 2行目で失敗しても、決済だけ通ってしまう
return Response.json({ ok: true });
🟢 直した例
// 予約は「仮押さえ→Webhookで確定」の2段階に
// 1) 予約を pending で作成(枠をロック)
// 2) 決済セッション作成
// 3) payment_intent.succeeded で confirmed に
// 4) 一定時間 pending のまま → 自動で解放+返金
await reconcilePendingBookings();  // 定期実行

なぜ危険? 決済とデータベース更新は別のシステムなので、「片方だけ成功」が必ず起こります。Stripe 側の状態を正(ソース・オブ・トゥルース)にして、Webhook で予約を確定し、取り残された決済は定期チェックで返金する。この設計にしておくと、初心者運営でも事故が表に出にくくなります。

レビューでお渡しするもの

・危険度(高 / 中 / 低)を付けた指摘リスト(この5項目のような形式)
・それぞれの「なぜ危険か」「どう直すか」の短い説明
・すぐ直すべき順番の提案
・画面共有での読み合わせ(用語はその場でかみくだいて説明します)

まずはコードレビューだけでも大丈夫です

現状の把握とリスクの洗い出しから。そのうえで、決済まわりをどう作っていくかを一緒に決めましょう。

開発の進め方を見る