Rebounder Tech Blog

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

招待リンクの申込ページはsp.unitsをそのまま表示、確定は1枠なのに5倍の金額を見せていた

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

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

結論

招待リンク経由の申込ページは、サーバー側の確定枠数がinviteのとき常に1に固定されている一方で、表示側はクエリパラメータsp.unitsをそのまま使っていたため、?units=5でアクセスすると実際は1枠の契約なのに画面は5枠・5倍の金額を見せ続けていた。

結論

招待リンク経由の申込ページは、サーバー側の確定枠数が常に1に固定されているのに、画面の表示だけがクエリパラメータの units をそのまま使っていました。 ?units=5 を付けてアクセスすると、実際に結ばれる契約は1枠なのに、画面は「5枠・5倍の金額」を見せ続けます。

症状

このポータルの申込ページ (/apply/[id]) は、通常の申込では ?units= の値で枠数を選べます。1社が複数枠をまとめ買いできる導線です。

同じページは、紹介店が発行した招待リンク経由でも開かれます。招待経路では商流上「1社=1枠」しか成立しないため、サーバー側の申込確定処理 (actions.ts) は昔から招待経路だと units を無条件に 1 へ上書きしていました。

// 招待経路の枠数は **1 に固定**する(Reviewer S6)。units はクエリ/hidden 由来で、
// 上限は残枠しかない=印刷した QR を1回読んだだけで 30 枠を全部押さえられてしまう。
// 商流は「1社=1枠」なので、ここを絞っても正規の申込は妨げない。
const applicantInput = {
  loopId,
  units: invite ? 1 : units,
  ...

問題は、このページ自体の表示側でした。招待経路かどうかに関わらず、画面に出す枠数・金額はクエリパラメータの sp.units をそのまま丸めて使っていました。

// 個別: クエリの枠数/期間をサーバーで正規化(改竄対策・最終確保は予約RPCで再検証)。
const parsedUnits = parseInt(sp.units ?? "1", 10);
const units = Math.min(
  Math.max(1, Number.isFinite(parsedUnits) ? parsedUnits : 1),
  Math.max(1, loop.remainingUnits)
);

招待リンクに ?units=5 を付けてアクセスすると、この units は残枠がある限り 5 になります。以降の枠数・金額表示はすべてこの値を使っているため、画面は「5枠 = 表示秒数 × 5」「金額 × 5」を見せます。申込者はその画面を見て申し込みますが、フォームを送信した先の actions.ts は招待経路だと判定した時点で units1 に上書きするため、実際に結ばれる契約は1枠だけです。

申込者が画面で確認した数量・金額と、実際に締結される契約の内容が食い違う状態でした。

原因

原因は一言で言うと、同じ「招待経路なら1枠に固定する」というルールが、サーバー側にしか実装されていなかったことです。

  • actions.ts(申込確定・server action): 招待経路を検知して units: invite ? 1 : units と分岐 — 先に直っていた
  • page.tsx(申込ページ・表示): 招待経路かどうかを見ず、クエリパラメータを丸めるだけの計算式のまま

サーバー側の分岐がいつ入ったかは、今回確認した範囲の差分には含まれていません(表示側を直したコミットの変更ファイルに actions.ts は入っていない=この時点で既に存在していた)。つまり時系列としては、サーバー側の防御が先にでき、画面側だけがその防御と歩調を合わせないまま残っていたことになります。

直す

page.tsx の表示用 units 計算に、actions.ts と同じ invite 分岐を足しました。

const parsedUnits = parseInt(sp.units ?? "1", 10);
// 招待経路は 1社=1枠に固定(申込 action も同じ規則で確定させる)。ここを揃えないと
// ?units=5 で「5枠・5倍の金額」を見せて 1枠の契約を結ばせることになる(Reviewer 再指摘)。
const units = invite
  ? 1
  : Math.min(
      Math.max(1, Number.isFinite(parsedUnits) ? parsedUnits : 1),
      Math.max(1, loop.remainingUnits)
    );

これで招待経路にアクセスしたときは、クエリパラメータに何を付けても表示は常に1枠・1枠分の金額になり、actions.ts が確定する内容と一致します。

気づけなかった理由

このズレが厄介なのは、サーバー側だけを見ていると「直っている」ように見えることです。契約データベースを確認すれば、招待経由の申込は常に1枠で記録されており、異常は見えません。ズレは「申込者がその場で見ていた画面」にしか出ておらず、記録には残りません。

サーバー側の防御と画面側の表示は、同じ units という言葉を扱っていても実装が別ファイル・別関数に分かれているため、片方を直したときにもう片方が追随している保証がありません。今回はレビューが2周し、1周目でサーバー側の固定が入り、2周目で「画面側は直っていない」ことが指摘されて初めて見つかっています。同じ制約を複数箇所で守らせる実装は、どちらか一方だけ直して終わったつもりになりやすい構造だと言えます。

よくある質問

Q1なぜ表示と実際の契約枠数がズレたのですか?

申込ページ(page.tsx)の表示用units計算が、招待経由かどうかを見ずにクエリパラメータsp.unitsをそのままMath.minで丸めて使っていたためです。一方でフォーム送信を受けるserver action(actions.ts)側は、招待経由なら常にunits: 1で契約を確定させていました。表示側だけがサーバー側の規則を反映していない状態でした。

Q2サーバー側の1枠固定はいつから入っていたのですか?

確認できた範囲では、今回の表示側修正(ead0be0)より前のレビュー対応(コード中の呼称ではReviewer S6)で既に入っており、actions.tsは変更差分に含まれていません。つまりサーバー側は先に直っていて、画面側の直し忘れだけが後から見つかった形です。

Q3招待経由以外の申込でも同じ問題が起きますか?

起きません。招待を介さない通常の申込では、表示側のunitsもサーバー側のunitsも同じクエリパラメータ由来の値をMath.minで丸める同じ規則を使っており、招待経路のような特例の分岐が無いためズレようがありません。ズレが起きるのは、サーバー側にだけinvite専用の特例(1固定)があった招待経路に限られます。

Q4どうやって直しましたか?

page.tsxの表示用units計算に、server action側と同じinvite分岐を追加しました。invite ? 1 : (クエリパラメータを丸めた値)という形にし、招待経由なら表示も確定と同じ1に固定されるようにしています。

確認した環境

  • Next.js 16.2.7 / React 19.2.4(kimiteras-portal)
  • 2026-07-24 に社内レビュー(再レビュー)で発見・同日修正

この記事の根拠

  • TypeScriptファイル 121〜126行目コミット 408053a
  • TypeScriptファイル 125〜134行目コミット ead0be0
  • TypeScriptファイル 98〜106行目コミット ead0be0

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