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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user