PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5
Yatra – Travel Booking & Tour Operator Software v3.0.5
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / BookingSessionController.php

BookingSessionController.php in Yatra – Travel Booking & Tour Operator Software 3.0.5, at app/Controllers/BookingSessionController.php

4,017 lines 183.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
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 = $request->get_param('gateway');
210 $data = $request->get_json_params();
211
212 $booking_id = $data['booking_id'] ?? 0;
213 $source_id = $data['source_id'] ?? '';
214 $amount = $data['amount'] ?? 0;
215 $currency = $data['currency'] ?? 'USD';
216
217 if (empty($booking_id) || empty($source_id)) {
218 return new WP_REST_Response([
219 'success' => false,
220 'message' => __('Missing required payment data.', 'yatra'),
221 ], 400);
222 }
223
224 // Get the gateway
225 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
226 $gateway = $registry->get($gateway_id);
227
228 if (!$gateway) {
229 return new WP_REST_Response([
230 'success' => false,
231 'message' => __('Invalid payment gateway.', 'yatra'),
232 ], 400);
233 }
234
235 // Check if gateway has createPayment method
236 if (!method_exists($gateway, 'createPayment')) {
237 return new WP_REST_Response([
238 'success' => false,
239 'message' => __('Gateway does not support this payment method.', 'yatra'),
240 ], 400);
241 }
242
243 // Create the payment
244 $result = $gateway->createPayment([
245 'source_id' => $source_id,
246 'booking_id' => $booking_id,
247 'amount' => $amount,
248 'currency' => $currency,
249 ]);
250
251 if (!$result['success']) {
252 return new WP_REST_Response([
253 'success' => false,
254 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
255 ], 400);
256 }
257
258 // Update booking payment status
259 $bookingRepository = new \Yatra\Repositories\BookingRepository();
260 $booking = $bookingRepository->find($booking_id);
261
262 if ($booking) {
263 // Record the payment using PaymentRepository
264 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
265 $paymentRepository->create([
266 'booking_id' => $booking_id,
267 'amount' => $amount,
268 'currency' => $currency,
269 'gateway' => $gateway_id,
270 'transaction_id' => $result['transaction_id'] ?? '',
271 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
272 ]);
273
274 // Update booking status if payment is complete
275 if (($result['status'] ?? 'completed') === 'completed') {
276 // Get total paid amount
277 $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
278 $total_amount = (float) $booking->total_amount;
279
280 if ($total_paid >= $total_amount) {
281 $prevStatus = (string) ($booking->status ?? 'pending');
282 $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
283 \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus);
284 } else {
285 $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
286 }
287 }
288 }
289
290 return new WP_REST_Response([
291 'success' => true,
292 'message' => __('Payment completed successfully.', 'yatra'),
293 'data' => [
294 'transaction_id' => $result['transaction_id'] ?? '',
295 'status' => $result['status'] ?? 'completed',
296 ],
297 ]);
298 }
299
300 /**
301 * Set booking session data
302 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
303 */
304 public function set_session(WP_REST_Request $request): WP_REST_Response
305 {
306 // Ensure session is started for REST API requests
307 yatra_start_session();
308
309 $data = $request->get_json_params();
310
311 // Check if this is a partial update (updating travelers or services in existing session)
312 $existing_session = yatra_get_booking_session();
313
314 // REST requests don't always carry PHPSESSID into the WP session scope,
315 // so for partial updates (where the JS only sends e.g.
316 // `additional_services: [1]`) we may end up with an empty
317 // `$existing_session` here and incorrectly bounce to the "trip_id
318 // required" guard below. Same fix as create_booking: when the page
319 // URL has a `?booking_token=…` (or the body carries it), look up the
320 // matching transient and treat it as the session. Without this, every
321 // service-toggle returns 400.
322 if (empty($existing_session) || empty($existing_session['trip_id'])) {
323 $token = null;
324 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
325 $token = sanitize_text_field((string) $data['booking_token']);
326 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
327 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
328 }
329 if ($token) {
330 $transient_data = get_transient($token);
331 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
332 $existing_session = $transient_data;
333 // CRITICAL: also seed $_SESSION with the rehydrated data
334 // AND the original token so yatra_set_booking_session()
335 // (called below in the partial-update branch) writes the
336 // updated session BACK into the same transient. Without
337 // this, the helper generates a fresh random token and
338 // writes to a different transient — the user's URL token
339 // never gets the new selection persisted, and the next
340 // refresh shows stale data.
341 $_SESSION['yatra_booking'] = $existing_session;
342 $_SESSION['yatra_booking_token'] = $token;
343 }
344 }
345 }
346
347 $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) &&
348 (isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services']));
349
350 if ($is_partial_update) {
351 // Partial update: merge new data with existing session
352 if (isset($data['travelers'])) {
353 $existing_session['travelers'] = max(1, (int) $data['travelers']);
354 }
355 if (isset($data['traveler_counts']) && is_array($data['traveler_counts'])) {
356 $existing_session['traveler_counts'] = $data['traveler_counts'];
357 // Recalculate total travelers from counts
358 $existing_session['travelers'] = max(1, array_sum(array_map('intval', $data['traveler_counts'])));
359 }
360 // Handle additional services selection
361 if (isset($data['additional_services']) && is_array($data['additional_services'])) {
362 $existing_session['additional_services'] = array_map('intval', $data['additional_services']);
363 }
364
365 // Recalculate taxes for partial updates
366 $trip_id = (int) ($existing_session['trip_id'] ?? 0);
367 $trip_price = (float) ($existing_session['trip_price'] ?? $existing_session['base_price'] ?? 0);
368 $travelers_count = (int) ($existing_session['travelers'] ?? 1);
369 $additional_services = $existing_session['additional_services'] ?? [];
370 $travel_date = (string) ($existing_session['travel_date'] ?? '');
371 $departure_time = (string) ($existing_session['departure_time'] ?? '');
372
373 // Calculate base amount - handle traveler-based pricing
374 $base_amount = 0;
375 $pricing_type = $existing_session['pricing_type'] ?? 'regular';
376 $price_types = $existing_session['price_types'] ?? [];
377 $traveler_counts = $existing_session['traveler_counts'] ?? [];
378
379 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
380 // Calculate for traveler-based pricing
381 foreach ($price_types as $pt) {
382 $pt = (array) $pt;
383 $category_id = $pt['category_id'] ?? 0;
384 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
385 $category_price = isset($pt['effective_price']) ? (float) $pt['effective_price'] : \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
386 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
387
388 if ($pricing_mode === 'per_group') {
389 // Per group: charge flat price once if any travelers in this category
390 if ($count > 0) {
391 $base_amount += $category_price;
392 }
393 } else {
394 // Per person: charge per traveler
395 $base_amount += $category_price * $count;
396 }
397 }
398 } else {
399 // Regular pricing
400 $base_amount = $trip_price * $travelers_count;
401 }
402
403 // Use CalculationService for pricing (fetches trip data from database)
404 $calculationService = new CalculationService();
405 $pricing = $calculationService->calculatePricing([
406 'trip_id' => $trip_id,
407 'travelers_count' => $travelers_count,
408 'traveler_counts' => $traveler_counts,
409 'travel_date' => $travel_date,
410 'departure_time' => $departure_time,
411 'selected_services' => $additional_services,
412 'coupon_code' => '',
413 'payment_method' => 'full',
414 ]);
415 $subtotal = (float) ($pricing['subtotal'] ?? $base_amount);
416 $total_with_tax = (float) ($pricing['final_total'] ?? $subtotal);
417 $tax_calculation = (array) ($pricing['tax_calculation'] ?? []);
418 $services_cost = (float) ($pricing['services_cost'] ?? $pricing['services_total'] ?? 0);
419
420 // Note: Pricing is calculated on-demand via CalculationService, not stored in session
421
422 $existing_session['timestamp'] = time();
423
424 // Save updated session
425 yatra_set_booking_session($existing_session);
426
427 return new WP_REST_Response([
428 'success' => true,
429 'message' => __('Booking session updated.', 'yatra'),
430 'data' => $existing_session,
431 ]);
432 }
433
434 if (empty($data['trip_id'])) {
435 return new WP_REST_Response([
436 'success' => false,
437 'message' => __('Trip ID is required.', 'yatra'),
438 ], 400);
439 }
440
441 // Validate trip exists
442 $trip = $this->tripRepository->findPublished((int) $data['trip_id']);
443
444 if (!$trip) {
445 return new WP_REST_Response([
446 'success' => false,
447 'message' => __('Trip not found.', 'yatra'),
448 ], 404);
449 }
450
451 global $wpdb;
452
453 // Get availability-specific data if date provided
454 $availability = null;
455 // availability_id may be numeric (manual date row) or a synthetic string (rule/default).
456 // Keep it as string in session and only cast to int when it is numeric.
457 $availability_id = !empty($data['availability_id']) ? sanitize_text_field((string) $data['availability_id']) : null;
458 $travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : '';
459 $departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : '';
460
461 // Use centralized AvailabilityResolutionService to get resolved availability
462 // Priority: Availability Dates → Recurring Rules → Trip Default (specific rows override patterns)
463 // Pass departure_time for day tours with multiple time slots on the same date
464 $availability = null;
465 if ($travel_date) {
466 try {
467 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
468 $availability = $resolutionService->resolveAvailabilityForDate(
469 (int) $data['trip_id'],
470 $travel_date,
471 $departure_time ?: null
472 );
473
474 if ($availability) {
475 $data['availability_id'] = $availability->id;
476 $data['seats_available'] = $availability->seats_available;
477 $data['seats_total'] = $availability->seats_total;
478 }
479 } catch (\Exception $e) {
480 $availability = null;
481 }
482 }
483
484 // Resolve pricing_type and price_types via centralized TripPricingService
485 // Priority: frontend data → availability → trip defaults
486 $pricing_type = !empty($data['pricing_type'])
487 ? sanitize_text_field($data['pricing_type'])
488 : \Yatra\Services\TripPricingService::resolvePricingType($trip);
489
490 $price_types = [];
491
492 // First priority: price_types sent from frontend (from availability card)
493 if (!empty($data['price_types']) && is_array($data['price_types'])) {
494 $price_types = $data['price_types'];
495 }
496 // Second priority: availability price_types (already includes trip fallback from AvailabilityResolutionService)
497 elseif ($availability && !empty($availability->price_types)) {
498 $price_types = is_array($availability->price_types) ? $availability->price_types : [];
499 }
500 // Third priority: trip's price_types via centralized normalizer
501 if (empty($price_types)) {
502 $price_types = \Yatra\Services\TripPricingService::resolvePriceTypes($trip);
503 }
504 // Auto-detect traveler_based if price_types are present
505 if (!empty($price_types) && $pricing_type === 'regular') {
506 $pricing_type = 'traveler_based';
507 }
508 // NOTE: Actual pricing calculation is handled entirely by CalculationService below
509
510 // Enrich price_types with category labels if needed (some might already have them from frontend)
511 if (!empty($price_types)) {
512 $needsEnrichment = false;
513 foreach ($price_types as $pt) {
514 $pt = (array) $pt;
515 if (empty($pt['category_label']) && !empty($pt['category_id'])) {
516 $needsEnrichment = true;
517 break;
518 }
519 }
520
521 if ($needsEnrichment) {
522 $categoryIds = array_filter(array_map(function($p) {
523 $p = (array) $p;
524 return isset($p['category_id']) ? (int) $p['category_id'] : null;
525 }, $price_types));
526
527 if (!empty($categoryIds)) {
528 // Use AvailabilityService to get traveler categories
529 $cats = $this->availabilityService->getTravelerCategories($categoryIds);
530
531 $catIndex = [];
532 foreach ($cats as $cat) {
533 $catIndex[(int) $cat->id] = $cat;
534 }
535
536 foreach ($price_types as &$pt) {
537 $pt = (array) $pt;
538 $catId = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
539 if ($catId && isset($catIndex[$catId]) && empty($pt['category_label'])) {
540 $cat = $catIndex[$catId];
541 $pt['category_label'] = $cat->label;
542 $pt['category_slug'] = $cat->slug;
543 $pt['age_min'] = $cat->age_min ? (int) $cat->age_min : null;
544 $pt['age_max'] = $cat->age_max ? (int) $cat->age_max : null;
545 }
546 // Ensure effective price is set via centralized resolver
547 if (!isset($pt['effective_price'])) {
548 $pt['effective_price'] = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
549 }
550 }
551 }
552 }
553 }
554
555 // Parse traveler_counts if provided (for traveler-based pricing)
556 $traveler_counts = [];
557 if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) {
558 $traveler_counts = $data['traveler_counts'];
559 }
560
561 // Calculate total travelers
562 $travelers_count = isset($data['travelers']) ? (int) $data['travelers'] : 0;
563 if (!empty($traveler_counts)) {
564 $travelers_count = array_sum(array_map('intval', $traveler_counts));
565 }
566
567 if ($travelers_count < 1) {
568 return new WP_REST_Response([
569 'success' => false,
570 'message' => __('Please select at least 1 traveler to continue.', 'yatra'),
571 ], 400);
572 }
573
574 // Determine if day trip - prefer frontend value, fallback to trip duration
575 $is_day_trip = isset($data['is_day_trip']) ? (bool) $data['is_day_trip'] : (($trip->duration_days ?? 1) <= 1);
576
577 // Get additional services from request (selected in popup)
578 $additional_services = [];
579 if (!empty($data['additional_services']) && is_array($data['additional_services'])) {
580 $additional_services = array_map('intval', $data['additional_services']);
581 }
582
583 // Resolve enabled gateways for this session (use registry to respect availability)
584 $gatewayRegistry = PaymentGatewayRegistry::getInstance();
585 $availableGateways = $gatewayRegistry->getForCheckout();
586 $enabled_gateways = [];
587 foreach ($availableGateways as $gateway) {
588 if (!empty($gateway['id'])) {
589 $enabled_gateways[$gateway['id']] = $gateway;
590 }
591 }
592 // Fallback: if registry returned nothing, use saved settings gateways
593 if (empty($enabled_gateways)) {
594 $settings_gateways = SettingsService::get('payment_gateways', []);
595 if (is_array($settings_gateways)) {
596 $enabled_gateways = $settings_gateways;
597 }
598 }
599
600 // Use CalculationService as single source of truth for all pricing
601 $calculationService = new CalculationService();
602 $pricing = $calculationService->calculatePricing([
603 'trip_id' => (int) $trip->id,
604 'travelers_count' => $travelers_count,
605 'traveler_counts' => $traveler_counts,
606 'travel_date' => $travel_date,
607 'departure_time' => $departure_time,
608 'selected_services' => $additional_services,
609 'availability_id' => $availability_id ? (int) $availability_id : null,
610 'coupon_code' => '',
611 'payment_method' => 'full',
612 ]);
613
614 // Prepare session data - essential trip data (pricing fetched from database on-demand)
615 $session_data = [
616 'trip_id' => (int) $trip->id,
617 'trip_title' => $trip->title,
618 'trip_slug' => $trip->slug,
619 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
620 'min_travelers' => (int) ($trip->min_travelers ?: 1),
621 'max_travelers' => (int) ($trip->max_travelers ?: 20),
622 'duration_days' => (int) ($trip->duration_days ?: 1),
623 'travelers' => $travelers_count,
624 'travel_date' => $travel_date,
625 'departure_time' => $departure_time,
626 'timestamp' => time(),
627 // Availability-specific data
628 'availability_id' => $availability_id,
629 'pricing_type' => $pricing['pricing_type'] ?? $pricing_type,
630 'price_types' => $price_types,
631 'traveler_counts' => $traveler_counts,
632 'is_day_trip' => $is_day_trip,
633 // Additional services (selected in popup)
634 'additional_services' => $additional_services,
635 // Payment gateways available for checkout UI
636 'enabled_gateways' => $enabled_gateways,
637 // Note: NO pricing data stored - fetched from database on-demand via CalculationService
638 ];
639
640 if (!empty($data['is_remaining_payment'])) {
641 $session_data['is_remaining_payment'] = true;
642 $session_data['existing_booking_id'] = (int) ($data['existing_booking_id'] ?? 0);
643 $session_data['booking_reference'] = $data['booking_reference'] ?? '';
644 $session_data['remaining_amount'] = isset($data['remaining_amount']) ? (float) $data['remaining_amount'] : null;
645 $session_data['amount_paid'] = isset($data['amount_paid']) ? (float) $data['amount_paid'] : null;
646 $session_data['total_amount'] = isset($data['total_amount']) ? (float) $data['total_amount'] : null;
647 }
648
649 // Reset any previous session to avoid stale flags
650 yatra_clear_booking_session();
651
652 // Set session
653 yatra_set_booking_session($session_data);
654
655 // Get the booking token directly from session (it's added by yatra_set_booking_session)
656 yatra_start_session();
657 $booking_token = $_SESSION['yatra_booking_token'] ?? null;
658
659
660 // Fire hook when trip is added to booking session (for Pro modules)
661 do_action('yatra_trip_added_to_session', $session_data['trip_id'], $session_data);
662
663 // Get redirect URL
664 $redirect_url = yatra_get_checkout_url();
665
666 // Add booking token to URL for REST API → page load session restoration
667 if ($booking_token) {
668 $redirect_url = add_query_arg('booking_token', $booking_token, $redirect_url);
669 }
670
671 // Add booking_token to response data for debugging
672 $session_data['booking_token'] = $booking_token;
673
674 return new WP_REST_Response([
675 'success' => true,
676 'message' => __('Booking session created.', 'yatra'),
677 'data' => $session_data,
678 'redirect_url' => $redirect_url,
679 ]);
680 }
681
682 /**
683 * Get booking session data
684 */
685 public function get_session(WP_REST_Request $request): WP_REST_Response
686 {
687 $session_data = yatra_get_booking_session();
688
689 // Recover from transient when PHPSESSID didn't propagate to REST.
690 // The JS appends `?booking_token=…` to the GET URL specifically so
691 // this branch can rehydrate after a page refresh — without it the
692 // sidebar's applied-coupon UI would never re-show and the remove
693 // button stayed hidden.
694 if (empty($session_data) || empty($session_data['trip_id'])) {
695 $token_raw = $request->get_param('booking_token');
696 if (empty($token_raw) && isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
697 $token_raw = wp_unslash((string) $_GET['booking_token']);
698 }
699 if (!empty($token_raw) && is_string($token_raw)) {
700 $token = sanitize_text_field($token_raw);
701 $transient_data = get_transient($token);
702 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
703 $session_data = $transient_data;
704 $_SESSION['yatra_booking'] = $session_data;
705 $_SESSION['yatra_booking_token'] = $token;
706 }
707 }
708 }
709
710 if (empty($session_data) || empty($session_data['trip_id'])) {
711 return new WP_REST_Response([
712 'success' => false,
713 'message' => __('No active booking session.', 'yatra'),
714 'data' => null,
715 ]);
716 }
717
718 return new WP_REST_Response([
719 'success' => true,
720 'data' => $session_data,
721 ]);
722 }
723
724 /**
725 * Clear booking session
726 */
727 public function clear_session(WP_REST_Request $request): WP_REST_Response
728 {
729 yatra_clear_booking_session();
730
731 return new WP_REST_Response([
732 'success' => true,
733 'message' => __('Booking session cleared.', 'yatra'),
734 ]);
735 }
736
737 /**
738 * Get trip data for booking page
739 */
740 public function get_trip_for_booking(WP_REST_Request $request): WP_REST_Response
741 {
742 $trip_id = (int) $request->get_param('id');
743
744 $trip = $this->tripRepository->findPublished($trip_id);
745
746 if (!$trip) {
747 return new WP_REST_Response([
748 'success' => false,
749 'message' => __('Trip not found.', 'yatra'),
750 ], 404);
751 }
752
753 // Format trip data for booking
754 $trip_data = [
755 'id' => (int) $trip->id,
756 'title' => $trip->title,
757 'slug' => $trip->slug,
758 'featured_image' => $trip->featured_image,
759 'duration_days' => (int) $trip->duration_days,
760 'duration_nights' => (int) $trip->duration_nights,
761 'difficulty_level' => $trip->difficulty_level,
762 'min_travelers' => (int) ($trip->min_travelers ?: 1),
763 'max_travelers' => (int) ($trip->max_travelers ?: 20),
764 'original_price' => (float) $trip->original_price,
765 'sale_price' => (float) $trip->sale_price,
766 'price' => !empty($trip->discounted_price) ? (float) $trip->discounted_price : (float) $trip->original_price,
767 'currency' => \Yatra\Services\SettingsService::getCurrency(),
768 'starting_location' => $trip->starting_location,
769 'ending_location' => $trip->ending_location,
770 ];
771
772 return new WP_REST_Response([
773 'success' => true,
774 'data' => $trip_data,
775 ]);
776 }
777
778 /**
779 * Create a new booking OR process remaining payment (same REST route).
780 *
781 * Regular checkout: persists the booking first (BookingService::createBooking), then starts
782 * online payment if needed; payment rows are written when the gateway confirms success
783 * (confirm endpoint, webhooks, IPN, or recordGatewayPayment for immediate captures).
784 *
785 * Remaining / balance payment: does not create a booking — only charges the existing row
786 * and records a payment on success via the same gateway completion paths.
787 */
788 /**
789 * Validate the booking-scoped CSRF nonce.
790 *
791 * Looks first in the `X-Yatra-Booking-Nonce` request header
792 * (the JS frontend's path), then in JSON body keys used by
793 * older or non-JS fallback flows. Returns true on a valid
794 * nonce, false otherwise.
795 *
796 * @param WP_REST_Request $request
797 * @param array<string, mixed>|null $data decoded JSON body
798 */
799 private function verifyBookingNonce(WP_REST_Request $request, $data): bool
800 {
801 $nonce = (string) $request->get_header('X-Yatra-Booking-Nonce');
802 if ($nonce === '' && \is_array($data)) {
803 $nonce = (string) (
804 $data['_yatra_booking_nonce']
805 ?? $data['yatra_booking_nonce']
806 ?? $data['booking_nonce']
807 ?? ''
808 );
809 }
810 if ($nonce === '') {
811 return false;
812 }
813 return (bool) wp_verify_nonce($nonce, 'yatra_booking_action');
814 }
815
816 public function create_booking(WP_REST_Request $request): WP_REST_Response
817 {
818 global $wpdb;
819
820 $data = $request->get_json_params();
821
822 // ========================================
823 // CSRF — booking-scoped action nonce
824 // ========================================
825 // The public_permission_callback on this route intentionally
826 // bypasses WP's default cookie/nonce check so guests can hit it
827 // at all. That bypass would otherwise leave the endpoint open
828 // to cross-site forgery (any third-party page could POST a
829 // booking using the visitor's session).
830 //
831 // We validate a booking-scoped action nonce here instead. The
832 // token is minted at page-render time (FrontendAssetsProvider
833 // injects it into `yatraBookingData.bookingNonce`) and the JS
834 // forwards it in `X-Yatra-Booking-Nonce`. We also accept it in
835 // the JSON body for any non-JS fallback flow.
836 //
837 // Returns 403 on failure — distinct from the 401 used by the
838 // login/guest-checkout gates so frontends can distinguish
839 // "security check failed" from "auth required".
840 if (!$this->verifyBookingNonce($request, $data)) {
841 return new WP_REST_Response([
842 'success' => false,
843 'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'),
844 'code' => 'invalid_nonce',
845 ], 403);
846 }
847
848 // ========================================
849 // REMAINING PAYMENT vs NEW BOOKING
850 // ========================================
851 // A leftover PHP session from "pay remaining balance" must not hijack a normal
852 // checkout POST (full traveler payload). Only treat as remaining-payment when the
853 // client is actually on that flow (hidden field) or sends no new-booking travelers.
854 // Early return: process_remaining_payment() — no createBooking().
855 if (yatra_has_remaining_session()) {
856 $is_remaining_checkout = !empty($data['is_remaining_payment']);
857 $travelers_payload = $data['travelers'] ?? null;
858 $has_new_booking_travelers = is_array($travelers_payload) && count($travelers_payload) > 0;
859
860 if ($is_remaining_checkout) {
861 return $this->process_remaining_payment($request);
862 }
863
864 if ($has_new_booking_travelers) {
865 yatra_clear_remaining_session();
866 } else {
867 return $this->process_remaining_payment($request);
868 }
869 }
870
871 // ========================================
872 // NEW BOOKING CHECKOUT (not pay-remaining)
873 // ========================================
874 // Below: createBooking() runs once; payment is initiated afterward if amount_due > 0.
875
876 // ========================================
877 // GET BOOKING SETTINGS
878 // ========================================
879 $settings = [
880 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
881 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
882 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
883 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
884 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
885 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
886 ];
887
888 // ========================================
889 // CHECK LOGIN REQUIREMENT
890 // ========================================
891 if ($settings['require_login'] && !is_user_logged_in()) {
892 return new WP_REST_Response([
893 'success' => false,
894 'message' => __('You must be logged in to make a booking.', 'yatra'),
895 'code' => 'login_required',
896 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
897 ], 401);
898 }
899
900 // Check guest checkout
901 if (!$settings['allow_guest_checkout'] && !is_user_logged_in()) {
902 return new WP_REST_Response([
903 'success' => false,
904 'message' => __('Guest checkout is not allowed. Please log in or create an account.', 'yatra'),
905 'code' => 'guest_not_allowed',
906 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
907 ], 401);
908 }
909
910 // ========================================
911 // GUEST EMAIL VERIFICATION GATE
912 // ========================================
913 // When `require_guest_email_verification` is on AND the
914 // customer is not logged in, the booking goes through a
915 // two-step flow:
916 // 1. Booking row is created with status='pending_verification'
917 // so the operator sees the intent in the admin and the
918 // cron can purge unverified rows after N days.
919 // 2. A magic-link email is sent. Payment is NOT initiated
920 // until the customer clicks the link.
921 // 3. On click, /yatra/v1/booking/verify-email validates the
922 // HMAC token, flips status to 'pending', and redirects
923 // to the payment continuation URL.
924 // Logged-in users skip this entirely — their email is already
925 // verified by WordPress on registration.
926 $needs_email_verification = !is_user_logged_in()
927 && (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false);
928
929 // Get session data
930 $session = yatra_get_booking_session();
931
932 // REST requests don't always carry PHPSESSID in the same scope as the
933 // page that rendered the booking form (cookie path mismatches, output
934 // buffering before session_start, server-side caching, etc). When
935 // `$session` is empty we try to rehydrate from the transient backup that
936 // BookingPageHandler writes when the form is rendered — the JS submit
937 // now carries `booking_token` in the request body for exactly this
938 // recovery path. Without this, `traveler_counts` / `pricing_type` /
939 // `price_types` are lost and a `traveler_based` trip's
940 // calculatePricing() collapses to 0, then BookingService rejects with
941 // "Total amount must be greater than zero."
942 if ((empty($session) || empty($session['trip_id'])) && !empty($data['booking_token'])) {
943 $tokenFromBody = sanitize_text_field((string) $data['booking_token']);
944 if ($tokenFromBody !== '') {
945 $transient_data = get_transient($tokenFromBody);
946 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
947 $session = $transient_data;
948 }
949 }
950 }
951
952 // Validate we have session data or direct booking data
953 $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0);
954
955 if (!$trip_id) {
956 return new WP_REST_Response([
957 'success' => false,
958 'message' => __('No trip selected for booking.', 'yatra'),
959 ], 400);
960 }
961
962 // Get contact email - handle both flat and nested formats
963 $contact_email = $data['contact_email'] ?? '';
964 $contact_phone = $data['contact_phone'] ?? '';
965 $contact_first_name = $data['contact_first_name'] ?? '';
966 $contact_last_name = $data['contact_last_name'] ?? '';
967 $contact_country = $data['contact_country'] ?? '';
968
969 $contact_nationality = $data['contact_nationality'] ?? '';
970 $contact_address = $data['contact_address'] ?? '';
971
972 // Emergency contact
973 $emergency_name = $data['emergency_name'] ?? '';
974 $emergency_phone = $data['emergency_phone'] ?? '';
975 $emergency_relationship = $data['emergency_relationship'] ?? '';
976
977 // Travel details
978 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
979 $travelers = $data['travelers'] ?? [];
980
981 // Validate required fields
982 if (empty($contact_email)) {
983 return new WP_REST_Response([
984 'success' => false,
985 'message' => __('Email address is required.', 'yatra'),
986 ], 400);
987 }
988
989 if (empty($contact_phone)) {
990 return new WP_REST_Response([
991 'success' => false,
992 'message' => __('Phone number is required.', 'yatra'),
993 ], 400);
994 }
995
996 if (empty($travel_date)) {
997 return new WP_REST_Response([
998 'success' => false,
999 'message' => __('Travel date is required.', 'yatra'),
1000 ], 400);
1001 }
1002
1003 if (empty($travelers) || !is_array($travelers)) {
1004 return new WP_REST_Response([
1005 'success' => false,
1006 'message' => __('At least one traveler is required.', 'yatra'),
1007 ], 400);
1008 }
1009
1010 // Validate email
1011 if (!is_email($contact_email)) {
1012 return new WP_REST_Response([
1013 'success' => false,
1014 'message' => __('Invalid email address.', 'yatra'),
1015 ], 400);
1016 }
1017
1018 // Get trip data
1019 $trip = $this->tripRepository->findPublished($trip_id);
1020
1021 if (!$trip) {
1022 return new WP_REST_Response([
1023 'success' => false,
1024 'message' => __('Trip not found.', 'yatra'),
1025 ], 404);
1026 }
1027
1028 // ========================================
1029 // PRICING via CalculationService (single source of truth)
1030 // ========================================
1031 // Count only actual travelers (exclude contact and emergency contact)
1032 $travelers_count = 0;
1033 foreach ($travelers as $traveler) {
1034 if (isset($traveler['type']) && $traveler['type'] === 'traveler') {
1035 $travelers_count++;
1036 }
1037 }
1038 // Fallback if no type is set (legacy data)
1039 if ($travelers_count === 0) {
1040 $travelers_count = count($travelers);
1041 }
1042
1043 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
1044 if ($payment_method === '') {
1045 $payment_method = 'full';
1046 }
1047 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
1048
1049 // Get coupon code from request OR session
1050 $coupon_code = sanitize_text_field($data['coupon_code'] ?? '');
1051 if (empty($coupon_code) && !empty($session['coupon']['code'])) {
1052 $coupon_code = sanitize_text_field($session['coupon']['code']);
1053 }
1054
1055 $departure_time = $data['departure_time'] ?? ($session['departure_time'] ?? '');
1056 $additional_services = $data['additional_services'] ?? ($session['additional_services'] ?? []);
1057 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
1058
1059 // Build traveler_counts from travelers array or session
1060 $traveler_counts = [];
1061 if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) {
1062 $traveler_counts = $data['traveler_counts'];
1063 } elseif (!empty($session['traveler_counts']) && is_array($session['traveler_counts'])) {
1064 $traveler_counts = $session['traveler_counts'];
1065 } else {
1066 // Build from travelers array if category_id is present
1067 foreach ($travelers as $traveler) {
1068 $categoryId = $traveler['category_id'] ?? null;
1069 if ($categoryId) {
1070 if (!isset($traveler_counts[$categoryId])) {
1071 $traveler_counts[$categoryId] = 0;
1072 }
1073 $traveler_counts[$categoryId]++;
1074 }
1075 }
1076 if (empty($traveler_counts) && $travelers_count > 0) {
1077 $traveler_counts['default'] = $travelers_count;
1078 }
1079 }
1080
1081 // Pricing single source of truth: ALWAYS use calculateFromSession.
1082 //
1083 // The booking session is kept in sync by the JS layer — every
1084 // service toggle, traveler-count change, and coupon apply/remove
1085 // POSTs to /booking/session, which updates the transient. The
1086 // sidebar's `/booking/summary` then renders from
1087 // `calculateFromSession($session, …)`. If we built a second pricing
1088 // path here from form fields, any subtle drift (form missing a
1089 // category_id, traveler_counts aggregated as 'default', etc) would
1090 // produce a different total than the customer saw — they'd be
1091 // charged something they didn't agree to. So we merge the latest
1092 // session with any form-submitted overrides (services list,
1093 // travel_date, availability_id) and let calculateFromSession do
1094 // the math the same way the sidebar did.
1095 $calculationService = new CalculationService();
1096 $session_for_pricing = is_array($session) ? $session : [];
1097 $session_for_pricing['trip_id'] = $trip_id;
1098 $session_for_pricing['travelers'] = $travelers_count;
1099 // Prefer per-category counts when we have them — otherwise let
1100 // calculateFromSession fall back to its internal handling.
1101 if (!empty($traveler_counts) && is_array($traveler_counts)) {
1102 $session_for_pricing['traveler_counts'] = $traveler_counts;
1103 }
1104 if (!empty($travel_date)) {
1105 $session_for_pricing['travel_date'] = $travel_date;
1106 }
1107 if (!empty($departure_time)) {
1108 $session_for_pricing['departure_time'] = $departure_time;
1109 }
1110 if (!empty($availability_id) && is_numeric($availability_id)) {
1111 $session_for_pricing['availability_id'] = (int) $availability_id;
1112 }
1113 if (is_array($additional_services)) {
1114 $session_for_pricing['additional_services'] = array_values(array_map('intval', $additional_services));
1115 }
1116
1117 try {
1118 $pricing = $calculationService->calculateFromSession(
1119 $session_for_pricing,
1120 $coupon_code,
1121 $payment_method
1122 );
1123 } catch (\Throwable $e) {
1124 $pricing = [];
1125 }
1126
1127 // If the session is so degenerate that even calculateFromSession
1128 // couldn't make a positive total (e.g. no traveler_counts at all),
1129 // surface that cleanly so the booking is rejected with a friendly
1130 // error rather than silently charging $0.
1131 if (((float) ($pricing['final_total'] ?? 0)) <= 0) {
1132 try {
1133 $sessionPricing = $calculationService->calculatePricing([
1134 'trip_id' => $trip_id,
1135 'travelers_count' => $travelers_count,
1136 'traveler_counts' => $traveler_counts,
1137 'travel_date' => $travel_date,
1138 'departure_time' => $departure_time,
1139 'selected_services' => $additional_services,
1140 'availability_id' => (!empty($availability_id) && is_numeric($availability_id)) ? (int) $availability_id : null,
1141 'coupon_code' => $coupon_code,
1142 'payment_method' => $payment_method,
1143 ]);
1144 if (((float) ($sessionPricing['final_total'] ?? 0)) > 0) {
1145 $pricing = $sessionPricing;
1146 }
1147 } catch (\Throwable $e) {
1148 // Keep $pricing as-is; downstream guard will surface a clean error.
1149 }
1150 }
1151
1152 // Extract pricing results
1153 $total_amount = $pricing['final_total'];
1154 $amount_due = $pricing['amount_due'];
1155 $amount_paid = $pricing['amount_paid'];
1156 $tax_calculation = $pricing['tax_calculation'];
1157 $tax_breakdown = $tax_calculation['tax_breakdown'];
1158 $total_tax_amount = $tax_calculation['total_tax_amount'];
1159
1160 $tax_inclusive = $tax_calculation['tax_inclusive'];
1161 $tax_rate = $tax_calculation['tax_rate'];
1162 $subtotal_before_discount = $pricing['subtotal'];
1163 $discount_amount = $pricing['total_discount_amount'];
1164 $discount_code = $pricing['group_discount']['code'] ?? ($pricing['coupon_discount']['code'] ?? null);
1165
1166 // ========================================
1167 // CAPACITY / WAITLIST
1168 // ========================================
1169 $resolvedAvailabilityForWaitlist = null;
1170 if ($travel_date !== '') {
1171 try {
1172 $availResolutionSvc = new \Yatra\Services\AvailabilityResolutionService();
1173 $resolvedAvailabilityForWaitlist = $availResolutionSvc->resolveAvailabilityForDate(
1174 $trip_id,
1175 $travel_date,
1176 $departure_time !== '' ? $departure_time : null
1177 );
1178 } catch (\Throwable $e) {
1179 // Leave null; legacy checkout without strict availability still works
1180 }
1181 }
1182
1183 $isWaitlistCheckout = false;
1184
1185 if ($resolvedAvailabilityForWaitlist !== null) {
1186 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1187 if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1188 return new WP_REST_Response([
1189 'success' => false,
1190 'message' => __('This departure is not open for booking.', 'yatra'),
1191 'code' => 'date_blocked',
1192 ], 400);
1193 }
1194 // Respect explicit sold_out even if seats_available is stale — waitlist only if allowed
1195 if ($availStatus === 'sold_out') {
1196 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
1197 $isWaitlistCheckout = true;
1198 } else {
1199 return new WP_REST_Response([
1200 'success' => false,
1201 'message' => __('This departure is sold out.', 'yatra'),
1202 'code' => 'sold_out',
1203 ], 400);
1204 }
1205 }
1206 }
1207
1208 if (
1209 !$isWaitlistCheckout
1210 && \Yatra\Services\WaitlistService::isInsufficientSeats($resolvedAvailabilityForWaitlist, $travelers_count)
1211 ) {
1212 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
1213 $isWaitlistCheckout = true;
1214 } else {
1215 return new WP_REST_Response([
1216 'success' => false,
1217 'message' => __('This departure is full. Waitlist is not available for this trip or date.', 'yatra'),
1218 'code' => 'sold_out',
1219 ], 400);
1220 }
1221 }
1222
1223 // Generate booking reference using the same method as BookingService
1224 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1225 $booking_reference = $bookingRepository->generateReference();
1226
1227 Logger::debug('Yatra booking create: pricing and payment selection', [
1228 'context' => 'booking_create_rest',
1229 'trip_id' => $trip_id,
1230 'booking_reference' => $booking_reference,
1231 'flexible_payments_enabled' => (bool) apply_filters('yatra_flexible_payments_enabled', false),
1232 'payment_method' => $payment_method,
1233 'payment_gateway' => $payment_gateway,
1234 'total_amount' => round((float) $total_amount, 4),
1235 'amount_due' => round((float) $amount_due, 4),
1236 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20, ['trip_id' => $trip_id]),
1237 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30, ['trip_id' => $trip_id]),
1238 ]);
1239
1240 // Prepare contact data
1241 $contact_data = [
1242 'first_name' => sanitize_text_field($contact_first_name),
1243 'last_name' => sanitize_text_field($contact_last_name),
1244 'email' => sanitize_email($contact_email),
1245 'phone' => sanitize_text_field($contact_phone),
1246 'country' => sanitize_text_field($contact_country),
1247 'nationality' => sanitize_text_field($contact_nationality),
1248 'address' => sanitize_text_field($contact_address),
1249 ];
1250
1251 // Prepare emergency contact data
1252 $emergency_data = [
1253 'name' => sanitize_text_field($emergency_name),
1254 'phone' => sanitize_text_field($emergency_phone),
1255 'relationship' => sanitize_text_field($emergency_relationship),
1256 ];
1257
1258 // Sanitize travelers data
1259 $sanitized_travelers = [];
1260 foreach ($travelers as $traveler) {
1261 if (is_array($traveler)) {
1262 $sanitized_traveler = [];
1263 foreach ($traveler as $key => $value) {
1264 $sk = sanitize_key((string) $key);
1265 if (is_array($value)) {
1266 $sanitized_traveler[$sk] = array_map(static function ($v) {
1267 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1268 }, $value);
1269 } else {
1270 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1271 }
1272 }
1273 $sanitized_travelers[] = $sanitized_traveler;
1274 }
1275 }
1276
1277 // ========================================
1278 // CREATE WORDPRESS USER ACCOUNT (if requested)
1279 // ========================================
1280 $user_id = get_current_user_id();
1281 $create_account = !empty($data['create_account']) && !empty($data['account_password']);
1282
1283 if (!$user_id && $create_account && !empty($data['account_password'])) {
1284 $account_password = sanitize_text_field($data['account_password']);
1285 $account_password_confirm = sanitize_text_field($data['account_password_confirm'] ?? '');
1286
1287 // Validate password
1288 if (strlen($account_password) < 8) {
1289 return new WP_REST_Response([
1290 'success' => false,
1291 'message' => __('Password must be at least 8 characters long.', 'yatra'),
1292 ], 400);
1293 }
1294
1295 if ($account_password !== $account_password_confirm) {
1296 return new WP_REST_Response([
1297 'success' => false,
1298 'message' => __('Passwords do not match.', 'yatra'),
1299 ], 400);
1300 }
1301
1302 // Check if user already exists
1303 if (email_exists($contact_email)) {
1304 return new WP_REST_Response([
1305 'success' => false,
1306 'message' => __('An account with this email already exists. Please log in.', 'yatra'),
1307 ], 400);
1308 }
1309
1310 // Create WordPress user
1311 $username = sanitize_user(current(explode('@', $contact_email)));
1312 $original_username = $username;
1313 $counter = 1;
1314
1315 while (username_exists($username)) {
1316 $username = $original_username . $counter;
1317 $counter++;
1318 }
1319
1320 $user_id = wp_create_user($username, $account_password, $contact_email);
1321
1322 if (is_wp_error($user_id)) {
1323 return new WP_REST_Response([
1324 'success' => false,
1325 'message' => wp_strip_all_tags($user_id->get_error_message()),
1326 ], 400);
1327 }
1328
1329 // Assign Yatra Customer role
1330 $user = new \WP_User($user_id);
1331 $user->set_role('yatra_customer');
1332
1333 // Update user meta with contact information
1334 wp_update_user([
1335 'ID' => $user_id,
1336 'first_name' => $contact_data['first_name'],
1337 'last_name' => $contact_data['last_name'],
1338 'display_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
1339 ]);
1340
1341 if (!empty($contact_phone)) {
1342 update_user_meta($user_id, 'billing_phone', $contact_phone);
1343 update_user_meta($user_id, 'phone', $contact_phone);
1344 }
1345
1346 if (!empty($contact_country)) {
1347 update_user_meta($user_id, 'billing_country', $contact_country);
1348 }
1349
1350 if (!empty($contact_address)) {
1351 update_user_meta($user_id, 'billing_address_1', $contact_address);
1352 }
1353
1354 // Auto-login the user
1355 wp_set_current_user($user_id);
1356 wp_set_auth_cookie($user_id);
1357 }
1358
1359 // ========================================
1360 // CREATE OR UPDATE CUSTOMER
1361 // ========================================
1362 // Customers are separate from WordPress users - this is for CRM purposes
1363 $customer_id = null;
1364 try {
1365 $customer_id = $this->customerRepository->findOrCreate([
1366 'user_id' => $user_id ?: null,
1367 'first_name' => $contact_data['first_name'],
1368 'last_name' => $contact_data['last_name'],
1369 'email' => $contact_data['email'],
1370 'phone' => $contact_data['phone'],
1371 'address' => $contact_data['address'],
1372 'country' => $contact_data['country'],
1373 'nationality' => $contact_data['nationality'],
1374 'emergency_name' => $emergency_data['name'],
1375 'emergency_phone' => $emergency_data['phone'],
1376 'emergency_relationship' => $emergency_data['relationship'],
1377 'newsletter_optin' => !empty($data['subscribe_newsletter']),
1378 'total_spent' => $total_amount,
1379 'source' => 'booking',
1380 ]);
1381 } catch (\Exception $e) {
1382 // Log error but continue - customer creation is not critical
1383 }
1384
1385 // Create booking using BookingService
1386 $booking_service = new \Yatra\Services\BookingService();
1387
1388 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1389
1390 // Ensure DB availability row is linked so inventory sync can subtract seats after booking.
1391 if (!$isWaitlistCheckout && empty($availability_id) && $trip_id > 0 && $travel_date !== '') {
1392 try {
1393 $dtForResolve = is_string($departure_time) ? trim($departure_time) : '';
1394 $dtForResolve = $dtForResolve !== '' ? $dtForResolve : null;
1395 $resolvedCheckout = (new \Yatra\Services\AvailabilityResolutionService())->resolveAvailabilityForDate(
1396 $trip_id,
1397 sanitize_text_field($travel_date),
1398 $dtForResolve
1399 );
1400 if (is_object($resolvedCheckout)
1401 && ($resolvedCheckout->source ?? '') === 'availability_date'
1402 && isset($resolvedCheckout->id)
1403 && is_numeric($resolvedCheckout->id)
1404 && (int) $resolvedCheckout->id > 0) {
1405 $availability_id = (int) $resolvedCheckout->id;
1406 }
1407 } catch (\Throwable $e) {
1408 // Recurring / trip-default checkout: no single availability_dates row
1409 }
1410 }
1411
1412 // Always defer createBooking's copy: session sends one rich confirmation at the end, or
1413 // transactional confirmation before payment redirect (see processPaymentGateway returns).
1414 $booking_data = [
1415 'reference' => $booking_reference,
1416 'trip_id' => $trip_id,
1417 'customer_id' => $customer_id,
1418 'user_id' => $user_id ?: null,
1419 'contact_first_name' => $contact_data['first_name'],
1420 'contact_last_name' => $contact_data['last_name'],
1421 'contact_email' => $contact_data['email'],
1422 'contact_phone' => $contact_data['phone'],
1423 'contact_country' => $contact_data['country'],
1424 'contact_data' => wp_json_encode($contact_data),
1425 'emergency_contact' => wp_json_encode($emergency_data),
1426 'travel_date' => sanitize_text_field($travel_date),
1427 'availability_id' => !empty($availability_id) ? (int) $availability_id : null,
1428 'departure_time' => is_string($departure_time) ? trim($departure_time) : '',
1429 'travelers_count' => $travelers_count,
1430 'total_amount' => $total_amount,
1431 'amount_paid' => 0,
1432 'amount_due' => $amount_due,
1433 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1434 'discount_amount' => $discount_amount,
1435 'discount_code' => $discount_code,
1436 'payment_method' => $payment_method,
1437 'payment_gateway' => $payment_gateway,
1438 'status' => 'pending',
1439 'special_requests' => sanitize_textarea_field($data['special_requests'] ?? ''),
1440 'newsletter_optin' => !empty($data['subscribe_newsletter']) ? 1 : 0,
1441 'ip_address' => $this->getClientIp(),
1442 'created_at' => current_time('mysql'),
1443 'updated_at' => current_time('mysql'),
1444 // Tax fields
1445 'subtotal' => $subtotal_before_discount,
1446 'tax_amount' => $total_tax_amount,
1447 'tax_rate' => $tax_rate,
1448 'tax_inclusive' => $tax_inclusive,
1449 'tax_details' => wp_json_encode($tax_breakdown),
1450 // Itinerary costs
1451 'itinerary_costs' => wp_json_encode($pricing['itinerary_costs'] ?? []),
1452 'itinerary_costs_total' => ($pricing['itinerary_costs_total'] ?? 0),
1453 'skip_initial_customer_confirmation' => true,
1454 ];
1455
1456 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1457 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1458 $booking_data['status'] = 'waitlist';
1459 $booking_data['payment_gateway'] = 'pay_later';
1460 $booking_data['payment_method'] = 'full';
1461 }
1462
1463 // Hold the booking in `pending_verification` until the guest
1464 // clicks the magic link. Payment is initiated only after the
1465 // status flips to 'pending' (in the verify-email endpoint).
1466 // We also pin the gateway to `pay_later` here because the
1467 // payment selection at this point would otherwise lock the
1468 // operator into a specific gateway before the customer has
1469 // even confirmed their email — better to defer that choice
1470 // until verification completes and the regular checkout
1471 // resumes.
1472 if ($needs_email_verification && !$isWaitlistCheckout) {
1473 $booking_data['status'] = 'pending_verification';
1474 $booking_data['payment_gateway'] = 'pay_later';
1475 $booking_data['payment_method'] = 'full';
1476 }
1477
1478 try {
1479 $booking = $booking_service->createBooking($booking_data);
1480 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1481 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
1482 if (empty($booking['success'])) {
1483 return new WP_REST_Response([
1484 'success' => false,
1485 'message' => $booking['message'] ?? __('Failed to create booking. Please try again.', 'yatra'),
1486 'error' => $booking['message'] ?? '',
1487 'errors' => $booking['errors'] ?? null,
1488 ], 500);
1489 }
1490 } catch (\Exception $e) {
1491 return new WP_REST_Response([
1492 'success' => false,
1493 'message' => __('Failed to create booking. Please try again.', 'yatra'),
1494 'error' => $e->getMessage(),
1495 ], 500);
1496 }
1497
1498 if (empty($booking_id)) {
1499 return new WP_REST_Response([
1500 'success' => false,
1501 'message' => __('Failed to create booking. Please try again.', 'yatra'),
1502 'error' => __('Booking ID was not generated.', 'yatra'),
1503 ], 500);
1504 }
1505
1506 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1507 \Yatra\Services\WaitlistService::incrementAvailabilityWaitlistCount(
1508 (int) $resolvedAvailabilityForWaitlist->id,
1509 $travelers_count
1510 );
1511 }
1512
1513 // Get the actual booking reference from database
1514 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1515 $saved_booking = $bookingRepository->find($booking_id);
1516 if ($saved_booking && !empty($saved_booking->reference)) {
1517 $booking_reference = $saved_booking->reference;
1518 }
1519
1520 // ========================================
1521 // SAVE ADDITIONAL SERVICES (Premium Feature)
1522 // ========================================
1523 /**
1524 * Action: Save additional services for the booking
1525 * Allows premium modules to save selected services with the booking
1526 *
1527 * @param int $booking_id The booking ID
1528 * @param int $trip_id The trip ID
1529 * @param array $data The booking request data (contains selected_services)
1530 * @param int $travelers_count Total number of travelers
1531 * @param int $duration_days Trip duration in days
1532 * @since 3.0.0
1533 */
1534 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1535 if (!isset($data['selected_services'])) {
1536 $data['selected_services'] = $data['additional_services']
1537 ?? $session['additional_services']
1538 ?? [];
1539 }
1540 if (!is_array($data['selected_services'])) {
1541 $data['selected_services'] = [];
1542 }
1543 $data['selected_services'] = array_map('intval', $data['selected_services']);
1544 do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
1545
1546 // ========================================
1547 // SAVE TRAVELLERS TO NORMALIZED TABLES
1548 // ========================================
1549 // Each traveller is saved to yatra_booking_travellers table
1550 // Their dynamic fields are saved to yatra_booking_traveller_meta table
1551 foreach ($sanitized_travelers as $index => $traveler_fields) {
1552 // First traveller (index 0) is always the lead traveller
1553 $is_lead = ($index === 0);
1554
1555 // Create traveller record with all their fields stored in meta
1556 $this->travellerRepository->create(
1557 $booking_id,
1558 $index,
1559 $is_lead,
1560 $traveler_fields
1561 );
1562 }
1563
1564 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1565 yatra_clear_booking_session();
1566 if ($settings['booking_confirmation']) {
1567 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1568 }
1569
1570 return new WP_REST_Response([
1571 'success' => true,
1572 'message' => __('You are on the waitlist. We will contact you if a space opens up.', 'yatra'),
1573 'data' => [
1574 'booking_id' => $booking_id,
1575 'reference' => $booking_reference,
1576 'status' => 'waitlist',
1577 'waitlist' => true,
1578 'redirect_url' => $this->getConfirmationUrl($booking_reference),
1579 'customer_email' => $contact_data['email'],
1580 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
1581 'trip_id' => $trip_id,
1582 'trip_date' => $travel_date,
1583 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1584 'total_amount' => $total_amount,
1585 'amount_due' => $amount_due,
1586 ],
1587 ]);
1588 }
1589
1590 // Departure link + booked_count: handled inside BookingService::createBooking (and inventory sync hooks).
1591 // Persist travel window on the booking row for reporting (optional columns).
1592 if (!empty($travel_date)) {
1593 try {
1594 $start_date = $travel_date;
1595 $duration_days = !empty($trip->duration_days) ? (int) $trip->duration_days : 1;
1596 $end_date = date('Y-m-d', strtotime($start_date . ' + ' . ($duration_days - 1) . ' days'));
1597 $bookingColumns = $this->bookingRepository->getTableColumns();
1598 $bookingUpdateData = [];
1599 if (in_array('start_date', $bookingColumns, true)) {
1600 $bookingUpdateData['start_date'] = $start_date;
1601 }
1602 if (in_array('end_date', $bookingColumns, true)) {
1603 $bookingUpdateData['end_date'] = $end_date;
1604 }
1605 if ($bookingUpdateData !== []) {
1606 $this->bookingRepository->update($booking_id, $bookingUpdateData);
1607 }
1608 } catch (\Exception $e) {
1609 // Non-fatal
1610 }
1611 }
1612
1613 // Clear booking session
1614 yatra_clear_booking_session();
1615
1616 // ========================================
1617 // GUEST EMAIL VERIFICATION — INTERCEPT
1618 // ========================================
1619 // Booking row + travelers + services are already saved at
1620 // this point with status='pending_verification'. Send the
1621 // magic-link email, return a structured "check your email"
1622 // response, and DO NOT initiate payment. The customer's
1623 // click on the verify-email endpoint transitions the booking
1624 // to 'pending' and emits the payment-continuation URL.
1625 if ($needs_email_verification) {
1626 $verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl(
1627 (int) $booking_id,
1628 (string) $contact_data['email']
1629 );
1630
1631 // Variables piped into the template email. All standard
1632 // booking merge tags resolve normally (the row exists);
1633 // we also pass intro_paragraph + footer_note + the
1634 // expiry banner so operators that haven't customised
1635 // the template still get good defaults.
1636 $email_vars = [];
1637 if ($saved_booking !== null) {
1638 $email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
1639 }
1640 // Belt-and-braces: ensure customer name + email are
1641 // populated even when variablesFromBooking returned an
1642 // empty shell (which shouldn't happen, but if it does
1643 // we don't want the email to render "Hi ,").
1644 $email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']);
1645 $email_vars['customer_name'] = (string) ($email_vars['customer_name']
1646 ?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? '')));
1647 $email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name']
1648 ?? ($contact_data['first_name'] ?? ''));
1649 $email_vars['verification_link'] = $verify_url;
1650 $email_vars['intro_paragraph'] = __(
1651 "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.",
1652 'yatra'
1653 );
1654 $email_vars['footer_note'] = __(
1655 "If you didn't make this booking, you can safely ignore this email — no charges have been made.",
1656 'yatra'
1657 );
1658 $email_vars['expiry_notice_html'] = '<strong>'
1659 . esc_html__('This link expires in 48 hours.', 'yatra')
1660 . '</strong>';
1661
1662 \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
1663 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
1664 (string) $contact_data['email'],
1665 $email_vars
1666 );
1667
1668 return new WP_REST_Response([
1669 'success' => true,
1670 'code' => 'email_verification_required',
1671 'message' => __(
1672 "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.",
1673 'yatra'
1674 ),
1675 'data' => [
1676 'booking_id' => $booking_id,
1677 'reference' => $booking_reference,
1678 'email' => $contact_data['email'],
1679 'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600),
1680 ],
1681 ]);
1682 }
1683
1684 // Check if this is an offline gateway
1685 $is_offline = $is_offline_gateway;
1686
1687 // For online gateways, create payment intent and return redirect URL
1688 if (!$is_offline && $amount_due > 0) {
1689 // Build payment params - merge with request data so gateways can access their own tokens
1690 $payment_params = array_merge($data, [
1691 'booking_id' => $booking_id,
1692 'reference' => $booking_reference,
1693 'amount' => $amount_due,
1694 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1695 'customer_email' => $contact_data['email'],
1696 'customer_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
1697 'trip_title' => $trip->title,
1698 ]);
1699
1700 // Process payment based on gateway
1701 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
1702
1703 if ($payment_result['success']) {
1704 // Handle redirect-based gateways (PayPal, eSewa, Khalti, etc.)
1705 if (!empty($payment_result['payment_url'])) {
1706 if ($settings['booking_confirmation']) {
1707 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1708 }
1709 return new WP_REST_Response([
1710 'success' => true,
1711 'message' => __('Booking created. Redirecting to payment...', 'yatra'),
1712 'data' => [
1713 'booking_id' => $booking_id,
1714 'reference' => $booking_reference,
1715 'payment_url' => $payment_result['payment_url'],
1716 ],
1717 ]);
1718 }
1719
1720 // Handle client-side payment gateways (Stripe, Razorpay, Square, etc.)
1721 if (!empty($payment_result['requires_action'])) {
1722 if ($settings['booking_confirmation']) {
1723 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1724 }
1725 return new WP_REST_Response([
1726 'success' => true,
1727 'message' => __('Booking created. Complete payment...', 'yatra'),
1728 'data' => array_merge([
1729 'booking_id' => $booking_id,
1730 'reference' => $booking_reference,
1731 ], $payment_result),
1732 ]);
1733 }
1734 }
1735
1736 // If payment processing failed, return error so user can fix the issue
1737 if (!$payment_result['success']) {
1738 $errorMessage = $payment_result['error'] ?? $payment_result['message'] ?? __('Payment processing failed. Please try again.', 'yatra');
1739 return new WP_REST_Response([
1740 'success' => false,
1741 'message' => $errorMessage,
1742 'data' => [
1743 'booking_id' => $booking_id,
1744 'reference' => $booking_reference,
1745 'payment_error' => true,
1746 ],
1747 ]);
1748 }
1749 }
1750
1751 // ========================================
1752 // DETERMINE BOOKING STATUS
1753 // ========================================
1754 // Priority:
1755 // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
1756 // 2. For pay_later: auto_confirm_pay_later setting
1757 // 3. For bank_transfer: always pending until verified
1758
1759 $booking_status = 'pending';
1760 $status_message = __('Booking received!', 'yatra');
1761
1762 // Check if auto-confirm all bookings is enabled
1763 if ($settings['auto_confirm_bookings']) {
1764 // Auto-confirm is enabled - confirm immediately regardless of payment
1765 $booking_status = 'confirmed';
1766 $status_message = __('Booking confirmed!', 'yatra');
1767 } elseif ($payment_gateway === 'pay_later') {
1768 // Pay Later: Check the specific pay_later auto-confirm setting
1769 if ($settings['auto_confirm_pay_later']) {
1770 $booking_status = 'confirmed';
1771 $status_message = __('Booking confirmed! Payment will be collected later.', 'yatra');
1772 } else {
1773 $booking_status = 'pending';
1774 $status_message = __('Booking received! We will contact you to arrange payment.', 'yatra');
1775 }
1776 } elseif ($payment_gateway === 'bank_transfer') {
1777 // Bank Transfer: Always pending until payment is verified by admin
1778 $booking_status = 'pending';
1779 $status_message = __('Booking received! Please complete the bank transfer. We will confirm once payment is verified.', 'yatra');
1780 }
1781
1782 // Calculate booking expiry time for pending bookings
1783 $expiry_datetime = null;
1784 if ($booking_status === 'pending' && $settings['booking_expiry_hours'] > 0) {
1785 $expiry_datetime = date('Y-m-d H:i:s', strtotime('+' . $settings['booking_expiry_hours'] . ' hours'));
1786 }
1787
1788 // Set confirmed_at if auto-confirmed
1789 $confirmed_at = ($booking_status === 'confirmed') ? current_time('mysql') : null;
1790
1791 // Update booking status with additional metadata
1792 $update_data = [
1793 'status' => $booking_status,
1794 'payment_status' => 'pending', // No payment made yet for offline gateways
1795 ];
1796
1797 if ($confirmed_at) {
1798 $update_data['confirmed_at'] = $confirmed_at;
1799 }
1800
1801 if ($expiry_datetime) {
1802 $update_data['expires_at'] = $expiry_datetime;
1803 }
1804
1805 // Use repository to update booking
1806 $this->bookingRepository->update($booking_id, $update_data);
1807
1808 /**
1809 * yatra_booking_created already fired from BookingService::createBooking — do not fire again here
1810 * (duplicate admin + Pro automation).
1811 *
1812 * Synthetic pending→confirmed on the same request duplicates Pro "booking.confirmed" sequences with the
1813 * checkout confirmation email. Skip by default; restore with:
1814 * add_filter('yatra_skip_checkout_autoconfirm_status_changed_event', '__return_false');
1815 */
1816 if ($booking_status === 'confirmed') {
1817 if (!apply_filters('yatra_skip_checkout_autoconfirm_status_changed_event', true, (int) $booking_id, 'pending', 'confirmed')) {
1818 do_action('yatra_booking_status_changed', (int) $booking_id, 'pending', 'confirmed');
1819 }
1820 // Trip Consent / Google Calendar listen on `yatra_booking_confirmed` (not fired by status_changed skip above).
1821 \yatra_trigger_booking_confirmed((int) $booking_id, 'pending');
1822 }
1823
1824 // ========================================
1825 // SEND CONFIRMATION EMAIL
1826 // ========================================
1827 if ($settings['booking_confirmation']) {
1828 $this->sendBookingConfirmationEmail($booking_id, $booking_reference, $trip, [
1829 'contact' => $contact_data,
1830 'emergency' => $emergency_data,
1831 'travelers' => $sanitized_travelers,
1832 'travel_date' => $travel_date,
1833 'payment_method' => $payment_method,
1834 'payment_gateway' => $payment_gateway,
1835 'total_amount' => $total_amount,
1836 'amount_due' => $amount_due,
1837 'booking_status' => $booking_status,
1838 'expiry_datetime' => $expiry_datetime,
1839 ]);
1840 }
1841
1842 return new WP_REST_Response([
1843 'success' => true,
1844 'message' => $status_message,
1845 'data' => [
1846 'booking_id' => $booking_id,
1847 'reference' => $booking_reference,
1848 'status' => $booking_status,
1849 'payment_status' => 'pending',
1850 'redirect_url' => $this->getConfirmationUrl($booking_reference),
1851 'customer_email' => $contact_data['email'],
1852 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
1853 'trip_id' => $trip_id,
1854 'trip_date' => $travel_date,
1855 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1856 'amount' => $amount_due,
1857 'subtotal' => $subtotal_before_discount,
1858 'discount_amount' => $discount_amount,
1859 'discount_code' => $discount_code,
1860 'total_amount' => $total_amount,
1861 ],
1862 ]);
1863 }
1864
1865
1866 /**
1867 * Get confirmation page URL (see yatra_get_booking_confirmation_url()).
1868 */
1869 private function getConfirmationUrl(string $reference): string
1870 {
1871 return yatra_get_booking_confirmation_url($reference);
1872 }
1873
1874 /**
1875 * Whether the gateway completes without an external payment step (registry flag + fallback).
1876 */
1877 private function isOfflineGateway(string $gatewayId): bool
1878 {
1879 try {
1880 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
1881 $gateway = $registry->get($gatewayId);
1882 if ($gateway) {
1883 return $gateway->isOffline();
1884 }
1885 } catch (\Throwable $e) {
1886 // Fall through to legacy IDs
1887 }
1888
1889 return in_array($gatewayId, ['pay_later', 'bank_transfer'], true);
1890 }
1891
1892 /**
1893 * Pay balance due on an existing booking only.
1894 *
1895 * Does not call BookingService::createBooking() or insert a second booking row.
1896 * Initiates gateway flow with the stored booking_id; on success, payment is recorded
1897 * via PaymentGatewayController::handle_successful_payment, webhooks, or recordGatewayPayment
1898 * — same completion paths as initial checkout.
1899 */
1900 private function process_remaining_payment(WP_REST_Request $request): WP_REST_Response
1901 {
1902 $data = $request->get_json_params();
1903 if (!is_array($data)) {
1904 $data = [];
1905 }
1906 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
1907
1908 $session = yatra_get_remaining_session();
1909
1910 $booking_id = (int) ($session['booking_id'] ?? 0);
1911 $booking_reference = (string) ($session['booking_reference'] ?? '');
1912 $currency = (string) ($session['currency'] ?? '');
1913 $contact_email = (string) ($session['contact_email'] ?? '');
1914 $contact_first_name = (string) ($session['contact_first_name'] ?? '');
1915 $contact_last_name = (string) ($session['contact_last_name'] ?? '');
1916 $trip_id = (int) ($session['trip_id'] ?? 0);
1917 $trip_title = (string) ($session['trip_title'] ?? '');
1918 $travel_date = (string) ($session['travel_date'] ?? '');
1919
1920 if ($booking_id <= 0) {
1921 return new WP_REST_Response([
1922 'success' => false,
1923 'message' => __('Invalid booking for remaining payment.', 'yatra'),
1924 ], 400);
1925 }
1926
1927 $booking = $this->bookingRepository->find($booking_id);
1928 if (!$booking) {
1929 yatra_clear_remaining_session();
1930 return new WP_REST_Response([
1931 'success' => false,
1932 'message' => __('Booking not found.', 'yatra'),
1933 ], 404);
1934 }
1935
1936 if ($booking_reference === '' && !empty($booking->reference)) {
1937 $booking_reference = (string) $booking->reference;
1938 }
1939
1940 // Authoritative balance from DB (do not rely on session alone)
1941 $remaining_amount = (float) ($booking->amount_due ?? 0);
1942 if ($remaining_amount <= 0 && isset($booking->total_amount)) {
1943 $remaining_amount = max(
1944 0,
1945 (float) $booking->total_amount - (float) ($booking->amount_paid ?? 0)
1946 );
1947 }
1948
1949 if ($remaining_amount <= 0) {
1950 yatra_clear_remaining_session();
1951 return new WP_REST_Response([
1952 'success' => false,
1953 'message' => __('This booking is already fully paid.', 'yatra'),
1954 ], 400);
1955 }
1956
1957 // Verify user owns this booking
1958 $current_user = get_current_user_id();
1959 if ($current_user && (int) $booking->user_id !== $current_user) {
1960 yatra_clear_remaining_session();
1961 return new WP_REST_Response([
1962 'success' => false,
1963 'message' => __('You do not have permission to pay for this booking.', 'yatra'),
1964 ], 403);
1965 }
1966
1967 if ($currency === '') {
1968 $currency = (string) ($booking->currency ?? SettingsService::getCurrency());
1969 }
1970
1971 // Use contact info from session or booking
1972 $customer_email = $contact_email !== '' ? $contact_email : (string) ($booking->contact_email ?? $booking->customer_email ?? '');
1973 $customer_name = trim($contact_first_name . ' ' . $contact_last_name);
1974 if ($customer_name === '') {
1975 $customer_name = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
1976 }
1977
1978 if ($customer_email === '') {
1979 return new WP_REST_Response([
1980 'success' => false,
1981 'message' => __('Email address is required.', 'yatra'),
1982 ], 400);
1983 }
1984
1985 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1986
1987 // Server-side guard: offline gateways (Pay Later, Bank Transfer, etc.) don't
1988 // actually collect money. Letting them be selected for a remaining-balance
1989 // payment leaves the booking unpaid while the customer thinks they finished
1990 // the flow. The frontend already filters them out for this checkout — this
1991 // catches a tampered client. Filterable so a custom Pay Later that does
1992 // settle can opt back in.
1993 $allow_offline_for_remaining = (bool) apply_filters(
1994 'yatra_remaining_payment_allow_offline_gateway',
1995 false,
1996 $payment_gateway,
1997 $booking
1998 );
1999 if ($is_offline_gateway && !$allow_offline_for_remaining) {
2000 return new WP_REST_Response([
2001 'success' => false,
2002 'message' => __('This payment method cannot be used to settle a remaining balance. Please choose a card-based gateway.', 'yatra'),
2003 'data' => [
2004 'rejected_gateway' => $payment_gateway,
2005 'reason' => 'offline_not_allowed_for_remaining_payment',
2006 ],
2007 ], 400);
2008 }
2009
2010 // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.).
2011 // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely.
2012 if (!$is_offline_gateway && $remaining_amount > 0) {
2013 // The gateway needs an explicit return URL with the `balance=paid` flag so the
2014 // confirmation page can show "balance just paid" content. Without setting it
2015 // here, processPaymentWithGateway() would fall back to a plain confirmation URL
2016 // and the customer would land on the generic post-booking template.
2017 $remaining_return_url = add_query_arg(
2018 'balance',
2019 'paid',
2020 $this->getConfirmationUrl($booking_reference)
2021 );
2022
2023 $payment_params = array_merge($data, [
2024 'booking_id' => $booking_id,
2025 'reference' => $booking_reference,
2026 'amount' => $remaining_amount,
2027 'currency' => $currency,
2028 'customer_email' => $customer_email,
2029 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email,
2030 'trip_title' => $trip_title,
2031 'return_url' => $remaining_return_url,
2032 ]);
2033
2034 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
2035
2036 if (!empty($payment_result['success'])) {
2037 // Common identity fields the Stripe.js / PayPal SDK frontend expects on
2038 // the response. process_remaining_payment is reached without going through
2039 // the new-booking checkout, so the gateway result alone is missing
2040 // customer_email / customer_name — re-attach them from the booking we
2041 // already loaded above. Also overwrite confirmation_url AND redirect_url
2042 // with the balance-tagged version so the post-payment landing page knows
2043 // this was a remaining-balance flow.
2044 //
2045 // Why both fields: the Stripe.js helper buildConfirmationUrlFromBookingInfo()
2046 // prefers bookingInfo.redirect_url and only falls back to rebuilding the URL
2047 // from scratch (without query params) when redirect_url is absent. Without
2048 // redirect_url being explicitly set here the `?balance=paid` flag would be
2049 // lost on the final navigation after Stripe success.
2050 $remaining_identity_fields = [
2051 'customer_email' => $customer_email,
2052 'customer_name' => $customer_name,
2053 'confirmation_url' => $remaining_return_url,
2054 'redirect_url' => $remaining_return_url,
2055 ];
2056
2057 if (!empty($payment_result['payment_url'])) {
2058 return new WP_REST_Response([
2059 'success' => true,
2060 'message' => __('Redirecting to payment...', 'yatra'),
2061 'data' => array_merge([
2062 'booking_id' => $booking_id,
2063 'reference' => $booking_reference,
2064 'payment_url' => $payment_result['payment_url'],
2065 'is_remaining_payment' => true,
2066 ], $remaining_identity_fields),
2067 ]);
2068 }
2069
2070 if (!empty($payment_result['requires_action'])) {
2071 return new WP_REST_Response([
2072 'success' => true,
2073 'message' => __('Complete payment...', 'yatra'),
2074 'data' => array_merge(
2075 [
2076 'booking_id' => $booking_id,
2077 'reference' => $booking_reference,
2078 'is_remaining_payment' => true,
2079 ],
2080 $payment_result,
2081 $remaining_identity_fields
2082 ),
2083 ]);
2084 }
2085
2086 if (!empty($payment_result['redirect_url'])) {
2087 return new WP_REST_Response([
2088 'success' => true,
2089 'message' => __('Payment processed.', 'yatra'),
2090 'data' => array_merge([
2091 'booking_id' => $booking_id,
2092 'reference' => $booking_reference,
2093 'redirect_url' => $payment_result['redirect_url'],
2094 'is_remaining_payment' => true,
2095 ], $remaining_identity_fields),
2096 ]);
2097 }
2098 }
2099
2100 $err = $payment_result['message'] ?? $payment_result['error'] ?? __('Payment processing failed. Please try again.', 'yatra');
2101
2102 return new WP_REST_Response([
2103 'success' => false,
2104 'message' => $err,
2105 'data' => [
2106 'payment_error' => true,
2107 'booking_id' => $booking_id,
2108 ],
2109 ], 400);
2110 }
2111
2112 // Offline gateways: no external redirect — confirmation page only.
2113 // Append `balance=paid` so the confirmation template renders the
2114 // remaining-payment-specific banner ("balance received, fully paid")
2115 // instead of the generic "booking confirmed" copy.
2116 yatra_clear_remaining_session();
2117
2118 $offline_redirect = add_query_arg(
2119 'balance',
2120 'paid',
2121 $this->getConfirmationUrl($booking_reference)
2122 );
2123
2124 return new WP_REST_Response([
2125 'success' => true,
2126 'message' => __('Continue to confirmation.', 'yatra'),
2127 'data' => [
2128 'booking_id' => $booking_id,
2129 'reference' => $booking_reference,
2130 'trip_id' => $trip_id,
2131 'trip_title' => $trip_title,
2132 'trip_date' => $travel_date,
2133 'currency' => $currency,
2134 'amount' => $remaining_amount,
2135 'customer_email' => $customer_email,
2136 'customer_name' => $customer_name,
2137 'redirect_url' => $offline_redirect,
2138 'is_remaining_payment' => true,
2139 ],
2140 ]);
2141 }
2142
2143 /**
2144 * Process payment through the selected gateway
2145 */
2146 private function processPaymentGateway(string $gateway, array $params): array
2147 {
2148 // Debug logging
2149 if (defined('WP_DEBUG') && WP_DEBUG) {
2150 }
2151
2152 // All gateways use the unified gateway system
2153 return $this->processPaymentWithGateway($gateway, $params);
2154 }
2155
2156 /**
2157 * Process payment using the proper gateway system
2158 */
2159 private function processPaymentWithGateway(string $gatewayId, array $params): array
2160 {
2161 try {
2162 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
2163 $gateway = $registry->get($gatewayId);
2164
2165 if (!$gateway) {
2166 return ['success' => false, 'message' => "Payment gateway '{$gatewayId}' not found"];
2167 }
2168
2169 if (!$gateway->isEnabled()) {
2170 return [
2171 'success' => false,
2172 'message' => __('This payment method is not available.', 'yatra'),
2173 ];
2174 }
2175
2176 if (!$gateway->isProperlyConfigured()) {
2177 return [
2178 'success' => false,
2179 'message' => GatewayUserMessages::gatewayNotConfigured($gateway),
2180 ];
2181 }
2182
2183 // Prepare payment data - pass all params, gateways extract what they need.
2184 // Default return_url to the configured booking confirmation URL so redirect gateways
2185 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2186 // may still append their own query args on top of this URL.
2187 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2188 $paymentData = array_merge($params, [
2189 'description' => $params['trip_title'] ?? '',
2190 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2191 'metadata' => [
2192 'booking_id' => $params['booking_id'],
2193 'reference' => $params['reference'] ?? ''
2194 ]
2195 ]);
2196 if ($ref !== '' && empty($paymentData['return_url'])) {
2197 $paymentData['return_url'] = $this->getConfirmationUrl($ref);
2198 }
2199
2200 // Process the payment through the gateway
2201 $result = $gateway->processPayment($paymentData);
2202
2203 // Debug logging
2204 if (defined('WP_DEBUG') && WP_DEBUG) {
2205 }
2206
2207 if ($result['success']) {
2208 // Save transaction ID for tracking
2209 if (!empty($result['transaction_id'])) {
2210 $this->bookingRepository->updatePaymentSessionId(
2211 (int) $params['booking_id'],
2212 $result['transaction_id']
2213 );
2214 }
2215
2216 // For gateways that require client-side action (Stripe, Razorpay, etc.)
2217 // Payment will be recorded after client completes the action
2218 if (!empty($result['requires_action'])) {
2219 return array_merge(['success' => true], $result);
2220 }
2221
2222 // For gateways that return a redirect URL for external payment (PayPal, eSewa, Khalti)
2223 // Payment will be recorded on callback/return
2224 if (!empty($result['redirect_url']) || !empty($result['payment_url'])) {
2225 // Check if this is a completed payment with redirect (like Square)
2226 // vs pending external payment (like PayPal)
2227 $isCompletedPayment = !empty($result['transaction_id']) &&
2228 (($result['status'] ?? '') === 'completed' || ($result['status'] ?? '') === 'succeeded');
2229
2230 if ($isCompletedPayment) {
2231 $this->recordGatewayPayment($params, $result, $gatewayId);
2232 }
2233
2234 return [
2235 'success' => true,
2236 'payment_url' => $result['redirect_url'] ?? $result['payment_url']
2237 ];
2238 }
2239
2240 // For offline gateways or successful direct payments without redirect
2241 return [
2242 'success' => true,
2243 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2244 ];
2245 }
2246
2247 // Log the payment failure for debugging
2248 if (defined('WP_DEBUG') && WP_DEBUG) {
2249 }
2250
2251 return [
2252 'success' => false,
2253 'message' => $result['message'] ?? $result['error'] ?? 'Payment processing failed. Please try again.'
2254 ];
2255
2256 } catch (\Exception $e) {
2257 // Log the exception for debugging
2258 return [
2259 'success' => false,
2260 'message' => 'An unexpected error occurred. Please try again or contact support.'
2261 ];
2262 }
2263 }
2264
2265 /**
2266 * Record payment from gateway result
2267 * Matches Stripe's completePayment behavior
2268 */
2269 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2270 {
2271 global $wpdb;
2272
2273 try {
2274 $bookingId = (int) $params['booking_id'];
2275 $amount = (float) ($params['amount'] ?? 0);
2276 $currency = $params['currency'] ?? 'USD';
2277 $transactionId = $result['transaction_id'] ?? '';
2278
2279 // Get booking
2280 $booking = $this->bookingRepository->find($bookingId);
2281 if (!$booking || $booking->payment_status === 'paid') {
2282 return;
2283 }
2284
2285 // Record the payment using PaymentRepository
2286 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2287 $payment_id = $paymentRepository->create([
2288 'booking_id' => $bookingId,
2289 'amount' => $amount,
2290 'currency' => $currency,
2291 'gateway' => $gatewayId,
2292 'transaction_id' => $transactionId,
2293 'status' => 'completed',
2294 'created_at' => current_time('mysql'),
2295 ]);
2296
2297 // Calculate total paid
2298 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2299
2300 // Fire payment completed action
2301 do_action('yatra_payment_completed', [
2302 'booking_id' => $bookingId,
2303 'transaction_id' => $transactionId,
2304 'amount' => $amount,
2305 'currency' => $currency,
2306 'gateway' => $gatewayId,
2307 ]);
2308
2309 } catch (\Exception $e) {
2310 }
2311 }
2312
2313 /**
2314 * Process PayPal payment
2315 */
2316 private function processPayPalPayment(array $params, array $config, bool $is_test): array
2317 {
2318 $client_id = $config['client_id'] ?? '';
2319 $client_secret = $config['client_secret'] ?? '';
2320
2321 if (empty($client_id) || empty($client_secret)) {
2322 return ['success' => false, 'message' => 'PayPal credentials not configured'];
2323 }
2324
2325 $base_url = $is_test ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
2326
2327 try {
2328 // Get access token
2329 $auth_response = wp_remote_post($base_url . '/v1/oauth2/token', [
2330 'headers' => [
2331 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret),
2332 'Content-Type' => 'application/x-www-form-urlencoded',
2333 ],
2334 'body' => 'grant_type=client_credentials',
2335 ]);
2336
2337 if (is_wp_error($auth_response)) {
2338 return ['success' => false, 'message' => $auth_response->get_error_message()];
2339 }
2340
2341 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
2342 $access_token = $auth_body['access_token'] ?? '';
2343
2344 if (empty($access_token)) {
2345 return ['success' => false, 'message' => 'Failed to get PayPal access token'];
2346 }
2347
2348 // Create order
2349 $order_response = wp_remote_post($base_url . '/v2/checkout/orders', [
2350 'headers' => [
2351 'Authorization' => 'Bearer ' . $access_token,
2352 'Content-Type' => 'application/json',
2353 ],
2354 'body' => wp_json_encode([
2355 'intent' => 'CAPTURE',
2356 'purchase_units' => [[
2357 'reference_id' => $params['reference'],
2358 'amount' => [
2359 'currency_code' => $params['currency'],
2360 'value' => number_format($params['amount'], 2, '.', ''),
2361 ],
2362 'description' => $params['trip_title'],
2363 ]],
2364 'application_context' => [
2365 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2366 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
2367 ],
2368 ]),
2369 ]);
2370
2371 if (is_wp_error($order_response)) {
2372 return ['success' => false, 'message' => $order_response->get_error_message()];
2373 }
2374
2375 $order_body = json_decode(wp_remote_retrieve_body($order_response), true);
2376
2377 // Find approval link
2378 foreach ($order_body['links'] ?? [] as $link) {
2379 if ($link['rel'] === 'approve') {
2380 // Save order ID for capture later
2381 $this->bookingRepository->updatePaymentSessionId(
2382 (int) $params['booking_id'],
2383 $order_body['id'] ?? ''
2384 );
2385
2386 return ['success' => true, 'payment_url' => $link['href']];
2387 }
2388 }
2389
2390 return ['success' => false, 'message' => 'Failed to create PayPal order'];
2391 } catch (\Exception $e) {
2392 return ['success' => false, 'message' => $e->getMessage()];
2393 }
2394 }
2395
2396 /**
2397 * Process Razorpay payment
2398 */
2399 private function processRazorpayPayment(array $params, array $config, bool $is_test): array
2400 {
2401 $key_id = $config['api_key'] ?? '';
2402 $key_secret = $config['api_secret'] ?? '';
2403
2404 if (empty($key_id) || empty($key_secret)) {
2405 return ['success' => false, 'message' => 'Razorpay credentials not configured'];
2406 }
2407
2408 try {
2409 // Create Razorpay order
2410 $response = wp_remote_post('https://api.razorpay.com/v1/orders', [
2411 'headers' => [
2412 'Authorization' => 'Basic ' . base64_encode($key_id . ':' . $key_secret),
2413 'Content-Type' => 'application/json',
2414 ],
2415 'body' => wp_json_encode([
2416 'amount' => (int) ($params['amount'] * 100), // Amount in paise
2417 'currency' => $params['currency'],
2418 'receipt' => $params['reference'],
2419 'notes' => [
2420 'booking_id' => $params['booking_id'],
2421 'trip' => $params['trip_title'],
2422 ],
2423 ]),
2424 ]);
2425
2426 if (is_wp_error($response)) {
2427 return ['success' => false, 'message' => $response->get_error_message()];
2428 }
2429
2430 $body = json_decode(wp_remote_retrieve_body($response), true);
2431
2432 if (!empty($body['id'])) {
2433 // Save order ID
2434 $this->bookingRepository->updatePaymentSessionId(
2435 (int) $params['booking_id'],
2436 $body['id']
2437 );
2438
2439 // Razorpay requires client-side integration, return data for JS
2440 // Store order details and redirect to a payment page
2441 $payment_url = add_query_arg([
2442 'razorpay_order' => $body['id'],
2443 'booking_ref' => $params['reference'],
2444 'key' => $key_id,
2445 'amount' => (int) ($params['amount'] * 100),
2446 'currency' => $params['currency'],
2447 'name' => get_bloginfo('name'),
2448 'description' => $params['trip_title'],
2449 'email' => $params['customer_email'],
2450 ], home_url('/yatra-payment/razorpay/'));
2451
2452 return ['success' => true, 'payment_url' => $payment_url];
2453 }
2454
2455 return ['success' => false, 'message' => $body['error']['description'] ?? 'Failed to create Razorpay order'];
2456 } catch (\Exception $e) {
2457 return ['success' => false, 'message' => $e->getMessage()];
2458 }
2459 }
2460
2461 /**
2462 * Process eSewa payment
2463 */
2464 private function processEsewaPayment(array $params, array $config, bool $is_test): array
2465 {
2466 $merchant_id = $config['merchant_id'] ?? '';
2467
2468 if (empty($merchant_id)) {
2469 return ['success' => false, 'message' => 'eSewa merchant ID not configured'];
2470 }
2471
2472 $base_url = $is_test ? 'https://uat.esewa.com.np/epay/main' : 'https://esewa.com.np/epay/main';
2473
2474 // eSewa uses form redirect, build URL with parameters
2475 $payment_url = add_query_arg([
2476 'amt' => $params['amount'],
2477 'psc' => 0,
2478 'pdc' => 0,
2479 'txAmt' => 0,
2480 'tAmt' => $params['amount'],
2481 'pid' => $params['reference'],
2482 'scd' => $merchant_id,
2483 'su' => add_query_arg(
2484 ['payment' => 'success', 'gateway' => 'esewa'],
2485 $this->getConfirmationUrl($params['reference'])
2486 ),
2487 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
2488 ], $base_url);
2489
2490 return ['success' => true, 'payment_url' => $payment_url];
2491 }
2492
2493 /**
2494 * Process Khalti payment
2495 */
2496 private function processKhaltiPayment(array $params, array $config, bool $is_test): array
2497 {
2498 $secret_key = $config['api_secret'] ?? '';
2499
2500 if (empty($secret_key)) {
2501 return ['success' => false, 'message' => 'Khalti secret key not configured'];
2502 }
2503
2504 $base_url = $is_test ? 'https://a.khalti.com/api/v2/epayment/initiate/' : 'https://khalti.com/api/v2/epayment/initiate/';
2505
2506 try {
2507 $response = wp_remote_post($base_url, [
2508 'headers' => [
2509 'Authorization' => 'Key ' . $secret_key,
2510 'Content-Type' => 'application/json',
2511 ],
2512 'body' => wp_json_encode([
2513 'return_url' => add_query_arg(
2514 ['payment' => 'success', 'gateway' => 'khalti'],
2515 $this->getConfirmationUrl($params['reference'])
2516 ),
2517 'website_url' => home_url(),
2518 'amount' => (int) ($params['amount'] * 100), // Amount in paisa
2519 'purchase_order_id' => $params['reference'],
2520 'purchase_order_name' => $params['trip_title'],
2521 'customer_info' => [
2522 'name' => $params['customer_name'],
2523 'email' => $params['customer_email'],
2524 ],
2525 ]),
2526 ]);
2527
2528 if (is_wp_error($response)) {
2529 return ['success' => false, 'message' => $response->get_error_message()];
2530 }
2531
2532 $body = json_decode(wp_remote_retrieve_body($response), true);
2533
2534 if (!empty($body['payment_url'])) {
2535 // Save pidx for verification
2536 $this->bookingRepository->updatePaymentSessionId(
2537 (int) $params['booking_id'],
2538 $body['pidx'] ?? ''
2539 );
2540
2541 return ['success' => true, 'payment_url' => $body['payment_url']];
2542 }
2543
2544 return ['success' => false, 'message' => $body['detail'] ?? 'Failed to initiate Khalti payment'];
2545 } catch (\Exception $e) {
2546 return ['success' => false, 'message' => $e->getMessage()];
2547 }
2548 }
2549
2550 /**
2551 * Process Authorize.net payment
2552 */
2553 private function processAuthorizeNetPayment(array $params, array $config, bool $is_test): array
2554 {
2555 // Authorize.net typically requires hosted payment page or client-side integration
2556 // Return URL for hosted payment page setup
2557 return [
2558 'success' => true,
2559 'payment_url' => add_query_arg([
2560 'booking_ref' => $params['reference'],
2561 'amount' => $params['amount'],
2562 'gateway' => 'authorize_net',
2563 ], home_url('/yatra-payment/authorize-net/'))
2564 ];
2565 }
2566
2567 /**
2568 * Get client IP address
2569 */
2570 private function getClientIp(): string
2571 {
2572 $ip_keys = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'];
2573
2574 foreach ($ip_keys as $key) {
2575 if (!empty($_SERVER[$key])) {
2576 $ip = sanitize_text_field($_SERVER[$key]);
2577 if (strpos($ip, ',') !== false) {
2578 $ip = trim(explode(',', $ip)[0]);
2579 }
2580 if (filter_var($ip, FILTER_VALIDATE_IP)) {
2581 return $ip;
2582 }
2583 }
2584 }
2585
2586 return '0.0.0.0';
2587 }
2588
2589 /**
2590 * Send booking confirmation email
2591 */
2592 private function sendBookingConfirmationEmail(int $booking_id, string $reference, object $trip, array $data): void
2593 {
2594 $contact = $data['contact'] ?? [];
2595 $travelers = $data['travelers'] ?? [];
2596 $travel_date = $data['travel_date'] ?? '';
2597 $total_amount = $data['total_amount'] ?? 0;
2598 $amount_due = $data['amount_due'] ?? 0;
2599 $payment_method = $data['payment_method'] ?? 'full';
2600 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2601 $booking_status = $data['booking_status'] ?? 'pending';
2602 // Cancellation copy in the email now comes from the trip's
2603 // own cancellation_policy field (set per-trip on the Trip
2604 // editor), not from removed global settings. Falls back to
2605 // empty so the paragraph is silently omitted when the trip
2606 // doesn't have a policy set.
2607 $trip_cancellation_policy = isset($trip->cancellation_policy)
2608 ? wp_strip_all_tags((string) $trip->cancellation_policy)
2609 : '';
2610 $expiry_datetime = $data['expiry_datetime'] ?? null;
2611
2612 $customer_email = $contact['email'] ?? '';
2613 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
2614
2615 if (empty($customer_email)) {
2616 return;
2617 }
2618
2619 // Format prices using global currency settings
2620 $formatted_total = yatra_format_price($total_amount);
2621 $formatted_due = yatra_format_price($amount_due);
2622
2623 $intro_paragraph = $booking_status === 'confirmed'
2624 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2625 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2626 if ($booking_status === 'pending' && $expiry_datetime) {
2627 $intro_paragraph .= ' ' . sprintf(
2628 /* translators: %s: payment expiry date and time (formatted). */
2629 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2630 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2631 );
2632 }
2633
2634 ob_start();
2635 ?>
2636 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2637 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2638 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2639 <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>
2640 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */
2641 echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
2642 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2643 </div>
2644 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2645 <p><?php /* translators: %s: total amount (formatted). */
2646 echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
2647 <?php if ($payment_method === 'deposit') : ?>
2648 <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
2649 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>
2650 <?php elseif ($payment_method === 'partial') : ?>
2651 <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
2652 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>
2653 <?php else : ?>
2654 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2655 <?php endif; ?>
2656 <?php if ($payment_gateway === 'pay_later') : ?>
2657 <p><?php esc_html_e('Pay later — please contact us to arrange payment.', 'yatra'); ?></p>
2658 <?php elseif ($payment_gateway === 'bank_transfer') : ?>
2659 <p><?php esc_html_e('Bank transfer — you will receive bank details separately.', 'yatra'); ?></p>
2660 <?php endif; ?>
2661 <h3 style="font-size:16px;"><?php esc_html_e('Travelers', 'yatra'); ?></h3>
2662 <ul style="padding-left:20px;">
2663 <?php foreach ($travelers as $i => $traveler) : ?>
2664 <?php
2665 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2666 ?>
2667 <li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */
2668 echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '')); ?></li>
2669 <?php endforeach; ?>
2670 </ul>
2671 <?php
2672 // Cancellation policy paragraph now sources from the trip's
2673 // per-trip cancellation_policy field (set on the Trip
2674 // editor). The previous version used global cancellation
2675 // settings that were display-only — they appeared here but
2676 // never enforced a real cancellation cutoff. We've removed
2677 // those settings; if the trip itself doesn't define a
2678 // policy, the whole section is silently omitted so the
2679 // email isn't padded with empty headings.
2680 if ($trip_cancellation_policy !== '') {
2681 ?>
2682 <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
2683 <p><?php echo esc_html($trip_cancellation_policy); ?></p>
2684 <?php
2685 }
2686 ?>
2687 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2688 <ol style="padding-left:20px;">
2689 <li><?php esc_html_e('You will receive a detailed trip itinerary within 24–48 hours.', 'yatra'); ?></li>
2690 <li><?php esc_html_e('Our team will contact you to confirm any special requirements.', 'yatra'); ?></li>
2691 <li><?php esc_html_e('Please ensure travel documents meet entry requirements for your destination.', 'yatra'); ?></li>
2692 </ol>
2693 <p><?php esc_html_e('If you have any questions, contact us anytime.', 'yatra'); ?></p>
2694 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2695 <?php
2696 $details_html = ob_get_clean();
2697
2698 $vars = [
2699 'customer_name' => $customer_name,
2700 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2701 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2702 'customer_email' => $customer_email,
2703 'customer_phone' => (string) ($contact['phone'] ?? ''),
2704 'booking_reference' => $reference,
2705 'booking_id' => (string) $booking_id,
2706 'trip_name' => (string) $trip->title,
2707 'trip_url' => home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) ($trip->slug ?? '')) . '/'),
2708 'travel_date' => date_i18n(get_option('date_format'), strtotime($travel_date)),
2709 'travelers_count' => (string) count($travelers),
2710 'total_amount_formatted' => $formatted_total,
2711 'amount_due_formatted' => $formatted_due,
2712 'currency' => SettingsService::getCurrency(),
2713 'intro_paragraph' => $intro_paragraph,
2714 'details_html' => $details_html,
2715 'details_html_only' => '1',
2716 /* translators: %s: site name. */
2717 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2718 'transactional_context' => 'booking_created',
2719 ];
2720
2721 TransactionalEmailTemplateService::sendIfEnabled(
2722 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2723 $customer_email,
2724 $vars
2725 );
2726
2727 // Admin new-booking email is sent from NotificationService (yatra_booking_created) using
2728 // Email → Templates → Admin: New booking, to avoid duplicate messages.
2729 }
2730
2731 /**
2732 * Apply coupon code to booking session
2733 */
2734 public function apply_coupon(WP_REST_Request $request): WP_REST_Response
2735 {
2736 yatra_start_session();
2737
2738 $data = $request->get_json_params() ?? [];
2739 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2740
2741 if (empty($code)) {
2742 return new WP_REST_Response([
2743 'success' => false,
2744 'message' => __('Please enter a coupon code.', 'yatra'),
2745 ], 400);
2746 }
2747
2748 // Get current session — with token-rehydration fallback for REST
2749 // contexts where PHPSESSID isn't propagated (same shape as the
2750 // service-toggle / summary endpoints). Also writes back to $_SESSION
2751 // so the subsequent `yatra_set_booking_session()` persists alongside
2752 // the existing transient.
2753 $session = yatra_get_booking_session();
2754 if (empty($session) || empty($session['trip_id'])) {
2755 $token = null;
2756 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
2757 $token = sanitize_text_field((string) $data['booking_token']);
2758 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
2759 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
2760 }
2761 if ($token) {
2762 $transient_data = get_transient($token);
2763 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
2764 $session = $transient_data;
2765 $_SESSION['yatra_booking'] = $session;
2766 $_SESSION['yatra_booking_token'] = $token;
2767 }
2768 }
2769 }
2770 if (empty($session) || empty($session['trip_id'])) {
2771 return new WP_REST_Response([
2772 'success' => false,
2773 'message' => __('No active booking session found.', 'yatra'),
2774 ], 400);
2775 }
2776
2777 // Use DiscountService to calculate coupon discount
2778 $discountService = new \Yatra\Services\DiscountService();
2779 $total_amount = $this->calculateSessionTotal($session);
2780 $trip_id = (int) $session['trip_id'];
2781 $travelers_count = (int) ($session['travelers'] ?? 1);
2782 $traveler_counts = $session['traveler_counts'] ?? [];
2783
2784 $coupon_result = $discountService->calculateCouponDiscount(
2785 $code,
2786 $total_amount,
2787 $trip_id,
2788 $travelers_count,
2789 $traveler_counts
2790 );
2791
2792 // Check if discount was calculated (validation passed)
2793 if ($coupon_result['calculated_amount'] <= 0) {
2794 return new WP_REST_Response([
2795 'success' => false,
2796 'message' => __('This coupon is not valid for your booking.', 'yatra'),
2797 ], 400);
2798 }
2799
2800 $discount_amount = $coupon_result['calculated_amount'];
2801
2802
2803 // Store coupon in session (use the original $code variable, not from result)
2804 $session['coupon'] = [
2805 'code' => $code, // Use the actual code that was validated
2806 'type' => $coupon_result['type'],
2807 'amount' => $coupon_result['amount'],
2808 'discount_amount' => $discount_amount,
2809 'label' => $coupon_result['label'],
2810 ];
2811 $session['timestamp'] = time();
2812
2813 yatra_set_booking_session($session);
2814
2815
2816 return new WP_REST_Response([
2817 'success' => true,
2818 'message' => __('Coupon applied successfully!', 'yatra'),
2819 'data' => [
2820 'code' => $code,
2821 'type' => $coupon_result['type'],
2822 'discount_amount' => $discount_amount,
2823 'discount_formatted' => yatra_format_price($discount_amount),
2824 'new_total' => $total_amount - $discount_amount,
2825 'new_total_formatted' => yatra_format_price($total_amount - $discount_amount),
2826 ],
2827 ]);
2828 }
2829
2830 /**
2831 * Verify a guest's booking email via magic-link token.
2832 *
2833 * Flow:
2834 * 1. Validate the HMAC token (forgery, expiry, email-binding).
2835 * 2. Look up the booking; confirm it's in `pending_verification`.
2836 * 3. Flip status to `pending` (or `confirmed` when auto-confirm
2837 * pay-later is enabled for this site) and fire the standard
2838 * yatra_booking_status_changed action so inventory + email
2839 * automations resume normally.
2840 * 4. 302 redirect the browser to a continuation URL:
2841 * - If amount_due > 0: back to the booking page for the
2842 * payment step the customer skipped earlier.
2843 * - If amount_due == 0 / auto-confirm: to the booking
2844 * confirmation/thank-you page.
2845 * 5. On any failure, render a friendly HTML page (not JSON) so
2846 * the customer sees readable text in their browser tab.
2847 *
2848 * @return WP_REST_Response|WP_Error|void
2849 */
2850 public function verify_email(WP_REST_Request $request)
2851 {
2852 $token = (string) $request->get_param('token');
2853 $bookingRepo = new \Yatra\Repositories\BookingRepository();
2854
2855 // First decode just to extract the booking id (for the
2856 // expectedEmail lookup). Verify() is called again below
2857 // with the actual email so the email-binding check runs.
2858 $partsPreview = explode('.', $token);
2859 $bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0]))
2860 ? (int) $partsPreview[0]
2861 : 0;
2862 $booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null;
2863 $expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : '';
2864
2865 $result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail);
2866
2867 if (!$result['ok']) {
2868 $this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid'));
2869 }
2870 if ($booking === null) {
2871 $this->renderVerifyEmailErrorPage('booking_not_found');
2872 }
2873
2874 // Re-entrant: if the booking has already been verified, show the
2875 // same success page (idempotent) — pre-3.0.5 silently redirected
2876 // and the customer was left wondering whether anything happened.
2877 $currentStatus = (string) ($booking->status ?? '');
2878 $alreadyVerified = $currentStatus !== 'pending_verification';
2879
2880 if (!$alreadyVerified) {
2881 // Flip status to 'pending' so downstream hooks (inventory /
2882 // notification automations) see fresh data, then fire
2883 // yatra_booking_created so the listeners we deferred at
2884 // creation time (admin "new booking" notification + Pro
2885 // email-automation booking.created fan-out) run now — i.e.
2886 // *after* the customer has proven the email is theirs.
2887 $bookingRepo->updateStatus((int) $booking->id, 'pending');
2888 do_action('yatra_booking_email_verified', (int) $booking->id);
2889
2890 // Re-fetch so the post-verification action receives the
2891 // booking row with the new status, then fire the deferred
2892 // booking-created action. See BookingService::createBooking()
2893 // for the matching skip-on-pending-verification branch.
2894 $verifiedBooking = $bookingRepo->find((int) $booking->id);
2895 if (is_object($verifiedBooking)) {
2896 do_action(
2897 \Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED,
2898 (int) $verifiedBooking->id,
2899 $verifiedBooking
2900 );
2901 }
2902 }
2903
2904 $this->renderVerifyEmailSuccessPage(
2905 (int) $booking->id,
2906 (string) ($booking->reference ?? ''),
2907 $alreadyVerified
2908 );
2909 }
2910
2911 /**
2912 * Friendly HTML error page rendered when a verification link
2913 * is invalid / expired / tampered with. Avoids JSON in the
2914 * customer's browser tab (terrible UX). Reasons map to clear
2915 * messages so customers know what to do next.
2916 *
2917 * Emits raw HTML and exits. We can't return WP_REST_Response with an
2918 * HTML body because the REST server JSON-encodes the response data
2919 * regardless of the Content-Type header on the response object —
2920 * the customer would see `"<!doctype..."` (a JSON string) in their
2921 * browser tab. Echoing + exiting short-circuits the REST pipeline.
2922 *
2923 * @return never
2924 */
2925 private function renderVerifyEmailErrorPage(string $reason): void
2926 {
2927 $messages = [
2928 'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'),
2929 'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'),
2930 'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'),
2931 'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'),
2932 'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'),
2933 ];
2934 $message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra');
2935
2936 $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
2937 $html = sprintf(
2938 '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
2939 . '<meta name="viewport" content="width=device-width,initial-scale=1">'
2940 . '<title>%2$s</title>'
2941 . '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
2942 . '.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}'
2943 . 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}'
2944 . 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>'
2945 . '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>',
2946 esc_attr(get_locale()),
2947 esc_html__('Verification link issue', 'yatra'),
2948 esc_html__('Verification link issue', 'yatra'),
2949 esc_html($message),
2950 esc_url(home_url('/')),
2951 esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName))
2952 );
2953
2954 if (!headers_sent()) {
2955 status_header(200);
2956 nocache_headers();
2957 header('Content-Type: text/html; charset=UTF-8');
2958 }
2959 echo $html;
2960 exit;
2961 }
2962
2963 /**
2964 * Build the booking continuation URL (post-verification destination).
2965 *
2966 * If the site has a Yatra Bookings page, route through that with the
2967 * booking reference; otherwise fall back to the trip URL. Filterable
2968 * via `yatra_guest_verification_continuation_url` so integrations can
2969 * route to a custom thank-you page.
2970 */
2971 private function continuationUrl(int $bookingId, string $reference): string
2972 {
2973 return (string) apply_filters(
2974 'yatra_guest_verification_continuation_url',
2975 add_query_arg(
2976 ['booking_id' => $bookingId, 'verified' => '1'],
2977 home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/')
2978 ),
2979 $bookingId,
2980 $reference
2981 );
2982 }
2983
2984 /**
2985 * Friendly HTML success page rendered after the guest clicks the
2986 * email-verification magic link.
2987 *
2988 * Pre-3.0.5 this endpoint silently 302-redirected to the booking page,
2989 * which made guests believe nothing had happened — there was no visible
2990 * "verified" feedback before they landed on the next step. This page
2991 * gives them an unambiguous confirmation, the booking reference, and
2992 * three explicit CTAs:
2993 * - Continue to booking (primary, continuation URL)
2994 * - My Account (when logged in) / Sign in (when not)
2995 * - Go to homepage (fallback)
2996 *
2997 * Idempotent: when the booking was already verified (re-click on the
2998 * same link), the heading + copy switch to the "already verified"
2999 * variant but the CTAs stay the same.
3000 *
3001 * Emits raw HTML and exits — same reasoning as
3002 * {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response
3003 * JSON-encodes string bodies, so the customer would see
3004 * `"<!doctype..."` in their tab instead of the rendered page.
3005 *
3006 * @return never
3007 */
3008 private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void
3009 {
3010 // Booking is already persisted at this point and (for a fresh verify)
3011 // the status flip + booking-created fan-out have just fired. The
3012 // verified UI's primary action is therefore "View your booking
3013 // confirmation" — NOT "Continue Booking", which mislabelled the
3014 // booking as still in-progress and confused customers into thinking
3015 // they needed to re-submit the form.
3016 $confirmationUrl = function_exists('yatra_get_booking_confirmation_url')
3017 ? yatra_get_booking_confirmation_url($reference)
3018 : $this->continuationUrl($bookingId, $reference);
3019
3020 // Account / login URL — prefer Yatra's account page when present,
3021 // fall back to wp_login_url() so the page never points nowhere.
3022 $accountBase = \Yatra\Services\SettingsService::getAccountBase();
3023 $accountUrl = $accountBase !== ''
3024 ? home_url('/' . trim($accountBase, '/') . '/')
3025 : home_url('/');
3026 $isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in();
3027 $secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl);
3028 $secondaryLabel = $isLoggedIn
3029 ? __('Go to My Account', 'yatra')
3030 : __('Sign in', 'yatra');
3031
3032 $heading = $alreadyVerified
3033 ? __('Email Already Verified', 'yatra')
3034 : __('Email Verified', 'yatra');
3035 $message = $alreadyVerified
3036 ? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra')
3037 : __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra');
3038
3039 $primaryLabel = __('View Booking Confirmation', 'yatra');
3040 $homeLabel = __('Go to Homepage', 'yatra');
3041 $referenceLabel = __('Booking reference', 'yatra');
3042 $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3043
3044 $referenceLine = $reference !== ''
3045 ? sprintf(
3046 '<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>',
3047 esc_html($referenceLabel),
3048 esc_html($reference)
3049 )
3050 : '';
3051
3052 // Inline-only styling so the page renders correctly regardless of
3053 // theme stylesheet load order (REST → wp_die/raw HTML response).
3054 $html = sprintf(
3055 '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3056 . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3057 . '<title>%2$s · %3$s</title>'
3058 . '<style>'
3059 . 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3060 . '.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}'
3061 . '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}'
3062 . 'h1{font-size:24px;margin:0 0 12px;color:#065f46}'
3063 . 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}'
3064 . '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}'
3065 . '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}'
3066 . '.ref code{font-weight:600;color:#111827}'
3067 . '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}'
3068 . '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}'
3069 . '.btn-primary{background:#059669;color:#fff}'
3070 . '.btn-primary:hover{background:#047857}'
3071 . '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}'
3072 . '.btn-secondary:hover{background:#f9fafb}'
3073 . '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}'
3074 . '</style></head>'
3075 . '<body><div class="box">'
3076 . '<div class="tick" aria-hidden="true">'
3077 . '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
3078 . '<polyline points="20 6 9 17 4 12"></polyline></svg>'
3079 . '</div>'
3080 . '<h1>%2$s</h1>'
3081 . '<p>%4$s</p>'
3082 . '%5$s'
3083 . '<div class="actions">'
3084 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3085 . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3086 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3087 . '</div>'
3088 . '</div></body></html>',
3089 esc_attr(get_locale()),
3090 esc_html($heading),
3091 esc_html($brandName),
3092 esc_html($message),
3093 $referenceLine,
3094 esc_url($confirmationUrl),
3095 esc_html($primaryLabel),
3096 esc_url($secondaryUrl),
3097 esc_html($secondaryLabel),
3098 esc_url(home_url('/')),
3099 esc_html($homeLabel)
3100 );
3101
3102 if (!headers_sent()) {
3103 status_header(200);
3104 nocache_headers();
3105 header('Content-Type: text/html; charset=UTF-8');
3106 }
3107 echo $html;
3108 exit;
3109 }
3110
3111 /**
3112 * Calculate booking summary and return HTML for dynamic updates
3113 * Called via AJAX when traveler count, date, or coupon changes
3114 */
3115 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
3116 {
3117 yatra_start_session();
3118 $session = yatra_get_booking_session();
3119 $data = $request->get_json_params() ?? [];
3120
3121 // Same REST-context session-rehydration fallback as set_session() /
3122 // create_booking(): when PHPSESSID isn't propagated to the REST API
3123 // scope, look up the transient by `booking_token` (from request body
3124 // first, then ?booking_token=) so the partial summary refresh
3125 // doesn't 400. Without this, every service-toggle re-render fails
3126 // because trip_id resolves to 0.
3127 //
3128 // We also write the rehydrated session into $_SESSION so that the
3129 // downstream buildPricingHtml() — which calls yatra_get_booking_session()
3130 // again — sees the same data and doesn't fall back to its
3131 // "Pricing information not available" branch.
3132 if (empty($session) || empty($session['trip_id'])) {
3133 $token = null;
3134 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
3135 $token = sanitize_text_field((string) $data['booking_token']);
3136 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
3137 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
3138 }
3139 if ($token) {
3140 $transient_data = get_transient($token);
3141 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
3142 $session = $transient_data;
3143 $_SESSION['yatra_booking'] = $session;
3144 $_SESSION['yatra_booking_token'] = $token;
3145 }
3146 }
3147 }
3148
3149 // Get trip_id from session (required)
3150 $trip_id = (int) ($session['trip_id'] ?? 0);
3151
3152 // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session
3153 $traveler_counts = $data['traveler_counts'] ?? ($session['traveler_counts'] ?? []);
3154
3155 // Get other data from request or session
3156 $travel_date = sanitize_text_field($data['travel_date'] ?? ($session['travel_date'] ?? ''));
3157 $departure_time = sanitize_text_field($data['departure_time'] ?? ($session['departure_time'] ?? ''));
3158 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
3159 $pricing_type_from_request = sanitize_text_field($data['pricing_type'] ?? ($session['pricing_type'] ?? ''));
3160 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
3161 if ($payment_method === '') {
3162 $payment_method = 'full';
3163 }
3164 $selected_service_ids_from_request = $data['additional_services'] ?? ($session['additional_services'] ?? null);
3165
3166 // IMPORTANT: Always read coupon from SESSION (not request) to maintain applied discount
3167 $coupon_code = isset($session['coupon']['code']) ? sanitize_text_field($session['coupon']['code']) : '';
3168
3169
3170 if (empty($trip_id)) {
3171 return new WP_REST_Response([
3172 'success' => false,
3173 'message' => __('No active booking session found.', 'yatra'),
3174 ], 400);
3175 }
3176
3177 // Get trip data
3178 $trip = $this->tripRepository->findPublished($trip_id);
3179 if (!$trip) {
3180 return new WP_REST_Response([
3181 'success' => false,
3182 'message' => __('Trip not found.', 'yatra'),
3183 ], 404);
3184 }
3185
3186 // Ensure pricing fields are properly set
3187 $trip->original_price = (float) ($trip->original_price ?? 0);
3188 $trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0;
3189 $trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0;
3190
3191 yatra_start_session();
3192 $existing_session = yatra_get_booking_session();
3193
3194 global $wpdb;
3195
3196 $availability = null;
3197 if (!empty($travel_date)) {
3198 // Use the same resolver as the single-trip UI so rule slots, manual dates,
3199 // and flexible defaults all return a consistent object shape.
3200 try {
3201 $resolver = new \Yatra\Services\AvailabilityResolutionService();
3202 $availability = $resolver->resolveAvailabilityForDate(
3203 $trip_id,
3204 $travel_date,
3205 $departure_time !== '' ? $departure_time : null
3206 );
3207 } catch (\Throwable $e) {
3208 $availability = null;
3209 }
3210 } elseif (!empty($availability_id) && is_numeric($availability_id)) {
3211 // Back-compat: allow summary by numeric availability_id only.
3212 $availability = $this->availabilityService->getById((int) $availability_id);
3213 }
3214
3215 // Resolve pricing type and price_types via centralized TripPricingService
3216 $resolved_pricing_type = !empty($pricing_type_from_request)
3217 ? $pricing_type_from_request
3218 : \Yatra\Services\TripPricingService::resolvePricingType($trip);
3219
3220 $price_types = [];
3221
3222 // First priority: availability price_types (already includes trip fallback from AvailabilityResolutionService)
3223 if ($availability && !empty($availability->price_types)) {
3224 $avail_pts = is_string($availability->price_types)
3225 ? (json_decode($availability->price_types, true) ?: [])
3226 : $availability->price_types;
3227 if (!empty($avail_pts) && is_array($avail_pts)) {
3228 $price_types = array_map(function ($pt) { return (object) $pt; }, $avail_pts);
3229 }
3230 }
3231 // Second priority: trip's price_types via centralized normalizer
3232 if (empty($price_types)) {
3233 $normalized = \Yatra\Services\TripPricingService::resolvePriceTypes($trip);
3234 if (!empty($normalized)) {
3235 $price_types = array_map(function ($pt) { return (object) $pt; }, $normalized);
3236 }
3237 }
3238 // Auto-detect traveler_based if price_types are present
3239 if (!empty($price_types)) {
3240 $resolved_pricing_type = 'traveler_based';
3241 }
3242
3243 // Enrich availability price_types with category labels if missing
3244 if (!empty($price_types)) {
3245 $missing_label_category_ids = [];
3246 foreach ($price_types as $pt) {
3247 $pt = (object) $pt;
3248 if (empty($pt->category_label) && !empty($pt->category_id)) {
3249 $missing_label_category_ids[] = (int) $pt->category_id;
3250 }
3251 }
3252
3253 $missing_label_category_ids = array_values(array_unique(array_filter($missing_label_category_ids)));
3254 if (!empty($missing_label_category_ids)) {
3255 // Use AvailabilityService to get traveler categories
3256 $cats = $this->availabilityService->getTravelerCategories($missing_label_category_ids);
3257
3258 $catIndex = [];
3259 foreach ($cats as $cat) {
3260 $catIndex[(int) $cat->id] = $cat;
3261 }
3262
3263 foreach ($price_types as &$pt) {
3264 $pt = (object) $pt;
3265 $catId = !empty($pt->category_id) ? (int) $pt->category_id : null;
3266 if ($catId && isset($catIndex[$catId])) {
3267 $cat = $catIndex[$catId];
3268 if (empty($pt->category_label)) {
3269 $pt->category_label = $cat->label;
3270 }
3271 if (empty($pt->category_slug)) {
3272 $pt->category_slug = $cat->slug;
3273 }
3274 if (!isset($pt->age_min)) {
3275 $pt->age_min = $cat->age_min ? (int) $cat->age_min : null;
3276 }
3277 if (!isset($pt->age_max)) {
3278 $pt->age_max = $cat->age_max ? (int) $cat->age_max : null;
3279 }
3280 }
3281 }
3282 unset($pt);
3283 }
3284 }
3285
3286 foreach ($price_types as $pt) {
3287 $pt->effective_price = $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
3288 }
3289
3290 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
3291 $spots_remaining = $availability ? (int) ($availability->spots_remaining ?? null) : null;
3292
3293 foreach ($price_types as $pt) {
3294 if (!isset($pt->effective_price)) {
3295 continue;
3296 }
3297 $price_before = $pt->effective_price;
3298 $pt_orig = (float) ($pt->original_price ?? 0);
3299 $pt->effective_price = apply_filters('yatra_trip_display_price', (float) $pt->effective_price, $trip_id, [
3300 'departure_date' => $travel_date ?: null,
3301 'spots_remaining' => $spots_remaining,
3302 'availability_id' => $availability_id,
3303 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
3304 'original_price' => $pt_orig > 0 ? $pt_orig : (float) $price_before,
3305 'discounted_price' => (float) $price_before,
3306 ]);
3307 }
3308 }
3309
3310 $is_traveler_based = $resolved_pricing_type === 'traveler_based' && !empty($price_types);
3311
3312 // Base trip price via centralized TripPricingService (single source of truth)
3313 $base_trip_price = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
3314
3315 // Override with availability pricing if available
3316 if ($availability) {
3317 $avail_price = !empty($availability->discounted_price) && (float) $availability->discounted_price > 0
3318 ? (float) $availability->discounted_price
3319 : (!empty($availability->original_price) && (float) $availability->original_price > 0
3320 ? (float) $availability->original_price : 0);
3321 if ($avail_price > 0) {
3322 $base_trip_price = $avail_price;
3323 }
3324 }
3325
3326 // Apply dynamic pricing filter (Pro DynamicPricingModule hooks here)
3327 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
3328 $list_for_dp = (float) ($trip->original_price ?? 0);
3329 if ($availability) {
3330 $ao = (float) ($availability->original_price ?? 0);
3331 if ($ao > 0) {
3332 $list_for_dp = $ao;
3333 }
3334 }
3335 $pre_dp_effective = (float) $base_trip_price;
3336 $base_trip_price = apply_filters('yatra_booking_trip_price', $base_trip_price, $trip_id, [
3337 'departure_date' => $travel_date,
3338 'spots_remaining' => $availability ? (int) ($availability->spots_remaining ?? null) : null,
3339 'availability_id' => $availability_id,
3340 'original_price' => $list_for_dp > 0 ? $list_for_dp : $pre_dp_effective,
3341 'discounted_price' => $pre_dp_effective,
3342 ]);
3343 }
3344
3345 // Calculate subtotals per category
3346 $category_breakdown = [];
3347 $subtotal = 0;
3348 $total_travelers = 0;
3349 $normalized_traveler_counts = [];
3350 if (!empty($traveler_counts) && is_array($traveler_counts)) {
3351 foreach ($traveler_counts as $k => $v) {
3352 $key = is_numeric($k) ? (int) $k : (string) $k;
3353 $normalized_traveler_counts[$key] = (int) $v;
3354 }
3355 }
3356
3357 if ($is_traveler_based && !empty($normalized_traveler_counts)) {
3358 foreach ($price_types as $pt) {
3359 $category_id = $pt->category_id;
3360 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
3361 if ($count > 0) {
3362 $category_subtotal = (float) $pt->effective_price * $count;
3363 $category_breakdown[] = [
3364 'category_id' => $category_id,
3365 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
3366 'count' => $count,
3367 'price' => (float) $pt->effective_price,
3368 'subtotal' => $category_subtotal,
3369 ];
3370 $subtotal += $category_subtotal;
3371 $total_travelers += $count;
3372 }
3373 }
3374 } else {
3375 // Regular pricing
3376 $total_travelers = array_sum(array_map('intval', $normalized_traveler_counts));
3377 // If traveler_counts is empty, fallback to session 'travelers' count
3378 if ($total_travelers < 1) {
3379 $total_travelers = !empty($session['travelers']) ? (int) $session['travelers'] : 1;
3380 }
3381 $price_per_person = $base_trip_price;
3382 $subtotal = $price_per_person * $total_travelers;
3383
3384 }
3385
3386 // Calculate group discount
3387 $discountService = new \Yatra\Services\DiscountService();
3388 $travelerCountsForDiscount = [];
3389 if ($is_traveler_based) {
3390 foreach ($normalized_traveler_counts as $k => $v) {
3391 if (is_numeric($k)) {
3392 $travelerCountsForDiscount[(int) $k] = (int) $v;
3393 }
3394 }
3395 }
3396 if (empty($travelerCountsForDiscount)) {
3397 $travelerCountsForDiscount['default'] = $total_travelers;
3398 }
3399
3400 $priceTypesForDiscount = [];
3401 if ($is_traveler_based) {
3402 foreach ($price_types as $pt) {
3403 $pt = (object) $pt;
3404 $priceTypesForDiscount[] = [
3405 'category_id' => $pt->category_id ?? null,
3406 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
3407 ];
3408 }
3409 } else {
3410 $priceTypesForDiscount[] = [
3411 'category_id' => 'default',
3412 'effective_price' => $base_trip_price,
3413 ];
3414 }
3415
3416 $group_discount = $discountService->calculateGroupDiscount($trip_id, $travelerCountsForDiscount, $priceTypesForDiscount);
3417 $group_discount_amount = $group_discount['amount'] ?? 0;
3418 $group_discount_label = $group_discount['label'] ?? __('Group Discount', 'yatra');
3419 $group_discount_code = $group_discount['code'] ?? null;
3420
3421 // Calculate coupon discount using DiscountService
3422 $coupon_discount_amount = 0;
3423 $coupon_discount_label = '';
3424 $coupon_error = '';
3425
3426 if (!empty($coupon_code)) {
3427 $discountService = new \Yatra\Services\DiscountService();
3428 $subtotal_after_group = $subtotal - $group_discount_amount;
3429
3430 $coupon_result = $discountService->calculateCouponDiscount(
3431 $coupon_code,
3432 $subtotal_after_group,
3433 $trip_id,
3434 $total_travelers,
3435 $is_traveler_based ? $normalized_traveler_counts : []
3436 );
3437
3438 if ($coupon_result['calculated_amount'] > 0) {
3439 $coupon_discount_amount = $coupon_result['calculated_amount'];
3440 $coupon_discount_label = $coupon_result['label'];
3441 } else {
3442 $coupon_error = __('This coupon is not valid for your booking.', 'yatra');
3443 }
3444 }
3445
3446 // Use CalculationService for on-demand pricing calculation
3447 $calculationService = new CalculationService();
3448
3449 // The selected-service ids the customer just submitted live in
3450 // `$selected_service_ids_from_request` (line ~2635). The previous
3451 // version of this method initialised a fresh `$additional_services
3452 // = []` here and passed that empty list into calculateFromSession
3453 // — which made the Pro AdditionalServicesModule's
3454 // `addServicesToSubtotal` hook bail out at its `empty($selectedServiceIds)`
3455 // guard, so every AJAX summary refresh wiped services out of the
3456 // Trip Subtotal even though the sidebar still rendered the rows.
3457 // Pass the real selected ids instead so CalculationService →
3458 // Pro filter chain folds them into `$subtotal` correctly.
3459 $additional_services = is_array($selected_service_ids_from_request)
3460 ? array_values(array_map('intval', $selected_service_ids_from_request))
3461 : [];
3462
3463 // Create session-like data structure for calculation (trip data fetched from database)
3464 $session_like_data = [
3465 'trip_id' => $trip_id,
3466 'travelers' => $total_travelers,
3467 'traveler_counts' => $traveler_counts,
3468 'travel_date' => $travel_date,
3469 'departure_time' => $departure_time,
3470 'additional_services' => $additional_services
3471 ];
3472
3473 // Apply filter for pro plugins to modify summary calculation parameters
3474 $calculation_params = apply_filters('yatra_summary_calculation_params', [
3475 'session_data' => $session_like_data,
3476 'coupon_code' => $coupon_code,
3477 'payment_method' => $payment_method,
3478 ]);
3479
3480 $pricing = $calculationService->calculateFromSession(
3481 $calculation_params['session_data'],
3482 $calculation_params['coupon_code'],
3483 $calculation_params['payment_method']
3484 );
3485
3486 $total_amount = $pricing['final_total'];
3487 $amount_due = $pricing['amount_due'];
3488 $tax_calculation = $pricing['tax_calculation'];
3489 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
3490 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
3491 $tax_breakdown = $pricing['tax_calculation']['tax_breakdown'];
3492
3493 // ── Reconcile per-category display prices with CalculationService ─
3494 //
3495 // The $category_breakdown computed above (~line 3358) used a SEPARATE
3496 // DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled.
3497 // In certain AJAX-recompute contexts (e.g. switching payment_method)
3498 // that loop could miss DP — for example when a stored availability
3499 // row's price_types already had a pre-DP effective_price baked in,
3500 // or when a date-sensitive DP rule didn't fire because the request
3501 // didn't carry the same departure_date context.
3502 //
3503 // CalculationService is the single source of truth for booking math;
3504 // it already computed the correct post-DP per-category prices and
3505 // returned them as `category_prices_post_dp` (keyed by string
3506 // category_id). Reconcile the display breakdown against that map so
3507 // "Adult x 8 ($131.12 x 8)" can never disagree with the Trip
3508 // Subtotal the rest of the page is built from.
3509 if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) {
3510 $catPricesPostDp = $pricing['category_prices_post_dp'];
3511 $reconciledSubtotal = 0.0;
3512 foreach ($category_breakdown as &$cat) {
3513 $cid = isset($cat['category_id']) ? (string) $cat['category_id'] : '';
3514 if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
3515 $authoritativePrice = (float) $catPricesPostDp[$cid];
3516 $count = (int) ($cat['count'] ?? 0);
3517 $cat['price'] = $authoritativePrice;
3518 $cat['subtotal'] = $authoritativePrice * $count;
3519 }
3520 $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
3521 }
3522 unset($cat);
3523 // Keep the top-level $subtotal in sync with the reconciled
3524 // breakdown so any downstream renderers that read it (instead
3525 // of $pricing['base_amount']) still see consistent numbers.
3526 if ($reconciledSubtotal > 0) {
3527 $subtotal = $reconciledSubtotal;
3528 }
3529 }
3530
3531 /**
3532 * Filter: Get additional services for this trip
3533 * Allows premium modules to add extra services to the booking summary
3534 *
3535 * @param array $services Empty array by default
3536 * @param int $trip_id The trip ID
3537 * @param int $total_travelers Total number of travelers
3538 * @param array $traveler_counts Traveler counts by category
3539 * @param string $travel_date The travel date
3540 * @since 3.0.0
3541 */
3542 $additional_services = apply_filters('yatra_booking_additional_services', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
3543
3544 // Get selected services from request (priority) or session
3545 // If request has additional_services, use that; otherwise fall back to session
3546 if ($selected_service_ids_from_request !== null) {
3547 $selected_service_ids = $selected_service_ids_from_request;
3548 } else {
3549 $selected_service_ids = isset($existing_session['additional_services']) && is_array($existing_session['additional_services'])
3550 ? array_map('intval', $existing_session['additional_services'])
3551 : [];
3552 }
3553
3554 // Mark which services are selected and calculate their price based on price_per
3555 $duration_days = (int) ($trip->duration_days ?? 1);
3556 $default_services_total = 0.0;
3557 foreach ($additional_services as &$service) {
3558 $serviceId = (int) $service['id'];
3559 $isInRequest = in_array($serviceId, $selected_service_ids, true);
3560 $isRequired = !empty($service['is_required']);
3561 $isIncluded = !empty($service['is_included']);
3562 $service['selected'] = $isInRequest || $isRequired || $isIncluded;
3563
3564 // Calculate the price based on price_per (person, day, booking)
3565 $basePrice = (float) ($service['price'] ?? 0);
3566 $pricePer = $service['price_per'] ?? 'person';
3567
3568 switch ($pricePer) {
3569 case 'person':
3570 $service['calculated_price'] = $basePrice * $total_travelers;
3571 break;
3572 case 'day':
3573 $service['calculated_price'] = $basePrice * max(1, $duration_days);
3574 break;
3575 case 'booking':
3576 default:
3577 $service['calculated_price'] = $basePrice;
3578 break;
3579 }
3580
3581 if (!empty($service['selected']) && empty($service['is_included'])) {
3582 $default_services_total += (float) $service['calculated_price'];
3583 }
3584 }
3585 unset($service);
3586
3587 /**
3588 * Filter: Calculate additional services total
3589 * Allows premium modules to add services cost to the booking total
3590 *
3591 * @param float $services_total The services total (0 by default)
3592 * @param array $additional_services The services with 'selected' flag
3593 * @param int $trip_id The trip ID
3594 * @param int $total_travelers Total number of travelers
3595 * @param int $duration_days Trip duration in days
3596 * @since 3.0.0
3597 */
3598 $services_total = apply_filters('yatra_booking_services_total', (float) $default_services_total, $additional_services, $trip_id, $total_travelers, (int) ($trip->duration_days ?? 1));
3599
3600 // Get itinerary costs (separate from additional services)
3601 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
3602 $itinerary_costs_total = 0.0;
3603
3604 foreach ($itinerary_costs as $cost) {
3605 $basePrice = (float) ($cost['price'] ?? 0);
3606 $pricePer = $cost['price_per'] ?? 'person';
3607
3608 switch ($pricePer) {
3609 case 'person':
3610 $calculatedPrice = $basePrice * $total_travelers;
3611 break;
3612 case 'day':
3613 $calculatedPrice = $basePrice * $duration_days;
3614 break;
3615 case 'booking':
3616 default:
3617 $calculatedPrice = $basePrice;
3618 break;
3619 }
3620
3621 $itinerary_costs_total += $calculatedPrice;
3622 }
3623
3624 // Note: CalculationService already includes itinerary costs in final_total
3625 // No need to add itinerary_costs_total again - it's already included in $total_amount
3626
3627 // Calculate due amount based on payment method.
3628 //
3629 // Flow: compute a sensible default using the *percentage* filters (which
3630 // Pro can already override per-trip via trip.deposit_percentage), then
3631 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
3632 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
3633 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
3634 $context = ['trip_id' => $trip_id];
3635 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
3636 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
3637 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
3638
3639 $amount_due = $total_amount;
3640 if ($payment_method === 'deposit') {
3641 $amount_due = $total_amount * ($deposit_percentage / 100);
3642 } elseif ($payment_method === 'partial') {
3643 $amount_due = $total_amount * ($partial_percentage / 100);
3644 }
3645
3646 $amount_due = (float) apply_filters(
3647 'yatra_calculate_amount_due',
3648 $amount_due,
3649 $total_amount,
3650 $payment_method,
3651 $context
3652 );
3653
3654 Logger::debug('Yatra booking summary: payment method and amount due', [
3655 'context' => 'booking_summary_rest',
3656 'trip_id' => $trip_id,
3657 'flexible_payments_enabled' => $flexible_payments_enabled,
3658 'payment_method' => $payment_method,
3659 'total_amount' => round($total_amount, 4),
3660 'amount_due' => round($amount_due, 4),
3661 'deposit_percentage' => $deposit_percentage,
3662 'partial_percentage' => $partial_percentage,
3663 ]);
3664
3665 // Build pricing HTML for the summary section (using CalculationService data)
3666 $pricing_html = $this->buildPricingHtml([
3667 'is_traveler_based' => $is_traveler_based,
3668 'category_breakdown' => $category_breakdown,
3669 'price_per_person' => $price_per_person ?? $base_trip_price,
3670 'total_travelers' => $total_travelers,
3671 'gross_total' => $pricing['gross_total'] ?? $pricing['base_amount'],
3672 'subtotal' => $pricing['gross_total'] ?? $pricing['base_amount'],
3673 'taxable_amount' => $pricing['taxable_amount'] ?? 0,
3674 'group_discount_amount' => $pricing['group_discount']['amount'] ?? 0,
3675 'group_discount_label' => $pricing['group_discount']['label'] ?? '',
3676 'coupon_discount_amount' => $pricing['coupon_discount']['calculated_amount'] ?? 0,
3677 'coupon_discount_label' => $pricing['coupon_discount']['label'] ?? '',
3678 'coupon_code' => $pricing['coupon_discount']['code'] ?? '',
3679 'additional_services' => $additional_services,
3680 'services_total' => $services_total,
3681 'itinerary_costs' => $itinerary_costs,
3682 'itinerary_costs_total' => $itinerary_costs_total,
3683 'total_amount' => $total_amount,
3684 'amount_due' => $amount_due,
3685 'payment_method' => $payment_method,
3686 'deposit_percentage' => $deposit_percentage,
3687 'partial_percentage' => $partial_percentage,
3688 // Tax variables from centralized calculation
3689 'enable_tax' => $pricing['tax_calculation']['enable_tax'],
3690 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'],
3691 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'],
3692 'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'],
3693 // Dynamic-pricing data — without these, the AJAX-refreshed summary
3694 // would never carry the DP line items even though CalculationService
3695 // produces them on every recalculation.
3696 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null,
3697 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null,
3698 'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0,
3699 // Authoritative post-DP per-category map. Checkout::getCategoryBreakdown
3700 // prefers this over the session's $pt->effective_price (which can be
3701 // pre-DP after a stored availability row's price_types come in
3702 // pre-baked), so forwarding it here is what keeps the AJAX-rendered
3703 // "Adult x N ($X x N)" row in sync with the actual Trip Subtotal.
3704 'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [],
3705 // Currency for consistent formatting
3706 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3707 ]);
3708
3709 // Build response
3710 return new WP_REST_Response([
3711 'success' => true,
3712 'data' => [
3713 'is_traveler_based' => $is_traveler_based,
3714 'category_breakdown' => $category_breakdown,
3715 'subtotal' => round($subtotal, 2),
3716 'subtotal_formatted' => yatra_format_price($subtotal),
3717 'total_travelers' => $total_travelers,
3718 'group_discount' => $group_discount ? [
3719 'amount' => round($group_discount_amount, 2),
3720 'amount_formatted' => yatra_format_price($group_discount_amount),
3721 'label' => $group_discount_label,
3722 'code' => $group_discount_code,
3723 'applied_categories' => $group_discount['applied_categories'] ?? [],
3724 ] : null,
3725 'coupon_discount' => $coupon_discount_amount > 0 ? [
3726 'amount' => round($coupon_discount_amount, 2),
3727 'amount_formatted' => yatra_format_price($coupon_discount_amount),
3728 'label' => $coupon_discount_label,
3729 'code' => $coupon_code,
3730 ] : null,
3731 'coupon_error' => $coupon_error,
3732 'total_discount' => round($group_discount_amount + $coupon_discount_amount, 2),
3733 'total_discount_formatted' => yatra_format_price($group_discount_amount + $coupon_discount_amount),
3734 // Additional services (premium feature)
3735 'additional_services' => $additional_services,
3736 'services_total' => round($services_total, 2),
3737 'services_total_formatted' => yatra_format_price($services_total),
3738 // Itinerary costs (separate from services)
3739 'itinerary_costs' => $itinerary_costs,
3740 'itinerary_costs_total' => round($itinerary_costs_total, 2),
3741 'itinerary_costs_total_formatted' => yatra_format_price($itinerary_costs_total),
3742 'total_amount' => round($total_amount, 2),
3743 'total_amount_formatted' => yatra_format_price($total_amount),
3744 'amount_due' => round($amount_due, 2),
3745 'amount_due_formatted' => yatra_format_price($amount_due),
3746 'deposit_percentage' => $deposit_percentage,
3747 'partial_percentage' => $partial_percentage,
3748 // HTML for the pricing section
3749 'pricing_html' => $pricing_html,
3750 ],
3751 ]);
3752 }
3753
3754 /**
3755 * Build HTML for the pricing summary section
3756 * This is returned via AJAX to update the pricing breakdown dynamically
3757 * Uses the pricing-summary.php template for rendering with Checkout model
3758 */
3759 private function buildPricingHtml(array $data): string
3760 {
3761 // Get session data to create Checkout model
3762 yatra_start_session();
3763 $session = yatra_get_booking_session();
3764
3765 // Get trip data
3766 $trip_id = (int) ($session['trip_id'] ?? 0);
3767 if (empty($trip_id)) {
3768 return '<p>' . __('Pricing information not available.', 'yatra') . '</p>';
3769 }
3770
3771 $tripRepository = new \Yatra\Repositories\TripRepository();
3772 $trip = $tripRepository->findPublished($trip_id);
3773 if (!$trip) {
3774 return '<p>' . __('Trip not found.', 'yatra') . '</p>';
3775 }
3776
3777 // Build pricing calculation array from data (centralized pricing)
3778 $resolvedCurrentPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
3779 $pricingCalculation = [
3780 'original_price' => $trip->original_price ?? 0,
3781 'discounted_price' => $resolvedCurrentPrice,
3782 'unit_price' => $data['price_per_person'] ?? $resolvedCurrentPrice,
3783 'pricing_type' => $session['pricing_type'] ?? 'regular',
3784 'base_amount' => $data['gross_total'] ?? 0,
3785 'subtotal' => $data['subtotal'] ?? $data['gross_total'] ?? 0,
3786 'taxable_amount' => $data['taxable_amount'] ?? 0,
3787 'gross_total' => $data['gross_total'] ?? 0,
3788 'final_total' => $data['total_amount'] ?? 0,
3789 'amount_due' => $data['amount_due'] ?? 0,
3790 'travelers_count' => $data['total_travelers'] ?? 1,
3791 'is_traveler_based' => $data['is_traveler_based'] ?? false,
3792 'category_breakdown' => $data['category_breakdown'] ?? [],
3793 'group_discount' => [
3794 'amount' => $data['group_discount_amount'] ?? 0,
3795 'label' => $data['group_discount_label'] ?? '',
3796 ],
3797 'coupon_discount' => [
3798 'code' => $data['coupon_code'] ?? '',
3799 'calculated_amount' => $data['coupon_discount_amount'] ?? 0,
3800 'label' => $data['coupon_discount_label'] ?? '',
3801 ],
3802 'total_discount_amount' => ($data['group_discount_amount'] ?? 0) + ($data['coupon_discount_amount'] ?? 0),
3803 'additional_services' => $data['additional_services'] ?? [],
3804 'services_total' => $data['services_total'] ?? 0,
3805 'itinerary_costs' => $data['itinerary_costs'] ?? [],
3806 'itinerary_costs_total' => $data['itinerary_costs_total'] ?? 0,
3807 'tax_calculation' => [
3808 'enable_tax' => $data['enable_tax'] ?? false,
3809 'tax_breakdown' => $data['tax_breakdown'] ?? [],
3810 'total_tax_amount' => $data['total_tax_amount'] ?? 0,
3811 'tax_inclusive' => $data['tax_inclusive'] ?? false,
3812 ],
3813 // Dynamic-pricing breakdown — without this, the AJAX-refreshed
3814 // sidebar would never show DP line items even though the page-load
3815 // path does. Keys match CalculationService's pricing_data shape so
3816 // Checkout::getDynamicPricing() returns identical results in both
3817 // render contexts.
3818 'dynamic_pricing' => $data['dynamic_pricing'] ?? null,
3819 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null,
3820 'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0,
3821 // Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown
3822 // keys off this to override the (potentially stale / pre-DP)
3823 // $pt->effective_price coming from session price_types — without
3824 // it, the AJAX recompute renders pre-DP rows while the rest of
3825 // the summary uses the post-DP base amount.
3826 'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [],
3827 'currency' => $data['currency'] ?? null,
3828 ];
3829
3830 // Update session with payment method if provided
3831 if (!empty($data['payment_method'])) {
3832 $session['payment_method'] = $data['payment_method'];
3833 }
3834 if (!empty($data['deposit_percentage'])) {
3835 $session['deposit_percentage'] = $data['deposit_percentage'];
3836 }
3837 if (!empty($data['partial_payment_percentage'])) {
3838 $session['partial_payment_percentage'] = $data['partial_payment_percentage'];
3839 }
3840 if (!empty($data['partial_percentage'])) {
3841 $session['partial_payment_percentage'] = $data['partial_percentage'];
3842 }
3843
3844 if (!empty($data['payment_method']) || !empty($data['deposit_percentage']) || !empty($data['partial_percentage']) || !empty($data['partial_payment_percentage'])) {
3845 yatra_set_booking_session($session);
3846 }
3847
3848 // Create Checkout model instance
3849 $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation);
3850
3851 // Surface dynamic-pricing breakdown to the template scope. The partial
3852 // reads `$dynamic_pricing` for the DP block; the page-load path
3853 // already sets this in booking-content.php.
3854 $dynamic_pricing = $pricingCalculation['dynamic_pricing'] ?? null;
3855 $currency = $pricingCalculation['currency'] ?? null;
3856
3857 // Load the template (uses $checkout model)
3858 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php';
3859
3860 if (!file_exists($template_path)) {
3861 return '<p>' . __('Template not found.', 'yatra') . '</p>';
3862 }
3863
3864 // Use output buffering to capture the template output
3865 ob_start();
3866 include $template_path;
3867 return ob_get_clean();
3868 }
3869
3870 /**
3871 * Remove coupon code from booking session
3872 */
3873 public function remove_coupon(WP_REST_Request $request): WP_REST_Response
3874 {
3875 yatra_start_session();
3876
3877 $data = $request->get_json_params() ?? [];
3878 $session = yatra_get_booking_session();
3879
3880 // Same booking_token rehydration as apply_coupon — handle REST
3881 // requests that arrive without a propagated PHPSESSID.
3882 if (empty($session) || empty($session['trip_id'])) {
3883 $token = null;
3884 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
3885 $token = sanitize_text_field((string) $data['booking_token']);
3886 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
3887 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
3888 }
3889 if ($token) {
3890 $transient_data = get_transient($token);
3891 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
3892 $session = $transient_data;
3893 $_SESSION['yatra_booking'] = $session;
3894 $_SESSION['yatra_booking_token'] = $token;
3895 }
3896 }
3897 }
3898
3899 if (empty($session)) {
3900 return new WP_REST_Response([
3901 'success' => false,
3902 'message' => __('No active booking session found.', 'yatra'),
3903 ], 400);
3904 }
3905
3906 // Remove coupon directly from session to avoid array_merge issues
3907 if (isset($_SESSION['yatra_booking']['coupon'])) {
3908 unset($_SESSION['yatra_booking']['coupon']);
3909 }
3910 $_SESSION['yatra_booking']['timestamp'] = time();
3911
3912 // Also update local session array for calculation
3913 unset($session['coupon']);
3914 $session['timestamp'] = time();
3915
3916 // Persist the coupon removal to the transient too — without this,
3917 // the next /booking/summary AJAX (which the JS calls right after
3918 // this endpoint) re-reads the still-couponed transient and the
3919 // sidebar shows the coupon discount as if it never went away.
3920 // `yatra_set_booking_session()` array_merges `$_SESSION['yatra_booking']`
3921 // (already coupon-less above) with the passed data and writes the
3922 // result into the transient keyed by the existing booking token.
3923 yatra_set_booking_session($session);
3924
3925 // Ensure session data is written immediately
3926 if (session_status() === PHP_SESSION_ACTIVE) {
3927 session_write_close();
3928 }
3929
3930 $total_amount = $this->calculateSessionTotal($session);
3931
3932 return new WP_REST_Response([
3933 'success' => true,
3934 'message' => __('Coupon removed.', 'yatra'),
3935 'data' => [
3936 'new_total' => $total_amount,
3937 'new_total_formatted' => yatra_format_price($total_amount),
3938 ],
3939 ]);
3940 }
3941
3942 /**
3943 * Calculate total amount from session
3944 * Uses CalculationService to get accurate pricing (without coupon)
3945 */
3946 private function calculateSessionTotal(array $session): float
3947 {
3948 // Use CalculationService to get accurate base pricing
3949 $calculationService = new \Yatra\Services\CalculationService();
3950
3951 try {
3952 // Calculate pricing WITHOUT coupon (we're calculating this to apply coupon to it)
3953 $pricing = $calculationService->calculateFromSession($session, '');
3954
3955 // Return gross_total (base amount before discounts but after any group discounts)
3956 $total = $pricing['gross_total'] ?? 0;
3957
3958 return (float) $total;
3959 } catch (\Throwable $e) {
3960 return 0.0;
3961 }
3962 }
3963
3964 /**
3965 * @deprecated Use DiscountService::calculateCouponDiscount() instead
3966 * Calculate discount amount
3967 */
3968 private function calculateDiscountAmount(\stdClass $discount, float $total, array $session): float
3969 {
3970 $discount_amount = 0;
3971
3972 // Check if group discount applies
3973 if ($discount->is_group_discount && !empty($discount->min_group_size)) {
3974 $travelers = (int) ($session['travelers'] ?? 1);
3975 if ($travelers >= (int) $discount->min_group_size && !empty($discount->group_discount_amount)) {
3976 // Apply group discount
3977 if ($discount->group_discount_type === 'percentage') {
3978 $discount_amount = $total * ((float) $discount->group_discount_amount / 100);
3979 } else {
3980 $discount_amount = (float) $discount->group_discount_amount;
3981 }
3982 }
3983 }
3984
3985 // If no group discount, apply regular discount
3986 if ($discount_amount === 0) {
3987 if ($discount->type === 'percentage') {
3988 $discount_amount = $total * ((float) $discount->amount / 100);
3989 } else {
3990 $discount_amount = (float) $discount->amount;
3991 }
3992 }
3993
3994 // Apply max discount cap if set
3995 if (!empty($discount->max_discount_amount) && $discount_amount > (float) $discount->max_discount_amount) {
3996 $discount_amount = (float) $discount->max_discount_amount;
3997 }
3998
3999 // Ensure discount doesn't exceed total
4000 if ($discount_amount > $total) {
4001 $discount_amount = $total;
4002 }
4003
4004 return round($discount_amount, 2);
4005 }
4006
4007 /**
4008 * Get coupon usage count by user
4009 */
4010 private function getCouponUsageByUser(string $discount_code, int $user_id): int
4011 {
4012 // Use AvailabilityService to check discount code usage
4013 return $this->availabilityService->getDiscountCodeUsage($user_id, strtoupper(sanitize_text_field($discount_code)));
4014 }
4015 }
4016
4017