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'])); // Data-subject erasure: wire customer transactions into WP's Erase Personal Data tool. add_filter('wp_privacy_personal_data_erasers', Guard::wrap([$this, 'register_privacy_erasers'])); // 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 '

ESP INSTANDA: The Test password could not be saved - no encryption key is available. Add SPG_INSTANDA_KEY to wp-config.php, or ensure the libsodium PHP extension is enabled.

'; } }); 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 (SPG_INSTANDA_USERNAME_TEST or SPG_INSTANDA_USER_TEST) in wp-config.php. Detected'] : ['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 SPG_INSTANDA_PASSWORD_TEST in wp-config.php. Detected', ], [ '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 ' . esc_html($constant) . ' in wp-config.php. Detected', ]; } 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 ? 'Test mode. This plugin is locked to the INSTANDA TEST environment. Live is disabled until the go-live checklist is complete.' : 'Live enabled. Set the environment to LIVE to send real customer quotes.'; $link = 'View go-live checklist →'; return '
' . $msg . ' ' . $link . '
Readiness: ' . (int) $readiness['complete'] . ' of ' . (int) $readiness['total'] . ' blocking items complete.
'; } private function live_credentials_html(): string { if (Config::live_credentials_are_constants()) { return '
Live credentials detected via wp-config.php constants. Detected
'; } return '
Live credentials are not configured. When ready for production, add a Live username constant (SPG_INSTANDA_USERNAME_LIVE or SPG_INSTANDA_USER_LIVE) and SPG_INSTANDA_PASSWORD_LIVE to wp-config.php. Live credentials are never stored in the database.
'; } /* ------------------------------------------------- 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( '', esc_attr($name), esc_attr($placeholder), esc_attr($placeholder) ); $note = Config::encryption_available() ? '

Encrypted at rest with libsodium; masked and never echoed back.

' : '

SPG_INSTANDA_KEY is not set - enter Test credentials via constants instead, or add the key to wp-config.php.

'; $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 = ' ' . ' ' . '' . '

Performs an authenticated, read-only probe (GET /swagger). Never creates a quote.

' . $this->test_connection_js(); if ($echo) { echo $html; } return $html; } private function test_connection_js(): string { $ajax = admin_url('admin-ajax.php'); return << (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);}); }); })(); 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) { // No-silent-failure: an async feed with no resolvable delivered transaction means // entry meta / note / notification are being skipped - make that visible. Logger::error(sprintf( 'record_feed_result: no delivered transaction for entry #%d (dedupe %s); entry meta/notification not recorded.', $entry_id, (string) (Submission::current_dedupe() ?? 'n/a') )); return; } 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 } } /* ------------------------------------------------------------ privacy */ public function register_privacy_erasers($erasers) { $erasers['esp-instanda'] = [ 'eraser_friendly_name' => 'INSTANDA quote transactions', 'callback' => [$this, 'privacy_erase'], ]; return $erasers; } /** WP Erase Personal Data callback - deletes all INSTANDA transactions for an email. */ public function privacy_erase($email, $page = 1) { $removed = (new Transaction_Store())->delete_by_email((string) $email); return [ 'items_removed' => $removed > 0, 'items_retained' => false, 'messages' => [], 'done' => true, ]; } /* ------------------------------------------------------------ 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 '
'; if ($tx_id > 0) { $this->render_transaction_detail($tx_id); } else { echo '

INSTANDA Transactions

'; $table = new Transactions_List_Table(); $table->prepare_items(); echo '
'; $table->views(); $table->display(); echo '
'; echo $this->resubmit_js(); } echo '
'; } private function render_transaction_detail(int $tx_id) { $tx = (new Transaction_Store())->find($tx_id); if ($tx === null) { echo '

Transaction not found.

'; return; } echo '

Transaction #' . (int) $tx->id . ' ' . esc_html(ucfirst($tx->status)) . '

'; if ($tx->possible_duplicate) { echo '

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.

'; } if ($tx->environment !== Config::environment()->value) { echo '

This transaction was created in ' . esc_html(strtoupper($tx->environment)) . ' but the plugin is currently set to ' . esc_html(strtoupper(Config::environment()->value)) . '. Resubmit is blocked.

'; } echo '

Event log

'; echo '

Request payload (secrets redacted)

'; echo '
' . esc_html($tx->redacted_payload) . '
'; echo '

Summary

'; $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 !== '' ? get_date_from_gmt($tx->created_at, 'Y-m-d H:i') : '—', ]; foreach ($rows as $k => $v) { echo ''; } echo '
' . esc_html($k) . '' . esc_html((string) $v) . '
'; } 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 '

Go-live checklist

'; echo '

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.

'; echo '

Readiness: ' . (int) $readiness['complete'] . ' of ' . (int) $readiness['total'] . ' blocking items complete. Operating mode: ' . esc_html(strtoupper(Config::operating_mode())) . ', env ' . esc_html(strtoupper(Config::environment()->value)) . '.

'; $this->render_checklist('Blocking items', $go_live->blocking_items()); $this->render_checklist('Advisory items (recommended - do not block)', $go_live->advisory_items()); echo '

' . 'Before you switch: the read-only probes (B2/B3) prove credentials authenticate - not that a Live StartQuote 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 priced quote, then re-confirm pricing (B5) on Live.' . '

'; echo '
'; wp_nonce_field('espinstanda_go_live'); echo '

'; echo ' '; echo ' '; foreach (Go_Live::ROLES as $key => $label) { echo ' '; } echo '

'; if (Config::operating_mode_pinned()) { echo 'Operating mode is pinned in wp-config.php. Change it there to cut over.'; } elseif ($go_live->can_switch_to_live()) { echo ''; } else { echo ''; } if (Config::operating_mode() === 'live' && !Config::operating_mode_pinned()) { echo ' '; } echo '

'; } private function render_checklist(string $title, array $items) { echo '

' . esc_html($title) . '

'; foreach ($items as $item) { $badge = $item['pass'] ? '✓ Pass' : '⚠ Pending'; echo ''; } echo '
StatusItemDetail
' . $badge . '' . esc_html($item['label']) . '' . esc_html($item['detail']) . '
'; } 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->status === Tx_Status::Delivered->value) { wp_send_json_error(['message' => 'Already delivered - resubmitting would create a duplicate quote']); } 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 << (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; }); }); }); })(); JS; } }