Rebounder Tech Blog

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

規約改定の再同意フォームがauto_renewをリセットしない

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

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

結論

自動更新フラグを立てる関数がfalse→trueの一方向にしか書き込まない設計だと、規約を改定して自動更新条項を削除しても、再同意(署名リセット)の側で明示的にfalseへ戻さない限りフラグはtrueのまま残り続け、満了時に後継契約が自動生成・自動請求される。

結論

「自動更新フラグを立てる処理」と「合意をリセットする処理」が別の関数に分かれていると、後者を作るときに前者の一方向性を見落とす。 自動更新フラグを立てる関数が「条項ありの版で署名したら false→true」としか書かない設計なら、規約への再同意で合意を白紙に戻す処理は、そのフラグも明示的に false へ戻さない限り、古い true をそのまま握り続ける。

これが表面化したのは、自動更新条項を削除した改定版の規約に顧客が再同意しても、契約の auto_renewtrue のまま残り、満了時に後継契約が自動生成・自動請求されるという形だった。顧客は「もう自動更新しない」と再同意したのに、契約と請求だけがその通りに動いていない。

症状

  • 規約を改定し、自動更新条項を削除した新版を用意した
  • 既存契約の顧客に再同意(署名リセット→再署名)を依頼した
  • 顧客は新版に再同意した
  • にもかかわらず、契約は満了時に自動更新され、次期分の請求が走った

契約データ上は「新版の規約に同意済み」で、その新版には自動更新条項がない。それなのに auto_renewtrue のまま — 同意した内容とデータベースの状態が食い違っている。

原因

自動更新フラグを立てる関数は、次のように書かれていた。

// src/lib/policies.ts
export async function applyAutoRenewFromSignedTerms(
  contractId: string
): Promise<void> {
  // ...署名した版に自動更新条項があるか(hasClause)を判定...
  if (!hasClause) return;
  await admin
    .from("contracts")
    .update({ auto_renew: true })
    .eq("id", contractId)
    .eq("auto_renew", false);
}

.eq("auto_renew", false) という条件が示す通り、この関数は false から true への一方向の書き込みしかしない。 条項ありの版で署名したときに一度だけ true を立てる、法務ゲートとしては正しい設計だ。

一方、規約改定で再同意を求める処理(requestPolicyReconsent)は、署名済みの状態を「未署名・再同意待ち」に戻すために契約のカラムをリセットする。修正前のこの処理は、auto_renew を含んでいなかった。

// src/app/admin/contracts/[id]/policy-actions.ts(修正前 = 111981011e97fe3b67de78c3c2699731d4506e76)
const { data: reset } = await supabase
  .from("contracts")
  .update({
    application_status: "署名依頼中",
    signed_terms_doc_id: null,
    signed_terms_source_version: null,
    signed_posting_standards_doc_id: null,
    signed_date: null,
    agreed_at: null,
    agreed_name: null,
    // auto_renew はここに無い
  })
  .eq("id", contractId)
  .eq("application_status", "署名済")
  .select("id");

署名の証跡(signed_terms_doc_id 等)は洗い流されるのに、過去に一方向で立った auto_renew: true だけは、どのリセット処理にも触れられずに生き残る。 顧客が新版(条項なし)に再同意しても、applyAutoRenewFromSignedTerms は「条項なし版で署名した」と判定して hasClause=false を返し、if (!hasClause) return; でそのまま抜ける。true を false に戻す経路が、コードのどこにも存在しなかった。

一方向の更新関数は、それ単体では正しい。壊れるのは、その関数が触らない「戻す側」の責務を、誰かが別の場所で引き受けていなかったときである。

直す

修正は、リセット処理側で auto_renew を明示的に false へ戻すことだった。

// src/app/admin/contracts/[id]/policy-actions.ts(修正後)
const { data: reset } = await supabase
  .from("contracts")
  .update({
    application_status: "署名依頼中",
    signed_terms_doc_id: null,
    signed_terms_source_version: null,
    signed_posting_standards_doc_id: null,
    signed_date: null,
    agreed_at: null,
    agreed_name: null,
    // 自動更新の合意もリセットする。applyAutoRenewFromSignedTerms は false→true の
    // 片方向しか書かないため、ここで倒さないと「自動更新条項を削除した改定版で
    // 再同意した契約」が true のまま残り、満了時に後継契約が自動生成・自動請求される。
    auto_renew: false,
    renewal_stopped_at: null,
  })
  .eq("id", contractId)
  .eq("application_status", "署名済")
  .select("id");

同時に、リセットで消える直前の値を証跡として残す。

await logAudit({
  action: "update",
  entity: "contracts",
  entityId: contractId,
  summary: "規約改定のため署名をリセット(再同意待ちへ)",
  // リセット前の「何に同意していたか」を証跡に残す(リセットで復元不能になるため)。
  metadata: {
    reason: "policy_reconsent",
    prev_signed_terms_source_version: contract.signed_terms_source_version ?? null,
    prev_signed_terms_doc_id: contract.signed_terms_doc_id ?? null,
    prev_auto_renew: contract.auto_renew ?? null,
  },
});

applyAutoRenewFromSignedTerms 自体は変えていない。変えたのは「リセットは何を白紙に戻すべきか」の一覧に auto_renew を足しただけ。 再同意後、新版に条項があれば applyAutoRenewFromSignedTerms がまた false→true で正しく立て直す。条項がなければ false のまま維持される。

一般化できる形

この事故は、フラグを立てる関数のバグではない。「立てる関数」と「白紙に戻す関数」が別の場所にあり、後者を書くときに前者の一方向性の前提を引き継がなかった、責務の境界の見落としだ。

  • 立てる側(applyAutoRenewFromSignedTerms)は「条項ありで署名したら true」だけを知っていればよく、それ自体は単純で正しい
  • リセット側(requestPolicyReconsent)は「署名にまつわる状態を全部白紙に戻す」責務を持つが、「まつわる状態」の一覧を単一の根拠から機械的に導いていたわけではなく、実装者が手で列挙していた

一方向にしか書き込まない関数を見つけたら、「もう一方向(戻す側)は誰の責務か」を必ず自問する必要がある。多くの場合、戻す側は「リセット」「取り消し」「解約」といった、まったく別の名前の処理の中に埋もれている。フラグを立てる関数のレビューだけでは、この抜けは見つからない。

よくある質問

Q1なぜ規約を改定して再同意させても、自動更新は止まらないのですか?

自動更新フラグを立てる関数(applyAutoRenewFromSignedTerms)は、条項ありの版で署名したときに false から true へ書き込む一方向の処理として作られています。再同意(署名リセット)の処理側がこのフラグを明示的に false へ戻さない限り、以前の署名で立った true はそのまま契約に残り続けます。改定後の規約から自動更新条項自体を消しても、フラグという別の状態を消したことにはならないからです。

Q2具体的にどんな被害が起きますか?

自動更新条項を削除した改定版の規約に顧客が再同意しても、契約のauto_renewはtrueのまま残ります。契約が満了すると、この古いtrueを見て後継契約が自動生成され、次期分の請求が自動的に走ります。顧客から見れば「もう自動更新はしないと同意したのに、また契約と請求が発生した」という、契約上の根拠がない課金になります。

Q3直し方はどこに書き込むのが正しいのですか?

フラグを立てる側(applyAutoRenewFromSignedTerms)を双方向にするのではなく、署名をリセットする側(requestPolicyReconsent)でauto_renewをfalseに戻します。再同意は「今の合意状態を白紙に戻す」操作なので、そこに合わせて自動更新の合意も明示的にリセットするのが筋です。あわせてリセット前の値をaudit_logのmetadataに残し、何に同意していたかの証跡を失わないようにします。

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2
  • 2026-07-24 に別エージェントの再レビューで発覚・同日修正

この記事の根拠

  • TypeScriptファイル 185〜233行目コミット 1119810
  • TypeScriptファイル 185〜250行目コミット ed4242f
  • TypeScriptファイル 158〜205行目コミット ed4242f

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