Created
August 17, 2026 19:34
-
-
Save kengitahi/d373f8d980a1bf88b709a100676f2a7f to your computer and use it in GitHub Desktop.
WTE Flitt
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // includes/class-wte-flitt-gateway.php | |
| <?php | |
| namespace WTEFlitt; | |
| use WPTravelEngine\Core\Models\Post\Booking; | |
| use WPTravelEngine\Core\Models\Post\Payment; | |
| use WPTravelEngine\PaymentGateways\BaseGateway; | |
| /** | |
| * WTE Flitt payment gateway. | |
| * | |
| * This class connects the existing WTE Payment/Booking system | |
| * to Flitt's API. | |
| */ | |
| class Gateway extends BaseGateway | |
| { | |
| /** | |
| * Return the gateway ID used by WTE. | |
| * | |
| * @return string | |
| */ | |
| public function get_gateway_id(): string | |
| { | |
| return 'flitt'; | |
| } | |
| /** | |
| * Return the human-readable gateway label. | |
| * | |
| * @return string | |
| */ | |
| public function get_gateway_label(): string | |
| { | |
| return 'Flitt'; | |
| } | |
| /** | |
| * Return the callback URL used by Flitt. | |
| * | |
| * This method must match WTE's BaseGateway signature. | |
| * | |
| * @param Payment $payment Payment object. | |
| * @param string $callback_type Callback type. | |
| * @param array $query_args Additional query arguments. | |
| * | |
| * @return string | |
| */ | |
| public function get_callback_url( | |
| Payment $payment, | |
| string $callback_type = 'success', | |
| array $query_args = [], | |
| ): string { | |
| return add_query_arg(array_merge(array( | |
| 'wte_flitt_callback' => '1', | |
| ), $query_args), home_url('/')); | |
| } | |
| /** | |
| * Return whether this gateway supports the current payment. | |
| * | |
| * @param Payment $payment Payment object. | |
| * | |
| * @return bool | |
| */ | |
| public function supports(Payment $payment): bool | |
| { | |
| return true; | |
| } | |
| /** | |
| * Create the Flitt order. | |
| * | |
| * This creates the Flitt order using the already-created | |
| * WTE Payment. | |
| * | |
| * @param Booking $booking WTE booking. | |
| * @param Payment $payment WTE payment. | |
| * | |
| * @return array | |
| * | |
| * @throws \RuntimeException When Flitt rejects the request. | |
| */ | |
| public function create_flitt_order(Booking $booking, Payment $payment): array | |
| { | |
| $amount = (float) $payment->get_payable_amount(); | |
| $currency = strtoupper(trim((string) $payment->get_payable_currency())); | |
| if ($amount <= 0) { | |
| throw new \RuntimeException('The WTE payment amount is invalid.'); | |
| } | |
| if (!$currency) { | |
| throw new \RuntimeException('The WTE payment currency is missing.'); | |
| } | |
| $payment_id = (int) $payment->get_id(); | |
| $order_id = $this->build_flitt_order_id($payment_id); | |
| $callback_url = $this->get_callback_url($payment, 'notification'); | |
| /* | |
| * Flitt expects integer minor currency units. | |
| * | |
| * For example: | |
| * | |
| * 95.00 GEL → 9500 | |
| */ | |
| $flitt_amount = $this->convert_amount_to_flitt($amount); | |
| $order_description = sprintf('WTE Booking #%d', (int) $booking->get_id()); | |
| $params = array( | |
| 'version' => '1.0.1', | |
| 'server_callback_url' => $callback_url, | |
| 'order_id' => $order_id, | |
| 'currency' => $currency, | |
| 'merchant_id' => (int) WTE_FLITT_MERCHANT_ID, | |
| 'order_desc' => $order_description, | |
| 'amount' => $flitt_amount, | |
| ); | |
| /* | |
| * Build the Flitt request signature. | |
| */ | |
| $params['signature'] = $this->build_signature($params); | |
| /* | |
| * Send the order creation request to Flitt. | |
| */ | |
| $response = wp_remote_post(WTE_FLITT_API_URL . '/api/checkout/token', array( | |
| 'timeout' => 30, | |
| 'headers' => array( | |
| 'Content-Type' => 'application/json; charset=UTF-8', | |
| 'Accept' => 'application/json', | |
| ), | |
| 'body' => wp_json_encode(array( | |
| 'request' => $params, | |
| )), | |
| )); | |
| if (is_wp_error($response)) { | |
| throw new \RuntimeException('Unable to connect to Flitt: ' . $response->get_error_message()); | |
| } | |
| $http_status = (int) wp_remote_retrieve_response_code($response); | |
| $body = wp_remote_retrieve_body($response); | |
| $data = json_decode($body, true); | |
| if (!is_array($data)) { | |
| throw new \RuntimeException('Flitt returned invalid order creation JSON.'); | |
| } | |
| $flitt_response = $data['response'] ?? array(); | |
| if (!is_array($flitt_response)) { | |
| throw new \RuntimeException('Flitt returned an invalid order creation response.'); | |
| } | |
| /* | |
| * Verify Flitt's response signature. | |
| */ | |
| // if (!$this->verify_signature($flitt_response)) { | |
| // throw new \RuntimeException( | |
| // 'Flitt order creation response signature verification failed.' | |
| // ); | |
| // } | |
| /* | |
| * Verify the merchant ID returned by Flitt. | |
| */ | |
| if ( | |
| isset($flitt_response['merchant_id']) | |
| && (string) $flitt_response['merchant_id'] !== (string) WTE_FLITT_MERCHANT_ID | |
| ) { | |
| throw new \RuntimeException('Flitt order creation merchant ID mismatch.'); | |
| } | |
| /* | |
| * Store the Flitt order information on the existing WTE Payment. | |
| */ | |
| $payment->sync_metas(array( | |
| 'flitt_order_id' => $order_id, | |
| 'flitt_token' => isset($flitt_response['token']) | |
| ? sanitize_text_field((string) $flitt_response['token']) | |
| : '', | |
| 'flitt_payment_id' => isset($flitt_response['payment_id']) ? (string) $flitt_response['payment_id'] : '', | |
| 'flitt_amount' => $flitt_amount, | |
| 'flitt_currency' => $currency, | |
| 'flitt_http_status' => $http_status, | |
| )); | |
| $payment->save(); | |
| /* | |
| * Flitt's embedded checkout needs the token. | |
| */ | |
| if (empty($flitt_response['token'])) { | |
| throw new \RuntimeException('Flitt did not return an embedded checkout token.'); | |
| } | |
| return array( | |
| 'token' => $flitt_response['token'], | |
| 'order_id' => $order_id, | |
| 'flitt_payment_id' => $flitt_response['payment_id'] ?? '', | |
| 'amount' => $amount, | |
| 'flitt_amount' => $flitt_amount, | |
| 'currency' => $currency, | |
| ); | |
| } | |
| /** | |
| * Process the WTE payment through our custom Flitt flow. | |
| * | |
| * WTE calls this after it has created the Booking and Payment. | |
| * Instead of allowing the normal WTE gateway flow to redirect, | |
| * we create the Flitt order and return Phase 3A JSON to our AJAX request. | |
| * | |
| * @param Booking $booking WTE booking. | |
| * @param Payment $payment WTE payment. | |
| * @param BookingProcess $booking_instance WTE booking process instance. | |
| * | |
| * @return void | |
| */ | |
| public function process_payment( | |
| Booking $booking, | |
| Payment $payment, | |
| \WPTravelEngine\Core\Booking\BookingProcess $booking_instance, | |
| ): void { | |
| $this->create_flitt_order_for_phase3a($booking, $payment); | |
| } | |
| /** | |
| * Create the Flitt order during Phase 3A. | |
| * | |
| * The WTE Booking and Payment already exist at this point. | |
| * | |
| * @param Booking $booking WTE booking. | |
| * @param Payment $payment WTE payment. | |
| * | |
| * @return void | |
| */ | |
| public function create_flitt_order_for_phase3a(Booking $booking, Payment $payment): void | |
| { | |
| $booking_instance = $booking; | |
| $amount = (float) $payment->get_payable_amount(); | |
| $currency = strtoupper(trim((string) $payment->get_payable_currency())); | |
| $payment_id = (int) $payment->get_id(); | |
| $booking_id = (int) $booking->get_id(); | |
| $payment_key = (string) $payment->get_payment_key(); | |
| $payment->set_payment_gateway($this->get_gateway_id()); | |
| $payment->save(); | |
| try { | |
| $flitt = $this->create_flitt_order($booking, $payment); | |
| do_action('wte_flitt_phase3a_order_created', $booking, $payment, $flitt, $booking_instance); | |
| wp_send_json_success(array( | |
| 'phase' => '3A', | |
| 'status' => 'flitt_order_created', | |
| 'gateway' => $this->get_gateway_id(), | |
| 'booking_id' => $booking_id, | |
| 'payment_id' => $payment_id, | |
| 'payment_key' => $payment_key, | |
| 'amount' => round($amount, 2), | |
| 'currency' => $currency, | |
| 'flitt' => array( | |
| 'token' => $flitt['token'], | |
| 'order_id' => $flitt['order_id'], | |
| 'payment_id' => $flitt['flitt_payment_id'], | |
| 'amount' => $flitt['amount'], | |
| 'flitt_amount' => $flitt['flitt_amount'], | |
| 'currency' => $flitt['currency'], | |
| ), | |
| 'payment_status' => method_exists($payment, 'get_payment_status') ? $payment->get_payment_status() : '', | |
| 'booking_status' => method_exists($booking, 'get_booking_status') ? $booking->get_booking_status() : '', | |
| )); | |
| } catch (\Throwable $e) { | |
| $payment->set_meta('wte_flitt_phase3a_error', array( | |
| 'booking_id' => $booking_id, | |
| 'payment_id' => $payment_id, | |
| 'amount' => $amount, | |
| 'currency' => $currency, | |
| 'message' => $e->getMessage(), | |
| 'timestamp' => current_time('mysql'), | |
| )); | |
| $payment->save(); | |
| wp_send_json_error(array( | |
| 'code' => 'FLITT_ORDER_CREATION_FAILED', | |
| 'message' => $e->getMessage(), | |
| 'booking_id' => $booking_id, | |
| 'payment_id' => $payment_id, | |
| ), 502); | |
| } | |
| } | |
| /** | |
| * Handle a Flitt server notification. | |
| * | |
| * This is the server-to-server callback from Flitt. | |
| * | |
| * @param Booking $booking WTE booking. | |
| * @param Payment $payment WTE payment. | |
| * | |
| * @return void | |
| */ | |
| public function handle_notification_request(Booking $booking, Payment $payment) | |
| { | |
| /* | |
| * Read Flitt's JSON callback body. | |
| */ | |
| $raw_body = file_get_contents('php://input'); | |
| $data = json_decode($raw_body, true); | |
| /* | |
| * Some environments may expose the JSON request through | |
| * $_POST instead. | |
| */ | |
| if (!is_array($data) || empty($data)) { | |
| $data = wp_unslash($_POST); | |
| } | |
| /* | |
| * Flitt may wrap the response inside "response". | |
| */ | |
| if (isset($data['response']) && is_array($data['response'])) { | |
| $data = $data['response']; | |
| } | |
| if (!is_array($data)) { | |
| $this->log_callback('Invalid Flitt callback payload.'); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Verify the callback signature before changing WTE. | |
| */ | |
| if (!$this->verify_signature($data)) { | |
| $this->log_callback('Invalid Flitt callback signature.', array( | |
| 'order_id' => $data['order_id'] ?? '', | |
| )); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Verify the merchant identity. | |
| */ | |
| $merchant_id = (string) ($data['merchant_id'] ?? ''); | |
| if ($merchant_id !== (string) WTE_FLITT_MERCHANT_ID) { | |
| $this->log_callback('Flitt callback merchant ID mismatch.', array( | |
| 'merchant_id' => $merchant_id, | |
| )); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Verify that this callback belongs to this WTE Payment. | |
| */ | |
| $expected_order_id = $this->build_flitt_order_id((int) $payment->get_id()); | |
| $flitt_order_id = sanitize_text_field((string) ($data['order_id'] ?? '')); | |
| if (!$flitt_order_id || $flitt_order_id !== $expected_order_id) { | |
| $this->log_callback('Flitt order ID does not match the WTE Payment.', array( | |
| 'expected_order_id' => $expected_order_id, | |
| 'received_order_id' => $flitt_order_id, | |
| 'payment_id' => (int) $payment->get_id(), | |
| )); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Verify the currency. | |
| */ | |
| $flitt_currency = strtoupper(trim((string) ($data['currency'] ?? ''))); | |
| $expected_currency = strtoupper(trim((string) $payment->get_payable_currency())); | |
| if (!$flitt_currency || $flitt_currency !== $expected_currency) { | |
| $this->log_callback('Flitt callback currency mismatch.', array( | |
| 'expected_currency' => $expected_currency, | |
| 'received_currency' => $flitt_currency, | |
| 'payment_id' => (int) $payment->get_id(), | |
| )); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Verify the callback amount. | |
| * | |
| * Flitt sends amounts in minor units. | |
| */ | |
| $callback_amount = isset($data['actual_amount']) && $data['actual_amount'] !== '' | |
| ? $data['actual_amount'] | |
| : $data['amount'] ?? ''; | |
| $callback_amount = (int) $callback_amount; | |
| $expected_amount = $this->convert_amount_to_flitt((float) $payment->get_payable_amount()); | |
| if ($callback_amount <= 0 || $callback_amount < $expected_amount) { | |
| $this->log_callback('Flitt callback amount mismatch.', array( | |
| 'expected_amount' => $expected_amount, | |
| 'received_amount' => $callback_amount, | |
| 'payment_id' => (int) $payment->get_id(), | |
| )); | |
| status_header(400); | |
| exit(); | |
| } | |
| /* | |
| * Store the complete verified callback. | |
| */ | |
| $payment->sync_metas(array( | |
| 'wte_flitt_callback' => $data, | |
| 'flitt_payment_id' => isset($data['payment_id']) ? (string) $data['payment_id'] : '', | |
| 'flitt_order_status' => isset($data['order_status']) | |
| ? sanitize_text_field((string) $data['order_status']) | |
| : '', | |
| )); | |
| $order_status = strtolower(trim((string) ($data['order_status'] ?? ''))); | |
| $response_status = strtolower(trim((string) ($data['response_status'] ?? ''))); | |
| /* | |
| * The authoritative successful state is: | |
| * | |
| * response_status = success | |
| * order_status = approved | |
| */ | |
| if ('success' === $response_status && 'approved' === $order_status) { | |
| $this->complete_wte_payment($booking, $payment, $data); | |
| status_header(200); | |
| exit(); | |
| } | |
| /* | |
| * Flitt delayed orders can report: | |
| * | |
| * order_status = processing | |
| * response_code = non-empty | |
| * | |
| * That means the current payment attempt was declined, | |
| * but the Flitt order itself remains open for another | |
| * attempt. | |
| * | |
| * Do NOT mark the WTE Payment failed here. | |
| */ | |
| $response_code = trim((string) ($data['response_code'] ?? '')); | |
| $response_description = trim((string) ($data['response_description'] ?? '')); | |
| $amount = $this->convert_amount_from_flitt($callback_amount); | |
| if ( | |
| in_array( | |
| $order_status, | |
| array( | |
| 'processing', | |
| 'pending', | |
| ), | |
| true, | |
| ) | |
| && ('' !== $response_code || '' !== $response_description) | |
| ) { | |
| $payment->set_meta('wte_flitt_payment_attempt', array( | |
| 'status' => 'declined', | |
| 'response_code' => $response_code, | |
| 'response_description' => $response_description, | |
| 'checked_at' => current_time('mysql'), | |
| )); | |
| $payment->save(); | |
| /* | |
| * Keep the WTE Payment pending so another Flitt | |
| * payment attempt can be made. | |
| */ | |
| $booking->sync_payment_pending_metas((int) $payment->get_id(), $amount); | |
| } elseif (in_array( | |
| $order_status, | |
| array( | |
| 'declined', | |
| 'failed', | |
| 'expired', | |
| 'reversed', | |
| ), | |
| true, | |
| )) { | |
| /* | |
| * These are genuine terminal Flitt order states. | |
| */ | |
| $booking->sync_payment_failed_metas((int) $payment->get_id(), $amount); | |
| } elseif (in_array( | |
| $order_status, | |
| array( | |
| 'processing', | |
| 'pending', | |
| ), | |
| true, | |
| )) { | |
| /* | |
| * Genuine processing state. | |
| */ | |
| $booking->sync_payment_pending_metas((int) $payment->get_id(), $amount); | |
| } | |
| /* | |
| * Tell Flitt that the callback was received. | |
| */ | |
| status_header(200); | |
| exit(); | |
| } | |
| /** | |
| * Complete the existing WTE Payment and Booking. | |
| * | |
| * This is the final successful-payment reconciliation. | |
| * | |
| * @param Booking $booking WTE booking. | |
| * @param Payment $payment WTE payment. | |
| * @param array $flitt_data Flitt response data. | |
| * | |
| * @return void | |
| */ | |
| private function complete_wte_payment(Booking $booking, Payment $payment, array $flitt_data): void | |
| { | |
| /* | |
| * Persist the final Flitt response against the existing | |
| * WTE Payment. | |
| */ | |
| $payment->sync_metas(array( | |
| 'wte_flitt_success_response' => $flitt_data, | |
| 'flitt_payment_id' => isset($flitt_data['payment_id']) ? (string) $flitt_data['payment_id'] : '', | |
| 'flitt_order_status' => 'approved', | |
| )); | |
| $payment->save(); | |
| /* | |
| * Let WTE perform its native successful-payment | |
| * synchronization. | |
| */ | |
| $actual_minor = isset($flitt_data['actual_amount']) && $flitt_data['actual_amount'] !== '' | |
| ? (int) $flitt_data['actual_amount'] | |
| : (int) ($flitt_data['amount'] ?? 0); | |
| $amount = $this->convert_amount_from_flitt($actual_minor); | |
| $booking->sync_payment_success_metas((int) $payment->get_id(), $amount); | |
| } | |
| /** | |
| * Build the Flitt order ID from the WTE Payment ID. | |
| * | |
| * @param int $payment_id WTE Payment ID. | |
| * | |
| * @return string | |
| */ | |
| private function build_flitt_order_id(int $payment_id): string | |
| { | |
| return 'wte_payment_' . $payment_id; | |
| } | |
| /** | |
| * Convert a WTE decimal amount into Flitt minor units. | |
| * | |
| * Example: | |
| * | |
| * 95.00 → 9500 | |
| * | |
| * @param float $amount Decimal amount. | |
| * | |
| * @return int | |
| */ | |
| private function convert_amount_to_flitt(float $amount): int | |
| { | |
| return (int) round($amount * 100); | |
| } | |
| /** | |
| * Convert Flitt minor units into a WTE decimal amount. | |
| * | |
| * Example: | |
| * | |
| * 9500 → 95.00 | |
| * | |
| * @param int $amount Minor-unit amount. | |
| * | |
| * @return float | |
| */ | |
| private function convert_amount_from_flitt(int $amount): float | |
| { | |
| return round($amount / 100, 2); | |
| } | |
| /** | |
| * Build the Flitt request signature. | |
| * | |
| * @param array $params Request parameters. | |
| * | |
| * @return string | |
| */ | |
| private function build_signature(array $params): string | |
| { | |
| unset($params['signature']); | |
| ksort($params); | |
| $values = array(); | |
| foreach ($params as $key => $value) { | |
| if (is_array($value)) { | |
| $value = wp_json_encode($value); | |
| } | |
| $values[] = (string) $value; | |
| } | |
| $signature_string = WTE_FLITT_SECRET_KEY . '|' . implode('|', $values); | |
| return sha1($signature_string); | |
| } | |
| /** | |
| * Temporarily diagnose Flitt response signature mismatches. | |
| * | |
| * This version logs the canonical signature data without exposing | |
| * the actual Flitt payment secret key. | |
| * | |
| * @param array $data Flitt response data. | |
| * | |
| * @return bool | |
| */ | |
| private function verify_signature(array $data): bool | |
| { | |
| if (empty($data['signature'])) { | |
| error_log('[WTE Flitt] SIGNATURE DIAGNOSTIC: Flitt response has no signature.'); | |
| return false; | |
| } | |
| $received_signature = (string) $data['signature']; | |
| /* | |
| * Keep a copy of the response so we don't modify the original. | |
| */ | |
| $params = $data; | |
| /* | |
| * These two parameters are explicitly excluded from the | |
| * signature calculation. | |
| */ | |
| unset($params['signature'], $params['response_signature_string']); | |
| /* | |
| * Flitt requires parameters to be sorted alphabetically by key. | |
| */ | |
| ksort($params); | |
| $values = array(); | |
| foreach ($params as $key => $value) { | |
| /* | |
| * Empty and null values are not included. | |
| * | |
| * IMPORTANT: | |
| * 0 and "0" are valid values and MUST remain. | |
| */ | |
| if ($value === '' || $value === null) { | |
| continue; | |
| } | |
| /* | |
| * Structured response values such as additional_info | |
| * must remain represented as JSON. | |
| */ | |
| if (is_array($value)) { | |
| $value = wp_json_encode($value); | |
| } | |
| $values[] = (string) $value; | |
| } | |
| /* | |
| * This is the exact value that will eventually be hashed: | |
| * | |
| * SECRET|value1|value2|value3... | |
| */ | |
| $signature_string = WTE_FLITT_SECRET_KEY . '|' . implode('|', $values); | |
| /* | |
| * Calculate our expected SHA-1 signature. | |
| */ | |
| $expected_signature = sha1($signature_string); | |
| /* | |
| * Never log the actual secret. | |
| * | |
| * Replace it only for diagnostic output. | |
| */ | |
| $safe_signature_string = WTE_FLITT_SECRET_KEY . '|' . implode('|', $values); | |
| /* | |
| * If the secret is defined as a constant, mask it in the | |
| * diagnostic output. | |
| */ | |
| if (defined('WTE_FLITT_SECRET_KEY')) { | |
| $safe_signature_string = '[SECRET]' . substr($safe_signature_string, strlen((string) WTE_FLITT_SECRET_KEY)); | |
| } | |
| /* | |
| * Flitt supplies response_signature_string in test mode. | |
| * It is extremely useful for diagnosing exactly how Flitt | |
| * constructed its signature. | |
| */ | |
| $flitt_signature_string = ''; | |
| if (isset($data['response_signature_string'])) { | |
| $flitt_signature_string = (string) $data['response_signature_string']; | |
| } | |
| /* | |
| * Log the diagnostic information. | |
| * | |
| * DO NOT log the actual WTE_FLITT_SECRET_KEY. | |
| */ | |
| error_log('[WTE Flitt] ================= SIGNATURE DIAGNOSTIC ================='); | |
| error_log('[WTE Flitt] Received Flitt signature: ' . $received_signature); | |
| error_log('[WTE Flitt] Calculated signature: ' . $expected_signature); | |
| error_log( | |
| '[WTE Flitt] Signatures match: ' . (hash_equals($expected_signature, $received_signature) ? 'YES' : 'NO'), | |
| ); | |
| error_log('[WTE Flitt] Flitt response_signature_string: ' . $flitt_signature_string); | |
| error_log('[WTE Flitt] Our signature string (secret masked): ' . $safe_signature_string); | |
| error_log( | |
| '[WTE Flitt] Parameters used in our signature: ' | |
| . wp_json_encode($params, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), | |
| ); | |
| error_log('[WTE Flitt] ================= END SIGNATURE DIAGNOSTIC ================='); | |
| return hash_equals($expected_signature, $received_signature); | |
| } | |
| /** | |
| * Query Flitt for the current order status. | |
| * | |
| * This is used by Phase 4 polling. | |
| * | |
| * @param Payment $payment WTE Payment. | |
| * | |
| * @return array | |
| * | |
| * @throws \RuntimeException When the status request fails. | |
| */ | |
| public function get_flitt_order_status(Payment $payment): array | |
| { | |
| $payment_id = (int) $payment->get_id(); | |
| $order_id = $this->build_flitt_order_id($payment_id); | |
| $params = array( | |
| 'version' => '1.0.1', | |
| 'order_id' => $order_id, | |
| 'merchant_id' => (int) WTE_FLITT_MERCHANT_ID, | |
| ); | |
| $params['signature'] = $this->build_signature($params); | |
| $response = wp_remote_post(WTE_FLITT_API_URL . '/api/status/order_id', array( | |
| 'timeout' => 30, | |
| 'headers' => array( | |
| 'Content-Type' => 'application/json; charset=UTF-8', | |
| 'Accept' => 'application/json', | |
| ), | |
| 'body' => wp_json_encode(array( | |
| 'request' => $params, | |
| )), | |
| )); | |
| if (is_wp_error($response)) { | |
| throw new \RuntimeException('Unable to query Flitt: ' . $response->get_error_message()); | |
| } | |
| $http_status = (int) wp_remote_retrieve_response_code($response); | |
| $body = wp_remote_retrieve_body($response); | |
| $data = json_decode($body, true); | |
| if (!is_array($data)) { | |
| throw new \RuntimeException('Flitt returned invalid status JSON.'); | |
| } | |
| $flitt_response = $data['response'] ?? array(); | |
| if (!is_array($flitt_response)) { | |
| throw new \RuntimeException('Flitt returned an invalid status response.'); | |
| } | |
| /* | |
| * Verify Flitt's status response. | |
| */ | |
| if (!$this->verify_signature($flitt_response)) { | |
| throw new \RuntimeException('Flitt status response signature verification failed.'); | |
| } | |
| /* | |
| * Verify merchant identity. | |
| */ | |
| if ( | |
| isset($flitt_response['merchant_id']) | |
| && (string) $flitt_response['merchant_id'] !== (string) WTE_FLITT_MERCHANT_ID | |
| ) { | |
| throw new \RuntimeException('Flitt status response merchant ID mismatch.'); | |
| } | |
| /* | |
| * Verify the order ID. | |
| */ | |
| $received_order_id = (string) ($flitt_response['order_id'] ?? ''); | |
| if ($received_order_id !== $order_id) { | |
| throw new \RuntimeException('Flitt status response order ID mismatch.'); | |
| } | |
| /* | |
| * Save the latest Flitt status on the existing WTE Payment. | |
| */ | |
| $payment->set_meta('wte_flitt_last_status', array( | |
| 'http_status' => $http_status, | |
| 'order_status' => $flitt_response['order_status'] ?? '', | |
| 'response_status' => $flitt_response['response_status'] ?? '', | |
| 'response_code' => $flitt_response['response_code'] ?? '', | |
| 'response_description' => $flitt_response['response_description'] ?? '', | |
| 'payment_id' => $flitt_response['payment_id'] ?? '', | |
| 'amount' => $flitt_response['amount'] ?? '', | |
| 'actual_amount' => $flitt_response['actual_amount'] ?? '', | |
| 'currency' => $flitt_response['currency'] ?? '', | |
| 'checked_at' => current_time('mysql'), | |
| )); | |
| $payment->save(); | |
| return array( | |
| 'http_status' => $http_status, | |
| 'order_id' => $order_id, | |
| 'order_status' => strtolower(trim((string) ($flitt_response['order_status'] ?? ''))), | |
| 'response_status' => strtolower(trim((string) ($flitt_response['response_status'] ?? ''))), | |
| 'response_code' => isset($flitt_response['response_code']) ? (string) $flitt_response['response_code'] : '', | |
| 'response_description' => isset($flitt_response['response_description']) | |
| ? (string) $flitt_response['response_description'] | |
| : '', | |
| 'payment_id' => isset($flitt_response['payment_id']) ? (string) $flitt_response['payment_id'] : '', | |
| 'amount' => isset($flitt_response['amount']) ? (string) $flitt_response['amount'] : '', | |
| 'actual_amount' => isset($flitt_response['actual_amount']) ? (string) $flitt_response['actual_amount'] : '', | |
| 'currency' => isset($flitt_response['currency']) ? strtoupper((string) $flitt_response['currency']) : '', | |
| 'raw' => $flitt_response, | |
| ); | |
| } | |
| /** | |
| * Reconcile the WTE Payment using the current Flitt status. | |
| * | |
| * This is the main Phase 4 server-side decision point. | |
| * | |
| * @param Payment $payment WTE Payment. | |
| * @param Booking $booking WTE Booking. | |
| * | |
| * @return array | |
| * | |
| * @throws \RuntimeException When reconciliation validation fails. | |
| */ | |
| public function reconcile_payment_status(Payment $payment, Booking $booking): array | |
| { | |
| $status = $this->get_flitt_order_status($payment); | |
| /* | |
| * If WTE already considers the payment completed, | |
| * don't process it again. | |
| */ | |
| if (method_exists($payment, 'is_completed') && $payment->is_completed()) { | |
| return $status | |
| + array( | |
| 'status' => 'approved', | |
| 'payment_attempt_status' => 'approved', | |
| 'wte_payment_status' => 'completed', | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /* | |
| * SUCCESS | |
| * | |
| * Flitt must report: | |
| * | |
| * response_status = success | |
| * order_status = approved | |
| */ | |
| if ('success' === $status['response_status'] && 'approved' === $status['order_status']) { | |
| $currency = strtoupper(trim((string) $status['currency'])); | |
| $expected_currency = strtoupper(trim((string) $payment->get_payable_currency())); | |
| if ($currency !== $expected_currency) { | |
| throw new \RuntimeException('Flitt approved the order in an unexpected currency.'); | |
| } | |
| /* | |
| * Prefer actual_amount once Flitt has approved | |
| * the transaction. | |
| */ | |
| $actual_minor = (int) ($status['actual_amount'] !== '' ? $status['actual_amount'] : $status['amount']); | |
| $expected_minor = $this->convert_amount_to_flitt((float) $payment->get_payable_amount()); | |
| if ($actual_minor < $expected_minor) { | |
| throw new \RuntimeException('Flitt approved an amount lower than the WTE payable amount.'); | |
| } | |
| /* | |
| * Use the verified Flitt response to complete the | |
| * existing WTE Payment and Booking. | |
| */ | |
| $flitt_data = $status['raw']; | |
| $this->complete_wte_payment($booking, $payment, $flitt_data); | |
| return $status | |
| + array( | |
| 'status' => 'approved', | |
| 'payment_attempt_status' => 'approved', | |
| 'wte_payment_status' => 'completed', | |
| 'wte_booking_status' => 'booked', | |
| ); | |
| } | |
| /* | |
| * IMPORTANT: | |
| * | |
| * Flitt delayed orders can remain "processing" after a | |
| * declined card attempt. | |
| * | |
| * Example: | |
| * | |
| * order_status = processing | |
| * response_status = success | |
| * response_code = non-empty | |
| * | |
| * That is NOT the same thing as a genuinely processing | |
| * transaction. | |
| * | |
| * The current card attempt has failed, but the Flitt order | |
| * remains open for another attempt. | |
| */ | |
| if (in_array( | |
| $status['order_status'], | |
| array( | |
| 'processing', | |
| 'pending', | |
| ), | |
| true, | |
| )) { | |
| $has_decline_response = | |
| '' !== trim((string) $status['response_code']) || '' !== trim((string) $status['response_description']); | |
| if ($has_decline_response) { | |
| /* | |
| * Tell the frontend that the payment attempt failed. | |
| * | |
| * We deliberately DO NOT call: | |
| * | |
| * sync_payment_failed_metas() | |
| * | |
| * because the WTE Payment/Booking should remain | |
| * available for another card attempt. | |
| */ | |
| return $status | |
| + array( | |
| 'status' => 'declined', | |
| 'payment_attempt_status' => 'declined', | |
| 'failure_reason' => $status['response_description'], | |
| 'wte_payment_status' => $payment->get_payment_status(), | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /* | |
| * No decline information exists, so this is a genuine | |
| * processing state. | |
| */ | |
| return $status | |
| + array( | |
| 'status' => $status['order_status'], | |
| 'payment_attempt_status' => 'processing', | |
| 'wte_payment_status' => $payment->get_payment_status(), | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /* | |
| * The order has been created but has not entered processing. | |
| */ | |
| if ('created' === $status['order_status']) { | |
| return $status | |
| + array( | |
| 'status' => 'created', | |
| 'payment_attempt_status' => 'processing', | |
| 'wte_payment_status' => $payment->get_payment_status(), | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /* | |
| * TRUE TERMINAL FAILURE | |
| * | |
| * These are actual terminal Flitt order states. | |
| */ | |
| if (in_array( | |
| $status['order_status'], | |
| array( | |
| 'declined', | |
| 'failed', | |
| 'expired', | |
| 'reversed', | |
| ), | |
| true, | |
| )) { | |
| $minor = (int) ($status['actual_amount'] !== '' ? $status['actual_amount'] : $status['amount']); | |
| $amount = $this->convert_amount_from_flitt($minor); | |
| /* | |
| * These states really do mean the Flitt order itself | |
| * has failed, so WTE may mark the Payment as failed. | |
| */ | |
| $booking->sync_payment_failed_metas((int) $payment->get_id(), $amount); | |
| return $status | |
| + array( | |
| 'status' => 'failed', | |
| 'payment_attempt_status' => 'failed', | |
| 'wte_payment_status' => 'failed', | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /* | |
| * Unknown/non-terminal state. | |
| * | |
| * Don't make a destructive WTE status change merely because | |
| * Flitt returned a state we don't recognize yet. | |
| */ | |
| return $status | |
| + array( | |
| 'status' => $status['order_status'], | |
| 'payment_attempt_status' => 'processing', | |
| 'wte_payment_status' => $payment->get_payment_status(), | |
| 'wte_booking_status' => $booking->get_booking_status(), | |
| ); | |
| } | |
| /** | |
| * Write diagnostic information to the PHP error log. | |
| * | |
| * No Flitt secret is written here. | |
| * | |
| * @param string $message Message. | |
| * @param array $context Context. | |
| * | |
| * @return void | |
| */ | |
| private function log_callback(string $message, array $context = array()): void | |
| { | |
| if (defined('WP_DEBUG') && WP_DEBUG) { | |
| error_log('[WTE Flitt] ' . $message . (empty($context) ? '' : ' ' . wp_json_encode($context))); | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // assets/js/wte-flitt.js | |
| (() => { | |
| /* | |
| * Intercept WTE's add-to-cart AJAX request. | |
| * | |
| * The commercial theme normally receives ADD_TO_CART_SUCCESS and | |
| * immediately redirects to WTE's checkout page. We replace that | |
| * response with success:false so the theme does not redirect. | |
| * | |
| * Our own modal then takes over the booking/payment flow. | |
| */ | |
| const originalFetch = window.fetch; | |
| window.fetch = async (...args) => { | |
| const url = String(args[0]); | |
| const isWteAddToCart = | |
| url.includes("admin-ajax.php") && | |
| url.includes("action=wte_add_trip_to_cart"); | |
| if (!isWteAddToCart) { | |
| return originalFetch(...args); | |
| } | |
| const response = await originalFetch(...args); | |
| const clone = response.clone(); | |
| try { | |
| const data = await clone.json(); | |
| if (data?.success && data?.data?.code === "ADD_TO_CART_SUCCESS") { | |
| /* | |
| * Keep the WTE cart data available globally in case other | |
| * parts of the integration need to access it. | |
| */ | |
| window.wteFlittCart = data.data; | |
| /* | |
| * Open our payment modal instead of allowing WTE's | |
| * commercial theme to redirect to /checkout/. | |
| */ | |
| showFlittModal(data.data); | |
| /* | |
| * The commercial theme checks e.success and then does: | |
| * | |
| * window.location.href = e.data.redirect | |
| * | |
| * Returning success:false prevents that redirect. | |
| * | |
| * We retain the WTE response data so the theme can still | |
| * finish its normal JavaScript flow without navigating away. | |
| */ | |
| return new Response( | |
| JSON.stringify({ | |
| success: false, | |
| data: data.data, | |
| }), | |
| { | |
| status: response.status, | |
| statusText: response.statusText, | |
| headers: { | |
| "Content-Type": "application/json", | |
| }, | |
| }, | |
| ); | |
| } | |
| } catch (error) { | |
| /* | |
| * If the response is not JSON, leave it untouched. | |
| */ | |
| console.error("[WTE Flitt] Unable to process WTE response:", error); | |
| } | |
| return response; | |
| }; | |
| })(); | |
| /** | |
| * Creates and displays the Flitt booking/payment modal. | |
| * | |
| * The modal contains the booking information, server-authoritative | |
| * totals, billing form and embedded Flitt checkout container. | |
| */ | |
| function showFlittModal(cartData) { | |
| const items = cartData?.items || {}; | |
| const item = Object.values(items)[0]; | |
| if (!item) { | |
| console.error("[WTE Flitt] No WTE cart item was found."); | |
| return; | |
| } | |
| /* | |
| * Remove any previous WTE modal and any previous Flitt modal. | |
| */ | |
| document | |
| .querySelectorAll(".wpte-modal__screen-overlay") | |
| .forEach((el) => el.remove()); | |
| document.getElementById("wte-flitt-modal")?.remove(); | |
| const modal = document.createElement("div"); | |
| modal.id = "wte-flitt-modal"; | |
| modal.innerHTML = ` | |
| <div class="wte-flitt-overlay"> | |
| <div class="wte-flitt-dialog"> | |
| <button | |
| type="button" | |
| class="wte-flitt-close" | |
| aria-label="Close" | |
| > | |
| × | |
| </button> | |
| <h2> | |
| Complete your booking | |
| </h2> | |
| <div class="wte-flitt-booking"> | |
| <div> | |
| <span>Trip</span> | |
| <strong> | |
| ${escapeHtml(item.trip_id)} | |
| </strong> | |
| </div> | |
| <div> | |
| <span>Date</span> | |
| <strong> | |
| ${escapeHtml(item.trip_date)} | |
| </strong> | |
| </div> | |
| <div> | |
| <span>Passengers</span> | |
| <strong> | |
| ${getPassengerCount(item)} | |
| </strong> | |
| </div> | |
| </div> | |
| <div class="wte-flitt-server-total"> | |
| <h3> | |
| Booking total | |
| </h3> | |
| <div> | |
| <span>Subtotal</span> | |
| <strong class="wte-flitt-subtotal"> | |
| Loading... | |
| </strong> | |
| </div> | |
| <div> | |
| <span>Tax</span> | |
| <strong class="wte-flitt-tax"> | |
| Loading... | |
| </strong> | |
| </div> | |
| <div> | |
| <span>Total</span> | |
| <strong class="wte-flitt-total"> | |
| Loading... | |
| </strong> | |
| </div> | |
| </div> | |
| <div class="wte-flitt-form"> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-fname"> | |
| First name | |
| </label> | |
| <input | |
| type="text" | |
| id="wte-flitt-fname" | |
| name="fname" | |
| autocomplete="given-name" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="fname" | |
| ></div> | |
| </div> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-lname"> | |
| Last name | |
| </label> | |
| <input | |
| type="text" | |
| id="wte-flitt-lname" | |
| name="lname" | |
| autocomplete="family-name" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="lname" | |
| ></div> | |
| </div> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-email"> | |
| </label> | |
| <input | |
| type="email" | |
| id="wte-flitt-email" | |
| name="email" | |
| autocomplete="email" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="email" | |
| ></div> | |
| </div> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-address"> | |
| Address | |
| </label> | |
| <input | |
| type="text" | |
| id="wte-flitt-address" | |
| name="address" | |
| autocomplete="street-address" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="address" | |
| ></div> | |
| </div> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-city"> | |
| City | |
| </label> | |
| <input | |
| type="text" | |
| id="wte-flitt-city" | |
| name="city" | |
| autocomplete="address-level2" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="city" | |
| ></div> | |
| </div> | |
| <div class="wte-flitt-field"> | |
| <label for="wte-flitt-country"> | |
| Country | |
| </label> | |
| <input | |
| type="text" | |
| id="wte-flitt-country" | |
| name="country" | |
| autocomplete="country" | |
| maxlength="2" | |
| placeholder="e.g. GE" | |
| required | |
| > | |
| <div | |
| class="wte-flitt-field-error" | |
| data-field-error="country" | |
| ></div> | |
| </div> | |
| </div> | |
| <div class="wte-flitt-phase1-status"> | |
| Verifying booking total... | |
| </div> | |
| <div | |
| class="wte-flitt-submit-error" | |
| hidden | |
| ></div> | |
| <button | |
| type="button" | |
| class="wte-flitt-pay" | |
| disabled | |
| > | |
| Continue to payment | |
| </button> | |
| <div | |
| class="wte-flitt-checkout" | |
| hidden | |
| > | |
| <div class="wte-flitt-checkout-heading"> | |
| Payment | |
| </div> | |
| <div | |
| id="wte-flitt-checkout-container" | |
| class="wte-flitt-checkout-container" | |
| ></div> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| document.body.appendChild(modal); | |
| /* | |
| * Closing the modal must also stop payment polling. | |
| */ | |
| modal.querySelector(".wte-flitt-close").addEventListener("click", () => { | |
| destroyFlittModal(modal); | |
| }); | |
| /* | |
| * Clicking outside the dialog also closes the modal. | |
| */ | |
| modal | |
| .querySelector(".wte-flitt-overlay") | |
| .addEventListener("click", (event) => { | |
| if (event.target === event.currentTarget) { | |
| destroyFlittModal(modal); | |
| } | |
| }); | |
| /* | |
| * Phase 1: | |
| * | |
| * Ask the server for the authoritative WTE cart instead of trusting | |
| * the values supplied by the browser. | |
| */ | |
| verifyServerCart(modal) | |
| .then((serverCart) => { | |
| displayServerCartTotals(modal, serverCart); | |
| modal.querySelector(".wte-flitt-pay").disabled = false; | |
| modal.querySelector(".wte-flitt-phase1-status").textContent = | |
| "Server cart verified successfully."; | |
| }) | |
| .catch((error) => { | |
| console.error("[WTE Flitt] Server cart verification failed:", error); | |
| const status = modal.querySelector(".wte-flitt-phase1-status"); | |
| status.textContent = | |
| error.message || "Unable to verify the booking total."; | |
| status.classList.add("is-error"); | |
| }); | |
| /* | |
| * Continue to payment: | |
| * | |
| * This starts Phase 2C, followed by Phase 3A. | |
| */ | |
| modal | |
| .querySelector(".wte-flitt-pay") | |
| .addEventListener("click", () => prepareWteBooking(modal)); | |
| } | |
| /** | |
| * Removes the Flitt modal and stops any active payment polling. | |
| */ | |
| function destroyFlittModal(modal) { | |
| stopPaymentPolling(modal); | |
| modal.remove(); | |
| } | |
| /** | |
| * Phase 2C + Phase 3A. | |
| * | |
| * Sends the customer's billing details to WordPress, where WTE creates | |
| * the actual booking/payment and our server creates the corresponding | |
| * Flitt order. | |
| */ | |
| async function prepareWteBooking(modal) { | |
| clearFormErrors(modal); | |
| const button = modal.querySelector(".wte-flitt-pay"); | |
| const errorBox = modal.querySelector(".wte-flitt-submit-error"); | |
| button.disabled = true; | |
| button.textContent = "Preparing payment..."; | |
| errorBox.hidden = true; | |
| /* | |
| * Collect exactly the billing fields used by WTE. | |
| */ | |
| const billing = { | |
| fname: modal.querySelector("#wte-flitt-fname").value.trim(), | |
| lname: modal.querySelector("#wte-flitt-lname").value.trim(), | |
| email: modal.querySelector("#wte-flitt-email").value.trim(), | |
| address: modal.querySelector("#wte-flitt-address").value.trim(), | |
| city: modal.querySelector("#wte-flitt-city").value.trim(), | |
| country: modal | |
| .querySelector("#wte-flitt-country") | |
| .value.trim() | |
| .toUpperCase(), | |
| }; | |
| try { | |
| const formData = new URLSearchParams(); | |
| formData.append("action", "wte_flitt_prepare_booking"); | |
| formData.append("nonce", window.wteFlitt.bookingNonce); | |
| formData.append("billing[fname]", billing.fname); | |
| formData.append("billing[lname]", billing.lname); | |
| formData.append("billing[email]", billing.email); | |
| formData.append("billing[address]", billing.address); | |
| formData.append("billing[city]", billing.city); | |
| formData.append("billing[country]", billing.country); | |
| const response = await fetch(window.wteFlitt.ajaxUrl, { | |
| method: "POST", | |
| credentials: "same-origin", | |
| headers: { | |
| "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", | |
| }, | |
| body: formData.toString(), | |
| }); | |
| // if (!response.ok) { | |
| // throw new Error(`Server returned HTTP ${response.status}.`); | |
| // } | |
| // const data = await response.json(); | |
| // | |
| const responseText = await response.text(); | |
| let data; | |
| try { | |
| data = JSON.parse(responseText); | |
| } catch (parseError) { | |
| console.error("[WTE Flitt] Non-JSON server response:", responseText); | |
| throw new Error( | |
| `Server returned HTTP ${response.status} with a non-JSON response.`, | |
| ); | |
| } | |
| if (!response.ok) { | |
| console.error("[WTE Flitt] Server error response:", data); | |
| throw new Error( | |
| data?.data?.message || `Server returned HTTP ${response.status}.`, | |
| ); | |
| } | |
| if (!data.success) { | |
| displayBookingErrors(modal, data?.data); | |
| throw new Error( | |
| data?.data?.message || "Unable to create the Flitt payment.", | |
| ); | |
| } | |
| const result = data.data || {}; | |
| /* | |
| * Phase 3A must give us a Flitt checkout token. | |
| */ | |
| if (result.phase !== "3A" || result.status !== "flitt_order_created") { | |
| throw new Error("WTE/Flitt returned an unexpected Phase 3A response."); | |
| } | |
| if (!result.flitt || !result.flitt.token) { | |
| throw new Error("Flitt did not return a checkout token."); | |
| } | |
| /* | |
| * Store the server-created booking/payment information so it is | |
| * available to the rest of the browser-side flow. | |
| */ | |
| window.wteFlittBooking = result; | |
| const status = modal.querySelector(".wte-flitt-phase1-status"); | |
| status.textContent = "Flitt payment checkout created."; | |
| status.classList.add("is-success"); | |
| /* | |
| * The customer has moved from the billing form to Flitt. | |
| */ | |
| modal.querySelector(".wte-flitt-form").setAttribute("hidden", ""); | |
| button.setAttribute("hidden", ""); | |
| /* | |
| * Render Flitt's embedded checkout. | |
| */ | |
| await renderFlittCheckout(modal, result); | |
| /* | |
| * Begin checking our server for the final Flitt/WTE payment state. | |
| * | |
| * We deliberately do not trust the embedded checkout alone to | |
| * determine whether the payment succeeded. | |
| */ | |
| startPaymentPolling(modal, result); | |
| } catch (error) { | |
| console.error("[WTE Flitt] Payment preparation failed:", error); | |
| errorBox.textContent = error.message || "Something went wrong."; | |
| errorBox.hidden = false; | |
| button.disabled = false; | |
| button.textContent = "Continue to payment"; | |
| } | |
| } | |
| /** | |
| * Initializes Flitt's embedded checkout inside our modal. | |
| */ | |
| async function renderFlittCheckout(modal, result) { | |
| const checkoutWrapper = modal.querySelector(".wte-flitt-checkout"); | |
| const checkoutContainer = modal.querySelector( | |
| "#wte-flitt-checkout-container", | |
| ); | |
| if (!checkoutWrapper || !checkoutContainer) { | |
| throw new Error("Flitt checkout container was not found."); | |
| } | |
| if (typeof window.checkout !== "function") { | |
| throw new Error("Flitt checkout JavaScript has not loaded."); | |
| } | |
| checkoutWrapper.hidden = false; | |
| /* | |
| * Allow the browser to render the now-visible container before Flitt | |
| * attempts to initialize its embedded checkout. | |
| */ | |
| await new Promise((resolve) => requestAnimationFrame(resolve)); | |
| const token = result.flitt.token; | |
| const options = { | |
| options: { | |
| methods: ["card"], | |
| methods_disabled: [], | |
| card_icons: ["mastercard", "visa", "maestro"], | |
| active_tab: "card", | |
| fields: false, | |
| title: "Secure payment", | |
| full_screen: false, | |
| button: true, | |
| email: true, | |
| show_amount: true, | |
| show_pay_button_amount: true, | |
| show_processed: true, | |
| show_secure_message: true, | |
| show_test_mode: true, | |
| theme: { | |
| type: "light", | |
| preset: "black", | |
| }, | |
| }, | |
| params: { | |
| token: token, | |
| }, | |
| }; | |
| checkoutContainer.innerHTML = ""; | |
| window.checkout("#wte-flitt-checkout-container", options); | |
| } | |
| /** | |
| * Starts server-side payment polling. | |
| * | |
| * The browser polls our WordPress endpoint using the WTE payment key. | |
| * WordPress communicates with Flitt and reconciles the result with | |
| * the existing WTE Payment/Booking. | |
| * | |
| * A declined payment attempt is treated as a terminal attempt failure, | |
| * but the WTE booking/payment remains pending so the customer can retry. | |
| */ | |
| function startPaymentPolling(modal, result) { | |
| const paymentKey = result?.payment_key; | |
| if (!paymentKey) { | |
| console.error( | |
| "[WTE Flitt] Cannot start payment polling: payment key missing.", | |
| ); | |
| return; | |
| } | |
| /* | |
| * Prevent multiple polling loops from running against the same modal. | |
| */ | |
| stopPaymentPolling(modal); | |
| let attempts = 0; | |
| const maxAttempts = 60; | |
| const intervalMs = 3000; | |
| /* | |
| * Prevent overlapping AJAX requests. | |
| */ | |
| let pollInProgress = false; | |
| const status = modal.querySelector(".wte-flitt-phase1-status"); | |
| if (status) { | |
| status.textContent = "Waiting for payment confirmation..."; | |
| status.classList.remove("is-error", "is-success"); | |
| } | |
| /** | |
| * Performs one payment-status check. | |
| */ | |
| const poll = async () => { | |
| if (pollInProgress || !document.body.contains(modal)) { | |
| return; | |
| } | |
| if (attempts >= maxAttempts) { | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = | |
| "We could not confirm the payment yet. Please wait or contact us if your card was charged."; | |
| status.classList.add("is-error"); | |
| } | |
| return; | |
| } | |
| pollInProgress = true; | |
| attempts++; | |
| try { | |
| const formData = new URLSearchParams(); | |
| formData.append("action", "wte_flitt_payment_status"); | |
| formData.append("nonce", window.wteFlitt.statusNonce); | |
| formData.append("payment_key", paymentKey); | |
| const response = await fetch(window.wteFlitt.ajaxUrl, { | |
| method: "POST", | |
| credentials: "same-origin", | |
| headers: { | |
| "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", | |
| }, | |
| body: formData.toString(), | |
| }); | |
| if (!response.ok) { | |
| throw new Error( | |
| `Payment status request returned HTTP ${response.status}.`, | |
| ); | |
| } | |
| const data = await response.json(); | |
| if (!data.success) { | |
| /* | |
| * Temporary server/network errors should not immediately | |
| * turn a potentially valid payment into a failure. | |
| */ | |
| if (attempts >= maxAttempts) { | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = | |
| "Unable to confirm the payment automatically. Please contact us if your card was charged."; | |
| status.classList.add("is-error"); | |
| } | |
| } | |
| return; | |
| } | |
| const resultData = data.data || {}; | |
| /* | |
| * There are two different concepts here: | |
| * | |
| * order_status: | |
| * The overall Flitt order state. | |
| * | |
| * payment_attempt_status: | |
| * The state of the customer's latest payment attempt. | |
| * | |
| * A delayed Flitt order can remain "processing" even when the | |
| * latest card attempt has been declined. | |
| */ | |
| const orderStatus = String( | |
| resultData.order_status || resultData.status || "", | |
| ).toLowerCase(); | |
| const paymentAttemptStatus = String( | |
| resultData.payment_attempt_status || "", | |
| ).toLowerCase(); | |
| const wtePaymentStatus = String( | |
| resultData.wte_payment_status || "", | |
| ).toLowerCase(); | |
| console.log("[WTE Flitt] Payment status:", { | |
| order_status: orderStatus, | |
| payment_attempt_status: paymentAttemptStatus, | |
| wte_payment_status: wtePaymentStatus, | |
| }); | |
| /* | |
| * SUCCESS | |
| * | |
| * We require both Flitt and WTE to report completion before | |
| * redirecting the customer. | |
| */ | |
| if (orderStatus === "approved" && wtePaymentStatus === "completed") { | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = "Payment successful. Redirecting..."; | |
| status.classList.add("is-success"); | |
| } | |
| setTimeout(() => { | |
| if (resultData.redirect_url) { | |
| window.location.href = resultData.redirect_url; | |
| } | |
| }, 700); | |
| return; | |
| } | |
| /* | |
| * TERMINAL PAYMENT-ATTEMPT FAILURE | |
| * | |
| * This is the important change. | |
| * | |
| * Flitt can return: | |
| * | |
| * order_status = processing | |
| * payment_attempt_status = declined | |
| * | |
| * Therefore we must inspect payment_attempt_status. | |
| */ | |
| if ( | |
| ["declined", "failed", "expired", "reversed"].includes( | |
| paymentAttemptStatus, | |
| ) | |
| ) { | |
| /* | |
| * The current Flitt payment attempt has reached a terminal | |
| * failure state. | |
| * | |
| * IMPORTANT: | |
| * | |
| * Do NOT restart polling here. | |
| * | |
| * The status endpoint will continue returning the same declined | |
| * attempt until the customer actually submits another payment. | |
| * Restarting polling immediately would therefore detect the same | |
| * failure again and create an infinite reload loop. | |
| */ | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = "Payment was not completed."; | |
| status.classList.remove("is-success"); | |
| status.classList.add("is-error"); | |
| } | |
| showPaymentFailure(modal, resultData); | |
| /* | |
| * Give Flitt a moment to finish displaying its own failure state, | |
| * then refresh the embedded checkout UI. | |
| * | |
| * We deliberately DO NOT restart our server-side polling here. | |
| */ | |
| setTimeout(() => { | |
| if (!document.body.contains(modal)) { | |
| return; | |
| } | |
| reloadFlittCheckout(modal, result, false); | |
| }, 500); | |
| return; | |
| } | |
| /* | |
| * Order exists but the customer has not completed payment yet. | |
| */ | |
| if (orderStatus === "created") { | |
| if (status) { | |
| status.textContent = "Waiting for payment..."; | |
| } | |
| } else if (orderStatus === "processing") { | |
| if (status) { | |
| status.textContent = "Payment is being processed..."; | |
| } | |
| } else { | |
| if (status) { | |
| status.textContent = "Waiting for payment confirmation..."; | |
| } | |
| } | |
| if (attempts >= maxAttempts) { | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = | |
| "We could not confirm the payment yet. Please contact us if your card was charged."; | |
| status.classList.add("is-error"); | |
| } | |
| } | |
| } catch (error) { | |
| console.error("[WTE Flitt] Payment polling error:", error); | |
| if (attempts >= maxAttempts) { | |
| stopPaymentPolling(modal); | |
| if (status) { | |
| status.textContent = "Unable to confirm the payment automatically."; | |
| status.classList.add("is-error"); | |
| } | |
| } | |
| } finally { | |
| pollInProgress = false; | |
| } | |
| }; | |
| /* | |
| * Check immediately. | |
| */ | |
| poll(); | |
| /* | |
| * Continue checking every three seconds. | |
| */ | |
| modal.wteFlittPolling = setInterval(poll, intervalMs); | |
| } | |
| /** | |
| * Stops the payment-status polling loop for a modal. | |
| */ | |
| function stopPaymentPolling(modal) { | |
| if (modal?.wteFlittPolling) { | |
| clearInterval(modal.wteFlittPolling); | |
| modal.wteFlittPolling = null; | |
| } | |
| } | |
| /** | |
| * Reloads Flitt's embedded checkout after a failed payment attempt. | |
| * | |
| * We recreate the checkout UI so the customer can enter another card. | |
| * | |
| * IMPORTANT: | |
| * | |
| * We do not automatically restart payment polling here. | |
| * The previous payment attempt has already reached a terminal state, | |
| * and polling immediately would simply detect that same old failure again. | |
| */ | |
| async function reloadFlittCheckout(modal, result, restartPolling = false) { | |
| const checkoutWrapper = modal.querySelector(".wte-flitt-checkout"); | |
| const checkoutContainer = modal.querySelector( | |
| "#wte-flitt-checkout-container", | |
| ); | |
| if (!checkoutWrapper || !checkoutContainer) { | |
| console.error( | |
| "[WTE Flitt] Cannot reload checkout: checkout container missing.", | |
| ); | |
| return; | |
| } | |
| if (!result?.flitt?.token) { | |
| console.error( | |
| "[WTE Flitt] Cannot reload checkout: Flitt token missing.", | |
| ); | |
| return; | |
| } | |
| console.log( | |
| "[WTE Flitt] Reloading embedded checkout after failed payment attempt.", | |
| ); | |
| /* | |
| * Clear the existing Flitt iframe/checkout instance. | |
| */ | |
| checkoutContainer.innerHTML = ""; | |
| try { | |
| /* | |
| * Create a fresh Flitt checkout interface. | |
| */ | |
| await renderFlittCheckout(modal, result); | |
| console.log( | |
| "[WTE Flitt] Embedded checkout reloaded successfully.", | |
| ); | |
| /* | |
| * Only restart polling if the caller explicitly requests it. | |
| * | |
| * For a failed attempt this remains false. | |
| */ | |
| if (restartPolling) { | |
| startPaymentPolling(modal, result); | |
| console.log( | |
| "[WTE Flitt] Payment polling restarted.", | |
| ); | |
| } | |
| /* | |
| * Tell the customer that the checkout is ready for another | |
| * payment attempt. | |
| */ | |
| const status = modal.querySelector( | |
| ".wte-flitt-phase1-status", | |
| ); | |
| if (status) { | |
| status.textContent = | |
| "Payment was not completed. Please try again."; | |
| status.classList.remove("is-success"); | |
| status.classList.add("is-error"); | |
| } | |
| } catch (error) { | |
| console.error( | |
| "[WTE Flitt] Failed to reload embedded checkout:", | |
| error, | |
| ); | |
| const status = modal.querySelector( | |
| ".wte-flitt-phase1-status", | |
| ); | |
| if (status) { | |
| status.textContent = | |
| "Payment was not completed. Please try again."; | |
| status.classList.remove("is-success"); | |
| status.classList.add("is-error"); | |
| } | |
| } | |
| } | |
| /** | |
| /** | |
| * Displays a useful payment failure message. | |
| */ | |
| function showPaymentFailure(modal, result) { | |
| const errorBox = modal.querySelector(".wte-flitt-submit-error"); | |
| if (!errorBox) { | |
| return; | |
| } | |
| const responseCode = String(result?.response_code || "").trim(); | |
| const responseDescription = String(result?.response_description || "").trim(); | |
| const failureReason = String(result?.failure_reason || "").trim(); | |
| let message = "Flitt did not approve the payment. Please try again."; | |
| /* | |
| * Prefer Flitt's explicit failure reason when available. | |
| */ | |
| if (failureReason) { | |
| message = `Payment was not completed. ${failureReason}. Please try again.`; | |
| } else if (responseDescription) { | |
| message = `Payment was not completed. ${responseDescription}. Please try again.`; | |
| } | |
| if (responseCode) { | |
| console.log("[WTE Flitt] Payment declined:", { | |
| response_code: responseCode, | |
| response_description: responseDescription, | |
| failure_reason: failureReason, | |
| }); | |
| } | |
| errorBox.textContent = message; | |
| errorBox.hidden = false; | |
| } | |
| /** | |
| * Retrieves the authoritative WTE cart from the WordPress server. | |
| * | |
| * This is Phase 1. It deliberately does not use the amount supplied | |
| * by the intercepted browser request as the source of truth. | |
| */ | |
| async function verifyServerCart(modal) { | |
| if ( | |
| typeof window.wteFlitt === "undefined" || | |
| !window.wteFlitt.ajaxUrl || | |
| !window.wteFlitt.nonce | |
| ) { | |
| throw new Error("WTE Flitt configuration is missing."); | |
| } | |
| const formData = new URLSearchParams(); | |
| formData.append("action", "wte_flitt_get_cart"); | |
| formData.append("nonce", window.wteFlitt.nonce); | |
| const response = await fetch(window.wteFlitt.ajaxUrl, { | |
| method: "POST", | |
| headers: { | |
| "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", | |
| }, | |
| credentials: "same-origin", | |
| body: formData.toString(), | |
| }); | |
| if (!response.ok) { | |
| throw new Error(`Server returned HTTP ${response.status}.`); | |
| } | |
| const data = await response.json(); | |
| if (!data.success) { | |
| throw new Error(data?.data?.message || "Unable to retrieve the WTE cart."); | |
| } | |
| modal.wteServerCart = data.data; | |
| return data.data; | |
| } | |
| /** | |
| * Displays the WTE-authoritative subtotal, tax percentage and total | |
| * in the booking modal. | |
| */ | |
| function displayServerCartTotals(modal, serverCart) { | |
| const subtotalElement = modal.querySelector(".wte-flitt-subtotal"); | |
| const taxElement = modal.querySelector(".wte-flitt-tax"); | |
| const totalElement = modal.querySelector(".wte-flitt-total"); | |
| const taxPercentage = Number(serverCart.tax_percentage || 0); | |
| subtotalElement.textContent = formatAmount( | |
| calculateServerSubtotal(serverCart), | |
| serverCart.currency, | |
| ); | |
| taxElement.textContent = taxPercentage > 0 ? `${taxPercentage}%` : "None"; | |
| totalElement.textContent = formatAmount( | |
| serverCart.total, | |
| serverCart.currency, | |
| ); | |
| } | |
| /** | |
| * Displays field-level validation errors returned by WTE. | |
| */ | |
| function displayBookingErrors(modal, errorData) { | |
| const fields = errorData?.fields || {}; | |
| Object.entries(fields).forEach(([field, message]) => { | |
| const element = modal.querySelector(`[data-field-error="${field}"]`); | |
| if (element) { | |
| element.textContent = message; | |
| } | |
| }); | |
| } | |
| /** | |
| * Clears previous validation and submission errors. | |
| */ | |
| function clearFormErrors(modal) { | |
| modal.querySelectorAll(".wte-flitt-field-error").forEach((element) => { | |
| element.textContent = ""; | |
| }); | |
| const errorBox = modal.querySelector(".wte-flitt-submit-error"); | |
| errorBox.hidden = true; | |
| errorBox.textContent = ""; | |
| } | |
| /** | |
| * Calculates a display-only subtotal from the server cart. | |
| * | |
| * This is only used to show the subtotal in the modal. | |
| * The authoritative total used for payment still comes from WTE. | |
| */ | |
| function calculateServerSubtotal(serverCart) { | |
| const items = serverCart.items || {}; | |
| let subtotal = 0; | |
| Object.values(items).forEach((item) => { | |
| subtotal += Number(item.trip_price || 0); | |
| if (Array.isArray(item.trip_extras)) { | |
| item.trip_extras.forEach((extra) => { | |
| if (typeof extra === "object" && extra !== null) { | |
| const price = Number(extra.price || 0); | |
| const quantity = Number(extra.qty || 0); | |
| subtotal += price * quantity; | |
| } | |
| }); | |
| } | |
| }); | |
| return subtotal; | |
| } | |
| /** | |
| * Formats a monetary amount using the WTE-provided currency. | |
| */ | |
| function formatAmount(amount, currency) { | |
| const value = Number(amount || 0); | |
| if (!currency) { | |
| return value.toFixed(2); | |
| } | |
| try { | |
| return new Intl.NumberFormat(undefined, { | |
| style: "currency", | |
| currency: currency, | |
| }).format(value); | |
| } catch (error) { | |
| return `Error:${error}. Data: ${currency} ${value.toFixed(2)}`; | |
| } | |
| } | |
| /** | |
| * Calculates the total number of travelers in a WTE cart item. | |
| */ | |
| function getPassengerCount(item) { | |
| return Object.values(item.travelers || {}).reduce( | |
| (total, quantity) => total + Number(quantity), | |
| 0, | |
| ); | |
| } | |
| /** | |
| * Escapes values before inserting them into the modal HTML. | |
| */ | |
| function escapeHtml(value) { | |
| return String(value ?? "") | |
| .replace(/&/g, "&") | |
| .replace(/</g, "<") | |
| .replace(/>/g, ">") | |
| .replace(/"/g, """) | |
| .replace(/'/g, "'"); | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // wp-flitt.php | |
| <?php | |
| /** | |
| * Plugin Name: Flitt Payment Integration for WP Travel Engine | |
| * Description: A plugin that integrates Flitt payments with WP Travel Engine. | |
| * Version: 0.3.0 | |
| * Author: Kennedy Gitahi | |
| */ | |
| if (!defined('WPINC')) { | |
| die(); | |
| } | |
| /** | |
| * Plugin directory. | |
| */ | |
| define('WTE_FLITT_PATH', plugin_dir_path(__FILE__)); | |
| /** | |
| * Plugin URL. | |
| */ | |
| define('WTE_FLITT_URL', plugin_dir_url(__FILE__)); | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * FLITT SANDBOX CONFIGURATION | |
| * ------------------------------------------------------------------------- | |
| * | |
| * Temporary sandbox credentials. | |
| * | |
| * These must eventually be moved into WordPress admin settings. | |
| */ | |
| define('WTE_FLITT_MERCHANT_ID', 1549901); | |
| define('WTE_FLITT_SECRET_KEY', 'test'); | |
| define('WTE_FLITT_API_URL', 'https://pay.flitt.com'); | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * LOAD GATEWAY | |
| * ------------------------------------------------------------------------- | |
| */ | |
| add_action( | |
| 'plugins_loaded', | |
| function () { | |
| if (!class_exists('\WPTravelEngine\PaymentGateways\BaseGateway')) { | |
| return; | |
| } | |
| require_once WTE_FLITT_PATH . 'includes/class-wte-flitt-gateway.php'; | |
| }, | |
| 20, | |
| ); | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * REGISTER GATEWAY | |
| * ------------------------------------------------------------------------- | |
| */ | |
| add_filter( | |
| 'wptravelengine_registering_payment_gateways', | |
| function ($payment_gateways) { | |
| if (class_exists('\WTEFlitt\Gateway') && class_exists('\WPTravelEngine\PaymentGateways\BaseGateway')) { | |
| $payment_gateways['flitt'] = new \WTEFlitt\Gateway(); | |
| } | |
| return $payment_gateways; | |
| }, | |
| 20, | |
| ); | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * FRONT-END ASSETS | |
| * ------------------------------------------------------------------------- | |
| */ | |
| add_action('wp_enqueue_scripts', function () { | |
| /* | |
| * Our Flitt modal CSS. | |
| */ | |
| wp_enqueue_style('wte-flitt', WTE_FLITT_URL . 'assets/css/wte-flitt.css', array(), '3.0.0'); | |
| /* | |
| * Flitt's official embedded checkout stylesheet. | |
| */ | |
| wp_enqueue_style('wte-flitt-checkout', 'https://pay.flitt.com/latest/checkout-vue/checkout.css', array(), null); | |
| /* | |
| * Flitt's official embedded checkout JavaScript. | |
| */ | |
| wp_enqueue_script( | |
| 'wte-flitt-checkout', | |
| 'https://pay.flitt.com/latest/checkout-vue/checkout.js', | |
| array(), | |
| null, | |
| true, | |
| ); | |
| /* | |
| * Our integration JavaScript. | |
| */ | |
| wp_enqueue_script( | |
| 'wte-flitt', | |
| WTE_FLITT_URL . 'assets/js/wte-flitt.js', | |
| array( | |
| 'wte-flitt-checkout', | |
| ), | |
| '3.0.0', | |
| true, | |
| ); | |
| wp_localize_script('wte-flitt', 'wteFlitt', array( | |
| 'ajaxUrl' => admin_url('admin-ajax.php'), | |
| /* | |
| * Phase 1 nonce. | |
| */ | |
| 'nonce' => wp_create_nonce('wte_flitt_phase1'), | |
| /* | |
| * Phase 2B/2C nonce. | |
| */ | |
| 'bookingNonce' => wp_create_nonce('wte_flitt_phase2b'), | |
| /* | |
| * Phase 4 status/reconciliation nonce. | |
| */ | |
| 'statusNonce' => wp_create_nonce('wte_flitt_phase4_status'), | |
| )); | |
| }); | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * PHASE 1 | |
| * ------------------------------------------------------------------------- | |
| */ | |
| add_action('wp_ajax_wte_flitt_get_cart', 'wte_flitt_get_cart'); | |
| add_action('wp_ajax_nopriv_wte_flitt_get_cart', 'wte_flitt_get_cart'); | |
| /** | |
| * Get current WTE cart. | |
| * | |
| * @return void | |
| */ | |
| function wte_flitt_get_cart() | |
| { | |
| check_ajax_referer('wte_flitt_phase1', 'nonce'); | |
| if (!isset($GLOBALS['wte_cart']) || !$GLOBALS['wte_cart']) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_CART_NOT_AVAILABLE', | |
| 'message' => 'The WP Travel Engine cart is not available.', | |
| ), 400); | |
| } | |
| $wte_cart = $GLOBALS['wte_cart']; | |
| $items = $wte_cart->getItems(); | |
| if (empty($items)) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_CART_EMPTY', | |
| 'message' => 'The WP Travel Engine cart is empty.', | |
| ), 400); | |
| } | |
| $total = (float) $wte_cart->get_cart_total(); | |
| $tax_percentage = 0; | |
| if (function_exists('wp_travel_engine_get_tax_percentage')) { | |
| $tax_data = wp_travel_engine_get_tax_percentage(); | |
| if (is_array($tax_data) && isset($tax_data['value'])) { | |
| $tax_percentage = (float) $tax_data['value']; | |
| } | |
| } | |
| $currency = ''; | |
| if (function_exists('wptravelengine_settings')) { | |
| $currency = wptravelengine_settings()->get('currency_code', ''); | |
| } | |
| if (!$currency) { | |
| $currency = get_option('wte_currency', ''); | |
| } | |
| if (!$currency) { | |
| $currency = get_option('currency_code', ''); | |
| } | |
| $currency = $currency ? strtoupper(sanitize_text_field($currency)) : ''; | |
| $payment_type = ''; | |
| if (method_exists($wte_cart, 'get_payment_type')) { | |
| $payment_type = $wte_cart->get_payment_type(); | |
| } | |
| wp_send_json_success(array( | |
| 'cart_exists' => true, | |
| 'payment_type' => $payment_type, | |
| 'tax_percentage' => $tax_percentage, | |
| 'total' => round($total, 2), | |
| 'currency' => $currency, | |
| 'items' => $items, | |
| )); | |
| } | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * PHASE 2C | |
| * ------------------------------------------------------------------------- | |
| */ | |
| add_action('wp_ajax_wte_flitt_prepare_booking', 'wte_flitt_prepare_booking'); | |
| add_action('wp_ajax_nopriv_wte_flitt_prepare_booking', 'wte_flitt_prepare_booking'); | |
| /** | |
| * Create the real WTE Booking + Payment through BookingProcess. | |
| * | |
| * @return void | |
| */ | |
| function wte_flitt_prepare_booking() | |
| { | |
| check_ajax_referer('wte_flitt_phase2b', 'nonce'); | |
| if ( | |
| !class_exists('\WPTravelEngine\Core\Booking\BookingProcess') | |
| || !class_exists('\WPTravelEngine\Helpers\Functions') | |
| ) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_BOOKING_PROCESS_UNAVAILABLE', | |
| 'message' => 'The WP Travel Engine booking process is not available.', | |
| ), 500); | |
| } | |
| global $wte_cart; | |
| if (!$wte_cart) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_CART_NOT_AVAILABLE', | |
| 'message' => 'The WP Travel Engine cart is not available.', | |
| ), 400); | |
| } | |
| $items = $wte_cart->getItems(); | |
| if (empty($items)) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_CART_EMPTY', | |
| 'message' => 'The WP Travel Engine cart is empty.', | |
| ), 400); | |
| } | |
| $billing = isset($_POST['billing']) ? wp_unslash($_POST['billing']) : array(); | |
| if (!is_array($billing)) { | |
| $billing = array(); | |
| } | |
| $billing = array( | |
| 'fname' => sanitize_text_field($billing['fname'] ?? ''), | |
| 'lname' => sanitize_text_field($billing['lname'] ?? ''), | |
| 'email' => sanitize_email($billing['email'] ?? ''), | |
| 'address' => sanitize_text_field($billing['address'] ?? ''), | |
| 'city' => sanitize_text_field($billing['city'] ?? ''), | |
| 'country' => strtoupper(sanitize_text_field($billing['country'] ?? '')), | |
| ); | |
| $errors = array(); | |
| if (!$billing['fname']) { | |
| $errors['fname'] = 'First name is required.'; | |
| } | |
| if (!$billing['lname']) { | |
| $errors['lname'] = 'Last name is required.'; | |
| } | |
| if (!$billing['email'] || !is_email($billing['email'])) { | |
| $errors['email'] = 'A valid email address is required.'; | |
| } | |
| if (!$billing['address']) { | |
| $errors['address'] = 'Address is required.'; | |
| } | |
| if (!$billing['city']) { | |
| $errors['city'] = 'City is required.'; | |
| } | |
| if (!$billing['country']) { | |
| $errors['country'] = 'Country is required.'; | |
| } | |
| if (!empty($errors)) { | |
| wp_send_json_error(array( | |
| 'code' => 'INVALID_BILLING_DATA', | |
| 'message' => 'Please complete all required billing fields.', | |
| 'fields' => $errors, | |
| ), 422); | |
| } | |
| $request_data = array( | |
| 'action' => 'wp_travel_engine_new_booking_process_action', | |
| 'wp_travel_engine_new_booking_process_nonce' => wp_create_nonce( | |
| 'wp_travel_engine_new_booking_process_nonce_action', | |
| ), | |
| '_wp_http_referer' => wp_unslash($_SERVER['HTTP_REFERER'] ?? ''), | |
| 'billing' => $billing, | |
| 'wpte_checkout_paymnet_method' => 'flitt', | |
| 'wp_travel_engine_payment_mode' => 'full_payment', | |
| 'wp_travel_engine_booking_setting' => array( | |
| 'terms_conditions' => array(0), | |
| ), | |
| ); | |
| try { | |
| $request = \WPTravelEngine\Helpers\Functions::create_request('POST'); | |
| if (!is_object($request) || !method_exists($request, 'set_body_params')) { | |
| throw new \RuntimeException('WTE did not return a compatible request object.'); | |
| } | |
| $request->set_body_params($request_data); | |
| new \WPTravelEngine\Core\Booking\BookingProcess($request, $wte_cart); | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_BOOKING_PROCESS_RETURNED', | |
| 'message' => 'WTE BookingProcess returned without invoking the Flitt gateway response.', | |
| ), 500); | |
| } catch (\Throwable $e) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_BOOKING_PROCESS_EXCEPTION', | |
| 'message' => $e->getMessage(), | |
| ), 500); | |
| } | |
| } | |
| /** | |
| * ------------------------------------------------------------------------- | |
| * PHASE 4 | |
| * ------------------------------------------------------------------------- | |
| * | |
| * Server-side payment status/reconciliation. | |
| * | |
| * The browser never talks directly to Flitt. | |
| * | |
| * Browser | |
| * ↓ | |
| * WordPress | |
| * ↓ | |
| * Flitt API | |
| * | |
| * This endpoint also acts as a fallback if the Flitt server callback | |
| * arrives after the frontend has started polling. | |
| */ | |
| add_action('wp_ajax_wte_flitt_payment_status', 'wte_flitt_payment_status'); | |
| add_action('wp_ajax_nopriv_wte_flitt_payment_status', 'wte_flitt_payment_status'); | |
| /** | |
| * Check and reconcile a WTE Flitt payment. | |
| * | |
| * @return void | |
| */ | |
| /** | |
| * Check and reconcile a WTE Flitt payment. | |
| * | |
| * The browser never communicates directly with Flitt. | |
| * | |
| * Browser | |
| * ↓ | |
| * WordPress | |
| * ↓ | |
| * Flitt API | |
| * | |
| * The endpoint returns two different concepts: | |
| * | |
| * 1. `status` | |
| * Frontend-facing payment state. | |
| * | |
| * 2. `order_status` | |
| * Flitt's actual raw order status. | |
| * | |
| * This distinction matters because Flitt delayed orders can have: | |
| * | |
| * order_status = processing | |
| * | |
| * while the current card attempt has actually been declined. | |
| * | |
| * @return void | |
| */ | |
| function wte_flitt_payment_status() | |
| { | |
| /* | |
| * Verify the AJAX nonce. | |
| */ | |
| check_ajax_referer('wte_flitt_phase4_status', 'nonce'); | |
| /* | |
| * Get the WTE Payment key supplied by the browser. | |
| */ | |
| $payment_key = isset($_POST['payment_key']) ? sanitize_text_field(wp_unslash($_POST['payment_key'])) : ''; | |
| if (!$payment_key) { | |
| wp_send_json_error(array( | |
| 'code' => 'MISSING_PAYMENT_KEY', | |
| 'message' => 'A payment key is required.', | |
| ), 400); | |
| } | |
| /* | |
| * Make sure the WTE Payment/Booking models exist. | |
| */ | |
| if ( | |
| !class_exists('\WPTravelEngine\Core\Models\Post\Payment') | |
| || !class_exists('\WPTravelEngine\Core\Models\Post\Booking') | |
| ) { | |
| wp_send_json_error(array( | |
| 'code' => 'WTE_MODELS_UNAVAILABLE', | |
| 'message' => 'WTE payment models are unavailable.', | |
| ), 500); | |
| } | |
| try { | |
| /* | |
| * Retrieve the EXISTING WTE Payment. | |
| * | |
| * We are not creating a new payment here. | |
| */ | |
| $payment = \WPTravelEngine\Core\Models\Post\Payment::from_payment_key($payment_key); | |
| if (!$payment || !$payment->get_id()) { | |
| throw new \RuntimeException('The WTE Payment could not be found.'); | |
| } | |
| /* | |
| * Only Flitt payments may be processed by this endpoint. | |
| */ | |
| if ('flitt' !== $payment->get_payment_gateway()) { | |
| throw new \RuntimeException('This payment does not belong to the Flitt gateway.'); | |
| } | |
| /* | |
| * Retrieve the existing WTE Booking attached to | |
| * this Payment. | |
| */ | |
| $booking = $payment->get_booking(); | |
| if (!$booking) { | |
| throw new \RuntimeException('The WTE Booking associated with this payment could not be found.'); | |
| } | |
| /* | |
| * Ask the Flitt gateway to query Flitt and reconcile | |
| * the result against the existing WTE Payment/Booking. | |
| */ | |
| $gateway = new \WTEFlitt\Gateway(); | |
| $result = $gateway->reconcile_payment_status($payment, $booking); | |
| /* | |
| * WTE's native confirmation URL is only returned | |
| * after WTE itself has been marked completed. | |
| */ | |
| $redirect_url = ''; | |
| if ('completed' === ($result['wte_payment_status'] ?? '')) { | |
| $redirect_url = add_query_arg(array( | |
| 'payment_key' => $payment->get_payment_key(), | |
| ), wp_travel_engine_get_booking_confirm_url()); | |
| } | |
| /* | |
| * `status` is the frontend-facing state. | |
| * | |
| * This is deliberately NOT always the same as | |
| * Flitt's raw `order_status`. | |
| * | |
| * For example: | |
| * | |
| * status = declined | |
| * order_status = processing | |
| * | |
| * means the current card attempt was declined while | |
| * the delayed Flitt order itself remains open. | |
| */ | |
| wp_send_json_success(array( | |
| 'phase' => '4', | |
| 'status' => $result['status'] ?? $result['order_status'] ?? '', | |
| 'order_status' => $result['order_status'] ?? '', | |
| 'payment_attempt_status' => $result['payment_attempt_status'] ?? '', | |
| 'failure_reason' => $result['failure_reason'] ?? '', | |
| 'response_status' => $result['response_status'] ?? '', | |
| 'payment_id' => (int) $payment->get_id(), | |
| 'booking_id' => (int) $booking->get_id(), | |
| 'payment_key' => $payment->get_payment_key(), | |
| 'flitt_payment_id' => $result['payment_id'] ?? '', | |
| 'amount' => isset($result['actual_amount']) && $result['actual_amount'] !== '' | |
| ? (float) $result['actual_amount'] / 100 | |
| : (isset($result['amount']) ? (float) $result['amount'] / 100 : 0), | |
| 'currency' => $result['currency'] ?? '', | |
| 'wte_payment_status' => $result['wte_payment_status'] ?? $payment->get_payment_status(), | |
| 'wte_booking_status' => $result['wte_booking_status'] ?? $booking->get_booking_status(), | |
| 'redirect_url' => $redirect_url, | |
| )); | |
| } catch (\Throwable $e) { | |
| /* | |
| * Do not expose internal PHP details beyond the | |
| * message already used by the existing integration. | |
| */ | |
| wp_send_json_error(array( | |
| 'code' => 'FLITT_STATUS_CHECK_FAILED', | |
| 'message' => $e->getMessage(), | |
| ), 500); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment