Rebounder Tech Blog

Written by the people who actually run these systems in production.

Re-consent to Revised Terms Does Not Reset auto_renew

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

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

結論

If the function that sets an auto-renewal flag only ever writes false to true, removing the clause and asking for re-consent leaves the flag true, and the contract renews and bills itself at expiry.

Conclusion

When “the code that sets a flag” and “the code that clears the agreement” live in different functions, writing the second one is where the first one’s one-way nature gets missed. If the auto-renewal flag is only ever written “false to true when signing a version containing the clause”, then the path that blanks the agreement on re-consent keeps holding the stale true unless it explicitly writes false.

It surfaced like this: a customer re-consented to a revised version with the auto-renewal clause removed, auto_renew on the contract stayed true, and at expiry a successor contract was generated and billed automatically. The customer had re-consented to “no more auto-renewal”; only the contract and the invoice did not follow.

Symptom

  • Terms were revised and a new version without the auto-renewal clause was prepared
  • Existing customers were asked to re-consent (signature reset, then re-sign)
  • The customer re-consented to the new version
  • The contract still auto-renewed at expiry and billed the next period

In the data the contract is “signed against the new version”, and that version has no auto-renewal clause. Yet auto_renew is truewhat was agreed and what is in the database disagree.

Cause

The function that sets the flag looked like this.

// src/lib/policies.ts
export async function applyAutoRenewFromSignedTerms(
  contractId: string
): Promise<void> {
  // ...decide whether the signed version contains the clause (hasClause)...
  if (!hasClause) return;
  await admin
    .from("contracts")
    .update({ auto_renew: true })
    .eq("id", contractId)
    .eq("auto_renew", false);
}

As .eq("auto_renew", false) shows, this function only ever writes one way, false to true. As a legal gate that sets the flag once when a clause-bearing version is signed, that is correct.

Meanwhile the re-consent path (requestPolicyReconsent) resets contract columns to move a signed state back to “unsigned, awaiting re-consent”. Before the fix it did not include auto_renew.

// src/app/admin/contracts/[id]/policy-actions.ts (pre-fix = 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 is not here
  })
  .eq("id", contractId)
  .eq("application_status", "署名済")
  .select("id");

The signature evidence (signed_terms_doc_id and friends) is washed away, but the auto_renew: true set one-way in the past survives, untouched by any reset path. When the customer re-consents to the new clause-free version, applyAutoRenewFromSignedTerms decides hasClause=false and exits at if (!hasClause) return;. No route anywhere in the codebase turned true back into false.

A one-way update function is fine on its own. It breaks when nobody has taken on the “turn it back” responsibility that the function deliberately does not cover.

The fix

Reset auto_renew to false explicitly in the reset path.

// src/app/admin/contracts/[id]/policy-actions.ts (post-fix)
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,
    // Reset the auto-renewal agreement too. applyAutoRenewFromSignedTerms only writes
    // false->true, so without clearing it here a contract that re-consented to a revised
    // version with the clause removed stays true and auto-generates and bills a successor.
    auto_renew: false,
    renewal_stopped_at: null,
  })
  .eq("id", contractId)
  .eq("application_status", "署名済")
  .select("id");

The values about to be wiped are recorded as evidence at the same time.

await logAudit({
  action: "update",
  entity: "contracts",
  entityId: contractId,
  summary: "規約改定のため署名をリセット(再同意待ちへ)",
  // Keep "what had been agreed" before the reset, since it is unrecoverable afterwards.
  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 itself is unchanged. The only change is adding auto_renew to the list of what a reset blanks. After re-consent, if the new version has the clause, that function sets true again correctly; if not, it stays false.

The general shape

This was not a bug in the function that sets the flag. It was a missed boundary of responsibility: the setter and the blanker live in different places, and whoever wrote the blanker did not carry over the setter’s one-way assumption.

  • The setter (applyAutoRenewFromSignedTerms) only needs to know “clause present at signing means true”. That is simple and correct
  • The blanker (requestPolicyReconsent) owns “return everything about the signature to blank” — but that list was enumerated by hand, not derived mechanically from a single source

When you find a function that only writes one way, always ask who owns the other direction. More often than not, it is buried in a differently named path: reset, cancel, terminate. Reviewing the setter alone will never surface the gap.

よくある質問

Q1Why doesn't revising the terms and re-consenting stop auto-renewal?

applyAutoRenewFromSignedTerms was built as a one-way write: false to true when signing a version that contains the clause. Unless the re-consent path clears it, the true set by the earlier signature survives. Deleting the clause from the terms does not delete the flag, which is separate state.

Q2What is the actual damage?

The customer re-consents to a version with no auto-renewal clause, but the contract's auto_renew stays true. At expiry the system reads that stale true, generates a successor contract and bills the next period. From the customer's side, they agreed to stop renewing and were charged anyway.

Q3Where should the fix be written?

Not by making applyAutoRenewFromSignedTerms bidirectional, but by resetting auto_renew to false in requestPolicyReconsent, the path that clears the signature. Re-consent means returning the agreed state to blank, so the auto-renewal agreement should be cleared with it — and the prior value logged.

確認した環境

  • Next.js 16.2.7 / @supabase/supabase-js 2.106.2
  • Found and fixed 2026-07-24 during a second review by another agent

この記事の根拠

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

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