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
@@ -251,6 +251,22 @@ final class Transaction_Store {
return $row ? Transaction::from_row($row) : null;
}
/**
* Most recent delivered transaction for a content-addressed dedupe key within a window.
* Powers the cross-request duplicate guard: an identical submission that already
* delivered recently is reused instead of creating a second quote.
*/
public function recent_delivered_by_dedupe(string $dedupe_key, int $within_seconds): ?Transaction {
global $wpdb;
$table = self::table_name();
$cutoff = gmdate('Y-m-d H:i:s', time() - $within_seconds);
$row = $wpdb->get_row($wpdb->prepare(
"SELECT * FROM {$table} WHERE dedupe_key = %s AND status = %s AND delivered_at >= %s ORDER BY id DESC LIMIT 1",
$dedupe_key, Tx_Status::Delivered->value, $cutoff
), ARRAY_A);
return $row ? Transaction::from_row($row) : null;
}
/**
* Paged list for the admin browser, scoped to the active environment.
*
@@ -308,19 +324,43 @@ final class Transaction_Store {
/* ----------------------------------------------------------------- retention */
/**
* Purge ONLY delivered records past the retention window for the given env.
* Pending/failed are never deleted on a timer. Returns the number removed.
* Purge delivered records past the retention window for the given env, EXCEPT
* stranded leads (delivered but never attached to an entry) which must survive so a
* quote whose customer was never notified is not silently lost. Returns rows removed.
*/
public function purge_delivered_older_than(int $days, Environment $env): int {
global $wpdb;
$table = self::table_name();
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
return (int) $wpdb->query($wpdb->prepare(
"DELETE FROM {$table} WHERE status = %s AND environment = %s AND delivered_at IS NOT NULL AND delivered_at < %s",
"DELETE FROM {$table} WHERE status = %s AND environment = %s AND entry_id IS NOT NULL AND delivered_at IS NOT NULL AND delivered_at < %s",
Tx_Status::Delivered->value, $env->value, $cutoff
));
}
/**
* Scrub PII from unresolved (failed/pending) records older than the window while
* keeping the row for audit. Failed/pending rows are never timer-deleted, so without
* this their customer email/payload would be retained indefinitely. Returns rows scrubbed.
*/
public function anonymize_stale_unresolved(int $days): int {
global $wpdb;
$table = self::table_name();
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
return (int) $wpdb->query($wpdb->prepare(
"UPDATE {$table} SET customer_email = '', redacted_payload = '', response_excerpt = NULL
WHERE status IN ('failed', 'pending') AND customer_email <> '' AND created_at < %s",
$cutoff
));
}
/** Data-subject erasure: remove every transaction for a customer email. Returns rows deleted. */
public function delete_by_email(string $email): int {
global $wpdb;
$table = self::table_name();
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE customer_email = %s", $email));
}
/* ----------------------------------------------------------------- internals */
private function update(int $id, array $data, ?string $event): void {