Skip to content

Instantly share code, notes, and snippets.

@kengitahi
Created August 17, 2026 19:34
Show Gist options
  • Select an option

  • Save kengitahi/d373f8d980a1bf88b709a100676f2a7f to your computer and use it in GitHub Desktop.

Select an option

Save kengitahi/d373f8d980a1bf88b709a100676f2a7f to your computer and use it in GitHub Desktop.
WTE Flitt
// 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)));
}
}
}
// 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"
>
&times;
</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">
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#039;");
}
// 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