- F-06: throttle the public create per IP (20/h) and per email (5/h) before the paid API call and the PII write. - F-11: wrap the create path in try/catch - on an unexpected error fail closed for that submission (no quote-less entry), alert admins, keep the form working. - F-14: abort the StartQuote when the write-ahead insert fails (no unaudited call); fire the failure alert even when storage is disabled (build an in-memory tx). - F-10/F-17: treat a 2xx with a missing/empty quoteRef as ambiguous (possible_duplicate=1), not a hard failure - a quote may exist. - F-07: write created_at/delivered_at/event timestamps in UTC to match the gmdate() retention/dedupe cutoffs; display in site-local via get_date_from_gmt. - F-09: log (no silent return) when async record_feed_result cannot resolve a delivered transaction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
669 lines
33 KiB
PHP
669 lines
33 KiB
PHP
<?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']));
|
|
|
|
// 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 '<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 →</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) {
|
|
// 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 '<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) {
|
|
$ts = isset($e['ts']) && $e['ts'] !== '' ? get_date_from_gmt((string) $e['ts'], 'Y-m-d H:i') : '';
|
|
echo '<li><code>' . esc_html($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 !== '' ? get_date_from_gmt($tx->created_at, 'Y-m-d H:i') : '—',
|
|
];
|
|
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">✓ Pass</span>' : '<span style="color:#b32d2e">⚠ 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->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 <<<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;
|
|
}
|
|
}
|