- 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>
186 lines
7.8 KiB
PHP
186 lines
7.8 KiB
PHP
<?php
|
|
/**
|
|
* Transactions browser - a WP_List_Table over the operational store with status
|
|
* views and a nonce + capability-gated, cross-env-guarded, async Resubmit.
|
|
*
|
|
* Resubmit never blocks the admin: it schedules a single cron event and returns.
|
|
* A "possible duplicate" record requires explicit confirmation before resubmit
|
|
* (INSTANDA has no idempotency, so resubmitting a request that actually succeeded
|
|
* would create a second quote).
|
|
*
|
|
* @package ESP\Instanda
|
|
*/
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace ESP\Instanda;
|
|
|
|
if (!class_exists('\WP_List_Table') && defined('ABSPATH')) {
|
|
require_once ABSPATH . 'wp-admin/includes/class-wp-list-table.php';
|
|
}
|
|
|
|
final class Transactions_List_Table extends \WP_List_Table {
|
|
|
|
private Transaction_Store $store;
|
|
|
|
public function __construct() {
|
|
parent::__construct(['singular' => 'transaction', 'plural' => 'transactions', 'ajax' => false]);
|
|
$this->store = new Transaction_Store();
|
|
}
|
|
|
|
public function get_columns(): array {
|
|
return [
|
|
'cb' => '<input type="checkbox" />',
|
|
'submitted' => 'Submitted',
|
|
'email' => 'Customer email',
|
|
'role' => 'Role',
|
|
'status' => 'Status',
|
|
'env' => 'Env',
|
|
'quote_ref' => 'quoteRef',
|
|
'retries' => 'Retries',
|
|
'row_actions'=> 'Actions',
|
|
];
|
|
}
|
|
|
|
protected function get_views(): array {
|
|
$counts = $this->store->counts();
|
|
$current = (string) ($_GET['status'] ?? 'all');
|
|
$base = admin_url('admin.php?page=espinstanda-transactions');
|
|
$make = static function (string $key, string $label, int $n) use ($current, $base): string {
|
|
$url = $key === 'all' ? $base : add_query_arg('status', $key, $base);
|
|
$class = $current === $key ? ' class="current"' : '';
|
|
return sprintf('<a href="%s"%s>%s <span class="count">(%d)</span></a>', esc_url($url), $class, esc_html($label), $n);
|
|
};
|
|
return [
|
|
'all' => $make('all', 'All', $counts['all']),
|
|
'delivered' => $make('delivered', 'Delivered', $counts['delivered']),
|
|
'failed' => $make('failed', 'Failed', $counts['failed']),
|
|
'pending' => $make('pending', 'Pending', $counts['pending']),
|
|
];
|
|
}
|
|
|
|
protected function get_bulk_actions(): array {
|
|
return ['resubmit' => 'Resubmit'];
|
|
}
|
|
|
|
public function prepare_items(): void {
|
|
$per_page = 25;
|
|
$page = max(1, (int) $this->get_pagenum());
|
|
$status = (string) ($_GET['status'] ?? 'all');
|
|
$filters = $status !== 'all' ? ['status' => $status] : [];
|
|
|
|
$this->items = $this->store->query($filters, $page, $per_page);
|
|
$this->_column_headers = [$this->get_columns(), [], []];
|
|
|
|
$counts = $this->store->counts();
|
|
$total = $status !== 'all' ? ($counts[$status] ?? 0) : $counts['all'];
|
|
$this->set_pagination_args(['total_items' => $total, 'per_page' => $per_page]);
|
|
}
|
|
|
|
protected function column_cb($item): string {
|
|
return sprintf('<input type="checkbox" name="tx[]" value="%d" />', $item->id);
|
|
}
|
|
|
|
protected function column_default($item, $column_name): string {
|
|
return match ($column_name) {
|
|
'submitted' => esc_html($item->created_at) . '<br><span class="mono">#' . $item->id . '</span>',
|
|
'email' => esc_html($item->customer_email),
|
|
'role' => esc_html($item->role_label),
|
|
'status' => $this->status_badge($item),
|
|
'env' => '<span class="espinstanda-env">' . esc_html(strtoupper($item->environment)) . '</span>',
|
|
'quote_ref' => esc_html($item->quote_ref ?? '—'),
|
|
'retries' => (string) $item->retry_count,
|
|
'row_actions' => $this->row_action_buttons($item),
|
|
default => '',
|
|
};
|
|
}
|
|
|
|
private function status_badge(Transaction $item): string {
|
|
if ($item->possible_duplicate) {
|
|
return '<span class="espinstanda-badge dup">Failed · possible dup</span>';
|
|
}
|
|
$label = ucfirst($item->status);
|
|
return '<span class="espinstanda-badge ' . esc_attr($item->status) . '">' . esc_html($label) . '</span>';
|
|
}
|
|
|
|
private function row_action_buttons(Transaction $item): string {
|
|
$detail = add_query_arg(['page' => 'espinstanda-transactions', 'tx' => $item->id], admin_url('admin.php'));
|
|
$view = sprintf('<a class="button button-small" href="%s">View log</a>', esc_url($detail));
|
|
|
|
if ($item->status === Tx_Status::Delivered->value) {
|
|
return $view;
|
|
}
|
|
$cross_env = $item->environment !== Config::environment()->value;
|
|
$label = $item->possible_duplicate ? 'Resubmit…' : 'Resubmit';
|
|
$resubmit = sprintf(
|
|
'<button class="button button-small espinstanda-resubmit" data-tx="%d" data-dup="%d" %s>%s</button>',
|
|
$item->id,
|
|
$item->possible_duplicate ? 1 : 0,
|
|
$cross_env ? 'disabled title="Blocked - stored environment differs from the active one"' : '',
|
|
esc_html($label)
|
|
);
|
|
return $view . ' ' . $resubmit;
|
|
}
|
|
}
|
|
|
|
/** Async resubmit worker - runs on the esp_instanda_resubmit cron event. */
|
|
final class Resubmit_Handler {
|
|
|
|
public static function schedule(int $tx_id): void {
|
|
wp_schedule_single_event(time() + 1, 'esp_instanda_resubmit', [$tx_id]);
|
|
}
|
|
|
|
public static function run(int $tx_id): void {
|
|
$store = new Transaction_Store();
|
|
$tx = $store->find($tx_id);
|
|
if ($tx === null) {
|
|
return;
|
|
}
|
|
if ($tx->status === Tx_Status::Delivered->value) {
|
|
$store->append_event($tx_id, 'Resubmit blocked - already delivered (would duplicate the quote).', 'error');
|
|
return;
|
|
}
|
|
if ($tx->environment !== Config::environment()->value) {
|
|
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
|
|
return;
|
|
}
|
|
|
|
// Self-recovery guard: skip only when an IDENTICAL submission already delivered
|
|
// recently. Scoped to the content dedupe key so a customer's different event
|
|
// (different key) is never blocked.
|
|
$recent = $store->recent_delivered_by_dedupe($tx->dedupe_key, Submission::DEDUPE_WINDOW);
|
|
if ($recent !== null && $recent->id !== $tx->id) {
|
|
$store->append_event($tx_id, 'Resubmit skipped - an identical submission already has a recent quote.');
|
|
return;
|
|
}
|
|
|
|
$body = json_decode($tx->redacted_payload, true);
|
|
if (!is_array($body)) {
|
|
$store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error');
|
|
return;
|
|
}
|
|
|
|
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
|
|
if ($result->ok && $result->quote_ref !== null) {
|
|
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
|
Failure_Surface::bump();
|
|
|
|
if ($tx->entry_id) {
|
|
Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment);
|
|
} else {
|
|
// Stranded lead (no entry) - mint a fresh link and email the customer (option B).
|
|
$fresh = (string) $result->url_single_use;
|
|
if ($fresh === '') {
|
|
$fresh = (string) (new Api_Client())->continue_quote($result->quote_ref)->url_single_use;
|
|
}
|
|
if ($fresh !== '') {
|
|
(new Notifier())->customer_quote_ready_email($tx, $fresh);
|
|
}
|
|
}
|
|
} else {
|
|
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
|
Failure_Surface::bump();
|
|
}
|
|
}
|
|
}
|