This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,198 @@
<?php
/**
* Api_Client - the INSTANDA HTTP client. Basic auth, bounded transient-only retry,
* never logs the auth header or password, and asserts the environment before any
* mutating call so a Live call can never be made while locked to Test.
*
* INSTANDA has no server-side idempotency, so an ambiguous timeout on a mutating
* call (request dispatched, response lost) is flagged - never auto-retried.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
/** Outcome of one INSTANDA call. */
final readonly class Api_Result {
/** @param list<string> $error_messages */
public function __construct(
public bool $ok,
public ?int $http_status,
public ?string $quote_ref,
public ?string $url_single_use,
public array $error_messages,
public bool $is_transient,
public bool $is_ambiguous,
public string $response_excerpt,
) {}
public static function success(int $status, ?string $ref, ?string $url, string $excerpt): self {
return new self(true, $status, $ref, $url, [], false, false, $excerpt);
}
/** @param list<string> $errors */
public static function failure(
?int $status,
array $errors,
string $excerpt,
bool $transient = false,
bool $ambiguous = false
): self {
return new self(false, $status, null, null, $errors, $transient, $ambiguous, $excerpt);
}
public function error_string(): string {
if ($this->error_messages) {
return implode('; ', $this->error_messages);
}
if ($this->http_status !== null) {
return 'HTTP ' . $this->http_status;
}
return $this->is_ambiguous ? 'Ambiguous timeout' : 'Request failed';
}
}
final class Api_Client {
private const TIMEOUT = 8; // seconds
private const MAX_ATTEMPTS = 2; // 1 try + 1 retry
private const RETRY_BUDGET_SEC = 3.0;
private const BACKOFF_USEC = 800000;
private const ALERT_401_AFTER = 3;
/** PUT /StartQuote - the one mutating create. */
public function start_quote(array $body, string $dedupe_key): Api_Result {
Logger::debug("StartQuote dispatch (dedupe {$dedupe_key})");
$url = Config::base_url() . 'StartQuote?siteDomainName=' . rawurlencode(Config::site_domain());
return $this->request('PUT', $url, null, $body, true);
}
/** GET /ContinueQuote - re-mint a fresh single-use URL from a stored quoteRef. */
public function continue_quote(string $quote_ref): Api_Result {
$url = Config::base_url() . 'ContinueQuote?'
. 'quoteRef=' . rawurlencode($quote_ref)
. '&siteDomainName=' . rawurlencode(Config::site_domain())
. '&targetSaleStage=Quote';
return $this->request('GET', $url, null, null, true);
}
/**
* GET /swagger - read-only auth + reachability probe. Creates nothing.
* Accepts an explicit environment so Live credentials can be tested while the
* plugin is still locked to Test (the only call allowed to target a non-active env).
*/
public function test_connection(?Environment $env = null, ?Credentials $cred = null): Api_Result {
$env = $env ?? Config::environment();
$url = $env->base_url() . 'swagger';
return $this->request('GET', $url, $env, null, false, $cred);
}
/* ----------------------------------------------------------------- core */
private function request(string $method, string $url, ?Environment $env, ?array $body, bool $mutating, ?Credentials $cred = null): Api_Result {
$env = $env ?? Config::environment();
// Outbound assertion (defense in depth): never make a mutating Live call while locked.
if ($mutating && Config::operating_mode() === 'test' && $env === Environment::Live) {
throw new \RuntimeException('Blocked a mutating Live call while operating mode is locked to Test.');
}
$cred = $cred ?? Config::credentials($env);
$args = [
'method' => $method,
'timeout' => self::TIMEOUT,
'headers' => [
'Authorization' => $cred->basic_auth_header(),
'Accept' => 'application/json',
],
];
if ($body !== null) {
$args['headers']['Content-Type'] = 'application/json';
$args['body'] = wp_json_encode($body);
}
$attempts = 0;
$started = microtime(true);
$response = null;
$transient = false;
do {
$attempts++;
$response = wp_remote_request($url, $args);
$transient = false;
if (!is_wp_error($response)) {
$code = (int) wp_remote_retrieve_response_code($response);
if ($code < 500) {
break; // 2xx or 4xx - deterministic, never retry
}
$transient = true; // 5xx - retryable
} else {
$transient = true; // transport/timeout - retryable
}
if ($attempts >= self::MAX_ATTEMPTS || (microtime(true) - $started) > self::RETRY_BUDGET_SEC) {
break;
}
Logger::debug("Transient error - retry {$attempts}/" . (self::MAX_ATTEMPTS - 1));
usleep(self::BACKOFF_USEC);
} while (true);
return $this->interpret($response, $mutating, $transient);
}
private function interpret(mixed $response, bool $mutating, bool $transient): Api_Result {
// Transport error / timeout: a mutating call is ambiguous (may have been delivered).
if (is_wp_error($response)) {
$msg = Logger::redact($response->get_error_message());
Logger::error("INSTANDA transport error: {$msg}");
return Api_Result::failure(null, [$msg], $msg, true, $mutating);
}
$code = (int) wp_remote_retrieve_response_code($response);
$raw = (string) wp_remote_retrieve_body($response);
$excerpt = Logger::redact(substr($raw, 0, 500));
$data = json_decode($raw, true);
$data = is_array($data) ? $data : [];
if ($code >= 200 && $code < 300) {
$ref = isset($data['quoteRef']) ? (string) $data['quoteRef'] : null;
$url = isset($data['urlSingleUse']) ? (string) $data['urlSingleUse'] : null;
return Api_Result::success($code, $ref, $url, $excerpt);
}
if ($code === 401) {
$this->note_401();
}
$errors = $this->parse_errors($data);
if (!$errors) {
$errors = ["HTTP {$code}"];
}
Logger::error("INSTANDA HTTP {$code}: " . implode('; ', $errors));
// 5xx after retries on a mutating call is ambiguous; 4xx is deterministic.
$ambiguous = $mutating && $code >= 500;
return Api_Result::failure($code, $errors, $excerpt, $transient, $ambiguous);
}
/** Handle both StartQuote ("Error Messages") and AbandonQuote ("Errors") envelopes. */
private function parse_errors(array $data): array {
foreach (['Error Messages', 'Errors', 'errors'] as $key) {
if (isset($data[$key]) && is_array($data[$key])) {
return array_map(static fn ($e) => (string) $e, $data[$key]);
}
}
return [];
}
private function note_401(): void {
$count = (int) get_transient('espinstanda_401_count') + 1;
set_transient('espinstanda_401_count', $count, HOUR_IN_SECONDS);
if ($count >= self::ALERT_401_AFTER && class_exists(__NAMESPACE__ . '\\Notifier')) {
(new Notifier())->credential_failure_alert();
}
}
}
@@ -0,0 +1,161 @@
<?php
/**
* Boost_Compat - keeps Gravity Forms' front-end scripts out of Jetpack Boost's
* JavaScript optimizations, which otherwise break the form.
*
* Two independent Boost modules each break GF in a different way, so each needs a
* different, primary-source-verified hook (verified against Automattic/jetpack
* trunk, Boost ~3.x/4.x):
*
* 1. "Concatenate JS" (Minify) bundles enqueued scripts. Its saved exclude list
* lives in Boost's Data Sync store with NO public PHP filter, but the engine
* consults `js_do_concat` (bool, $handle) per script at runtime - returning
* false there force-excludes a handle from the bundle. The
* data-jetpack-boost="ignore" attribute does NOT affect concatenation.
*
* 2. "Defer / Render Blocking JS" relocates <script> tags to just before </body>
* and skips any tag carrying data-jetpack-boost="ignore". We stamp that
* attribute onto GF tags via `script_loader_tag`, and additionally feed GF's
* known handles to Boost's own `jetpack_boost_render_blocking_js_exclude_handles`
* filter (which also covers GF's inline before/after blocks).
*
* When GF's core script (gform_gravityforms - it defines window.gform /
* gform.addAction) is deferred or concatenated out of order, the inline init runs
* before gform exists ("gform.addAction is not a function") and the AJAX submit /
* post-submit redirect never fire. Protecting the gform / gravityforms handle
* family (plus reCAPTCHA and jQuery) fixes both, form- and version-agnostically.
*
* Every hook here is a no-op when Boost is absent (the Boost-specific filters never
* fire; the script_loader_tag attribute is inert HTML), so the shim is always safe.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Boost_Compat {
/**
* Handle prefixes that identify Gravity Forms' own scripts across versions
* (2.5 Orbital -> 2.10): the file names drift but these handle stems are stable.
*/
private const GF_PREFIXES = ['gform', 'gforms', 'gravityforms', 'gravity_form'];
/**
* Exact extra handles to protect beyond the GF pattern: GF's hard dependencies
* that, if deferred, silently break the form (deferring jQuery alone makes GF
* forms disappear), plus the reCAPTCHA loader.
*/
private const EXTRA_HANDLES = [
'jquery', 'jquery-core', 'jquery-migrate',
'google-recaptcha',
// GF's JS layer uses the wp.* runtime; protect the whole closure so a
// protected script never runs before a deferred dependency (e.g. wp-a11y
// calling wp-dom-ready/wp-i18n before they load).
'wp-hooks', 'wp-i18n', 'wp-a11y', 'wp-dom-ready', 'wp-polyfill',
];
/**
* GF handles to hand to Boost's defer exclude-handles filter. script_loader_tag
* only sees the main tag; this list lets Boost also exempt the inline-attached
* (-js-before / -js-after) blocks bound to these handles.
*/
private const GF_KNOWN_HANDLES = [
'gform_gravityforms', 'gform_gravityforms_utils', 'gform_gravityforms_vendors',
'gform_gravityforms_theme', 'gform_gravityforms_theme_reset',
'gform_conditional_logic', 'gform_datepicker_init', 'gform_recaptcha',
];
/** Wire the compatibility filters. Called from Instanda_AddOn::init(). */
public static function register(): void {
// Concatenate JS - the only runtime hook Boost's concat engine consults.
add_filter('js_do_concat', [self::class, 'filter_js_do_concat'], 10, 2);
// Defer / Render Blocking JS - stamp the ignore attribute on the front end only
// (the attribute is meaningless in wp-admin and Boost never defers there).
if (!is_admin()) {
add_filter('script_loader_tag', [self::class, 'filter_script_loader_tag'], 10, 2);
}
// Secondary defer safety: Boost's own handle-list exclusion, which also
// covers GF's inline before/after script blocks.
add_filter('jetpack_boost_render_blocking_js_exclude_handles', [self::class, 'filter_exclude_handles']);
}
/**
* The protected exact-handle set, filterable so the site can extend it (e.g. a
* custom GF add-on with an unusual handle) without editing the plugin.
*
* @return list<string>
*/
public static function protected_handles(): array {
$handles = self::EXTRA_HANDLES;
if (function_exists('apply_filters')) {
$handles = (array) apply_filters('esp_instanda_boost_protected_handles', $handles);
}
return array_values(array_filter(array_map('strval', $handles)));
}
/** Is this a Gravity Forms (or GF hard-dependency) script handle that must not be optimized? */
public static function is_protected(string $handle): bool {
$h = strtolower($handle);
if ($h === '') {
return false;
}
foreach (self::GF_PREFIXES as $prefix) {
if (str_starts_with($h, $prefix)) {
return true;
}
}
if (str_contains($h, 'gravityforms')) {
return true;
}
foreach (self::protected_handles() as $exact) {
if ($h === strtolower($exact)) {
return true;
}
}
return false;
}
/**
* `js_do_concat` filter: returning false keeps the handle out of Boost's
* concatenated JS bundle. Unrelated handles pass through unchanged.
*/
public static function filter_js_do_concat($do_concat, $handle = ''): bool {
if (is_string($handle) && self::is_protected($handle)) {
return false;
}
return (bool) $do_concat;
}
/**
* `script_loader_tag` filter: stamp data-jetpack-boost="ignore" onto GF tags so
* Boost's Defer module leaves them in place. Idempotent and byte-preserving for
* everything else.
*/
public static function filter_script_loader_tag($tag, $handle = '') {
if (!is_string($tag) || !is_string($handle) || !self::is_protected($handle)) {
return $tag;
}
if (str_contains($tag, 'data-jetpack-boost')) {
return $tag; // already marked - never double-stamp
}
return str_ireplace('<script', '<script data-jetpack-boost="ignore"', $tag);
}
/**
* `jetpack_boost_render_blocking_js_exclude_handles` filter: append GF's known
* handles (and the protected extras) to Boost's defer exclusion list. Deduped;
* tolerant of a non-array input.
*
* @return list<string>
*/
public static function filter_exclude_handles($handles): array {
$base = is_array($handles) ? array_map('strval', $handles) : [];
$add = array_merge(self::GF_KNOWN_HANDLES, self::protected_handles());
return array_values(array_unique(array_merge($base, $add)));
}
}
@@ -0,0 +1,248 @@
<?php
/**
* Config - the single source of truth for environment, credentials, and the
* operating-mode lock. This is the enforcement core of the test-mode posture.
*
* Resolution order for every value: wp-config.php constant > stored option > default.
* While the operating mode is "test" (the default), environment() is CLAMPED to Test
* regardless of any stored env value, so no code path can construct a Live call until
* an explicit, gated switch to Live - see the Go-Live checklist.
*
* Secrets entered in the admin UI are encrypted at rest with libsodium
* (sodium_crypto_secretbox, XSalsa20-Poly1305). The key comes from the
* SPG_INSTANDA_KEY constant, so a database dump alone never exposes a usable secret.
* Live credentials are expected as constants and are never written to the database.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
/** INSTANDA environment - selects the base URL path segment and credential pair. */
enum Environment: string {
case Test = 'test';
case Live = 'live';
public function base_url(): string {
$segment = $this === self::Live ? 'Live' : 'Test';
return "https://api.instanda.us/{$segment}/v0.1/" . Config::PACKAGE . '/';
}
public function label(): string {
return $this === self::Live ? 'LIVE' : 'TEST';
}
}
/** Immutable credential pair that never leaks the password through casts/dumps. */
final readonly class Credentials {
public function __construct(
public string $username,
public string $password,
) {}
public function is_complete(): bool {
return $this->username !== '' && $this->password !== '';
}
public function basic_auth_header(): string {
return 'Basic ' . base64_encode($this->username . ':' . $this->password);
}
public function __toString(): string {
return $this->username . ':***';
}
/** Keep the password out of var_dump / error backtraces. */
public function __debugInfo(): array {
return ['username' => $this->username, 'password' => '***'];
}
}
final class Config {
public const PACKAGE = 'Package25536';
private const OPTION_PREFIX = 'espinstanda_';
private const SECRET_KEY_OPTION = 'espinstanda_secret_key';
/* --------------------------------------------------------- operating mode */
/** 'test' (locked) or 'live' (enabled). Constant > option > default 'test'. */
public static function operating_mode(): string {
$mode = self::const_val('SPG_INSTANDA_OPERATING_MODE') ?? (string) self::option('operating_mode', 'test');
return $mode === 'live' ? 'live' : 'test';
}
public static function operating_mode_pinned(): bool {
return self::const_val('SPG_INSTANDA_OPERATING_MODE') !== null;
}
/* --------------------------------------------------------- environment */
/**
* The effective environment. While operating mode is 'test', this is clamped to
* Test no matter what env value is stored - the production-safety floor.
*/
public static function environment(): Environment {
if (self::operating_mode() === 'test') {
return Environment::Test;
}
return self::raw_env() === 'live' ? Environment::Live : Environment::Test;
}
/** The selected env value before the operating-mode clamp (constant > option). */
private static function raw_env(): string {
$env = self::const_val('SPG_INSTANDA_ENV') ?? (string) self::option('env', 'test');
return $env === 'live' ? 'live' : 'test';
}
public static function base_url(): string {
return self::environment()->base_url();
}
public static function site_domain(): string {
return self::const_val('SPG_INSTANDA_SITEDOMAIN') ?? (string) self::option('site_domain', '');
}
/* --------------------------------------------------------- credentials */
/** Credentials for a given environment (default: the active one). Constant > decrypted option. */
public static function credentials(?Environment $env = null): Credentials {
$env = $env ?? self::environment();
$suffix = $env === Environment::Live ? 'LIVE' : 'TEST';
$key = strtolower($suffix); // 'live' | 'test'
$username = self::username_const($suffix)
?? (string) self::option("username_{$key}", '');
$password_const = self::const_val("SPG_INSTANDA_PASSWORD_{$suffix}");
if ($password_const !== null) {
$password = $password_const;
} else {
$stored = (string) self::option("password_{$key}", '');
$password = $stored !== '' ? (self::decrypt($stored) ?? '') : '';
}
return new Credentials($username, $password);
}
/**
* Resolve a username constant for an env suffix (TEST|LIVE), accepting BOTH the
* plugin's canonical SPG_INSTANDA_USERNAME_{ENV} and the INSTANDA solution doc's
* SPG_INSTANDA_USER_{ENV} spelling. Canonical wins when both are set. This prevents
* a silent cutover failure when ops follows INSTANDA's doc verbatim.
*/
private static function username_const(string $suffix): ?string {
return self::const_val("SPG_INSTANDA_USERNAME_{$suffix}")
?? self::const_val("SPG_INSTANDA_USER_{$suffix}");
}
/** True when a username constant (either spelling) is set for the env suffix (TEST|LIVE). */
public static function username_is_constant(string $suffix): bool {
return self::username_const(strtoupper($suffix)) !== null;
}
/** True when Live credentials are supplied via constants (the required Live posture). */
public static function live_credentials_are_constants(): bool {
return self::username_is_constant('LIVE')
&& self::const_val('SPG_INSTANDA_PASSWORD_LIVE') !== null;
}
/** True when the given credential field is supplied by a constant (so the UI locks it). */
public static function field_is_constant(string $field): bool {
return self::const_val($field) !== null;
}
/* --------------------------------------------------------- encryption */
public static function encryption_available(): bool {
return function_exists('sodium_crypto_secretbox') && self::key() !== null;
}
/** Encrypt a UI secret for at-rest storage. Refuses (throws) when no key is configured. */
public static function encrypt(string $plaintext): string {
$key = self::key();
if ($key === null) {
throw new \RuntimeException(
'SPG_INSTANDA_KEY is missing or invalid; cannot encrypt. Use wp-config.php constants for credentials instead.'
);
}
$nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = sodium_crypto_secretbox($plaintext, $nonce, $key);
return base64_encode($nonce . $cipher);
}
/** Decrypt a stored secret. Returns null (never throws) on any tamper/format/auth failure. */
public static function decrypt(string $ciphertext): ?string {
$key = self::key();
if ($key === null) {
return null;
}
$raw = base64_decode($ciphertext, true);
$min = SODIUM_CRYPTO_SECRETBOX_NONCEBYTES + SODIUM_CRYPTO_SECRETBOX_MACBYTES;
if ($raw === false || strlen($raw) < $min) {
return null;
}
$nonce = substr($raw, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$cipher = substr($raw, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES);
$plain = sodium_crypto_secretbox_open($cipher, $nonce, $key);
return $plain === false ? null : $plain;
}
private static function key(): ?string {
$b64 = self::const_val('SPG_INSTANDA_KEY') ?? self::db_key();
if ($b64 === null) {
return null;
}
$key = base64_decode($b64, true);
if ($key === false || strlen($key) !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) {
return null;
}
return $key;
}
/**
* Self-provisioned encryption key, used only when no SPG_INSTANDA_KEY constant is set.
* Generated once and stored in the DB (autoload off) so the UI credential path works
* without a manual wp-config edit. Live credentials come from constants and never touch
* the DB, so this key only ever protects DB-stored Test secrets. For the hardened posture
* (a DB dump alone never exposes a usable secret) set the SPG_INSTANDA_KEY constant - it wins.
*/
private static function db_key(): ?string {
if (!function_exists('get_option')) {
return null;
}
$existing = get_option(self::SECRET_KEY_OPTION);
if (is_string($existing) && $existing !== '') {
return $existing;
}
if (!function_exists('sodium_crypto_secretbox') || !function_exists('update_option')) {
return null;
}
$generated = base64_encode(random_bytes(SODIUM_CRYPTO_SECRETBOX_KEYBYTES));
update_option(self::SECRET_KEY_OPTION, $generated, false);
return $generated;
}
/* --------------------------------------------------------- low-level seams */
/** A defined, non-empty constant's value, else null. */
private static function const_val(string $name): ?string {
if (defined($name)) {
$value = (string) constant($name);
if ($value !== '') {
return $value;
}
}
return null;
}
/** A prefixed plugin option. M5 mirrors GF settings into these on save. */
private static function option(string $key, mixed $default): mixed {
if (function_exists('get_option')) {
return get_option(self::OPTION_PREFIX . $key, $default);
}
return $default;
}
}
@@ -0,0 +1,32 @@
<?php
/**
* Cron_Cleanup - daily retention purge. Removes only confirmed-delivered records
* past the retention window, across both environments. Pending/failed are never
* purged on a timer.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Cron_Cleanup {
public static function run(): void {
$days = (int) get_option('espinstanda_retention_days', 7);
if ($days <= 0) {
return; // retention disabled
}
$store = new Transaction_Store();
$deleted = 0;
foreach ([Environment::Test, Environment::Live] as $env) {
$deleted += $store->purge_delivered_older_than($days, $env);
}
if ($deleted > 0) {
Logger::debug("Retention purge removed {$deleted} delivered transaction(s) older than {$days} day(s).");
}
}
}
@@ -0,0 +1,74 @@
<?php
/**
* Entry_Integration - records the INSTANDA result against the native Gravity Forms
* entry: registered entry meta (sortable, exportable Entries columns) and the
* {instanda_quote_ref} / {instanda_quote_url} merge tags. The meta values are
* written by the add-on's process_feed(); this class registers and reads them.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Entry_Integration {
public const META_REF = 'instanda_quote_ref';
public const META_URL = 'instanda_quote_url';
public const META_STATUS = 'instanda_status';
public const META_ENV = 'instanda_environment';
/** gform_entry_meta - registers our meta as Entries-list columns. */
public function register_entry_meta(array $entry_meta, int $form_id): array {
$entry_meta[self::META_REF] = [
'label' => 'INSTANDA quoteRef',
'is_numeric' => false,
'is_default_column' => true,
];
$entry_meta[self::META_STATUS] = [
'label' => 'INSTANDA status',
'is_numeric' => false,
'is_default_column' => true,
];
$entry_meta[self::META_URL] = [
'label' => 'INSTANDA quote URL',
'is_numeric' => false,
'is_default_column' => false,
];
$entry_meta[self::META_ENV] = [
'label' => 'INSTANDA environment',
'is_numeric' => false,
'is_default_column' => false,
];
return $entry_meta;
}
/** gform_custom_merge_tags - advertise our merge tags in the editor. */
public function register_merge_tags(array $merge_tags, int $form_id, array $fields, int $element_id): array {
$merge_tags[] = ['label' => 'INSTANDA quote ref', 'tag' => '{instanda_quote_ref}'];
$merge_tags[] = ['label' => 'INSTANDA quote URL', 'tag' => '{instanda_quote_url}'];
return $merge_tags;
}
/** gform_replace_merge_tags - resolve our merge tags from entry meta. */
public function replace_merge_tags(string $text, $form, $entry, $url_encode, $esc_html, $nl2br, $format): string {
if (!is_array($entry) || empty($entry['id'])) {
return $text;
}
$ref = (string) gform_get_meta((int) $entry['id'], self::META_REF);
$url = (string) gform_get_meta((int) $entry['id'], self::META_URL);
$text = str_replace('{instanda_quote_ref}', esc_html($ref), $text);
$text = str_replace('{instanda_quote_url}', esc_url($url), $text);
return $text;
}
/** Write the result to the entry. Called from the add-on's process_feed(). */
public static function record(int $entry_id, string $quote_ref, string $quote_url, string $status, string $environment): void {
gform_update_meta($entry_id, self::META_REF, $quote_ref);
gform_update_meta($entry_id, self::META_URL, $quote_url);
gform_update_meta($entry_id, self::META_STATUS, $status);
gform_update_meta($entry_id, self::META_ENV, $environment);
}
}
@@ -0,0 +1,60 @@
<?php
/**
* Failure_Surface - makes unresolved failures impossible to miss even if email
* fails: an admin-bar badge and an admin notice with the count of failed/pending
* records in the active environment. The count is cached in a short transient and
* invalidated on any transaction status write.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Failure_Surface {
private const CACHE_KEY = 'espinstanda_failure_count';
private const CACHE_TTL = 300;
public function admin_bar_badge(\WP_Admin_Bar $bar): void {
$count = $this->count();
if ($count <= 0) {
return;
}
$bar->add_node([
'id' => 'espinstanda-failures',
'title' => sprintf('INSTANDA: %d need attention', $count),
'href' => admin_url('admin.php?page=espinstanda-transactions&status=failed'),
'meta' => ['class' => 'espinstanda-failure-badge'],
]);
}
public function unresolved_notice(): void {
$count = $this->count();
if ($count <= 0) {
return;
}
$url = admin_url('admin.php?page=espinstanda-transactions&status=failed');
printf(
'<div class="notice notice-error"><p><strong>ESP INSTANDA:</strong> %d submission(s) need attention. <a href="%s">Review</a></p></div>',
$count,
esc_url($url)
);
}
public function count(): int {
$cached = get_transient(self::CACHE_KEY);
if ($cached !== false) {
return (int) $cached;
}
$count = (new Transaction_Store())->unresolved_count();
set_transient(self::CACHE_KEY, $count, self::CACHE_TTL);
return $count;
}
/** Invalidate the cached count after any status write. */
public static function bump(): void {
delete_transient(self::CACHE_KEY);
}
}
@@ -0,0 +1,168 @@
<?php
/**
* Field_Mapper - pure transform from the Gravity Forms "Start a Quote" values to
* the INSTANDA StartQuote wire body, plus server-side re-validation.
*
* Pure by design: a flat array in, an array out. No WordPress, Gravity Forms,
* database, or HTTP access - the caller extracts GF field values (via GF field-value
* APIs, never raw $_POST) and hands them here. This keeps every transform rule
* (D1-D11 in the INSTANDA workbook) unit-testable with no bootstrap.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Field_Mapper {
/** Role catalogue values (workbook "Role" sheet) - byte-exact, drive every rule. */
public const ROLE_EVENT_HOST = 'Event Host or Organizer';
public const ROLE_VENDOR = 'Vendor, Exhibitor or Subcontractor';
public const ROLE_PERFORMER = 'Performer, Entertainer or DJ';
/**
* Build the INSTANDA StartQuote body. Assumes the input has already passed
* validate(); third-party choice values are transmitted verbatim (no normalization).
*
* @param array<string,mixed> $values Keyed: role, vendor_type, performer_type,
* activity_type, location_state, max_attendance, event_start, event_end, customer_email.
* @return array<string,mixed> INSTANDA wire variables (VendorType omitted per D1).
*/
public function to_payload(array $values, string $timezone): array {
$tz = new \DateTimeZone($timezone);
$start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz);
$end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz);
$payload = [
'Role' => (string) ($values['role'] ?? ''),
'ActivityType' => (string) ($values['activity_type'] ?? ''),
'PQ_LocationState_CHOICE' => (string) ($values['location_state'] ?? ''),
'PQ_MaximumDailyAttendance_NUM' => (int) ($values['max_attendance'] ?? 0),
'PQ_EventStart_DATE' => $start ? $start->format('Y-m-d') : '',
'PQ_EventEnd_DATE' => $end ? $end->format('Y-m-d') : '',
'PQ_EventConsecutiveDays_NUM' => ($start && $end) ? $this->consecutive_days($start, $end) : 0,
'PQ_CustomerEmailAddress_TXT' => trim((string) ($values['customer_email'] ?? '')),
];
// D1/D4 - VendorType is conditional and never sent empty.
$vendor_type = $this->resolve_vendor_type($values);
if ($vendor_type !== null) {
$payload['VendorType'] = $vendor_type;
}
return $payload;
}
/**
* Server-side re-validation. Returns a list of stable error codes (empty = valid)
* that the submission layer maps to friendly inline messages.
*
* @param array<string,mixed> $values See to_payload().
* @param \DateTimeImmutable|null $now Injectable clock for the "start in the past"
* check; defaults to now in $timezone.
* @return list<string>
*/
public function validate(array $values, string $timezone, ?\DateTimeImmutable $now = null): array {
$errors = [];
$tz = new \DateTimeZone($timezone);
$role = (string) ($values['role'] ?? '');
if ($role === '') {
$errors[] = 'role_required';
}
// D2/D3 - the role's sub-type dropdown is required when that role is selected.
if ($role === self::ROLE_VENDOR && (string) ($values['vendor_type'] ?? '') === '') {
$errors[] = 'vendor_type_required';
}
if ($role === self::ROLE_PERFORMER && (string) ($values['performer_type'] ?? '') === '') {
$errors[] = 'performer_type_required';
}
if ((string) ($values['activity_type'] ?? '') === '') {
$errors[] = 'activity_type_required';
}
if ((string) ($values['location_state'] ?? '') === '') {
$errors[] = 'location_required';
}
// D10 - attendance is a positive integer.
if (!$this->is_positive_integer($values['max_attendance'] ?? null)) {
$errors[] = 'attendance_not_positive_integer';
}
// D9 - a well-formed email.
$email = trim((string) ($values['customer_email'] ?? ''));
if ($email === '') {
$errors[] = 'email_required';
} elseif (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
$errors[] = 'email_invalid';
}
// D6/D7 - both dates required, YYYY-MM-DD, end >= start, start today-or-later (site tz).
$start = $this->parse_date((string) ($values['event_start'] ?? ''), $tz);
$end = $this->parse_date((string) ($values['event_end'] ?? ''), $tz);
if ($start === null) {
$errors[] = 'start_required';
}
if ($end === null) {
$errors[] = 'end_required';
}
if ($start !== null && $end !== null) {
if ($end < $start) {
$errors[] = 'end_before_start';
}
$today_midnight = ($now ?? new \DateTimeImmutable('now', $tz))
->setTimezone($tz)
->setTime(0, 0, 0);
if ($start < $today_midnight) {
$errors[] = 'start_in_past';
}
}
return $errors;
}
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
return (int) $start->diff($end)->days + 1;
}
/** D1/D4 - resolve the one VendorType value, or null to omit the key entirely. */
private function resolve_vendor_type(array $values): ?string {
$role = (string) ($values['role'] ?? '');
if ($role === self::ROLE_EVENT_HOST) {
return null; // D1 - omit, never empty string.
}
// D4 - trust the role, then read whichever sub-type field that role populates.
$value = $role === self::ROLE_PERFORMER
? (string) ($values['performer_type'] ?? '')
: (string) ($values['vendor_type'] ?? '');
return $value !== '' ? $value : null;
}
/** Parse a strict YYYY-MM-DD date at midnight in $tz; null if missing or malformed. */
private function parse_date(string $value, \DateTimeZone $tz): ?\DateTimeImmutable {
$value = trim($value);
if ($value === '') {
return null;
}
$date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value, $tz);
// createFromFormat is lenient on overflow (e.g. 2026-13-40); reject anything
// that does not round-trip to the exact input.
if ($date === false || $date->format('Y-m-d') !== $value) {
return null;
}
return $date;
}
private function is_positive_integer(mixed $value): bool {
if (is_int($value)) {
return $value > 0;
}
if (is_string($value)) {
$value = trim($value);
return $value !== '' && ctype_digit($value) && (int) $value > 0;
}
return false;
}
}
@@ -0,0 +1,213 @@
<?php
/**
* Go_Live - the test-mode gate. Computes the blocking (B1-B5) and advisory checklist
* items, stores acknowledgements, and performs the capability-gated, audited switch
* from "locked to Test" to "live-enabled". The switch is only possible when every
* blocking item passes; rolling back to Test is always available.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Go_Live {
public const ROLES = [
'event_host' => 'Event Host or Organizer',
'vendor' => 'Vendor, Exhibitor or Subcontractor',
'performer' => 'Performer, Entertainer or DJ',
];
private const FRESHNESS = 1800; // a Test probe counts as "passing" for 30 minutes
/** @return list<array{key:string,label:string,detail:string,pass:bool}> */
public function blocking_items(): array {
$pricing = $this->pricing_confirmed();
$items = [
[
'key' => 'B1',
'label' => 'Live credentials present as wp-config.php constants',
'detail' => Config::live_credentials_are_constants()
? 'Detected'
: 'Not configured - add SPG_INSTANDA_USERNAME_LIVE (or SPG_INSTANDA_USER_LIVE) + SPG_INSTANDA_PASSWORD_LIVE',
'pass' => Config::live_credentials_are_constants(),
],
[
'key' => 'B2',
'label' => 'Test connection passing',
'detail' => $this->probe_detail('espinstanda_test_probe_ok'),
'pass' => $this->probe_fresh('espinstanda_test_probe_ok'),
],
[
'key' => 'B3',
'label' => 'Live credentials authenticate',
'detail' => $this->probe_detail('espinstanda_live_probe_ok'),
'pass' => $this->probe_fresh('espinstanda_live_probe_ok'),
],
[
'key' => 'B4',
'label' => 'Dropdown workbook synced (byte-exact)',
'detail' => $this->ack_detail('espinstanda_workbook_ack'),
'pass' => (bool) get_option('espinstanda_workbook_ack'),
],
[
'key' => 'B5',
'label' => 'Pricing confirmed per role path (' . count($pricing) . ' of 3)',
'detail' => $pricing ? 'Confirmed: ' . implode(', ', $pricing) : 'No role paths confirmed yet',
'pass' => count($pricing) === 3,
],
[
'key' => 'B6',
'label' => 'siteDomainName set to a valid host',
'detail' => $this->site_domain_detail(),
'pass' => $this->site_domain_valid(),
],
];
return $items;
}
/** INSTANDA's two documented consumer/agent journey hosts (a custom domain is also allowed). */
private const ACCEPTED_DOMAINS = ['consumer.instanda.us', 'agent.instanda.us'];
/** siteDomainName is a required StartQuote/ContinueQuote query param: must be a real host. */
private function site_domain_valid(): bool {
$domain = Config::site_domain();
if ($domain === '') {
return false;
}
// Accept the two documented hosts, or any plausible bare hostname (INSTANDA may
// configure a custom consumer domain). Reject schemes, paths, and empties.
return in_array($domain, self::ACCEPTED_DOMAINS, true)
|| (bool) preg_match('/^(?!-)[A-Za-z0-9-]+(\.[A-Za-z0-9-]+)+$/', $domain);
}
private function site_domain_detail(): string {
$domain = Config::site_domain();
if ($domain === '') {
return 'Not set - required query param (recommended: consumer.instanda.us)';
}
if (in_array($domain, self::ACCEPTED_DOMAINS, true)) {
return $domain;
}
return $this->site_domain_valid() ? $domain . ' (custom host - confirm with INSTANDA)' : 'Invalid host: ' . $domain;
}
/** @return list<array{key:string,label:string,detail:string,pass:bool}> */
public function advisory_items(): array {
$retention = (int) get_option('espinstanda_retention_days', 7);
$storage = get_option('espinstanda_storage_enabled', '1') === '1';
$emails = trim((string) get_option('espinstanda_alert_emails', ''));
$unresolved = (new Transaction_Store())->unresolved_count();
return [
['key' => 'A1', 'label' => 'Storage on + retention set', 'detail' => $storage ? "On, {$retention} day(s)" : 'Off', 'pass' => $storage && $retention > 0],
['key' => 'A2', 'label' => 'Alert email recipient(s)', 'detail' => $emails !== '' ? $emails : 'None set', 'pass' => $emails !== ''],
['key' => 'A3', 'label' => 'Front-end form submit + redirect verified in a browser', 'detail' => $this->ack_detail('espinstanda_frontend_ack'), 'pass' => (bool) get_option('espinstanda_frontend_ack')],
['key' => 'A4', 'label' => 'No unresolved failures', 'detail' => $unresolved === 0 ? 'Clean' : "{$unresolved} unresolved", 'pass' => $unresolved === 0],
];
}
/** @return array{complete:int,total:int,ready:bool} */
public function readiness(): array {
$blocking = $this->blocking_items();
$complete = count(array_filter($blocking, static fn ($i) => $i['pass']));
return [
'complete' => $complete,
'total' => count($blocking),
'ready' => $complete === count($blocking),
];
}
public function can_switch_to_live(): bool {
return $this->readiness()['ready'] && !Config::operating_mode_pinned();
}
public function switch_to_live(): bool {
if (!\GFCommon::current_user_can_any('espinstanda_settings') || !$this->can_switch_to_live()) {
return false;
}
update_option('espinstanda_operating_mode', 'live');
update_option('espinstanda_operating_mode_audit', [
'actor' => get_current_user_id(),
'ts' => current_time('mysql'),
'snapshot' => $this->readiness(),
]);
Logger::debug('Operating mode switched to LIVE by user ' . get_current_user_id());
return true;
}
public function return_to_test(): bool {
if (!\GFCommon::current_user_can_any('espinstanda_settings') || Config::operating_mode_pinned()) {
return false;
}
update_option('espinstanda_operating_mode', 'test');
update_option('espinstanda_env', 'test');
Logger::debug('Operating mode returned to TEST by user ' . get_current_user_id());
return true;
}
/* -------------------------------------------------------- acknowledgements */
public function acknowledge_workbook(): void {
update_option('espinstanda_workbook_ack', [
'actor' => get_current_user_id(),
'ts' => current_time('mysql'),
]);
}
public function acknowledge_frontend(): void {
update_option('espinstanda_frontend_ack', [
'actor' => get_current_user_id(),
'ts' => current_time('mysql'),
]);
}
public function confirm_role_pricing(string $role_key): void {
if (!isset(self::ROLES[$role_key])) {
return;
}
$confirmed = (array) get_option('espinstanda_pricing_ack', []);
$confirmed[$role_key] = ['ts' => current_time('mysql'), 'actor' => get_current_user_id()];
update_option('espinstanda_pricing_ack', $confirmed);
}
public function record_probe(string $option_key, bool $ok): void {
update_option($option_key, ['ok' => $ok, 'ts' => time()]);
}
/** @return list<string> human labels of confirmed roles */
private function pricing_confirmed(): array {
$confirmed = (array) get_option('espinstanda_pricing_ack', []);
$labels = [];
foreach (self::ROLES as $key => $label) {
if (isset($confirmed[$key])) {
$labels[] = $label;
}
}
return $labels;
}
private function probe_fresh(string $option_key): bool {
$probe = get_option($option_key);
return is_array($probe) && !empty($probe['ok']) && (time() - (int) ($probe['ts'] ?? 0)) < self::FRESHNESS;
}
private function probe_detail(string $option_key): string {
$probe = get_option($option_key);
if (!is_array($probe) || empty($probe['ts'])) {
return 'Not run yet';
}
$ago = human_time_diff((int) $probe['ts']);
return (!empty($probe['ok']) ? '200 OK' : 'Failed') . ", {$ago} ago";
}
private function ack_detail(string $option_key): string {
$ack = get_option($option_key);
if (!is_array($ack) || empty($ack['ts'])) {
return 'Not acknowledged';
}
return 'Acknowledged ' . $ack['ts'];
}
}
@@ -0,0 +1,54 @@
<?php
/**
* Guard - wraps every hook callback so a Throwable can never white-screen the
* site or admin (requirement #5). The error is logged and surfaced, then the
* call fails safe.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Guard {
/**
* Wrap a callback so any Throwable is caught, reported, and swallowed.
*
* Fail-safe return: the first argument unchanged. For filters (gform_validation,
* gform_confirmation, gform_entry_meta, ...) this passes the filtered value
* through untouched, so a bug in our code never corrupts another plugin's data
* or blocks a submission. For actions the value is simply ignored.
*/
public static function wrap(callable $callback): callable {
return static function (mixed ...$args) use ($callback): mixed {
try {
return $callback(...$args);
} catch (\Throwable $e) {
self::report($e);
return $args[0] ?? null;
}
};
}
/** Log a Throwable with full context and fan it out to the failure surface (wired in M9). */
public static function report(\Throwable $e): void {
$message = sprintf(
'ESP INSTANDA: %s in %s:%d',
$e->getMessage(),
$e->getFile(),
$e->getLine()
);
if (class_exists('\GFCommon')) {
\GFCommon::log_error($message);
} elseif (function_exists('error_log')) {
error_log($message);
}
if (function_exists('do_action')) {
do_action('esp_instanda_throwable', $e);
}
}
}
@@ -0,0 +1,632 @@
<?php
/**
* Instanda_AddOn - the Gravity Forms Feed Add-On orchestration hub. The framework
* provides the settings page, per-form feed mapping, capabilities, logging, and
* clean uninstall; this class wires our submission flow, native recording, the
* test-mode admin screens, and the credential-mirroring seam to Config.
*
* Note: methods overriding GFFeedAddOn keep GF's untyped signatures intentionally.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
\GFForms::include_feed_addon_framework();
final class Instanda_AddOn extends \GFFeedAddOn {
protected $_version = '1.0.7';
protected $_min_gravityforms_version = '2.10.3';
protected $_slug = 'espinstanda';
protected $_path = 'esp-instanda-integration/esp-instanda-integration.php';
protected $_full_path = __FILE__;
protected $_title = 'ESP INSTANDA Start Quote';
protected $_short_title = 'INSTANDA';
protected $_async_feed_processing = true;
protected $_capabilities_settings_page = 'espinstanda_settings';
protected $_capabilities_form_settings = 'espinstanda_feed';
protected $_capabilities_uninstall = 'espinstanda_uninstall';
protected $_capabilities = ['espinstanda_settings', 'espinstanda_feed', 'espinstanda_uninstall', 'espinstanda_transactions'];
private const SECRET_MASK = "\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}\u{2022}";
private static $_instance = null;
private ?Submission $submission = null;
private ?Entry_Integration $entry = null;
public static function get_instance() {
if (self::$_instance === null) {
self::$_instance = new self();
}
return self::$_instance;
}
private function submission(): Submission {
return $this->submission ??= new Submission();
}
private function entry(): Entry_Integration {
return $this->entry ??= new Entry_Integration();
}
/* ------------------------------------------------------------------ init */
public function init() {
parent::init();
add_filter('gform_validation', Guard::wrap([$this->submission(), 'validate']));
add_filter('gform_validation_message', Guard::wrap([$this->submission(), 'validation_message']), 10, 2);
add_filter('gform_confirmation', Guard::wrap([$this->submission(), 'confirmation']), 10, 4);
add_filter('gform_entry_meta', Guard::wrap([$this->entry(), 'register_entry_meta']), 10, 2);
add_filter('gform_custom_merge_tags', Guard::wrap([$this->entry(), 'register_merge_tags']), 10, 4);
add_filter('gform_replace_merge_tags', Guard::wrap([$this->entry(), 'replace_merge_tags']), 10, 7);
add_filter('gform_notification_events', Guard::wrap([new Notifier(), 'register_events']), 10, 2);
add_action('gform_entry_created', Guard::wrap([$this, 'on_entry_created']), 10, 2);
add_action('esp_instanda_resubmit', Guard::wrap([Resubmit_Handler::class, 'run']));
// Keep Gravity Forms' scripts out of Jetpack Boost's JS concat/defer, which
// otherwise break GF's AJAX init and the post-submit INSTANDA redirect.
Boost_Compat::register();
}
public function init_admin() {
parent::init_admin();
add_action('admin_notices', Guard::wrap([new Failure_Surface(), 'unresolved_notice']));
add_action('admin_bar_menu', Guard::wrap([new Failure_Surface(), 'admin_bar_badge']), 100);
add_action('admin_notices', static function (): void {
if (get_transient('espinstanda_secret_save_error')) {
echo '<div class="notice notice-error"><p><strong>ESP INSTANDA:</strong> The Test password could not be saved - no encryption key is available. Add <code>SPG_INSTANDA_KEY</code> to wp-config.php, or ensure the libsodium PHP extension is enabled.</p></div>';
}
});
add_action('admin_menu', [$this, 'register_admin_pages'], 20);
}
/**
* GFAddOn dispatches admin-ajax.php requests to init_ajax() (NOT init_admin), so the
* wp_ajax_* handlers MUST be registered here or they never hook on an AJAX request
* (admin-ajax then returns HTTP 400 for the unregistered action).
*/
public function init_ajax() {
parent::init_ajax();
add_action('wp_ajax_espinstanda_test_connection', Guard::wrap([$this, 'ajax_test_connection']));
add_action('wp_ajax_espinstanda_resubmit', Guard::wrap([$this, 'ajax_resubmit']));
}
public function get_menu_icon() {
return 'gform-icon--cog';
}
/* ---------------------------------------------------------- plugin settings */
public function plugin_settings_fields() {
$go_live = new Go_Live();
$readiness = $go_live->readiness();
return [
[
'title' => 'Operating mode',
'fields' => [
[
'name' => 'mode_banner',
'type' => 'html',
'html' => $this->mode_banner_html($readiness),
],
[
'name' => 'env',
'label' => 'Active environment',
'type' => 'radio',
'horizontal' => true,
'default_value' => 'test',
'choices' => [
['label' => 'TEST', 'value' => 'test'],
['label' => Config::operating_mode() === 'live' ? 'LIVE' : 'LIVE (locked - complete the go-live checklist)', 'value' => 'live'],
],
'tooltip' => 'While locked to Test, the Live environment cannot be selected. Maps to SPG_INSTANDA_ENV.',
],
$this->maybe_const_field('site_domain', 'Site domain (siteDomainName)', 'SPG_INSTANDA_SITEDOMAIN', 'consumer.instanda.us'),
],
],
[
'title' => 'Credentials (Basic auth)',
'fields' => [
Config::username_is_constant('TEST')
? ['name' => 'username_test_const', 'label' => 'Test username', 'type' => 'html', 'html' => 'Set via the Test username constant (<code>SPG_INSTANDA_USERNAME_TEST</code> or <code>SPG_INSTANDA_USER_TEST</code>) in wp-config.php. <span class="espinstanda-tag">Detected</span>']
: ['name' => 'username_test', 'label' => 'Test username', 'type' => 'text', 'default_value' => ''],
[
'name' => 'password_test',
'label' => 'Test password',
'type' => Config::field_is_constant('SPG_INSTANDA_PASSWORD_TEST') ? 'html' : 'espinstanda_secret',
'html' => 'Set via <code>SPG_INSTANDA_PASSWORD_TEST</code> in wp-config.php. <span class="espinstanda-tag">Detected</span>',
],
[
'name' => 'live_credentials',
'type' => 'html',
'html' => $this->live_credentials_html(),
],
[
'name' => 'test_connection',
'label' => 'Test connection',
'type' => 'espinstanda_test_connection',
],
],
],
[
'title' => 'Submission storage & retention',
'fields' => [
['name' => 'storage_enabled', 'label' => 'Store submissions for resubmit', 'type' => 'toggle', 'default_value' => true],
['name' => 'retention_days', 'label' => 'Retention (days)', 'type' => 'text', 'input_type' => 'number', 'default_value' => '7', 'class' => 'small-text',
'tooltip' => 'Only delivered records are purged after this window. Failed/pending are kept until resolved.'],
],
],
[
'title' => 'Failure alerts',
'fields' => [
['name' => 'alert_emails', 'label' => 'Validation-failure alert emails', 'type' => 'textarea', 'class' => 'medium',
'tooltip' => 'Comma-separated. Used for failures that happen before an entry exists (sent with wp_mail).'],
],
],
];
}
/** A text field that locks to a "managed in wp-config.php" note when its constant is set. */
private function maybe_const_field(string $name, string $label, string $constant, string $default = ''): array {
if (Config::field_is_constant($constant)) {
return [
'name' => $name . '_const',
'label' => $label,
'type' => 'html',
'html' => 'Set via <code>' . esc_html($constant) . '</code> in wp-config.php. <span class="espinstanda-tag">Detected</span>',
];
}
return ['name' => $name, 'label' => $label, 'type' => 'text', 'default_value' => $default];
}
private function mode_banner_html(array $readiness): string {
$locked = Config::operating_mode() === 'test';
$class = $locked ? 'notice-warning' : ($readiness['ready'] ? 'notice-success' : 'notice-warning');
$msg = $locked
? '<strong>Test mode.</strong> This plugin is locked to the INSTANDA <strong>TEST</strong> environment. Live is disabled until the go-live checklist is complete.'
: '<strong>Live enabled.</strong> Set the environment to LIVE to send real customer quotes.';
$link = '<a href="' . esc_url(admin_url('admin.php?page=espinstanda-go-live')) . '">View go-live checklist &rarr;</a>';
return '<div class="notice ' . $class . ' inline" style="margin:0 0 10px;padding:8px 12px">' . $msg . ' ' . $link
. '<br><small>Readiness: ' . (int) $readiness['complete'] . ' of ' . (int) $readiness['total'] . ' blocking items complete.</small></div>';
}
private function live_credentials_html(): string {
if (Config::live_credentials_are_constants()) {
return '<div class="notice notice-success inline" style="padding:8px 12px;margin:0">Live credentials detected via <code>wp-config.php</code> constants. <span class="espinstanda-tag">Detected</span></div>';
}
return '<div class="notice notice-warning inline" style="padding:8px 12px;margin:0">Live credentials are not configured. When ready for production, add a Live username constant (<code>SPG_INSTANDA_USERNAME_LIVE</code> or <code>SPG_INSTANDA_USER_LIVE</code>) and <code>SPG_INSTANDA_PASSWORD_LIVE</code> to <code>wp-config.php</code>. Live credentials are never stored in the database.</div>';
}
/* ------------------------------------------------- custom settings fields */
public function settings_espinstanda_secret($field, $echo = true) {
$name = $field['name'];
$value = $this->get_plugin_setting($name);
$placeholder = $value ? self::SECRET_MASK : '';
$html = sprintf(
'<input type="password" name="_gaddon_setting_%1$s" value="%2$s" class="regular-text" autocomplete="new-password" placeholder="%3$s" />',
esc_attr($name),
esc_attr($placeholder),
esc_attr($placeholder)
);
$note = Config::encryption_available()
? '<p class="gaddon-setting-description">Encrypted at rest with libsodium; masked and never echoed back.</p>'
: '<p class="gaddon-setting-description" style="color:#b32d2e">SPG_INSTANDA_KEY is not set - enter Test credentials via constants instead, or add the key to wp-config.php.</p>';
$html .= $note;
if ($echo) {
echo $html;
}
return $html;
}
public function settings_espinstanda_test_connection($field, $echo = true) {
$nonce = wp_create_nonce('espinstanda_test_connection');
$live_disabled = Config::live_credentials_are_constants() ? '' : 'disabled title="Add Live constants to wp-config.php first"';
$html = '<button type="button" class="button button-primary" id="espinstanda-test-conn" data-nonce="' . esc_attr($nonce) . '" data-env="active">Test connection</button> '
. '<button type="button" class="button" id="espinstanda-test-live" data-nonce="' . esc_attr($nonce) . '" data-env="live" ' . $live_disabled . '>Test Live credentials</button> '
. '<span id="espinstanda-test-result" style="margin-left:10px"></span>'
. '<p class="gaddon-setting-description">Performs an authenticated, read-only probe (GET /swagger). Never creates a quote.</p>'
. $this->test_connection_js();
if ($echo) {
echo $html;
}
return $html;
}
private function test_connection_js(): string {
$ajax = admin_url('admin-ajax.php');
return <<<JS
<script>
(function(){
function run(btn){
var out=document.getElementById('espinstanda-test-result');
out.textContent='Probing…';
var data=new FormData();
data.append('action','espinstanda_test_connection');
data.append('_ajax_nonce',btn.getAttribute('data-nonce'));
data.append('env',btn.getAttribute('data-env'));
if(btn.getAttribute('data-env')!=='live'){
var u=document.querySelector('[name="_gaddon_setting_username_test"]');
var p=document.querySelector('[name="_gaddon_setting_password_test"]');
// Send typed creds only when the password was freshly entered (not the bullet mask, charCode 8226).
if(u && p && p.value && p.value.charCodeAt(0)!==8226){ data.append('u',u.value); data.append('p',p.value); }
}
fetch('{$ajax}',{method:'POST',body:data,credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(j){ out.textContent = (j&&j.data&&j.data.message)?j.data.message:(j.success?'OK':'Failed'); })
.catch(function(){ out.textContent='Network error'; });
}
['espinstanda-test-conn','espinstanda-test-live'].forEach(function(id){
var b=document.getElementById(id);
if(b) b.addEventListener('click',function(){run(b);});
});
})();
</script>
JS;
}
/** Mirror UI settings into standalone options Config reads; encrypt secrets; never store plaintext. */
public function update_plugin_settings($settings) {
$mode_locked = Config::operating_mode() === 'test';
$env = ($settings['env'] ?? 'test') === 'live' && !$mode_locked ? 'live' : 'test';
update_option('espinstanda_env', $env);
if (!Config::field_is_constant('SPG_INSTANDA_SITEDOMAIN')) {
update_option('espinstanda_site_domain', $settings['site_domain'] ?? '');
}
if (!Config::username_is_constant('TEST')) {
update_option('espinstanda_username_test', $settings['username_test'] ?? '');
}
update_option('espinstanda_storage_enabled', !empty($settings['storage_enabled']) ? '1' : '0');
update_option('espinstanda_retention_days', max(0, (int) ($settings['retention_days'] ?? 7)));
update_option('espinstanda_alert_emails', $settings['alert_emails'] ?? '');
// Secret: only overwrite when a new value (not the mask) was entered.
$secret = (string) ($settings['password_test'] ?? '');
if ($secret !== '' && $secret !== self::SECRET_MASK && !Config::field_is_constant('SPG_INSTANDA_PASSWORD_TEST')) {
if (Config::encryption_available()) {
update_option('espinstanda_password_test', Config::encrypt($secret));
delete_transient('espinstanda_secret_save_error');
} else {
// No silent failure: surface why the password did not persist.
set_transient('espinstanda_secret_save_error', 1, 600);
Logger::error('Test password not saved: no encryption key available (set SPG_INSTANDA_KEY or enable sodium).');
}
}
$settings['password_test'] = ''; // never persist plaintext in the GF settings blob
parent::update_plugin_settings($settings);
return $settings; // the Settings framework persists the returned values
}
/* ------------------------------------------------------------ feed settings */
public function feed_settings_fields() {
return [
[
'title' => 'Feed settings',
'fields' => [
['name' => 'feedName', 'label' => 'Feed name', 'type' => 'text', 'default_value' => 'INSTANDA Start Quote', 'required' => true],
],
],
[
'title' => 'Field mapping',
'description' => 'Map each INSTANDA wire variable to a form field. Choice values must match the INSTANDA workbook exactly. PQ_EventConsecutiveDays_NUM is derived automatically.',
'fields' => [
$this->map('role', 'Role (required)'),
$this->map('vendor_type', 'VendorType - Vendor branch'),
$this->map('performer_type', 'VendorType - Performer branch'),
$this->map('activity_type', 'ActivityType (required)'),
$this->map('location_state', 'PQ_LocationState_CHOICE (required)'),
$this->map('max_attendance', 'PQ_MaximumDailyAttendance_NUM (required)'),
$this->map('event_start', 'PQ_EventStart_DATE (required)'),
$this->map('event_end', 'PQ_EventEnd_DATE (required)'),
$this->map('customer_email', 'PQ_CustomerEmailAddress_TXT (required)'),
],
],
];
}
private function map(string $name, string $label): array {
return ['name' => $name, 'label' => $label, 'type' => 'field_select'];
}
public function feed_list_columns() {
return ['feedName' => 'Name'];
}
/** One INSTANDA feed per form - every submission of that form is sent. */
public function can_create_feed() {
return count($this->get_feeds(rgget('id') ? (int) rgget('id') : null)) === 0;
}
/* -------------------------------------------------- native recording (success) */
public function on_entry_created($entry, $form) {
$dedupe = Submission::current_dedupe();
if (!$dedupe) {
return;
}
$store = new Transaction_Store();
$tx = $store->find_by_dedupe($dedupe);
if ($tx !== null && $tx->status === Tx_Status::Delivered->value) {
$store->attach_entry($tx->id, (int) $entry['id']);
}
}
/** RECORD only - never calls the INSTANDA API. Fail-safe: a throw here is logged, not fatal. */
public function process_feed($feed, $entry, $form) {
try {
$this->record_feed_result($entry, $form);
} catch (\Throwable $e) {
Guard::report($e);
}
}
private function record_feed_result($entry, $form) {
$store = new Transaction_Store();
$entry_id = (int) $entry['id'];
$tx = $store->find_by_entry($entry_id);
if ($tx === null) {
$dedupe = Submission::current_dedupe();
if ($dedupe) {
$tx = $store->find_by_dedupe($dedupe);
if ($tx !== null) {
$store->attach_entry($tx->id, $entry_id);
}
}
}
if ($tx === null || $tx->quote_ref === null) {
return; // nothing delivered to record (defensive)
}
Entry_Integration::record($entry_id, $tx->quote_ref, (string) $tx->quote_url, 'delivered', $tx->environment);
if (class_exists('\GFFormsModel')) {
\GFFormsModel::add_note($entry_id, 0, 'INSTANDA Add-On', 'INSTANDA: quote created - quoteRef ' . $tx->quote_ref);
}
if (class_exists('\GFAPI')) {
\GFAPI::send_notifications($form, $entry, 'espinstanda_quote_success'); // @verify-on-site
}
}
/* ------------------------------------------------------------ admin pages */
public function register_admin_pages() {
add_submenu_page(
'gf_edit_forms',
'INSTANDA Transactions',
'INSTANDA Transactions',
'gform_full_access',
'espinstanda-transactions',
[$this, 'render_transactions_page']
);
$readiness = (new Go_Live())->readiness();
$suffix = $readiness['ready'] ? '' : ' (' . ((int) $readiness['total'] - (int) $readiness['complete']) . ' to go)';
add_submenu_page(
'gf_edit_forms',
'INSTANDA Go-live',
'INSTANDA Go-live' . $suffix,
'gform_full_access',
'espinstanda-go-live',
[$this, 'render_go_live_page']
);
}
public function render_transactions_page() {
if (!\GFCommon::current_user_can_any('espinstanda_transactions')) {
wp_die('Insufficient permissions.');
}
$tx_id = (int) ($_GET['tx'] ?? 0);
echo '<div class="wrap">';
if ($tx_id > 0) {
$this->render_transaction_detail($tx_id);
} else {
echo '<h1>INSTANDA Transactions</h1>';
$table = new Transactions_List_Table();
$table->prepare_items();
echo '<form method="get"><input type="hidden" name="page" value="espinstanda-transactions" />';
$table->views();
$table->display();
echo '</form>';
echo $this->resubmit_js();
}
echo '</div>';
}
private function render_transaction_detail(int $tx_id) {
$tx = (new Transaction_Store())->find($tx_id);
if ($tx === null) {
echo '<p>Transaction not found.</p>';
return;
}
echo '<h1>Transaction #' . (int) $tx->id . ' <span class="espinstanda-badge ' . esc_attr($tx->status) . '">' . esc_html(ucfirst($tx->status)) . '</span></h1>';
if ($tx->possible_duplicate) {
echo '<div class="notice notice-warning"><p>This submission may already have created a quote in INSTANDA. Resubmitting will create a second quote. Confirm there is no existing quote for this customer before resubmitting.</p></div>';
}
if ($tx->environment !== Config::environment()->value) {
echo '<div class="notice notice-warning"><p>This transaction was created in <strong>' . esc_html(strtoupper($tx->environment)) . '</strong> but the plugin is currently set to <strong>' . esc_html(strtoupper(Config::environment()->value)) . '</strong>. Resubmit is blocked.</p></div>';
}
echo '<h2>Event log</h2><ul class="espinstanda-timeline">';
foreach ($tx->event_log as $e) {
echo '<li><code>' . esc_html($e['ts'] ?? '') . '</code> ' . esc_html($e['message'] ?? '') . '</li>';
}
echo '</ul>';
echo '<h2>Request payload <small>(secrets redacted)</small></h2>';
echo '<pre style="background:#1d2327;color:#e2e3e8;padding:12px;border-radius:5px;overflow:auto">' . esc_html($tx->redacted_payload) . '</pre>';
echo '<h2>Summary</h2><table class="widefat striped" style="max-width:520px"><tbody>';
$rows = [
'Status' => ucfirst($tx->status), 'Environment' => strtoupper($tx->environment),
'quoteRef' => $tx->quote_ref ?? '—', 'HTTP' => $tx->http_status ?? '—',
'Retries' => $tx->retry_count, 'Dedupe key' => $tx->dedupe_key,
'Customer' => $tx->customer_email, 'Created' => $tx->created_at,
];
foreach ($rows as $k => $v) {
echo '<tr><th>' . esc_html($k) . '</th><td>' . esc_html((string) $v) . '</td></tr>';
}
echo '</tbody></table>';
}
public function render_go_live_page() {
if (!\GFCommon::current_user_can_any('espinstanda_settings')) {
wp_die('Insufficient permissions.');
}
$go_live = new Go_Live();
$this->handle_go_live_post($go_live);
$readiness = $go_live->readiness();
echo '<div class="wrap"><h1>Go-live checklist</h1>';
echo '<p>Everything that must be true before this integration sends real customer quotes to INSTANDA Live. Blocking items must all pass before Live can be enabled. This is reversible.</p>';
echo '<p><strong>Readiness:</strong> ' . (int) $readiness['complete'] . ' of ' . (int) $readiness['total'] . ' blocking items complete. Operating mode: <strong>' . esc_html(strtoupper(Config::operating_mode())) . '</strong>, env <strong>' . esc_html(strtoupper(Config::environment()->value)) . '</strong>.</p>';
$this->render_checklist('Blocking items', $go_live->blocking_items());
$this->render_checklist('Advisory items (recommended - do not block)', $go_live->advisory_items());
echo '<p style="background:#fff8e5;border-left:4px solid #dba617;padding:8px 12px;max-width:860px">'
. '<strong>Before you switch:</strong> the read-only probes (B2/B3) prove credentials <em>authenticate</em> - not that a Live <em>StartQuote</em> renders a priced quote - and they expire after 30 minutes, so re-run "Test connection" / "Test Live credentials" just before switching. After switching to Live, complete one watched real submit per role, confirm the redirect lands on a <strong>priced</strong> quote, then re-confirm pricing (B5) on Live.'
. '</p>';
echo '<form method="post">';
wp_nonce_field('espinstanda_go_live');
echo '<p>';
echo '<button class="button" name="espinstanda_action" value="ack_workbook">Acknowledge workbook synced</button> ';
echo '<button class="button" name="espinstanda_action" value="ack_frontend">Confirm front-end submit + redirect verified</button> ';
foreach (Go_Live::ROLES as $key => $label) {
echo '<button class="button" name="espinstanda_action" value="pricing_' . esc_attr($key) . '">Confirm pricing: ' . esc_html($label) . '</button> ';
}
echo '</p><p>';
if (Config::operating_mode_pinned()) {
echo '<em>Operating mode is pinned in wp-config.php. Change it there to cut over.</em>';
} elseif ($go_live->can_switch_to_live()) {
echo '<button class="button button-primary" name="espinstanda_action" value="switch_live" onclick="return confirm(\'Enable Live submissions? Real customer quotes will be created in INSTANDA Live.\')">Switch to Live…</button>';
} else {
echo '<button class="button" disabled title="Complete all blocking items first">Switch to Live…</button>';
}
if (Config::operating_mode() === 'live' && !Config::operating_mode_pinned()) {
echo ' <button class="button" name="espinstanda_action" value="return_test">Return to Test mode</button>';
}
echo '</p></form></div>';
}
private function render_checklist(string $title, array $items) {
echo '<h2>' . esc_html($title) . '</h2><table class="widefat striped"><thead><tr><th>Status</th><th>Item</th><th>Detail</th></tr></thead><tbody>';
foreach ($items as $item) {
$badge = $item['pass'] ? '<span style="color:#007017">&#10003; Pass</span>' : '<span style="color:#b32d2e">&#9888; Pending</span>';
echo '<tr><td>' . $badge . '</td><td>' . esc_html($item['label']) . '</td><td>' . esc_html($item['detail']) . '</td></tr>';
}
echo '</tbody></table>';
}
private function handle_go_live_post(Go_Live $go_live) {
if (empty($_POST['espinstanda_action']) || !check_admin_referer('espinstanda_go_live')) {
return;
}
$action = sanitize_text_field((string) $_POST['espinstanda_action']);
if ($action === 'ack_workbook') {
$go_live->acknowledge_workbook();
} elseif ($action === 'ack_frontend') {
$go_live->acknowledge_frontend();
} elseif (str_starts_with($action, 'pricing_')) {
$go_live->confirm_role_pricing(substr($action, strlen('pricing_')));
} elseif ($action === 'switch_live') {
$go_live->switch_to_live();
} elseif ($action === 'return_test') {
$go_live->return_to_test();
}
}
/* ------------------------------------------------------------ AJAX */
public function ajax_test_connection() {
check_ajax_referer('espinstanda_test_connection');
if (!\GFCommon::current_user_can_any('espinstanda_settings')) {
wp_send_json_error(['message' => 'Forbidden'], 403);
}
$env_param = (string) ($_POST['env'] ?? 'active');
$env = $env_param === 'live' ? Environment::Live : Config::environment();
// If the form passed freshly typed credentials, probe those - so Test connection
// works before (or without) saving, and tests exactly what is on screen.
$typed_user = isset($_POST['u']) ? sanitize_text_field(wp_unslash((string) $_POST['u'])) : '';
$typed_pass = isset($_POST['p']) ? (string) wp_unslash((string) $_POST['p']) : '';
$cred = ($typed_user !== '' && $typed_pass !== '') ? new Credentials($typed_user, $typed_pass) : null;
$result = (new Api_Client())->test_connection($env, $cred);
(new Go_Live())->record_probe($env === Environment::Live ? 'espinstanda_live_probe_ok' : 'espinstanda_test_probe_ok', $result->ok);
if ($result->ok) {
wp_send_json_success(['message' => '✓ Authenticated & reachable (no quote created)']);
}
$msg = $result->http_status === 401
? 'Authentication failed (401) - check the username and password'
: ('Could not reach INSTANDA' . ($result->http_status ? ' (HTTP ' . $result->http_status . ')' : ''));
wp_send_json_error(['message' => $msg]);
}
public function ajax_resubmit() {
check_ajax_referer('espinstanda_resubmit');
if (!\GFCommon::current_user_can_any('espinstanda_transactions')) {
wp_send_json_error(['message' => 'Forbidden'], 403);
}
$tx_id = (int) ($_POST['tx_id'] ?? 0);
$confirmed = !empty($_POST['confirm_duplicate']);
$store = new Transaction_Store();
$tx = $store->find($tx_id);
if ($tx === null) {
wp_send_json_error(['message' => 'Not found']);
}
if ($tx->environment !== Config::environment()->value) {
wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']);
}
if ($tx->possible_duplicate && !$confirmed) {
wp_send_json_error(['message' => 'needs_confirmation']);
}
Resubmit_Handler::schedule($tx_id);
$store->append_event($tx_id, 'Resubmit queued (async).');
wp_send_json_success(['message' => 'Resubmit queued']);
}
private function resubmit_js(): string {
$nonce = wp_create_nonce('espinstanda_resubmit');
$ajax = admin_url('admin-ajax.php');
return <<<JS
<script>
(function(){
document.querySelectorAll('.espinstanda-resubmit').forEach(function(btn){
btn.addEventListener('click',function(){
var dup=btn.getAttribute('data-dup')==='1';
if(dup && !confirm('This may already have created a quote. Resubmitting will create a second quote. Continue?')) return;
var data=new FormData();
data.append('action','espinstanda_resubmit');
data.append('_ajax_nonce','{$nonce}');
data.append('tx_id',btn.getAttribute('data-tx'));
if(dup) data.append('confirm_duplicate','1');
btn.disabled=true; btn.textContent='Queued…';
fetch('{$ajax}',{method:'POST',body:data,credentials:'same-origin'})
.then(function(r){return r.json();})
.then(function(j){ btn.textContent=(j&&j.data&&j.data.message)?j.data.message:'Done'; })
.catch(function(){ btn.textContent='Error'; btn.disabled=false; });
});
});
})();
</script>
JS;
}
}
@@ -0,0 +1,44 @@
<?php
/**
* Logger - thin wrapper over Gravity Forms' add-on logging, with mandatory
* redaction on every write. The Basic auth header and any password must never
* reach a log line or a transaction record.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Logger {
public static function debug(string $message): void {
self::write('debug', $message);
}
public static function error(string $message): void {
self::write('error', $message);
}
private static function write(string $level, string $message): void {
$message = self::redact($message);
$addon = class_exists(__NAMESPACE__ . '\\Instanda_AddOn')
? Instanda_AddOn::get_instance()
: null;
if ($addon !== null) {
$level === 'error' ? $addon->log_error($message) : $addon->log_debug($message);
} elseif (function_exists('error_log')) {
error_log("ESP INSTANDA [{$level}] {$message}");
}
}
/** Strip Basic auth headers and password-like values from any string before it is written. */
public static function redact(string $text): string {
$text = (string) preg_replace('/(Authorization:\s*Basic\s+)\S+/i', '$1[redacted]', $text);
$text = (string) preg_replace('/("?(?:password|pass|pwd)"?\s*[:=]\s*)"?[^",\s}]+/i', '$1[redacted]', $text);
return $text;
}
}
@@ -0,0 +1,80 @@
<?php
/**
* Notifier - registers Gravity Forms notification events for entry-bound cases
* (success, resubmit) and sends wp_mail for validation-time failures and repeated
* 401s (cases with no entry). Every wp_mail is return-checked - a false is logged
* and escalated, never silently dropped.
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Notifier {
/** gform_notification_events - admins attach native Notifications to these. */
public function register_events(array $events, array $form): array {
$events['espinstanda_quote_success'] = 'INSTANDA - quote created';
$events['espinstanda_resubmit_success'] = 'INSTANDA - quote created on resubmit';
return $events;
}
public function validation_failure_alert(Transaction $tx, ?Api_Result $result = null): bool {
$recipients = $this->alert_recipients();
if (!$recipients) {
return false;
}
$error = $result !== null ? $result->error_string() : (string) $tx->error_detail;
$body = "A 'Start a Quote' submission failed before a Gravity Forms entry was created.\n\n"
. 'Environment: ' . strtoupper($tx->environment) . "\n"
. 'Customer: ' . $tx->customer_email . "\n"
. 'Role: ' . $tx->role_label . "\n"
. 'Error: ' . $error . "\n"
. 'Transaction: #' . $tx->id . "\n\n"
. 'Review and resubmit: ' . admin_url('admin.php?page=espinstanda-transactions&status=failed') . "\n";
return $this->send($recipients, '[ESP INSTANDA] Quote submission failed', $body);
}
public function credential_failure_alert(): bool {
$recipients = $this->alert_recipients();
if (!$recipients) {
return false;
}
return $this->send(
$recipients,
'[ESP INSTANDA] Repeated authentication failures (401)',
"INSTANDA returned repeated 401 Unauthorized responses. Check the API credentials on the INSTANDA settings screen.\n"
);
}
/** Option-B lead recovery: email the customer their freshly minted quote link. */
public function customer_quote_ready_email(Transaction $tx, string $fresh_url): bool {
if ($tx->customer_email === '' || $fresh_url === '') {
return false;
}
$body = "Thanks for starting a quote with ESP Specialty.\n\n"
. "Your quote is ready. Continue here:\n{$fresh_url}\n";
return $this->send([$tx->customer_email], 'Your ESP Specialty quote is ready', $body);
}
/** @return list<string> */
private function alert_recipients(): array {
$raw = (string) get_option('espinstanda_alert_emails', '');
$list = array_filter(array_map('trim', explode(',', $raw)), static fn ($e) => $e !== '' && is_email($e));
return array_values($list);
}
private function send(array $to, string $subject, string $body): bool {
$ok = wp_mail($to, $subject, $body);
if (!$ok) {
Logger::error('wp_mail failed for recipients: ' . implode(', ', $to));
if (function_exists('do_action')) {
do_action('esp_instanda_mail_failed', $to, $subject);
}
}
return (bool) $ok;
}
}
@@ -0,0 +1,316 @@
<?php
/**
* Submission - the no-loss / no-duplicate control core.
*
* The gated INSTANDA create lives in gform_validation (NOT process_feed) so that a
* failure can re-render the form with every value intact and save no entry. On
* success, validation passes, the entry saves, and the add-on's process_feed()
* records the result natively. gform_confirmation then redirects to the quote URL.
*
* Guards against duplicates: final-page-only + a once-per-submission lock +
* an internal dedupe key (INSTANDA has no server-side idempotency).
*
* @package ESP\Instanda
*/
declare(strict_types=1);
namespace ESP\Instanda;
final class Submission {
private const LOCK_TTL = 60; // seconds
private const RESULT_TTL = 120;
private const STASH_TTL = 300;
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
private const FIELD_KEYS = [
'role', 'vendor_type', 'performer_type', 'activity_type', 'location_state',
'max_attendance', 'event_start', 'event_end', 'customer_email',
];
private ?string $pending_message = null;
/** Request-scoped dedupe of the just-succeeded submission, for process_feed/entry-created. */
private static ?string $current_dedupe = null;
public static function current_dedupe(): ?string {
return self::$current_dedupe;
}
/* ----------------------------------------------------------- gform_validation */
/** @param array{is_valid:bool,form:array} $validation_result */
public function validate(array $validation_result): array {
$form = $validation_result['form'];
$feed = $this->active_feed($form);
if ($feed === null) {
return $validation_result; // GATE 1 - form has no INSTANDA feed
}
if (empty($validation_result['is_valid'])) {
return $validation_result; // GATE 2 - anti-spam / native validation already failed
}
if (!$this->is_last_page($form)) {
return $validation_result; // GATE 3 - only act on the final page
}
$token = $this->submission_token($form);
if (!$this->acquire_lock($token)) {
return $this->replay($token, $validation_result); // GATE 4 - duplicate fire: replay cached outcome
}
$dedupe = $this->dedupe_key($token);
$store = new Transaction_Store();
// Own double-fire guard: a prior attempt for this submission already delivered.
$existing = $store->find_by_dedupe($dedupe);
if ($existing !== null && $existing->status === Tx_Status::Delivered->value) {
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
return $this->remember($token, $validation_result, true);
}
$values = $this->extract_values($form, $feed);
$mapper = new Field_Mapper();
$tz = wp_timezone_string();
$errors = $mapper->validate($values, $tz);
if ($errors) {
$this->apply_field_errors($validation_result, $form, $feed, $errors);
return $this->remember($token, $validation_result, false);
}
$body = $mapper->to_payload($values, $tz);
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
$store_enabled = get_option('espinstanda_storage_enabled', '1') === '1';
$tx_id = 0;
if ($store_enabled) {
$tx = new Transaction();
$tx->form_id = (int) $form['id'];
$tx->environment = Config::environment()->value;
$tx->dedupe_key = $dedupe;
$tx->customer_email = (string) ($values['customer_email'] ?? '');
$tx->role_label = (string) ($values['role'] ?? '');
$tx->redacted_payload = (string) wp_json_encode($body);
$tx_id = $store->create_pending($tx);
}
$result = (new Api_Client())->start_quote($body, $dedupe);
if ($result->ok && $result->quote_ref !== null) {
if ($tx_id) {
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
}
$this->stash_success($dedupe, $result->quote_ref, $result->url_single_use);
return $this->remember($token, $validation_result, true);
}
// Failure / ambiguous timeout - input preserved, no entry saved.
if ($tx_id) {
if ($result->is_ambiguous) {
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
} else {
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
}
$stored = $store->find($tx_id);
if ($stored !== null) {
(new Notifier())->validation_failure_alert($stored, $result);
}
}
Failure_Surface::bump();
$this->pending_message = $this->friendly_failure_message($result);
$invalid = $this->mark_form_invalid($validation_result);
return $this->remember($token, $invalid, false);
}
/** gform_validation_message filter - surface our form-level failure message. */
public function validation_message(string $message, array $form): string {
return $this->pending_message !== null ? esc_html($this->pending_message) : $message;
}
/* ----------------------------------------------------------- gform_confirmation */
public function confirmation(mixed $confirmation, array $form, array $entry, bool $ajax): mixed {
if ($this->active_feed($form) === null) {
return $confirmation;
}
$url = (string) gform_get_meta((int) $entry['id'], 'instanda_quote_url');
if ($url === '') {
$stash = $this->read_stash_for_entry($form);
$url = $stash['url'] ?? '';
}
if ($url !== '') {
return ['redirect' => $url];
}
return $confirmation; // defensive: fall through to the form's default confirmation
}
/* ----------------------------------------------------------- success stash */
/** Stash by dedupe so process_feed (possibly async) can record without re-calling the API. */
private function stash_success(string $dedupe, string $quote_ref, ?string $url): void {
self::$current_dedupe = $dedupe;
set_transient('espinstanda_stash_' . md5($dedupe), [
'quote_ref' => $quote_ref,
'url' => (string) $url,
], self::STASH_TTL);
}
public function read_stash(string $dedupe): ?array {
$v = get_transient('espinstanda_stash_' . md5($dedupe));
return is_array($v) ? $v : null;
}
private function read_stash_for_entry(array $form): array {
// Best-effort: the confirmation runs in the same request as a fresh success.
$token = $this->submission_token($form);
$dedupe = $this->dedupe_key($token);
return $this->read_stash($dedupe) ?? [];
}
/* ----------------------------------------------------------- feed + values */
private function active_feed(array $form): ?array {
$addon = Instanda_AddOn::get_instance();
$feeds = $addon->get_feeds((int) $form['id']);
foreach ($feeds as $feed) {
if (!empty($feed['is_active'])) {
return $feed;
}
}
return null;
}
/** @return array<string,string> intent key => submitted value */
private function extract_values(array $form, array $feed): array {
$meta = $feed['meta'] ?? [];
$values = [];
foreach (self::FIELD_KEYS as $key) {
$field_id = (string) ($meta[$key] ?? '');
$values[$key] = $field_id !== '' ? $this->submitted_value($form, $field_id) : '';
}
return $values;
}
/** Read a submitted field value via GF's API (never raw $_POST). @verify-on-site */
private function submitted_value(array $form, string $field_id): string {
$field = \GFFormsModel::get_field($form, $field_id);
if ($field === null) {
return '';
}
$value = \GFFormsModel::get_field_value($field);
return is_array($value) ? trim((string) reset($value)) : trim((string) $value);
}
/* ----------------------------------------------------------- duplicate guards */
private function is_last_page(array $form): bool {
// @verify-on-site: GFFormDisplay::get_max_page_number returns 0 for single-page forms.
if (!class_exists('\GFFormDisplay')) {
return true;
}
$target = (int) rgpost('gform_target_page_number_' . $form['id']);
$max = (int) \GFFormDisplay::get_max_page_number($form);
return $max === 0 || $target === 0;
}
private function submission_token(array $form): string {
$unique = '';
if (class_exists('\GFFormsModel') && method_exists('\GFFormsModel', 'get_form_unique_id')) {
$unique = (string) \GFFormsModel::get_form_unique_id((int) $form['id']); // @verify-on-site
}
if ($unique === '') {
// Fallback: stable across re-validation of one submit, unique per real submit.
$unique = md5(($form['id'] ?? '') . '|' . (string) rgpost('gform_submit') . '|' . (string) wp_date('YmdHi'));
}
return 'espinstanda_' . $unique;
}
private function dedupe_key(string $token): string {
return substr('dk_' . md5($token), 0, 64);
}
private function acquire_lock(string $token): bool {
$key = 'lock_' . md5($token);
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
return true;
}
if (get_transient('espinstanda_' . $key)) {
return false;
}
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
return true;
}
private function remember(string $token, array $validation_result, bool $passed): array {
set_transient('espinstanda_res_' . md5($token), [
'passed' => $passed,
'message' => $this->pending_message,
], self::RESULT_TTL);
return $validation_result;
}
private function replay(string $token, array $validation_result): array {
$cached = get_transient('espinstanda_res_' . md5($token));
if (!is_array($cached)) {
return $validation_result; // unknown - let it pass through unchanged
}
if (empty($cached['passed'])) {
$this->pending_message = $cached['message'] ?? null;
return $this->mark_form_invalid($validation_result);
}
return $validation_result;
}
/* ----------------------------------------------------------- error rendering */
private function mark_form_invalid(array $validation_result): array {
$validation_result['is_valid'] = false;
return $validation_result;
}
/** Attach mapper error codes to their fields where possible; else a form-level message. */
private function apply_field_errors(array &$validation_result, array $form, array $feed, array $errors): void {
$meta = $feed['meta'] ?? [];
$map = [
'vendor_type_required' => ['vendor_type', 'Please choose a vendor type.'],
'performer_type_required' => ['performer_type', 'Please choose a performer type.'],
'activity_type_required' => ['activity_type', 'Please choose an activity type.'],
'location_required' => ['location_state', 'Please choose an event location.'],
'attendance_not_positive_integer' => ['max_attendance', 'Enter a positive number for daily attendance.'],
'start_required' => ['event_start', 'Please enter a valid start date.'],
'end_required' => ['event_end', 'Please enter a valid end date.'],
'end_before_start' => ['event_end', 'The end date must be on or after the start date.'],
'start_in_past' => ['event_start', 'The start date must be today or later.'],
'email_required' => ['customer_email', 'Please enter your email address.'],
'email_invalid' => ['customer_email', 'Please enter a valid email address.'],
'role_required' => ['role', 'Please choose an event role.'],
];
$validation_result['is_valid'] = false;
foreach ($validation_result['form']['fields'] as &$field) {
foreach ($errors as $code) {
if (!isset($map[$code])) {
continue;
}
[$intent, $message] = $map[$code];
$mapped_id = (string) ($meta[$intent] ?? '');
if ($mapped_id !== '' && (string) $field->id === $mapped_id) {
$field->failed_validation = true;
$field->validation_message = $message;
}
}
}
unset($field);
}
private function friendly_failure_message(Api_Result $result): string {
if ($result->is_ambiguous) {
return 'We could not confirm your quote. Please try again in a moment - we have saved your details.';
}
return 'We could not start your quote just now. Please review your entries and try again.';
}
}
@@ -0,0 +1,348 @@
<?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;
}
/**
* 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 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)];
}
}
@@ -0,0 +1,179 @@
<?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->environment !== Config::environment()->value) {
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
return;
}
// Self-recovery guard: skip if the customer already has a recent successful quote.
$recent = $store->recent_success_for_email($tx->customer_email, DAY_IN_SECONDS);
if ($recent !== null && $recent->id !== $tx->id) {
$store->append_event($tx_id, 'Resubmit skipped - customer already has a recent successful 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();
}
}
}