Files
hub-insurance/wp-content/plugins/esp-instanda-integration/includes/class-transactions-table.php
T
zbatteandClaude Fable 5 5d2e9cb7e5 fix(instanda): hygiene pass + v1.1.0 (uninstall, notices, cron, resubmit, theme)
- F-15: uninstall now removes ALL espinstanda_* options and transients (incl. the
  encrypted password and its self-provisioned key).
- F-16: register the "Gravity Forms required" admin notice unconditionally so it is
  reachable when GF is inactive (gform_loaded never fires then).
- F-22: self-heal the daily retention cron on admin_init (matches the schema self-heal).
- F-19: re-validate the stored payload via Field_Mapper::validate_payload() before a
  resubmit (DB-tamper trust boundary).
- F-20: increment the retry count on resubmit and dispatch the previously-dead
  espinstanda_resubmit_success notification event.
- F-23: emit the theme's Form-5 date-range JS only on pages that embed a Gravity Form
  and disconnect the MutationObserver (was observing the DOM subtree forever).
- F-24: bump plugin to 1.1.0; add readme.txt changelog documenting the audit fixes and
  the required ops actions.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-02 16:20:10 -06:00

211 lines
9.0 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(get_date_from_gmt($item->created_at, 'Y-m-d H:i')) . '<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;
}
// Trust boundary: the stored body is untrusted input on the way back out to the
// insurer - re-validate it before re-sending (guards against DB tampering).
$payload_errors = (new Field_Mapper())->validate_payload($body);
if ($payload_errors) {
$store->append_event($tx_id, 'Resubmit aborted - stored payload failed re-validation: ' . implode(', ', $payload_errors), 'error');
return;
}
$store->increment_retry($tx_id); // this attempt is a retry - keep the count truthful
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
if ($result->ok && $result->quote_ref !== null && $result->quote_ref !== '') {
$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);
self::notify_resubmit_success((int) $tx->entry_id);
} 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();
}
}
/** Dispatch the registered espinstanda_resubmit_success notification event for an entry. */
private static function notify_resubmit_success(int $entry_id): void {
if (!class_exists('\GFAPI')) {
return;
}
$entry = \GFAPI::get_entry($entry_id);
if (is_wp_error($entry)) {
return;
}
$form = \GFAPI::get_form((int) $entry['form_id']);
if ($form) {
\GFAPI::send_notifications($form, $entry, 'espinstanda_resubmit_success');
}
}
}