Files
hub-insurance/wp-content/plugins/esp-instanda-integration/includes/class-transaction-store.php
T
zbatteandClaude Fable 5 2b1c226d46 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>
2026-07-02 16:06:13 -06:00

389 lines
16 KiB
PHP

<?php
/**
* Transaction_Store - the write-ahead operational store. A record is saved with
* status 'pending' BEFORE the INSTANDA HTTP call, so a fatal or timeout can never
* lose a submission. Powers the resubmit queue and retention.
*
* Retention is delivered-only: the cron purge removes confirmed-delivered records
* past their window and NEVER deletes pending or failed records (they wait for an
* admin to resolve them).
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
/** Lifecycle status of a transaction. */
enum Tx_Status: string {
case Pending = 'pending';
case Delivered = 'delivered';
case Failed = 'failed';
}
/** Hydrated transaction row. */
final class Transaction {
public int $id = 0;
public ?int $entry_id = null;
public ?int $form_id = null;
public string $status = 'pending';
public string $environment = 'test';
public string $dedupe_key = '';
public bool $possible_duplicate = false;
public string $customer_email = '';
public string $role_label = '';
public ?string $quote_ref = null;
public ?string $quote_url = null;
public string $redacted_payload = '';
public ?int $http_status = null;
public ?string $response_excerpt = null;
public int $retry_count = 0;
public ?string $error_detail = null;
/** @var list<array{ts:string,level:string,message:string}> */
public array $event_log = [];
public string $created_at = '';
public ?string $delivered_at = null;
public ?string $expiry = null;
/** @param array<string,mixed> $row */
public static function from_row(array $row): self {
$t = new self();
$t->id = (int) ($row['id'] ?? 0);
$t->entry_id = isset($row['entry_id']) ? (int) $row['entry_id'] : null;
$t->form_id = isset($row['form_id']) ? (int) $row['form_id'] : null;
$t->status = (string) ($row['status'] ?? 'pending');
$t->environment = (string) ($row['environment'] ?? 'test');
$t->dedupe_key = (string) ($row['dedupe_key'] ?? '');
$t->possible_duplicate = (bool) ($row['possible_duplicate'] ?? false);
$t->customer_email = (string) ($row['customer_email'] ?? '');
$t->role_label = (string) ($row['role_label'] ?? '');
$t->quote_ref = $row['quote_ref'] !== null ? (string) $row['quote_ref'] : null;
$t->quote_url = $row['quote_url'] !== null ? (string) $row['quote_url'] : null;
$t->redacted_payload = (string) ($row['redacted_payload'] ?? '');
$t->http_status = isset($row['http_status']) && $row['http_status'] !== null ? (int) $row['http_status'] : null;
$t->response_excerpt = $row['response_excerpt'] !== null ? (string) $row['response_excerpt'] : null;
$t->retry_count = (int) ($row['retry_count'] ?? 0);
$t->error_detail = $row['error_detail'] !== null ? (string) $row['error_detail'] : null;
$t->event_log = $row['event_log'] ? (array) json_decode((string) $row['event_log'], true) : [];
$t->created_at = (string) ($row['created_at'] ?? '');
$t->delivered_at = $row['delivered_at'] !== null ? (string) $row['delivered_at'] : null;
$t->expiry = $row['expiry'] !== null ? (string) $row['expiry'] : null;
return $t;
}
}
final class Transaction_Store {
public const DB_VERSION = '1.0.0';
public const DB_VERSION_OPTION = 'espinstanda_db_version';
private const TABLE = 'esp_instanda_transactions';
public static function table_name(): string {
global $wpdb;
return $wpdb->prefix . self::TABLE;
}
/* ----------------------------------------------------------------- schema */
public static function install_table(): void {
global $wpdb;
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
$table = self::table_name();
$charset = $wpdb->get_charset_collate();
$sql = "CREATE TABLE {$table} (
id bigint(20) unsigned NOT NULL auto_increment,
entry_id bigint(20) unsigned DEFAULT NULL,
form_id bigint(20) unsigned DEFAULT NULL,
status varchar(20) NOT NULL DEFAULT 'pending',
environment varchar(10) NOT NULL DEFAULT 'test',
dedupe_key varchar(64) NOT NULL DEFAULT '',
possible_duplicate tinyint(1) NOT NULL DEFAULT 0,
customer_email varchar(191) NOT NULL DEFAULT '',
role_label varchar(191) NOT NULL DEFAULT '',
quote_ref varchar(100) DEFAULT NULL,
quote_url text DEFAULT NULL,
redacted_payload longtext NOT NULL,
http_status smallint(5) unsigned DEFAULT NULL,
response_excerpt text DEFAULT NULL,
retry_count tinyint(3) unsigned NOT NULL DEFAULT 0,
error_detail text DEFAULT NULL,
event_log longtext DEFAULT NULL,
created_at datetime NOT NULL DEFAULT '0000-00-00 00:00:00',
delivered_at datetime DEFAULT NULL,
expiry datetime DEFAULT NULL,
PRIMARY KEY (id),
KEY status (status),
KEY environment (environment),
KEY created_at (created_at),
KEY expiry (expiry),
KEY entry_id (entry_id),
KEY dedupe_key (dedupe_key),
KEY status_env (status,environment)
) {$charset};";
dbDelta($sql);
update_option(self::DB_VERSION_OPTION, self::DB_VERSION);
}
public static function needs_migration(): bool {
return version_compare((string) get_option(self::DB_VERSION_OPTION, '0'), self::DB_VERSION, '<');
}
/* ----------------------------------------------------------------- writes */
/** Write-ahead insert. Returns the new row id (0 on failure). */
public function create_pending(Transaction $t): int {
global $wpdb;
$now = current_time('mysql');
$t->event_log[] = self::event('Write-ahead record created (status: pending)');
$ok = $wpdb->insert(self::table_name(), [
'entry_id' => $t->entry_id,
'form_id' => $t->form_id,
'status' => Tx_Status::Pending->value,
'environment' => $t->environment,
'dedupe_key' => $t->dedupe_key,
'possible_duplicate' => 0,
'customer_email' => $t->customer_email,
'role_label' => $t->role_label,
'redacted_payload' => $t->redacted_payload,
'retry_count' => 0,
'event_log' => wp_json_encode($t->event_log),
'created_at' => $now,
], ['%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%d', '%s', '%s']);
return $ok ? (int) $wpdb->insert_id : 0;
}
public function mark_success(int $id, string $quote_ref, ?string $quote_url, ?int $http_status): void {
$delivered = current_time('mysql');
$this->update($id, [
'status' => Tx_Status::Delivered->value,
'quote_ref' => $quote_ref,
'quote_url' => $quote_url,
'http_status' => $http_status,
'delivered_at' => $delivered,
'expiry' => $this->compute_expiry($delivered),
], 'Delivered - quoteRef ' . $quote_ref);
}
public function mark_failed(int $id, ?int $http_status, ?string $response_excerpt, string $error_detail): void {
$this->update($id, [
'status' => Tx_Status::Failed->value,
'http_status' => $http_status,
'response_excerpt' => $response_excerpt,
'error_detail' => $error_detail,
], 'Failed - ' . $error_detail);
}
public function mark_ambiguous(int $id, ?int $http_status, string $error_detail): void {
$this->update($id, [
'status' => Tx_Status::Failed->value,
'possible_duplicate' => 1,
'http_status' => $http_status,
'error_detail' => $error_detail,
], 'Ambiguous timeout - flagged possible duplicate');
}
public function increment_retry(int $id): void {
global $wpdb;
$table = self::table_name();
$wpdb->query($wpdb->prepare("UPDATE {$table} SET retry_count = retry_count + 1 WHERE id = %d", $id));
}
public function attach_entry(int $id, int $entry_id): void {
$this->update($id, ['entry_id' => $entry_id], null);
}
public function append_event(int $id, string $message, string $level = 'info'): void {
$t = $this->find($id);
if ($t === null) {
return;
}
$t->event_log[] = self::event($message, $level);
$this->update($id, ['event_log' => wp_json_encode($t->event_log)], null);
}
/* ----------------------------------------------------------------- reads */
public function find(int $id): ?Transaction {
global $wpdb;
$table = self::table_name();
$row = $wpdb->get_row($wpdb->prepare("SELECT * FROM {$table} WHERE id = %d", $id), ARRAY_A);
return $row ? Transaction::from_row($row) : null;
}
public function find_by_dedupe(string $dedupe_key): ?Transaction {
global $wpdb;
$table = self::table_name();
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE dedupe_key = %s ORDER BY id DESC LIMIT 1", $dedupe_key),
ARRAY_A
);
return $row ? Transaction::from_row($row) : null;
}
public function find_by_entry(int $entry_id): ?Transaction {
global $wpdb;
$table = self::table_name();
$row = $wpdb->get_row(
$wpdb->prepare("SELECT * FROM {$table} WHERE entry_id = %d ORDER BY id DESC LIMIT 1", $entry_id),
ARRAY_A
);
return $row ? Transaction::from_row($row) : null;
}
/**
* Most recent delivered transaction for a customer email within a window.
* Self-recovery guard: do not resubmit/email if the customer already succeeded.
*/
public function recent_success_for_email(string $email, 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 customer_email = %s AND status = %s AND delivered_at >= %s ORDER BY id DESC LIMIT 1",
$email, Tx_Status::Delivered->value, $cutoff
), ARRAY_A);
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.
*
* @param array{status?:string} $filters
* @return list<Transaction>
*/
public function query(array $filters, int $page, int $per_page): array {
global $wpdb;
$table = self::table_name();
$where = ['environment = %s'];
$args = [Config::environment()->value];
if (!empty($filters['status']) && in_array($filters['status'], ['delivered', 'failed', 'pending'], true)) {
$where[] = 'status = %s';
$args[] = $filters['status'];
}
$offset = max(0, ($page - 1) * $per_page);
$sql = "SELECT * FROM {$table} WHERE " . implode(' AND ', $where)
. ' ORDER BY id DESC LIMIT %d OFFSET %d';
$args[] = $per_page;
$args[] = $offset;
$rows = $wpdb->get_results($wpdb->prepare($sql, $args), ARRAY_A) ?: [];
return array_map([Transaction::class, 'from_row'], $rows);
}
/** Status counts for the active environment (powers the list-table views). */
public function counts(): array {
global $wpdb;
$table = self::table_name();
$env = Config::environment()->value;
$rows = $wpdb->get_results($wpdb->prepare(
"SELECT status, COUNT(*) AS n FROM {$table} WHERE environment = %s GROUP BY status", $env
), ARRAY_A) ?: [];
$counts = ['all' => 0, 'delivered' => 0, 'failed' => 0, 'pending' => 0];
foreach ($rows as $row) {
$status = (string) $row['status'];
$n = (int) $row['n'];
$counts['all'] += $n;
if (isset($counts[$status])) {
$counts[$status] += $n;
}
}
return $counts;
}
/** Unresolved (failed + pending) count in the active env - powers the failure badge. */
public function unresolved_count(): int {
$c = $this->counts();
return $c['failed'] + $c['pending'];
}
/* ----------------------------------------------------------------- retention */
/**
* 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 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 {
global $wpdb;
if ($event !== null) {
$t = $this->find($id);
if ($t !== null) {
$t->event_log[] = self::event($event);
$data['event_log'] = wp_json_encode($t->event_log);
}
}
$wpdb->update(self::table_name(), $data, ['id' => $id]);
}
private function compute_expiry(string $delivered_at): string {
$days = (int) get_option('espinstanda_retention_days', 7);
$ts = strtotime($delivered_at) ?: time();
return gmdate('Y-m-d H:i:s', $ts + ($days * DAY_IN_SECONDS));
}
/** @return array{ts:string,level:string,message:string} */
private static function event(string $message, string $level = 'info'): array {
return ['ts' => current_time('mysql'), 'level' => $level, 'message' => Logger::redact($message)];
}
}