Rebounder Tech Blog

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

招待のcompany_idで重複申込みをチェックすると、共有リンク経由では常にnullになる

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

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

結論

招待レコードのcompany_idは招待トークンを経由したときしか埋まらないため、共有・QRリンクなど招待を経由しない申込みでは常にnullになり、それを重複チェックの判定にそのまま使うと既に保有している枠をもう一度予約できてしまう。

結論

「対象を全部保有しているか」を見る重複チェックに渡す会社IDを、招待レコードから取ってはいけない。 招待トークンを経由しない公開・共有・QRリンクからの申込みでは招待レコードそのものが存在せず、会社IDは常にnullになる。重複チェック側がnullのときだけ判定を丸ごとスキップする作りだと、既に保有している枠が候補から外れず、もう一度予約されて二重請求になる。

症状

このシステムには、対象枠をまとめて申し込むと自動で割引が適用される「セット割引」の提案機能がある。申込みフォームで「対象枠も一緒に」にチェックを入れると、サーバー側で対象枠の候補を引き直し、まだ保有していない枠だけをまとめて予約する設計になっていた。

ところが、招待トークンを使わない公開ページや、紹介店が発行した共有リンク・QRコード経由の申込みでは、既に保有している対象枠までもう一度予約候補に挙がり、そのまま予約・請求されていた。

原因

まとめ申込みの候補を引く関数は、会社IDを受け取ったときだけ「既に保有している対象枠を除外する」処理を実行していた。

if (companyId) {
  const { data: held } = await admin
    .from("placements")
    .select("loop_id")
    .eq("company_id", companyId)
    .in("availability", ACTIVE);
  const heldSet = new Set(
    ((held ?? []) as { loop_id: string | null }[]).map((r) => r.loop_id).filter(Boolean)
  );
  memberIds = memberIds.filter((id) => !heldSet.has(id));
  if (memberIds.length === 0) return null;
}

companyId が渡らなければ、この if ブロックごと実行されない。つまり「除外しない」のではなく「除外する処理自体が無かったことになる」。

呼び出し側はこの会社IDを、招待レコードからそのまま取っていた。

const offer = await getQualifierBundleOffer(
  createSupabaseAdminClient(),
  loopId,
  invite?.company?.id ?? null
);

invite は招待トークンを検証できたときだけ値が入るオブジェクトで、公開・共有・QRリンク経由の申込みでは invite 自体が常に null になる。したがって invite?.company?.id は常に undefined となり、?? null で null に固定される。招待を経由しない申込み経路のすべてで、除外チェックが機能していなかった。

直す

招待レコードのIDではなく、この申込みの直前の予約処理(book)が確定させた会社IDを使うように変更した。

// ⚠ 会社は booking #1 が名寄せで確定した ID を使う。招待の会社 ID だと
//   共有/QR や公開経路で常に null になり、「保有済みの枠を除く」判定が効かない。
const offer = await getQualifierBundleOffer(admin, loopId, result.companyId);

result は、この申込み自体を予約した処理の戻り値で、招待の有無にかかわらず会社名などから確定した会社IDを持っている。これを使えば、公開・共有・QRリンク経由でも除外チェックが正しく動く。

割引が実際に付いたかどうかも、提案できたことと切り離した。**提示できたこと=付与されたこと、ではない。**付与の事実は契約テーブルへの実際の書き込みでだけ確認し、成立しなかった場合は「割引は適用されていません」と返すようにした。

再発しない形にする

この種のバグは、if (companyId) { ... } のように値があるときだけ安全処理を足す書き方に現れやすい。値が無いときに「安全側のデフォルト」ではなく「安全処理の不在」になってしまうためで、テストもnullを渡すケースを素通りしやすい。会社IDのような重複防止の鍵になる値は、呼び出し側のどの経路でも欠けずに届くことを先に保証してから、受け取る側のガードを書く順番にしている。

同じ「一見無関係な経路を1つ足しただけで、既存の安全装置が静かに外れる」形の話として、お名前.comのゾーン全体送信で別のサブドメインを足しただけで既存のAレコードが消えた も書いています。あちらは送信範囲、こちらは判定条件が対象ですが、壊れ方の形は同じです。

よくある質問

Q1なぜ会社IDがnullになったのですか?

まとめ申込みの重複チェックに渡す会社IDを、招待レコード(invite.company.id)から取っていました。招待トークンを経由しない公開・共有・QRリンクからの申込みでは招待レコード自体が作られないため、invite?.company?.id は常にundefinedとなり、?? nullでnullに固定されていました。

Q2nullだと何が起きるのですか?

重複チェック側の関数は、会社IDが渡されたときだけ「既に保有している対象枠を候補から除外する」処理を実行する作りでした。nullが渡されるとこの除外処理そのものが丸ごとスキップされ、対象枠が全部候補に残ります。結果として、既に保有している枠も割引の対象として再度予約され、二重に請求される状態でした。

Q3直し方はどうしたのですか?

招待レコードのIDではなく、直前の予約処理(book)が会社名などから確定させたcompanyIdを使うように変更しました。公開・共有・QRリンク経由の申込みでも、予約の時点で会社が特定できていればその値が渡るため、重複チェックが正しく働くようになります。

Q4招待経由の申込みでも同じ問題は起きますか?

招待経由では招待レコードに会社IDが最初から紐づいているため、この経路単体では今回のnull化は起きません。問題が起きるのは招待トークンを経由しない公開・共有・QRリンクからの申込みに限られます。

確認した環境

  • Next.js(kimiteras-portal) / Supabase admin client
  • 2026-07-24 のレビュー指摘B2で修正

この記事の根拠

  • TypeScriptファイル 147〜158行目コミット 4314479
  • TypeScriptファイル 172〜205行目コミット 4314479
  • TypeScriptファイル 149〜155行目コミット 8d1878b

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