Rebounder Tech Blog

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

セット割引の判定を「保有しているか」だけにすると、買い増すたびに再発する

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

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

結論

セット割引の適用条件を「対象を全部保有しているか」だけで判定すると、パックが完成したあとに別の対象外の枠を買い増すたびに、同じ割引がそのつど適用され続ける。

結論

複数の対象をまとめて保有すると自動適用されるセット割引を、「対象を全部保有しているか」という現在の保有状態だけで判定してはいけない。 それだけでは、セットが一度完成したあとに対象外の別枠を追加購入するたびに、保有条件は毎回満たされ続けるため、本来1回きりのはずの割引がそのつど再計算されて適用される。直し方は、判定条件に「このセットの割引を過去に付与したことがあるか」という履歴チェックを1本足すことだった。

症状

このシステムには、同一エリア内の対象枠をすべて保有している会社に自動で割引を適用する「セット割引」がある。3つの枠を対象とするセットなら、3つとも保有した状態で申込を行うと、そのぶんディスカウントが載る仕組みだった。

割引が適用できるかどうかは、次のロジックで判定していた。

// src/lib/promo-bundle.ts(修正前の判定ロジック)
let best: { discountYen: number; bundleId: string; bundleName: string } | null = null;
for (const b of active) {
  if (b.discount_yen <= 0) continue;
  const members = membersByBundle.get(b.id) ?? [];
  if (!members.includes(currentLoopId)) continue; // 念のため
  // 資格枠(あれば)を既に保有しているか。
  if (b.qualifier_loop_id && !heldLoops.has(b.qualifier_loop_id)) continue;
  // 現枠以外の member を全部既に保有しているか(=現申込が最後の1枠)。
  const others = members.filter((m) => m !== currentLoopId);
  if (others.length === 0) continue;
  if (!others.every((m) => heldLoops.has(m))) continue;

  if (!best || b.discount_yen > best.discountYen)
    best = { discountYen: b.discount_yen, bundleId: b.id, bundleName: b.name };
}

heldLoops は、その会社が今アクティブに保有している枠の集合を都度取得したものだ。この判定は「セットを完成させる最後の1枠を今申し込んでいるか」を見ているように読めるが、実際には「対象枠を全部保有している状態で、今どれか1枠を申し込んでいるか」しか見ていない。セットが完成したあとに対象外の別の枠を追加購入すると、others.every((m) => heldLoops.has(m)) は変わらず true のままなので、同じ割引がまた計算される。

エリア内の枠数に上限は無いため、セットを完成させたあとに追加購入を繰り返すだけで、本来1回きりのはずの割引が購入のたびに繰り返し適用されていた。正規の申込フローをそのまま使えるため、これは自己申込による金額漏れとして悪用可能な状態だった。

原因

このロジックが見ていたのは「今この会社が何を保有しているか」という現在のスナップショットだけで、「この会社にこのセットの割引を過去に付与したことがあるか」という履歴を一切見ていなかった。

在庫・定員のチェック(対象枠を保有しているか)と、割引の冪等性チェック(その割引を既に使ったか)は独立した判定軸だ。前者は毎回の申込ごとに評価し直して構わないが、後者は「一度成立したら二度と成立させない」という別の性質を持つ。このコードは前者しか実装していなかったので、セット完成後の追加購入という、前者の条件を満たし続ける状態が普通に起こり得ることに対して無防備だった。

直し方

その会社が過去にそのセットの割引を付与された契約(キャンセル済みを除く)を持っているかどうかを別途取得し、既に付与済みのセットは候補から除外するようにした。

// src/lib/promo-bundle.ts(追加した冪等性チェック)
// ⚠ 冪等性(money leak 防止): パック割引は 1 会社 1 パックで一度きり。
//   既にこのパックの割引を載せた契約(cancelled 以外)があるパックは除外する。
//   これが無いと、パック完成後に対象枠を買い増すたび毎回 -割引 が再発する(自己申込で悪用可)。
const { data: grantedRows } = await admin
  .from("contracts")
  .select("promo_bundle_id")
  .eq("company_id", companyId)
  .not("promo_bundle_id", "is", null)
  .neq("status", "cancelled");
const alreadyGranted = new Set(
  ((grantedRows ?? []) as { promo_bundle_id: string | null }[])
    .map((r) => r.promo_bundle_id)
    .filter((x): x is string => !!x)
);

このセットで割引を候補にする直前で、alreadyGranted に含まれていれば飛ばす1行を足す。

for (const b of active) {
  if (b.discount_yen <= 0) continue;
  if (alreadyGranted.has(b.id)) continue; // 冪等: このパックの割引は既に付与済み
  const members = membersByBundle.get(b.id) ?? [];
  // ...(保有状態のチェックはそのまま)
}

「対象を全部保有しているか」の判定はそのまま残し、その手前に「このセットの割引を既に付与済みか」の判定を1本追加しただけで、在庫・定員のチェックと冪等性のチェックがそれぞれ独立して働くようになった。テストには、セット対象を全部保有しており、かつそのセットの割引が付与済みの契約が既にある状態から、対象枠をもう1つ追加した場合に割引が null になることを確認するケースを足している。

なぜ気づけなかったか

このロジックを最初に書いたときの主な関心は「セットを完成させた瞬間に正しく割引を出す」ことだった。そのシナリオではheldLoopsによる保有チェックだけで十分に正しく動く。見落としていたのは「セット完成後も、対象外の枠は制限なく追加購入できる」という、割引ロジックの外側にある業務ルールだった。保有チェックの実装自体にバグは無く、想定していたシナリオの外側で条件が繰り返し成立し得ることに気づいていなかった、というのが実態に近い。

よくある質問

Q1なぜ同じ割引が何度も適用されてしまったのですか?

割引の適用条件が「セット対象の枠を全部保有しているか」という現在の保有状態だけで判定されており、「このセットの割引を過去に付与したことがあるか」を見ていなかったためです。一度セットが完成したあとは、対象枠をどれだけ追加購入しても保有条件は満たされ続けるので、そのたびに割引が再計算されて適用されました。

Q2実害はどの程度でしたか?

会社が対象エリアの枠をまとめて保有した状態を維持しながら別の枠を追加購入するたびに、本来1回きりのはずの割引が毎回上乗せされる形になります。悪意を持って追加購入を繰り返せば、正規の申込を装いながら割引分の金額を際限なく取り戻せる状態でした。

Q3テストではどう確認したのですか?

セット対象の枠を全部保有しており、かつそのセットの割引が付与済みの契約が既にある状態を用意し、そこから対象枠をもう1つ追加した場合に割引が返らない(null になる)ことを確認するテストを追加しました。

Q4在庫や定員のチェックと、この冪等性のチェックは何が違うのですか?

在庫・定員のチェックは『この会社が今どの枠を保有しているか』という現在時点のスナップショットです。冪等性のチェックは『この会社にこのセットの割引を過去に付与したことがあるか』という履歴の有無です。両者は独立した判定軸で、前者だけでは「今の状態」しか見えず「以前すでに1回使った」という事実は見えません。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js ^2.106.2
  • 2026-07-24 の修正コミットで対応

この記事の根拠

  • TypeScriptファイル 64〜150行目コミット d8a3370

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