feed meta key (the mapped GF field id is stored under each). */ private const FIELD_KEYS = [ 'role', 'vendor_type', 'performer_type', 'activity_type', 'location_state', 'max_attendance', 'event_start', 'event_end', 'customer_email', ]; private ?string $pending_message = null; /** Request-scoped dedupe of the just-succeeded submission, for process_feed/entry-created. */ private static ?string $current_dedupe = null; public static function current_dedupe(): ?string { return self::$current_dedupe; } /* ----------------------------------------------------------- gform_validation */ /** @param array{is_valid:bool,form:array} $validation_result */ public function validate(array $validation_result): array { $form = $validation_result['form']; $feed = $this->active_feed($form); if ($feed === null) { return $validation_result; // GATE 1 - form has no INSTANDA feed } if (empty($validation_result['is_valid'])) { return $validation_result; // GATE 2 - anti-spam / native validation already failed } if (!$this->is_last_page($form)) { return $validation_result; // GATE 3 - only act on the final page } $token = $this->submission_token($form); if (!$this->acquire_lock($token)) { return $this->replay($token, $validation_result); // GATE 4 - within-request double-fire } $store = new Transaction_Store(); $values = $this->extract_values($form, $feed); $dedupe = $this->content_dedupe_key($form, $values); // content-addressed, stable across requests // Cross-request duplicate guard: an identical submission already delivered // recently -> reuse its quote instead of creating a second one at INSTANDA. $existing = $store->recent_delivered_by_dedupe($dedupe, self::DEDUPE_WINDOW); if ($existing !== null) { $this->stash_success($dedupe, (string) $existing->quote_ref, $existing->quote_url); return $this->remember($token, $validation_result, true); } // 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(); $tz = wp_timezone_string(); $errors = $mapper->validate($values, $tz); if ($errors) { $this->apply_field_errors($validation_result, $form, $feed, $errors); return $this->remember($token, $validation_result, false); } $body = $mapper->to_payload($values, $tz); // Write-ahead: persist BEFORE the HTTP call (redacted body carries no secrets). $store_enabled = get_option('espinstanda_storage_enabled', '1') === '1'; $tx_id = 0; if ($store_enabled) { $tx = new Transaction(); $tx->form_id = (int) $form['id']; $tx->environment = Config::environment()->value; $tx->dedupe_key = $dedupe; $tx->customer_email = (string) ($values['customer_email'] ?? ''); $tx->role_label = (string) ($values['role'] ?? ''); $tx->redacted_payload = (string) wp_json_encode($body); $tx_id = $store->create_pending($tx); } $result = (new Api_Client())->start_quote($body, $dedupe); if ($result->ok && $result->quote_ref !== null) { if ($tx_id) { $store->mark_success($tx_id, $result->quote_ref, $result->url_single_use, $result->http_status); } $this->stash_success($dedupe, $result->quote_ref, $result->url_single_use); return $this->remember($token, $validation_result, true); } // Failure / ambiguous timeout - input preserved, no entry saved. if ($tx_id) { if ($result->is_ambiguous) { $store->mark_ambiguous($tx_id, $result->http_status, $result->error_string()); } else { $store->mark_failed($tx_id, $result->http_status, $result->response_excerpt, $result->error_string()); } $stored = $store->find($tx_id); if ($stored !== null) { (new Notifier())->validation_failure_alert($stored, $result); } } Failure_Surface::bump(); $this->pending_message = $this->friendly_failure_message($result); $invalid = $this->mark_form_invalid($validation_result); return $this->remember($token, $invalid, false); } /** gform_validation_message filter - surface our form-level failure message. */ public function validation_message(string $message, array $form): string { return $this->pending_message !== null ? esc_html($this->pending_message) : $message; } /* ----------------------------------------------------------- gform_confirmation */ public function confirmation(mixed $confirmation, array $form, array $entry, bool $ajax): mixed { if ($this->active_feed($form) === null) { return $confirmation; } $url = (string) gform_get_meta((int) $entry['id'], 'instanda_quote_url'); if ($url === '') { $stash = $this->read_stash_for_entry($form); $url = $stash['url'] ?? ''; } if ($url !== '') { return ['redirect' => $url]; } return $confirmation; // defensive: fall through to the form's default confirmation } /* ----------------------------------------------------------- success stash */ /** Stash by dedupe so process_feed (possibly async) can record without re-calling the API. */ private function stash_success(string $dedupe, string $quote_ref, ?string $url): void { self::$current_dedupe = $dedupe; set_transient('espinstanda_stash_' . md5($dedupe), [ 'quote_ref' => $quote_ref, 'url' => (string) $url, ], self::STASH_TTL); } public function read_stash(string $dedupe): ?array { $v = get_transient('espinstanda_stash_' . md5($dedupe)); return is_array($v) ? $v : null; } private function read_stash_for_entry(array $form): array { // Best-effort: confirmation runs in the same request as a fresh success, so the // content dedupe key was recorded in the request-scoped static during validate(). $dedupe = self::$current_dedupe; return $dedupe !== null ? ($this->read_stash($dedupe) ?? []) : []; } /* ----------------------------------------------------------- feed + values */ private function active_feed(array $form): ?array { $addon = Instanda_AddOn::get_instance(); $feeds = $addon->get_feeds((int) $form['id']); foreach ($feeds as $feed) { if (!empty($feed['is_active'])) { return $feed; } } return null; } /** @return array intent key => submitted value */ private function extract_values(array $form, array $feed): array { $meta = $feed['meta'] ?? []; $values = []; foreach (self::FIELD_KEYS as $key) { $field_id = (string) ($meta[$key] ?? ''); $values[$key] = $field_id !== '' ? $this->submitted_value($form, $field_id) : ''; } return $values; } /** Read a submitted field value via GF's API (never raw $_POST). @verify-on-site */ private function submitted_value(array $form, string $field_id): string { $field = \GFFormsModel::get_field($form, $field_id); if ($field === null) { return ''; } $value = \GFFormsModel::get_field_value($field); return is_array($value) ? trim((string) reset($value)) : trim((string) $value); } /* ----------------------------------------------------------- duplicate guards */ private function is_last_page(array $form): bool { // @verify-on-site: GFFormDisplay::get_max_page_number returns 0 for single-page forms. if (!class_exists('\GFFormDisplay')) { return true; } $target = (int) rgpost('gform_target_page_number_' . $form['id']); $max = (int) \GFFormDisplay::get_max_page_number($form); return $max === 0 || $target === 0; } private function submission_token(array $form): string { $unique = ''; if (class_exists('\GFFormsModel') && method_exists('\GFFormsModel', 'get_form_unique_id')) { $unique = (string) \GFFormsModel::get_form_unique_id((int) $form['id']); // @verify-on-site } if ($unique === '') { // Fallback: stable across re-validation of one submit, unique per real submit. $unique = md5(($form['id'] ?? '') . '|' . (string) rgpost('gform_submit') . '|' . (string) wp_date('YmdHi')); } return 'espinstanda_' . $unique; } /** * 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 { 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)) { set_transient('espinstanda_' . $key, 1, self::LOCK_TTL); return true; } if (get_transient('espinstanda_' . $key)) { return false; } set_transient('espinstanda_' . $key, 1, self::LOCK_TTL); return true; } private function remember(string $token, array $validation_result, bool $passed): array { set_transient('espinstanda_res_' . md5($token), [ 'passed' => $passed, 'message' => $this->pending_message, ], self::RESULT_TTL); return $validation_result; } private function replay(string $token, array $validation_result): array { $cached = get_transient('espinstanda_res_' . md5($token)); if (!is_array($cached)) { return $validation_result; // unknown - let it pass through unchanged } if (empty($cached['passed'])) { $this->pending_message = $cached['message'] ?? null; return $this->mark_form_invalid($validation_result); } return $validation_result; } /* ----------------------------------------------------------- error rendering */ private function mark_form_invalid(array $validation_result): array { $validation_result['is_valid'] = false; return $validation_result; } /** Attach mapper error codes to their fields where possible; else a form-level message. */ private function apply_field_errors(array &$validation_result, array $form, array $feed, array $errors): void { $meta = $feed['meta'] ?? []; $map = [ 'vendor_type_required' => ['vendor_type', 'Please choose a vendor type.'], 'performer_type_required' => ['performer_type', 'Please choose a performer type.'], 'activity_type_required' => ['activity_type', 'Please choose an activity type.'], 'location_required' => ['location_state', 'Please choose an event location.'], 'attendance_not_positive_integer' => ['max_attendance', 'Enter a positive number for daily attendance.'], 'start_required' => ['event_start', 'Please enter a valid start date.'], 'end_required' => ['event_end', 'Please enter a valid end date.'], 'end_before_start' => ['event_end', 'The end date must be on or after the start date.'], 'start_in_past' => ['event_start', 'The start date must be today or later.'], 'email_required' => ['customer_email', 'Please enter your email address.'], 'email_invalid' => ['customer_email', 'Please enter a valid email address.'], 'role_required' => ['role', 'Please choose an event role.'], ]; $validation_result['is_valid'] = false; foreach ($validation_result['form']['fields'] as &$field) { foreach ($errors as $code) { if (!isset($map[$code])) { continue; } [$intent, $message] = $map[$code]; $mapped_id = (string) ($meta[$intent] ?? ''); if ($mapped_id !== '' && (string) $field->id === $mapped_id) { $field->failed_validation = true; $field->validation_message = $message; } } } unset($field); } private function friendly_failure_message(Api_Result $result): string { if ($result->is_ambiguous) { return 'We could not confirm your quote. Please try again in a moment - we have saved your details.'; } return 'We could not start your quote just now. Please review your entries and try again.'; } }