空文字のcontractIdがinvalid input syntax for type uuidを起こす
※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。
結論
呼び出し側の型を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 を持たないため contractId は null になる。だが 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);
}
}
persistSavedCard は contractId が存在すること前提で無条件に 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
本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。