fix(instanda): content-based dedupe, resubmit guards, PII retention

- F-01: derive the dedupe key from submission content (form id + mapped values)
  instead of a per-request token, so identical re-POSTs / retry-after-error
  collapse to the existing quote. Keeps the per-request lock; adds a content lock
  race guard and a 30-min recent_delivered_by_dedupe short-circuit.
- F-12: refuse resubmit of an already-delivered transaction (AJAX + worker).
- F-18: scope the resubmit self-recovery guard to the content dedupe key so a
  customer's different event is no longer skipped.
- F-05: anonymize PII on stale failed/pending rows via the cron; add delete_by_email
  and wire WP's Erase Personal Data eraser.
- F-13: exempt stranded delivered rows (no entry_id) from the retention purge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:06:13 -06:00
co-authored by Claude Fable 5
parent 4d7abdbb3b
commit 2b1c226d46
5 changed files with 127 additions and 19 deletions
@@ -22,6 +22,7 @@ final class Submission {
private const LOCK_TTL = 60; // seconds
private const RESULT_TTL = 120;
private const STASH_TTL = 300;
public const DEDUPE_WINDOW = 1800; // 30 min - collapse identical resubmits to the existing quote
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
private const FIELD_KEYS = [
@@ -57,20 +58,28 @@ final class Submission {
$token = $this->submission_token($form);
if (!$this->acquire_lock($token)) {
return $this->replay($token, $validation_result); // GATE 4 - duplicate fire: replay cached outcome
return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire
}
$dedupe = $this->dedupe_key($token);
$store = new Transaction_Store();
$values = $this->extract_values($form, $feed);
$dedupe = $this->content_dedupe_key($form, $values); // content-addressed, stable across requests
// Own double-fire guard: a prior attempt for this submission already delivered.
$existing = $store->find_by_dedupe($dedupe);
if ($existing !== null && $existing->status === Tx_Status::Delivered->value) {
// Cross-request duplicate guard: an identical submission already delivered
// recently -> reuse its quote instead of creating a second one at INSTANDA.
$existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW);
if ($existing !== null) {
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
return $this->remember($token, $validation_result, true);
}
$values = $this->extract_values($form, $feed);
// Race guard: serialize concurrent identical submits (atomic where a persistent
// object cache is present, e.g. production; degrades to a transient lock locally).
if (!$this->acquire_content_lock($dedupe)) {
$this->pending_message = 'We are finalizing your quote - please wait a moment and try again.';
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
}
$mapper = new Field_Mapper();
$tz = wp_timezone_string();
@@ -164,10 +173,10 @@ final class Submission {
}
private function read_stash_for_entry(array $form): array {
// Best-effort: the confirmation runs in the same request as a fresh success.
$token = $this->submission_token($form);
$dedupe = $this->dedupe_key($token);
return $this->read_stash($dedupe) ?? [];
// Best-effort: confirmation runs in the same request as a fresh success, so the
// content dedupe key was recorded in the request-scoped static during validate().
$dedupe = self::$current_dedupe;
return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : [];
}
/* ----------------------------------------------------------- feed + values */
@@ -228,12 +237,31 @@ final class Submission {
return 'espinstanda_' . $unique;
}
private function dedupe_key(string $token): string {
return substr('dk_' . md5($token), 0, 64);
/**
* Content-addressed dedupe key: two submissions of the same form with the same
* mapped field values produce the same key, so an identical re-POST or a
* retry-after-error collapses to the existing quote instead of creating a new one.
*/
private function content_dedupe_key(array $form, array $values): string {
$parts = [(string) ($form['id'] ?? '')];
foreach (self::FIELD_KEYS as $key) {
$v = trim((string) ($values[$key] ?? ''));
$parts[] = $key === 'customer_email' ? strtolower($v) : $v;
}
return 'dk_' . substr(hash('sha256', implode('|', $parts)), 0, 60);
}
/** Per-request lock: stops a single submission from double-firing within one request. */
private function acquire_lock(string $token): bool {
$key = 'lock_' . md5($token);
return $this->try_lock('lock_' . md5($token));
}
/** Brief atomic lock on the content key: serializes concurrent identical submits. */
private function acquire_content_lock(string $dedupe): bool {
return $this->try_lock('clock_' . md5($dedupe));
}
private function try_lock(string $key): bool {
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
return true;