*/ public array $event_log = []; public string $created_at = ''; public ?string $delivered_at = null; public ?string $expiry = null; /** @param array $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; } /** * Paged list for the admin browser, scoped to the active environment. * * @param array{status?:string} $filters * @return list */ 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 ONLY delivered records past the retention window for the given env. * Pending/failed are never deleted on a timer. Returns the number 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", Tx_Status::Delivered->value, $env->value, $cutoff )); } /* ----------------------------------------------------------------- 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)]; } }