'transaction', 'plural' => 'transactions', 'ajax' => false]);
$this->store = new Transaction_Store();
}
public function get_columns(): array {
return [
'cb' => '',
'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('%s (%d)', 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('', $item->id);
}
protected function column_default($item, $column_name): string {
return match ($column_name) {
'submitted' => esc_html($item->created_at) . '
#' . $item->id . '',
'email' => esc_html($item->customer_email),
'role' => esc_html($item->role_label),
'status' => $this->status_badge($item),
'env' => '' . esc_html(strtoupper($item->environment)) . '',
'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 'Failed · possible dup';
}
$label = ucfirst($item->status);
return '' . esc_html($label) . '';
}
private function row_action_buttons(Transaction $item): string {
$detail = add_query_arg(['page' => 'espinstanda-transactions', 'tx' => $item->id], admin_url('admin.php'));
$view = sprintf('View log', 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(
'',
$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();
}
}
}