PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 trunk, at app/Controllers/BookingSessionController.php

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