Rebounder Tech Blog

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

Re-consent to Revised Terms Does Not Reset auto_renew

Published About 5 min readBy the Rebounder engineering team — the people who operate these systems

This article may contain affiliate links. Its content is not affected by advertising.

In short

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 true — what 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.

Frequently asked questions

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.

Environment verified

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

What this article is based on

  • TypeScript file lines 185-233commit 1119810
  • TypeScript file lines 185-250commit ed4242f
  • TypeScript file lines 158-205commit ed4242f

Every claim in this article comes from the records above. The repositories we operate are private so we cannot link to them, but which file, which lines, and at which commit we read them is recorded for every article. Nothing here is written from guesswork.