Compare commits
6
Commits
9883323161
...
d561326b84
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d561326b84 | ||
|
|
d607badac1 | ||
|
|
5d2e9cb7e5 | ||
|
|
0732dd662d | ||
|
|
2b1c226d46 | ||
|
|
4d7abdbb3b |
Binary file not shown.
@@ -3,7 +3,7 @@
|
|||||||
* Plugin Name: ESP INSTANDA Start Quote Integration
|
* Plugin Name: ESP INSTANDA Start Quote Integration
|
||||||
* Plugin URI: https://getfused.com
|
* Plugin URI: https://getfused.com
|
||||||
* Description: Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. Form-agnostic - attach the feed to any form.
|
* Description: Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock. Form-agnostic - attach the feed to any form.
|
||||||
* Version: 1.0.7
|
* Version: 1.1.0
|
||||||
* Requires at least: 7.0
|
* Requires at least: 7.0
|
||||||
* Requires PHP: 8.5
|
* Requires PHP: 8.5
|
||||||
* Author: Getfused
|
* Author: Getfused
|
||||||
@@ -75,25 +75,36 @@ function platform_blockers(): array {
|
|||||||
}
|
}
|
||||||
|
|
||||||
\add_action('admin_init', static function (): void {
|
\add_action('admin_init', static function (): void {
|
||||||
|
if (!empty(platform_blockers())) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
// Keep the schema current if the plugin was updated without re-activation.
|
// Keep the schema current if the plugin was updated without re-activation.
|
||||||
if (empty(platform_blockers()) && Transaction_Store::needs_migration()) {
|
if (Transaction_Store::needs_migration()) {
|
||||||
Transaction_Store::install_table();
|
Transaction_Store::install_table();
|
||||||
}
|
}
|
||||||
|
// Self-heal the retention cron if its scheduled event was ever lost (the schema
|
||||||
|
// self-heals above; the cron must too, or retention silently stops forever).
|
||||||
|
if (!\wp_next_scheduled(CLEANUP_HOOK)) {
|
||||||
|
\wp_schedule_event(time(), 'daily', CLEANUP_HOOK);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Requirements notice registered UNCONDITIONALLY: gform_loaded never fires when Gravity
|
||||||
|
// Forms is absent, so a notice registered only inside it would be unreachable in exactly
|
||||||
|
// the case (GF missing) an admin most needs to see it.
|
||||||
|
\add_action('admin_notices', static function (): void {
|
||||||
|
$errors = platform_blockers();
|
||||||
|
if ($errors) {
|
||||||
|
echo '<div class="notice notice-error"><p><strong>ESP INSTANDA Integration is inactive:</strong> '
|
||||||
|
. esc_html(implode(' ', $errors)) . '</p></div>';
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------ Gravity Forms Add-On registration */
|
/* ------------------------------------------------ Gravity Forms Add-On registration */
|
||||||
|
|
||||||
\add_action('gform_loaded', Guard::wrap(static function (): void {
|
\add_action('gform_loaded', Guard::wrap(static function (): void {
|
||||||
if (!empty(platform_blockers()) || !method_exists('GFForms', 'include_feed_addon_framework')) {
|
if (!empty(platform_blockers()) || !method_exists('GFForms', 'include_feed_addon_framework')) {
|
||||||
\add_action('admin_notices', static function (): void {
|
return; // the requirements notice is registered unconditionally above
|
||||||
$errors = platform_blockers();
|
|
||||||
if (!$errors) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
echo '<div class="notice notice-error"><p><strong>ESP INSTANDA Integration is inactive:</strong> '
|
|
||||||
. esc_html(implode(' ', $errors)) . '</p></div>';
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
\GFForms::include_feed_addon_framework();
|
\GFForms::include_feed_addon_framework();
|
||||||
@@ -111,7 +122,7 @@ function platform_blockers(): array {
|
|||||||
require_once __DIR__ . '/includes/class-instanda-addon.php';
|
require_once __DIR__ . '/includes/class-instanda-addon.php';
|
||||||
|
|
||||||
\GFAddOn::register(Instanda_AddOn::class);
|
\GFAddOn::register(Instanda_AddOn::class);
|
||||||
}, 5));
|
}), 5);
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- cron cleanup */
|
/* ----------------------------------------------------------------- cron cleanup */
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,9 @@ final class Api_Client {
|
|||||||
$data = is_array($data) ? $data : [];
|
$data = is_array($data) ? $data : [];
|
||||||
|
|
||||||
if ($code >= 200 && $code < 300) {
|
if ($code >= 200 && $code < 300) {
|
||||||
$ref = isset($data['quoteRef']) ? (string) $data['quoteRef'] : null;
|
// An empty-string quoteRef is not usable - treat it as missing so the caller
|
||||||
|
// handles the 2xx-without-quoteRef case as ambiguous, not as a delivered quote.
|
||||||
|
$ref = isset($data['quoteRef']) && $data['quoteRef'] !== '' ? (string) $data['quoteRef'] : null;
|
||||||
$url = isset($data['urlSingleUse']) ? (string) $data['urlSingleUse'] : null;
|
$url = isset($data['urlSingleUse']) ? (string) $data['urlSingleUse'] : null;
|
||||||
return Api_Result::success($code, $ref, $url, $excerpt);
|
return Api_Result::success($code, $ref, $url, $excerpt);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,11 @@
|
|||||||
* an explicit, gated switch to Live - see the Go-Live checklist.
|
* an explicit, gated switch to Live - see the Go-Live checklist.
|
||||||
*
|
*
|
||||||
* Secrets entered in the admin UI are encrypted at rest with libsodium
|
* Secrets entered in the admin UI are encrypted at rest with libsodium
|
||||||
* (sodium_crypto_secretbox, XSalsa20-Poly1305). The key comes from the
|
* (sodium_crypto_secretbox, XSalsa20-Poly1305). The "a database dump alone never
|
||||||
* SPG_INSTANDA_KEY constant, so a database dump alone never exposes a usable secret.
|
* exposes a usable secret" guarantee holds ONLY when the SPG_INSTANDA_KEY constant is
|
||||||
|
* set: without it the key is self-provisioned into the DB (see db_key()), so the key
|
||||||
|
* and ciphertext live in the same database and a dump recovers the password. Set
|
||||||
|
* SPG_INSTANDA_KEY (and the credential constants) in production for the hardened posture.
|
||||||
* Live credentials are expected as constants and are never written to the database.
|
* Live credentials are expected as constants and are never written to the database.
|
||||||
*
|
*
|
||||||
* @package ESP\Instanda
|
* @package ESP\Instanda
|
||||||
|
|||||||
@@ -28,5 +28,12 @@ final class Cron_Cleanup {
|
|||||||
if ($deleted > 0) {
|
if ($deleted > 0) {
|
||||||
Logger::debug("Retention purge removed {$deleted} delivered transaction(s) older than {$days} day(s).");
|
Logger::debug("Retention purge removed {$deleted} delivered transaction(s) older than {$days} day(s).");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scrub PII from unresolved (failed/pending) rows past the window; they are never
|
||||||
|
// timer-deleted, so this bounds how long customer PII is retained on them.
|
||||||
|
$scrubbed = $store->anonymize_stale_unresolved($days);
|
||||||
|
if ($scrubbed > 0) {
|
||||||
|
Logger::debug("Retention scrub anonymized PII on {$scrubbed} unresolved transaction(s) older than {$days} day(s).");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,6 +121,37 @@ final class Field_Mapper {
|
|||||||
return $errors;
|
return $errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanity-check a stored StartQuote wire body before re-sending it (resubmit trust
|
||||||
|
* boundary): the DB is treated as untrusted input. Returns error codes (empty = ok).
|
||||||
|
* Pure - uses a fixed UTC zone since only Y-m-d well-formedness is being checked.
|
||||||
|
*
|
||||||
|
* @param array<string,mixed> $body A body previously built by to_payload().
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public function validate_payload(array $body): array {
|
||||||
|
$errors = [];
|
||||||
|
foreach (['Role', 'ActivityType', 'PQ_LocationState_CHOICE'] as $key) {
|
||||||
|
if (trim((string) ($body[$key] ?? '')) === '') {
|
||||||
|
$errors[] = $key . '_missing';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!$this->is_positive_integer($body['PQ_MaximumDailyAttendance_NUM'] ?? null)) {
|
||||||
|
$errors[] = 'attendance_invalid';
|
||||||
|
}
|
||||||
|
$email = trim((string) ($body['PQ_CustomerEmailAddress_TXT'] ?? ''));
|
||||||
|
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
|
||||||
|
$errors[] = 'email_invalid';
|
||||||
|
}
|
||||||
|
$tz = new \DateTimeZone('UTC');
|
||||||
|
foreach (['PQ_EventStart_DATE', 'PQ_EventEnd_DATE'] as $key) {
|
||||||
|
if ($this->parse_date((string) ($body[$key] ?? ''), $tz) === null) {
|
||||||
|
$errors[] = $key . '_invalid';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $errors;
|
||||||
|
}
|
||||||
|
|
||||||
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
|
/** D8 - inclusive consecutive-day count: (end - start) + 1; a single-day event is 1. */
|
||||||
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
|
private function consecutive_days(\DateTimeImmutable $start, \DateTimeImmutable $end): int {
|
||||||
return (int) $start->diff($end)->days + 1;
|
return (int) $start->diff($end)->days + 1;
|
||||||
|
|||||||
@@ -106,9 +106,29 @@ final class Go_Live {
|
|||||||
['key' => 'A2', 'label' => 'Alert email recipient(s)', 'detail' => $emails !== '' ? $emails : 'None set', 'pass' => $emails !== ''],
|
['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' => '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],
|
['key' => 'A4', 'label' => 'No unresolved failures', 'detail' => $unresolved === 0 ? 'Clean' : "{$unresolved} unresolved", 'pass' => $unresolved === 0],
|
||||||
|
['key' => 'A5', 'label' => 'Exactly one active INSTANDA feed', 'detail' => $this->feed_summary(), 'pass' => $this->active_feed_count() === 1],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Count active INSTANDA feeds across all forms (should be exactly one at go-live). */
|
||||||
|
private function active_feed_count(): int {
|
||||||
|
if (!class_exists(__NAMESPACE__ . '\\Instanda_AddOn')) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
$feeds = Instanda_AddOn::get_instance()->get_feeds();
|
||||||
|
return count(array_filter((array) $feeds, static fn ($f) => !empty($f['is_active'])));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function feed_summary(): string {
|
||||||
|
$n = $this->active_feed_count();
|
||||||
|
if ($n === 1) {
|
||||||
|
return '1 active feed';
|
||||||
|
}
|
||||||
|
return $n === 0
|
||||||
|
? 'No active feed - the quote form will not push to INSTANDA'
|
||||||
|
: "{$n} active feeds - deactivate all but the canonical quote form (a stray test-form feed also creates quotes)";
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array{complete:int,total:int,ready:bool} */
|
/** @return array{complete:int,total:int,ready:bool} */
|
||||||
public function readiness(): array {
|
public function readiness(): array {
|
||||||
$blocking = $this->blocking_items();
|
$blocking = $this->blocking_items();
|
||||||
@@ -129,6 +149,10 @@ final class Go_Live {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
update_option('espinstanda_operating_mode', 'live');
|
update_option('espinstanda_operating_mode', 'live');
|
||||||
|
// Switching the mode must also select the Live environment, mirroring
|
||||||
|
// return_to_test(); otherwise environment() stays clamped to Test and real
|
||||||
|
// customer quotes would keep hitting the Test endpoint after go-live.
|
||||||
|
update_option('espinstanda_env', 'live');
|
||||||
update_option('espinstanda_operating_mode_audit', [
|
update_option('espinstanda_operating_mode_audit', [
|
||||||
'actor' => get_current_user_id(),
|
'actor' => get_current_user_id(),
|
||||||
'ts' => current_time('mysql'),
|
'ts' => current_time('mysql'),
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace ESP\Instanda;
|
|||||||
|
|
||||||
final class Instanda_AddOn extends \GFFeedAddOn {
|
final class Instanda_AddOn extends \GFFeedAddOn {
|
||||||
|
|
||||||
protected $_version = '1.0.7';
|
protected $_version = '1.1.0';
|
||||||
protected $_min_gravityforms_version = '2.10.3';
|
protected $_min_gravityforms_version = '2.10.3';
|
||||||
protected $_slug = 'espinstanda';
|
protected $_slug = 'espinstanda';
|
||||||
protected $_path = 'esp-instanda-integration/esp-instanda-integration.php';
|
protected $_path = 'esp-instanda-integration/esp-instanda-integration.php';
|
||||||
@@ -71,6 +71,9 @@ final class Instanda_AddOn extends \GFFeedAddOn {
|
|||||||
|
|
||||||
add_action('esp_instanda_resubmit', Guard::wrap([Resubmit_Handler::class, 'run']));
|
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
|
// 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.
|
// otherwise break GF's AJAX init and the post-submit INSTANDA redirect.
|
||||||
Boost_Compat::register();
|
Boost_Compat::register();
|
||||||
@@ -389,7 +392,14 @@ JS;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($tx === null || $tx->quote_ref === null) {
|
if ($tx === null || $tx->quote_ref === null) {
|
||||||
return; // nothing delivered to record (defensive)
|
// 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);
|
Entry_Integration::record($entry_id, $tx->quote_ref, (string) $tx->quote_url, 'delivered', $tx->environment);
|
||||||
@@ -402,6 +412,27 @@ JS;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 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 */
|
/* ------------------------------------------------------------ admin pages */
|
||||||
|
|
||||||
public function register_admin_pages() {
|
public function register_admin_pages() {
|
||||||
@@ -463,7 +494,8 @@ JS;
|
|||||||
|
|
||||||
echo '<h2>Event log</h2><ul class="espinstanda-timeline">';
|
echo '<h2>Event log</h2><ul class="espinstanda-timeline">';
|
||||||
foreach ($tx->event_log as $e) {
|
foreach ($tx->event_log as $e) {
|
||||||
echo '<li><code>' . esc_html($e['ts'] ?? '') . '</code> ' . esc_html($e['message'] ?? '') . '</li>';
|
$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 '</ul>';
|
||||||
|
|
||||||
@@ -475,7 +507,8 @@ JS;
|
|||||||
'Status' => ucfirst($tx->status), 'Environment' => strtoupper($tx->environment),
|
'Status' => ucfirst($tx->status), 'Environment' => strtoupper($tx->environment),
|
||||||
'quoteRef' => $tx->quote_ref ?? '—', 'HTTP' => $tx->http_status ?? '—',
|
'quoteRef' => $tx->quote_ref ?? '—', 'HTTP' => $tx->http_status ?? '—',
|
||||||
'Retries' => $tx->retry_count, 'Dedupe key' => $tx->dedupe_key,
|
'Retries' => $tx->retry_count, 'Dedupe key' => $tx->dedupe_key,
|
||||||
'Customer' => $tx->customer_email, 'Created' => $tx->created_at,
|
'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) {
|
foreach ($rows as $k => $v) {
|
||||||
echo '<tr><th>' . esc_html($k) . '</th><td>' . esc_html((string) $v) . '</td></tr>';
|
echo '<tr><th>' . esc_html($k) . '</th><td>' . esc_html((string) $v) . '</td></tr>';
|
||||||
@@ -592,6 +625,9 @@ JS;
|
|||||||
if ($tx === null) {
|
if ($tx === null) {
|
||||||
wp_send_json_error(['message' => 'Not found']);
|
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) {
|
if ($tx->environment !== Config::environment()->value) {
|
||||||
wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']);
|
wp_send_json_error(['message' => 'Cross-environment resubmit is blocked']);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ final class Submission {
|
|||||||
private const LOCK_TTL = 60; // seconds
|
private const LOCK_TTL = 60; // seconds
|
||||||
private const RESULT_TTL = 120;
|
private const RESULT_TTL = 120;
|
||||||
private const STASH_TTL = 300;
|
private const STASH_TTL = 300;
|
||||||
|
public const DEDUPE_WINDOW = 1800; // 30 min - collapse identical resubmits to the existing quote
|
||||||
|
private const RL_IP_MAX = 20; // valid submissions per IP per hour
|
||||||
|
private const RL_EMAIL_MAX = 5; // valid submissions per email per hour
|
||||||
|
private const RL_WINDOW = 3600; // rate-limit window (seconds)
|
||||||
|
|
||||||
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
/** Wire var key => feed meta key (the mapped GF field id is stored under each). */
|
||||||
private const FIELD_KEYS = [
|
private const FIELD_KEYS = [
|
||||||
@@ -55,22 +59,44 @@ final class Submission {
|
|||||||
return $validation_result; // GATE 3 - only act on the final page
|
return $validation_result; // GATE 3 - only act on the final page
|
||||||
}
|
}
|
||||||
|
|
||||||
$token = $this->submission_token($form);
|
// Fail CLOSED for this one submission if the create path throws (no quote-less
|
||||||
|
// entry saved), keep the form working for everyone else, and surface the failure.
|
||||||
|
try {
|
||||||
|
return $this->process($validation_result, $form, $feed);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Guard::report($e);
|
||||||
|
$this->record_hard_failure($e);
|
||||||
|
$this->pending_message = 'We could not start your quote just now. Please try again.';
|
||||||
|
return $this->mark_form_invalid($validation_result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The gated INSTANDA create - runs only after GATE 1-3 pass, inside validate()'s guard. */
|
||||||
|
private function process(array $validation_result, array $form, array $feed): array {
|
||||||
|
$token = $this->submission_token($form);
|
||||||
if (!$this->acquire_lock($token)) {
|
if (!$this->acquire_lock($token)) {
|
||||||
return $this->replay($token, $validation_result); // GATE 4 - duplicate fire: replay cached outcome
|
return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire
|
||||||
}
|
}
|
||||||
|
|
||||||
$dedupe = $this->dedupe_key($token);
|
|
||||||
$store = new Transaction_Store();
|
$store = new Transaction_Store();
|
||||||
|
$values = $this->extract_values($form, $feed);
|
||||||
|
$dedupe = $this->content_dedupe_key($form, $values); // content-addressed, stable across requests
|
||||||
|
|
||||||
// Own double-fire guard: a prior attempt for this submission already delivered.
|
// Cross-request duplicate guard: an identical submission already delivered
|
||||||
$existing = $store->find_by_dedupe($dedupe);
|
// recently -> reuse its quote instead of creating a second one at INSTANDA.
|
||||||
if ($existing !== null && $existing->status === Tx_Status::Delivered->value) {
|
$existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW);
|
||||||
|
if ($existing !== null) {
|
||||||
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
$this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url);
|
||||||
return $this->remember($token, $validation_result, true);
|
return $this->remember($token, $validation_result, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
$values = $this->extract_values($form, $feed);
|
// Race guard: serialize concurrent identical submits (atomic where a persistent
|
||||||
|
// object cache is present, e.g. production; degrades to a transient lock locally).
|
||||||
|
if (!$this->acquire_content_lock($dedupe)) {
|
||||||
|
$this->pending_message = 'We are finalizing your quote - please wait a moment and try again.';
|
||||||
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||||
|
}
|
||||||
|
|
||||||
$mapper = new Field_Mapper();
|
$mapper = new Field_Mapper();
|
||||||
$tz = wp_timezone_string();
|
$tz = wp_timezone_string();
|
||||||
|
|
||||||
@@ -80,6 +106,13 @@ final class Submission {
|
|||||||
return $this->remember($token, $validation_result, false);
|
return $this->remember($token, $validation_result, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Abuse control: throttle the paid API + PII table per IP and per email.
|
||||||
|
$throttle = $this->rate_limit_message((string) ($values['customer_email'] ?? ''));
|
||||||
|
if ($throttle !== null) {
|
||||||
|
$this->pending_message = $throttle;
|
||||||
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||||
|
}
|
||||||
|
|
||||||
$body = $mapper->to_payload($values, $tz);
|
$body = $mapper->to_payload($values, $tz);
|
||||||
|
|
||||||
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
|
// Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets).
|
||||||
@@ -94,35 +127,47 @@ final class Submission {
|
|||||||
$tx->role_label = (string) ($values['role'] ?? '');
|
$tx->role_label = (string) ($values['role'] ?? '');
|
||||||
$tx->redacted_payload = (string) wp_json_encode($body);
|
$tx->redacted_payload = (string) wp_json_encode($body);
|
||||||
$tx_id = $store->create_pending($tx);
|
$tx_id = $store->create_pending($tx);
|
||||||
|
|
||||||
|
// A failed write-ahead insert means we cannot audit the call - do not send it.
|
||||||
|
if ($tx_id === 0) {
|
||||||
|
Logger::error('Write-ahead insert failed; aborting StartQuote to avoid an unaudited call.');
|
||||||
|
$this->alert_failure($this->synth_tx($form, $values, $dedupe, 'Write-ahead insert failed - submission not sent.'), null);
|
||||||
|
Failure_Surface::bump();
|
||||||
|
$this->pending_message = $this->friendly_failure_message(true);
|
||||||
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$result = (new Api_Client())->start_quote($body, $dedupe);
|
$result = (new Api_Client())->start_quote($body, $dedupe);
|
||||||
|
$has_ref = $result->quote_ref !== null && $result->quote_ref !== '';
|
||||||
|
|
||||||
if ($result->ok && $result->quote_ref !== null) {
|
if ($result->ok && $has_ref) {
|
||||||
if ($tx_id) {
|
if ($tx_id) {
|
||||||
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
$store->mark_success($tx_id, (string) $result->quote_ref, $result->url_single_use, $result->http_status);
|
||||||
}
|
}
|
||||||
$this->stash_success($dedupe, $result->quote_ref, $result->url_single_use);
|
$this->stash_success($dedupe, (string) $result->quote_ref, $result->url_single_use);
|
||||||
return $this->remember($token, $validation_result, true);
|
return $this->remember($token, $validation_result, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Failure / ambiguous timeout - input preserved, no entry saved.
|
// Failure / ambiguous - input preserved, no entry saved. A 2xx without a usable
|
||||||
|
// quoteRef is ambiguous too: INSTANDA may have created a quote we cannot see.
|
||||||
|
$ambiguous = $result->is_ambiguous || ($result->ok && !$has_ref);
|
||||||
if ($tx_id) {
|
if ($tx_id) {
|
||||||
if ($result->is_ambiguous) {
|
if ($ambiguous) {
|
||||||
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
|
$store->mark_ambiguous($tx_id, $result->http_status, $result->error_string());
|
||||||
} else {
|
} else {
|
||||||
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
$store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string());
|
||||||
}
|
}
|
||||||
$stored = $store->find($tx_id);
|
$stored = $store->find($tx_id) ?? $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
||||||
if ($stored !== null) {
|
} else {
|
||||||
(new Notifier())->validation_failure_alert($stored, $result);
|
// Storage disabled - still surface the failure (no silent drop).
|
||||||
}
|
$stored = $this->synth_tx($form, $values, $dedupe, $result->error_string());
|
||||||
}
|
}
|
||||||
|
$this->alert_failure($stored, $result);
|
||||||
Failure_Surface::bump();
|
Failure_Surface::bump();
|
||||||
|
|
||||||
$this->pending_message = $this->friendly_failure_message($result);
|
$this->pending_message = $this->friendly_failure_message($ambiguous);
|
||||||
$invalid = $this->mark_form_invalid($validation_result);
|
return $this->remember($token, $this->mark_form_invalid($validation_result), false);
|
||||||
return $this->remember($token, $invalid, false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** gform_validation_message filter - surface our form-level failure message. */
|
/** gform_validation_message filter - surface our form-level failure message. */
|
||||||
@@ -164,10 +209,10 @@ final class Submission {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private function read_stash_for_entry(array $form): array {
|
private function read_stash_for_entry(array $form): array {
|
||||||
// Best-effort: the confirmation runs in the same request as a fresh success.
|
// Best-effort: confirmation runs in the same request as a fresh success, so the
|
||||||
$token = $this->submission_token($form);
|
// content dedupe key was recorded in the request-scoped static during validate().
|
||||||
$dedupe = $this->dedupe_key($token);
|
$dedupe = self::$current_dedupe;
|
||||||
return $this->read_stash($dedupe) ?? [];
|
return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------- feed + values */
|
/* ----------------------------------------------------------- feed + values */
|
||||||
@@ -228,12 +273,31 @@ final class Submission {
|
|||||||
return 'espinstanda_' . $unique;
|
return 'espinstanda_' . $unique;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function dedupe_key(string $token): string {
|
/**
|
||||||
return substr('dk_' . md5($token), 0, 64);
|
* Content-addressed dedupe key: two submissions of the same form with the same
|
||||||
|
* mapped field values produce the same key, so an identical re-POST or a
|
||||||
|
* retry-after-error collapses to the existing quote instead of creating a new one.
|
||||||
|
*/
|
||||||
|
private function content_dedupe_key(array $form, array $values): string {
|
||||||
|
$parts = [(string) ($form['id'] ?? '')];
|
||||||
|
foreach (self::FIELD_KEYS as $key) {
|
||||||
|
$v = trim((string) ($values[$key] ?? ''));
|
||||||
|
$parts[] = $key === 'customer_email' ? strtolower($v) : $v;
|
||||||
|
}
|
||||||
|
return 'dk_' . substr(hash('sha256', implode('|', $parts)), 0, 60);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Per-request lock: stops a single submission from double-firing within one request. */
|
||||||
private function acquire_lock(string $token): bool {
|
private function acquire_lock(string $token): bool {
|
||||||
$key = 'lock_' . md5($token);
|
return $this->try_lock('lock_' . md5($token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Brief atomic lock on the content key: serializes concurrent identical submits. */
|
||||||
|
private function acquire_content_lock(string $dedupe): bool {
|
||||||
|
return $this->try_lock('clock_' . md5($dedupe));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function try_lock(string $key): bool {
|
||||||
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
|
if (function_exists('wp_cache_add') && wp_cache_add($key, 1, 'espinstanda', self::LOCK_TTL)) {
|
||||||
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
set_transient('espinstanda_' . $key, 1, self::LOCK_TTL);
|
||||||
return true;
|
return true;
|
||||||
@@ -307,10 +371,62 @@ final class Submission {
|
|||||||
unset($field);
|
unset($field);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function friendly_failure_message(Api_Result $result): string {
|
private function friendly_failure_message(bool $ambiguous): string {
|
||||||
if ($result->is_ambiguous) {
|
if ($ambiguous) {
|
||||||
return 'We could not confirm your quote. Please try again in a moment - we have saved your details.';
|
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.';
|
return 'We could not start your quote just now. Please review your entries and try again.';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- abuse control */
|
||||||
|
|
||||||
|
/** A friendly message when over the per-IP or per-email hourly limit, else null. */
|
||||||
|
private function rate_limit_message(string $email): ?string {
|
||||||
|
$ip = isset($_SERVER['REMOTE_ADDR']) ? (string) $_SERVER['REMOTE_ADDR'] : '';
|
||||||
|
if ($ip !== '' && $this->bump_counter('rl_ip_' . md5($ip)) > self::RL_IP_MAX) {
|
||||||
|
return 'Too many quote attempts from your connection. Please try again later.';
|
||||||
|
}
|
||||||
|
$email = strtolower(trim($email));
|
||||||
|
if ($email !== '' && $this->bump_counter('rl_email_' . md5($email)) > self::RL_EMAIL_MAX) {
|
||||||
|
return 'You have started several quotes recently. Please try again later or contact us for help.';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function bump_counter(string $key): int {
|
||||||
|
$tkey = 'espinstanda_' . $key;
|
||||||
|
$n = (int) get_transient($tkey) + 1;
|
||||||
|
set_transient($tkey, $n, self::RL_WINDOW);
|
||||||
|
return $n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------- failure surfacing */
|
||||||
|
|
||||||
|
private function alert_failure(Transaction $tx, ?Api_Result $result): void {
|
||||||
|
(new Notifier())->validation_failure_alert($tx, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-memory transaction used to alert when no row was stored (storage off / insert failed). */
|
||||||
|
private function synth_tx(array $form, array $values, string $dedupe, string $error): Transaction {
|
||||||
|
$tx = new Transaction();
|
||||||
|
$tx->form_id = (int) ($form['id'] ?? 0);
|
||||||
|
$tx->environment = Config::environment()->value;
|
||||||
|
$tx->dedupe_key = $dedupe;
|
||||||
|
$tx->customer_email = (string) ($values['customer_email'] ?? '');
|
||||||
|
$tx->role_label = (string) ($values['role'] ?? '');
|
||||||
|
$tx->error_detail = $error;
|
||||||
|
return $tx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Best-effort admin alert when process() itself throws (F-11). Never throws. */
|
||||||
|
private function record_hard_failure(\Throwable $e): void {
|
||||||
|
try {
|
||||||
|
$tx = new Transaction();
|
||||||
|
$tx->environment = Config::environment()->value;
|
||||||
|
$tx->error_detail = 'Unexpected error during quote submission: ' . $e->getMessage();
|
||||||
|
(new Notifier())->validation_failure_alert($tx);
|
||||||
|
} catch (\Throwable $ignored) {
|
||||||
|
// never let the failure path itself throw
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ final class Transaction_Store {
|
|||||||
/** Write-ahead insert. Returns the new row id (0 on failure). */
|
/** Write-ahead insert. Returns the new row id (0 on failure). */
|
||||||
public function create_pending(Transaction $t): int {
|
public function create_pending(Transaction $t): int {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$now = current_time('mysql');
|
$now = current_time('mysql', true); // UTC - matches the gmdate() retention/dedupe cutoffs
|
||||||
$t->event_log[] = self::event('Write-ahead record created (status: pending)');
|
$t->event_log[] = self::event('Write-ahead record created (status: pending)');
|
||||||
|
|
||||||
$ok = $wpdb->insert(self::table_name(), [
|
$ok = $wpdb->insert(self::table_name(), [
|
||||||
@@ -159,14 +159,14 @@ final class Transaction_Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function mark_success(int $id, string $quote_ref, ?string $quote_url, ?int $http_status): void {
|
public function mark_success(int $id, string $quote_ref, ?string $quote_url, ?int $http_status): void {
|
||||||
$delivered = current_time('mysql');
|
$delivered = current_time('mysql', true); // UTC
|
||||||
$this->update($id, [
|
$this->update($id, [
|
||||||
'status' => Tx_Status::Delivered->value,
|
'status' => Tx_Status::Delivered->value,
|
||||||
'quote_ref' => $quote_ref,
|
'quote_ref' => $quote_ref,
|
||||||
'quote_url' => $quote_url,
|
'quote_url' => $quote_url,
|
||||||
'http_status' => $http_status,
|
'http_status' => $http_status,
|
||||||
'delivered_at' => $delivered,
|
'delivered_at' => $delivered,
|
||||||
'expiry' => $this->compute_expiry($delivered),
|
'expiry' => $this->compute_expiry(),
|
||||||
], 'Delivered - quoteRef ' . $quote_ref);
|
], 'Delivered - quoteRef ' . $quote_ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +251,22 @@ final class Transaction_Store {
|
|||||||
return $row ? Transaction::from_row($row) : null;
|
return $row ? Transaction::from_row($row) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Most recent delivered transaction for a content-addressed dedupe key within a window.
|
||||||
|
* Powers the cross-request duplicate guard: an identical submission that already
|
||||||
|
* delivered recently is reused instead of creating a second quote.
|
||||||
|
*/
|
||||||
|
public function recent_delivered_by_dedupe(string $dedupe_key, 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 dedupe_key = %s AND status = %s AND delivered_at >= %s ORDER BY id DESC LIMIT 1",
|
||||||
|
$dedupe_key, 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.
|
* Paged list for the admin browser, scoped to the active environment.
|
||||||
*
|
*
|
||||||
@@ -308,19 +324,43 @@ final class Transaction_Store {
|
|||||||
/* ----------------------------------------------------------------- retention */
|
/* ----------------------------------------------------------------- retention */
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purge ONLY delivered records past the retention window for the given env.
|
* Purge delivered records past the retention window for the given env, EXCEPT
|
||||||
* Pending/failed are never deleted on a timer. Returns the number removed.
|
* stranded leads (delivered but never attached to an entry) which must survive so a
|
||||||
|
* quote whose customer was never notified is not silently lost. Returns rows removed.
|
||||||
*/
|
*/
|
||||||
public function purge_delivered_older_than(int $days, Environment $env): int {
|
public function purge_delivered_older_than(int $days, Environment $env): int {
|
||||||
global $wpdb;
|
global $wpdb;
|
||||||
$table = self::table_name();
|
$table = self::table_name();
|
||||||
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
$cutoff = gmdate('Y-m-d H:i:s', time() - ($days * DAY_IN_SECONDS));
|
||||||
return (int) $wpdb->query($wpdb->prepare(
|
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",
|
"DELETE FROM {$table} WHERE status = %s AND environment = %s AND entry_id IS NOT NULL AND delivered_at IS NOT NULL AND delivered_at < %s",
|
||||||
Tx_Status::Delivered->value, $env->value, $cutoff
|
Tx_Status::Delivered->value, $env->value, $cutoff
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrub PII from unresolved (failed/pending) records older than the window while
|
||||||
|
* keeping the row for audit. Failed/pending rows are never timer-deleted, so without
|
||||||
|
* this their customer email/payload would be retained indefinitely. Returns rows scrubbed.
|
||||||
|
*/
|
||||||
|
public function anonymize_stale_unresolved(int $days): 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(
|
||||||
|
"UPDATE {$table} SET customer_email = '', redacted_payload = '', response_excerpt = NULL
|
||||||
|
WHERE status IN ('failed', 'pending') AND customer_email <> '' AND created_at < %s",
|
||||||
|
$cutoff
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Data-subject erasure: remove every transaction for a customer email. Returns rows deleted. */
|
||||||
|
public function delete_by_email(string $email): int {
|
||||||
|
global $wpdb;
|
||||||
|
$table = self::table_name();
|
||||||
|
return (int) $wpdb->query($wpdb->prepare("DELETE FROM {$table} WHERE customer_email = %s", $email));
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------- internals */
|
/* ----------------------------------------------------------------- internals */
|
||||||
|
|
||||||
private function update(int $id, array $data, ?string $event): void {
|
private function update(int $id, array $data, ?string $event): void {
|
||||||
@@ -335,14 +375,14 @@ final class Transaction_Store {
|
|||||||
$wpdb->update(self::table_name(), $data, ['id' => $id]);
|
$wpdb->update(self::table_name(), $data, ['id' => $id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function compute_expiry(string $delivered_at): string {
|
/** Expiry = now + retention window, in UTC (delivered_at is written at ~the same instant). */
|
||||||
|
private function compute_expiry(): string {
|
||||||
$days = (int) get_option('espinstanda_retention_days', 7);
|
$days = (int) get_option('espinstanda_retention_days', 7);
|
||||||
$ts = strtotime($delivered_at) ?: time();
|
return gmdate('Y-m-d H:i:s', time() + ($days * DAY_IN_SECONDS));
|
||||||
return gmdate('Y-m-d H:i:s', $ts + ($days * DAY_IN_SECONDS));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array{ts:string,level:string,message:string} */
|
/** @return array{ts:string,level:string,message:string} */
|
||||||
private static function event(string $message, string $level = 'info'): array {
|
private static function event(string $message, string $level = 'info'): array {
|
||||||
return ['ts' => current_time('mysql'), 'level' => $level, 'message' => Logger::redact($message)];
|
return ['ts' => current_time('mysql', true), 'level' => $level, 'message' => Logger::redact($message)];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ final class Transactions_List_Table extends \WP_List_Table {
|
|||||||
|
|
||||||
protected function column_default($item, $column_name): string {
|
protected function column_default($item, $column_name): string {
|
||||||
return match ($column_name) {
|
return match ($column_name) {
|
||||||
'submitted' => esc_html($item->created_at) . '<br><span class="mono">#' . $item->id . '</span>',
|
'submitted' => esc_html(get_date_from_gmt($item->created_at, 'Y-m-d H:i')) . '<br><span class="mono">#' . $item->id . '</span>',
|
||||||
'email' => esc_html($item->customer_email),
|
'email' => esc_html($item->customer_email),
|
||||||
'role' => esc_html($item->role_label),
|
'role' => esc_html($item->role_label),
|
||||||
'status' => $this->status_badge($item),
|
'status' => $this->status_badge($item),
|
||||||
@@ -136,15 +136,21 @@ final class Resubmit_Handler {
|
|||||||
if ($tx === null) {
|
if ($tx === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ($tx->status === Tx_Status::Delivered->value) {
|
||||||
|
$store->append_event($tx_id, 'Resubmit blocked - already delivered (would duplicate the quote).', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if ($tx->environment !== Config::environment()->value) {
|
if ($tx->environment !== Config::environment()->value) {
|
||||||
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
|
$store->append_event($tx_id, 'Resubmit blocked - cross-environment.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Self-recovery guard: skip if the customer already has a recent successful quote.
|
// Self-recovery guard: skip only when an IDENTICAL submission already delivered
|
||||||
$recent = $store->recent_success_for_email($tx->customer_email, DAY_IN_SECONDS);
|
// recently. Scoped to the content dedupe key so a customer's different event
|
||||||
|
// (different key) is never blocked.
|
||||||
|
$recent = $store->recent_delivered_by_dedupe($tx->dedupe_key, Submission::DEDUPE_WINDOW);
|
||||||
if ($recent !== null && $recent->id !== $tx->id) {
|
if ($recent !== null && $recent->id !== $tx->id) {
|
||||||
$store->append_event($tx_id, 'Resubmit skipped - customer already has a recent successful quote.');
|
$store->append_event($tx_id, 'Resubmit skipped - an identical submission already has a recent quote.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,14 +159,24 @@ final class Resubmit_Handler {
|
|||||||
$store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error');
|
$store->append_event($tx_id, 'Resubmit aborted - stored payload unreadable.', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Trust boundary: the stored body is untrusted input on the way back out to the
|
||||||
|
// insurer - re-validate it before re-sending (guards against DB tampering).
|
||||||
|
$payload_errors = (new Field_Mapper())->validate_payload($body);
|
||||||
|
if ($payload_errors) {
|
||||||
|
$store->append_event($tx_id, 'Resubmit aborted - stored payload failed re-validation: ' . implode(', ', $payload_errors), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$store->increment_retry($tx_id); // this attempt is a retry - keep the count truthful
|
||||||
|
|
||||||
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
|
$result = (new Api_Client())->start_quote($body, $tx->dedupe_key);
|
||||||
if ($result->ok && $result->quote_ref !== null) {
|
if ($result->ok && $result->quote_ref !== null && $result->quote_ref !== '') {
|
||||||
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
$store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status);
|
||||||
Failure_Surface::bump();
|
Failure_Surface::bump();
|
||||||
|
|
||||||
if ($tx->entry_id) {
|
if ($tx->entry_id) {
|
||||||
Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment);
|
Entry_Integration::record($tx->entry_id, $result->quote_ref, (string) $result->url_single_use, 'delivered', $tx->environment);
|
||||||
|
self::notify_resubmit_success((int) $tx->entry_id);
|
||||||
} else {
|
} else {
|
||||||
// Stranded lead (no entry) - mint a fresh link and email the customer (option B).
|
// Stranded lead (no entry) - mint a fresh link and email the customer (option B).
|
||||||
$fresh = (string) $result->url_single_use;
|
$fresh = (string) $result->url_single_use;
|
||||||
@@ -176,4 +192,19 @@ final class Resubmit_Handler {
|
|||||||
Failure_Surface::bump();
|
Failure_Surface::bump();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Dispatch the registered espinstanda_resubmit_success notification event for an entry. */
|
||||||
|
private static function notify_resubmit_success(int $entry_id): void {
|
||||||
|
if (!class_exists('\GFAPI')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$entry = \GFAPI::get_entry($entry_id);
|
||||||
|
if (is_wp_error($entry)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$form = \GFAPI::get_form((int) $entry['form_id']);
|
||||||
|
if ($form) {
|
||||||
|
\GFAPI::send_notifications($form, $entry, 'espinstanda_resubmit_success');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
=== ESP INSTANDA Start Quote Integration ===
|
||||||
|
Contributors: getfused
|
||||||
|
Requires at least: 7.0
|
||||||
|
Requires PHP: 8.5
|
||||||
|
Stable tag: 1.1.0
|
||||||
|
|
||||||
|
Pushes Gravity Forms "Start a Quote" submissions into INSTANDA's Package API with
|
||||||
|
write-ahead transaction logging, native entry recording, admin resubmit, and a test-mode lock.
|
||||||
|
|
||||||
|
== Changelog ==
|
||||||
|
|
||||||
|
= 1.1.0 =
|
||||||
|
Remediation of the 2026-07 security/reliability/PII audit (see docs/audit-esp-instanda-2026-07.md).
|
||||||
|
|
||||||
|
No duplicate policies / no lost quotes:
|
||||||
|
* Dedupe key is now content-addressed (form id + mapped field values) instead of a
|
||||||
|
per-request token, so an identical re-POST or a retry-after-error reuses the existing
|
||||||
|
quote instead of creating a second one at INSTANDA (F-01). Adds a 30-minute
|
||||||
|
short-circuit window and a content-key race lock.
|
||||||
|
* Refuse to resubmit an already-delivered transaction (F-12); scope the resubmit
|
||||||
|
self-recovery guard to the content dedupe key so a customer's *different* event is not
|
||||||
|
skipped (F-18).
|
||||||
|
* A 2xx response with a missing/empty quoteRef is now recorded as ambiguous
|
||||||
|
(possible_duplicate = 1), not a hard failure (F-10, F-17).
|
||||||
|
* Aborts the StartQuote when the write-ahead insert fails, so no unaudited call is made (F-14).
|
||||||
|
|
||||||
|
Go-live safety:
|
||||||
|
* switch_to_live() now also sets the environment to Live; previously the go-live button
|
||||||
|
left real quotes hitting the Test endpoint (F-03).
|
||||||
|
* Go-live advisory flags when more than one active INSTANDA feed exists (F-02).
|
||||||
|
|
||||||
|
Security & PII:
|
||||||
|
* Corrected the at-rest-secret docblock: the "DB dump never exposes a secret" guarantee
|
||||||
|
only holds when SPG_INSTANDA_KEY is set as a constant (F-04).
|
||||||
|
* Retention now scrubs PII from stale failed/pending rows (previously kept forever) and
|
||||||
|
exempts stranded delivered leads from the purge (F-05, F-13); wired WordPress's Erase
|
||||||
|
Personal Data tool to delete a customer's transactions (F-05).
|
||||||
|
* Uninstall now removes ALL plugin options and transients, including the encrypted
|
||||||
|
password and its key (F-15).
|
||||||
|
* Added per-IP (20/h) and per-email (5/h) rate limiting on the public create (F-06).
|
||||||
|
|
||||||
|
Reliability & correctness:
|
||||||
|
* Timestamps are written in UTC to match the retention/dedupe cutoffs and displayed in
|
||||||
|
site-local time (F-07). NOTE: rows created before 1.1.0 were stored in site-local time;
|
||||||
|
clear the transaction table (test data) at deploy to avoid a mixed-timezone history.
|
||||||
|
* Fail closed on an unexpected error in the create path (no quote-less entry) and alert
|
||||||
|
admins, instead of silently passing (F-11); log when async recording cannot resolve a
|
||||||
|
transaction (F-09).
|
||||||
|
* The "Gravity Forms required" admin notice is now shown even when Gravity Forms is
|
||||||
|
inactive (F-16); the daily retention cron self-heals on admin_init (F-22).
|
||||||
|
* Resubmit re-validates the stored payload before re-sending (F-19); the Retries count is
|
||||||
|
now incremented and the resubmit-success notification event is dispatched (F-20).
|
||||||
|
|
||||||
|
Theme:
|
||||||
|
* The Form-5 date-range inline JS is emitted only on pages that embed a Gravity Form and
|
||||||
|
its MutationObserver now disconnects instead of watching the DOM forever (F-23).
|
||||||
|
|
||||||
|
Operations required alongside this release (see the audit report, Ops checklist):
|
||||||
|
* Set SPG_INSTANDA_KEY + SPG_INSTANDA_USERNAME_LIVE/_PASSWORD_LIVE + SPG_INSTANDA_ENV in
|
||||||
|
production wp-config.php (blocking) - OPS-1 / F-04.
|
||||||
|
* Deactivate the INSTANDA feed on the test Form 6 in production (blocking) - OPS-3 / F-02.
|
||||||
|
* Configure espinstanda_alert_emails and the quote-success notification (blocking) - OPS-4.
|
||||||
|
* Rotate the SendGrid API key out of committed wp-config.php - OPS-2 / F-08.
|
||||||
|
* Add reCAPTCHA/Akismet to the canonical quote form - OPS-5 / F-06.
|
||||||
|
|
||||||
|
= 1.0.7 =
|
||||||
|
* Initial audited release.
|
||||||
@@ -20,10 +20,19 @@ try {
|
|||||||
// Table name is a trusted internal constant, not user input.
|
// Table name is a trusted internal constant, not user input.
|
||||||
$wpdb->query("DROP TABLE IF EXISTS {$table}");
|
$wpdb->query("DROP TABLE IF EXISTS {$table}");
|
||||||
|
|
||||||
delete_option('espinstanda_db_version');
|
// Remove EVERY plugin option and transient - including the encrypted Test password
|
||||||
delete_option('espinstanda_operating_mode_audit');
|
// and its self-provisioned key - so no recoverable secret survives uninstall.
|
||||||
|
$opt = $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$tr = '_transient_' . $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$trt = '_transient_timeout_' . $wpdb->esc_like('espinstanda_') . '%';
|
||||||
|
$wpdb->query($wpdb->prepare(
|
||||||
|
"DELETE FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s OR option_name LIKE %s",
|
||||||
|
$opt,
|
||||||
|
$tr,
|
||||||
|
$trt
|
||||||
|
));
|
||||||
|
|
||||||
// Clear any scheduled cleanup that may still be queued.
|
// Clear any scheduled events that may still be queued.
|
||||||
wp_clear_scheduled_hook('esp_instanda_cleanup');
|
wp_clear_scheduled_hook('esp_instanda_cleanup');
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
if (function_exists('error_log')) {
|
if (function_exists('error_log')) {
|
||||||
|
|||||||
@@ -189,7 +189,17 @@ function gf_validate_date_range( $validation_result ) {
|
|||||||
add_action( 'wp_footer', 'gf_date_range_scripts' );
|
add_action( 'wp_footer', 'gf_date_range_scripts' );
|
||||||
|
|
||||||
function gf_date_range_scripts() {
|
function gf_date_range_scripts() {
|
||||||
if ( ! class_exists( 'GFForms' ) ) {
|
if ( ! class_exists( 'GFForms' ) || is_admin() ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only emit this inline script on requests whose content embeds a Gravity Form -
|
||||||
|
// keeps ~220 lines of JS off every other page. Server-side validation is unaffected.
|
||||||
|
// If the post cannot be inspected, fall back to emitting (safe default).
|
||||||
|
$post = get_post();
|
||||||
|
if ( $post instanceof WP_Post
|
||||||
|
&& ! has_shortcode( (string) $post->post_content, 'gravityform' )
|
||||||
|
&& ! has_block( 'gravityforms/form', $post )
|
||||||
|
&& false === strpos( (string) $post->post_content, 'gform' ) ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
@@ -410,10 +420,14 @@ function gf_date_range_scripts() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ( ! attachListeners() ) {
|
if ( ! attachListeners() ) {
|
||||||
|
var tries = 0;
|
||||||
var observer = new MutationObserver( function () {
|
var observer = new MutationObserver( function () {
|
||||||
if ( attachListeners() ) observer.disconnect();
|
// Stop once the fields are wired, or after a bounded number of tries, so we
|
||||||
|
// never observe the whole document subtree indefinitely on non-form pages.
|
||||||
|
if ( attachListeners() || ++tries > 20 ) observer.disconnect();
|
||||||
} );
|
} );
|
||||||
observer.observe( document.body, { childList: true, subtree: true } );
|
observer.observe( document.body, { childList: true, subtree: true } );
|
||||||
|
setTimeout( function () { observer.disconnect(); }, 10000 );
|
||||||
}
|
}
|
||||||
|
|
||||||
} )();
|
} )();
|
||||||
|
|||||||
Reference in New Issue
Block a user