| 1 |
<?php |
| 2 |
|
| 3 |
declare(strict_types=1); |
| 4 |
|
| 5 |
namespace Yatra\Controllers; |
| 6 |
|
| 7 |
use WP_REST_Request; |
| 8 |
use WP_REST_Response; |
| 9 |
use WP_Error; |
| 10 |
use Yatra\Repositories\TravellerRepository; |
| 11 |
use Yatra\Repositories\CustomerRepository; |
| 12 |
use Yatra\Repositories\TripRepository; |
| 13 |
use Yatra\Repositories\BookingRepository; |
| 14 |
use Yatra\Services\SettingsService; |
| 15 |
use Yatra\Services\TransactionalEmailTemplateService; |
| 16 |
use Yatra\Services\EmailService; |
| 17 |
use Yatra\Services\DepartureService; |
| 18 |
use Yatra\Services\AvailabilityService; |
| 19 |
use Yatra\Services\CalculationService; |
| 20 |
use Yatra\Repositories\DepartureRepository; |
| 21 |
use Yatra\Repositories\BookingDepartureRepository; |
| 22 |
use Yatra\PaymentGateways\GatewayUserMessages; |
| 23 |
use Yatra\PaymentGateways\PaymentGatewayRegistry; |
| 24 |
use Yatra\Utils\Logger; |
| 25 |
|
| 26 |
/** |
| 27 |
* Booking Session REST API Controller |
| 28 |
* Manages booking session data via REST API |
| 29 |
*/ |
| 30 |
class BookingSessionController extends BaseController |
| 31 |
{ |
| 32 |
/** |
| 33 |
* @var TravellerRepository |
| 34 |
*/ |
| 35 |
private TravellerRepository $travellerRepository; |
| 36 |
|
| 37 |
/** |
| 38 |
* @var CustomerRepository |
| 39 |
*/ |
| 40 |
private CustomerRepository $customerRepository; |
| 41 |
|
| 42 |
/** |
| 43 |
* @var TripRepository |
| 44 |
*/ |
| 45 |
private TripRepository $tripRepository; |
| 46 |
|
| 47 |
/** |
| 48 |
* @var BookingRepository |
| 49 |
*/ |
| 50 |
private BookingRepository $bookingRepository; |
| 51 |
|
| 52 |
/** |
| 53 |
* @var DepartureService |
| 54 |
*/ |
| 55 |
private DepartureService $departureService; |
| 56 |
|
| 57 |
/** |
| 58 |
* @var AvailabilityService |
| 59 |
*/ |
| 60 |
private AvailabilityService $availabilityService; |
| 61 |
|
| 62 |
/** |
| 63 |
* @var \Yatra\Repositories\DiscountRepository |
| 64 |
*/ |
| 65 |
private $discountRepository; |
| 66 |
|
| 67 |
/** |
| 68 |
* Constructor |
| 69 |
*/ |
| 70 |
public function __construct() |
| 71 |
{ |
| 72 |
$this->travellerRepository = new TravellerRepository(); |
| 73 |
$this->customerRepository = new CustomerRepository(); |
| 74 |
$this->tripRepository = new TripRepository(); |
| 75 |
$this->bookingRepository = new BookingRepository(); |
| 76 |
$this->departureService = new DepartureService( |
| 77 |
new DepartureRepository(), |
| 78 |
new \Yatra\Repositories\BookingDepartureRepository(), |
| 79 |
$this->bookingRepository, |
| 80 |
$this->tripRepository |
| 81 |
); |
| 82 |
$this->availabilityService = new AvailabilityService( |
| 83 |
new \Yatra\Repositories\AvailabilityRepository() |
| 84 |
); |
| 85 |
$this->discountRepository = new \Yatra\Repositories\DiscountRepository(); |
| 86 |
} |
| 87 |
|
| 88 |
/** |
| 89 |
* Public permission callback for REST API routes |
| 90 |
* Allows public access to booking endpoints |
| 91 |
* |
| 92 |
* Removes cookie validation to prevent "Cookie check failed" errors for logged-out users |
| 93 |
*/ |
| 94 |
public function public_permission_callback(?WP_REST_Request $request = null): bool |
| 95 |
{ |
| 96 |
// Remove cookie validation requirement for this endpoint |
| 97 |
// This allows guest users to access the endpoint without nonce validation |
| 98 |
remove_filter('rest_authentication_errors', 'rest_cookie_check_errors', 100); |
| 99 |
return true; |
| 100 |
} |
| 101 |
|
| 102 |
/** |
| 103 |
* REST API namespace |
| 104 |
*/ |
| 105 |
protected string $namespace = 'yatra/v1'; |
| 106 |
|
| 107 |
/** |
| 108 |
* Register REST API routes |
| 109 |
*/ |
| 110 |
public function register_routes(): void |
| 111 |
{ |
| 112 |
// Set booking session |
| 113 |
register_rest_route($this->namespace, '/booking/session', [ |
| 114 |
'methods' => 'POST', |
| 115 |
'callback' => [$this, 'set_session'], |
| 116 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 117 |
]); |
| 118 |
|
| 119 |
// Get booking session |
| 120 |
register_rest_route($this->namespace, '/booking/session', [ |
| 121 |
'methods' => 'GET', |
| 122 |
'callback' => [$this, 'get_session'], |
| 123 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 124 |
]); |
| 125 |
|
| 126 |
// Clear booking session |
| 127 |
register_rest_route($this->namespace, '/booking/session', [ |
| 128 |
'methods' => 'DELETE', |
| 129 |
'callback' => [$this, 'clear_session'], |
| 130 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 131 |
]); |
| 132 |
|
| 133 |
// Get trip data for booking |
| 134 |
register_rest_route($this->namespace, '/booking/trip/(?P<id>\d+)', [ |
| 135 |
'methods' => 'GET', |
| 136 |
'callback' => [$this, 'get_trip_for_booking'], |
| 137 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 138 |
'args' => [ |
| 139 |
'id' => [ |
| 140 |
'required' => true, |
| 141 |
'type' => 'integer', |
| 142 |
'sanitize_callback' => 'absint', |
| 143 |
], |
| 144 |
], |
| 145 |
]); |
| 146 |
|
| 147 |
// Create booking |
| 148 |
register_rest_route($this->namespace, '/booking/create', [ |
| 149 |
'methods' => 'POST', |
| 150 |
'callback' => [$this, 'create_booking'], |
| 151 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 152 |
]); |
| 153 |
|
| 154 |
// Verify a guest's booking email via magic-link token. |
| 155 |
// Public + GET so the customer's browser can hit it from a |
| 156 |
// plain email-client link. The token itself carries the |
| 157 |
// authorisation (HMAC-signed); permission_callback is |
| 158 |
// intentionally open. On success the booking transitions to |
| 159 |
// 'pending' and the browser is redirected to the continuation |
| 160 |
// URL (where the customer completes payment as normal). |
| 161 |
register_rest_route($this->namespace, '/booking/verify-email', [ |
| 162 |
'methods' => 'GET', |
| 163 |
'callback' => [$this, 'verify_email'], |
| 164 |
'permission_callback' => '__return_true', |
| 165 |
'args' => [ |
| 166 |
'token' => [ |
| 167 |
'required' => true, |
| 168 |
'type' => 'string', |
| 169 |
'sanitize_callback' => 'sanitize_text_field', |
| 170 |
], |
| 171 |
], |
| 172 |
]); |
| 173 |
|
| 174 |
// Apply coupon code |
| 175 |
register_rest_route($this->namespace, '/booking/coupon/apply', [ |
| 176 |
'methods' => 'POST', |
| 177 |
'callback' => [$this, 'apply_coupon'], |
| 178 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 179 |
]); |
| 180 |
|
| 181 |
// Remove coupon code |
| 182 |
register_rest_route($this->namespace, '/booking/coupon/remove', [ |
| 183 |
'methods' => 'POST', |
| 184 |
'callback' => [$this, 'remove_coupon'], |
| 185 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 186 |
]); |
| 187 |
|
| 188 |
// Calculate booking summary (AJAX endpoint for dynamic updates) |
| 189 |
register_rest_route($this->namespace, '/booking/summary', [ |
| 190 |
'methods' => 'POST', |
| 191 |
'callback' => [$this, 'calculate_summary'], |
| 192 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 193 |
]); |
| 194 |
|
| 195 |
// Complete payment for client-side gateways (Square, etc.) |
| 196 |
register_rest_route($this->namespace, '/payment/(?P<gateway>[a-z_]+)/complete', [ |
| 197 |
'methods' => 'POST', |
| 198 |
'callback' => [$this, 'complete_gateway_payment'], |
| 199 |
'permission_callback' => [$this, 'public_permission_callback'], |
| 200 |
]); |
| 201 |
} |
| 202 |
|
| 203 |
/** |
| 204 |
* Complete payment for client-side payment gateways |
| 205 |
* Used by Square, and other gateways that tokenize on client |
| 206 |
*/ |
| 207 |
public function complete_gateway_payment(WP_REST_Request $request): WP_REST_Response |
| 208 |
{ |
| 209 |
$gateway_id = sanitize_key((string) $request->get_param('gateway')); |
| 210 |
$data = $request->get_json_params(); |
| 211 |
if (!is_array($data)) { |
| 212 |
$data = []; |
| 213 |
} |
| 214 |
|
| 215 |
$booking_id = (int) ($data['booking_id'] ?? 0); |
| 216 |
$source_id = sanitize_text_field((string) ($data['source_id'] ?? '')); |
| 217 |
$client_amount = (float) ($data['amount'] ?? 0); |
| 218 |
$client_currency = sanitize_text_field((string) ($data['currency'] ?? 'USD')); |
| 219 |
|
| 220 |
if ($booking_id <= 0 || $source_id === '') { |
| 221 |
return new WP_REST_Response([ |
| 222 |
'success' => false, |
| 223 |
'message' => __('Missing required payment data.', 'yatra'), |
| 224 |
], 400); |
| 225 |
} |
| 226 |
|
| 227 |
$bookingRepository = new \Yatra\Repositories\BookingRepository(); |
| 228 |
$booking = $bookingRepository->find($booking_id); |
| 229 |
|
| 230 |
// Resolve the guest booking-session token (body first, then ?booking_token=), |
| 231 |
// exactly as the other booking-session endpoints do. |
| 232 |
$booking_token = ''; |
| 233 |
if (!empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 234 |
$booking_token = sanitize_text_field((string) $data['booking_token']); |
| 235 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 236 |
$booking_token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 237 |
} |
| 238 |
|
| 239 |
// H-1: ownership gate (monitor-first). An honest caller either owns the |
| 240 |
// booking (logged-in user / admin) or carries the booking_token bound to |
| 241 |
// it; only a stranger targeting someone else's booking_id is rejected. |
| 242 |
// In monitor mode this just logs and proceeds (zero behaviour change). |
| 243 |
if (!$this->requesterOwnsBooking($booking_id, $booking, $booking_token)) { |
| 244 |
if (\Yatra\Security\Guard::denied('payment_complete_ownership', [ |
| 245 |
'booking_id' => $booking_id, |
| 246 |
'user' => get_current_user_id(), |
| 247 |
'gateway' => $gateway_id, |
| 248 |
])) { |
| 249 |
return new WP_REST_Response([ |
| 250 |
'success' => false, |
| 251 |
'message' => __('You are not allowed to complete this payment.', 'yatra'), |
| 252 |
], 403); |
| 253 |
} |
| 254 |
} |
| 255 |
|
| 256 |
// H-1: server-authoritative amount/currency. Honest clients already send |
| 257 |
// the booking's due amount, so this is invisible to them; it removes the |
| 258 |
// ability to tamper the charged amount. Override only when enforcing. |
| 259 |
$amount = $client_amount; |
| 260 |
$currency = $client_currency; |
| 261 |
if ($booking) { |
| 262 |
$server_amount = (float) ($booking->amount_due ?? 0); |
| 263 |
if ($server_amount <= 0) { |
| 264 |
$server_amount = (float) ($booking->total_amount ?? 0); |
| 265 |
} |
| 266 |
$server_currency = (string) ($booking->currency ?? $client_currency); |
| 267 |
|
| 268 |
if ($server_amount > 0) { |
| 269 |
$mismatch = abs($server_amount - $client_amount) > 0.001 |
| 270 |
|| ($client_currency !== '' && $server_currency !== '' |
| 271 |
&& strcasecmp($client_currency, $server_currency) !== 0); |
| 272 |
|
| 273 |
if ($mismatch) { |
| 274 |
\Yatra\Security\Guard::flag('payment_complete_amount_mismatch', [ |
| 275 |
'booking_id' => $booking_id, |
| 276 |
'client_amount' => $client_amount, |
| 277 |
'server_amount' => $server_amount, |
| 278 |
]); |
| 279 |
} |
| 280 |
|
| 281 |
if (\Yatra\Security\Guard::enforcing()) { |
| 282 |
$amount = $server_amount; |
| 283 |
$currency = $server_currency; |
| 284 |
} |
| 285 |
} |
| 286 |
} |
| 287 |
|
| 288 |
// Get the gateway |
| 289 |
$registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance(); |
| 290 |
$gateway = $registry->get($gateway_id); |
| 291 |
|
| 292 |
if (!$gateway) { |
| 293 |
return new WP_REST_Response([ |
| 294 |
'success' => false, |
| 295 |
'message' => __('Invalid payment gateway.', 'yatra'), |
| 296 |
], 400); |
| 297 |
} |
| 298 |
|
| 299 |
// Check if gateway has createPayment method |
| 300 |
if (!method_exists($gateway, 'createPayment')) { |
| 301 |
return new WP_REST_Response([ |
| 302 |
'success' => false, |
| 303 |
'message' => __('Gateway does not support this payment method.', 'yatra'), |
| 304 |
], 400); |
| 305 |
} |
| 306 |
|
| 307 |
// Create the payment |
| 308 |
$result = $gateway->createPayment([ |
| 309 |
'source_id' => $source_id, |
| 310 |
'booking_id' => $booking_id, |
| 311 |
'amount' => $amount, |
| 312 |
'currency' => $currency, |
| 313 |
]); |
| 314 |
|
| 315 |
if (!$result['success']) { |
| 316 |
return new WP_REST_Response([ |
| 317 |
'success' => false, |
| 318 |
'message' => $result['error'] ?? __('Payment failed.', 'yatra'), |
| 319 |
], 400); |
| 320 |
} |
| 321 |
|
| 322 |
$transaction_id = (string) ($result['transaction_id'] ?? ''); |
| 323 |
|
| 324 |
// Update booking payment status |
| 325 |
if ($booking) { |
| 326 |
$paymentRepository = new \Yatra\Repositories\PaymentRepository(); |
| 327 |
|
| 328 |
// Idempotency guard: never double-record the same gateway transaction |
| 329 |
// for the same booking (e.g. a retried submit or a webhook racing this |
| 330 |
// call). Safe always-on — only blocks a duplicate, never a first payment. |
| 331 |
$alreadyRecorded = false; |
| 332 |
if ($transaction_id !== '' && method_exists($paymentRepository, 'findByTransactionId')) { |
| 333 |
$existing = $paymentRepository->findByTransactionId($transaction_id); |
| 334 |
$alreadyRecorded = $existing && (int) ($existing->booking_id ?? 0) === $booking_id; |
| 335 |
} |
| 336 |
|
| 337 |
if (!$alreadyRecorded) { |
| 338 |
// Record the payment using PaymentRepository |
| 339 |
$paymentRepository->create([ |
| 340 |
'booking_id' => $booking_id, |
| 341 |
'amount' => $amount, |
| 342 |
'currency' => $currency, |
| 343 |
'gateway' => $gateway_id, |
| 344 |
'transaction_id' => $transaction_id, |
| 345 |
'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending', |
| 346 |
]); |
| 347 |
|
| 348 |
// Update booking status if payment is complete |
| 349 |
if (($result['status'] ?? 'completed') === 'completed') { |
| 350 |
// Get total paid amount |
| 351 |
$total_paid = $paymentRepository->getTotalPaidForBooking($booking_id); |
| 352 |
$total_amount = (float) $booking->total_amount; |
| 353 |
|
| 354 |
if ($total_paid >= $total_amount) { |
| 355 |
$prevStatus = (string) ($booking->status ?? 'pending'); |
| 356 |
$bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']); |
| 357 |
\yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus); |
| 358 |
} else { |
| 359 |
$bookingRepository->update($booking_id, ['payment_status' => 'partial']); |
| 360 |
} |
| 361 |
} |
| 362 |
} |
| 363 |
} |
| 364 |
|
| 365 |
return new WP_REST_Response([ |
| 366 |
'success' => true, |
| 367 |
'message' => __('Payment completed successfully.', 'yatra'), |
| 368 |
'data' => [ |
| 369 |
'transaction_id' => $transaction_id, |
| 370 |
'status' => $result['status'] ?? 'completed', |
| 371 |
], |
| 372 |
]); |
| 373 |
} |
| 374 |
|
| 375 |
/** |
| 376 |
* Ownership check for booking-session mutations (H-1 / M-2). |
| 377 |
* |
| 378 |
* Mirrors {@see \Yatra\Controllers\PaymentGatewayController::get_payment_status()}: |
| 379 |
* - admins always pass; |
| 380 |
* - a registered-user booking requires the owning user; |
| 381 |
* - a guest booking (user_id NULL/0) requires the short-lived booking_token |
| 382 |
* transient whose stored `booking_id` matches — i.e. the same browser that |
| 383 |
* started this checkout. Honest guests always carry that token in the URL. |
| 384 |
* |
| 385 |
* @param object|null $booking Booking row, or null when not found. |
| 386 |
*/ |
| 387 |
private function requesterOwnsBooking(int $bookingId, $booking, string $bookingToken): bool |
| 388 |
{ |
| 389 |
if (current_user_can('manage_options')) { |
| 390 |
return true; |
| 391 |
} |
| 392 |
|
| 393 |
if (!$booking) { |
| 394 |
return false; |
| 395 |
} |
| 396 |
|
| 397 |
$bookingUserId = (int) ($booking->user_id ?? 0); |
| 398 |
$currentUserId = (int) get_current_user_id(); |
| 399 |
|
| 400 |
if ($bookingUserId > 0) { |
| 401 |
return $currentUserId === $bookingUserId; |
| 402 |
} |
| 403 |
|
| 404 |
// Guest booking: prove possession of the booking-session token bound to it. |
| 405 |
if ($bookingToken !== '') { |
| 406 |
$session = get_transient($bookingToken); |
| 407 |
if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) { |
| 408 |
return true; |
| 409 |
} |
| 410 |
} |
| 411 |
|
| 412 |
return false; |
| 413 |
} |
| 414 |
|
| 415 |
/** |
| 416 |
* Set booking session data |
| 417 |
* Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts) |
| 418 |
*/ |
| 419 |
public function set_session(WP_REST_Request $request): WP_REST_Response |
| 420 |
{ |
| 421 |
// Ensure session is started for REST API requests |
| 422 |
yatra_start_session(); |
| 423 |
|
| 424 |
$data = $request->get_json_params(); |
| 425 |
|
| 426 |
// M-2: restore CSRF protection stripped by public_permission_callback. |
| 427 |
if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) { |
| 428 |
return $blocked; |
| 429 |
} |
| 430 |
|
| 431 |
// Check if this is a partial update (updating travelers or services in existing session) |
| 432 |
$existing_session = yatra_get_booking_session(); |
| 433 |
|
| 434 |
// REST requests don't always carry PHPSESSID into the WP session scope, |
| 435 |
// so for partial updates (where the JS only sends e.g. |
| 436 |
// `additional_services: [1]`) we may end up with an empty |
| 437 |
// `$existing_session` here and incorrectly bounce to the "trip_id |
| 438 |
// required" guard below. Same fix as create_booking: when the page |
| 439 |
// URL has a `?booking_token=…` (or the body carries it), look up the |
| 440 |
// matching transient and treat it as the session. Without this, every |
| 441 |
// service-toggle returns 400. |
| 442 |
if (empty($existing_session) || empty($existing_session['trip_id'])) { |
| 443 |
$token = null; |
| 444 |
if (!empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 445 |
$token = sanitize_text_field((string) $data['booking_token']); |
| 446 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 447 |
$token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 448 |
} |
| 449 |
if ($token) { |
| 450 |
$transient_data = get_transient($token); |
| 451 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 452 |
$existing_session = $transient_data; |
| 453 |
// CRITICAL: also seed $_SESSION with the rehydrated data |
| 454 |
// AND the original token so yatra_set_booking_session() |
| 455 |
// (called below in the partial-update branch) writes the |
| 456 |
// updated session BACK into the same transient. Without |
| 457 |
// this, the helper generates a fresh random token and |
| 458 |
// writes to a different transient — the user's URL token |
| 459 |
// never gets the new selection persisted, and the next |
| 460 |
// refresh shows stale data. |
| 461 |
$_SESSION['yatra_booking'] = $existing_session; |
| 462 |
$_SESSION['yatra_booking_token'] = $token; |
| 463 |
} |
| 464 |
} |
| 465 |
} |
| 466 |
|
| 467 |
$is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) && |
| 468 |
(isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services'])); |
| 469 |
|
| 470 |
if ($is_partial_update) { |
| 471 |
// Partial update: merge new data with existing session |
| 472 |
if (isset($data['travelers'])) { |
| 473 |
$existing_session['travelers'] = max(1, (int) $data['travelers']); |
| 474 |
} |
| 475 |
if (isset($data['traveler_counts']) && is_array($data['traveler_counts'])) { |
| 476 |
$existing_session['traveler_counts'] = $data['traveler_counts']; |
| 477 |
// Recalculate total travelers from counts |
| 478 |
$existing_session['travelers'] = max(1, array_sum(array_map('intval', $data['traveler_counts']))); |
| 479 |
} |
| 480 |
// Handle additional services selection |
| 481 |
if (isset($data['additional_services']) && is_array($data['additional_services'])) { |
| 482 |
$existing_session['additional_services'] = array_map('intval', $data['additional_services']); |
| 483 |
} |
| 484 |
|
| 485 |
// Recalculate taxes for partial updates |
| 486 |
$trip_id = (int) ($existing_session['trip_id'] ?? 0); |
| 487 |
$trip_price = (float) ($existing_session['trip_price'] ?? $existing_session['base_price'] ?? 0); |
| 488 |
$travelers_count = (int) ($existing_session['travelers'] ?? 1); |
| 489 |
$additional_services = $existing_session['additional_services'] ?? []; |
| 490 |
$travel_date = (string) ($existing_session['travel_date'] ?? ''); |
| 491 |
$departure_time = (string) ($existing_session['departure_time'] ?? ''); |
| 492 |
|
| 493 |
// Calculate base amount - handle traveler-based pricing |
| 494 |
$base_amount = 0; |
| 495 |
$pricing_type = $existing_session['pricing_type'] ?? 'regular'; |
| 496 |
$price_types = $existing_session['price_types'] ?? []; |
| 497 |
$traveler_counts = $existing_session['traveler_counts'] ?? []; |
| 498 |
|
| 499 |
if ($pricing_type === 'traveler_based' && !empty($price_types)) { |
| 500 |
// Calculate for traveler-based pricing |
| 501 |
foreach ($price_types as $pt) { |
| 502 |
$pt = (array) $pt; |
| 503 |
$category_id = $pt['category_id'] ?? 0; |
| 504 |
$pricing_mode = $pt['pricing_mode'] ?? 'per_person'; |
| 505 |
$category_price = isset($pt['effective_price']) ? (float) $pt['effective_price'] : \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt); |
| 506 |
$count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0; |
| 507 |
|
| 508 |
if ($pricing_mode === 'per_group') { |
| 509 |
// Per group: charge flat price once if any travelers in this category |
| 510 |
if ($count > 0) { |
| 511 |
$base_amount += $category_price; |
| 512 |
} |
| 513 |
} else { |
| 514 |
// Per person: charge per traveler |
| 515 |
$base_amount += $category_price * $count; |
| 516 |
} |
| 517 |
} |
| 518 |
} else { |
| 519 |
// Regular pricing |
| 520 |
$base_amount = $trip_price * $travelers_count; |
| 521 |
} |
| 522 |
|
| 523 |
// Use CalculationService for pricing (fetches trip data from database) |
| 524 |
$calculationService = new CalculationService(); |
| 525 |
$pricing = $calculationService->calculatePricing([ |
| 526 |
'trip_id' => $trip_id, |
| 527 |
'travelers_count' => $travelers_count, |
| 528 |
'traveler_counts' => $traveler_counts, |
| 529 |
'travel_date' => $travel_date, |
| 530 |
'departure_time' => $departure_time, |
| 531 |
'selected_services' => $additional_services, |
| 532 |
'coupon_code' => '', |
| 533 |
'payment_method' => 'full', |
| 534 |
]); |
| 535 |
$subtotal = (float) ($pricing['subtotal'] ?? $base_amount); |
| 536 |
$total_with_tax = (float) ($pricing['final_total'] ?? $subtotal); |
| 537 |
$tax_calculation = (array) ($pricing['tax_calculation'] ?? []); |
| 538 |
$services_cost = (float) ($pricing['services_cost'] ?? $pricing['services_total'] ?? 0); |
| 539 |
|
| 540 |
// Note: Pricing is calculated on-demand via CalculationService, not stored in session |
| 541 |
|
| 542 |
$existing_session['timestamp'] = time(); |
| 543 |
|
| 544 |
// Save updated session |
| 545 |
yatra_set_booking_session($existing_session); |
| 546 |
|
| 547 |
return new WP_REST_Response([ |
| 548 |
'success' => true, |
| 549 |
'message' => __('Booking session updated.', 'yatra'), |
| 550 |
'data' => $existing_session, |
| 551 |
]); |
| 552 |
} |
| 553 |
|
| 554 |
if (empty($data['trip_id'])) { |
| 555 |
return new WP_REST_Response([ |
| 556 |
'success' => false, |
| 557 |
'message' => __('Trip ID is required.', 'yatra'), |
| 558 |
], 400); |
| 559 |
} |
| 560 |
|
| 561 |
// Validate trip exists |
| 562 |
$trip = $this->tripRepository->findPublished((int) $data['trip_id']); |
| 563 |
|
| 564 |
if (!$trip) { |
| 565 |
return new WP_REST_Response([ |
| 566 |
'success' => false, |
| 567 |
'message' => __('Trip not found.', 'yatra'), |
| 568 |
], 404); |
| 569 |
} |
| 570 |
|
| 571 |
global $wpdb; |
| 572 |
|
| 573 |
// Get availability-specific data if date provided |
| 574 |
$availability = null; |
| 575 |
// availability_id may be numeric (manual date row) or a synthetic string (rule/default). |
| 576 |
// Keep it as string in session and only cast to int when it is numeric. |
| 577 |
$availability_id = !empty($data['availability_id']) ? sanitize_text_field((string) $data['availability_id']) : null; |
| 578 |
$travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : ''; |
| 579 |
$departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : ''; |
| 580 |
|
| 581 |
// Use centralized AvailabilityResolutionService to get resolved availability |
| 582 |
// Priority: Availability Dates → Recurring Rules → Trip Default (specific rows override patterns) |
| 583 |
// Pass departure_time for day tours with multiple time slots on the same date |
| 584 |
$availability = null; |
| 585 |
if ($travel_date) { |
| 586 |
try { |
| 587 |
$resolutionService = new \Yatra\Services\AvailabilityResolutionService(); |
| 588 |
$availability = $resolutionService->resolveAvailabilityForDate( |
| 589 |
(int) $data['trip_id'], |
| 590 |
$travel_date, |
| 591 |
$departure_time ?: null |
| 592 |
); |
| 593 |
|
| 594 |
if ($availability) { |
| 595 |
$data['availability_id'] = $availability->id; |
| 596 |
$data['seats_available'] = $availability->seats_available; |
| 597 |
$data['seats_total'] = $availability->seats_total; |
| 598 |
} |
| 599 |
} catch (\Exception $e) { |
| 600 |
$availability = null; |
| 601 |
} |
| 602 |
} |
| 603 |
|
| 604 |
// Resolve pricing_type and price_types via centralized TripPricingService |
| 605 |
// Priority: frontend data → availability → trip defaults |
| 606 |
$pricing_type = !empty($data['pricing_type']) |
| 607 |
? sanitize_text_field($data['pricing_type']) |
| 608 |
: \Yatra\Services\TripPricingService::resolvePricingType($trip); |
| 609 |
|
| 610 |
$price_types = []; |
| 611 |
|
| 612 |
// First priority: price_types sent from frontend (from availability card) |
| 613 |
if (!empty($data['price_types']) && is_array($data['price_types'])) { |
| 614 |
$price_types = $data['price_types']; |
| 615 |
} |
| 616 |
// Second priority: availability price_types (already includes trip fallback from AvailabilityResolutionService) |
| 617 |
elseif ($availability && !empty($availability->price_types)) { |
| 618 |
$price_types = is_array($availability->price_types) ? $availability->price_types : []; |
| 619 |
} |
| 620 |
// Third priority: trip's price_types via centralized normalizer |
| 621 |
if (empty($price_types)) { |
| 622 |
$price_types = \Yatra\Services\TripPricingService::resolvePriceTypes($trip); |
| 623 |
} |
| 624 |
// Auto-detect traveler_based if price_types are present |
| 625 |
if (!empty($price_types) && $pricing_type === 'regular') { |
| 626 |
$pricing_type = 'traveler_based'; |
| 627 |
} |
| 628 |
// NOTE: Actual pricing calculation is handled entirely by CalculationService below |
| 629 |
|
| 630 |
// Enrich price_types with category labels if needed (some might already have them from frontend) |
| 631 |
if (!empty($price_types)) { |
| 632 |
$needsEnrichment = false; |
| 633 |
foreach ($price_types as $pt) { |
| 634 |
$pt = (array) $pt; |
| 635 |
if (empty($pt['category_label']) && !empty($pt['category_id'])) { |
| 636 |
$needsEnrichment = true; |
| 637 |
break; |
| 638 |
} |
| 639 |
} |
| 640 |
|
| 641 |
if ($needsEnrichment) { |
| 642 |
$categoryIds = array_filter(array_map(function($p) { |
| 643 |
$p = (array) $p; |
| 644 |
return isset($p['category_id']) ? (int) $p['category_id'] : null; |
| 645 |
}, $price_types)); |
| 646 |
|
| 647 |
if (!empty($categoryIds)) { |
| 648 |
// Use AvailabilityService to get traveler categories |
| 649 |
$cats = $this->availabilityService->getTravelerCategories($categoryIds); |
| 650 |
|
| 651 |
$catIndex = []; |
| 652 |
foreach ($cats as $cat) { |
| 653 |
$catIndex[(int) $cat->id] = $cat; |
| 654 |
} |
| 655 |
|
| 656 |
foreach ($price_types as &$pt) { |
| 657 |
$pt = (array) $pt; |
| 658 |
$catId = isset($pt['category_id']) ? (int) $pt['category_id'] : null; |
| 659 |
if ($catId && isset($catIndex[$catId]) && empty($pt['category_label'])) { |
| 660 |
$cat = $catIndex[$catId]; |
| 661 |
$pt['category_label'] = $cat->label; |
| 662 |
$pt['category_slug'] = $cat->slug; |
| 663 |
$pt['age_min'] = $cat->age_min ? (int) $cat->age_min : null; |
| 664 |
$pt['age_max'] = $cat->age_max ? (int) $cat->age_max : null; |
| 665 |
} |
| 666 |
// Ensure effective price is set via centralized resolver |
| 667 |
if (!isset($pt['effective_price'])) { |
| 668 |
$pt['effective_price'] = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt); |
| 669 |
} |
| 670 |
} |
| 671 |
} |
| 672 |
} |
| 673 |
} |
| 674 |
|
| 675 |
// Parse traveler_counts if provided (for traveler-based pricing) |
| 676 |
$traveler_counts = []; |
| 677 |
if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) { |
| 678 |
$traveler_counts = $data['traveler_counts']; |
| 679 |
} |
| 680 |
|
| 681 |
// Calculate total travelers |
| 682 |
$travelers_count = isset($data['travelers']) ? (int) $data['travelers'] : 0; |
| 683 |
if (!empty($traveler_counts)) { |
| 684 |
$travelers_count = array_sum(array_map('intval', $traveler_counts)); |
| 685 |
} |
| 686 |
|
| 687 |
if ($travelers_count < 1) { |
| 688 |
return new WP_REST_Response([ |
| 689 |
'success' => false, |
| 690 |
'message' => __('Please select at least 1 traveler to continue.', 'yatra'), |
| 691 |
], 400); |
| 692 |
} |
| 693 |
|
| 694 |
// Determine if day trip - prefer frontend value, fallback to trip duration |
| 695 |
$is_day_trip = isset($data['is_day_trip']) ? (bool) $data['is_day_trip'] : (($trip->duration_days ?? 1) <= 1); |
| 696 |
|
| 697 |
// Get additional services from request (selected in popup) |
| 698 |
$additional_services = []; |
| 699 |
if (!empty($data['additional_services']) && is_array($data['additional_services'])) { |
| 700 |
$additional_services = array_map('intval', $data['additional_services']); |
| 701 |
} |
| 702 |
|
| 703 |
// Resolve enabled gateways for this session (use registry to respect availability) |
| 704 |
$gatewayRegistry = PaymentGatewayRegistry::getInstance(); |
| 705 |
$availableGateways = $gatewayRegistry->getForCheckout(); |
| 706 |
$enabled_gateways = []; |
| 707 |
foreach ($availableGateways as $gateway) { |
| 708 |
if (!empty($gateway['id'])) { |
| 709 |
$enabled_gateways[$gateway['id']] = $gateway; |
| 710 |
} |
| 711 |
} |
| 712 |
// Fallback: if registry returned nothing, use saved settings gateways |
| 713 |
if (empty($enabled_gateways)) { |
| 714 |
$settings_gateways = SettingsService::get('payment_gateways', []); |
| 715 |
if (is_array($settings_gateways)) { |
| 716 |
$enabled_gateways = $settings_gateways; |
| 717 |
} |
| 718 |
} |
| 719 |
|
| 720 |
// Use CalculationService as single source of truth for all pricing |
| 721 |
$calculationService = new CalculationService(); |
| 722 |
$pricing = $calculationService->calculatePricing([ |
| 723 |
'trip_id' => (int) $trip->id, |
| 724 |
'travelers_count' => $travelers_count, |
| 725 |
'traveler_counts' => $traveler_counts, |
| 726 |
'travel_date' => $travel_date, |
| 727 |
'departure_time' => $departure_time, |
| 728 |
'selected_services' => $additional_services, |
| 729 |
'availability_id' => $availability_id ? (int) $availability_id : null, |
| 730 |
'coupon_code' => '', |
| 731 |
'payment_method' => 'full', |
| 732 |
]); |
| 733 |
|
| 734 |
// Resolve pricing_mode / group-size limits authoritatively from the |
| 735 |
// TravelerCategory before persisting, so the checkout breakdown (which |
| 736 |
// reads these session price_types) renders a per-group category as a |
| 737 |
// flat charge. Per-person categories are unchanged. |
| 738 |
$price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types); |
| 739 |
|
| 740 |
// Prepare session data - essential trip data (pricing fetched from database on-demand) |
| 741 |
$session_data = [ |
| 742 |
'trip_id' => (int) $trip->id, |
| 743 |
'trip_title' => $trip->title, |
| 744 |
'trip_slug' => $trip->slug, |
| 745 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 746 |
'min_travelers' => (int) ($trip->min_travelers ?: 1), |
| 747 |
'max_travelers' => (int) ($trip->max_travelers ?: 20), |
| 748 |
'duration_days' => (int) ($trip->duration_days ?: 1), |
| 749 |
'travelers' => $travelers_count, |
| 750 |
'travel_date' => $travel_date, |
| 751 |
'departure_time' => $departure_time, |
| 752 |
'timestamp' => time(), |
| 753 |
// Availability-specific data |
| 754 |
'availability_id' => $availability_id, |
| 755 |
'pricing_type' => $pricing['pricing_type'] ?? $pricing_type, |
| 756 |
'price_types' => $price_types, |
| 757 |
'traveler_counts' => $traveler_counts, |
| 758 |
'is_day_trip' => $is_day_trip, |
| 759 |
// Additional services (selected in popup) |
| 760 |
'additional_services' => $additional_services, |
| 761 |
// Payment gateways available for checkout UI |
| 762 |
'enabled_gateways' => $enabled_gateways, |
| 763 |
// Note: NO pricing data stored - fetched from database on-demand via CalculationService |
| 764 |
]; |
| 765 |
|
| 766 |
if (!empty($data['is_remaining_payment'])) { |
| 767 |
$session_data['is_remaining_payment'] = true; |
| 768 |
$session_data['existing_booking_id'] = (int) ($data['existing_booking_id'] ?? 0); |
| 769 |
$session_data['booking_reference'] = $data['booking_reference'] ?? ''; |
| 770 |
$session_data['remaining_amount'] = isset($data['remaining_amount']) ? (float) $data['remaining_amount'] : null; |
| 771 |
$session_data['amount_paid'] = isset($data['amount_paid']) ? (float) $data['amount_paid'] : null; |
| 772 |
$session_data['total_amount'] = isset($data['total_amount']) ? (float) $data['total_amount'] : null; |
| 773 |
} |
| 774 |
|
| 775 |
// Reset any previous session to avoid stale flags |
| 776 |
yatra_clear_booking_session(); |
| 777 |
|
| 778 |
// Set session |
| 779 |
yatra_set_booking_session($session_data); |
| 780 |
|
| 781 |
// Get the booking token directly from session (it's added by yatra_set_booking_session) |
| 782 |
yatra_start_session(); |
| 783 |
$booking_token = $_SESSION['yatra_booking_token'] ?? null; |
| 784 |
|
| 785 |
|
| 786 |
// Fire hook when trip is added to booking session (for Pro modules) |
| 787 |
do_action('yatra_trip_added_to_session', $session_data['trip_id'], $session_data); |
| 788 |
|
| 789 |
// Get redirect URL |
| 790 |
$redirect_url = yatra_get_checkout_url(); |
| 791 |
|
| 792 |
// Add booking token to URL for REST API → page load session restoration |
| 793 |
if ($booking_token) { |
| 794 |
$redirect_url = add_query_arg('booking_token', $booking_token, $redirect_url); |
| 795 |
} |
| 796 |
|
| 797 |
// Add booking_token to response data for debugging |
| 798 |
$session_data['booking_token'] = $booking_token; |
| 799 |
|
| 800 |
return new WP_REST_Response([ |
| 801 |
'success' => true, |
| 802 |
'message' => __('Booking session created.', 'yatra'), |
| 803 |
'data' => $session_data, |
| 804 |
'redirect_url' => $redirect_url, |
| 805 |
]); |
| 806 |
} |
| 807 |
|
| 808 |
/** |
| 809 |
* Get booking session data |
| 810 |
*/ |
| 811 |
public function get_session(WP_REST_Request $request): WP_REST_Response |
| 812 |
{ |
| 813 |
$session_data = yatra_get_booking_session(); |
| 814 |
|
| 815 |
// Recover from transient when PHPSESSID didn't propagate to REST. |
| 816 |
// The JS appends `?booking_token=…` to the GET URL specifically so |
| 817 |
// this branch can rehydrate after a page refresh — without it the |
| 818 |
// sidebar's applied-coupon UI would never re-show and the remove |
| 819 |
// button stayed hidden. |
| 820 |
if (empty($session_data) || empty($session_data['trip_id'])) { |
| 821 |
$token_raw = $request->get_param('booking_token'); |
| 822 |
if (empty($token_raw) && isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 823 |
$token_raw = wp_unslash((string) $_GET['booking_token']); |
| 824 |
} |
| 825 |
if (!empty($token_raw) && is_string($token_raw)) { |
| 826 |
$token = sanitize_text_field($token_raw); |
| 827 |
$transient_data = get_transient($token); |
| 828 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 829 |
$session_data = $transient_data; |
| 830 |
$_SESSION['yatra_booking'] = $session_data; |
| 831 |
$_SESSION['yatra_booking_token'] = $token; |
| 832 |
} |
| 833 |
} |
| 834 |
} |
| 835 |
|
| 836 |
if (empty($session_data) || empty($session_data['trip_id'])) { |
| 837 |
return new WP_REST_Response([ |
| 838 |
'success' => false, |
| 839 |
'message' => __('No active booking session.', 'yatra'), |
| 840 |
'data' => null, |
| 841 |
]); |
| 842 |
} |
| 843 |
|
| 844 |
return new WP_REST_Response([ |
| 845 |
'success' => true, |
| 846 |
'data' => $session_data, |
| 847 |
]); |
| 848 |
} |
| 849 |
|
| 850 |
/** |
| 851 |
* Clear booking session |
| 852 |
*/ |
| 853 |
public function clear_session(WP_REST_Request $request): WP_REST_Response |
| 854 |
{ |
| 855 |
// M-2: restore CSRF protection stripped by public_permission_callback. |
| 856 |
if (($blocked = $this->guardPublicBookingMutation($request)) !== null) { |
| 857 |
return $blocked; |
| 858 |
} |
| 859 |
|
| 860 |
yatra_clear_booking_session(); |
| 861 |
|
| 862 |
return new WP_REST_Response([ |
| 863 |
'success' => true, |
| 864 |
'message' => __('Booking session cleared.', 'yatra'), |
| 865 |
]); |
| 866 |
} |
| 867 |
|
| 868 |
/** |
| 869 |
* Get trip data for booking page |
| 870 |
*/ |
| 871 |
public function get_trip_for_booking(WP_REST_Request $request): WP_REST_Response |
| 872 |
{ |
| 873 |
$trip_id = (int) $request->get_param('id'); |
| 874 |
|
| 875 |
$trip = $this->tripRepository->findPublished($trip_id); |
| 876 |
|
| 877 |
if (!$trip) { |
| 878 |
return new WP_REST_Response([ |
| 879 |
'success' => false, |
| 880 |
'message' => __('Trip not found.', 'yatra'), |
| 881 |
], 404); |
| 882 |
} |
| 883 |
|
| 884 |
// Format trip data for booking |
| 885 |
$trip_data = [ |
| 886 |
'id' => (int) $trip->id, |
| 887 |
'title' => $trip->title, |
| 888 |
'slug' => $trip->slug, |
| 889 |
'featured_image' => $trip->featured_image, |
| 890 |
'duration_days' => (int) $trip->duration_days, |
| 891 |
'duration_nights' => (int) $trip->duration_nights, |
| 892 |
'difficulty_level' => $trip->difficulty_level, |
| 893 |
'min_travelers' => (int) ($trip->min_travelers ?: 1), |
| 894 |
'max_travelers' => (int) ($trip->max_travelers ?: 20), |
| 895 |
'original_price' => (float) $trip->original_price, |
| 896 |
'sale_price' => (float) $trip->sale_price, |
| 897 |
'price' => !empty($trip->discounted_price) ? (float) $trip->discounted_price : (float) $trip->original_price, |
| 898 |
'currency' => \Yatra\Services\SettingsService::getCurrency(), |
| 899 |
'starting_location' => $trip->starting_location, |
| 900 |
'ending_location' => $trip->ending_location, |
| 901 |
]; |
| 902 |
|
| 903 |
return new WP_REST_Response([ |
| 904 |
'success' => true, |
| 905 |
'data' => $trip_data, |
| 906 |
]); |
| 907 |
} |
| 908 |
|
| 909 |
/** |
| 910 |
* Create a new booking OR process remaining payment (same REST route). |
| 911 |
* |
| 912 |
* Regular checkout: persists the booking first (BookingService::createBooking), then starts |
| 913 |
* online payment if needed; payment rows are written when the gateway confirms success |
| 914 |
* (confirm endpoint, webhooks, IPN, or recordGatewayPayment for immediate captures). |
| 915 |
* |
| 916 |
* Remaining / balance payment: does not create a booking — only charges the existing row |
| 917 |
* and records a payment on success via the same gateway completion paths. |
| 918 |
*/ |
| 919 |
/** |
| 920 |
* Validate the booking-scoped CSRF nonce. |
| 921 |
* |
| 922 |
* Looks first in the `X-Yatra-Booking-Nonce` request header |
| 923 |
* (the JS frontend's path), then in JSON body keys used by |
| 924 |
* older or non-JS fallback flows. Returns true on a valid |
| 925 |
* nonce, false otherwise. |
| 926 |
* |
| 927 |
* @param WP_REST_Request $request |
| 928 |
* @param array<string, mixed>|null $data decoded JSON body |
| 929 |
*/ |
| 930 |
private function verifyBookingNonce(WP_REST_Request $request, $data): bool |
| 931 |
{ |
| 932 |
$nonce = (string) $request->get_header('X-Yatra-Booking-Nonce'); |
| 933 |
if ($nonce === '' && \is_array($data)) { |
| 934 |
$nonce = (string) ( |
| 935 |
$data['_yatra_booking_nonce'] |
| 936 |
?? $data['yatra_booking_nonce'] |
| 937 |
?? $data['booking_nonce'] |
| 938 |
?? '' |
| 939 |
); |
| 940 |
} |
| 941 |
if ($nonce === '') { |
| 942 |
return false; |
| 943 |
} |
| 944 |
return (bool) wp_verify_nonce($nonce, 'yatra_booking_action'); |
| 945 |
} |
| 946 |
|
| 947 |
/** |
| 948 |
* CSRF guard for the public booking-session mutations (M-2). |
| 949 |
* |
| 950 |
* `public_permission_callback` strips WP's REST cookie-nonce so guests can |
| 951 |
* reach these routes, which would otherwise leave them open to cross-site |
| 952 |
* forgery of a visitor's session. This restores protection by requiring at |
| 953 |
* least one signal that an honest same-origin checkout always carries: |
| 954 |
* - the booking-scoped nonce (`X-Yatra-Booking-Nonce`), or |
| 955 |
* - a valid WP REST nonce (`X-WP-Nonce`, the one that was stripped), or |
| 956 |
* - a booking_token transient, or |
| 957 |
* - an active PHP booking session. |
| 958 |
* A blind cross-site POST has none of these. |
| 959 |
* |
| 960 |
* Monitor-first: returns a 403 response ONLY when the guard is enforcing; |
| 961 |
* in monitor mode it logs and returns null so behaviour is unchanged. |
| 962 |
* |
| 963 |
* @param array<string, mixed>|null $data decoded JSON body (decoded here if null) |
| 964 |
* @return WP_REST_Response|null 403 response to short-circuit with, or null to proceed |
| 965 |
*/ |
| 966 |
private function guardPublicBookingMutation(WP_REST_Request $request, $data = null): ?WP_REST_Response |
| 967 |
{ |
| 968 |
if ($data === null) { |
| 969 |
$data = $request->get_json_params(); |
| 970 |
} |
| 971 |
|
| 972 |
// 1) booking-scoped nonce, or 2) the stripped WP REST nonce. |
| 973 |
if ($this->verifyBookingNonce($request, $data)) { |
| 974 |
return null; |
| 975 |
} |
| 976 |
$restNonce = (string) $request->get_header('X-WP-Nonce'); |
| 977 |
if ($restNonce !== '' && wp_verify_nonce($restNonce, 'wp_rest')) { |
| 978 |
return null; |
| 979 |
} |
| 980 |
|
| 981 |
// 3) a booking-session token (body first, then ?booking_token=). |
| 982 |
$token = ''; |
| 983 |
if (is_array($data) && !empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 984 |
$token = sanitize_text_field((string) $data['booking_token']); |
| 985 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 986 |
$token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 987 |
} |
| 988 |
if ($token !== '' && is_array(get_transient($token))) { |
| 989 |
return null; |
| 990 |
} |
| 991 |
|
| 992 |
// 4) an active server-side booking session. |
| 993 |
if (function_exists('yatra_get_booking_session')) { |
| 994 |
$session = yatra_get_booking_session(); |
| 995 |
if (!empty($session) && !empty($session['trip_id'])) { |
| 996 |
return null; |
| 997 |
} |
| 998 |
} |
| 999 |
|
| 1000 |
if (\Yatra\Security\Guard::denied('public_booking_csrf', [ |
| 1001 |
'route' => $request->get_route(), |
| 1002 |
])) { |
| 1003 |
return new WP_REST_Response([ |
| 1004 |
'success' => false, |
| 1005 |
'message' => __('Your session could not be verified. Please refresh the page and try again.', 'yatra'), |
| 1006 |
], 403); |
| 1007 |
} |
| 1008 |
|
| 1009 |
return null; |
| 1010 |
} |
| 1011 |
|
| 1012 |
/** |
| 1013 |
* IDs of enabled email-type fields in a single form section. |
| 1014 |
* |
| 1015 |
* "Email type" follows the same rule as the admin form-builder's |
| 1016 |
* "form captures email" notice: a field with type === 'email' OR the |
| 1017 |
* conventional id === 'email'. Used so the booking email can be resolved |
| 1018 |
* from a CUSTOM email field (e.g. id 'work_email') and not only the locked |
| 1019 |
* core `email` field. On a default/un-customised form this returns |
| 1020 |
* ['email'] for the contact section and [] for the traveler section, so |
| 1021 |
* the downstream resolution collapses to the original behaviour. |
| 1022 |
* |
| 1023 |
* @param array<string,mixed> $section |
| 1024 |
* @return array<int,string> |
| 1025 |
*/ |
| 1026 |
private function emailFieldIds(array $section): array |
| 1027 |
{ |
| 1028 |
if (empty($section['fields']) || !is_array($section['fields'])) { |
| 1029 |
return []; |
| 1030 |
} |
| 1031 |
$ids = []; |
| 1032 |
foreach ($section['fields'] as $field) { |
| 1033 |
if (!is_array($field)) { |
| 1034 |
continue; |
| 1035 |
} |
| 1036 |
$enabled = !isset($field['enabled']) || (bool) $field['enabled']; |
| 1037 |
$is_email = (($field['type'] ?? '') === 'email') || (($field['id'] ?? '') === 'email'); |
| 1038 |
if ($enabled && $is_email && !empty($field['id'])) { |
| 1039 |
$ids[] = (string) $field['id']; |
| 1040 |
} |
| 1041 |
} |
| 1042 |
return $ids; |
| 1043 |
} |
| 1044 |
|
| 1045 |
/** |
| 1046 |
* Enforce required booking-form fields server-side (Dynamic Form module). |
| 1047 |
* |
| 1048 |
* Mirrors the frontend's required rules so a crafted request can't omit a |
| 1049 |
* required field (built-in or CUSTOM). Only enabled+required fields in |
| 1050 |
* enabled sections are checked, honouring the operator's saved config. |
| 1051 |
* `email` and contact `phone` are skipped — they have dedicated handling |
| 1052 |
* (email resolution + the contact-phone check). Returns an error message, |
| 1053 |
* or null when everything required is present. |
| 1054 |
* |
| 1055 |
* @param array<string,mixed> $form_config |
| 1056 |
* @param array<string,mixed> $data |
| 1057 |
* @param array<int,mixed> $travelers |
| 1058 |
*/ |
| 1059 |
private function validateRequiredFormFields( |
| 1060 |
array $form_config, |
| 1061 |
array $data, |
| 1062 |
array $travelers, |
| 1063 |
bool $contact_enabled, |
| 1064 |
bool $traveler_enabled |
| 1065 |
): ?string { |
| 1066 |
$is_missing = static function ($value): bool { |
| 1067 |
return !is_scalar($value) || trim((string) $value) === ''; |
| 1068 |
}; |
| 1069 |
|
| 1070 |
// --- Contact section (flat contact_<id> keys) --- |
| 1071 |
if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) { |
| 1072 |
foreach ($form_config['contact_form']['fields'] as $field) { |
| 1073 |
if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') { |
| 1074 |
continue; |
| 1075 |
} |
| 1076 |
$id = (string) $field['id']; |
| 1077 |
if ($id === 'email' || $id === 'phone') { |
| 1078 |
continue; // handled by the email resolution + contact-phone check |
| 1079 |
} |
| 1080 |
if ($is_missing($data['contact_' . $id] ?? null)) { |
| 1081 |
/* translators: %s: form field label. */ |
| 1082 |
return sprintf(__('%s is required.', 'yatra'), (string) ($field['label'] ?? $id)); |
| 1083 |
} |
| 1084 |
} |
| 1085 |
} |
| 1086 |
|
| 1087 |
// --- Emergency section (flat emergency_<id> keys) --- |
| 1088 |
$emergency = $form_config['emergency_contact_form'] ?? null; |
| 1089 |
$emergency_enabled = is_array($emergency) && (!isset($emergency['enabled']) || (bool) $emergency['enabled']); |
| 1090 |
if ($emergency_enabled && !empty($emergency['fields']) && is_array($emergency['fields'])) { |
| 1091 |
foreach ($emergency['fields'] as $field) { |
| 1092 |
if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') { |
| 1093 |
continue; |
| 1094 |
} |
| 1095 |
$id = (string) $field['id']; |
| 1096 |
if ($is_missing($data['emergency_' . $id] ?? null)) { |
| 1097 |
/* translators: %s: emergency contact field label. */ |
| 1098 |
return sprintf(__('Emergency contact: %s is required.', 'yatra'), (string) ($field['label'] ?? $id)); |
| 1099 |
} |
| 1100 |
} |
| 1101 |
} |
| 1102 |
|
| 1103 |
// --- Traveler section (per-traveler travelers[i][<id>]) --- |
| 1104 |
// Skipped when the section is off (book-by-count synthesises travelers). |
| 1105 |
if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) { |
| 1106 |
$required_traveler_fields = []; |
| 1107 |
foreach ($form_config['traveler_form']['fields'] as $field) { |
| 1108 |
if (is_array($field) && !empty($field['enabled']) && !empty($field['required']) && !empty($field['id']) && ($field['type'] ?? '') !== 'text_block') { |
| 1109 |
$required_traveler_fields[(string) $field['id']] = [ |
| 1110 |
'label' => (string) ($field['label'] ?? $field['id']), |
| 1111 |
// "lead" fields are only required on the lead traveler; |
| 1112 |
// absent/"all" is required on every traveler (legacy). |
| 1113 |
'applies_to' => ($field['applies_to'] ?? 'all'), |
| 1114 |
]; |
| 1115 |
} |
| 1116 |
} |
| 1117 |
if (!empty($required_traveler_fields)) { |
| 1118 |
$traveler_index = 0; |
| 1119 |
foreach ($travelers as $traveler) { |
| 1120 |
if (!is_array($traveler)) { |
| 1121 |
continue; |
| 1122 |
} |
| 1123 |
// Only real travelers; skip any contact/emergency pseudo-entries. |
| 1124 |
if (isset($traveler['type']) && $traveler['type'] !== 'traveler') { |
| 1125 |
continue; |
| 1126 |
} |
| 1127 |
$traveler_index++; |
| 1128 |
foreach ($required_traveler_fields as $fid => $meta) { |
| 1129 |
// Lead-only required fields apply to Traveler 1 only. |
| 1130 |
if (($meta['applies_to'] ?? 'all') === 'lead' && $traveler_index !== 1) { |
| 1131 |
continue; |
| 1132 |
} |
| 1133 |
if ($is_missing($traveler[$fid] ?? null)) { |
| 1134 |
/* translators: 1: traveler number, 2: field label. */ |
| 1135 |
return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']); |
| 1136 |
} |
| 1137 |
} |
| 1138 |
} |
| 1139 |
} |
| 1140 |
} |
| 1141 |
|
| 1142 |
return null; |
| 1143 |
} |
| 1144 |
|
| 1145 |
/** |
| 1146 |
* Enforce per-group category size limits at booking time. |
| 1147 |
* |
| 1148 |
* A traveler category priced "per group" (pricing_mode === 'per_group') |
| 1149 |
* charges one flat price for the whole group, bounded by an optional group |
| 1150 |
* size range (min_pax / max_pax) configured on the category. This validates |
| 1151 |
* the selected headcount for each such category against that range. |
| 1152 |
* |
| 1153 |
* It is a strict no-op for per-person categories and for per-group |
| 1154 |
* categories that have no limit configured, so existing trips are |
| 1155 |
* unaffected. Categories that aren't selected (count 0) are skipped. |
| 1156 |
* |
| 1157 |
* @param array<int, mixed> $price_types Resolved price types (carry pricing_mode/min_pax/max_pax). |
| 1158 |
* @param array<int|string, mixed> $traveler_counts Selected count keyed by category id. |
| 1159 |
* @return string|null Error message when a limit is violated, otherwise null. |
| 1160 |
*/ |
| 1161 |
private function validateGroupSizeLimits(array $price_types, array $traveler_counts): ?string |
| 1162 |
{ |
| 1163 |
foreach ($price_types as $pt) { |
| 1164 |
$pt = (array) $pt; |
| 1165 |
|
| 1166 |
if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') { |
| 1167 |
continue; |
| 1168 |
} |
| 1169 |
|
| 1170 |
$cid = $pt['category_id'] ?? null; |
| 1171 |
if ($cid === null) { |
| 1172 |
continue; |
| 1173 |
} |
| 1174 |
|
| 1175 |
// traveler_counts may be keyed by int or string category id. |
| 1176 |
$count = (int) ($traveler_counts[(int) $cid] |
| 1177 |
?? $traveler_counts[(string) $cid] |
| 1178 |
?? 0); |
| 1179 |
if ($count <= 0) { |
| 1180 |
continue; // category not selected — nothing to validate |
| 1181 |
} |
| 1182 |
|
| 1183 |
$label = $pt['category_label'] ?? ($pt['label'] ?? __('group', 'yatra')); |
| 1184 |
$min = (isset($pt['min_pax']) && $pt['min_pax'] !== null && $pt['min_pax'] !== '') ? (int) $pt['min_pax'] : null; |
| 1185 |
$max = (isset($pt['max_pax']) && $pt['max_pax'] !== null && $pt['max_pax'] !== '') ? (int) $pt['max_pax'] : null; |
| 1186 |
$overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block'; |
| 1187 |
|
| 1188 |
if ($min !== null && $min > 0 && $count < $min) { |
| 1189 |
/* translators: 1: category label, 2: minimum group size. */ |
| 1190 |
return sprintf(__('%1$s requires at least %2$d people.', 'yatra'), $label, $min); |
| 1191 |
} |
| 1192 |
// In "per_block" mode a party may exceed the max group size — it just |
| 1193 |
// buys additional group blocks — so only enforce the max for "block". |
| 1194 |
if ($overflow !== 'per_block' && $max !== null && $max > 0 && $count > $max) { |
| 1195 |
/* translators: 1: category label, 2: maximum group size. */ |
| 1196 |
return sprintf(__('%1$s allows a maximum of %2$d people.', 'yatra'), $label, $max); |
| 1197 |
} |
| 1198 |
} |
| 1199 |
|
| 1200 |
return null; |
| 1201 |
} |
| 1202 |
|
| 1203 |
public function create_booking(WP_REST_Request $request): WP_REST_Response |
| 1204 |
{ |
| 1205 |
global $wpdb; |
| 1206 |
|
| 1207 |
$data = $request->get_json_params(); |
| 1208 |
|
| 1209 |
// reCAPTCHA v3 — no-op unless the booking form is explicitly protected in |
| 1210 |
// settings (off by default so payment flows are never gated unless the |
| 1211 |
// operator opts in). |
| 1212 |
$recaptcha = \Yatra\Services\RecaptchaService::verifyForm( |
| 1213 |
'booking', |
| 1214 |
(string) (($data['recaptcha_token'] ?? '') ?: ''), |
| 1215 |
$_SERVER['REMOTE_ADDR'] ?? null |
| 1216 |
); |
| 1217 |
if (empty($recaptcha['success'])) { |
| 1218 |
return new WP_REST_Response([ |
| 1219 |
'success' => false, |
| 1220 |
'message' => $recaptcha['message'] ?? __('reCAPTCHA verification failed.', 'yatra'), |
| 1221 |
], 400); |
| 1222 |
} |
| 1223 |
|
| 1224 |
// ======================================== |
| 1225 |
// CSRF — booking-scoped action nonce |
| 1226 |
// ======================================== |
| 1227 |
// The public_permission_callback on this route intentionally |
| 1228 |
// bypasses WP's default cookie/nonce check so guests can hit it |
| 1229 |
// at all. That bypass would otherwise leave the endpoint open |
| 1230 |
// to cross-site forgery (any third-party page could POST a |
| 1231 |
// booking using the visitor's session). |
| 1232 |
// |
| 1233 |
// We validate a booking-scoped action nonce here instead. The |
| 1234 |
// token is minted at page-render time (FrontendAssetsProvider |
| 1235 |
// injects it into `yatraBookingData.bookingNonce`) and the JS |
| 1236 |
// forwards it in `X-Yatra-Booking-Nonce`. We also accept it in |
| 1237 |
// the JSON body for any non-JS fallback flow. |
| 1238 |
// |
| 1239 |
// Returns 403 on failure — distinct from the 401 used by the |
| 1240 |
// login/guest-checkout gates so frontends can distinguish |
| 1241 |
// "security check failed" from "auth required". |
| 1242 |
if (!$this->verifyBookingNonce($request, $data)) { |
| 1243 |
return new WP_REST_Response([ |
| 1244 |
'success' => false, |
| 1245 |
'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'), |
| 1246 |
'code' => 'invalid_nonce', |
| 1247 |
], 403); |
| 1248 |
} |
| 1249 |
|
| 1250 |
// ======================================== |
| 1251 |
// REMAINING PAYMENT vs NEW BOOKING |
| 1252 |
// ======================================== |
| 1253 |
// A leftover PHP session from "pay remaining balance" must not hijack a normal |
| 1254 |
// checkout POST (full traveler payload). Only treat as remaining-payment when the |
| 1255 |
// client is actually on that flow (hidden field) or sends no new-booking travelers. |
| 1256 |
// Early return: process_remaining_payment() — no createBooking(). |
| 1257 |
if (yatra_has_remaining_session()) { |
| 1258 |
$is_remaining_checkout = !empty($data['is_remaining_payment']); |
| 1259 |
$travelers_payload = $data['travelers'] ?? null; |
| 1260 |
$has_new_booking_travelers = is_array($travelers_payload) && count($travelers_payload) > 0; |
| 1261 |
|
| 1262 |
if ($is_remaining_checkout) { |
| 1263 |
return $this->process_remaining_payment($request); |
| 1264 |
} |
| 1265 |
|
| 1266 |
if ($has_new_booking_travelers) { |
| 1267 |
yatra_clear_remaining_session(); |
| 1268 |
} else { |
| 1269 |
return $this->process_remaining_payment($request); |
| 1270 |
} |
| 1271 |
} |
| 1272 |
|
| 1273 |
// ======================================== |
| 1274 |
// NEW BOOKING CHECKOUT (not pay-remaining) |
| 1275 |
// ======================================== |
| 1276 |
// Below: createBooking() runs once; payment is initiated afterward if amount_due > 0. |
| 1277 |
|
| 1278 |
// ======================================== |
| 1279 |
// GET BOOKING SETTINGS |
| 1280 |
// ======================================== |
| 1281 |
$settings = [ |
| 1282 |
'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true), |
| 1283 |
'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false), |
| 1284 |
'require_login' => \Yatra\Services\SettingsService::get('require_login', false), |
| 1285 |
'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true), |
| 1286 |
'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24), |
| 1287 |
'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true), |
| 1288 |
]; |
| 1289 |
|
| 1290 |
// ======================================== |
| 1291 |
// CHECK LOGIN REQUIREMENT |
| 1292 |
// ======================================== |
| 1293 |
if ($settings['require_login'] && !is_user_logged_in()) { |
| 1294 |
return new WP_REST_Response([ |
| 1295 |
'success' => false, |
| 1296 |
'message' => __('You must be logged in to make a booking.', 'yatra'), |
| 1297 |
'code' => 'login_required', |
| 1298 |
'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')), |
| 1299 |
], 401); |
| 1300 |
} |
| 1301 |
|
| 1302 |
// Check guest checkout |
| 1303 |
if (!$settings['allow_guest_checkout'] && !is_user_logged_in()) { |
| 1304 |
return new WP_REST_Response([ |
| 1305 |
'success' => false, |
| 1306 |
'message' => __('Guest checkout is not allowed. Please log in or create an account.', 'yatra'), |
| 1307 |
'code' => 'guest_not_allowed', |
| 1308 |
'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')), |
| 1309 |
], 401); |
| 1310 |
} |
| 1311 |
|
| 1312 |
// ======================================== |
| 1313 |
// GUEST EMAIL VERIFICATION GATE |
| 1314 |
// ======================================== |
| 1315 |
// When `require_guest_email_verification` is on AND the |
| 1316 |
// customer is not logged in, the booking goes through a |
| 1317 |
// two-step flow: |
| 1318 |
// 1. Booking row is created with status='pending_verification' |
| 1319 |
// so the operator sees the intent in the admin and the |
| 1320 |
// cron can purge unverified rows after N days. |
| 1321 |
// 2. A magic-link email is sent. Payment is NOT initiated |
| 1322 |
// until the customer clicks the link. |
| 1323 |
// 3. On click, /yatra/v1/booking/verify-email validates the |
| 1324 |
// HMAC token, flips status to 'pending', and redirects |
| 1325 |
// to the payment continuation URL. |
| 1326 |
// Logged-in users skip this entirely — their email is already |
| 1327 |
// verified by WordPress on registration. |
| 1328 |
$needs_email_verification = !is_user_logged_in() |
| 1329 |
&& (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false); |
| 1330 |
|
| 1331 |
// Get session data |
| 1332 |
$session = yatra_get_booking_session(); |
| 1333 |
|
| 1334 |
// REST requests don't always carry PHPSESSID in the same scope as the |
| 1335 |
// page that rendered the booking form (cookie path mismatches, output |
| 1336 |
// buffering before session_start, server-side caching, etc). When |
| 1337 |
// `$session` is empty we try to rehydrate from the transient backup that |
| 1338 |
// BookingPageHandler writes when the form is rendered — the JS submit |
| 1339 |
// now carries `booking_token` in the request body for exactly this |
| 1340 |
// recovery path. Without this, `traveler_counts` / `pricing_type` / |
| 1341 |
// `price_types` are lost and a `traveler_based` trip's |
| 1342 |
// calculatePricing() collapses to 0, then BookingService rejects with |
| 1343 |
// "Total amount must be greater than zero." |
| 1344 |
if ((empty($session) || empty($session['trip_id'])) && !empty($data['booking_token'])) { |
| 1345 |
$tokenFromBody = sanitize_text_field((string) $data['booking_token']); |
| 1346 |
if ($tokenFromBody !== '') { |
| 1347 |
$transient_data = get_transient($tokenFromBody); |
| 1348 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 1349 |
$session = $transient_data; |
| 1350 |
} |
| 1351 |
} |
| 1352 |
} |
| 1353 |
|
| 1354 |
// Validate we have session data or direct booking data |
| 1355 |
$trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0); |
| 1356 |
|
| 1357 |
if (!$trip_id) { |
| 1358 |
return new WP_REST_Response([ |
| 1359 |
'success' => false, |
| 1360 |
'message' => __('No trip selected for booking.', 'yatra'), |
| 1361 |
], 400); |
| 1362 |
} |
| 1363 |
|
| 1364 |
// Which booking-form sections are enabled (Pro Dynamic Form module). |
| 1365 |
// The default config has every section enabled, so on existing/un-customised |
| 1366 |
// sites $contact_enabled and $traveler_enabled are both true and the logic |
| 1367 |
// below behaves exactly as before — only disabled sections change anything. |
| 1368 |
$form_config = function_exists('yatra_get_booking_form_config') ? yatra_get_booking_form_config() : []; |
| 1369 |
$contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled']; |
| 1370 |
$traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled']; |
| 1371 |
|
| 1372 |
// Get contact email - handle both flat and nested formats |
| 1373 |
$contact_email = trim((string) ($data['contact_email'] ?? '')); |
| 1374 |
$contact_phone = $data['contact_phone'] ?? ''; |
| 1375 |
// International phone widget: fold the chosen country (companion |
| 1376 |
// *_country field carrying the ISO) into the number as "+<dial><digits>". |
| 1377 |
// A no-op for legacy submissions with no companion field, an already |
| 1378 |
// "+"-prefixed value, or an unknown ISO — so existing data is never |
| 1379 |
// altered and nothing is invented. |
| 1380 |
$contact_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone( |
| 1381 |
(string) $contact_phone, |
| 1382 |
(string) ($data['contact_phone_country'] ?? '') |
| 1383 |
); |
| 1384 |
$contact_first_name = $data['contact_first_name'] ?? ''; |
| 1385 |
$contact_last_name = $data['contact_last_name'] ?? ''; |
| 1386 |
$contact_country = $data['contact_country'] ?? ''; |
| 1387 |
|
| 1388 |
$contact_nationality = $data['contact_nationality'] ?? ''; |
| 1389 |
$contact_address = $data['contact_address'] ?? ''; |
| 1390 |
|
| 1391 |
// Emergency contact |
| 1392 |
$emergency_name = $data['emergency_name'] ?? ''; |
| 1393 |
$emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone( |
| 1394 |
(string) ($data['emergency_phone'] ?? ''), |
| 1395 |
(string) ($data['emergency_phone_country'] ?? '') |
| 1396 |
); |
| 1397 |
$emergency_relationship = $data['emergency_relationship'] ?? ''; |
| 1398 |
|
| 1399 |
// Travel details |
| 1400 |
$travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? ''); |
| 1401 |
$travelers = $data['travelers'] ?? []; |
| 1402 |
|
| 1403 |
// EMAIL RESOLUTION: prefer the Contact email. When it's missing — the |
| 1404 |
// Contact section is off, or the operator collects email through a |
| 1405 |
// CUSTOM email-type field rather than the locked core `email` field — |
| 1406 |
// resolve it from the form config instead, mirroring the admin |
| 1407 |
// "form captures email" notice (contact + traveler sections). At least |
| 1408 |
// one enabled form must capture an email; the form builder warns the |
| 1409 |
// operator about this too. On a default form the core `email` field |
| 1410 |
// already populated $contact_email, so none of the fallbacks run. |
| 1411 |
|
| 1412 |
// (a) Custom email-type field in the Contact section (submitted as |
| 1413 |
// contact_<id>). The core `email` field is already read above, so skip |
| 1414 |
// it here. |
| 1415 |
if ($contact_email === '' && $contact_enabled) { |
| 1416 |
foreach ($this->emailFieldIds($form_config['contact_form'] ?? []) as $fid) { |
| 1417 |
if ($fid === 'email') { |
| 1418 |
continue; |
| 1419 |
} |
| 1420 |
$val = trim((string) ($data['contact_' . $fid] ?? '')); |
| 1421 |
if ($val !== '' && is_email($val)) { |
| 1422 |
$contact_email = $val; |
| 1423 |
break; |
| 1424 |
} |
| 1425 |
} |
| 1426 |
} |
| 1427 |
|
| 1428 |
// (b) Fall back to a traveler email — the conventional `email` key OR |
| 1429 |
// any traveler email-type field — adopting the lead traveler's |
| 1430 |
// name/phone as the contact when the Contact section is off, so the |
| 1431 |
// booking/customer isn't nameless. On a default form this checks only |
| 1432 |
// $t['email'], identical to the original behaviour. |
| 1433 |
if ($contact_email === '' && is_array($travelers)) { |
| 1434 |
$traveler_email_ids = $traveler_enabled |
| 1435 |
? $this->emailFieldIds($form_config['traveler_form'] ?? []) |
| 1436 |
: []; |
| 1437 |
if (!in_array('email', $traveler_email_ids, true)) { |
| 1438 |
$traveler_email_ids[] = 'email'; |
| 1439 |
} |
| 1440 |
foreach ($travelers as $t) { |
| 1441 |
if (!is_array($t)) { |
| 1442 |
continue; |
| 1443 |
} |
| 1444 |
$found = ''; |
| 1445 |
foreach ($traveler_email_ids as $fid) { |
| 1446 |
if (!empty($t[$fid]) && is_email((string) $t[$fid])) { |
| 1447 |
$found = trim((string) $t[$fid]); |
| 1448 |
break; |
| 1449 |
} |
| 1450 |
} |
| 1451 |
if ($found !== '') { |
| 1452 |
$contact_email = $found; |
| 1453 |
if ($contact_first_name === '') { $contact_first_name = (string) ($t['first_name'] ?? ''); } |
| 1454 |
if ($contact_last_name === '') { $contact_last_name = (string) ($t['last_name'] ?? ''); } |
| 1455 |
if (empty($contact_phone) && !empty($t['phone'])) { $contact_phone = (string) $t['phone']; } |
| 1456 |
break; |
| 1457 |
} |
| 1458 |
} |
| 1459 |
} |
| 1460 |
|
| 1461 |
// When the Traveler form is disabled there are no per-traveler fields, so |
| 1462 |
// build traveler rows from the selected count and use the lead contact as |
| 1463 |
// traveler 1 (book-by-count). Only runs when the section is off. |
| 1464 |
if (!$traveler_enabled && (empty($travelers) || !is_array($travelers))) { |
| 1465 |
$synth_count = (int) ($data['travelers_count'] |
| 1466 |
?? $session['travelers'] |
| 1467 |
?? (is_array($session['traveler_counts'] ?? null) ? array_sum(array_map('intval', $session['traveler_counts'])) : 0)); |
| 1468 |
$synth_count = max(1, $synth_count); |
| 1469 |
$travelers = []; |
| 1470 |
for ($i = 1; $i <= $synth_count; $i++) { |
| 1471 |
$travelers[] = [ |
| 1472 |
'type' => 'traveler', |
| 1473 |
'first_name' => $i === 1 ? $contact_first_name : '', |
| 1474 |
'last_name' => $i === 1 ? $contact_last_name : '', |
| 1475 |
'email' => $i === 1 ? $contact_email : '', |
| 1476 |
]; |
| 1477 |
} |
| 1478 |
} |
| 1479 |
|
| 1480 |
// Validate required fields — email is always required (resolved above). |
| 1481 |
if ($contact_email === '' || !is_email($contact_email)) { |
| 1482 |
return new WP_REST_Response([ |
| 1483 |
'success' => false, |
| 1484 |
'message' => __('A valid email address is required to complete this booking.', 'yatra'), |
| 1485 |
], 400); |
| 1486 |
} |
| 1487 |
|
| 1488 |
// Phone belongs to the Contact section. Require it only when that section |
| 1489 |
// is enabled AND the phone field is itself enabled+required in the config, |
| 1490 |
// so an operator who made phone optional (or disabled it) via the Dynamic |
| 1491 |
// Form module isn't blocked on a field the customer never saw. On a |
| 1492 |
// default form phone is locked+required, so this is unchanged for |
| 1493 |
// existing Free/Pro users. |
| 1494 |
$contact_phone_required = false; |
| 1495 |
if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) { |
| 1496 |
foreach ($form_config['contact_form']['fields'] as $cf) { |
| 1497 |
if (is_array($cf) && ($cf['id'] ?? '') === 'phone') { |
| 1498 |
$cf_enabled = !isset($cf['enabled']) || (bool) $cf['enabled']; |
| 1499 |
$contact_phone_required = $cf_enabled && !empty($cf['required']); |
| 1500 |
break; |
| 1501 |
} |
| 1502 |
} |
| 1503 |
} elseif ($contact_enabled) { |
| 1504 |
// No field metadata available (legacy/edge): preserve the original |
| 1505 |
// "require phone when contact is on" behaviour. |
| 1506 |
$contact_phone_required = true; |
| 1507 |
} |
| 1508 |
if ($contact_phone_required && empty($contact_phone)) { |
| 1509 |
return new WP_REST_Response([ |
| 1510 |
'success' => false, |
| 1511 |
'message' => __('Phone number is required.', 'yatra'), |
| 1512 |
], 400); |
| 1513 |
} |
| 1514 |
|
| 1515 |
if (empty($travel_date)) { |
| 1516 |
return new WP_REST_Response([ |
| 1517 |
'success' => false, |
| 1518 |
'message' => __('Travel date is required.', 'yatra'), |
| 1519 |
], 400); |
| 1520 |
} |
| 1521 |
|
| 1522 |
if (empty($travelers) || !is_array($travelers)) { |
| 1523 |
return new WP_REST_Response([ |
| 1524 |
'success' => false, |
| 1525 |
'message' => __('At least one traveler is required.', 'yatra'), |
| 1526 |
], 400); |
| 1527 |
} |
| 1528 |
|
| 1529 |
// Server-side enforcement of required form fields (incl. CUSTOM fields). |
| 1530 |
// Gated on the Dynamic Form Field module: free/default installs keep their |
| 1531 |
// existing validation untouched. Mirrors the frontend's required rules so |
| 1532 |
// a crafted request can't bypass them; respects the operator's config |
| 1533 |
// (only enabled+required fields in enabled sections are checked). |
| 1534 |
if (function_exists('apply_filters') && apply_filters('yatra_dynamic_form_field_enabled', false)) { |
| 1535 |
$required_error = $this->validateRequiredFormFields( |
| 1536 |
is_array($form_config) ? $form_config : [], |
| 1537 |
$data, |
| 1538 |
$travelers, |
| 1539 |
$contact_enabled, |
| 1540 |
$traveler_enabled |
| 1541 |
); |
| 1542 |
if ($required_error !== null) { |
| 1543 |
return new WP_REST_Response([ |
| 1544 |
'success' => false, |
| 1545 |
'message' => $required_error, |
| 1546 |
], 400); |
| 1547 |
} |
| 1548 |
} |
| 1549 |
|
| 1550 |
// Get trip data |
| 1551 |
$trip = $this->tripRepository->findPublished($trip_id); |
| 1552 |
|
| 1553 |
if (!$trip) { |
| 1554 |
return new WP_REST_Response([ |
| 1555 |
'success' => false, |
| 1556 |
'message' => __('Trip not found.', 'yatra'), |
| 1557 |
], 404); |
| 1558 |
} |
| 1559 |
|
| 1560 |
// ======================================== |
| 1561 |
// PRICING via CalculationService (single source of truth) |
| 1562 |
// ======================================== |
| 1563 |
// Count only actual travelers (exclude contact and emergency contact) |
| 1564 |
$travelers_count = 0; |
| 1565 |
foreach ($travelers as $traveler) { |
| 1566 |
if (isset($traveler['type']) && $traveler['type'] === 'traveler') { |
| 1567 |
$travelers_count++; |
| 1568 |
} |
| 1569 |
} |
| 1570 |
// Fallback if no type is set (legacy data) |
| 1571 |
if ($travelers_count === 0) { |
| 1572 |
$travelers_count = count($travelers); |
| 1573 |
} |
| 1574 |
|
| 1575 |
$payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full')))); |
| 1576 |
if ($payment_method === '') { |
| 1577 |
$payment_method = 'full'; |
| 1578 |
} |
| 1579 |
$payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later'))); |
| 1580 |
|
| 1581 |
// Get coupon code from request OR session |
| 1582 |
$coupon_code = sanitize_text_field($data['coupon_code'] ?? ''); |
| 1583 |
if (empty($coupon_code) && !empty($session['coupon']['code'])) { |
| 1584 |
$coupon_code = sanitize_text_field($session['coupon']['code']); |
| 1585 |
} |
| 1586 |
|
| 1587 |
$departure_time = $data['departure_time'] ?? ($session['departure_time'] ?? ''); |
| 1588 |
$additional_services = $data['additional_services'] ?? ($session['additional_services'] ?? []); |
| 1589 |
$availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null); |
| 1590 |
|
| 1591 |
// Build traveler_counts from travelers array or session |
| 1592 |
$traveler_counts = []; |
| 1593 |
if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) { |
| 1594 |
$traveler_counts = $data['traveler_counts']; |
| 1595 |
} elseif (!empty($session['traveler_counts']) && is_array($session['traveler_counts'])) { |
| 1596 |
$traveler_counts = $session['traveler_counts']; |
| 1597 |
} else { |
| 1598 |
// Build from travelers array if category_id is present |
| 1599 |
foreach ($travelers as $traveler) { |
| 1600 |
$categoryId = $traveler['category_id'] ?? null; |
| 1601 |
if ($categoryId) { |
| 1602 |
if (!isset($traveler_counts[$categoryId])) { |
| 1603 |
$traveler_counts[$categoryId] = 0; |
| 1604 |
} |
| 1605 |
$traveler_counts[$categoryId]++; |
| 1606 |
} |
| 1607 |
} |
| 1608 |
if (empty($traveler_counts) && $travelers_count > 0) { |
| 1609 |
$traveler_counts['default'] = $travelers_count; |
| 1610 |
} |
| 1611 |
} |
| 1612 |
|
| 1613 |
// Pricing single source of truth: ALWAYS use calculateFromSession. |
| 1614 |
// |
| 1615 |
// The booking session is kept in sync by the JS layer — every |
| 1616 |
// service toggle, traveler-count change, and coupon apply/remove |
| 1617 |
// POSTs to /booking/session, which updates the transient. The |
| 1618 |
// sidebar's `/booking/summary` then renders from |
| 1619 |
// `calculateFromSession($session, …)`. If we built a second pricing |
| 1620 |
// path here from form fields, any subtle drift (form missing a |
| 1621 |
// category_id, traveler_counts aggregated as 'default', etc) would |
| 1622 |
// produce a different total than the customer saw — they'd be |
| 1623 |
// charged something they didn't agree to. So we merge the latest |
| 1624 |
// session with any form-submitted overrides (services list, |
| 1625 |
// travel_date, availability_id) and let calculateFromSession do |
| 1626 |
// the math the same way the sidebar did. |
| 1627 |
$calculationService = new CalculationService(); |
| 1628 |
$session_for_pricing = is_array($session) ? $session : []; |
| 1629 |
$session_for_pricing['trip_id'] = $trip_id; |
| 1630 |
$session_for_pricing['travelers'] = $travelers_count; |
| 1631 |
// Prefer per-category counts when we have them — otherwise let |
| 1632 |
// calculateFromSession fall back to its internal handling. |
| 1633 |
if (!empty($traveler_counts) && is_array($traveler_counts)) { |
| 1634 |
$session_for_pricing['traveler_counts'] = $traveler_counts; |
| 1635 |
} |
| 1636 |
if (!empty($travel_date)) { |
| 1637 |
$session_for_pricing['travel_date'] = $travel_date; |
| 1638 |
} |
| 1639 |
if (!empty($departure_time)) { |
| 1640 |
$session_for_pricing['departure_time'] = $departure_time; |
| 1641 |
} |
| 1642 |
if (!empty($availability_id) && is_numeric($availability_id)) { |
| 1643 |
$session_for_pricing['availability_id'] = (int) $availability_id; |
| 1644 |
} |
| 1645 |
if (is_array($additional_services)) { |
| 1646 |
$session_for_pricing['additional_services'] = array_values(array_map('intval', $additional_services)); |
| 1647 |
} |
| 1648 |
|
| 1649 |
try { |
| 1650 |
$pricing = $calculationService->calculateFromSession( |
| 1651 |
$session_for_pricing, |
| 1652 |
$coupon_code, |
| 1653 |
$payment_method |
| 1654 |
); |
| 1655 |
} catch (\Throwable $e) { |
| 1656 |
$pricing = []; |
| 1657 |
} |
| 1658 |
|
| 1659 |
// If the session is so degenerate that even calculateFromSession |
| 1660 |
// couldn't make a positive total (e.g. no traveler_counts at all), |
| 1661 |
// surface that cleanly so the booking is rejected with a friendly |
| 1662 |
// error rather than silently charging $0. |
| 1663 |
if (((float) ($pricing['final_total'] ?? 0)) <= 0) { |
| 1664 |
try { |
| 1665 |
$sessionPricing = $calculationService->calculatePricing([ |
| 1666 |
'trip_id' => $trip_id, |
| 1667 |
'travelers_count' => $travelers_count, |
| 1668 |
'traveler_counts' => $traveler_counts, |
| 1669 |
'travel_date' => $travel_date, |
| 1670 |
'departure_time' => $departure_time, |
| 1671 |
'selected_services' => $additional_services, |
| 1672 |
'availability_id' => (!empty($availability_id) && is_numeric($availability_id)) ? (int) $availability_id : null, |
| 1673 |
'coupon_code' => $coupon_code, |
| 1674 |
'payment_method' => $payment_method, |
| 1675 |
]); |
| 1676 |
if (((float) ($sessionPricing['final_total'] ?? 0)) > 0) { |
| 1677 |
$pricing = $sessionPricing; |
| 1678 |
} |
| 1679 |
} catch (\Throwable $e) { |
| 1680 |
// Keep $pricing as-is; downstream guard will surface a clean error. |
| 1681 |
} |
| 1682 |
} |
| 1683 |
|
| 1684 |
// Enforce per-group category size limits (min_pax / max_pax). A per-group |
| 1685 |
// category charges one flat price for a group within the configured |
| 1686 |
// range, so a selection outside that range must be rejected before we |
| 1687 |
// charge. No-op for per-person categories and categories with no limits. |
| 1688 |
$group_size_error = $this->validateGroupSizeLimits($pricing['price_types'] ?? [], $traveler_counts); |
| 1689 |
if ($group_size_error !== null) { |
| 1690 |
return new WP_REST_Response([ |
| 1691 |
'success' => false, |
| 1692 |
'message' => $group_size_error, |
| 1693 |
], 400); |
| 1694 |
} |
| 1695 |
|
| 1696 |
// Extract pricing results |
| 1697 |
$total_amount = $pricing['final_total']; |
| 1698 |
$amount_due = $pricing['amount_due']; |
| 1699 |
$amount_paid = $pricing['amount_paid']; |
| 1700 |
$tax_calculation = $pricing['tax_calculation']; |
| 1701 |
$tax_breakdown = $tax_calculation['tax_breakdown']; |
| 1702 |
$total_tax_amount = $tax_calculation['total_tax_amount']; |
| 1703 |
|
| 1704 |
$tax_inclusive = $tax_calculation['tax_inclusive']; |
| 1705 |
$tax_rate = $tax_calculation['tax_rate']; |
| 1706 |
$subtotal_before_discount = $pricing['subtotal']; |
| 1707 |
$discount_amount = $pricing['total_discount_amount']; |
| 1708 |
$discount_code = $pricing['group_discount']['code'] ?? ($pricing['coupon_discount']['code'] ?? null); |
| 1709 |
|
| 1710 |
// ======================================== |
| 1711 |
// CAPACITY / WAITLIST |
| 1712 |
// ======================================== |
| 1713 |
$resolvedAvailabilityForWaitlist = null; |
| 1714 |
if ($travel_date !== '') { |
| 1715 |
try { |
| 1716 |
$availResolutionSvc = new \Yatra\Services\AvailabilityResolutionService(); |
| 1717 |
$resolvedAvailabilityForWaitlist = $availResolutionSvc->resolveAvailabilityForDate( |
| 1718 |
$trip_id, |
| 1719 |
$travel_date, |
| 1720 |
$departure_time !== '' ? $departure_time : null |
| 1721 |
); |
| 1722 |
} catch (\Throwable $e) { |
| 1723 |
// Leave null; legacy checkout without strict availability still works |
| 1724 |
} |
| 1725 |
} |
| 1726 |
|
| 1727 |
$isWaitlistCheckout = false; |
| 1728 |
|
| 1729 |
if ($resolvedAvailabilityForWaitlist !== null) { |
| 1730 |
$availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available'); |
| 1731 |
if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) { |
| 1732 |
return new WP_REST_Response([ |
| 1733 |
'success' => false, |
| 1734 |
'message' => __('This departure is not open for booking.', 'yatra'), |
| 1735 |
'code' => 'date_blocked', |
| 1736 |
], 400); |
| 1737 |
} |
| 1738 |
// Respect explicit sold_out even if seats_available is stale — waitlist only if allowed |
| 1739 |
if ($availStatus === 'sold_out') { |
| 1740 |
if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) { |
| 1741 |
$isWaitlistCheckout = true; |
| 1742 |
} else { |
| 1743 |
return new WP_REST_Response([ |
| 1744 |
'success' => false, |
| 1745 |
'message' => __('This departure is sold out.', 'yatra'), |
| 1746 |
'code' => 'sold_out', |
| 1747 |
], 400); |
| 1748 |
} |
| 1749 |
} |
| 1750 |
} |
| 1751 |
|
| 1752 |
if ( |
| 1753 |
!$isWaitlistCheckout |
| 1754 |
&& \Yatra\Services\WaitlistService::isInsufficientSeats($resolvedAvailabilityForWaitlist, $travelers_count) |
| 1755 |
) { |
| 1756 |
if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) { |
| 1757 |
$isWaitlistCheckout = true; |
| 1758 |
} else { |
| 1759 |
return new WP_REST_Response([ |
| 1760 |
'success' => false, |
| 1761 |
'message' => __('This departure is full. Waitlist is not available for this trip or date.', 'yatra'), |
| 1762 |
'code' => 'sold_out', |
| 1763 |
], 400); |
| 1764 |
} |
| 1765 |
} |
| 1766 |
|
| 1767 |
// Generate booking reference using the same method as BookingService |
| 1768 |
$bookingRepository = new \Yatra\Repositories\BookingRepository(); |
| 1769 |
$booking_reference = $bookingRepository->generateReference(); |
| 1770 |
|
| 1771 |
Logger::debug('Yatra booking create: pricing and payment selection', [ |
| 1772 |
'context' => 'booking_create_rest', |
| 1773 |
'trip_id' => $trip_id, |
| 1774 |
'booking_reference' => $booking_reference, |
| 1775 |
'flexible_payments_enabled' => (bool) apply_filters('yatra_flexible_payments_enabled', false), |
| 1776 |
'payment_method' => $payment_method, |
| 1777 |
'payment_gateway' => $payment_gateway, |
| 1778 |
'total_amount' => round((float) $total_amount, 4), |
| 1779 |
'amount_due' => round((float) $amount_due, 4), |
| 1780 |
'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20, ['trip_id' => $trip_id]), |
| 1781 |
'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30, ['trip_id' => $trip_id]), |
| 1782 |
]); |
| 1783 |
|
| 1784 |
// Prepare contact data |
| 1785 |
$contact_data = [ |
| 1786 |
'first_name' => sanitize_text_field($contact_first_name), |
| 1787 |
'last_name' => sanitize_text_field($contact_last_name), |
| 1788 |
'email' => sanitize_email($contact_email), |
| 1789 |
'phone' => sanitize_text_field($contact_phone), |
| 1790 |
'country' => sanitize_text_field($contact_country), |
| 1791 |
'nationality' => sanitize_text_field($contact_nationality), |
| 1792 |
'address' => sanitize_text_field($contact_address), |
| 1793 |
]; |
| 1794 |
// Persist every submitted contact_* field (incl. CUSTOM fields the |
| 1795 |
// operator added to the form) so the data isn't lost and is usable as |
| 1796 |
// {{contact_<id>}} email variables. Built-in keys above are not overwritten. |
| 1797 |
foreach ($data as $field_key => $field_value) { |
| 1798 |
if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) { |
| 1799 |
$field_id = substr($field_key, strlen('contact_')); |
| 1800 |
if ($field_id === '' || $field_id === 'data') { |
| 1801 |
continue; |
| 1802 |
} |
| 1803 |
// A phone widget's `<field>_country` companion is folded into the |
| 1804 |
// phone value below, not stored as its own field. |
| 1805 |
if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) { |
| 1806 |
continue; |
| 1807 |
} |
| 1808 |
if (isset($contact_data[$field_id])) { |
| 1809 |
continue; |
| 1810 |
} |
| 1811 |
$field_string = (string) $field_value; |
| 1812 |
// Custom phone field: combine national number + country companion. |
| 1813 |
if (isset($data[$field_key . '_country'])) { |
| 1814 |
$field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone( |
| 1815 |
$field_string, |
| 1816 |
(string) $data[$field_key . '_country'] |
| 1817 |
); |
| 1818 |
} |
| 1819 |
$contact_data[$field_id] = sanitize_text_field($field_string); |
| 1820 |
} |
| 1821 |
} |
| 1822 |
|
| 1823 |
// Prepare emergency contact data |
| 1824 |
$emergency_data = [ |
| 1825 |
'name' => sanitize_text_field($emergency_name), |
| 1826 |
'phone' => sanitize_text_field($emergency_phone), |
| 1827 |
'relationship' => sanitize_text_field($emergency_relationship), |
| 1828 |
]; |
| 1829 |
// Same dynamic capture for emergency_* custom fields. |
| 1830 |
foreach ($data as $field_key => $field_value) { |
| 1831 |
if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) { |
| 1832 |
$field_id = substr($field_key, strlen('emergency_')); |
| 1833 |
if ($field_id === '' || $field_id === 'contact') { |
| 1834 |
continue; |
| 1835 |
} |
| 1836 |
if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) { |
| 1837 |
continue; |
| 1838 |
} |
| 1839 |
if (isset($emergency_data[$field_id])) { |
| 1840 |
continue; |
| 1841 |
} |
| 1842 |
$field_string = (string) $field_value; |
| 1843 |
if (isset($data[$field_key . '_country'])) { |
| 1844 |
$field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone( |
| 1845 |
$field_string, |
| 1846 |
(string) $data[$field_key . '_country'] |
| 1847 |
); |
| 1848 |
} |
| 1849 |
$emergency_data[$field_id] = sanitize_text_field($field_string); |
| 1850 |
} |
| 1851 |
} |
| 1852 |
|
| 1853 |
// Sanitize travelers data |
| 1854 |
$sanitized_travelers = []; |
| 1855 |
foreach ($travelers as $traveler) { |
| 1856 |
if (is_array($traveler)) { |
| 1857 |
$sanitized_traveler = []; |
| 1858 |
foreach ($traveler as $key => $value) { |
| 1859 |
$sk = sanitize_key((string) $key); |
| 1860 |
// Skip a phone widget's `<field>_country` companion; it is |
| 1861 |
// folded into the phone value in the pass below. |
| 1862 |
if (substr($sk, -8) === '_country' && isset($traveler[substr((string) $key, 0, -8)])) { |
| 1863 |
continue; |
| 1864 |
} |
| 1865 |
if (is_array($value)) { |
| 1866 |
$sanitized_traveler[$sk] = array_map(static function ($v) { |
| 1867 |
return sanitize_text_field(is_scalar($v) ? (string) $v : ''); |
| 1868 |
}, $value); |
| 1869 |
} else { |
| 1870 |
$sanitized_traveler[$sk] = sanitize_text_field((string) $value); |
| 1871 |
} |
| 1872 |
} |
| 1873 |
// Combine each phone field with its country companion (national |
| 1874 |
// number + dial code → "+<dial><digits>"). |
| 1875 |
foreach (array_keys($sanitized_traveler) as $tk) { |
| 1876 |
$companion = $tk . '_country'; |
| 1877 |
if (isset($traveler[$companion]) && is_string($sanitized_traveler[$tk])) { |
| 1878 |
$sanitized_traveler[$tk] = \Yatra\Helpers\FormatHelper::combineInternationalPhone( |
| 1879 |
(string) $sanitized_traveler[$tk], |
| 1880 |
(string) $traveler[$companion] |
| 1881 |
); |
| 1882 |
} |
| 1883 |
} |
| 1884 |
$sanitized_travelers[] = $sanitized_traveler; |
| 1885 |
} |
| 1886 |
} |
| 1887 |
|
| 1888 |
// ======================================== |
| 1889 |
// CREATE WORDPRESS USER ACCOUNT (if requested) |
| 1890 |
// ======================================== |
| 1891 |
$user_id = get_current_user_id(); |
| 1892 |
$create_account = !empty($data['create_account']) && !empty($data['account_password']); |
| 1893 |
|
| 1894 |
if (!$user_id && $create_account && !empty($data['account_password'])) { |
| 1895 |
$account_password = sanitize_text_field($data['account_password']); |
| 1896 |
$account_password_confirm = sanitize_text_field($data['account_password_confirm'] ?? ''); |
| 1897 |
|
| 1898 |
// Validate password |
| 1899 |
if (strlen($account_password) < 8) { |
| 1900 |
return new WP_REST_Response([ |
| 1901 |
'success' => false, |
| 1902 |
'message' => __('Password must be at least 8 characters long.', 'yatra'), |
| 1903 |
], 400); |
| 1904 |
} |
| 1905 |
|
| 1906 |
if ($account_password !== $account_password_confirm) { |
| 1907 |
return new WP_REST_Response([ |
| 1908 |
'success' => false, |
| 1909 |
'message' => __('Passwords do not match.', 'yatra'), |
| 1910 |
], 400); |
| 1911 |
} |
| 1912 |
|
| 1913 |
// Check if user already exists |
| 1914 |
if (email_exists($contact_email)) { |
| 1915 |
return new WP_REST_Response([ |
| 1916 |
'success' => false, |
| 1917 |
'message' => __('An account with this email already exists. Please log in.', 'yatra'), |
| 1918 |
], 400); |
| 1919 |
} |
| 1920 |
|
| 1921 |
// Create WordPress user |
| 1922 |
$username = sanitize_user(current(explode('@', $contact_email))); |
| 1923 |
$original_username = $username; |
| 1924 |
$counter = 1; |
| 1925 |
|
| 1926 |
while (username_exists($username)) { |
| 1927 |
$username = $original_username . $counter; |
| 1928 |
$counter++; |
| 1929 |
} |
| 1930 |
|
| 1931 |
$user_id = wp_create_user($username, $account_password, $contact_email); |
| 1932 |
|
| 1933 |
if (is_wp_error($user_id)) { |
| 1934 |
return new WP_REST_Response([ |
| 1935 |
'success' => false, |
| 1936 |
'message' => wp_strip_all_tags($user_id->get_error_message()), |
| 1937 |
], 400); |
| 1938 |
} |
| 1939 |
|
| 1940 |
// Assign Yatra Customer role |
| 1941 |
$user = new \WP_User($user_id); |
| 1942 |
$user->set_role('yatra_customer'); |
| 1943 |
|
| 1944 |
// Update user meta with contact information |
| 1945 |
wp_update_user([ |
| 1946 |
'ID' => $user_id, |
| 1947 |
'first_name' => $contact_data['first_name'], |
| 1948 |
'last_name' => $contact_data['last_name'], |
| 1949 |
'display_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'], |
| 1950 |
]); |
| 1951 |
|
| 1952 |
if (!empty($contact_phone)) { |
| 1953 |
update_user_meta($user_id, 'billing_phone', $contact_phone); |
| 1954 |
update_user_meta($user_id, 'phone', $contact_phone); |
| 1955 |
} |
| 1956 |
|
| 1957 |
if (!empty($contact_country)) { |
| 1958 |
update_user_meta($user_id, 'billing_country', $contact_country); |
| 1959 |
} |
| 1960 |
|
| 1961 |
if (!empty($contact_address)) { |
| 1962 |
update_user_meta($user_id, 'billing_address_1', $contact_address); |
| 1963 |
} |
| 1964 |
|
| 1965 |
// Auto-login the user |
| 1966 |
wp_set_current_user($user_id); |
| 1967 |
wp_set_auth_cookie($user_id); |
| 1968 |
} |
| 1969 |
|
| 1970 |
// ======================================== |
| 1971 |
// CREATE OR UPDATE CUSTOMER |
| 1972 |
// ======================================== |
| 1973 |
// Customers are separate from WordPress users - this is for CRM purposes |
| 1974 |
$customer_id = null; |
| 1975 |
try { |
| 1976 |
$customer_id = $this->customerRepository->findOrCreate([ |
| 1977 |
'user_id' => $user_id ?: null, |
| 1978 |
'first_name' => $contact_data['first_name'], |
| 1979 |
'last_name' => $contact_data['last_name'], |
| 1980 |
'email' => $contact_data['email'], |
| 1981 |
'phone' => $contact_data['phone'], |
| 1982 |
'address' => $contact_data['address'], |
| 1983 |
'country' => $contact_data['country'], |
| 1984 |
'nationality' => $contact_data['nationality'], |
| 1985 |
'emergency_name' => $emergency_data['name'], |
| 1986 |
'emergency_phone' => $emergency_data['phone'], |
| 1987 |
'emergency_relationship' => $emergency_data['relationship'], |
| 1988 |
'newsletter_optin' => !empty($data['subscribe_newsletter']), |
| 1989 |
'total_spent' => $total_amount, |
| 1990 |
'source' => 'booking', |
| 1991 |
]); |
| 1992 |
} catch (\Exception $e) { |
| 1993 |
// Log error but continue - customer creation is not critical |
| 1994 |
} |
| 1995 |
|
| 1996 |
// Create booking using BookingService |
| 1997 |
$booking_service = new \Yatra\Services\BookingService(); |
| 1998 |
|
| 1999 |
$is_offline_gateway = $this->isOfflineGateway($payment_gateway); |
| 2000 |
|
| 2001 |
// Ensure DB availability row is linked so inventory sync can subtract seats after booking. |
| 2002 |
if (!$isWaitlistCheckout && empty($availability_id) && $trip_id > 0 && $travel_date !== '') { |
| 2003 |
try { |
| 2004 |
$dtForResolve = is_string($departure_time) ? trim($departure_time) : ''; |
| 2005 |
$dtForResolve = $dtForResolve !== '' ? $dtForResolve : null; |
| 2006 |
$resolvedCheckout = (new \Yatra\Services\AvailabilityResolutionService())->resolveAvailabilityForDate( |
| 2007 |
$trip_id, |
| 2008 |
sanitize_text_field($travel_date), |
| 2009 |
$dtForResolve |
| 2010 |
); |
| 2011 |
if (is_object($resolvedCheckout) |
| 2012 |
&& ($resolvedCheckout->source ?? '') === 'availability_date' |
| 2013 |
&& isset($resolvedCheckout->id) |
| 2014 |
&& is_numeric($resolvedCheckout->id) |
| 2015 |
&& (int) $resolvedCheckout->id > 0) { |
| 2016 |
$availability_id = (int) $resolvedCheckout->id; |
| 2017 |
} |
| 2018 |
} catch (\Throwable $e) { |
| 2019 |
// Recurring / trip-default checkout: no single availability_dates row |
| 2020 |
} |
| 2021 |
} |
| 2022 |
|
| 2023 |
// Always defer createBooking's copy: session sends one rich confirmation at the end, or |
| 2024 |
// transactional confirmation before payment redirect (see processPaymentGateway returns). |
| 2025 |
$booking_data = [ |
| 2026 |
'reference' => $booking_reference, |
| 2027 |
'trip_id' => $trip_id, |
| 2028 |
'customer_id' => $customer_id, |
| 2029 |
'user_id' => $user_id ?: null, |
| 2030 |
'contact_first_name' => $contact_data['first_name'], |
| 2031 |
'contact_last_name' => $contact_data['last_name'], |
| 2032 |
'contact_email' => $contact_data['email'], |
| 2033 |
'contact_phone' => $contact_data['phone'], |
| 2034 |
'contact_country' => $contact_data['country'], |
| 2035 |
'contact_data' => wp_json_encode($contact_data), |
| 2036 |
'emergency_contact' => wp_json_encode($emergency_data), |
| 2037 |
'travel_date' => sanitize_text_field($travel_date), |
| 2038 |
'availability_id' => !empty($availability_id) ? (int) $availability_id : null, |
| 2039 |
'departure_time' => is_string($departure_time) ? trim($departure_time) : '', |
| 2040 |
'travelers_count' => $travelers_count, |
| 2041 |
'total_amount' => $total_amount, |
| 2042 |
'amount_paid' => 0, |
| 2043 |
'amount_due' => $amount_due, |
| 2044 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 2045 |
'discount_amount' => $discount_amount, |
| 2046 |
'discount_code' => $discount_code, |
| 2047 |
'payment_method' => $payment_method, |
| 2048 |
'payment_gateway' => $payment_gateway, |
| 2049 |
'status' => 'pending', |
| 2050 |
'special_requests' => sanitize_textarea_field($data['special_requests'] ?? ''), |
| 2051 |
'newsletter_optin' => !empty($data['subscribe_newsletter']) ? 1 : 0, |
| 2052 |
'ip_address' => $this->getClientIp(), |
| 2053 |
'created_at' => current_time('mysql'), |
| 2054 |
'updated_at' => current_time('mysql'), |
| 2055 |
// Tax fields |
| 2056 |
'subtotal' => $subtotal_before_discount, |
| 2057 |
'tax_amount' => $total_tax_amount, |
| 2058 |
'tax_rate' => $tax_rate, |
| 2059 |
'tax_inclusive' => $tax_inclusive, |
| 2060 |
'tax_details' => wp_json_encode($tax_breakdown), |
| 2061 |
// Itinerary costs |
| 2062 |
'itinerary_costs' => wp_json_encode($pricing['itinerary_costs'] ?? []), |
| 2063 |
'itinerary_costs_total' => ($pricing['itinerary_costs_total'] ?? 0), |
| 2064 |
'skip_initial_customer_confirmation' => true, |
| 2065 |
]; |
| 2066 |
|
| 2067 |
if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) { |
| 2068 |
$booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id; |
| 2069 |
$booking_data['status'] = 'waitlist'; |
| 2070 |
// Preserve the customer's real OFFLINE gateway + deposit/partial |
| 2071 |
// choice (Bank Transfer / Pay Later). No charge is taken for a |
| 2072 |
// waitlisted slot regardless, and waitlist promotion only flips the |
| 2073 |
// status — it never restores the selection — so pinning to |
| 2074 |
// pay_later/full here would permanently drop the chosen gateway AND |
| 2075 |
// wipe the deposit (BookingService recomputes amount_due from |
| 2076 |
// payment_method). Online gateways stay deferred to pay_later/full |
| 2077 |
// since a card can't be charged for a non-guaranteed slot. |
| 2078 |
if (!$is_offline_gateway) { |
| 2079 |
$booking_data['payment_gateway'] = 'pay_later'; |
| 2080 |
$booking_data['payment_method'] = 'full'; |
| 2081 |
} |
| 2082 |
} |
| 2083 |
|
| 2084 |
// Hold the booking in `pending_verification` until the guest |
| 2085 |
// clicks the magic link. Payment is initiated only after the |
| 2086 |
// status flips to 'pending' (in the verify-email endpoint). |
| 2087 |
// We also pin the gateway to `pay_later` here because the |
| 2088 |
// payment selection at this point would otherwise lock the |
| 2089 |
// operator into a specific gateway before the customer has |
| 2090 |
// even confirmed their email — better to defer that choice |
| 2091 |
// until verification completes and the regular checkout |
| 2092 |
// resumes. |
| 2093 |
if ($needs_email_verification && !$isWaitlistCheckout) { |
| 2094 |
$booking_data['status'] = 'pending_verification'; |
| 2095 |
// Defer the gateway choice ONLY for online gateways: a real charge |
| 2096 |
// would otherwise lock the customer into a gateway before they have |
| 2097 |
// confirmed their email. For OFFLINE gateways (Bank Transfer / Pay |
| 2098 |
// Later) there is no charge to defer, and the verify-email endpoint |
| 2099 |
// does not restore the selection afterwards — so pinning to |
| 2100 |
// pay_later/full here would permanently drop the customer's chosen |
| 2101 |
// gateway AND their deposit/partial amount (BookingService recomputes |
| 2102 |
// amount_due from payment_method, so 'full' wipes the deposit). |
| 2103 |
// Preserve the real selection for offline gateways. |
| 2104 |
if (!$is_offline_gateway) { |
| 2105 |
$booking_data['payment_gateway'] = 'pay_later'; |
| 2106 |
$booking_data['payment_method'] = 'full'; |
| 2107 |
} |
| 2108 |
} |
| 2109 |
|
| 2110 |
try { |
| 2111 |
$booking = $booking_service->createBooking($booking_data); |
| 2112 |
// BookingService returns ['success'=>bool, 'booking_id'=>int, ...] |
| 2113 |
$booking_id = $booking['booking_id'] ?? $booking['id'] ?? null; |
| 2114 |
if (empty($booking['success'])) { |
| 2115 |
return new WP_REST_Response([ |
| 2116 |
'success' => false, |
| 2117 |
'message' => $booking['message'] ?? __('Failed to create booking. Please try again.', 'yatra'), |
| 2118 |
'error' => $booking['message'] ?? '', |
| 2119 |
'errors' => $booking['errors'] ?? null, |
| 2120 |
], 500); |
| 2121 |
} |
| 2122 |
} catch (\Exception $e) { |
| 2123 |
return new WP_REST_Response([ |
| 2124 |
'success' => false, |
| 2125 |
'message' => __('Failed to create booking. Please try again.', 'yatra'), |
| 2126 |
'error' => $e->getMessage(), |
| 2127 |
], 500); |
| 2128 |
} |
| 2129 |
|
| 2130 |
if (empty($booking_id)) { |
| 2131 |
return new WP_REST_Response([ |
| 2132 |
'success' => false, |
| 2133 |
'message' => __('Failed to create booking. Please try again.', 'yatra'), |
| 2134 |
'error' => __('Booking ID was not generated.', 'yatra'), |
| 2135 |
], 500); |
| 2136 |
} |
| 2137 |
|
| 2138 |
if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) { |
| 2139 |
\Yatra\Services\WaitlistService::incrementAvailabilityWaitlistCount( |
| 2140 |
(int) $resolvedAvailabilityForWaitlist->id, |
| 2141 |
$travelers_count |
| 2142 |
); |
| 2143 |
} |
| 2144 |
|
| 2145 |
// Get the actual booking reference from database |
| 2146 |
$bookingRepository = new \Yatra\Repositories\BookingRepository(); |
| 2147 |
$saved_booking = $bookingRepository->find($booking_id); |
| 2148 |
if ($saved_booking && !empty($saved_booking->reference)) { |
| 2149 |
$booking_reference = $saved_booking->reference; |
| 2150 |
} |
| 2151 |
|
| 2152 |
// ======================================== |
| 2153 |
// SAVE ADDITIONAL SERVICES (Premium Feature) |
| 2154 |
// ======================================== |
| 2155 |
/** |
| 2156 |
* Action: Save additional services for the booking |
| 2157 |
* Allows premium modules to save selected services with the booking |
| 2158 |
* |
| 2159 |
* @param int $booking_id The booking ID |
| 2160 |
* @param int $trip_id The trip ID |
| 2161 |
* @param array $data The booking request data (contains selected_services) |
| 2162 |
* @param int $travelers_count Total number of travelers |
| 2163 |
* @param int $duration_days Trip duration in days |
| 2164 |
* @param float $base_amount Trip base price (pre-services, pre-discount) — |
| 2165 |
* the authoritative base used by the pricing engine for this |
| 2166 |
* booking. Listeners persisting percentage-type services price |
| 2167 |
* them against this exact value so the saved line-items reconcile |
| 2168 |
* with the charged total. Added in a backward-compatible way: |
| 2169 |
* existing 5-arg listeners simply ignore it. |
| 2170 |
* @since 3.0.0 |
| 2171 |
*/ |
| 2172 |
// Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services'] |
| 2173 |
if (!isset($data['selected_services'])) { |
| 2174 |
$data['selected_services'] = $data['additional_services'] |
| 2175 |
?? $session['additional_services'] |
| 2176 |
?? []; |
| 2177 |
} |
| 2178 |
if (!is_array($data['selected_services'])) { |
| 2179 |
$data['selected_services'] = []; |
| 2180 |
} |
| 2181 |
$data['selected_services'] = array_map('intval', $data['selected_services']); |
| 2182 |
do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0)); |
| 2183 |
|
| 2184 |
// ======================================== |
| 2185 |
// SAVE TRAVELLERS TO NORMALIZED TABLES |
| 2186 |
// ======================================== |
| 2187 |
// Each traveller is saved to yatra_booking_travellers table |
| 2188 |
// Their dynamic fields are saved to yatra_booking_traveller_meta table |
| 2189 |
foreach ($sanitized_travelers as $index => $traveler_fields) { |
| 2190 |
// First traveller (index 0) is always the lead traveller |
| 2191 |
$is_lead = ($index === 0); |
| 2192 |
|
| 2193 |
// Create traveller record with all their fields stored in meta |
| 2194 |
$this->travellerRepository->create( |
| 2195 |
$booking_id, |
| 2196 |
$index, |
| 2197 |
$is_lead, |
| 2198 |
$traveler_fields |
| 2199 |
); |
| 2200 |
} |
| 2201 |
|
| 2202 |
if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) { |
| 2203 |
yatra_clear_booking_session(); |
| 2204 |
if ($settings['booking_confirmation']) { |
| 2205 |
$booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id); |
| 2206 |
} |
| 2207 |
|
| 2208 |
return new WP_REST_Response([ |
| 2209 |
'success' => true, |
| 2210 |
'message' => __('You are on the waitlist. We will contact you if a space opens up.', 'yatra'), |
| 2211 |
'data' => [ |
| 2212 |
'booking_id' => $booking_id, |
| 2213 |
'reference' => $booking_reference, |
| 2214 |
'status' => 'waitlist', |
| 2215 |
'waitlist' => true, |
| 2216 |
'redirect_url' => $this->getConfirmationUrl($booking_reference), |
| 2217 |
'customer_email' => $contact_data['email'], |
| 2218 |
'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']), |
| 2219 |
'trip_id' => $trip_id, |
| 2220 |
'trip_date' => $travel_date, |
| 2221 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 2222 |
'total_amount' => $total_amount, |
| 2223 |
'amount_due' => $amount_due, |
| 2224 |
], |
| 2225 |
]); |
| 2226 |
} |
| 2227 |
|
| 2228 |
// Departure link + booked_count: handled inside BookingService::createBooking (and inventory sync hooks). |
| 2229 |
// Persist travel window on the booking row for reporting (optional columns). |
| 2230 |
if (!empty($travel_date)) { |
| 2231 |
try { |
| 2232 |
$start_date = $travel_date; |
| 2233 |
$duration_days = !empty($trip->duration_days) ? (int) $trip->duration_days : 1; |
| 2234 |
$end_date = date('Y-m-d', strtotime($start_date . ' + ' . ($duration_days - 1) . ' days')); |
| 2235 |
$bookingColumns = $this->bookingRepository->getTableColumns(); |
| 2236 |
$bookingUpdateData = []; |
| 2237 |
if (in_array('start_date', $bookingColumns, true)) { |
| 2238 |
$bookingUpdateData['start_date'] = $start_date; |
| 2239 |
} |
| 2240 |
if (in_array('end_date', $bookingColumns, true)) { |
| 2241 |
$bookingUpdateData['end_date'] = $end_date; |
| 2242 |
} |
| 2243 |
if ($bookingUpdateData !== []) { |
| 2244 |
$this->bookingRepository->update($booking_id, $bookingUpdateData); |
| 2245 |
} |
| 2246 |
} catch (\Exception $e) { |
| 2247 |
// Non-fatal |
| 2248 |
} |
| 2249 |
} |
| 2250 |
|
| 2251 |
// Clear booking session |
| 2252 |
yatra_clear_booking_session(); |
| 2253 |
|
| 2254 |
// ======================================== |
| 2255 |
// GUEST EMAIL VERIFICATION — INTERCEPT |
| 2256 |
// ======================================== |
| 2257 |
// Booking row + travelers + services are already saved at |
| 2258 |
// this point with status='pending_verification'. Send the |
| 2259 |
// magic-link email, return a structured "check your email" |
| 2260 |
// response, and DO NOT initiate payment. The customer's |
| 2261 |
// click on the verify-email endpoint transitions the booking |
| 2262 |
// to 'pending' and emits the payment-continuation URL. |
| 2263 |
if ($needs_email_verification) { |
| 2264 |
$verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl( |
| 2265 |
(int) $booking_id, |
| 2266 |
(string) $contact_data['email'] |
| 2267 |
); |
| 2268 |
|
| 2269 |
// Variables piped into the template email. All standard |
| 2270 |
// booking merge tags resolve normally (the row exists); |
| 2271 |
// we also pass intro_paragraph + footer_note + the |
| 2272 |
// expiry banner so operators that haven't customised |
| 2273 |
// the template still get good defaults. |
| 2274 |
$email_vars = []; |
| 2275 |
if ($saved_booking !== null) { |
| 2276 |
$email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking); |
| 2277 |
} |
| 2278 |
// Belt-and-braces: ensure customer name + email are |
| 2279 |
// populated even when variablesFromBooking returned an |
| 2280 |
// empty shell (which shouldn't happen, but if it does |
| 2281 |
// we don't want the email to render "Hi ,"). |
| 2282 |
$email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']); |
| 2283 |
$email_vars['customer_name'] = (string) ($email_vars['customer_name'] |
| 2284 |
?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? ''))); |
| 2285 |
$email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name'] |
| 2286 |
?? ($contact_data['first_name'] ?? '')); |
| 2287 |
$email_vars['verification_link'] = $verify_url; |
| 2288 |
$email_vars['intro_paragraph'] = __( |
| 2289 |
"Thanks for booking with us! To confirm this is really your email, please click the button below. Your booking is held for you in the meantime — payment isn't taken until you verify.", |
| 2290 |
'yatra' |
| 2291 |
); |
| 2292 |
$email_vars['footer_note'] = __( |
| 2293 |
"If you didn't make this booking, you can safely ignore this email — no charges have been made.", |
| 2294 |
'yatra' |
| 2295 |
); |
| 2296 |
$email_vars['expiry_notice_html'] = '<strong>' |
| 2297 |
. esc_html__('This link expires in 48 hours.', 'yatra') |
| 2298 |
. '</strong>'; |
| 2299 |
|
| 2300 |
// Guest-checkout verification prefers the operator's CONFIGURED |
| 2301 |
// customer verification template so their customisation is honoured |
| 2302 |
// (the guest system template was consolidated away — using the guest |
| 2303 |
// type always fell back to the built-in default and ignored the |
| 2304 |
// configured one). This email MUST still carry the verification link |
| 2305 |
// — a guest can't complete the booking without it — so we only fall |
| 2306 |
// back to the built-in GUEST default when the effective customer |
| 2307 |
// template would omit {{verification_link}} (an operator can, and on |
| 2308 |
// real sites does, customise that template and drop the tag). The |
| 2309 |
// check respects Pro-owned DB templates too. Booking copy is injected |
| 2310 |
// above via intro_paragraph / footer_note / expiry merge vars. |
| 2311 |
// |
| 2312 |
// Keep the booking-specific SUBJECT line ("Verify your email to |
| 2313 |
// complete your booking") that guests saw before the guest template |
| 2314 |
// was consolidated away — reusing the customer template body must not |
| 2315 |
// drag along the account-oriented "Verify your email address" |
| 2316 |
// subject. This is honoured additively by the renderer / Pro sender |
| 2317 |
// via the reserved `_subject_override` var, so only this guest send |
| 2318 |
// is affected. Computed before it is stored, so the render below |
| 2319 |
// resolves the clean guest subject (no self-reference). |
| 2320 |
$email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render( |
| 2321 |
\Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION, |
| 2322 |
$email_vars |
| 2323 |
)['subject']; |
| 2324 |
$verificationEmailSent = false; |
| 2325 |
if (\Yatra\Services\TransactionalEmailTemplateService::templateRendersVerificationLink( |
| 2326 |
\Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION |
| 2327 |
)) { |
| 2328 |
$verificationEmailSent = \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled( |
| 2329 |
\Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION, |
| 2330 |
(string) $contact_data['email'], |
| 2331 |
$email_vars |
| 2332 |
); |
| 2333 |
} |
| 2334 |
// Guarantee a verification email even if the customer template would |
| 2335 |
// drop the link OR its per-type toggle is disabled — a guest can't |
| 2336 |
// complete checkout without it. The built-in GUEST default always |
| 2337 |
// carries the link. sendIfEnabled() returns whether it actually sent, |
| 2338 |
// so this only fires when the preferred send did not (no double send). |
| 2339 |
if (!$verificationEmailSent) { |
| 2340 |
\Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled( |
| 2341 |
\Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION, |
| 2342 |
(string) $contact_data['email'], |
| 2343 |
$email_vars |
| 2344 |
); |
| 2345 |
} |
| 2346 |
|
| 2347 |
return new WP_REST_Response([ |
| 2348 |
'success' => true, |
| 2349 |
'code' => 'email_verification_required', |
| 2350 |
'message' => __( |
| 2351 |
"We've sent a verification email to your address. Click the link in that email to complete your booking — your spot is being held while you verify.", |
| 2352 |
'yatra' |
| 2353 |
), |
| 2354 |
'data' => [ |
| 2355 |
'booking_id' => $booking_id, |
| 2356 |
'reference' => $booking_reference, |
| 2357 |
'email' => $contact_data['email'], |
| 2358 |
'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600), |
| 2359 |
], |
| 2360 |
]); |
| 2361 |
} |
| 2362 |
|
| 2363 |
// Check if this is an offline gateway |
| 2364 |
$is_offline = $is_offline_gateway; |
| 2365 |
|
| 2366 |
// For online gateways, create payment intent and return redirect URL |
| 2367 |
if (!$is_offline && $amount_due > 0) { |
| 2368 |
// Build payment params - merge with request data so gateways can access their own tokens |
| 2369 |
$payment_params = array_merge($data, [ |
| 2370 |
'booking_id' => $booking_id, |
| 2371 |
'reference' => $booking_reference, |
| 2372 |
'amount' => $amount_due, |
| 2373 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 2374 |
'customer_email' => $contact_data['email'], |
| 2375 |
'customer_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'], |
| 2376 |
'trip_title' => $trip->title, |
| 2377 |
]); |
| 2378 |
|
| 2379 |
// Process payment based on gateway |
| 2380 |
$payment_result = $this->processPaymentGateway($payment_gateway, $payment_params); |
| 2381 |
|
| 2382 |
if ($payment_result['success']) { |
| 2383 |
// Handle redirect-based gateways (PayPal, eSewa, Khalti, etc.) |
| 2384 |
if (!empty($payment_result['payment_url'])) { |
| 2385 |
if ($settings['booking_confirmation']) { |
| 2386 |
$booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id); |
| 2387 |
} |
| 2388 |
return new WP_REST_Response([ |
| 2389 |
'success' => true, |
| 2390 |
'message' => __('Booking created. Redirecting to payment...', 'yatra'), |
| 2391 |
'data' => [ |
| 2392 |
'booking_id' => $booking_id, |
| 2393 |
'reference' => $booking_reference, |
| 2394 |
'payment_url' => $payment_result['payment_url'], |
| 2395 |
], |
| 2396 |
]); |
| 2397 |
} |
| 2398 |
|
| 2399 |
// Handle client-side payment gateways (Stripe, Razorpay, Square, etc.) |
| 2400 |
if (!empty($payment_result['requires_action'])) { |
| 2401 |
if ($settings['booking_confirmation']) { |
| 2402 |
$booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id); |
| 2403 |
} |
| 2404 |
return new WP_REST_Response([ |
| 2405 |
'success' => true, |
| 2406 |
'message' => __('Booking created. Complete payment...', 'yatra'), |
| 2407 |
'data' => array_merge([ |
| 2408 |
'booking_id' => $booking_id, |
| 2409 |
'reference' => $booking_reference, |
| 2410 |
], $payment_result), |
| 2411 |
]); |
| 2412 |
} |
| 2413 |
} |
| 2414 |
|
| 2415 |
// If payment processing failed, return error so user can fix the issue |
| 2416 |
if (!$payment_result['success']) { |
| 2417 |
$errorMessage = $payment_result['error'] ?? $payment_result['message'] ?? __('Payment processing failed. Please try again.', 'yatra'); |
| 2418 |
return new WP_REST_Response([ |
| 2419 |
'success' => false, |
| 2420 |
'message' => $errorMessage, |
| 2421 |
'data' => [ |
| 2422 |
'booking_id' => $booking_id, |
| 2423 |
'reference' => $booking_reference, |
| 2424 |
'payment_error' => true, |
| 2425 |
], |
| 2426 |
]); |
| 2427 |
} |
| 2428 |
} |
| 2429 |
|
| 2430 |
// ======================================== |
| 2431 |
// DETERMINE BOOKING STATUS |
| 2432 |
// ======================================== |
| 2433 |
// Priority: |
| 2434 |
// 1. auto_confirm_bookings setting (confirms ALL bookings automatically) |
| 2435 |
// 2. For pay_later: auto_confirm_pay_later setting |
| 2436 |
// 3. For bank_transfer: always pending until verified |
| 2437 |
|
| 2438 |
$booking_status = 'pending'; |
| 2439 |
$status_message = __('Booking received!', 'yatra'); |
| 2440 |
|
| 2441 |
// Check if auto-confirm all bookings is enabled |
| 2442 |
if ($settings['auto_confirm_bookings']) { |
| 2443 |
// Auto-confirm is enabled - confirm immediately regardless of payment |
| 2444 |
$booking_status = 'confirmed'; |
| 2445 |
$status_message = __('Booking confirmed!', 'yatra'); |
| 2446 |
} elseif ($payment_gateway === 'pay_later') { |
| 2447 |
// Pay Later: Check the specific pay_later auto-confirm setting |
| 2448 |
if ($settings['auto_confirm_pay_later']) { |
| 2449 |
$booking_status = 'confirmed'; |
| 2450 |
$status_message = __('Booking confirmed! Payment will be collected later.', 'yatra'); |
| 2451 |
} else { |
| 2452 |
$booking_status = 'pending'; |
| 2453 |
$status_message = __('Booking received! We will contact you to arrange payment.', 'yatra'); |
| 2454 |
} |
| 2455 |
} elseif ($payment_gateway === 'bank_transfer') { |
| 2456 |
// Bank Transfer: Always pending until payment is verified by admin |
| 2457 |
$booking_status = 'pending'; |
| 2458 |
$status_message = __('Booking received! Please complete the bank transfer. We will confirm once payment is verified.', 'yatra'); |
| 2459 |
} |
| 2460 |
|
| 2461 |
// Calculate booking expiry time for pending bookings |
| 2462 |
$expiry_datetime = null; |
| 2463 |
if ($booking_status === 'pending' && $settings['booking_expiry_hours'] > 0) { |
| 2464 |
$expiry_datetime = date('Y-m-d H:i:s', strtotime('+' . $settings['booking_expiry_hours'] . ' hours')); |
| 2465 |
} |
| 2466 |
|
| 2467 |
// Set confirmed_at if auto-confirmed |
| 2468 |
$confirmed_at = ($booking_status === 'confirmed') ? current_time('mysql') : null; |
| 2469 |
|
| 2470 |
// Update booking status with additional metadata |
| 2471 |
$update_data = [ |
| 2472 |
'status' => $booking_status, |
| 2473 |
'payment_status' => 'pending', // No payment made yet for offline gateways |
| 2474 |
]; |
| 2475 |
|
| 2476 |
if ($confirmed_at) { |
| 2477 |
$update_data['confirmed_at'] = $confirmed_at; |
| 2478 |
} |
| 2479 |
|
| 2480 |
if ($expiry_datetime) { |
| 2481 |
$update_data['expires_at'] = $expiry_datetime; |
| 2482 |
} |
| 2483 |
|
| 2484 |
// Use repository to update booking |
| 2485 |
$this->bookingRepository->update($booking_id, $update_data); |
| 2486 |
|
| 2487 |
/** |
| 2488 |
* yatra_booking_created already fired from BookingService::createBooking — do not fire again here |
| 2489 |
* (duplicate admin + Pro automation). |
| 2490 |
* |
| 2491 |
* Synthetic pending→confirmed on the same request duplicates Pro "booking.confirmed" sequences with the |
| 2492 |
* checkout confirmation email. Skip by default; restore with: |
| 2493 |
* add_filter('yatra_skip_checkout_autoconfirm_status_changed_event', '__return_false'); |
| 2494 |
*/ |
| 2495 |
if ($booking_status === 'confirmed') { |
| 2496 |
if (!apply_filters('yatra_skip_checkout_autoconfirm_status_changed_event', true, (int) $booking_id, 'pending', 'confirmed')) { |
| 2497 |
do_action('yatra_booking_status_changed', (int) $booking_id, 'pending', 'confirmed'); |
| 2498 |
} |
| 2499 |
// Trip Consent / Google Calendar listen on `yatra_booking_confirmed` (not fired by status_changed skip above). |
| 2500 |
\yatra_trigger_booking_confirmed((int) $booking_id, 'pending'); |
| 2501 |
} |
| 2502 |
|
| 2503 |
// ======================================== |
| 2504 |
// SEND CONFIRMATION EMAIL |
| 2505 |
// ======================================== |
| 2506 |
if ($settings['booking_confirmation']) { |
| 2507 |
$this->sendBookingConfirmationEmail($booking_id, $booking_reference, $trip, [ |
| 2508 |
'contact' => $contact_data, |
| 2509 |
'emergency' => $emergency_data, |
| 2510 |
'travelers' => $sanitized_travelers, |
| 2511 |
'travel_date' => $travel_date, |
| 2512 |
'payment_method' => $payment_method, |
| 2513 |
'payment_gateway' => $payment_gateway, |
| 2514 |
'total_amount' => $total_amount, |
| 2515 |
'amount_due' => $amount_due, |
| 2516 |
'booking_status' => $booking_status, |
| 2517 |
'expiry_datetime' => $expiry_datetime, |
| 2518 |
]); |
| 2519 |
} |
| 2520 |
|
| 2521 |
return new WP_REST_Response([ |
| 2522 |
'success' => true, |
| 2523 |
'message' => $status_message, |
| 2524 |
'data' => [ |
| 2525 |
'booking_id' => $booking_id, |
| 2526 |
'reference' => $booking_reference, |
| 2527 |
'status' => $booking_status, |
| 2528 |
'payment_status' => 'pending', |
| 2529 |
'redirect_url' => $this->getConfirmationUrl($booking_reference), |
| 2530 |
'customer_email' => $contact_data['email'], |
| 2531 |
'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']), |
| 2532 |
'trip_id' => $trip_id, |
| 2533 |
'trip_date' => $travel_date, |
| 2534 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 2535 |
'amount' => $amount_due, |
| 2536 |
'subtotal' => $subtotal_before_discount, |
| 2537 |
'discount_amount' => $discount_amount, |
| 2538 |
'discount_code' => $discount_code, |
| 2539 |
'total_amount' => $total_amount, |
| 2540 |
], |
| 2541 |
]); |
| 2542 |
} |
| 2543 |
|
| 2544 |
|
| 2545 |
/** |
| 2546 |
* Get confirmation page URL (see yatra_get_booking_confirmation_url()). |
| 2547 |
*/ |
| 2548 |
private function getConfirmationUrl(string $reference): string |
| 2549 |
{ |
| 2550 |
return yatra_get_booking_confirmation_url($reference); |
| 2551 |
} |
| 2552 |
|
| 2553 |
/** |
| 2554 |
* Whether the gateway completes without an external payment step (registry flag + fallback). |
| 2555 |
*/ |
| 2556 |
private function isOfflineGateway(string $gatewayId): bool |
| 2557 |
{ |
| 2558 |
try { |
| 2559 |
$registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance(); |
| 2560 |
$gateway = $registry->get($gatewayId); |
| 2561 |
if ($gateway) { |
| 2562 |
return $gateway->isOffline(); |
| 2563 |
} |
| 2564 |
} catch (\Throwable $e) { |
| 2565 |
// Fall through to legacy IDs |
| 2566 |
} |
| 2567 |
|
| 2568 |
return in_array($gatewayId, ['pay_later', 'bank_transfer'], true); |
| 2569 |
} |
| 2570 |
|
| 2571 |
/** |
| 2572 |
* Pay balance due on an existing booking only. |
| 2573 |
* |
| 2574 |
* Does not call BookingService::createBooking() or insert a second booking row. |
| 2575 |
* Initiates gateway flow with the stored booking_id; on success, payment is recorded |
| 2576 |
* via PaymentGatewayController::handle_successful_payment, webhooks, or recordGatewayPayment |
| 2577 |
* — same completion paths as initial checkout. |
| 2578 |
*/ |
| 2579 |
private function process_remaining_payment(WP_REST_Request $request): WP_REST_Response |
| 2580 |
{ |
| 2581 |
$data = $request->get_json_params(); |
| 2582 |
if (!is_array($data)) { |
| 2583 |
$data = []; |
| 2584 |
} |
| 2585 |
$payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later'))); |
| 2586 |
|
| 2587 |
$session = yatra_get_remaining_session(); |
| 2588 |
|
| 2589 |
$booking_id = (int) ($session['booking_id'] ?? 0); |
| 2590 |
$booking_reference = (string) ($session['booking_reference'] ?? ''); |
| 2591 |
$currency = (string) ($session['currency'] ?? ''); |
| 2592 |
$contact_email = (string) ($session['contact_email'] ?? ''); |
| 2593 |
$contact_first_name = (string) ($session['contact_first_name'] ?? ''); |
| 2594 |
$contact_last_name = (string) ($session['contact_last_name'] ?? ''); |
| 2595 |
$trip_id = (int) ($session['trip_id'] ?? 0); |
| 2596 |
$trip_title = (string) ($session['trip_title'] ?? ''); |
| 2597 |
$travel_date = (string) ($session['travel_date'] ?? ''); |
| 2598 |
|
| 2599 |
if ($booking_id <= 0) { |
| 2600 |
return new WP_REST_Response([ |
| 2601 |
'success' => false, |
| 2602 |
'message' => __('Invalid booking for remaining payment.', 'yatra'), |
| 2603 |
], 400); |
| 2604 |
} |
| 2605 |
|
| 2606 |
$booking = $this->bookingRepository->find($booking_id); |
| 2607 |
if (!$booking) { |
| 2608 |
yatra_clear_remaining_session(); |
| 2609 |
return new WP_REST_Response([ |
| 2610 |
'success' => false, |
| 2611 |
'message' => __('Booking not found.', 'yatra'), |
| 2612 |
], 404); |
| 2613 |
} |
| 2614 |
|
| 2615 |
if ($booking_reference === '' && !empty($booking->reference)) { |
| 2616 |
$booking_reference = (string) $booking->reference; |
| 2617 |
} |
| 2618 |
|
| 2619 |
// Authoritative balance from DB (do not rely on session alone) |
| 2620 |
$remaining_amount = (float) ($booking->amount_due ?? 0); |
| 2621 |
if ($remaining_amount <= 0 && isset($booking->total_amount)) { |
| 2622 |
$remaining_amount = max( |
| 2623 |
0, |
| 2624 |
(float) $booking->total_amount - (float) ($booking->amount_paid ?? 0) |
| 2625 |
); |
| 2626 |
} |
| 2627 |
|
| 2628 |
if ($remaining_amount <= 0) { |
| 2629 |
yatra_clear_remaining_session(); |
| 2630 |
return new WP_REST_Response([ |
| 2631 |
'success' => false, |
| 2632 |
'message' => __('This booking is already fully paid.', 'yatra'), |
| 2633 |
], 400); |
| 2634 |
} |
| 2635 |
|
| 2636 |
// Verify user owns this booking |
| 2637 |
$current_user = get_current_user_id(); |
| 2638 |
if ($current_user && (int) $booking->user_id !== $current_user) { |
| 2639 |
yatra_clear_remaining_session(); |
| 2640 |
return new WP_REST_Response([ |
| 2641 |
'success' => false, |
| 2642 |
'message' => __('You do not have permission to pay for this booking.', 'yatra'), |
| 2643 |
], 403); |
| 2644 |
} |
| 2645 |
|
| 2646 |
if ($currency === '') { |
| 2647 |
$currency = (string) ($booking->currency ?? SettingsService::getCurrency()); |
| 2648 |
} |
| 2649 |
|
| 2650 |
// Use contact info from session or booking |
| 2651 |
$customer_email = $contact_email !== '' ? $contact_email : (string) ($booking->contact_email ?? $booking->customer_email ?? ''); |
| 2652 |
$customer_name = trim($contact_first_name . ' ' . $contact_last_name); |
| 2653 |
if ($customer_name === '') { |
| 2654 |
$customer_name = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? '')); |
| 2655 |
} |
| 2656 |
|
| 2657 |
if ($customer_email === '') { |
| 2658 |
return new WP_REST_Response([ |
| 2659 |
'success' => false, |
| 2660 |
'message' => __('Email address is required.', 'yatra'), |
| 2661 |
], 400); |
| 2662 |
} |
| 2663 |
|
| 2664 |
$is_offline_gateway = $this->isOfflineGateway($payment_gateway); |
| 2665 |
|
| 2666 |
// Server-side guard: offline gateways (Pay Later, Bank Transfer, etc.) don't |
| 2667 |
// actually collect money. Letting them be selected for a remaining-balance |
| 2668 |
// payment leaves the booking unpaid while the customer thinks they finished |
| 2669 |
// the flow. The frontend already filters them out for this checkout — this |
| 2670 |
// catches a tampered client. Filterable so a custom Pay Later that does |
| 2671 |
// settle can opt back in. |
| 2672 |
$allow_offline_for_remaining = (bool) apply_filters( |
| 2673 |
'yatra_remaining_payment_allow_offline_gateway', |
| 2674 |
false, |
| 2675 |
$payment_gateway, |
| 2676 |
$booking |
| 2677 |
); |
| 2678 |
if ($is_offline_gateway && !$allow_offline_for_remaining) { |
| 2679 |
return new WP_REST_Response([ |
| 2680 |
'success' => false, |
| 2681 |
'message' => __('This payment method cannot be used to settle a remaining balance. Please choose a card-based gateway.', 'yatra'), |
| 2682 |
'data' => [ |
| 2683 |
'rejected_gateway' => $payment_gateway, |
| 2684 |
'reason' => 'offline_not_allowed_for_remaining_payment', |
| 2685 |
], |
| 2686 |
], 400); |
| 2687 |
} |
| 2688 |
|
| 2689 |
// Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.). |
| 2690 |
// Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely. |
| 2691 |
if (!$is_offline_gateway && $remaining_amount > 0) { |
| 2692 |
// The gateway needs an explicit return URL with the `balance=paid` flag so the |
| 2693 |
// confirmation page can show "balance just paid" content. Without setting it |
| 2694 |
// here, processPaymentWithGateway() would fall back to a plain confirmation URL |
| 2695 |
// and the customer would land on the generic post-booking template. |
| 2696 |
$remaining_return_url = add_query_arg( |
| 2697 |
'balance', |
| 2698 |
'paid', |
| 2699 |
$this->getConfirmationUrl($booking_reference) |
| 2700 |
); |
| 2701 |
|
| 2702 |
$payment_params = array_merge($data, [ |
| 2703 |
'booking_id' => $booking_id, |
| 2704 |
'reference' => $booking_reference, |
| 2705 |
'amount' => $remaining_amount, |
| 2706 |
'currency' => $currency, |
| 2707 |
'customer_email' => $customer_email, |
| 2708 |
'customer_name' => $customer_name !== '' ? $customer_name : $customer_email, |
| 2709 |
'trip_title' => $trip_title, |
| 2710 |
'return_url' => $remaining_return_url, |
| 2711 |
]); |
| 2712 |
|
| 2713 |
$payment_result = $this->processPaymentGateway($payment_gateway, $payment_params); |
| 2714 |
|
| 2715 |
if (!empty($payment_result['success'])) { |
| 2716 |
// Common identity fields the Stripe.js / PayPal SDK frontend expects on |
| 2717 |
// the response. process_remaining_payment is reached without going through |
| 2718 |
// the new-booking checkout, so the gateway result alone is missing |
| 2719 |
// customer_email / customer_name — re-attach them from the booking we |
| 2720 |
// already loaded above. Also overwrite confirmation_url AND redirect_url |
| 2721 |
// with the balance-tagged version so the post-payment landing page knows |
| 2722 |
// this was a remaining-balance flow. |
| 2723 |
// |
| 2724 |
// Why both fields: the Stripe.js helper buildConfirmationUrlFromBookingInfo() |
| 2725 |
// prefers bookingInfo.redirect_url and only falls back to rebuilding the URL |
| 2726 |
// from scratch (without query params) when redirect_url is absent. Without |
| 2727 |
// redirect_url being explicitly set here the `?balance=paid` flag would be |
| 2728 |
// lost on the final navigation after Stripe success. |
| 2729 |
$remaining_identity_fields = [ |
| 2730 |
'customer_email' => $customer_email, |
| 2731 |
'customer_name' => $customer_name, |
| 2732 |
'confirmation_url' => $remaining_return_url, |
| 2733 |
'redirect_url' => $remaining_return_url, |
| 2734 |
]; |
| 2735 |
|
| 2736 |
if (!empty($payment_result['payment_url'])) { |
| 2737 |
return new WP_REST_Response([ |
| 2738 |
'success' => true, |
| 2739 |
'message' => __('Redirecting to payment...', 'yatra'), |
| 2740 |
'data' => array_merge([ |
| 2741 |
'booking_id' => $booking_id, |
| 2742 |
'reference' => $booking_reference, |
| 2743 |
'payment_url' => $payment_result['payment_url'], |
| 2744 |
'is_remaining_payment' => true, |
| 2745 |
], $remaining_identity_fields), |
| 2746 |
]); |
| 2747 |
} |
| 2748 |
|
| 2749 |
if (!empty($payment_result['requires_action'])) { |
| 2750 |
return new WP_REST_Response([ |
| 2751 |
'success' => true, |
| 2752 |
'message' => __('Complete payment...', 'yatra'), |
| 2753 |
'data' => array_merge( |
| 2754 |
[ |
| 2755 |
'booking_id' => $booking_id, |
| 2756 |
'reference' => $booking_reference, |
| 2757 |
'is_remaining_payment' => true, |
| 2758 |
], |
| 2759 |
$payment_result, |
| 2760 |
$remaining_identity_fields |
| 2761 |
), |
| 2762 |
]); |
| 2763 |
} |
| 2764 |
|
| 2765 |
if (!empty($payment_result['redirect_url'])) { |
| 2766 |
return new WP_REST_Response([ |
| 2767 |
'success' => true, |
| 2768 |
'message' => __('Payment processed.', 'yatra'), |
| 2769 |
'data' => array_merge([ |
| 2770 |
'booking_id' => $booking_id, |
| 2771 |
'reference' => $booking_reference, |
| 2772 |
'redirect_url' => $payment_result['redirect_url'], |
| 2773 |
'is_remaining_payment' => true, |
| 2774 |
], $remaining_identity_fields), |
| 2775 |
]); |
| 2776 |
} |
| 2777 |
} |
| 2778 |
|
| 2779 |
$err = $payment_result['message'] ?? $payment_result['error'] ?? __('Payment processing failed. Please try again.', 'yatra'); |
| 2780 |
|
| 2781 |
return new WP_REST_Response([ |
| 2782 |
'success' => false, |
| 2783 |
'message' => $err, |
| 2784 |
'data' => [ |
| 2785 |
'payment_error' => true, |
| 2786 |
'booking_id' => $booking_id, |
| 2787 |
], |
| 2788 |
], 400); |
| 2789 |
} |
| 2790 |
|
| 2791 |
// Offline gateways: no external redirect — confirmation page only. |
| 2792 |
// Append `balance=paid` so the confirmation template renders the |
| 2793 |
// remaining-payment-specific banner ("balance received, fully paid") |
| 2794 |
// instead of the generic "booking confirmed" copy. |
| 2795 |
yatra_clear_remaining_session(); |
| 2796 |
|
| 2797 |
$offline_redirect = add_query_arg( |
| 2798 |
'balance', |
| 2799 |
'paid', |
| 2800 |
$this->getConfirmationUrl($booking_reference) |
| 2801 |
); |
| 2802 |
|
| 2803 |
return new WP_REST_Response([ |
| 2804 |
'success' => true, |
| 2805 |
'message' => __('Continue to confirmation.', 'yatra'), |
| 2806 |
'data' => [ |
| 2807 |
'booking_id' => $booking_id, |
| 2808 |
'reference' => $booking_reference, |
| 2809 |
'trip_id' => $trip_id, |
| 2810 |
'trip_title' => $trip_title, |
| 2811 |
'trip_date' => $travel_date, |
| 2812 |
'currency' => $currency, |
| 2813 |
'amount' => $remaining_amount, |
| 2814 |
'customer_email' => $customer_email, |
| 2815 |
'customer_name' => $customer_name, |
| 2816 |
'redirect_url' => $offline_redirect, |
| 2817 |
'is_remaining_payment' => true, |
| 2818 |
], |
| 2819 |
]); |
| 2820 |
} |
| 2821 |
|
| 2822 |
/** |
| 2823 |
* Process payment through the selected gateway |
| 2824 |
*/ |
| 2825 |
private function processPaymentGateway(string $gateway, array $params): array |
| 2826 |
{ |
| 2827 |
// Debug logging |
| 2828 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2829 |
} |
| 2830 |
|
| 2831 |
// All gateways use the unified gateway system |
| 2832 |
return $this->processPaymentWithGateway($gateway, $params); |
| 2833 |
} |
| 2834 |
|
| 2835 |
/** |
| 2836 |
* Process payment using the proper gateway system |
| 2837 |
*/ |
| 2838 |
private function processPaymentWithGateway(string $gatewayId, array $params): array |
| 2839 |
{ |
| 2840 |
try { |
| 2841 |
$registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance(); |
| 2842 |
$gateway = $registry->get($gatewayId); |
| 2843 |
|
| 2844 |
if (!$gateway) { |
| 2845 |
return ['success' => false, 'message' => "Payment gateway '{$gatewayId}' not found"]; |
| 2846 |
} |
| 2847 |
|
| 2848 |
if (!$gateway->isEnabled()) { |
| 2849 |
return [ |
| 2850 |
'success' => false, |
| 2851 |
'message' => __('This payment method is not available.', 'yatra'), |
| 2852 |
]; |
| 2853 |
} |
| 2854 |
|
| 2855 |
if (!$gateway->isProperlyConfigured()) { |
| 2856 |
return [ |
| 2857 |
'success' => false, |
| 2858 |
'message' => GatewayUserMessages::gatewayNotConfigured($gateway), |
| 2859 |
]; |
| 2860 |
} |
| 2861 |
|
| 2862 |
// Prepare payment data - pass all params, gateways extract what they need. |
| 2863 |
// Default return_url to the configured booking confirmation URL so redirect gateways |
| 2864 |
// (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways |
| 2865 |
// may still append their own query args on top of this URL. |
| 2866 |
$ref = isset($params['reference']) ? trim((string) $params['reference']) : ''; |
| 2867 |
// Cancel returns must land on the booking-confirmation page (always resolvable); |
| 2868 |
// `home_url('/book/?...')` 404s under a custom booking base/page. Use the reference, |
| 2869 |
// falling back to the booking id so the confirmation route always has a token. |
| 2870 |
$cancelRef = $ref !== '' ? $ref : (string) ($params['booking_id'] ?? ''); |
| 2871 |
$paymentData = array_merge($params, [ |
| 2872 |
'description' => $params['trip_title'] ?? '', |
| 2873 |
'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)), |
| 2874 |
'metadata' => [ |
| 2875 |
'booking_id' => $params['booking_id'], |
| 2876 |
'reference' => $params['reference'] ?? '' |
| 2877 |
] |
| 2878 |
]); |
| 2879 |
if ($ref !== '' && empty($paymentData['return_url'])) { |
| 2880 |
$paymentData['return_url'] = $this->getConfirmationUrl($ref); |
| 2881 |
} |
| 2882 |
|
| 2883 |
// Process the payment through the gateway |
| 2884 |
$result = $gateway->processPayment($paymentData); |
| 2885 |
|
| 2886 |
// Debug logging |
| 2887 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2888 |
} |
| 2889 |
|
| 2890 |
if ($result['success']) { |
| 2891 |
// Save transaction ID for tracking |
| 2892 |
if (!empty($result['transaction_id'])) { |
| 2893 |
$this->bookingRepository->updatePaymentSessionId( |
| 2894 |
(int) $params['booking_id'], |
| 2895 |
$result['transaction_id'] |
| 2896 |
); |
| 2897 |
} |
| 2898 |
|
| 2899 |
// For gateways that require client-side action (Stripe, Razorpay, etc.) |
| 2900 |
// Payment will be recorded after client completes the action |
| 2901 |
if (!empty($result['requires_action'])) { |
| 2902 |
return array_merge(['success' => true], $result); |
| 2903 |
} |
| 2904 |
|
| 2905 |
// For gateways that return a redirect URL for external payment (PayPal, eSewa, Khalti) |
| 2906 |
// Payment will be recorded on callback/return |
| 2907 |
if (!empty($result['redirect_url']) || !empty($result['payment_url'])) { |
| 2908 |
// Check if this is a completed payment with redirect (like Square) |
| 2909 |
// vs pending external payment (like PayPal) |
| 2910 |
$isCompletedPayment = !empty($result['transaction_id']) && |
| 2911 |
(($result['status'] ?? '') === 'completed' || ($result['status'] ?? '') === 'succeeded'); |
| 2912 |
|
| 2913 |
if ($isCompletedPayment) { |
| 2914 |
$this->recordGatewayPayment($params, $result, $gatewayId); |
| 2915 |
} |
| 2916 |
|
| 2917 |
return [ |
| 2918 |
'success' => true, |
| 2919 |
'payment_url' => $result['redirect_url'] ?? $result['payment_url'] |
| 2920 |
]; |
| 2921 |
} |
| 2922 |
|
| 2923 |
// For offline gateways or successful direct payments without redirect |
| 2924 |
$this->recordOfflinePendingPayment($params, $result, $gatewayId); |
| 2925 |
|
| 2926 |
return [ |
| 2927 |
'success' => true, |
| 2928 |
'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '') |
| 2929 |
]; |
| 2930 |
} |
| 2931 |
|
| 2932 |
// Log the payment failure for debugging |
| 2933 |
if (defined('WP_DEBUG') && WP_DEBUG) { |
| 2934 |
} |
| 2935 |
|
| 2936 |
return [ |
| 2937 |
'success' => false, |
| 2938 |
'message' => $result['message'] ?? $result['error'] ?? 'Payment processing failed. Please try again.' |
| 2939 |
]; |
| 2940 |
|
| 2941 |
} catch (\Exception $e) { |
| 2942 |
// Log the exception for debugging |
| 2943 |
return [ |
| 2944 |
'success' => false, |
| 2945 |
'message' => 'An unexpected error occurred. Please try again or contact support.' |
| 2946 |
]; |
| 2947 |
} |
| 2948 |
} |
| 2949 |
|
| 2950 |
/** |
| 2951 |
* Record payment from gateway result |
| 2952 |
* Matches Stripe's completePayment behavior |
| 2953 |
*/ |
| 2954 |
/** |
| 2955 |
* Record the awaited payment for an offline gateway (bank transfer, cash on |
| 2956 |
* arrival, pay later) as a PENDING ledger row. |
| 2957 |
* |
| 2958 |
* These gateways take no money at checkout, and previously wrote no payment |
| 2959 |
* row at all — so when the transfer finally landed there was nothing in the |
| 2960 |
* Payments screen for the operator to mark as received. The booking's own |
| 2961 |
* fields were the only record, and marking those by hand left the invoice |
| 2962 |
* reporting "Payment Pending" with nothing paid. |
| 2963 |
* |
| 2964 |
* The row is deliberately `pending`: no money has arrived yet, and |
| 2965 |
* getTotalPaidForBooking() counts only `completed`, so booking financials and |
| 2966 |
* every report are untouched until the operator confirms it. |
| 2967 |
*/ |
| 2968 |
private function recordOfflinePendingPayment(array $params, array $result, string $gatewayId): void |
| 2969 |
{ |
| 2970 |
try { |
| 2971 |
$bookingId = (int) ($params['booking_id'] ?? 0); |
| 2972 |
$amount = (float) ($params['amount'] ?? 0); |
| 2973 |
|
| 2974 |
if ($bookingId <= 0 || $amount <= 0) { |
| 2975 |
return; |
| 2976 |
} |
| 2977 |
|
| 2978 |
// Only for gateways that settle out of band. Anything reporting a |
| 2979 |
// completed/succeeded status already records its own row. |
| 2980 |
$status = strtolower((string) ($result['status'] ?? '')); |
| 2981 |
if (!in_array($status, ['', 'pending', 'pending_verification'], true)) { |
| 2982 |
return; |
| 2983 |
} |
| 2984 |
|
| 2985 |
$booking = $this->bookingRepository->find($bookingId); |
| 2986 |
if (!$booking || ($booking->payment_status ?? '') === 'paid') { |
| 2987 |
return; |
| 2988 |
} |
| 2989 |
|
| 2990 |
$paymentRepository = new \Yatra\Repositories\PaymentRepository(); |
| 2991 |
|
| 2992 |
// Idempotency: a retried checkout must not stack up duplicate rows. |
| 2993 |
foreach ($paymentRepository->findByBookingId($bookingId) as $existing) { |
| 2994 |
if ((string) ($existing->gateway ?? '') === $gatewayId |
| 2995 |
&& in_array((string) ($existing->status ?? ''), ['pending', 'completed'], true) |
| 2996 |
) { |
| 2997 |
return; |
| 2998 |
} |
| 2999 |
} |
| 3000 |
|
| 3001 |
$paymentRepository->create([ |
| 3002 |
'booking_id' => $bookingId, |
| 3003 |
'amount' => $amount, |
| 3004 |
'currency' => $params['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 3005 |
'gateway' => $gatewayId, |
| 3006 |
'status' => 'pending', |
| 3007 |
'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null, |
| 3008 |
'notes' => __('Awaiting payment — mark as completed once received.', 'yatra'), |
| 3009 |
'created_at' => current_time('mysql'), |
| 3010 |
]); |
| 3011 |
} catch (\Throwable $e) { |
| 3012 |
// Never break a successful checkout over a bookkeeping row. |
| 3013 |
\Yatra\Utils\Logger::warning('Could not record pending offline payment', [ |
| 3014 |
'booking_id' => $params['booking_id'] ?? 0, |
| 3015 |
'gateway' => $gatewayId, |
| 3016 |
'error' => $e->getMessage(), |
| 3017 |
]); |
| 3018 |
} |
| 3019 |
} |
| 3020 |
|
| 3021 |
private function recordGatewayPayment(array $params, array $result, string $gatewayId): void |
| 3022 |
{ |
| 3023 |
global $wpdb; |
| 3024 |
|
| 3025 |
try { |
| 3026 |
$bookingId = (int) $params['booking_id']; |
| 3027 |
$amount = (float) ($params['amount'] ?? 0); |
| 3028 |
$currency = $params['currency'] ?? 'USD'; |
| 3029 |
$transactionId = $result['transaction_id'] ?? ''; |
| 3030 |
|
| 3031 |
// Get booking |
| 3032 |
$booking = $this->bookingRepository->find($bookingId); |
| 3033 |
if (!$booking) { |
| 3034 |
return; |
| 3035 |
} |
| 3036 |
|
| 3037 |
// Already settled in full — never apply another charge to it. A fresh |
| 3038 |
// booking is never already paid, so in practice this only guards a |
| 3039 |
// stray/duplicate completion call (with a different transaction id) |
| 3040 |
// against over-applying the ledger. |
| 3041 |
if (($booking->payment_status ?? '') === 'paid') { |
| 3042 |
return; |
| 3043 |
} |
| 3044 |
|
| 3045 |
$paymentRepository = new \Yatra\Repositories\PaymentRepository(); |
| 3046 |
|
| 3047 |
// Idempotency guard: skip if this gateway transaction is already |
| 3048 |
// recorded for this booking. Prevents duplicate ledger rows when a |
| 3049 |
// payment is submitted twice (the gateway uses a fresh idempotency |
| 3050 |
// key per call, so it won't dedupe a true retry). Mirrors |
| 3051 |
// PaymentGatewayController::handle_successful_payment(). |
| 3052 |
if ($transactionId !== '') { |
| 3053 |
$existing = $paymentRepository->findByTransactionId($transactionId); |
| 3054 |
if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) { |
| 3055 |
return; |
| 3056 |
} |
| 3057 |
} |
| 3058 |
|
| 3059 |
// Record the payment |
| 3060 |
$paymentRepository->create([ |
| 3061 |
'booking_id' => $bookingId, |
| 3062 |
'amount' => $amount, |
| 3063 |
'currency' => $currency, |
| 3064 |
'gateway' => $gatewayId, |
| 3065 |
'transaction_id' => $transactionId, |
| 3066 |
'status' => 'completed', |
| 3067 |
'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null, |
| 3068 |
'created_at' => current_time('mysql'), |
| 3069 |
]); |
| 3070 |
|
| 3071 |
// Update the booking ledger + status. The synchronous gateways |
| 3072 |
// (Square, Authorize.Net) reach this generic path but previously left |
| 3073 |
// the booking at pending/pending — only the payment row was written. |
| 3074 |
// This now matches handle_successful_payment(): accumulate amount_paid, |
| 3075 |
// recompute amount_due, set payment_status (paid vs partial), and |
| 3076 |
// confirm the booking (a deposit confirms too, consistent with Stripe). |
| 3077 |
$newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount; |
| 3078 |
$newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid); |
| 3079 |
$paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid'; |
| 3080 |
$previousStatus = (string) ($booking->status ?? 'pending'); |
| 3081 |
|
| 3082 |
// Only auto-confirm when the operator allows it (or fully paid). A |
| 3083 |
// deposit / partial payment must not confirm when "Auto-Confirm |
| 3084 |
// Bookings" is off. |
| 3085 |
$shouldConfirm = \yatra_should_confirm_booking_on_payment($newAmountDue <= 0.0, $bookingId); |
| 3086 |
|
| 3087 |
$bookingUpdate = [ |
| 3088 |
'amount_paid' => $newAmountPaid, |
| 3089 |
'amount_due' => $newAmountDue, |
| 3090 |
'payment_status' => $paymentStatus, |
| 3091 |
]; |
| 3092 |
if ($shouldConfirm) { |
| 3093 |
$bookingUpdate['status'] = 'confirmed'; |
| 3094 |
} |
| 3095 |
$this->bookingRepository->update($bookingId, $bookingUpdate); |
| 3096 |
|
| 3097 |
if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) { |
| 3098 |
\yatra_trigger_booking_confirmed($bookingId, $previousStatus); |
| 3099 |
} |
| 3100 |
|
| 3101 |
// Fire payment completed action |
| 3102 |
do_action('yatra_payment_completed', [ |
| 3103 |
'booking_id' => $bookingId, |
| 3104 |
'transaction_id' => $transactionId, |
| 3105 |
'amount' => $amount, |
| 3106 |
'currency' => $currency, |
| 3107 |
'gateway' => $gatewayId, |
| 3108 |
]); |
| 3109 |
} catch (\Throwable $e) { |
| 3110 |
// Best-effort: the charge is already recorded; a confirmation-page |
| 3111 |
// reload / status reconciliation can recover if this update fails. |
| 3112 |
} |
| 3113 |
} |
| 3114 |
|
| 3115 |
/** |
| 3116 |
* Process PayPal payment |
| 3117 |
*/ |
| 3118 |
private function processPayPalPayment(array $params, array $config, bool $is_test): array |
| 3119 |
{ |
| 3120 |
$client_id = $config['client_id'] ?? ''; |
| 3121 |
$client_secret = $config['client_secret'] ?? ''; |
| 3122 |
|
| 3123 |
if (empty($client_id) || empty($client_secret)) { |
| 3124 |
return ['success' => false, 'message' => 'PayPal credentials not configured']; |
| 3125 |
} |
| 3126 |
|
| 3127 |
$base_url = $is_test ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com'; |
| 3128 |
|
| 3129 |
try { |
| 3130 |
// Get access token |
| 3131 |
$auth_response = wp_remote_post($base_url . '/v1/oauth2/token', [ |
| 3132 |
'headers' => [ |
| 3133 |
'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret), |
| 3134 |
'Content-Type' => 'application/x-www-form-urlencoded', |
| 3135 |
], |
| 3136 |
'body' => 'grant_type=client_credentials', |
| 3137 |
]); |
| 3138 |
|
| 3139 |
if (is_wp_error($auth_response)) { |
| 3140 |
return ['success' => false, 'message' => $auth_response->get_error_message()]; |
| 3141 |
} |
| 3142 |
|
| 3143 |
$auth_body = json_decode(wp_remote_retrieve_body($auth_response), true); |
| 3144 |
$access_token = $auth_body['access_token'] ?? ''; |
| 3145 |
|
| 3146 |
if (empty($access_token)) { |
| 3147 |
return ['success' => false, 'message' => 'Failed to get PayPal access token']; |
| 3148 |
} |
| 3149 |
|
| 3150 |
// Create order |
| 3151 |
$order_response = wp_remote_post($base_url . '/v2/checkout/orders', [ |
| 3152 |
'headers' => [ |
| 3153 |
'Authorization' => 'Bearer ' . $access_token, |
| 3154 |
'Content-Type' => 'application/json', |
| 3155 |
], |
| 3156 |
'body' => wp_json_encode([ |
| 3157 |
'intent' => 'CAPTURE', |
| 3158 |
'purchase_units' => [[ |
| 3159 |
'reference_id' => $params['reference'], |
| 3160 |
'amount' => [ |
| 3161 |
'currency_code' => $params['currency'], |
| 3162 |
'value' => number_format($params['amount'], 2, '.', ''), |
| 3163 |
], |
| 3164 |
'description' => $params['trip_title'], |
| 3165 |
]], |
| 3166 |
'application_context' => [ |
| 3167 |
'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])), |
| 3168 |
'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])), |
| 3169 |
], |
| 3170 |
]), |
| 3171 |
]); |
| 3172 |
|
| 3173 |
if (is_wp_error($order_response)) { |
| 3174 |
return ['success' => false, 'message' => $order_response->get_error_message()]; |
| 3175 |
} |
| 3176 |
|
| 3177 |
$order_body = json_decode(wp_remote_retrieve_body($order_response), true); |
| 3178 |
|
| 3179 |
// Find approval link |
| 3180 |
foreach ($order_body['links'] ?? [] as $link) { |
| 3181 |
if ($link['rel'] === 'approve') { |
| 3182 |
// Save order ID for capture later |
| 3183 |
$this->bookingRepository->updatePaymentSessionId( |
| 3184 |
(int) $params['booking_id'], |
| 3185 |
$order_body['id'] ?? '' |
| 3186 |
); |
| 3187 |
|
| 3188 |
return ['success' => true, 'payment_url' => $link['href']]; |
| 3189 |
} |
| 3190 |
} |
| 3191 |
|
| 3192 |
return ['success' => false, 'message' => 'Failed to create PayPal order']; |
| 3193 |
} catch (\Exception $e) { |
| 3194 |
return ['success' => false, 'message' => $e->getMessage()]; |
| 3195 |
} |
| 3196 |
} |
| 3197 |
|
| 3198 |
/** |
| 3199 |
* Process Razorpay payment |
| 3200 |
*/ |
| 3201 |
private function processRazorpayPayment(array $params, array $config, bool $is_test): array |
| 3202 |
{ |
| 3203 |
$key_id = $config['api_key'] ?? ''; |
| 3204 |
$key_secret = $config['api_secret'] ?? ''; |
| 3205 |
|
| 3206 |
if (empty($key_id) || empty($key_secret)) { |
| 3207 |
return ['success' => false, 'message' => 'Razorpay credentials not configured']; |
| 3208 |
} |
| 3209 |
|
| 3210 |
try { |
| 3211 |
// Create Razorpay order |
| 3212 |
$response = wp_remote_post('https://api.razorpay.com/v1/orders', [ |
| 3213 |
'headers' => [ |
| 3214 |
'Authorization' => 'Basic ' . base64_encode($key_id . ':' . $key_secret), |
| 3215 |
'Content-Type' => 'application/json', |
| 3216 |
], |
| 3217 |
'body' => wp_json_encode([ |
| 3218 |
'amount' => (int) ($params['amount'] * 100), // Amount in paise |
| 3219 |
'currency' => $params['currency'], |
| 3220 |
'receipt' => $params['reference'], |
| 3221 |
'notes' => [ |
| 3222 |
'booking_id' => $params['booking_id'], |
| 3223 |
'trip' => $params['trip_title'], |
| 3224 |
], |
| 3225 |
]), |
| 3226 |
]); |
| 3227 |
|
| 3228 |
if (is_wp_error($response)) { |
| 3229 |
return ['success' => false, 'message' => $response->get_error_message()]; |
| 3230 |
} |
| 3231 |
|
| 3232 |
$body = json_decode(wp_remote_retrieve_body($response), true); |
| 3233 |
|
| 3234 |
if (!empty($body['id'])) { |
| 3235 |
// Save order ID |
| 3236 |
$this->bookingRepository->updatePaymentSessionId( |
| 3237 |
(int) $params['booking_id'], |
| 3238 |
$body['id'] |
| 3239 |
); |
| 3240 |
|
| 3241 |
// Razorpay requires client-side integration, return data for JS |
| 3242 |
// Store order details and redirect to a payment page |
| 3243 |
$payment_url = add_query_arg([ |
| 3244 |
'razorpay_order' => $body['id'], |
| 3245 |
'booking_ref' => $params['reference'], |
| 3246 |
'key' => $key_id, |
| 3247 |
'amount' => (int) ($params['amount'] * 100), |
| 3248 |
'currency' => $params['currency'], |
| 3249 |
'name' => get_bloginfo('name'), |
| 3250 |
'description' => $params['trip_title'], |
| 3251 |
'email' => $params['customer_email'], |
| 3252 |
], home_url('/yatra-payment/razorpay/')); |
| 3253 |
|
| 3254 |
return ['success' => true, 'payment_url' => $payment_url]; |
| 3255 |
} |
| 3256 |
|
| 3257 |
return ['success' => false, 'message' => $body['error']['description'] ?? 'Failed to create Razorpay order']; |
| 3258 |
} catch (\Exception $e) { |
| 3259 |
return ['success' => false, 'message' => $e->getMessage()]; |
| 3260 |
} |
| 3261 |
} |
| 3262 |
|
| 3263 |
/** |
| 3264 |
* Process eSewa payment |
| 3265 |
*/ |
| 3266 |
private function processEsewaPayment(array $params, array $config, bool $is_test): array |
| 3267 |
{ |
| 3268 |
$merchant_id = $config['merchant_id'] ?? ''; |
| 3269 |
|
| 3270 |
if (empty($merchant_id)) { |
| 3271 |
return ['success' => false, 'message' => 'eSewa merchant ID not configured']; |
| 3272 |
} |
| 3273 |
|
| 3274 |
$base_url = $is_test ? 'https://uat.esewa.com.np/epay/main' : 'https://esewa.com.np/epay/main'; |
| 3275 |
|
| 3276 |
// eSewa uses form redirect, build URL with parameters |
| 3277 |
$payment_url = add_query_arg([ |
| 3278 |
'amt' => $params['amount'], |
| 3279 |
'psc' => 0, |
| 3280 |
'pdc' => 0, |
| 3281 |
'txAmt' => 0, |
| 3282 |
'tAmt' => $params['amount'], |
| 3283 |
'pid' => $params['reference'], |
| 3284 |
'scd' => $merchant_id, |
| 3285 |
'su' => add_query_arg( |
| 3286 |
['payment' => 'success', 'gateway' => 'esewa'], |
| 3287 |
$this->getConfirmationUrl($params['reference']) |
| 3288 |
), |
| 3289 |
'fu' => add_query_arg( |
| 3290 |
['payment' => 'failed', 'gateway' => 'esewa'], |
| 3291 |
$this->getConfirmationUrl($params['reference']) |
| 3292 |
), |
| 3293 |
], $base_url); |
| 3294 |
|
| 3295 |
return ['success' => true, 'payment_url' => $payment_url]; |
| 3296 |
} |
| 3297 |
|
| 3298 |
/** |
| 3299 |
* Process Khalti payment |
| 3300 |
*/ |
| 3301 |
private function processKhaltiPayment(array $params, array $config, bool $is_test): array |
| 3302 |
{ |
| 3303 |
$secret_key = $config['api_secret'] ?? ''; |
| 3304 |
|
| 3305 |
if (empty($secret_key)) { |
| 3306 |
return ['success' => false, 'message' => 'Khalti secret key not configured']; |
| 3307 |
} |
| 3308 |
|
| 3309 |
$base_url = $is_test ? 'https://a.khalti.com/api/v2/epayment/initiate/' : 'https://khalti.com/api/v2/epayment/initiate/'; |
| 3310 |
|
| 3311 |
try { |
| 3312 |
$response = wp_remote_post($base_url, [ |
| 3313 |
'headers' => [ |
| 3314 |
'Authorization' => 'Key ' . $secret_key, |
| 3315 |
'Content-Type' => 'application/json', |
| 3316 |
], |
| 3317 |
'body' => wp_json_encode([ |
| 3318 |
'return_url' => add_query_arg( |
| 3319 |
['payment' => 'success', 'gateway' => 'khalti'], |
| 3320 |
$this->getConfirmationUrl($params['reference']) |
| 3321 |
), |
| 3322 |
'website_url' => home_url(), |
| 3323 |
'amount' => (int) ($params['amount'] * 100), // Amount in paisa |
| 3324 |
'purchase_order_id' => $params['reference'], |
| 3325 |
'purchase_order_name' => $params['trip_title'], |
| 3326 |
'customer_info' => [ |
| 3327 |
'name' => $params['customer_name'], |
| 3328 |
'email' => $params['customer_email'], |
| 3329 |
], |
| 3330 |
]), |
| 3331 |
]); |
| 3332 |
|
| 3333 |
if (is_wp_error($response)) { |
| 3334 |
return ['success' => false, 'message' => $response->get_error_message()]; |
| 3335 |
} |
| 3336 |
|
| 3337 |
$body = json_decode(wp_remote_retrieve_body($response), true); |
| 3338 |
|
| 3339 |
if (!empty($body['payment_url'])) { |
| 3340 |
// Save pidx for verification |
| 3341 |
$this->bookingRepository->updatePaymentSessionId( |
| 3342 |
(int) $params['booking_id'], |
| 3343 |
$body['pidx'] ?? '' |
| 3344 |
); |
| 3345 |
|
| 3346 |
return ['success' => true, 'payment_url' => $body['payment_url']]; |
| 3347 |
} |
| 3348 |
|
| 3349 |
return ['success' => false, 'message' => $body['detail'] ?? 'Failed to initiate Khalti payment']; |
| 3350 |
} catch (\Exception $e) { |
| 3351 |
return ['success' => false, 'message' => $e->getMessage()]; |
| 3352 |
} |
| 3353 |
} |
| 3354 |
|
| 3355 |
/** |
| 3356 |
* Process Authorize.net payment |
| 3357 |
*/ |
| 3358 |
private function processAuthorizeNetPayment(array $params, array $config, bool $is_test): array |
| 3359 |
{ |
| 3360 |
// Authorize.net typically requires hosted payment page or client-side integration |
| 3361 |
// Return URL for hosted payment page setup |
| 3362 |
return [ |
| 3363 |
'success' => true, |
| 3364 |
'payment_url' => add_query_arg([ |
| 3365 |
'booking_ref' => $params['reference'], |
| 3366 |
'amount' => $params['amount'], |
| 3367 |
'gateway' => 'authorize_net', |
| 3368 |
], home_url('/yatra-payment/authorize-net/')) |
| 3369 |
]; |
| 3370 |
} |
| 3371 |
|
| 3372 |
/** |
| 3373 |
* Get client IP address |
| 3374 |
*/ |
| 3375 |
private function getClientIp(): string |
| 3376 |
{ |
| 3377 |
$ip_keys = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR']; |
| 3378 |
|
| 3379 |
foreach ($ip_keys as $key) { |
| 3380 |
if (!empty($_SERVER[$key])) { |
| 3381 |
$ip = sanitize_text_field($_SERVER[$key]); |
| 3382 |
if (strpos($ip, ',') !== false) { |
| 3383 |
$ip = trim(explode(',', $ip)[0]); |
| 3384 |
} |
| 3385 |
if (filter_var($ip, FILTER_VALIDATE_IP)) { |
| 3386 |
return $ip; |
| 3387 |
} |
| 3388 |
} |
| 3389 |
} |
| 3390 |
|
| 3391 |
return '0.0.0.0'; |
| 3392 |
} |
| 3393 |
|
| 3394 |
/** |
| 3395 |
* Send booking confirmation email |
| 3396 |
*/ |
| 3397 |
private function sendBookingConfirmationEmail(int $booking_id, string $reference, object $trip, array $data): void |
| 3398 |
{ |
| 3399 |
$contact = $data['contact'] ?? []; |
| 3400 |
$travelers = $data['travelers'] ?? []; |
| 3401 |
$travel_date = $data['travel_date'] ?? ''; |
| 3402 |
$total_amount = $data['total_amount'] ?? 0; |
| 3403 |
$amount_due = $data['amount_due'] ?? 0; |
| 3404 |
$payment_method = $data['payment_method'] ?? 'full'; |
| 3405 |
$payment_gateway = $data['payment_gateway'] ?? 'pay_later'; |
| 3406 |
$booking_status = $data['booking_status'] ?? 'pending'; |
| 3407 |
// Cancellation copy in the email now comes from the trip's |
| 3408 |
// own cancellation_policy field (set per-trip on the Trip |
| 3409 |
// editor), not from removed global settings. Falls back to |
| 3410 |
// empty so the paragraph is silently omitted when the trip |
| 3411 |
// doesn't have a policy set. |
| 3412 |
$trip_cancellation_policy = isset($trip->cancellation_policy) |
| 3413 |
? wp_strip_all_tags((string) $trip->cancellation_policy) |
| 3414 |
: ''; |
| 3415 |
$expiry_datetime = $data['expiry_datetime'] ?? null; |
| 3416 |
|
| 3417 |
$customer_email = $contact['email'] ?? ''; |
| 3418 |
$customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? '')); |
| 3419 |
|
| 3420 |
if (empty($customer_email)) { |
| 3421 |
return; |
| 3422 |
} |
| 3423 |
|
| 3424 |
// Format prices using global currency settings |
| 3425 |
$formatted_total = yatra_format_price($total_amount); |
| 3426 |
$formatted_due = yatra_format_price($amount_due); |
| 3427 |
|
| 3428 |
$intro_paragraph = $booking_status === 'confirmed' |
| 3429 |
? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra') |
| 3430 |
: __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra'); |
| 3431 |
if ($booking_status === 'pending' && $expiry_datetime) { |
| 3432 |
$intro_paragraph .= ' ' . sprintf( |
| 3433 |
/* translators: %s: payment expiry date and time (formatted). */ |
| 3434 |
__('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'), |
| 3435 |
date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime)) |
| 3436 |
); |
| 3437 |
} |
| 3438 |
|
| 3439 |
ob_start(); |
| 3440 |
?> |
| 3441 |
<div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;"> |
| 3442 |
<p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p> |
| 3443 |
<p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p> |
| 3444 |
<p style="margin:0 0 8px;"><strong><?php esc_html_e('Travel date', 'yatra'); ?>:</strong> <?php echo esc_html(date_i18n(get_option('date_format'), strtotime($travel_date))); ?></p> |
| 3445 |
<p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */ |
| 3446 |
echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p> |
| 3447 |
<p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p> |
| 3448 |
</div> |
| 3449 |
<h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3> |
| 3450 |
<p><?php /* translators: %s: total amount (formatted). */ |
| 3451 |
echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p> |
| 3452 |
<?php if ($payment_method === 'deposit') : ?> |
| 3453 |
<p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */ |
| 3454 |
echo esc_html(sprintf(__('Payment type: Deposit — due now %1$s, remaining %2$s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p> |
| 3455 |
<?php elseif ($payment_method === 'partial') : ?> |
| 3456 |
<p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */ |
| 3457 |
echo esc_html(sprintf(__('Payment type: Partial — due now %1$s, remaining %2$s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p> |
| 3458 |
<?php else : ?> |
| 3459 |
<p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p> |
| 3460 |
<?php endif; ?> |
| 3461 |
<?php if ($payment_gateway === 'pay_later') : ?> |
| 3462 |
<p><?php esc_html_e('Pay later — please contact us to arrange payment.', 'yatra'); ?></p> |
| 3463 |
<?php elseif ($payment_gateway === 'bank_transfer') : ?> |
| 3464 |
<p><?php esc_html_e('Bank transfer — you will receive bank details separately.', 'yatra'); ?></p> |
| 3465 |
<?php endif; ?> |
| 3466 |
<h3 style="font-size:16px;"><?php esc_html_e('Travelers', 'yatra'); ?></h3> |
| 3467 |
<ul style="padding-left:20px;"> |
| 3468 |
<?php foreach ($travelers as $i => $traveler) : ?> |
| 3469 |
<?php |
| 3470 |
$traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? '')); |
| 3471 |
?> |
| 3472 |
<li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */ |
| 3473 |
echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li> |
| 3474 |
<?php endforeach; ?> |
| 3475 |
</ul> |
| 3476 |
<?php |
| 3477 |
// Cancellation policy paragraph now sources from the trip's |
| 3478 |
// per-trip cancellation_policy field (set on the Trip |
| 3479 |
// editor). The previous version used global cancellation |
| 3480 |
// settings that were display-only — they appeared here but |
| 3481 |
// never enforced a real cancellation cutoff. We've removed |
| 3482 |
// those settings; if the trip itself doesn't define a |
| 3483 |
// policy, the whole section is silently omitted so the |
| 3484 |
// email isn't padded with empty headings. |
| 3485 |
if ($trip_cancellation_policy !== '') { |
| 3486 |
?> |
| 3487 |
<h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3> |
| 3488 |
<p><?php echo esc_html($trip_cancellation_policy); ?></p> |
| 3489 |
<?php |
| 3490 |
} |
| 3491 |
?> |
| 3492 |
<h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3> |
| 3493 |
<ol style="padding-left:20px;"> |
| 3494 |
<li><?php esc_html_e('You will receive a detailed trip itinerary within 24–48 hours.', 'yatra'); ?></li> |
| 3495 |
<li><?php esc_html_e('Our team will contact you to confirm any special requirements.', 'yatra'); ?></li> |
| 3496 |
<li><?php esc_html_e('Please ensure travel documents meet entry requirements for your destination.', 'yatra'); ?></li> |
| 3497 |
</ol> |
| 3498 |
<p><?php esc_html_e('If you have any questions, contact us anytime.', 'yatra'); ?></p> |
| 3499 |
<p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p> |
| 3500 |
<?php |
| 3501 |
$details_html = ob_get_clean(); |
| 3502 |
|
| 3503 |
// Seed from the canonical booking variables FIRST so every dynamic |
| 3504 |
// merge tag — {{contact_*}} / {{emergency_*}} custom fields, |
| 3505 |
// {{traveler_custom_fields_html}}, {{balance_due}}, payment/schedule |
| 3506 |
// tags, etc. — resolves on this offline / pay-later path exactly like |
| 3507 |
// the online-gateway path (BookingService::sendBookingConfirmationEmail). |
| 3508 |
// Previously this method hand-built only ~18 core keys, so an operator |
| 3509 |
// who customised the Booking Confirmation template with a custom-field |
| 3510 |
// variable saw it render empty on offline bookings. The hand-built keys |
| 3511 |
// below (the self-rendered details_html, intro, footer) intentionally |
| 3512 |
// take precedence via array_merge ordering. |
| 3513 |
$base_vars = []; |
| 3514 |
$saved_booking = $this->bookingRepository->find($booking_id); |
| 3515 |
if ($saved_booking) { |
| 3516 |
$base_vars = TransactionalEmailTemplateService::variablesFromBooking($saved_booking); |
| 3517 |
} |
| 3518 |
|
| 3519 |
$vars = array_merge($base_vars, [ |
| 3520 |
'customer_name' => $customer_name, |
| 3521 |
'customer_first_name' => (string) ($contact['first_name'] ?? ''), |
| 3522 |
'customer_last_name' => (string) ($contact['last_name'] ?? ''), |
| 3523 |
'customer_email' => $customer_email, |
| 3524 |
'customer_phone' => (string) ($contact['phone'] ?? ''), |
| 3525 |
'booking_reference' => $reference, |
| 3526 |
'booking_id' => (string) $booking_id, |
| 3527 |
'trip_name' => (string) $trip->title, |
| 3528 |
'trip_url' => home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) ($trip->slug ?? '')) . '/'), |
| 3529 |
'travel_date' => date_i18n(get_option('date_format'), strtotime($travel_date)), |
| 3530 |
'travelers_count' => (string) count($travelers), |
| 3531 |
'total_amount_formatted' => $formatted_total, |
| 3532 |
'amount_due_formatted' => $formatted_due, |
| 3533 |
'currency' => SettingsService::getCurrency(), |
| 3534 |
'intro_paragraph' => $intro_paragraph, |
| 3535 |
'details_html' => $details_html, |
| 3536 |
'details_html_only' => '1', |
| 3537 |
/* translators: %s: site name. */ |
| 3538 |
'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')), |
| 3539 |
'transactional_context' => 'booking_created', |
| 3540 |
]); |
| 3541 |
|
| 3542 |
TransactionalEmailTemplateService::sendIfEnabled( |
| 3543 |
TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION, |
| 3544 |
$customer_email, |
| 3545 |
$vars |
| 3546 |
); |
| 3547 |
|
| 3548 |
// Admin new-booking email is sent from NotificationService (yatra_booking_created) using |
| 3549 |
// Email → Templates → Admin: New booking, to avoid duplicate messages. |
| 3550 |
} |
| 3551 |
|
| 3552 |
/** |
| 3553 |
* Apply coupon code to booking session |
| 3554 |
*/ |
| 3555 |
public function apply_coupon(WP_REST_Request $request): WP_REST_Response |
| 3556 |
{ |
| 3557 |
yatra_start_session(); |
| 3558 |
|
| 3559 |
$data = $request->get_json_params() ?? []; |
| 3560 |
|
| 3561 |
// M-2: restore CSRF protection stripped by public_permission_callback. |
| 3562 |
if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) { |
| 3563 |
return $blocked; |
| 3564 |
} |
| 3565 |
|
| 3566 |
$code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : ''; |
| 3567 |
|
| 3568 |
if (empty($code)) { |
| 3569 |
return new WP_REST_Response([ |
| 3570 |
'success' => false, |
| 3571 |
'message' => __('Please enter a coupon code.', 'yatra'), |
| 3572 |
], 400); |
| 3573 |
} |
| 3574 |
|
| 3575 |
// Get current session — with token-rehydration fallback for REST |
| 3576 |
// contexts where PHPSESSID isn't propagated (same shape as the |
| 3577 |
// service-toggle / summary endpoints). Also writes back to $_SESSION |
| 3578 |
// so the subsequent `yatra_set_booking_session()` persists alongside |
| 3579 |
// the existing transient. |
| 3580 |
$session = yatra_get_booking_session(); |
| 3581 |
if (empty($session) || empty($session['trip_id'])) { |
| 3582 |
$token = null; |
| 3583 |
if (!empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 3584 |
$token = sanitize_text_field((string) $data['booking_token']); |
| 3585 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 3586 |
$token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 3587 |
} |
| 3588 |
if ($token) { |
| 3589 |
$transient_data = get_transient($token); |
| 3590 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 3591 |
$session = $transient_data; |
| 3592 |
$_SESSION['yatra_booking'] = $session; |
| 3593 |
$_SESSION['yatra_booking_token'] = $token; |
| 3594 |
} |
| 3595 |
} |
| 3596 |
} |
| 3597 |
if (empty($session) || empty($session['trip_id'])) { |
| 3598 |
return new WP_REST_Response([ |
| 3599 |
'success' => false, |
| 3600 |
'message' => __('No active booking session found.', 'yatra'), |
| 3601 |
], 400); |
| 3602 |
} |
| 3603 |
|
| 3604 |
// Use DiscountService to calculate coupon discount |
| 3605 |
$discountService = new \Yatra\Services\DiscountService(); |
| 3606 |
$total_amount = $this->calculateSessionTotal($session); |
| 3607 |
$trip_id = (int) $session['trip_id']; |
| 3608 |
$travelers_count = (int) ($session['travelers'] ?? 1); |
| 3609 |
$traveler_counts = $session['traveler_counts'] ?? []; |
| 3610 |
|
| 3611 |
$coupon_result = $discountService->calculateCouponDiscount( |
| 3612 |
$code, |
| 3613 |
$total_amount, |
| 3614 |
$trip_id, |
| 3615 |
$travelers_count, |
| 3616 |
$traveler_counts |
| 3617 |
); |
| 3618 |
|
| 3619 |
// Check if discount was calculated (validation passed) |
| 3620 |
if ($coupon_result['calculated_amount'] <= 0) { |
| 3621 |
return new WP_REST_Response([ |
| 3622 |
'success' => false, |
| 3623 |
'message' => __('This coupon is not valid for your booking.', 'yatra'), |
| 3624 |
], 400); |
| 3625 |
} |
| 3626 |
|
| 3627 |
$discount_amount = $coupon_result['calculated_amount']; |
| 3628 |
|
| 3629 |
|
| 3630 |
// Store coupon in session (use the original $code variable, not from result) |
| 3631 |
$session['coupon'] = [ |
| 3632 |
'code' => $code, // Use the actual code that was validated |
| 3633 |
'type' => $coupon_result['type'], |
| 3634 |
'amount' => $coupon_result['amount'], |
| 3635 |
'discount_amount' => $discount_amount, |
| 3636 |
'label' => $coupon_result['label'], |
| 3637 |
]; |
| 3638 |
$session['timestamp'] = time(); |
| 3639 |
|
| 3640 |
yatra_set_booking_session($session); |
| 3641 |
|
| 3642 |
|
| 3643 |
return new WP_REST_Response([ |
| 3644 |
'success' => true, |
| 3645 |
'message' => __('Coupon applied successfully!', 'yatra'), |
| 3646 |
'data' => [ |
| 3647 |
'code' => $code, |
| 3648 |
'type' => $coupon_result['type'], |
| 3649 |
'discount_amount' => $discount_amount, |
| 3650 |
'discount_formatted' => yatra_format_price($discount_amount), |
| 3651 |
'new_total' => $total_amount - $discount_amount, |
| 3652 |
'new_total_formatted' => yatra_format_price($total_amount - $discount_amount), |
| 3653 |
], |
| 3654 |
]); |
| 3655 |
} |
| 3656 |
|
| 3657 |
/** |
| 3658 |
* Verify a guest's booking email via magic-link token. |
| 3659 |
* |
| 3660 |
* Flow: |
| 3661 |
* 1. Validate the HMAC token (forgery, expiry, email-binding). |
| 3662 |
* 2. Look up the booking; confirm it's in `pending_verification`. |
| 3663 |
* 3. Flip status to `pending` (or `confirmed` when auto-confirm |
| 3664 |
* pay-later is enabled for this site) and fire the standard |
| 3665 |
* yatra_booking_status_changed action so inventory + email |
| 3666 |
* automations resume normally. |
| 3667 |
* 4. 302 redirect the browser to a continuation URL: |
| 3668 |
* - If amount_due > 0: back to the booking page for the |
| 3669 |
* payment step the customer skipped earlier. |
| 3670 |
* - If amount_due == 0 / auto-confirm: to the booking |
| 3671 |
* confirmation/thank-you page. |
| 3672 |
* 5. On any failure, render a friendly HTML page (not JSON) so |
| 3673 |
* the customer sees readable text in their browser tab. |
| 3674 |
* |
| 3675 |
* @return WP_REST_Response|WP_Error|void |
| 3676 |
*/ |
| 3677 |
public function verify_email(WP_REST_Request $request) |
| 3678 |
{ |
| 3679 |
$token = (string) $request->get_param('token'); |
| 3680 |
$bookingRepo = new \Yatra\Repositories\BookingRepository(); |
| 3681 |
|
| 3682 |
// First decode just to extract the booking id (for the |
| 3683 |
// expectedEmail lookup). Verify() is called again below |
| 3684 |
// with the actual email so the email-binding check runs. |
| 3685 |
$partsPreview = explode('.', $token); |
| 3686 |
$bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0])) |
| 3687 |
? (int) $partsPreview[0] |
| 3688 |
: 0; |
| 3689 |
$booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null; |
| 3690 |
$expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : ''; |
| 3691 |
|
| 3692 |
$result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail); |
| 3693 |
|
| 3694 |
if (!$result['ok']) { |
| 3695 |
$this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid')); |
| 3696 |
} |
| 3697 |
if ($booking === null) { |
| 3698 |
$this->renderVerifyEmailErrorPage('booking_not_found'); |
| 3699 |
} |
| 3700 |
|
| 3701 |
// Re-entrant: if the booking has already been verified, show the |
| 3702 |
// same success page (idempotent) — pre-3.0.5 silently redirected |
| 3703 |
// and the customer was left wondering whether anything happened. |
| 3704 |
$currentStatus = (string) ($booking->status ?? ''); |
| 3705 |
$alreadyVerified = $currentStatus !== 'pending_verification'; |
| 3706 |
|
| 3707 |
if (!$alreadyVerified) { |
| 3708 |
// Flip status to 'pending' so downstream hooks (inventory / |
| 3709 |
// notification automations) see fresh data, then fire |
| 3710 |
// yatra_booking_created so the listeners we deferred at |
| 3711 |
// creation time (admin "new booking" notification + Pro |
| 3712 |
// email-automation booking.created fan-out) run now — i.e. |
| 3713 |
// *after* the customer has proven the email is theirs. |
| 3714 |
$bookingRepo->updateStatus((int) $booking->id, 'pending'); |
| 3715 |
do_action('yatra_booking_email_verified', (int) $booking->id); |
| 3716 |
|
| 3717 |
// Re-fetch so the post-verification action receives the |
| 3718 |
// booking row with the new status, then fire the deferred |
| 3719 |
// booking-created action. See BookingService::createBooking() |
| 3720 |
// for the matching skip-on-pending-verification branch. |
| 3721 |
$verifiedBooking = $bookingRepo->find((int) $booking->id); |
| 3722 |
if (is_object($verifiedBooking)) { |
| 3723 |
do_action( |
| 3724 |
\Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, |
| 3725 |
(int) $verifiedBooking->id, |
| 3726 |
$verifiedBooking |
| 3727 |
); |
| 3728 |
} |
| 3729 |
|
| 3730 |
// Guest email-verification defers the customer booking-confirmation |
| 3731 |
// email: the checkout flow returns at the verification gate, before |
| 3732 |
// its send-site (~line 2465), so the confirmation is never sent for a |
| 3733 |
// verified guest booking. Send it now that the email is proven and the |
| 3734 |
// booking is live — gated by the same `booking_confirmation` option the |
| 3735 |
// checkout paths use. Only in this fresh-verify branch, so a re-clicked |
| 3736 |
// link never re-sends. TYPE_BOOKING_CONFIRMATION is skipped by the Pro |
| 3737 |
// booking.created fan-out, so this is the single source of the email. |
| 3738 |
if ((bool) \Yatra\Services\SettingsService::get('booking_confirmation', true)) { |
| 3739 |
try { |
| 3740 |
(new \Yatra\Services\BookingService())->sendNewBookingTransactionalConfirmation((int) $booking->id); |
| 3741 |
} catch (\Throwable $e) { |
| 3742 |
// A mail failure must never break the customer's "verified" page. |
| 3743 |
Logger::error('Post-verification booking confirmation email failed', [ |
| 3744 |
'booking_id' => (int) $booking->id, |
| 3745 |
'error' => $e->getMessage(), |
| 3746 |
]); |
| 3747 |
} |
| 3748 |
} |
| 3749 |
} |
| 3750 |
|
| 3751 |
$this->renderVerifyEmailSuccessPage( |
| 3752 |
(int) $booking->id, |
| 3753 |
(string) ($booking->reference ?? ''), |
| 3754 |
$alreadyVerified |
| 3755 |
); |
| 3756 |
} |
| 3757 |
|
| 3758 |
/** |
| 3759 |
* Friendly HTML error page rendered when a verification link |
| 3760 |
* is invalid / expired / tampered with. Avoids JSON in the |
| 3761 |
* customer's browser tab (terrible UX). Reasons map to clear |
| 3762 |
* messages so customers know what to do next. |
| 3763 |
* |
| 3764 |
* Emits raw HTML and exits. We can't return WP_REST_Response with an |
| 3765 |
* HTML body because the REST server JSON-encodes the response data |
| 3766 |
* regardless of the Content-Type header on the response object — |
| 3767 |
* the customer would see `"<!doctype..."` (a JSON string) in their |
| 3768 |
* browser tab. Echoing + exiting short-circuits the REST pipeline. |
| 3769 |
* |
| 3770 |
* @return never |
| 3771 |
*/ |
| 3772 |
private function renderVerifyEmailErrorPage(string $reason): void |
| 3773 |
{ |
| 3774 |
$messages = [ |
| 3775 |
'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'), |
| 3776 |
'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'), |
| 3777 |
'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'), |
| 3778 |
'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'), |
| 3779 |
'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'), |
| 3780 |
]; |
| 3781 |
$message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra'); |
| 3782 |
|
| 3783 |
$brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra'; |
| 3784 |
$html = sprintf( |
| 3785 |
'<!doctype html><html lang="%1$s"><head><meta charset="utf-8">' |
| 3786 |
. '<meta name="viewport" content="width=device-width,initial-scale=1">' |
| 3787 |
. '<title>%2$s</title>' |
| 3788 |
. '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}' |
| 3789 |
. '.box{max-width:480px;margin:60px auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 1px 3px rgba(0,0,0,.1);text-align:center}' |
| 3790 |
. 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}' |
| 3791 |
. 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>' |
| 3792 |
. '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>', |
| 3793 |
esc_attr(get_locale()), |
| 3794 |
esc_html__('Verification link issue', 'yatra'), |
| 3795 |
esc_html__('Verification link issue', 'yatra'), |
| 3796 |
esc_html($message), |
| 3797 |
esc_url(home_url('/')), |
| 3798 |
esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName)) |
| 3799 |
); |
| 3800 |
|
| 3801 |
if (!headers_sent()) { |
| 3802 |
status_header(200); |
| 3803 |
nocache_headers(); |
| 3804 |
header('Content-Type: text/html; charset=UTF-8'); |
| 3805 |
} |
| 3806 |
echo $html; |
| 3807 |
exit; |
| 3808 |
} |
| 3809 |
|
| 3810 |
/** |
| 3811 |
* Build the booking continuation URL (post-verification destination). |
| 3812 |
* |
| 3813 |
* If the site has a Yatra Bookings page, route through that with the |
| 3814 |
* booking reference; otherwise fall back to the trip URL. Filterable |
| 3815 |
* via `yatra_guest_verification_continuation_url` so integrations can |
| 3816 |
* route to a custom thank-you page. |
| 3817 |
*/ |
| 3818 |
private function continuationUrl(int $bookingId, string $reference): string |
| 3819 |
{ |
| 3820 |
return (string) apply_filters( |
| 3821 |
'yatra_guest_verification_continuation_url', |
| 3822 |
add_query_arg( |
| 3823 |
['booking_id' => $bookingId, 'verified' => '1'], |
| 3824 |
home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/') |
| 3825 |
), |
| 3826 |
$bookingId, |
| 3827 |
$reference |
| 3828 |
); |
| 3829 |
} |
| 3830 |
|
| 3831 |
/** |
| 3832 |
* Friendly HTML success page rendered after the guest clicks the |
| 3833 |
* email-verification magic link. |
| 3834 |
* |
| 3835 |
* Pre-3.0.5 this endpoint silently 302-redirected to the booking page, |
| 3836 |
* which made guests believe nothing had happened — there was no visible |
| 3837 |
* "verified" feedback before they landed on the next step. This page |
| 3838 |
* gives them an unambiguous confirmation, the booking reference, and |
| 3839 |
* three explicit CTAs: |
| 3840 |
* - Continue to booking (primary, continuation URL) |
| 3841 |
* - My Account (when logged in) / Sign in (when not) |
| 3842 |
* - Go to homepage (fallback) |
| 3843 |
* |
| 3844 |
* Idempotent: when the booking was already verified (re-click on the |
| 3845 |
* same link), the heading + copy switch to the "already verified" |
| 3846 |
* variant but the CTAs stay the same. |
| 3847 |
* |
| 3848 |
* Emits raw HTML and exits — same reasoning as |
| 3849 |
* {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response |
| 3850 |
* JSON-encodes string bodies, so the customer would see |
| 3851 |
* `"<!doctype..."` in their tab instead of the rendered page. |
| 3852 |
* |
| 3853 |
* @return never |
| 3854 |
*/ |
| 3855 |
private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void |
| 3856 |
{ |
| 3857 |
// Booking is already persisted at this point and (for a fresh verify) |
| 3858 |
// the status flip + booking-created fan-out have just fired. The |
| 3859 |
// verified UI's primary action is therefore "View your booking |
| 3860 |
// confirmation" — NOT "Continue Booking", which mislabelled the |
| 3861 |
// booking as still in-progress and confused customers into thinking |
| 3862 |
// they needed to re-submit the form. |
| 3863 |
$confirmationUrl = function_exists('yatra_get_booking_confirmation_url') |
| 3864 |
? yatra_get_booking_confirmation_url($reference) |
| 3865 |
: $this->continuationUrl($bookingId, $reference); |
| 3866 |
|
| 3867 |
// Account / login URL — prefer Yatra's account page when present, |
| 3868 |
// fall back to wp_login_url() so the page never points nowhere. |
| 3869 |
$accountBase = \Yatra\Services\SettingsService::getAccountBase(); |
| 3870 |
$accountUrl = $accountBase !== '' |
| 3871 |
? home_url('/' . trim($accountBase, '/') . '/') |
| 3872 |
: home_url('/'); |
| 3873 |
$isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in(); |
| 3874 |
$secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl); |
| 3875 |
$secondaryLabel = $isLoggedIn |
| 3876 |
? __('Go to My Account', 'yatra') |
| 3877 |
: __('Sign in', 'yatra'); |
| 3878 |
|
| 3879 |
// Logged-in customers always get "Go to My Account". A guest is only |
| 3880 |
// offered "Sign in" when an account is genuinely part of the flow — |
| 3881 |
// registration is enabled AND guest checkout is not the operating mode. |
| 3882 |
// This is a guest email-verification page (guest checkout is normally |
| 3883 |
// on), so with guest checkout enabled OR registration disabled there is |
| 3884 |
// no account to sign into; the CTA is hidden rather than dangling to a |
| 3885 |
// login the guest can't use. |
| 3886 |
$registrationEnabled = \Yatra\Services\SettingsService::isEnabled('customer_registration'); |
| 3887 |
$guestCheckoutEnabled = \Yatra\Services\SettingsService::isEnabled('allow_guest_checkout'); |
| 3888 |
$showSecondaryCta = $isLoggedIn || ($registrationEnabled && !$guestCheckoutEnabled); |
| 3889 |
$secondaryCta = $showSecondaryCta |
| 3890 |
? '<a class="btn btn-secondary" href="' . esc_url($secondaryUrl) . '">' . esc_html($secondaryLabel) . '</a>' |
| 3891 |
: ''; |
| 3892 |
|
| 3893 |
$heading = $alreadyVerified |
| 3894 |
? __('Email Already Verified', 'yatra') |
| 3895 |
: __('Email Verified', 'yatra'); |
| 3896 |
$message = $alreadyVerified |
| 3897 |
? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra') |
| 3898 |
: __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra'); |
| 3899 |
|
| 3900 |
$primaryLabel = __('View Booking Confirmation', 'yatra'); |
| 3901 |
$homeLabel = __('Go to Homepage', 'yatra'); |
| 3902 |
$referenceLabel = __('Booking reference', 'yatra'); |
| 3903 |
$brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra'; |
| 3904 |
|
| 3905 |
$referenceLine = $reference !== '' |
| 3906 |
? sprintf( |
| 3907 |
'<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>', |
| 3908 |
esc_html($referenceLabel), |
| 3909 |
esc_html($reference) |
| 3910 |
) |
| 3911 |
: ''; |
| 3912 |
|
| 3913 |
// Inline-only styling so the page renders correctly regardless of |
| 3914 |
// theme stylesheet load order (REST → wp_die/raw HTML response). |
| 3915 |
$html = sprintf( |
| 3916 |
'<!doctype html><html lang="%1$s"><head><meta charset="utf-8">' |
| 3917 |
. '<meta name="viewport" content="width=device-width,initial-scale=1">' |
| 3918 |
. '<title>%2$s · %3$s</title>' |
| 3919 |
. '<style>' |
| 3920 |
. 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}' |
| 3921 |
. '.box{max-width:520px;margin:60px auto;background:#fff;border-radius:12px;padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.1);text-align:center}' |
| 3922 |
. '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}' |
| 3923 |
. 'h1{font-size:24px;margin:0 0 12px;color:#065f46}' |
| 3924 |
. 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}' |
| 3925 |
. '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}' |
| 3926 |
. '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}' |
| 3927 |
. '.ref code{font-weight:600;color:#111827}' |
| 3928 |
. '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}' |
| 3929 |
. '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}' |
| 3930 |
. '.btn-primary{background:#059669;color:#fff}' |
| 3931 |
. '.btn-primary:hover{background:#047857}' |
| 3932 |
. '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}' |
| 3933 |
. '.btn-secondary:hover{background:#f9fafb}' |
| 3934 |
. '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}' |
| 3935 |
. '</style></head>' |
| 3936 |
. '<body><div class="box">' |
| 3937 |
. '<div class="tick" aria-hidden="true">' |
| 3938 |
. '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">' |
| 3939 |
. '<polyline points="20 6 9 17 4 12"></polyline></svg>' |
| 3940 |
. '</div>' |
| 3941 |
. '<h1>%2$s</h1>' |
| 3942 |
. '<p>%4$s</p>' |
| 3943 |
. '%5$s' |
| 3944 |
. '<div class="actions">' |
| 3945 |
. '<a class="btn btn-primary" href="%6$s">%7$s</a>' |
| 3946 |
. '%8$s' |
| 3947 |
. '<a class="btn btn-tertiary" href="%10$s">%11$s</a>' |
| 3948 |
. '</div>' |
| 3949 |
. '</div></body></html>', |
| 3950 |
esc_attr(get_locale()), |
| 3951 |
esc_html($heading), |
| 3952 |
esc_html($brandName), |
| 3953 |
esc_html($message), |
| 3954 |
$referenceLine, |
| 3955 |
esc_url($confirmationUrl), |
| 3956 |
esc_html($primaryLabel), |
| 3957 |
// %8 is the fully-built secondary CTA (or '' when hidden — see |
| 3958 |
// $showSecondaryCta above). %9 is intentionally empty to keep the |
| 3959 |
// positional args aligned with %10/%11. |
| 3960 |
$secondaryCta, |
| 3961 |
'', |
| 3962 |
esc_url(home_url('/')), |
| 3963 |
esc_html($homeLabel) |
| 3964 |
); |
| 3965 |
|
| 3966 |
if (!headers_sent()) { |
| 3967 |
status_header(200); |
| 3968 |
nocache_headers(); |
| 3969 |
header('Content-Type: text/html; charset=UTF-8'); |
| 3970 |
} |
| 3971 |
echo $html; |
| 3972 |
exit; |
| 3973 |
} |
| 3974 |
|
| 3975 |
/** |
| 3976 |
* Calculate booking summary and return HTML for dynamic updates |
| 3977 |
* Called via AJAX when traveler count, date, or coupon changes |
| 3978 |
*/ |
| 3979 |
public function calculate_summary(WP_REST_Request $request): WP_REST_Response |
| 3980 |
{ |
| 3981 |
yatra_start_session(); |
| 3982 |
$session = yatra_get_booking_session(); |
| 3983 |
$data = $request->get_json_params() ?? []; |
| 3984 |
|
| 3985 |
// M-2: restore CSRF protection stripped by public_permission_callback. |
| 3986 |
if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) { |
| 3987 |
return $blocked; |
| 3988 |
} |
| 3989 |
|
| 3990 |
// Same REST-context session-rehydration fallback as set_session() / |
| 3991 |
// create_booking(): when PHPSESSID isn't propagated to the REST API |
| 3992 |
// scope, look up the transient by `booking_token` (from request body |
| 3993 |
// first, then ?booking_token=) so the partial summary refresh |
| 3994 |
// doesn't 400. Without this, every service-toggle re-render fails |
| 3995 |
// because trip_id resolves to 0. |
| 3996 |
// |
| 3997 |
// We also write the rehydrated session into $_SESSION so that the |
| 3998 |
// downstream buildPricingHtml() — which calls yatra_get_booking_session() |
| 3999 |
// again — sees the same data and doesn't fall back to its |
| 4000 |
// "Pricing information not available" branch. |
| 4001 |
if (empty($session) || empty($session['trip_id'])) { |
| 4002 |
$token = null; |
| 4003 |
if (!empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 4004 |
$token = sanitize_text_field((string) $data['booking_token']); |
| 4005 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 4006 |
$token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 4007 |
} |
| 4008 |
if ($token) { |
| 4009 |
$transient_data = get_transient($token); |
| 4010 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 4011 |
$session = $transient_data; |
| 4012 |
$_SESSION['yatra_booking'] = $session; |
| 4013 |
$_SESSION['yatra_booking_token'] = $token; |
| 4014 |
} |
| 4015 |
} |
| 4016 |
} |
| 4017 |
|
| 4018 |
// Get trip_id from session (required) |
| 4019 |
$trip_id = (int) ($session['trip_id'] ?? 0); |
| 4020 |
|
| 4021 |
// Get traveler_counts from REQUEST (for dynamic updates) or fallback to session |
| 4022 |
$traveler_counts = $data['traveler_counts'] ?? ($session['traveler_counts'] ?? []); |
| 4023 |
|
| 4024 |
// Get other data from request or session |
| 4025 |
$travel_date = sanitize_text_field($data['travel_date'] ?? ($session['travel_date'] ?? '')); |
| 4026 |
$departure_time = sanitize_text_field($data['departure_time'] ?? ($session['departure_time'] ?? '')); |
| 4027 |
$availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null); |
| 4028 |
$pricing_type_from_request = sanitize_text_field($data['pricing_type'] ?? ($session['pricing_type'] ?? '')); |
| 4029 |
$payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full')))); |
| 4030 |
if ($payment_method === '') { |
| 4031 |
$payment_method = 'full'; |
| 4032 |
} |
| 4033 |
$selected_service_ids_from_request = $data['additional_services'] ?? ($session['additional_services'] ?? null); |
| 4034 |
|
| 4035 |
// IMPORTANT: Always read coupon from SESSION (not request) to maintain applied discount |
| 4036 |
$coupon_code = isset($session['coupon']['code']) ? sanitize_text_field($session['coupon']['code']) : ''; |
| 4037 |
|
| 4038 |
|
| 4039 |
if (empty($trip_id)) { |
| 4040 |
return new WP_REST_Response([ |
| 4041 |
'success' => false, |
| 4042 |
'message' => __('No active booking session found.', 'yatra'), |
| 4043 |
], 400); |
| 4044 |
} |
| 4045 |
|
| 4046 |
// Get trip data |
| 4047 |
$trip = $this->tripRepository->findPublished($trip_id); |
| 4048 |
if (!$trip) { |
| 4049 |
return new WP_REST_Response([ |
| 4050 |
'success' => false, |
| 4051 |
'message' => __('Trip not found.', 'yatra'), |
| 4052 |
], 404); |
| 4053 |
} |
| 4054 |
|
| 4055 |
// Ensure pricing fields are properly set |
| 4056 |
$trip->original_price = (float) ($trip->original_price ?? 0); |
| 4057 |
$trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0; |
| 4058 |
$trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0; |
| 4059 |
|
| 4060 |
yatra_start_session(); |
| 4061 |
$existing_session = yatra_get_booking_session(); |
| 4062 |
|
| 4063 |
global $wpdb; |
| 4064 |
|
| 4065 |
$availability = null; |
| 4066 |
if (!empty($travel_date)) { |
| 4067 |
// Use the same resolver as the single-trip UI so rule slots, manual dates, |
| 4068 |
// and flexible defaults all return a consistent object shape. |
| 4069 |
try { |
| 4070 |
$resolver = new \Yatra\Services\AvailabilityResolutionService(); |
| 4071 |
$availability = $resolver->resolveAvailabilityForDate( |
| 4072 |
$trip_id, |
| 4073 |
$travel_date, |
| 4074 |
$departure_time !== '' ? $departure_time : null |
| 4075 |
); |
| 4076 |
} catch (\Throwable $e) { |
| 4077 |
$availability = null; |
| 4078 |
} |
| 4079 |
} elseif (!empty($availability_id) && is_numeric($availability_id)) { |
| 4080 |
// Back-compat: allow summary by numeric availability_id only. |
| 4081 |
$availability = $this->availabilityService->getById((int) $availability_id); |
| 4082 |
} |
| 4083 |
|
| 4084 |
// Resolve pricing type and price_types via centralized TripPricingService |
| 4085 |
$resolved_pricing_type = !empty($pricing_type_from_request) |
| 4086 |
? $pricing_type_from_request |
| 4087 |
: \Yatra\Services\TripPricingService::resolvePricingType($trip); |
| 4088 |
|
| 4089 |
$price_types = []; |
| 4090 |
|
| 4091 |
// First priority: availability price_types (already includes trip fallback from AvailabilityResolutionService) |
| 4092 |
if ($availability && !empty($availability->price_types)) { |
| 4093 |
$avail_pts = is_string($availability->price_types) |
| 4094 |
? (json_decode($availability->price_types, true) ?: []) |
| 4095 |
: $availability->price_types; |
| 4096 |
if (!empty($avail_pts) && is_array($avail_pts)) { |
| 4097 |
$price_types = array_map(function ($pt) { return (object) $pt; }, $avail_pts); |
| 4098 |
} |
| 4099 |
} |
| 4100 |
// Second priority: trip's price_types via centralized normalizer |
| 4101 |
if (empty($price_types)) { |
| 4102 |
$normalized = \Yatra\Services\TripPricingService::resolvePriceTypes($trip); |
| 4103 |
if (!empty($normalized)) { |
| 4104 |
$price_types = array_map(function ($pt) { return (object) $pt; }, $normalized); |
| 4105 |
} |
| 4106 |
} |
| 4107 |
// Auto-detect traveler_based if price_types are present |
| 4108 |
if (!empty($price_types)) { |
| 4109 |
$resolved_pricing_type = 'traveler_based'; |
| 4110 |
} |
| 4111 |
|
| 4112 |
// Resolve pricing_mode / group-size limits authoritatively from the |
| 4113 |
// TravelerCategory so the summary breakdown treats a per-group category |
| 4114 |
// as a flat charge. No-op for per-person categories. |
| 4115 |
if (!empty($price_types)) { |
| 4116 |
$price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types); |
| 4117 |
} |
| 4118 |
|
| 4119 |
// Enrich availability price_types with category labels if missing |
| 4120 |
if (!empty($price_types)) { |
| 4121 |
$missing_label_category_ids = []; |
| 4122 |
foreach ($price_types as $pt) { |
| 4123 |
$pt = (object) $pt; |
| 4124 |
if (empty($pt->category_label) && !empty($pt->category_id)) { |
| 4125 |
$missing_label_category_ids[] = (int) $pt->category_id; |
| 4126 |
} |
| 4127 |
} |
| 4128 |
|
| 4129 |
$missing_label_category_ids = array_values(array_unique(array_filter($missing_label_category_ids))); |
| 4130 |
if (!empty($missing_label_category_ids)) { |
| 4131 |
// Use AvailabilityService to get traveler categories |
| 4132 |
$cats = $this->availabilityService->getTravelerCategories($missing_label_category_ids); |
| 4133 |
|
| 4134 |
$catIndex = []; |
| 4135 |
foreach ($cats as $cat) { |
| 4136 |
$catIndex[(int) $cat->id] = $cat; |
| 4137 |
} |
| 4138 |
|
| 4139 |
foreach ($price_types as &$pt) { |
| 4140 |
$pt = (object) $pt; |
| 4141 |
$catId = !empty($pt->category_id) ? (int) $pt->category_id : null; |
| 4142 |
if ($catId && isset($catIndex[$catId])) { |
| 4143 |
$cat = $catIndex[$catId]; |
| 4144 |
if (empty($pt->category_label)) { |
| 4145 |
$pt->category_label = $cat->label; |
| 4146 |
} |
| 4147 |
if (empty($pt->category_slug)) { |
| 4148 |
$pt->category_slug = $cat->slug; |
| 4149 |
} |
| 4150 |
if (!isset($pt->age_min)) { |
| 4151 |
$pt->age_min = $cat->age_min ? (int) $cat->age_min : null; |
| 4152 |
} |
| 4153 |
if (!isset($pt->age_max)) { |
| 4154 |
$pt->age_max = $cat->age_max ? (int) $cat->age_max : null; |
| 4155 |
} |
| 4156 |
} |
| 4157 |
} |
| 4158 |
unset($pt); |
| 4159 |
} |
| 4160 |
} |
| 4161 |
|
| 4162 |
foreach ($price_types as $pt) { |
| 4163 |
$pt->effective_price = $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt); |
| 4164 |
} |
| 4165 |
|
| 4166 |
if (apply_filters('yatra_dynamic_pricing_enabled', false)) { |
| 4167 |
$spots_remaining = $availability ? (int) ($availability->spots_remaining ?? null) : null; |
| 4168 |
|
| 4169 |
foreach ($price_types as $pt) { |
| 4170 |
if (!isset($pt->effective_price)) { |
| 4171 |
continue; |
| 4172 |
} |
| 4173 |
$price_before = $pt->effective_price; |
| 4174 |
$pt_orig = (float) ($pt->original_price ?? 0); |
| 4175 |
$pt->effective_price = apply_filters('yatra_trip_display_price', (float) $pt->effective_price, $trip_id, [ |
| 4176 |
'departure_date' => $travel_date ?: null, |
| 4177 |
'spots_remaining' => $spots_remaining, |
| 4178 |
'availability_id' => $availability_id, |
| 4179 |
'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null), |
| 4180 |
'original_price' => $pt_orig > 0 ? $pt_orig : (float) $price_before, |
| 4181 |
'discounted_price' => (float) $price_before, |
| 4182 |
]); |
| 4183 |
} |
| 4184 |
} |
| 4185 |
|
| 4186 |
$is_traveler_based = $resolved_pricing_type === 'traveler_based' && !empty($price_types); |
| 4187 |
|
| 4188 |
// Base trip price via centralized TripPricingService (single source of truth) |
| 4189 |
$base_trip_price = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip); |
| 4190 |
|
| 4191 |
// Override with availability pricing if available |
| 4192 |
if ($availability) { |
| 4193 |
$avail_price = !empty($availability->discounted_price) && (float) $availability->discounted_price > 0 |
| 4194 |
? (float) $availability->discounted_price |
| 4195 |
: (!empty($availability->original_price) && (float) $availability->original_price > 0 |
| 4196 |
? (float) $availability->original_price : 0); |
| 4197 |
if ($avail_price > 0) { |
| 4198 |
$base_trip_price = $avail_price; |
| 4199 |
} |
| 4200 |
} |
| 4201 |
|
| 4202 |
// Apply dynamic pricing filter (Pro DynamicPricingModule hooks here) |
| 4203 |
if (apply_filters('yatra_dynamic_pricing_enabled', false)) { |
| 4204 |
$list_for_dp = (float) ($trip->original_price ?? 0); |
| 4205 |
if ($availability) { |
| 4206 |
$ao = (float) ($availability->original_price ?? 0); |
| 4207 |
if ($ao > 0) { |
| 4208 |
$list_for_dp = $ao; |
| 4209 |
} |
| 4210 |
} |
| 4211 |
$pre_dp_effective = (float) $base_trip_price; |
| 4212 |
$base_trip_price = apply_filters('yatra_booking_trip_price', $base_trip_price, $trip_id, [ |
| 4213 |
'departure_date' => $travel_date, |
| 4214 |
'spots_remaining' => $availability ? (int) ($availability->spots_remaining ?? null) : null, |
| 4215 |
'availability_id' => $availability_id, |
| 4216 |
'original_price' => $list_for_dp > 0 ? $list_for_dp : $pre_dp_effective, |
| 4217 |
'discounted_price' => $pre_dp_effective, |
| 4218 |
]); |
| 4219 |
} |
| 4220 |
|
| 4221 |
// Calculate subtotals per category |
| 4222 |
$category_breakdown = []; |
| 4223 |
$subtotal = 0; |
| 4224 |
$total_travelers = 0; |
| 4225 |
$normalized_traveler_counts = []; |
| 4226 |
if (!empty($traveler_counts) && is_array($traveler_counts)) { |
| 4227 |
foreach ($traveler_counts as $k => $v) { |
| 4228 |
$key = is_numeric($k) ? (int) $k : (string) $k; |
| 4229 |
$normalized_traveler_counts[$key] = (int) $v; |
| 4230 |
} |
| 4231 |
} |
| 4232 |
|
| 4233 |
if ($is_traveler_based && !empty($normalized_traveler_counts)) { |
| 4234 |
foreach ($price_types as $pt) { |
| 4235 |
$category_id = $pt->category_id; |
| 4236 |
$count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0)); |
| 4237 |
if ($count > 0) { |
| 4238 |
// Single source of truth for the line amount (per-person × |
| 4239 |
// count, flat per-group, or per-block group pricing). |
| 4240 |
$pt_pricing_mode = $pt->pricing_mode ?? 'per_person'; |
| 4241 |
$category_subtotal = \Yatra\Services\TripPricingService::categoryLineSubtotal($pt, $count, (float) $pt->effective_price); |
| 4242 |
$category_breakdown[] = [ |
| 4243 |
'category_id' => $category_id, |
| 4244 |
'label' => $pt->category_label ?? __('Traveler', 'yatra'), |
| 4245 |
'count' => $count, |
| 4246 |
'price' => (float) $pt->effective_price, |
| 4247 |
'subtotal' => $category_subtotal, |
| 4248 |
'pricing_mode' => $pt_pricing_mode, |
| 4249 |
// Carry the group-size knobs so the reconciliation pass |
| 4250 |
// below can re-derive the same per-block/flat subtotal. |
| 4251 |
'max_pax' => isset($pt->max_pax) && $pt->max_pax !== null && $pt->max_pax !== '' ? (int) $pt->max_pax : null, |
| 4252 |
'group_overflow' => $pt->group_overflow ?? 'block', |
| 4253 |
]; |
| 4254 |
$subtotal += $category_subtotal; |
| 4255 |
$total_travelers += $count; |
| 4256 |
} |
| 4257 |
} |
| 4258 |
} else { |
| 4259 |
// Regular pricing |
| 4260 |
$total_travelers = array_sum(array_map('intval', $normalized_traveler_counts)); |
| 4261 |
// If traveler_counts is empty, fallback to session 'travelers' count |
| 4262 |
if ($total_travelers < 1) { |
| 4263 |
$total_travelers = !empty($session['travelers']) ? (int) $session['travelers'] : 1; |
| 4264 |
} |
| 4265 |
$price_per_person = $base_trip_price; |
| 4266 |
$subtotal = $price_per_person * $total_travelers; |
| 4267 |
|
| 4268 |
} |
| 4269 |
|
| 4270 |
// Calculate group discount |
| 4271 |
$discountService = new \Yatra\Services\DiscountService(); |
| 4272 |
$travelerCountsForDiscount = []; |
| 4273 |
if ($is_traveler_based) { |
| 4274 |
foreach ($normalized_traveler_counts as $k => $v) { |
| 4275 |
if (is_numeric($k)) { |
| 4276 |
$travelerCountsForDiscount[(int) $k] = (int) $v; |
| 4277 |
} |
| 4278 |
} |
| 4279 |
} |
| 4280 |
if (empty($travelerCountsForDiscount)) { |
| 4281 |
$travelerCountsForDiscount['default'] = $total_travelers; |
| 4282 |
} |
| 4283 |
|
| 4284 |
$priceTypesForDiscount = []; |
| 4285 |
if ($is_traveler_based) { |
| 4286 |
foreach ($price_types as $pt) { |
| 4287 |
$pt = (array) $pt; |
| 4288 |
// Keep pricing_mode / max_pax / group_overflow so the group |
| 4289 |
// discount base honours flat and per-block group pricing |
| 4290 |
// (not just category_id + effective_price). |
| 4291 |
$pt['effective_price'] = $pt['effective_price'] ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt); |
| 4292 |
$priceTypesForDiscount[] = $pt; |
| 4293 |
} |
| 4294 |
} else { |
| 4295 |
$priceTypesForDiscount[] = [ |
| 4296 |
'category_id' => 'default', |
| 4297 |
'effective_price' => $base_trip_price, |
| 4298 |
]; |
| 4299 |
} |
| 4300 |
|
| 4301 |
$group_discount = $discountService->calculateGroupDiscount($trip_id, $travelerCountsForDiscount, $priceTypesForDiscount); |
| 4302 |
$group_discount_amount = $group_discount['amount'] ?? 0; |
| 4303 |
$group_discount_label = $group_discount['label'] ?? __('Group Discount', 'yatra'); |
| 4304 |
$group_discount_code = $group_discount['code'] ?? null; |
| 4305 |
|
| 4306 |
// Calculate coupon discount using DiscountService |
| 4307 |
$coupon_discount_amount = 0; |
| 4308 |
$coupon_discount_label = ''; |
| 4309 |
$coupon_error = ''; |
| 4310 |
|
| 4311 |
if (!empty($coupon_code)) { |
| 4312 |
$discountService = new \Yatra\Services\DiscountService(); |
| 4313 |
$subtotal_after_group = $subtotal - $group_discount_amount; |
| 4314 |
|
| 4315 |
$coupon_result = $discountService->calculateCouponDiscount( |
| 4316 |
$coupon_code, |
| 4317 |
$subtotal_after_group, |
| 4318 |
$trip_id, |
| 4319 |
$total_travelers, |
| 4320 |
$is_traveler_based ? $normalized_traveler_counts : [] |
| 4321 |
); |
| 4322 |
|
| 4323 |
if ($coupon_result['calculated_amount'] > 0) { |
| 4324 |
$coupon_discount_amount = $coupon_result['calculated_amount']; |
| 4325 |
$coupon_discount_label = $coupon_result['label']; |
| 4326 |
} else { |
| 4327 |
$coupon_error = __('This coupon is not valid for your booking.', 'yatra'); |
| 4328 |
} |
| 4329 |
} |
| 4330 |
|
| 4331 |
// Use CalculationService for on-demand pricing calculation |
| 4332 |
$calculationService = new CalculationService(); |
| 4333 |
|
| 4334 |
// The selected-service ids the customer just submitted live in |
| 4335 |
// `$selected_service_ids_from_request` (line ~2635). The previous |
| 4336 |
// version of this method initialised a fresh `$additional_services |
| 4337 |
// = []` here and passed that empty list into calculateFromSession |
| 4338 |
// — which made the Pro AdditionalServicesModule's |
| 4339 |
// `addServicesToSubtotal` hook bail out at its `empty($selectedServiceIds)` |
| 4340 |
// guard, so every AJAX summary refresh wiped services out of the |
| 4341 |
// Trip Subtotal even though the sidebar still rendered the rows. |
| 4342 |
// Pass the real selected ids instead so CalculationService → |
| 4343 |
// Pro filter chain folds them into `$subtotal` correctly. |
| 4344 |
$additional_services = is_array($selected_service_ids_from_request) |
| 4345 |
? array_values(array_map('intval', $selected_service_ids_from_request)) |
| 4346 |
: []; |
| 4347 |
|
| 4348 |
// Create session-like data structure for calculation (trip data fetched from database) |
| 4349 |
$session_like_data = [ |
| 4350 |
'trip_id' => $trip_id, |
| 4351 |
'travelers' => $total_travelers, |
| 4352 |
'traveler_counts' => $traveler_counts, |
| 4353 |
'travel_date' => $travel_date, |
| 4354 |
'departure_time' => $departure_time, |
| 4355 |
'additional_services' => $additional_services |
| 4356 |
]; |
| 4357 |
|
| 4358 |
// Apply filter for pro plugins to modify summary calculation parameters |
| 4359 |
$calculation_params = apply_filters('yatra_summary_calculation_params', [ |
| 4360 |
'session_data' => $session_like_data, |
| 4361 |
'coupon_code' => $coupon_code, |
| 4362 |
'payment_method' => $payment_method, |
| 4363 |
]); |
| 4364 |
|
| 4365 |
$pricing = $calculationService->calculateFromSession( |
| 4366 |
$calculation_params['session_data'], |
| 4367 |
$calculation_params['coupon_code'], |
| 4368 |
$calculation_params['payment_method'] |
| 4369 |
); |
| 4370 |
|
| 4371 |
$total_amount = $pricing['final_total']; |
| 4372 |
$amount_due = $pricing['amount_due']; |
| 4373 |
$tax_calculation = $pricing['tax_calculation']; |
| 4374 |
$total_tax_amount = $pricing['tax_calculation']['total_tax_amount']; |
| 4375 |
$tax_inclusive = $pricing['tax_calculation']['tax_inclusive']; |
| 4376 |
$tax_breakdown = $pricing['tax_calculation']['tax_breakdown']; |
| 4377 |
|
| 4378 |
// ── Reconcile per-category display prices with CalculationService ─ |
| 4379 |
// |
| 4380 |
// The $category_breakdown computed above (~line 3358) used a SEPARATE |
| 4381 |
// DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled. |
| 4382 |
// In certain AJAX-recompute contexts (e.g. switching payment_method) |
| 4383 |
// that loop could miss DP — for example when a stored availability |
| 4384 |
// row's price_types already had a pre-DP effective_price baked in, |
| 4385 |
// or when a date-sensitive DP rule didn't fire because the request |
| 4386 |
// didn't carry the same departure_date context. |
| 4387 |
// |
| 4388 |
// CalculationService is the single source of truth for booking math; |
| 4389 |
// it already computed the correct post-DP per-category prices and |
| 4390 |
// returned them as `category_prices_post_dp` (keyed by string |
| 4391 |
// category_id). Reconcile the display breakdown against that map so |
| 4392 |
// "Adult x 8 ($131.12 x 8)" can never disagree with the Trip |
| 4393 |
// Subtotal the rest of the page is built from. |
| 4394 |
if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) { |
| 4395 |
$catPricesPostDp = $pricing['category_prices_post_dp']; |
| 4396 |
$reconciledSubtotal = 0.0; |
| 4397 |
foreach ($category_breakdown as &$cat) { |
| 4398 |
$cid = isset($cat['category_id']) ? (string) $cat['category_id'] : ''; |
| 4399 |
if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) { |
| 4400 |
$authoritativePrice = (float) $catPricesPostDp[$cid]; |
| 4401 |
$count = (int) ($cat['count'] ?? 0); |
| 4402 |
$cat['price'] = $authoritativePrice; |
| 4403 |
// Re-derive the line amount from the authoritative post-DP |
| 4404 |
// price using the same rule as the charge (flat per-group, |
| 4405 |
// per-block, or per-person × count). |
| 4406 |
$cat['subtotal'] = \Yatra\Services\TripPricingService::categoryLineSubtotal($cat, $count, $authoritativePrice); |
| 4407 |
} |
| 4408 |
$reconciledSubtotal += (float) ($cat['subtotal'] ?? 0); |
| 4409 |
} |
| 4410 |
unset($cat); |
| 4411 |
// Keep the top-level $subtotal in sync with the reconciled |
| 4412 |
// breakdown so any downstream renderers that read it (instead |
| 4413 |
// of $pricing['base_amount']) still see consistent numbers. |
| 4414 |
if ($reconciledSubtotal > 0) { |
| 4415 |
$subtotal = $reconciledSubtotal; |
| 4416 |
} |
| 4417 |
} |
| 4418 |
|
| 4419 |
/** |
| 4420 |
* Filter: Get additional services for this trip |
| 4421 |
* Allows premium modules to add extra services to the booking summary |
| 4422 |
* |
| 4423 |
* @param array $services Empty array by default |
| 4424 |
* @param int $trip_id The trip ID |
| 4425 |
* @param int $total_travelers Total number of travelers |
| 4426 |
* @param array $traveler_counts Traveler counts by category |
| 4427 |
* @param string $travel_date The travel date |
| 4428 |
* @since 3.0.0 |
| 4429 |
*/ |
| 4430 |
$additional_services = apply_filters('yatra_booking_additional_services', [], $trip_id, $total_travelers, $traveler_counts, $travel_date); |
| 4431 |
|
| 4432 |
// Get selected services from request (priority) or session |
| 4433 |
// If request has additional_services, use that; otherwise fall back to session |
| 4434 |
if ($selected_service_ids_from_request !== null) { |
| 4435 |
$selected_service_ids = $selected_service_ids_from_request; |
| 4436 |
} else { |
| 4437 |
$selected_service_ids = isset($existing_session['additional_services']) && is_array($existing_session['additional_services']) |
| 4438 |
? array_map('intval', $existing_session['additional_services']) |
| 4439 |
: []; |
| 4440 |
} |
| 4441 |
|
| 4442 |
// Mark which services are selected and calculate their price based on price_per |
| 4443 |
$duration_days = (int) ($trip->duration_days ?? 1); |
| 4444 |
$default_services_total = 0.0; |
| 4445 |
foreach ($additional_services as &$service) { |
| 4446 |
$serviceId = (int) $service['id']; |
| 4447 |
$isInRequest = in_array($serviceId, $selected_service_ids, true); |
| 4448 |
$isRequired = !empty($service['is_required']); |
| 4449 |
$isIncluded = !empty($service['is_included']); |
| 4450 |
$service['selected'] = $isInRequest || $isRequired || $isIncluded; |
| 4451 |
|
| 4452 |
// Calculate the price based on price_per (person, day, booking) |
| 4453 |
$basePrice = (float) ($service['price'] ?? 0); |
| 4454 |
$pricePer = $service['price_per'] ?? 'person'; |
| 4455 |
|
| 4456 |
switch ($pricePer) { |
| 4457 |
case 'person': |
| 4458 |
$service['calculated_price'] = $basePrice * $total_travelers; |
| 4459 |
break; |
| 4460 |
case 'day': |
| 4461 |
$service['calculated_price'] = $basePrice * max(1, $duration_days); |
| 4462 |
break; |
| 4463 |
case 'booking': |
| 4464 |
default: |
| 4465 |
$service['calculated_price'] = $basePrice; |
| 4466 |
break; |
| 4467 |
} |
| 4468 |
|
| 4469 |
if (!empty($service['selected']) && empty($service['is_included'])) { |
| 4470 |
$default_services_total += (float) $service['calculated_price']; |
| 4471 |
} |
| 4472 |
} |
| 4473 |
unset($service); |
| 4474 |
|
| 4475 |
/** |
| 4476 |
* Filter: Calculate additional services total |
| 4477 |
* Allows premium modules to add services cost to the booking total |
| 4478 |
* |
| 4479 |
* @param float $services_total The services total (0 by default) |
| 4480 |
* @param array $additional_services The services with 'selected' flag |
| 4481 |
* @param int $trip_id The trip ID |
| 4482 |
* @param int $total_travelers Total number of travelers |
| 4483 |
* @param int $duration_days Trip duration in days |
| 4484 |
* @since 3.0.0 |
| 4485 |
*/ |
| 4486 |
$services_total = apply_filters('yatra_booking_services_total', (float) $default_services_total, $additional_services, $trip_id, $total_travelers, (int) ($trip->duration_days ?? 1)); |
| 4487 |
|
| 4488 |
// Get itinerary costs (separate from additional services) |
| 4489 |
$itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $total_travelers, $traveler_counts, $travel_date); |
| 4490 |
$itinerary_costs_total = 0.0; |
| 4491 |
|
| 4492 |
foreach ($itinerary_costs as $cost) { |
| 4493 |
$basePrice = (float) ($cost['price'] ?? 0); |
| 4494 |
$pricePer = $cost['price_per'] ?? 'person'; |
| 4495 |
|
| 4496 |
switch ($pricePer) { |
| 4497 |
case 'person': |
| 4498 |
$calculatedPrice = $basePrice * $total_travelers; |
| 4499 |
break; |
| 4500 |
case 'day': |
| 4501 |
$calculatedPrice = $basePrice * $duration_days; |
| 4502 |
break; |
| 4503 |
case 'booking': |
| 4504 |
default: |
| 4505 |
$calculatedPrice = $basePrice; |
| 4506 |
break; |
| 4507 |
} |
| 4508 |
|
| 4509 |
$itinerary_costs_total += $calculatedPrice; |
| 4510 |
} |
| 4511 |
|
| 4512 |
// Note: CalculationService already includes itinerary costs in final_total |
| 4513 |
// No need to add itinerary_costs_total again - it's already included in $total_amount |
| 4514 |
|
| 4515 |
// Calculate due amount based on payment method. |
| 4516 |
// |
| 4517 |
// Flow: compute a sensible default using the *percentage* filters (which |
| 4518 |
// Pro can already override per-trip via trip.deposit_percentage), then |
| 4519 |
// hand off to `yatra_calculate_amount_due` so Pro can apply absolute |
| 4520 |
// overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both |
| 4521 |
// keeps the math consistent with CalculationService::calculatePaymentAmounts(). |
| 4522 |
// Tour start → Pro can force full payment when the tour is within the |
| 4523 |
// balance-due window (tour-anchored scheduled payments). |
| 4524 |
$context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')]; |
| 4525 |
$flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false); |
| 4526 |
$deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context); |
| 4527 |
$partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context); |
| 4528 |
|
| 4529 |
$amount_due = $total_amount; |
| 4530 |
if ($payment_method === 'deposit') { |
| 4531 |
$amount_due = $total_amount * ($deposit_percentage / 100); |
| 4532 |
} elseif ($payment_method === 'partial') { |
| 4533 |
$amount_due = $total_amount * ($partial_percentage / 100); |
| 4534 |
} |
| 4535 |
|
| 4536 |
$amount_due = (float) apply_filters( |
| 4537 |
'yatra_calculate_amount_due', |
| 4538 |
$amount_due, |
| 4539 |
$total_amount, |
| 4540 |
$payment_method, |
| 4541 |
$context |
| 4542 |
); |
| 4543 |
|
| 4544 |
Logger::debug('Yatra booking summary: payment method and amount due', [ |
| 4545 |
'context' => 'booking_summary_rest', |
| 4546 |
'trip_id' => $trip_id, |
| 4547 |
'flexible_payments_enabled' => $flexible_payments_enabled, |
| 4548 |
'payment_method' => $payment_method, |
| 4549 |
'total_amount' => round($total_amount, 4), |
| 4550 |
'amount_due' => round($amount_due, 4), |
| 4551 |
'deposit_percentage' => $deposit_percentage, |
| 4552 |
'partial_percentage' => $partial_percentage, |
| 4553 |
]); |
| 4554 |
|
| 4555 |
// Build pricing HTML for the summary section (using CalculationService data) |
| 4556 |
$pricing_html = $this->buildPricingHtml([ |
| 4557 |
'is_traveler_based' => $is_traveler_based, |
| 4558 |
'category_breakdown' => $category_breakdown, |
| 4559 |
'price_per_person' => $price_per_person ?? $base_trip_price, |
| 4560 |
'total_travelers' => $total_travelers, |
| 4561 |
'gross_total' => $pricing['gross_total'] ?? $pricing['base_amount'], |
| 4562 |
'subtotal' => $pricing['gross_total'] ?? $pricing['base_amount'], |
| 4563 |
'taxable_amount' => $pricing['taxable_amount'] ?? 0, |
| 4564 |
'group_discount_amount' => $pricing['group_discount']['amount'] ?? 0, |
| 4565 |
'group_discount_label' => $pricing['group_discount']['label'] ?? '', |
| 4566 |
'coupon_discount_amount' => $pricing['coupon_discount']['calculated_amount'] ?? 0, |
| 4567 |
'coupon_discount_label' => $pricing['coupon_discount']['label'] ?? '', |
| 4568 |
'coupon_code' => $pricing['coupon_discount']['code'] ?? '', |
| 4569 |
'additional_services' => $additional_services, |
| 4570 |
'services_total' => $services_total, |
| 4571 |
'itinerary_costs' => $itinerary_costs, |
| 4572 |
'itinerary_costs_total' => $itinerary_costs_total, |
| 4573 |
'total_amount' => $total_amount, |
| 4574 |
'amount_due' => $amount_due, |
| 4575 |
'payment_method' => $payment_method, |
| 4576 |
'deposit_percentage' => $deposit_percentage, |
| 4577 |
'partial_percentage' => $partial_percentage, |
| 4578 |
// Tax variables from centralized calculation |
| 4579 |
'enable_tax' => $pricing['tax_calculation']['enable_tax'], |
| 4580 |
'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'], |
| 4581 |
'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'], |
| 4582 |
'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'], |
| 4583 |
// Dynamic-pricing data — without these, the AJAX-refreshed summary |
| 4584 |
// would never carry the DP line items even though CalculationService |
| 4585 |
// produces them on every recalculation. |
| 4586 |
'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null, |
| 4587 |
'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null, |
| 4588 |
'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0, |
| 4589 |
// Authoritative post-DP per-category map. Checkout::getCategoryBreakdown |
| 4590 |
// prefers this over the session's $pt->effective_price (which can be |
| 4591 |
// pre-DP after a stored availability row's price_types come in |
| 4592 |
// pre-baked), so forwarding it here is what keeps the AJAX-rendered |
| 4593 |
// "Adult x N ($X x N)" row in sync with the actual Trip Subtotal. |
| 4594 |
'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [], |
| 4595 |
// Currency for consistent formatting |
| 4596 |
'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 4597 |
]); |
| 4598 |
|
| 4599 |
// Build response |
| 4600 |
return new WP_REST_Response([ |
| 4601 |
'success' => true, |
| 4602 |
'data' => [ |
| 4603 |
'is_traveler_based' => $is_traveler_based, |
| 4604 |
'category_breakdown' => $category_breakdown, |
| 4605 |
'subtotal' => round($subtotal, 2), |
| 4606 |
'subtotal_formatted' => yatra_format_price($subtotal), |
| 4607 |
'total_travelers' => $total_travelers, |
| 4608 |
'group_discount' => $group_discount ? [ |
| 4609 |
'amount' => round($group_discount_amount, 2), |
| 4610 |
'amount_formatted' => yatra_format_price($group_discount_amount), |
| 4611 |
'label' => $group_discount_label, |
| 4612 |
'code' => $group_discount_code, |
| 4613 |
'applied_categories' => $group_discount['applied_categories'] ?? [], |
| 4614 |
] : null, |
| 4615 |
'coupon_discount' => $coupon_discount_amount > 0 ? [ |
| 4616 |
'amount' => round($coupon_discount_amount, 2), |
| 4617 |
'amount_formatted' => yatra_format_price($coupon_discount_amount), |
| 4618 |
'label' => $coupon_discount_label, |
| 4619 |
'code' => $coupon_code, |
| 4620 |
] : null, |
| 4621 |
'coupon_error' => $coupon_error, |
| 4622 |
'total_discount' => round($group_discount_amount + $coupon_discount_amount, 2), |
| 4623 |
'total_discount_formatted' => yatra_format_price($group_discount_amount + $coupon_discount_amount), |
| 4624 |
// Additional services (premium feature) |
| 4625 |
'additional_services' => $additional_services, |
| 4626 |
'services_total' => round($services_total, 2), |
| 4627 |
'services_total_formatted' => yatra_format_price($services_total), |
| 4628 |
// Itinerary costs (separate from services) |
| 4629 |
'itinerary_costs' => $itinerary_costs, |
| 4630 |
'itinerary_costs_total' => round($itinerary_costs_total, 2), |
| 4631 |
'itinerary_costs_total_formatted' => yatra_format_price($itinerary_costs_total), |
| 4632 |
'total_amount' => round($total_amount, 2), |
| 4633 |
'total_amount_formatted' => yatra_format_price($total_amount), |
| 4634 |
'amount_due' => round($amount_due, 2), |
| 4635 |
'amount_due_formatted' => yatra_format_price($amount_due), |
| 4636 |
'deposit_percentage' => $deposit_percentage, |
| 4637 |
'partial_percentage' => $partial_percentage, |
| 4638 |
// HTML for the pricing section |
| 4639 |
'pricing_html' => $pricing_html, |
| 4640 |
], |
| 4641 |
]); |
| 4642 |
} |
| 4643 |
|
| 4644 |
/** |
| 4645 |
* Build HTML for the pricing summary section |
| 4646 |
* This is returned via AJAX to update the pricing breakdown dynamically |
| 4647 |
* Uses the pricing-summary.php template for rendering with Checkout model |
| 4648 |
*/ |
| 4649 |
private function buildPricingHtml(array $data): string |
| 4650 |
{ |
| 4651 |
// Get session data to create Checkout model |
| 4652 |
yatra_start_session(); |
| 4653 |
$session = yatra_get_booking_session(); |
| 4654 |
|
| 4655 |
// Get trip data |
| 4656 |
$trip_id = (int) ($session['trip_id'] ?? 0); |
| 4657 |
if (empty($trip_id)) { |
| 4658 |
return '<p>' . __('Pricing information not available.', 'yatra') . '</p>'; |
| 4659 |
} |
| 4660 |
|
| 4661 |
$tripRepository = new \Yatra\Repositories\TripRepository(); |
| 4662 |
$trip = $tripRepository->findPublished($trip_id); |
| 4663 |
if (!$trip) { |
| 4664 |
return '<p>' . __('Trip not found.', 'yatra') . '</p>'; |
| 4665 |
} |
| 4666 |
|
| 4667 |
// Build pricing calculation array from data (centralized pricing) |
| 4668 |
$resolvedCurrentPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip); |
| 4669 |
$pricingCalculation = [ |
| 4670 |
'original_price' => $trip->original_price ?? 0, |
| 4671 |
'discounted_price' => $resolvedCurrentPrice, |
| 4672 |
'unit_price' => $data['price_per_person'] ?? $resolvedCurrentPrice, |
| 4673 |
'pricing_type' => $session['pricing_type'] ?? 'regular', |
| 4674 |
'base_amount' => $data['gross_total'] ?? 0, |
| 4675 |
'subtotal' => $data['subtotal'] ?? $data['gross_total'] ?? 0, |
| 4676 |
'taxable_amount' => $data['taxable_amount'] ?? 0, |
| 4677 |
'gross_total' => $data['gross_total'] ?? 0, |
| 4678 |
'final_total' => $data['total_amount'] ?? 0, |
| 4679 |
'amount_due' => $data['amount_due'] ?? 0, |
| 4680 |
'travelers_count' => $data['total_travelers'] ?? 1, |
| 4681 |
'is_traveler_based' => $data['is_traveler_based'] ?? false, |
| 4682 |
'category_breakdown' => $data['category_breakdown'] ?? [], |
| 4683 |
'group_discount' => [ |
| 4684 |
'amount' => $data['group_discount_amount'] ?? 0, |
| 4685 |
'label' => $data['group_discount_label'] ?? '', |
| 4686 |
], |
| 4687 |
'coupon_discount' => [ |
| 4688 |
'code' => $data['coupon_code'] ?? '', |
| 4689 |
'calculated_amount' => $data['coupon_discount_amount'] ?? 0, |
| 4690 |
'label' => $data['coupon_discount_label'] ?? '', |
| 4691 |
], |
| 4692 |
'total_discount_amount' => ($data['group_discount_amount'] ?? 0) + ($data['coupon_discount_amount'] ?? 0), |
| 4693 |
'additional_services' => $data['additional_services'] ?? [], |
| 4694 |
'services_total' => $data['services_total'] ?? 0, |
| 4695 |
'itinerary_costs' => $data['itinerary_costs'] ?? [], |
| 4696 |
'itinerary_costs_total' => $data['itinerary_costs_total'] ?? 0, |
| 4697 |
'tax_calculation' => [ |
| 4698 |
'enable_tax' => $data['enable_tax'] ?? false, |
| 4699 |
'tax_breakdown' => $data['tax_breakdown'] ?? [], |
| 4700 |
'total_tax_amount' => $data['total_tax_amount'] ?? 0, |
| 4701 |
'tax_inclusive' => $data['tax_inclusive'] ?? false, |
| 4702 |
], |
| 4703 |
// Dynamic-pricing breakdown — without this, the AJAX-refreshed |
| 4704 |
// sidebar would never show DP line items even though the page-load |
| 4705 |
// path does. Keys match CalculationService's pricing_data shape so |
| 4706 |
// Checkout::getDynamicPricing() returns identical results in both |
| 4707 |
// render contexts. |
| 4708 |
'dynamic_pricing' => $data['dynamic_pricing'] ?? null, |
| 4709 |
'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null, |
| 4710 |
'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0, |
| 4711 |
// Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown |
| 4712 |
// keys off this to override the (potentially stale / pre-DP) |
| 4713 |
// $pt->effective_price coming from session price_types — without |
| 4714 |
// it, the AJAX recompute renders pre-DP rows while the rest of |
| 4715 |
// the summary uses the post-DP base amount. |
| 4716 |
'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [], |
| 4717 |
'currency' => $data['currency'] ?? null, |
| 4718 |
]; |
| 4719 |
|
| 4720 |
// Update session with payment method if provided |
| 4721 |
if (!empty($data['payment_method'])) { |
| 4722 |
$session['payment_method'] = $data['payment_method']; |
| 4723 |
} |
| 4724 |
if (!empty($data['deposit_percentage'])) { |
| 4725 |
$session['deposit_percentage'] = $data['deposit_percentage']; |
| 4726 |
} |
| 4727 |
if (!empty($data['partial_payment_percentage'])) { |
| 4728 |
$session['partial_payment_percentage'] = $data['partial_payment_percentage']; |
| 4729 |
} |
| 4730 |
if (!empty($data['partial_percentage'])) { |
| 4731 |
$session['partial_payment_percentage'] = $data['partial_percentage']; |
| 4732 |
} |
| 4733 |
|
| 4734 |
if (!empty($data['payment_method']) || !empty($data['deposit_percentage']) || !empty($data['partial_percentage']) || !empty($data['partial_payment_percentage'])) { |
| 4735 |
yatra_set_booking_session($session); |
| 4736 |
} |
| 4737 |
|
| 4738 |
// Create Checkout model instance |
| 4739 |
$checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation); |
| 4740 |
|
| 4741 |
// Surface dynamic-pricing breakdown to the template scope. The partial |
| 4742 |
// reads `$dynamic_pricing` for the DP block; the page-load path |
| 4743 |
// already sets this in booking-content.php. |
| 4744 |
$dynamic_pricing = $pricingCalculation['dynamic_pricing'] ?? null; |
| 4745 |
$currency = $pricingCalculation['currency'] ?? null; |
| 4746 |
|
| 4747 |
// Load the template (uses $checkout model) |
| 4748 |
$template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php'; |
| 4749 |
|
| 4750 |
if (!file_exists($template_path)) { |
| 4751 |
return '<p>' . __('Template not found.', 'yatra') . '</p>'; |
| 4752 |
} |
| 4753 |
|
| 4754 |
// Use output buffering to capture the template output |
| 4755 |
ob_start(); |
| 4756 |
include $template_path; |
| 4757 |
return ob_get_clean(); |
| 4758 |
} |
| 4759 |
|
| 4760 |
/** |
| 4761 |
* Remove coupon code from booking session |
| 4762 |
*/ |
| 4763 |
public function remove_coupon(WP_REST_Request $request): WP_REST_Response |
| 4764 |
{ |
| 4765 |
yatra_start_session(); |
| 4766 |
|
| 4767 |
$data = $request->get_json_params() ?? []; |
| 4768 |
|
| 4769 |
// M-2: restore CSRF protection stripped by public_permission_callback. |
| 4770 |
if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) { |
| 4771 |
return $blocked; |
| 4772 |
} |
| 4773 |
|
| 4774 |
$session = yatra_get_booking_session(); |
| 4775 |
|
| 4776 |
// Same booking_token rehydration as apply_coupon — handle REST |
| 4777 |
// requests that arrive without a propagated PHPSESSID. |
| 4778 |
if (empty($session) || empty($session['trip_id'])) { |
| 4779 |
$token = null; |
| 4780 |
if (!empty($data['booking_token']) && is_string($data['booking_token'])) { |
| 4781 |
$token = sanitize_text_field((string) $data['booking_token']); |
| 4782 |
} elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { |
| 4783 |
$token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); |
| 4784 |
} |
| 4785 |
if ($token) { |
| 4786 |
$transient_data = get_transient($token); |
| 4787 |
if (is_array($transient_data) && !empty($transient_data['trip_id'])) { |
| 4788 |
$session = $transient_data; |
| 4789 |
$_SESSION['yatra_booking'] = $session; |
| 4790 |
$_SESSION['yatra_booking_token'] = $token; |
| 4791 |
} |
| 4792 |
} |
| 4793 |
} |
| 4794 |
|
| 4795 |
if (empty($session)) { |
| 4796 |
return new WP_REST_Response([ |
| 4797 |
'success' => false, |
| 4798 |
'message' => __('No active booking session found.', 'yatra'), |
| 4799 |
], 400); |
| 4800 |
} |
| 4801 |
|
| 4802 |
// Remove coupon directly from session to avoid array_merge issues |
| 4803 |
if (isset($_SESSION['yatra_booking']['coupon'])) { |
| 4804 |
unset($_SESSION['yatra_booking']['coupon']); |
| 4805 |
} |
| 4806 |
$_SESSION['yatra_booking']['timestamp'] = time(); |
| 4807 |
|
| 4808 |
// Also update local session array for calculation |
| 4809 |
unset($session['coupon']); |
| 4810 |
$session['timestamp'] = time(); |
| 4811 |
|
| 4812 |
// Persist the coupon removal to the transient too — without this, |
| 4813 |
// the next /booking/summary AJAX (which the JS calls right after |
| 4814 |
// this endpoint) re-reads the still-couponed transient and the |
| 4815 |
// sidebar shows the coupon discount as if it never went away. |
| 4816 |
// `yatra_set_booking_session()` array_merges `$_SESSION['yatra_booking']` |
| 4817 |
// (already coupon-less above) with the passed data and writes the |
| 4818 |
// result into the transient keyed by the existing booking token. |
| 4819 |
yatra_set_booking_session($session); |
| 4820 |
|
| 4821 |
// Ensure session data is written immediately |
| 4822 |
if (session_status() === PHP_SESSION_ACTIVE) { |
| 4823 |
session_write_close(); |
| 4824 |
} |
| 4825 |
|
| 4826 |
$total_amount = $this->calculateSessionTotal($session); |
| 4827 |
|
| 4828 |
return new WP_REST_Response([ |
| 4829 |
'success' => true, |
| 4830 |
'message' => __('Coupon removed.', 'yatra'), |
| 4831 |
'data' => [ |
| 4832 |
'new_total' => $total_amount, |
| 4833 |
'new_total_formatted' => yatra_format_price($total_amount), |
| 4834 |
], |
| 4835 |
]); |
| 4836 |
} |
| 4837 |
|
| 4838 |
/** |
| 4839 |
* Calculate total amount from session |
| 4840 |
* Uses CalculationService to get accurate pricing (without coupon) |
| 4841 |
*/ |
| 4842 |
private function calculateSessionTotal(array $session): float |
| 4843 |
{ |
| 4844 |
// Use CalculationService to get accurate base pricing |
| 4845 |
$calculationService = new \Yatra\Services\CalculationService(); |
| 4846 |
|
| 4847 |
try { |
| 4848 |
// Calculate pricing WITHOUT coupon (we're calculating this to apply coupon to it) |
| 4849 |
$pricing = $calculationService->calculateFromSession($session, ''); |
| 4850 |
|
| 4851 |
// Return gross_total (base amount before discounts but after any group discounts) |
| 4852 |
$total = $pricing['gross_total'] ?? 0; |
| 4853 |
|
| 4854 |
return (float) $total; |
| 4855 |
} catch (\Throwable $e) { |
| 4856 |
return 0.0; |
| 4857 |
} |
| 4858 |
} |
| 4859 |
|
| 4860 |
/** |
| 4861 |
* @deprecated Use DiscountService::calculateCouponDiscount() instead |
| 4862 |
* Calculate discount amount |
| 4863 |
*/ |
| 4864 |
private function calculateDiscountAmount(\stdClass $discount, float $total, array $session): float |
| 4865 |
{ |
| 4866 |
$discount_amount = 0; |
| 4867 |
|
| 4868 |
// Check if group discount applies |
| 4869 |
if ($discount->is_group_discount && !empty($discount->min_group_size)) { |
| 4870 |
$travelers = (int) ($session['travelers'] ?? 1); |
| 4871 |
if ($travelers >= (int) $discount->min_group_size && !empty($discount->group_discount_amount)) { |
| 4872 |
// Apply group discount |
| 4873 |
if ($discount->group_discount_type === 'percentage') { |
| 4874 |
$discount_amount = $total * ((float) $discount->group_discount_amount / 100); |
| 4875 |
} else { |
| 4876 |
$discount_amount = (float) $discount->group_discount_amount; |
| 4877 |
} |
| 4878 |
} |
| 4879 |
} |
| 4880 |
|
| 4881 |
// If no group discount, apply regular discount |
| 4882 |
if ($discount_amount === 0) { |
| 4883 |
if ($discount->type === 'percentage') { |
| 4884 |
$discount_amount = $total * ((float) $discount->amount / 100); |
| 4885 |
} else { |
| 4886 |
$discount_amount = (float) $discount->amount; |
| 4887 |
} |
| 4888 |
} |
| 4889 |
|
| 4890 |
// Apply max discount cap if set |
| 4891 |
if (!empty($discount->max_discount_amount) && $discount_amount > (float) $discount->max_discount_amount) { |
| 4892 |
$discount_amount = (float) $discount->max_discount_amount; |
| 4893 |
} |
| 4894 |
|
| 4895 |
// Ensure discount doesn't exceed total |
| 4896 |
if ($discount_amount > $total) { |
| 4897 |
$discount_amount = $total; |
| 4898 |
} |
| 4899 |
|
| 4900 |
return round($discount_amount, 2); |
| 4901 |
} |
| 4902 |
|
| 4903 |
/** |
| 4904 |
* Get coupon usage count by user |
| 4905 |
*/ |
| 4906 |
private function getCouponUsageByUser(string $discount_code, int $user_id): int |
| 4907 |
{ |
| 4908 |
// Use AvailabilityService to check discount code usage |
| 4909 |
return $this->availabilityService->getDiscountCodeUsage($user_id, strtoupper(sanitize_text_field($discount_code))); |
| 4910 |
} |
| 4911 |
} |
| 4912 |
|
| 4913 |
|