Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

空文字のcontractIdがinvalid input syntax for type uuidを起こす

公開 読了時間 約9分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

呼び出し側の型をcontractId: string必須のままにして`?? ""`で空文字に変換すると、Postgresはuuid列への空文字投入をinvalid input syntax for type uuidで拒否し、その例外がwebhookの再配信ループを引き起こす。

結論

契約に紐付かない「会社レベルのカード登録」(Stripeの Setup Mode)を通すと、persistSavedCardにcontractIdが渡らない経路がある。だが呼び出し側の引数の型はcontractId: string必須のままだったため、呼び出し元はnullを?? ""で空文字に変換して渡していた。 空文字はcontracts.idのuuid列に対して有効な値ではなく、UPDATE時にPostgresがinvalid input syntax for type uuidで拒否する。この例外はwebhookルート側でcatchされ、冪等台帳の行を削除したうえで500を返す設計だったため、Stripeから同じイベントが再配信され続け、毎回同じ入力で同じ例外が再発する無限ループになる構造だった。

症状

Stripeの決済基盤には、契約(contracts)に紐付く通常のカード登録と、契約とは無関係に会社(companies)へカードを紐付ける「会社レベルのカード登録」の2種類がある。後者はStripeの Setup Mode(課金なしでカードだけを登録するフロー)で扱う。

setup_intent.succeeded イベントのハンドラは次のように書かれていた。

case "setup_intent.succeeded": {
  const si = event.data.object as Stripe.SetupIntent;
  const contractId = si.metadata?.contract_id ?? null;
  const companyId = si.metadata?.company_id ?? null;
  const customerId = typeof si.customer === "string" ? si.customer : (si.customer?.id ?? null);
  const card = extractCard(si.payment_method as Stripe.PaymentMethod | string | null);
  if (contractId || companyId) {
    await persistSavedCard(admin, {
      contractId: contractId ?? "",
      billingCompanyId: companyId,
      customerId,
      card,
    });
  }
  ...
}

会社レベルの登録は metadata.contract_id を持たないため contractIdnull になる。だが persistSavedCard を呼ぶには contractId ?? "" として空文字を渡している。この経路が実行されると、persistSavedCard 内部で contracts テーブルへ空文字のidでUPDATEをかけ、Postgresが invalid input syntax for type uuid を返す。

原因

原因は「実データはnullを許すのに、関数の型シグネチャだけがstring必須のまま」というズレだった。

export async function persistSavedCard(
  admin: SupabaseClient,
  args: {
    contractId: string;                 // ← null を想定していない
    billingCompanyId?: string | null;
    customerId?: string | null;
    card: CardInfo;
  }
): Promise<void> {
  const { contractId, billingCompanyId, customerId, card } = args;
  ...
  const cPatch: Record<string, unknown> = {};
  if (card.paymentMethodId) cPatch.stripe_payment_method_id = card.paymentMethodId;
  if (card.last4) cPatch.card_last4 = card.last4;
  if (card.brand) cPatch.card_brand = card.brand;
  if (Object.keys(cPatch).length > 0) {
    await admin.from("contracts").update(cPatch).eq("id", contractId);
  }
}

persistSavedCardcontractId が存在すること前提で無条件に contracts を更新する。会社レベルの登録という「契約が無い」ケースを後から追加したとき、この関数の型は直さずに、呼び出し側だけを contractId ?? "" で合わせてしまった。TypeScriptの型チェックは通る(string型にstringを渡しているだけなので)。だが実行時には空文字がそのままuuid列に渡り、Postgres側の制約違反として初めて表面化する。型レベルの整合と、実行時に意味のある値かどうかは別で、??で型エラーを消す書き方はこのズレを隠しやすい。

さらにこの例外は握りつぶされずに上位へ伝播する。webhookルートは例外発生時、冪等台帳(stripe_events)の該当行を削除してから500を返す設計になっていた。

try {
  const { contractId } = await handleStripeEvent(admin, stripe, event);
  await admin
    .from("stripe_events")
    .update({ processed_at: new Date().toISOString(), contract_id: contractId })
    .eq("id", event.id);
  return Response.json({ received: true });
} catch (e) {
  // 台帳行を削除し 500 → Stripe の再配信で再処理させる(at-least-once)。
  await admin.from("stripe_events").delete().eq("id", event.id);
  const msg = e instanceof Error ? e.message : "error";
  await notifySlack(/* ... */);
  return new Response(`error: ${msg}`, { status: 500 });
}

台帳から行を消して500を返すのは「一時的なDB障害などで処理が失敗しただけなら、Stripeの再配信でやり直せる」ための設計。だが今回の例外は一時的なものではなく、同じイベント・同じ空文字を渡す限り必ず再発する恒常的な失敗だった。Stripeは500を「未処理」と解釈して再配信し、再配信のたびに同じsetup_intent.succeededイベントが同じ空文字でハンドラに渡り、同じ例外で再び500を返す。会社レベルのカード登録が1件成立するたびに、この経路がそのまま無限の再配信ループになる構造だった。

直し方

修正は「型をnull許容に直し、関数側でガードする」の2点。

export async function persistSavedCard(
  admin: SupabaseClient,
  args: {
    contractId?: string | null;   // optional に変更
    billingCompanyId?: string | null;
    customerId?: string | null;
    card: CardInfo;
  }
): Promise<void> {
  const { contractId, billingCompanyId, customerId, card } = args;
  ...
  // contractId が無い(会社レベルのカード登録=Setup Mode)ときは契約更新をスキップ。
  // 空文字を uuid 列に投げると Postgres が構文エラー→ webhook 500 再配信ループになるため必須のガード。
  if (contractId) {
    const cPatch: Record<string, unknown> = {};
    if (card.paymentMethodId) cPatch.stripe_payment_method_id = card.paymentMethodId;
    if (card.last4) cPatch.card_last4 = card.last4;
    if (card.brand) cPatch.card_brand = card.brand;
    if (Object.keys(cPatch).length > 0) {
      await admin.from("contracts").update(cPatch).eq("id", contractId);
    }
  }
}

呼び出し側も辻褄合わせをやめ、nullをそのまま渡すように変えた。

await persistSavedCard(admin, {
  contractId, // null なら persistSavedCard が契約更新をスキップ(会社レベルのカード登録)
  billingCompanyId: companyId,
  customerId,
  card,
});

?? ""で型エラーを消して辻褄を合わせるのではなく、「契約が無い状態」をnullのまま関数へ通し、関数側で「契約が無ければ契約更新をしない」という業務ルールとして明示的にガードする。呼び出し側で無理に値を作らないことで、型と実データの意味が一致する。

再発防止

このリポジトリでは、実装コミットの直後に別のエージェントによる独立レビューを通す運用になっている。今回の不具合は、Stripeカード決済基盤を実装したコミットの14分後に、そのレビューの指摘(M2: setup_intentの空uuidガード)として見つかり、同日中に修正された。

またStripeのカード決済導線自体がintegrationsテーブルのis_enabled列でON/OFFできるkill-switchの配下にあり、この実装期間を通じて機能はOFFのままだった。

/**
 * `integrations(provider='stripe').is_enabled` ゲート(カード導線の kill-switch・段階有効化)。
 * OFF の間は card 指定契約でも従来の請求書レールにフォールバックする(カード導線を出さない)。
 */
export async function isStripeEnabled(): Promise<boolean> {
  ...
}

そのため実際の顧客のカード登録がこの経路を通ることはなかった。型のズレそのものはレビューが無ければ本番まで残っていたが、機能単位のkill-switchと実装直後の独立レビューという2段構えが、コードの不備がそのまま顧客影響になる前に止めた形になる。

よくある質問

Q1invalid input syntax for type uuidはどんな時に起きますか?

PostgresのUUID型カラムに対して、UUID形式ではない文字列を渡すと出るエラーです。空文字もUUID形式ではないためこのエラーの対象になり、TypeScript側の型チェックでは検出できません。

Q2なぜcontractIdがnullではなく空文字として渡っていたのですか?

呼び出される関数の引数の型がcontractId: string必須のままで、契約に紐付かない会社レベルのカード登録(Setup Mode)を通すためにnullを`?? ""`で空文字へ変換し、型エラーを避けて辻褄を合わせていたためです。

Q3このバグは実際の顧客のカード登録に影響しましたか?

影響していません。Stripeのカード決済導線はintegrations.is_enabledというDBのkill-switchで丸ごとOFFになっており、機能を実装した14分後の独立エージェントレビューで指摘され、本番投入前に修正されました。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2 / stripe ^22.3.1
  • 2026-07-12 に実装・同日の独立レビューで発見・修正(is_enabled=OFFのため顧客影響なし)

この記事の根拠

  • TypeScriptファイル 85〜98行目コミット 1aec8fd
  • TypeScriptファイル 54〜88行目コミット 1aec8fd
  • TypeScriptファイル 59〜74行目コミット 1aec8fd
  • TypeScriptファイル 81〜91行目コミット a6b7ab3
  • TypeScriptファイル 30〜48行目コミット 1aec8fd

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。