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

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

4,675 lines 216.5 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 // ========================================
1210 // CSRF — booking-scoped action nonce
1211 // ========================================
1212 // The public_permission_callback on this route intentionally
1213 // bypasses WP's default cookie/nonce check so guests can hit it
1214 // at all. That bypass would otherwise leave the endpoint open
1215 // to cross-site forgery (any third-party page could POST a
1216 // booking using the visitor's session).
1217 //
1218 // We validate a booking-scoped action nonce here instead. The
1219 // token is minted at page-render time (FrontendAssetsProvider
1220 // injects it into `yatraBookingData.bookingNonce`) and the JS
1221 // forwards it in `X-Yatra-Booking-Nonce`. We also accept it in
1222 // the JSON body for any non-JS fallback flow.
1223 //
1224 // Returns 403 on failure — distinct from the 401 used by the
1225 // login/guest-checkout gates so frontends can distinguish
1226 // "security check failed" from "auth required".
1227 if (!$this->verifyBookingNonce($request, $data)) {
1228 return new WP_REST_Response([
1229 'success' => false,
1230 'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'),
1231 'code' => 'invalid_nonce',
1232 ], 403);
1233 }
1234
1235 // ========================================
1236 // REMAINING PAYMENT vs NEW BOOKING
1237 // ========================================
1238 // A leftover PHP session from "pay remaining balance" must not hijack a normal
1239 // checkout POST (full traveler payload). Only treat as remaining-payment when the
1240 // client is actually on that flow (hidden field) or sends no new-booking travelers.
1241 // Early return: process_remaining_payment() — no createBooking().
1242 if (yatra_has_remaining_session()) {
1243 $is_remaining_checkout = !empty($data['is_remaining_payment']);
1244 $travelers_payload = $data['travelers'] ?? null;
1245 $has_new_booking_travelers = is_array($travelers_payload) && count($travelers_payload) > 0;
1246
1247 if ($is_remaining_checkout) {
1248 return $this->process_remaining_payment($request);
1249 }
1250
1251 if ($has_new_booking_travelers) {
1252 yatra_clear_remaining_session();
1253 } else {
1254 return $this->process_remaining_payment($request);
1255 }
1256 }
1257
1258 // ========================================
1259 // NEW BOOKING CHECKOUT (not pay-remaining)
1260 // ========================================
1261 // Below: createBooking() runs once; payment is initiated afterward if amount_due > 0.
1262
1263 // ========================================
1264 // GET BOOKING SETTINGS
1265 // ========================================
1266 $settings = [
1267 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
1268 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1269 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
1270 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
1271 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
1272 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
1273 ];
1274
1275 // ========================================
1276 // CHECK LOGIN REQUIREMENT
1277 // ========================================
1278 if ($settings['require_login'] && !is_user_logged_in()) {
1279 return new WP_REST_Response([
1280 'success' => false,
1281 'message' => __('You must be logged in to make a booking.', 'yatra'),
1282 'code' => 'login_required',
1283 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
1284 ], 401);
1285 }
1286
1287 // Check guest checkout
1288 if (!$settings['allow_guest_checkout'] && !is_user_logged_in()) {
1289 return new WP_REST_Response([
1290 'success' => false,
1291 'message' => __('Guest checkout is not allowed. Please log in or create an account.', 'yatra'),
1292 'code' => 'guest_not_allowed',
1293 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
1294 ], 401);
1295 }
1296
1297 // ========================================
1298 // GUEST EMAIL VERIFICATION GATE
1299 // ========================================
1300 // When `require_guest_email_verification` is on AND the
1301 // customer is not logged in, the booking goes through a
1302 // two-step flow:
1303 // 1. Booking row is created with status='pending_verification'
1304 // so the operator sees the intent in the admin and the
1305 // cron can purge unverified rows after N days.
1306 // 2. A magic-link email is sent. Payment is NOT initiated
1307 // until the customer clicks the link.
1308 // 3. On click, /yatra/v1/booking/verify-email validates the
1309 // HMAC token, flips status to 'pending', and redirects
1310 // to the payment continuation URL.
1311 // Logged-in users skip this entirely — their email is already
1312 // verified by WordPress on registration.
1313 $needs_email_verification = !is_user_logged_in()
1314 && (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false);
1315
1316 // Get session data
1317 $session = yatra_get_booking_session();
1318
1319 // REST requests don't always carry PHPSESSID in the same scope as the
1320 // page that rendered the booking form (cookie path mismatches, output
1321 // buffering before session_start, server-side caching, etc). When
1322 // `$session` is empty we try to rehydrate from the transient backup that
1323 // BookingPageHandler writes when the form is rendered — the JS submit
1324 // now carries `booking_token` in the request body for exactly this
1325 // recovery path. Without this, `traveler_counts` / `pricing_type` /
1326 // `price_types` are lost and a `traveler_based` trip's
1327 // calculatePricing() collapses to 0, then BookingService rejects with
1328 // "Total amount must be greater than zero."
1329 if ((empty($session) || empty($session['trip_id'])) && !empty($data['booking_token'])) {
1330 $tokenFromBody = sanitize_text_field((string) $data['booking_token']);
1331 if ($tokenFromBody !== '') {
1332 $transient_data = get_transient($tokenFromBody);
1333 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
1334 $session = $transient_data;
1335 }
1336 }
1337 }
1338
1339 // Validate we have session data or direct booking data
1340 $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0);
1341
1342 if (!$trip_id) {
1343 return new WP_REST_Response([
1344 'success' => false,
1345 'message' => __('No trip selected for booking.', 'yatra'),
1346 ], 400);
1347 }
1348
1349 // Which booking-form sections are enabled (Pro Dynamic Form module).
1350 // The default config has every section enabled, so on existing/un-customised
1351 // sites $contact_enabled and $traveler_enabled are both true and the logic
1352 // below behaves exactly as before — only disabled sections change anything.
1353 $form_config = function_exists('yatra_get_booking_form_config') ? yatra_get_booking_form_config() : [];
1354 $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled'];
1355 $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled'];
1356
1357 // Get contact email - handle both flat and nested formats
1358 $contact_email = trim((string) ($data['contact_email'] ?? ''));
1359 $contact_phone = $data['contact_phone'] ?? '';
1360 $contact_first_name = $data['contact_first_name'] ?? '';
1361 $contact_last_name = $data['contact_last_name'] ?? '';
1362 $contact_country = $data['contact_country'] ?? '';
1363
1364 $contact_nationality = $data['contact_nationality'] ?? '';
1365 $contact_address = $data['contact_address'] ?? '';
1366
1367 // Emergency contact
1368 $emergency_name = $data['emergency_name'] ?? '';
1369 $emergency_phone = $data['emergency_phone'] ?? '';
1370 $emergency_relationship = $data['emergency_relationship'] ?? '';
1371
1372 // Travel details
1373 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
1374 $travelers = $data['travelers'] ?? [];
1375
1376 // EMAIL RESOLUTION: prefer the Contact email. When it's missing — the
1377 // Contact section is off, or the operator collects email through a
1378 // CUSTOM email-type field rather than the locked core `email` field —
1379 // resolve it from the form config instead, mirroring the admin
1380 // "form captures email" notice (contact + traveler sections). At least
1381 // one enabled form must capture an email; the form builder warns the
1382 // operator about this too. On a default form the core `email` field
1383 // already populated $contact_email, so none of the fallbacks run.
1384
1385 // (a) Custom email-type field in the Contact section (submitted as
1386 // contact_<id>). The core `email` field is already read above, so skip
1387 // it here.
1388 if ($contact_email === '' && $contact_enabled) {
1389 foreach ($this->emailFieldIds($form_config['contact_form'] ?? []) as $fid) {
1390 if ($fid === 'email') {
1391 continue;
1392 }
1393 $val = trim((string) ($data['contact_' . $fid] ?? ''));
1394 if ($val !== '' && is_email($val)) {
1395 $contact_email = $val;
1396 break;
1397 }
1398 }
1399 }
1400
1401 // (b) Fall back to a traveler email — the conventional `email` key OR
1402 // any traveler email-type field — adopting the lead traveler's
1403 // name/phone as the contact when the Contact section is off, so the
1404 // booking/customer isn't nameless. On a default form this checks only
1405 // $t['email'], identical to the original behaviour.
1406 if ($contact_email === '' && is_array($travelers)) {
1407 $traveler_email_ids = $traveler_enabled
1408 ? $this->emailFieldIds($form_config['traveler_form'] ?? [])
1409 : [];
1410 if (!in_array('email', $traveler_email_ids, true)) {
1411 $traveler_email_ids[] = 'email';
1412 }
1413 foreach ($travelers as $t) {
1414 if (!is_array($t)) {
1415 continue;
1416 }
1417 $found = '';
1418 foreach ($traveler_email_ids as $fid) {
1419 if (!empty($t[$fid]) && is_email((string) $t[$fid])) {
1420 $found = trim((string) $t[$fid]);
1421 break;
1422 }
1423 }
1424 if ($found !== '') {
1425 $contact_email = $found;
1426 if ($contact_first_name === '') { $contact_first_name = (string) ($t['first_name'] ?? ''); }
1427 if ($contact_last_name === '') { $contact_last_name = (string) ($t['last_name'] ?? ''); }
1428 if (empty($contact_phone) && !empty($t['phone'])) { $contact_phone = (string) $t['phone']; }
1429 break;
1430 }
1431 }
1432 }
1433
1434 // When the Traveler form is disabled there are no per-traveler fields, so
1435 // build traveler rows from the selected count and use the lead contact as
1436 // traveler 1 (book-by-count). Only runs when the section is off.
1437 if (!$traveler_enabled && (empty($travelers) || !is_array($travelers))) {
1438 $synth_count = (int) ($data['travelers_count']
1439 ?? $session['travelers']
1440 ?? (is_array($session['traveler_counts'] ?? null) ? array_sum(array_map('intval', $session['traveler_counts'])) : 0));
1441 $synth_count = max(1, $synth_count);
1442 $travelers = [];
1443 for ($i = 1; $i <= $synth_count; $i++) {
1444 $travelers[] = [
1445 'type' => 'traveler',
1446 'first_name' => $i === 1 ? $contact_first_name : '',
1447 'last_name' => $i === 1 ? $contact_last_name : '',
1448 'email' => $i === 1 ? $contact_email : '',
1449 ];
1450 }
1451 }
1452
1453 // Validate required fields — email is always required (resolved above).
1454 if ($contact_email === '' || !is_email($contact_email)) {
1455 return new WP_REST_Response([
1456 'success' => false,
1457 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
1458 ], 400);
1459 }
1460
1461 // Phone belongs to the Contact section. Require it only when that section
1462 // is enabled AND the phone field is itself enabled+required in the config,
1463 // so an operator who made phone optional (or disabled it) via the Dynamic
1464 // Form module isn't blocked on a field the customer never saw. On a
1465 // default form phone is locked+required, so this is unchanged for
1466 // existing Free/Pro users.
1467 $contact_phone_required = false;
1468 if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1469 foreach ($form_config['contact_form']['fields'] as $cf) {
1470 if (is_array($cf) && ($cf['id'] ?? '') === 'phone') {
1471 $cf_enabled = !isset($cf['enabled']) || (bool) $cf['enabled'];
1472 $contact_phone_required = $cf_enabled && !empty($cf['required']);
1473 break;
1474 }
1475 }
1476 } elseif ($contact_enabled) {
1477 // No field metadata available (legacy/edge): preserve the original
1478 // "require phone when contact is on" behaviour.
1479 $contact_phone_required = true;
1480 }
1481 if ($contact_phone_required && empty($contact_phone)) {
1482 return new WP_REST_Response([
1483 'success' => false,
1484 'message' => __('Phone number is required.', 'yatra'),
1485 ], 400);
1486 }
1487
1488 if (empty($travel_date)) {
1489 return new WP_REST_Response([
1490 'success' => false,
1491 'message' => __('Travel date is required.', 'yatra'),
1492 ], 400);
1493 }
1494
1495 if (empty($travelers) || !is_array($travelers)) {
1496 return new WP_REST_Response([
1497 'success' => false,
1498 'message' => __('At least one traveler is required.', 'yatra'),
1499 ], 400);
1500 }
1501
1502 // Server-side enforcement of required form fields (incl. CUSTOM fields).
1503 // Gated on the Dynamic Form Field module: free/default installs keep their
1504 // existing validation untouched. Mirrors the frontend's required rules so
1505 // a crafted request can't bypass them; respects the operator's config
1506 // (only enabled+required fields in enabled sections are checked).
1507 if (function_exists('apply_filters') && apply_filters('yatra_dynamic_form_field_enabled', false)) {
1508 $required_error = $this->validateRequiredFormFields(
1509 is_array($form_config) ? $form_config : [],
1510 $data,
1511 $travelers,
1512 $contact_enabled,
1513 $traveler_enabled
1514 );
1515 if ($required_error !== null) {
1516 return new WP_REST_Response([
1517 'success' => false,
1518 'message' => $required_error,
1519 ], 400);
1520 }
1521 }
1522
1523 // Get trip data
1524 $trip = $this->tripRepository->findPublished($trip_id);
1525
1526 if (!$trip) {
1527 return new WP_REST_Response([
1528 'success' => false,
1529 'message' => __('Trip not found.', 'yatra'),
1530 ], 404);
1531 }
1532
1533 // ========================================
1534 // PRICING via CalculationService (single source of truth)
1535 // ========================================
1536 // Count only actual travelers (exclude contact and emergency contact)
1537 $travelers_count = 0;
1538 foreach ($travelers as $traveler) {
1539 if (isset($traveler['type']) && $traveler['type'] === 'traveler') {
1540 $travelers_count++;
1541 }
1542 }
1543 // Fallback if no type is set (legacy data)
1544 if ($travelers_count === 0) {
1545 $travelers_count = count($travelers);
1546 }
1547
1548 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
1549 if ($payment_method === '') {
1550 $payment_method = 'full';
1551 }
1552 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
1553
1554 // Get coupon code from request OR session
1555 $coupon_code = sanitize_text_field($data['coupon_code'] ?? '');
1556 if (empty($coupon_code) && !empty($session['coupon']['code'])) {
1557 $coupon_code = sanitize_text_field($session['coupon']['code']);
1558 }
1559
1560 $departure_time = $data['departure_time'] ?? ($session['departure_time'] ?? '');
1561 $additional_services = $data['additional_services'] ?? ($session['additional_services'] ?? []);
1562 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
1563
1564 // Build traveler_counts from travelers array or session
1565 $traveler_counts = [];
1566 if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) {
1567 $traveler_counts = $data['traveler_counts'];
1568 } elseif (!empty($session['traveler_counts']) && is_array($session['traveler_counts'])) {
1569 $traveler_counts = $session['traveler_counts'];
1570 } else {
1571 // Build from travelers array if category_id is present
1572 foreach ($travelers as $traveler) {
1573 $categoryId = $traveler['category_id'] ?? null;
1574 if ($categoryId) {
1575 if (!isset($traveler_counts[$categoryId])) {
1576 $traveler_counts[$categoryId] = 0;
1577 }
1578 $traveler_counts[$categoryId]++;
1579 }
1580 }
1581 if (empty($traveler_counts) && $travelers_count > 0) {
1582 $traveler_counts['default'] = $travelers_count;
1583 }
1584 }
1585
1586 // Pricing single source of truth: ALWAYS use calculateFromSession.
1587 //
1588 // The booking session is kept in sync by the JS layer — every
1589 // service toggle, traveler-count change, and coupon apply/remove
1590 // POSTs to /booking/session, which updates the transient. The
1591 // sidebar's `/booking/summary` then renders from
1592 // `calculateFromSession($session, …)`. If we built a second pricing
1593 // path here from form fields, any subtle drift (form missing a
1594 // category_id, traveler_counts aggregated as 'default', etc) would
1595 // produce a different total than the customer saw — they'd be
1596 // charged something they didn't agree to. So we merge the latest
1597 // session with any form-submitted overrides (services list,
1598 // travel_date, availability_id) and let calculateFromSession do
1599 // the math the same way the sidebar did.
1600 $calculationService = new CalculationService();
1601 $session_for_pricing = is_array($session) ? $session : [];
1602 $session_for_pricing['trip_id'] = $trip_id;
1603 $session_for_pricing['travelers'] = $travelers_count;
1604 // Prefer per-category counts when we have them — otherwise let
1605 // calculateFromSession fall back to its internal handling.
1606 if (!empty($traveler_counts) && is_array($traveler_counts)) {
1607 $session_for_pricing['traveler_counts'] = $traveler_counts;
1608 }
1609 if (!empty($travel_date)) {
1610 $session_for_pricing['travel_date'] = $travel_date;
1611 }
1612 if (!empty($departure_time)) {
1613 $session_for_pricing['departure_time'] = $departure_time;
1614 }
1615 if (!empty($availability_id) && is_numeric($availability_id)) {
1616 $session_for_pricing['availability_id'] = (int) $availability_id;
1617 }
1618 if (is_array($additional_services)) {
1619 $session_for_pricing['additional_services'] = array_values(array_map('intval', $additional_services));
1620 }
1621
1622 try {
1623 $pricing = $calculationService->calculateFromSession(
1624 $session_for_pricing,
1625 $coupon_code,
1626 $payment_method
1627 );
1628 } catch (\Throwable $e) {
1629 $pricing = [];
1630 }
1631
1632 // If the session is so degenerate that even calculateFromSession
1633 // couldn't make a positive total (e.g. no traveler_counts at all),
1634 // surface that cleanly so the booking is rejected with a friendly
1635 // error rather than silently charging $0.
1636 if (((float) ($pricing['final_total'] ?? 0)) <= 0) {
1637 try {
1638 $sessionPricing = $calculationService->calculatePricing([
1639 'trip_id' => $trip_id,
1640 'travelers_count' => $travelers_count,
1641 'traveler_counts' => $traveler_counts,
1642 'travel_date' => $travel_date,
1643 'departure_time' => $departure_time,
1644 'selected_services' => $additional_services,
1645 'availability_id' => (!empty($availability_id) && is_numeric($availability_id)) ? (int) $availability_id : null,
1646 'coupon_code' => $coupon_code,
1647 'payment_method' => $payment_method,
1648 ]);
1649 if (((float) ($sessionPricing['final_total'] ?? 0)) > 0) {
1650 $pricing = $sessionPricing;
1651 }
1652 } catch (\Throwable $e) {
1653 // Keep $pricing as-is; downstream guard will surface a clean error.
1654 }
1655 }
1656
1657 // Enforce per-group category size limits (min_pax / max_pax). A per-group
1658 // category charges one flat price for a group within the configured
1659 // range, so a selection outside that range must be rejected before we
1660 // charge. No-op for per-person categories and categories with no limits.
1661 $group_size_error = $this->validateGroupSizeLimits($pricing['price_types'] ?? [], $traveler_counts);
1662 if ($group_size_error !== null) {
1663 return new WP_REST_Response([
1664 'success' => false,
1665 'message' => $group_size_error,
1666 ], 400);
1667 }
1668
1669 // Extract pricing results
1670 $total_amount = $pricing['final_total'];
1671 $amount_due = $pricing['amount_due'];
1672 $amount_paid = $pricing['amount_paid'];
1673 $tax_calculation = $pricing['tax_calculation'];
1674 $tax_breakdown = $tax_calculation['tax_breakdown'];
1675 $total_tax_amount = $tax_calculation['total_tax_amount'];
1676
1677 $tax_inclusive = $tax_calculation['tax_inclusive'];
1678 $tax_rate = $tax_calculation['tax_rate'];
1679 $subtotal_before_discount = $pricing['subtotal'];
1680 $discount_amount = $pricing['total_discount_amount'];
1681 $discount_code = $pricing['group_discount']['code'] ?? ($pricing['coupon_discount']['code'] ?? null);
1682
1683 // ========================================
1684 // CAPACITY / WAITLIST
1685 // ========================================
1686 $resolvedAvailabilityForWaitlist = null;
1687 if ($travel_date !== '') {
1688 try {
1689 $availResolutionSvc = new \Yatra\Services\AvailabilityResolutionService();
1690 $resolvedAvailabilityForWaitlist = $availResolutionSvc->resolveAvailabilityForDate(
1691 $trip_id,
1692 $travel_date,
1693 $departure_time !== '' ? $departure_time : null
1694 );
1695 } catch (\Throwable $e) {
1696 // Leave null; legacy checkout without strict availability still works
1697 }
1698 }
1699
1700 $isWaitlistCheckout = false;
1701
1702 if ($resolvedAvailabilityForWaitlist !== null) {
1703 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1704 if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1705 return new WP_REST_Response([
1706 'success' => false,
1707 'message' => __('This departure is not open for booking.', 'yatra'),
1708 'code' => 'date_blocked',
1709 ], 400);
1710 }
1711 // Respect explicit sold_out even if seats_available is stale — waitlist only if allowed
1712 if ($availStatus === 'sold_out') {
1713 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
1714 $isWaitlistCheckout = true;
1715 } else {
1716 return new WP_REST_Response([
1717 'success' => false,
1718 'message' => __('This departure is sold out.', 'yatra'),
1719 'code' => 'sold_out',
1720 ], 400);
1721 }
1722 }
1723 }
1724
1725 if (
1726 !$isWaitlistCheckout
1727 && \Yatra\Services\WaitlistService::isInsufficientSeats($resolvedAvailabilityForWaitlist, $travelers_count)
1728 ) {
1729 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
1730 $isWaitlistCheckout = true;
1731 } else {
1732 return new WP_REST_Response([
1733 'success' => false,
1734 'message' => __('This departure is full. Waitlist is not available for this trip or date.', 'yatra'),
1735 'code' => 'sold_out',
1736 ], 400);
1737 }
1738 }
1739
1740 // Generate booking reference using the same method as BookingService
1741 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1742 $booking_reference = $bookingRepository->generateReference();
1743
1744 Logger::debug('Yatra booking create: pricing and payment selection', [
1745 'context' => 'booking_create_rest',
1746 'trip_id' => $trip_id,
1747 'booking_reference' => $booking_reference,
1748 'flexible_payments_enabled' => (bool) apply_filters('yatra_flexible_payments_enabled', false),
1749 'payment_method' => $payment_method,
1750 'payment_gateway' => $payment_gateway,
1751 'total_amount' => round((float) $total_amount, 4),
1752 'amount_due' => round((float) $amount_due, 4),
1753 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20, ['trip_id' => $trip_id]),
1754 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30, ['trip_id' => $trip_id]),
1755 ]);
1756
1757 // Prepare contact data
1758 $contact_data = [
1759 'first_name' => sanitize_text_field($contact_first_name),
1760 'last_name' => sanitize_text_field($contact_last_name),
1761 'email' => sanitize_email($contact_email),
1762 'phone' => sanitize_text_field($contact_phone),
1763 'country' => sanitize_text_field($contact_country),
1764 'nationality' => sanitize_text_field($contact_nationality),
1765 'address' => sanitize_text_field($contact_address),
1766 ];
1767 // Persist every submitted contact_* field (incl. CUSTOM fields the
1768 // operator added to the form) so the data isn't lost and is usable as
1769 // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1770 foreach ($data as $field_key => $field_value) {
1771 if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1772 $field_id = substr($field_key, strlen('contact_'));
1773 if ($field_id !== '' && $field_id !== 'data' && !isset($contact_data[$field_id])) {
1774 $contact_data[$field_id] = sanitize_text_field((string) $field_value);
1775 }
1776 }
1777 }
1778
1779 // Prepare emergency contact data
1780 $emergency_data = [
1781 'name' => sanitize_text_field($emergency_name),
1782 'phone' => sanitize_text_field($emergency_phone),
1783 'relationship' => sanitize_text_field($emergency_relationship),
1784 ];
1785 // Same dynamic capture for emergency_* custom fields.
1786 foreach ($data as $field_key => $field_value) {
1787 if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1788 $field_id = substr($field_key, strlen('emergency_'));
1789 if ($field_id !== '' && $field_id !== 'contact' && !isset($emergency_data[$field_id])) {
1790 $emergency_data[$field_id] = sanitize_text_field((string) $field_value);
1791 }
1792 }
1793 }
1794
1795 // Sanitize travelers data
1796 $sanitized_travelers = [];
1797 foreach ($travelers as $traveler) {
1798 if (is_array($traveler)) {
1799 $sanitized_traveler = [];
1800 foreach ($traveler as $key => $value) {
1801 $sk = sanitize_key((string) $key);
1802 if (is_array($value)) {
1803 $sanitized_traveler[$sk] = array_map(static function ($v) {
1804 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1805 }, $value);
1806 } else {
1807 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1808 }
1809 }
1810 $sanitized_travelers[] = $sanitized_traveler;
1811 }
1812 }
1813
1814 // ========================================
1815 // CREATE WORDPRESS USER ACCOUNT (if requested)
1816 // ========================================
1817 $user_id = get_current_user_id();
1818 $create_account = !empty($data['create_account']) && !empty($data['account_password']);
1819
1820 if (!$user_id && $create_account && !empty($data['account_password'])) {
1821 $account_password = sanitize_text_field($data['account_password']);
1822 $account_password_confirm = sanitize_text_field($data['account_password_confirm'] ?? '');
1823
1824 // Validate password
1825 if (strlen($account_password) < 8) {
1826 return new WP_REST_Response([
1827 'success' => false,
1828 'message' => __('Password must be at least 8 characters long.', 'yatra'),
1829 ], 400);
1830 }
1831
1832 if ($account_password !== $account_password_confirm) {
1833 return new WP_REST_Response([
1834 'success' => false,
1835 'message' => __('Passwords do not match.', 'yatra'),
1836 ], 400);
1837 }
1838
1839 // Check if user already exists
1840 if (email_exists($contact_email)) {
1841 return new WP_REST_Response([
1842 'success' => false,
1843 'message' => __('An account with this email already exists. Please log in.', 'yatra'),
1844 ], 400);
1845 }
1846
1847 // Create WordPress user
1848 $username = sanitize_user(current(explode('@', $contact_email)));
1849 $original_username = $username;
1850 $counter = 1;
1851
1852 while (username_exists($username)) {
1853 $username = $original_username . $counter;
1854 $counter++;
1855 }
1856
1857 $user_id = wp_create_user($username, $account_password, $contact_email);
1858
1859 if (is_wp_error($user_id)) {
1860 return new WP_REST_Response([
1861 'success' => false,
1862 'message' => wp_strip_all_tags($user_id->get_error_message()),
1863 ], 400);
1864 }
1865
1866 // Assign Yatra Customer role
1867 $user = new \WP_User($user_id);
1868 $user->set_role('yatra_customer');
1869
1870 // Update user meta with contact information
1871 wp_update_user([
1872 'ID' => $user_id,
1873 'first_name' => $contact_data['first_name'],
1874 'last_name' => $contact_data['last_name'],
1875 'display_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
1876 ]);
1877
1878 if (!empty($contact_phone)) {
1879 update_user_meta($user_id, 'billing_phone', $contact_phone);
1880 update_user_meta($user_id, 'phone', $contact_phone);
1881 }
1882
1883 if (!empty($contact_country)) {
1884 update_user_meta($user_id, 'billing_country', $contact_country);
1885 }
1886
1887 if (!empty($contact_address)) {
1888 update_user_meta($user_id, 'billing_address_1', $contact_address);
1889 }
1890
1891 // Auto-login the user
1892 wp_set_current_user($user_id);
1893 wp_set_auth_cookie($user_id);
1894 }
1895
1896 // ========================================
1897 // CREATE OR UPDATE CUSTOMER
1898 // ========================================
1899 // Customers are separate from WordPress users - this is for CRM purposes
1900 $customer_id = null;
1901 try {
1902 $customer_id = $this->customerRepository->findOrCreate([
1903 'user_id' => $user_id ?: null,
1904 'first_name' => $contact_data['first_name'],
1905 'last_name' => $contact_data['last_name'],
1906 'email' => $contact_data['email'],
1907 'phone' => $contact_data['phone'],
1908 'address' => $contact_data['address'],
1909 'country' => $contact_data['country'],
1910 'nationality' => $contact_data['nationality'],
1911 'emergency_name' => $emergency_data['name'],
1912 'emergency_phone' => $emergency_data['phone'],
1913 'emergency_relationship' => $emergency_data['relationship'],
1914 'newsletter_optin' => !empty($data['subscribe_newsletter']),
1915 'total_spent' => $total_amount,
1916 'source' => 'booking',
1917 ]);
1918 } catch (\Exception $e) {
1919 // Log error but continue - customer creation is not critical
1920 }
1921
1922 // Create booking using BookingService
1923 $booking_service = new \Yatra\Services\BookingService();
1924
1925 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1926
1927 // Ensure DB availability row is linked so inventory sync can subtract seats after booking.
1928 if (!$isWaitlistCheckout && empty($availability_id) && $trip_id > 0 && $travel_date !== '') {
1929 try {
1930 $dtForResolve = is_string($departure_time) ? trim($departure_time) : '';
1931 $dtForResolve = $dtForResolve !== '' ? $dtForResolve : null;
1932 $resolvedCheckout = (new \Yatra\Services\AvailabilityResolutionService())->resolveAvailabilityForDate(
1933 $trip_id,
1934 sanitize_text_field($travel_date),
1935 $dtForResolve
1936 );
1937 if (is_object($resolvedCheckout)
1938 && ($resolvedCheckout->source ?? '') === 'availability_date'
1939 && isset($resolvedCheckout->id)
1940 && is_numeric($resolvedCheckout->id)
1941 && (int) $resolvedCheckout->id > 0) {
1942 $availability_id = (int) $resolvedCheckout->id;
1943 }
1944 } catch (\Throwable $e) {
1945 // Recurring / trip-default checkout: no single availability_dates row
1946 }
1947 }
1948
1949 // Always defer createBooking's copy: session sends one rich confirmation at the end, or
1950 // transactional confirmation before payment redirect (see processPaymentGateway returns).
1951 $booking_data = [
1952 'reference' => $booking_reference,
1953 'trip_id' => $trip_id,
1954 'customer_id' => $customer_id,
1955 'user_id' => $user_id ?: null,
1956 'contact_first_name' => $contact_data['first_name'],
1957 'contact_last_name' => $contact_data['last_name'],
1958 'contact_email' => $contact_data['email'],
1959 'contact_phone' => $contact_data['phone'],
1960 'contact_country' => $contact_data['country'],
1961 'contact_data' => wp_json_encode($contact_data),
1962 'emergency_contact' => wp_json_encode($emergency_data),
1963 'travel_date' => sanitize_text_field($travel_date),
1964 'availability_id' => !empty($availability_id) ? (int) $availability_id : null,
1965 'departure_time' => is_string($departure_time) ? trim($departure_time) : '',
1966 'travelers_count' => $travelers_count,
1967 'total_amount' => $total_amount,
1968 'amount_paid' => 0,
1969 'amount_due' => $amount_due,
1970 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1971 'discount_amount' => $discount_amount,
1972 'discount_code' => $discount_code,
1973 'payment_method' => $payment_method,
1974 'payment_gateway' => $payment_gateway,
1975 'status' => 'pending',
1976 'special_requests' => sanitize_textarea_field($data['special_requests'] ?? ''),
1977 'newsletter_optin' => !empty($data['subscribe_newsletter']) ? 1 : 0,
1978 'ip_address' => $this->getClientIp(),
1979 'created_at' => current_time('mysql'),
1980 'updated_at' => current_time('mysql'),
1981 // Tax fields
1982 'subtotal' => $subtotal_before_discount,
1983 'tax_amount' => $total_tax_amount,
1984 'tax_rate' => $tax_rate,
1985 'tax_inclusive' => $tax_inclusive,
1986 'tax_details' => wp_json_encode($tax_breakdown),
1987 // Itinerary costs
1988 'itinerary_costs' => wp_json_encode($pricing['itinerary_costs'] ?? []),
1989 'itinerary_costs_total' => ($pricing['itinerary_costs_total'] ?? 0),
1990 'skip_initial_customer_confirmation' => true,
1991 ];
1992
1993 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1994 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1995 $booking_data['status'] = 'waitlist';
1996 // Preserve the customer's real OFFLINE gateway + deposit/partial
1997 // choice (Bank Transfer / Pay Later). No charge is taken for a
1998 // waitlisted slot regardless, and waitlist promotion only flips the
1999 // status — it never restores the selection — so pinning to
2000 // pay_later/full here would permanently drop the chosen gateway AND
2001 // wipe the deposit (BookingService recomputes amount_due from
2002 // payment_method). Online gateways stay deferred to pay_later/full
2003 // since a card can't be charged for a non-guaranteed slot.
2004 if (!$is_offline_gateway) {
2005 $booking_data['payment_gateway'] = 'pay_later';
2006 $booking_data['payment_method'] = 'full';
2007 }
2008 }
2009
2010 // Hold the booking in `pending_verification` until the guest
2011 // clicks the magic link. Payment is initiated only after the
2012 // status flips to 'pending' (in the verify-email endpoint).
2013 // We also pin the gateway to `pay_later` here because the
2014 // payment selection at this point would otherwise lock the
2015 // operator into a specific gateway before the customer has
2016 // even confirmed their email — better to defer that choice
2017 // until verification completes and the regular checkout
2018 // resumes.
2019 if ($needs_email_verification && !$isWaitlistCheckout) {
2020 $booking_data['status'] = 'pending_verification';
2021 // Defer the gateway choice ONLY for online gateways: a real charge
2022 // would otherwise lock the customer into a gateway before they have
2023 // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2024 // Later) there is no charge to defer, and the verify-email endpoint
2025 // does not restore the selection afterwards — so pinning to
2026 // pay_later/full here would permanently drop the customer's chosen
2027 // gateway AND their deposit/partial amount (BookingService recomputes
2028 // amount_due from payment_method, so 'full' wipes the deposit).
2029 // Preserve the real selection for offline gateways.
2030 if (!$is_offline_gateway) {
2031 $booking_data['payment_gateway'] = 'pay_later';
2032 $booking_data['payment_method'] = 'full';
2033 }
2034 }
2035
2036 try {
2037 $booking = $booking_service->createBooking($booking_data);
2038 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
2039 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
2040 if (empty($booking['success'])) {
2041 return new WP_REST_Response([
2042 'success' => false,
2043 'message' => $booking['message'] ?? __('Failed to create booking. Please try again.', 'yatra'),
2044 'error' => $booking['message'] ?? '',
2045 'errors' => $booking['errors'] ?? null,
2046 ], 500);
2047 }
2048 } catch (\Exception $e) {
2049 return new WP_REST_Response([
2050 'success' => false,
2051 'message' => __('Failed to create booking. Please try again.', 'yatra'),
2052 'error' => $e->getMessage(),
2053 ], 500);
2054 }
2055
2056 if (empty($booking_id)) {
2057 return new WP_REST_Response([
2058 'success' => false,
2059 'message' => __('Failed to create booking. Please try again.', 'yatra'),
2060 'error' => __('Booking ID was not generated.', 'yatra'),
2061 ], 500);
2062 }
2063
2064 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
2065 \Yatra\Services\WaitlistService::incrementAvailabilityWaitlistCount(
2066 (int) $resolvedAvailabilityForWaitlist->id,
2067 $travelers_count
2068 );
2069 }
2070
2071 // Get the actual booking reference from database
2072 $bookingRepository = new \Yatra\Repositories\BookingRepository();
2073 $saved_booking = $bookingRepository->find($booking_id);
2074 if ($saved_booking && !empty($saved_booking->reference)) {
2075 $booking_reference = $saved_booking->reference;
2076 }
2077
2078 // ========================================
2079 // SAVE ADDITIONAL SERVICES (Premium Feature)
2080 // ========================================
2081 /**
2082 * Action: Save additional services for the booking
2083 * Allows premium modules to save selected services with the booking
2084 *
2085 * @param int $booking_id The booking ID
2086 * @param int $trip_id The trip ID
2087 * @param array $data The booking request data (contains selected_services)
2088 * @param int $travelers_count Total number of travelers
2089 * @param int $duration_days Trip duration in days
2090 * @param float $base_amount Trip base price (pre-services, pre-discount) —
2091 * the authoritative base used by the pricing engine for this
2092 * booking. Listeners persisting percentage-type services price
2093 * them against this exact value so the saved line-items reconcile
2094 * with the charged total. Added in a backward-compatible way:
2095 * existing 5-arg listeners simply ignore it.
2096 * @since 3.0.0
2097 */
2098 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
2099 if (!isset($data['selected_services'])) {
2100 $data['selected_services'] = $data['additional_services']
2101 ?? $session['additional_services']
2102 ?? [];
2103 }
2104 if (!is_array($data['selected_services'])) {
2105 $data['selected_services'] = [];
2106 }
2107 $data['selected_services'] = array_map('intval', $data['selected_services']);
2108 do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
2109
2110 // ========================================
2111 // SAVE TRAVELLERS TO NORMALIZED TABLES
2112 // ========================================
2113 // Each traveller is saved to yatra_booking_travellers table
2114 // Their dynamic fields are saved to yatra_booking_traveller_meta table
2115 foreach ($sanitized_travelers as $index => $traveler_fields) {
2116 // First traveller (index 0) is always the lead traveller
2117 $is_lead = ($index === 0);
2118
2119 // Create traveller record with all their fields stored in meta
2120 $this->travellerRepository->create(
2121 $booking_id,
2122 $index,
2123 $is_lead,
2124 $traveler_fields
2125 );
2126 }
2127
2128 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
2129 yatra_clear_booking_session();
2130 if ($settings['booking_confirmation']) {
2131 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
2132 }
2133
2134 return new WP_REST_Response([
2135 'success' => true,
2136 'message' => __('You are on the waitlist. We will contact you if a space opens up.', 'yatra'),
2137 'data' => [
2138 'booking_id' => $booking_id,
2139 'reference' => $booking_reference,
2140 'status' => 'waitlist',
2141 'waitlist' => true,
2142 'redirect_url' => $this->getConfirmationUrl($booking_reference),
2143 'customer_email' => $contact_data['email'],
2144 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
2145 'trip_id' => $trip_id,
2146 'trip_date' => $travel_date,
2147 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2148 'total_amount' => $total_amount,
2149 'amount_due' => $amount_due,
2150 ],
2151 ]);
2152 }
2153
2154 // Departure link + booked_count: handled inside BookingService::createBooking (and inventory sync hooks).
2155 // Persist travel window on the booking row for reporting (optional columns).
2156 if (!empty($travel_date)) {
2157 try {
2158 $start_date = $travel_date;
2159 $duration_days = !empty($trip->duration_days) ? (int) $trip->duration_days : 1;
2160 $end_date = date('Y-m-d', strtotime($start_date . ' + ' . ($duration_days - 1) . ' days'));
2161 $bookingColumns = $this->bookingRepository->getTableColumns();
2162 $bookingUpdateData = [];
2163 if (in_array('start_date', $bookingColumns, true)) {
2164 $bookingUpdateData['start_date'] = $start_date;
2165 }
2166 if (in_array('end_date', $bookingColumns, true)) {
2167 $bookingUpdateData['end_date'] = $end_date;
2168 }
2169 if ($bookingUpdateData !== []) {
2170 $this->bookingRepository->update($booking_id, $bookingUpdateData);
2171 }
2172 } catch (\Exception $e) {
2173 // Non-fatal
2174 }
2175 }
2176
2177 // Clear booking session
2178 yatra_clear_booking_session();
2179
2180 // ========================================
2181 // GUEST EMAIL VERIFICATION — INTERCEPT
2182 // ========================================
2183 // Booking row + travelers + services are already saved at
2184 // this point with status='pending_verification'. Send the
2185 // magic-link email, return a structured "check your email"
2186 // response, and DO NOT initiate payment. The customer's
2187 // click on the verify-email endpoint transitions the booking
2188 // to 'pending' and emits the payment-continuation URL.
2189 if ($needs_email_verification) {
2190 $verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl(
2191 (int) $booking_id,
2192 (string) $contact_data['email']
2193 );
2194
2195 // Variables piped into the template email. All standard
2196 // booking merge tags resolve normally (the row exists);
2197 // we also pass intro_paragraph + footer_note + the
2198 // expiry banner so operators that haven't customised
2199 // the template still get good defaults.
2200 $email_vars = [];
2201 if ($saved_booking !== null) {
2202 $email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
2203 }
2204 // Belt-and-braces: ensure customer name + email are
2205 // populated even when variablesFromBooking returned an
2206 // empty shell (which shouldn't happen, but if it does
2207 // we don't want the email to render "Hi ,").
2208 $email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']);
2209 $email_vars['customer_name'] = (string) ($email_vars['customer_name']
2210 ?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? '')));
2211 $email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name']
2212 ?? ($contact_data['first_name'] ?? ''));
2213 $email_vars['verification_link'] = $verify_url;
2214 $email_vars['intro_paragraph'] = __(
2215 "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.",
2216 'yatra'
2217 );
2218 $email_vars['footer_note'] = __(
2219 "If you didn't make this booking, you can safely ignore this email — no charges have been made.",
2220 'yatra'
2221 );
2222 $email_vars['expiry_notice_html'] = '<strong>'
2223 . esc_html__('This link expires in 48 hours.', 'yatra')
2224 . '</strong>';
2225
2226 \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2227 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2228 (string) $contact_data['email'],
2229 $email_vars
2230 );
2231
2232 return new WP_REST_Response([
2233 'success' => true,
2234 'code' => 'email_verification_required',
2235 'message' => __(
2236 "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.",
2237 'yatra'
2238 ),
2239 'data' => [
2240 'booking_id' => $booking_id,
2241 'reference' => $booking_reference,
2242 'email' => $contact_data['email'],
2243 'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600),
2244 ],
2245 ]);
2246 }
2247
2248 // Check if this is an offline gateway
2249 $is_offline = $is_offline_gateway;
2250
2251 // For online gateways, create payment intent and return redirect URL
2252 if (!$is_offline && $amount_due > 0) {
2253 // Build payment params - merge with request data so gateways can access their own tokens
2254 $payment_params = array_merge($data, [
2255 'booking_id' => $booking_id,
2256 'reference' => $booking_reference,
2257 'amount' => $amount_due,
2258 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2259 'customer_email' => $contact_data['email'],
2260 'customer_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
2261 'trip_title' => $trip->title,
2262 ]);
2263
2264 // Process payment based on gateway
2265 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
2266
2267 if ($payment_result['success']) {
2268 // Handle redirect-based gateways (PayPal, eSewa, Khalti, etc.)
2269 if (!empty($payment_result['payment_url'])) {
2270 if ($settings['booking_confirmation']) {
2271 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
2272 }
2273 return new WP_REST_Response([
2274 'success' => true,
2275 'message' => __('Booking created. Redirecting to payment...', 'yatra'),
2276 'data' => [
2277 'booking_id' => $booking_id,
2278 'reference' => $booking_reference,
2279 'payment_url' => $payment_result['payment_url'],
2280 ],
2281 ]);
2282 }
2283
2284 // Handle client-side payment gateways (Stripe, Razorpay, Square, etc.)
2285 if (!empty($payment_result['requires_action'])) {
2286 if ($settings['booking_confirmation']) {
2287 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
2288 }
2289 return new WP_REST_Response([
2290 'success' => true,
2291 'message' => __('Booking created. Complete payment...', 'yatra'),
2292 'data' => array_merge([
2293 'booking_id' => $booking_id,
2294 'reference' => $booking_reference,
2295 ], $payment_result),
2296 ]);
2297 }
2298 }
2299
2300 // If payment processing failed, return error so user can fix the issue
2301 if (!$payment_result['success']) {
2302 $errorMessage = $payment_result['error'] ?? $payment_result['message'] ?? __('Payment processing failed. Please try again.', 'yatra');
2303 return new WP_REST_Response([
2304 'success' => false,
2305 'message' => $errorMessage,
2306 'data' => [
2307 'booking_id' => $booking_id,
2308 'reference' => $booking_reference,
2309 'payment_error' => true,
2310 ],
2311 ]);
2312 }
2313 }
2314
2315 // ========================================
2316 // DETERMINE BOOKING STATUS
2317 // ========================================
2318 // Priority:
2319 // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
2320 // 2. For pay_later: auto_confirm_pay_later setting
2321 // 3. For bank_transfer: always pending until verified
2322
2323 $booking_status = 'pending';
2324 $status_message = __('Booking received!', 'yatra');
2325
2326 // Check if auto-confirm all bookings is enabled
2327 if ($settings['auto_confirm_bookings']) {
2328 // Auto-confirm is enabled - confirm immediately regardless of payment
2329 $booking_status = 'confirmed';
2330 $status_message = __('Booking confirmed!', 'yatra');
2331 } elseif ($payment_gateway === 'pay_later') {
2332 // Pay Later: Check the specific pay_later auto-confirm setting
2333 if ($settings['auto_confirm_pay_later']) {
2334 $booking_status = 'confirmed';
2335 $status_message = __('Booking confirmed! Payment will be collected later.', 'yatra');
2336 } else {
2337 $booking_status = 'pending';
2338 $status_message = __('Booking received! We will contact you to arrange payment.', 'yatra');
2339 }
2340 } elseif ($payment_gateway === 'bank_transfer') {
2341 // Bank Transfer: Always pending until payment is verified by admin
2342 $booking_status = 'pending';
2343 $status_message = __('Booking received! Please complete the bank transfer. We will confirm once payment is verified.', 'yatra');
2344 }
2345
2346 // Calculate booking expiry time for pending bookings
2347 $expiry_datetime = null;
2348 if ($booking_status === 'pending' && $settings['booking_expiry_hours'] > 0) {
2349 $expiry_datetime = date('Y-m-d H:i:s', strtotime('+' . $settings['booking_expiry_hours'] . ' hours'));
2350 }
2351
2352 // Set confirmed_at if auto-confirmed
2353 $confirmed_at = ($booking_status === 'confirmed') ? current_time('mysql') : null;
2354
2355 // Update booking status with additional metadata
2356 $update_data = [
2357 'status' => $booking_status,
2358 'payment_status' => 'pending', // No payment made yet for offline gateways
2359 ];
2360
2361 if ($confirmed_at) {
2362 $update_data['confirmed_at'] = $confirmed_at;
2363 }
2364
2365 if ($expiry_datetime) {
2366 $update_data['expires_at'] = $expiry_datetime;
2367 }
2368
2369 // Use repository to update booking
2370 $this->bookingRepository->update($booking_id, $update_data);
2371
2372 /**
2373 * yatra_booking_created already fired from BookingService::createBooking — do not fire again here
2374 * (duplicate admin + Pro automation).
2375 *
2376 * Synthetic pending→confirmed on the same request duplicates Pro "booking.confirmed" sequences with the
2377 * checkout confirmation email. Skip by default; restore with:
2378 * add_filter('yatra_skip_checkout_autoconfirm_status_changed_event', '__return_false');
2379 */
2380 if ($booking_status === 'confirmed') {
2381 if (!apply_filters('yatra_skip_checkout_autoconfirm_status_changed_event', true, (int) $booking_id, 'pending', 'confirmed')) {
2382 do_action('yatra_booking_status_changed', (int) $booking_id, 'pending', 'confirmed');
2383 }
2384 // Trip Consent / Google Calendar listen on `yatra_booking_confirmed` (not fired by status_changed skip above).
2385 \yatra_trigger_booking_confirmed((int) $booking_id, 'pending');
2386 }
2387
2388 // ========================================
2389 // SEND CONFIRMATION EMAIL
2390 // ========================================
2391 if ($settings['booking_confirmation']) {
2392 $this->sendBookingConfirmationEmail($booking_id, $booking_reference, $trip, [
2393 'contact' => $contact_data,
2394 'emergency' => $emergency_data,
2395 'travelers' => $sanitized_travelers,
2396 'travel_date' => $travel_date,
2397 'payment_method' => $payment_method,
2398 'payment_gateway' => $payment_gateway,
2399 'total_amount' => $total_amount,
2400 'amount_due' => $amount_due,
2401 'booking_status' => $booking_status,
2402 'expiry_datetime' => $expiry_datetime,
2403 ]);
2404 }
2405
2406 return new WP_REST_Response([
2407 'success' => true,
2408 'message' => $status_message,
2409 'data' => [
2410 'booking_id' => $booking_id,
2411 'reference' => $booking_reference,
2412 'status' => $booking_status,
2413 'payment_status' => 'pending',
2414 'redirect_url' => $this->getConfirmationUrl($booking_reference),
2415 'customer_email' => $contact_data['email'],
2416 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
2417 'trip_id' => $trip_id,
2418 'trip_date' => $travel_date,
2419 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2420 'amount' => $amount_due,
2421 'subtotal' => $subtotal_before_discount,
2422 'discount_amount' => $discount_amount,
2423 'discount_code' => $discount_code,
2424 'total_amount' => $total_amount,
2425 ],
2426 ]);
2427 }
2428
2429
2430 /**
2431 * Get confirmation page URL (see yatra_get_booking_confirmation_url()).
2432 */
2433 private function getConfirmationUrl(string $reference): string
2434 {
2435 return yatra_get_booking_confirmation_url($reference);
2436 }
2437
2438 /**
2439 * Whether the gateway completes without an external payment step (registry flag + fallback).
2440 */
2441 private function isOfflineGateway(string $gatewayId): bool
2442 {
2443 try {
2444 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
2445 $gateway = $registry->get($gatewayId);
2446 if ($gateway) {
2447 return $gateway->isOffline();
2448 }
2449 } catch (\Throwable $e) {
2450 // Fall through to legacy IDs
2451 }
2452
2453 return in_array($gatewayId, ['pay_later', 'bank_transfer'], true);
2454 }
2455
2456 /**
2457 * Pay balance due on an existing booking only.
2458 *
2459 * Does not call BookingService::createBooking() or insert a second booking row.
2460 * Initiates gateway flow with the stored booking_id; on success, payment is recorded
2461 * via PaymentGatewayController::handle_successful_payment, webhooks, or recordGatewayPayment
2462 * — same completion paths as initial checkout.
2463 */
2464 private function process_remaining_payment(WP_REST_Request $request): WP_REST_Response
2465 {
2466 $data = $request->get_json_params();
2467 if (!is_array($data)) {
2468 $data = [];
2469 }
2470 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
2471
2472 $session = yatra_get_remaining_session();
2473
2474 $booking_id = (int) ($session['booking_id'] ?? 0);
2475 $booking_reference = (string) ($session['booking_reference'] ?? '');
2476 $currency = (string) ($session['currency'] ?? '');
2477 $contact_email = (string) ($session['contact_email'] ?? '');
2478 $contact_first_name = (string) ($session['contact_first_name'] ?? '');
2479 $contact_last_name = (string) ($session['contact_last_name'] ?? '');
2480 $trip_id = (int) ($session['trip_id'] ?? 0);
2481 $trip_title = (string) ($session['trip_title'] ?? '');
2482 $travel_date = (string) ($session['travel_date'] ?? '');
2483
2484 if ($booking_id <= 0) {
2485 return new WP_REST_Response([
2486 'success' => false,
2487 'message' => __('Invalid booking for remaining payment.', 'yatra'),
2488 ], 400);
2489 }
2490
2491 $booking = $this->bookingRepository->find($booking_id);
2492 if (!$booking) {
2493 yatra_clear_remaining_session();
2494 return new WP_REST_Response([
2495 'success' => false,
2496 'message' => __('Booking not found.', 'yatra'),
2497 ], 404);
2498 }
2499
2500 if ($booking_reference === '' && !empty($booking->reference)) {
2501 $booking_reference = (string) $booking->reference;
2502 }
2503
2504 // Authoritative balance from DB (do not rely on session alone)
2505 $remaining_amount = (float) ($booking->amount_due ?? 0);
2506 if ($remaining_amount <= 0 && isset($booking->total_amount)) {
2507 $remaining_amount = max(
2508 0,
2509 (float) $booking->total_amount - (float) ($booking->amount_paid ?? 0)
2510 );
2511 }
2512
2513 if ($remaining_amount <= 0) {
2514 yatra_clear_remaining_session();
2515 return new WP_REST_Response([
2516 'success' => false,
2517 'message' => __('This booking is already fully paid.', 'yatra'),
2518 ], 400);
2519 }
2520
2521 // Verify user owns this booking
2522 $current_user = get_current_user_id();
2523 if ($current_user && (int) $booking->user_id !== $current_user) {
2524 yatra_clear_remaining_session();
2525 return new WP_REST_Response([
2526 'success' => false,
2527 'message' => __('You do not have permission to pay for this booking.', 'yatra'),
2528 ], 403);
2529 }
2530
2531 if ($currency === '') {
2532 $currency = (string) ($booking->currency ?? SettingsService::getCurrency());
2533 }
2534
2535 // Use contact info from session or booking
2536 $customer_email = $contact_email !== '' ? $contact_email : (string) ($booking->contact_email ?? $booking->customer_email ?? '');
2537 $customer_name = trim($contact_first_name . ' ' . $contact_last_name);
2538 if ($customer_name === '') {
2539 $customer_name = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
2540 }
2541
2542 if ($customer_email === '') {
2543 return new WP_REST_Response([
2544 'success' => false,
2545 'message' => __('Email address is required.', 'yatra'),
2546 ], 400);
2547 }
2548
2549 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
2550
2551 // Server-side guard: offline gateways (Pay Later, Bank Transfer, etc.) don't
2552 // actually collect money. Letting them be selected for a remaining-balance
2553 // payment leaves the booking unpaid while the customer thinks they finished
2554 // the flow. The frontend already filters them out for this checkout — this
2555 // catches a tampered client. Filterable so a custom Pay Later that does
2556 // settle can opt back in.
2557 $allow_offline_for_remaining = (bool) apply_filters(
2558 'yatra_remaining_payment_allow_offline_gateway',
2559 false,
2560 $payment_gateway,
2561 $booking
2562 );
2563 if ($is_offline_gateway && !$allow_offline_for_remaining) {
2564 return new WP_REST_Response([
2565 'success' => false,
2566 'message' => __('This payment method cannot be used to settle a remaining balance. Please choose a card-based gateway.', 'yatra'),
2567 'data' => [
2568 'rejected_gateway' => $payment_gateway,
2569 'reason' => 'offline_not_allowed_for_remaining_payment',
2570 ],
2571 ], 400);
2572 }
2573
2574 // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.).
2575 // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely.
2576 if (!$is_offline_gateway && $remaining_amount > 0) {
2577 // The gateway needs an explicit return URL with the `balance=paid` flag so the
2578 // confirmation page can show "balance just paid" content. Without setting it
2579 // here, processPaymentWithGateway() would fall back to a plain confirmation URL
2580 // and the customer would land on the generic post-booking template.
2581 $remaining_return_url = add_query_arg(
2582 'balance',
2583 'paid',
2584 $this->getConfirmationUrl($booking_reference)
2585 );
2586
2587 $payment_params = array_merge($data, [
2588 'booking_id' => $booking_id,
2589 'reference' => $booking_reference,
2590 'amount' => $remaining_amount,
2591 'currency' => $currency,
2592 'customer_email' => $customer_email,
2593 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email,
2594 'trip_title' => $trip_title,
2595 'return_url' => $remaining_return_url,
2596 ]);
2597
2598 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
2599
2600 if (!empty($payment_result['success'])) {
2601 // Common identity fields the Stripe.js / PayPal SDK frontend expects on
2602 // the response. process_remaining_payment is reached without going through
2603 // the new-booking checkout, so the gateway result alone is missing
2604 // customer_email / customer_name — re-attach them from the booking we
2605 // already loaded above. Also overwrite confirmation_url AND redirect_url
2606 // with the balance-tagged version so the post-payment landing page knows
2607 // this was a remaining-balance flow.
2608 //
2609 // Why both fields: the Stripe.js helper buildConfirmationUrlFromBookingInfo()
2610 // prefers bookingInfo.redirect_url and only falls back to rebuilding the URL
2611 // from scratch (without query params) when redirect_url is absent. Without
2612 // redirect_url being explicitly set here the `?balance=paid` flag would be
2613 // lost on the final navigation after Stripe success.
2614 $remaining_identity_fields = [
2615 'customer_email' => $customer_email,
2616 'customer_name' => $customer_name,
2617 'confirmation_url' => $remaining_return_url,
2618 'redirect_url' => $remaining_return_url,
2619 ];
2620
2621 if (!empty($payment_result['payment_url'])) {
2622 return new WP_REST_Response([
2623 'success' => true,
2624 'message' => __('Redirecting to payment...', 'yatra'),
2625 'data' => array_merge([
2626 'booking_id' => $booking_id,
2627 'reference' => $booking_reference,
2628 'payment_url' => $payment_result['payment_url'],
2629 'is_remaining_payment' => true,
2630 ], $remaining_identity_fields),
2631 ]);
2632 }
2633
2634 if (!empty($payment_result['requires_action'])) {
2635 return new WP_REST_Response([
2636 'success' => true,
2637 'message' => __('Complete payment...', 'yatra'),
2638 'data' => array_merge(
2639 [
2640 'booking_id' => $booking_id,
2641 'reference' => $booking_reference,
2642 'is_remaining_payment' => true,
2643 ],
2644 $payment_result,
2645 $remaining_identity_fields
2646 ),
2647 ]);
2648 }
2649
2650 if (!empty($payment_result['redirect_url'])) {
2651 return new WP_REST_Response([
2652 'success' => true,
2653 'message' => __('Payment processed.', 'yatra'),
2654 'data' => array_merge([
2655 'booking_id' => $booking_id,
2656 'reference' => $booking_reference,
2657 'redirect_url' => $payment_result['redirect_url'],
2658 'is_remaining_payment' => true,
2659 ], $remaining_identity_fields),
2660 ]);
2661 }
2662 }
2663
2664 $err = $payment_result['message'] ?? $payment_result['error'] ?? __('Payment processing failed. Please try again.', 'yatra');
2665
2666 return new WP_REST_Response([
2667 'success' => false,
2668 'message' => $err,
2669 'data' => [
2670 'payment_error' => true,
2671 'booking_id' => $booking_id,
2672 ],
2673 ], 400);
2674 }
2675
2676 // Offline gateways: no external redirect — confirmation page only.
2677 // Append `balance=paid` so the confirmation template renders the
2678 // remaining-payment-specific banner ("balance received, fully paid")
2679 // instead of the generic "booking confirmed" copy.
2680 yatra_clear_remaining_session();
2681
2682 $offline_redirect = add_query_arg(
2683 'balance',
2684 'paid',
2685 $this->getConfirmationUrl($booking_reference)
2686 );
2687
2688 return new WP_REST_Response([
2689 'success' => true,
2690 'message' => __('Continue to confirmation.', 'yatra'),
2691 'data' => [
2692 'booking_id' => $booking_id,
2693 'reference' => $booking_reference,
2694 'trip_id' => $trip_id,
2695 'trip_title' => $trip_title,
2696 'trip_date' => $travel_date,
2697 'currency' => $currency,
2698 'amount' => $remaining_amount,
2699 'customer_email' => $customer_email,
2700 'customer_name' => $customer_name,
2701 'redirect_url' => $offline_redirect,
2702 'is_remaining_payment' => true,
2703 ],
2704 ]);
2705 }
2706
2707 /**
2708 * Process payment through the selected gateway
2709 */
2710 private function processPaymentGateway(string $gateway, array $params): array
2711 {
2712 // Debug logging
2713 if (defined('WP_DEBUG') && WP_DEBUG) {
2714 }
2715
2716 // All gateways use the unified gateway system
2717 return $this->processPaymentWithGateway($gateway, $params);
2718 }
2719
2720 /**
2721 * Process payment using the proper gateway system
2722 */
2723 private function processPaymentWithGateway(string $gatewayId, array $params): array
2724 {
2725 try {
2726 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
2727 $gateway = $registry->get($gatewayId);
2728
2729 if (!$gateway) {
2730 return ['success' => false, 'message' => "Payment gateway '{$gatewayId}' not found"];
2731 }
2732
2733 if (!$gateway->isEnabled()) {
2734 return [
2735 'success' => false,
2736 'message' => __('This payment method is not available.', 'yatra'),
2737 ];
2738 }
2739
2740 if (!$gateway->isProperlyConfigured()) {
2741 return [
2742 'success' => false,
2743 'message' => GatewayUserMessages::gatewayNotConfigured($gateway),
2744 ];
2745 }
2746
2747 // Prepare payment data - pass all params, gateways extract what they need.
2748 // Default return_url to the configured booking confirmation URL so redirect gateways
2749 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2750 // may still append their own query args on top of this URL.
2751 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2752 $paymentData = array_merge($params, [
2753 'description' => $params['trip_title'] ?? '',
2754 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2755 'metadata' => [
2756 'booking_id' => $params['booking_id'],
2757 'reference' => $params['reference'] ?? ''
2758 ]
2759 ]);
2760 if ($ref !== '' && empty($paymentData['return_url'])) {
2761 $paymentData['return_url'] = $this->getConfirmationUrl($ref);
2762 }
2763
2764 // Process the payment through the gateway
2765 $result = $gateway->processPayment($paymentData);
2766
2767 // Debug logging
2768 if (defined('WP_DEBUG') && WP_DEBUG) {
2769 }
2770
2771 if ($result['success']) {
2772 // Save transaction ID for tracking
2773 if (!empty($result['transaction_id'])) {
2774 $this->bookingRepository->updatePaymentSessionId(
2775 (int) $params['booking_id'],
2776 $result['transaction_id']
2777 );
2778 }
2779
2780 // For gateways that require client-side action (Stripe, Razorpay, etc.)
2781 // Payment will be recorded after client completes the action
2782 if (!empty($result['requires_action'])) {
2783 return array_merge(['success' => true], $result);
2784 }
2785
2786 // For gateways that return a redirect URL for external payment (PayPal, eSewa, Khalti)
2787 // Payment will be recorded on callback/return
2788 if (!empty($result['redirect_url']) || !empty($result['payment_url'])) {
2789 // Check if this is a completed payment with redirect (like Square)
2790 // vs pending external payment (like PayPal)
2791 $isCompletedPayment = !empty($result['transaction_id']) &&
2792 (($result['status'] ?? '') === 'completed' || ($result['status'] ?? '') === 'succeeded');
2793
2794 if ($isCompletedPayment) {
2795 $this->recordGatewayPayment($params, $result, $gatewayId);
2796 }
2797
2798 return [
2799 'success' => true,
2800 'payment_url' => $result['redirect_url'] ?? $result['payment_url']
2801 ];
2802 }
2803
2804 // For offline gateways or successful direct payments without redirect
2805 return [
2806 'success' => true,
2807 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2808 ];
2809 }
2810
2811 // Log the payment failure for debugging
2812 if (defined('WP_DEBUG') && WP_DEBUG) {
2813 }
2814
2815 return [
2816 'success' => false,
2817 'message' => $result['message'] ?? $result['error'] ?? 'Payment processing failed. Please try again.'
2818 ];
2819
2820 } catch (\Exception $e) {
2821 // Log the exception for debugging
2822 return [
2823 'success' => false,
2824 'message' => 'An unexpected error occurred. Please try again or contact support.'
2825 ];
2826 }
2827 }
2828
2829 /**
2830 * Record payment from gateway result
2831 * Matches Stripe's completePayment behavior
2832 */
2833 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2834 {
2835 global $wpdb;
2836
2837 try {
2838 $bookingId = (int) $params['booking_id'];
2839 $amount = (float) ($params['amount'] ?? 0);
2840 $currency = $params['currency'] ?? 'USD';
2841 $transactionId = $result['transaction_id'] ?? '';
2842
2843 // Get booking
2844 $booking = $this->bookingRepository->find($bookingId);
2845 if (!$booking) {
2846 return;
2847 }
2848
2849 // Already settled in full — never apply another charge to it. A fresh
2850 // booking is never already paid, so in practice this only guards a
2851 // stray/duplicate completion call (with a different transaction id)
2852 // against over-applying the ledger.
2853 if (($booking->payment_status ?? '') === 'paid') {
2854 return;
2855 }
2856
2857 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2858
2859 // Idempotency guard: skip if this gateway transaction is already
2860 // recorded for this booking. Prevents duplicate ledger rows when a
2861 // payment is submitted twice (the gateway uses a fresh idempotency
2862 // key per call, so it won't dedupe a true retry). Mirrors
2863 // PaymentGatewayController::handle_successful_payment().
2864 if ($transactionId !== '') {
2865 $existing = $paymentRepository->findByTransactionId($transactionId);
2866 if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
2867 return;
2868 }
2869 }
2870
2871 // Record the payment
2872 $paymentRepository->create([
2873 'booking_id' => $bookingId,
2874 'amount' => $amount,
2875 'currency' => $currency,
2876 'gateway' => $gatewayId,
2877 'transaction_id' => $transactionId,
2878 'status' => 'completed',
2879 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
2880 'created_at' => current_time('mysql'),
2881 ]);
2882
2883 // Update the booking ledger + status. The synchronous gateways
2884 // (Square, Authorize.Net) reach this generic path but previously left
2885 // the booking at pending/pending — only the payment row was written.
2886 // This now matches handle_successful_payment(): accumulate amount_paid,
2887 // recompute amount_due, set payment_status (paid vs partial), and
2888 // confirm the booking (a deposit confirms too, consistent with Stripe).
2889 $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
2890 $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
2891 $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
2892 $previousStatus = (string) ($booking->status ?? 'pending');
2893
2894 $this->bookingRepository->update($bookingId, [
2895 'amount_paid' => $newAmountPaid,
2896 'amount_due' => $newAmountDue,
2897 'payment_status' => $paymentStatus,
2898 'status' => 'confirmed',
2899 ]);
2900
2901 if (function_exists('yatra_trigger_booking_confirmed')) {
2902 \yatra_trigger_booking_confirmed($bookingId, $previousStatus);
2903 }
2904
2905 // Fire payment completed action
2906 do_action('yatra_payment_completed', [
2907 'booking_id' => $bookingId,
2908 'transaction_id' => $transactionId,
2909 'amount' => $amount,
2910 'currency' => $currency,
2911 'gateway' => $gatewayId,
2912 ]);
2913 } catch (\Throwable $e) {
2914 // Best-effort: the charge is already recorded; a confirmation-page
2915 // reload / status reconciliation can recover if this update fails.
2916 }
2917 }
2918
2919 /**
2920 * Process PayPal payment
2921 */
2922 private function processPayPalPayment(array $params, array $config, bool $is_test): array
2923 {
2924 $client_id = $config['client_id'] ?? '';
2925 $client_secret = $config['client_secret'] ?? '';
2926
2927 if (empty($client_id) || empty($client_secret)) {
2928 return ['success' => false, 'message' => 'PayPal credentials not configured'];
2929 }
2930
2931 $base_url = $is_test ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
2932
2933 try {
2934 // Get access token
2935 $auth_response = wp_remote_post($base_url . '/v1/oauth2/token', [
2936 'headers' => [
2937 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret),
2938 'Content-Type' => 'application/x-www-form-urlencoded',
2939 ],
2940 'body' => 'grant_type=client_credentials',
2941 ]);
2942
2943 if (is_wp_error($auth_response)) {
2944 return ['success' => false, 'message' => $auth_response->get_error_message()];
2945 }
2946
2947 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
2948 $access_token = $auth_body['access_token'] ?? '';
2949
2950 if (empty($access_token)) {
2951 return ['success' => false, 'message' => 'Failed to get PayPal access token'];
2952 }
2953
2954 // Create order
2955 $order_response = wp_remote_post($base_url . '/v2/checkout/orders', [
2956 'headers' => [
2957 'Authorization' => 'Bearer ' . $access_token,
2958 'Content-Type' => 'application/json',
2959 ],
2960 'body' => wp_json_encode([
2961 'intent' => 'CAPTURE',
2962 'purchase_units' => [[
2963 'reference_id' => $params['reference'],
2964 'amount' => [
2965 'currency_code' => $params['currency'],
2966 'value' => number_format($params['amount'], 2, '.', ''),
2967 ],
2968 'description' => $params['trip_title'],
2969 ]],
2970 'application_context' => [
2971 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2972 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
2973 ],
2974 ]),
2975 ]);
2976
2977 if (is_wp_error($order_response)) {
2978 return ['success' => false, 'message' => $order_response->get_error_message()];
2979 }
2980
2981 $order_body = json_decode(wp_remote_retrieve_body($order_response), true);
2982
2983 // Find approval link
2984 foreach ($order_body['links'] ?? [] as $link) {
2985 if ($link['rel'] === 'approve') {
2986 // Save order ID for capture later
2987 $this->bookingRepository->updatePaymentSessionId(
2988 (int) $params['booking_id'],
2989 $order_body['id'] ?? ''
2990 );
2991
2992 return ['success' => true, 'payment_url' => $link['href']];
2993 }
2994 }
2995
2996 return ['success' => false, 'message' => 'Failed to create PayPal order'];
2997 } catch (\Exception $e) {
2998 return ['success' => false, 'message' => $e->getMessage()];
2999 }
3000 }
3001
3002 /**
3003 * Process Razorpay payment
3004 */
3005 private function processRazorpayPayment(array $params, array $config, bool $is_test): array
3006 {
3007 $key_id = $config['api_key'] ?? '';
3008 $key_secret = $config['api_secret'] ?? '';
3009
3010 if (empty($key_id) || empty($key_secret)) {
3011 return ['success' => false, 'message' => 'Razorpay credentials not configured'];
3012 }
3013
3014 try {
3015 // Create Razorpay order
3016 $response = wp_remote_post('https://api.razorpay.com/v1/orders', [
3017 'headers' => [
3018 'Authorization' => 'Basic ' . base64_encode($key_id . ':' . $key_secret),
3019 'Content-Type' => 'application/json',
3020 ],
3021 'body' => wp_json_encode([
3022 'amount' => (int) ($params['amount'] * 100), // Amount in paise
3023 'currency' => $params['currency'],
3024 'receipt' => $params['reference'],
3025 'notes' => [
3026 'booking_id' => $params['booking_id'],
3027 'trip' => $params['trip_title'],
3028 ],
3029 ]),
3030 ]);
3031
3032 if (is_wp_error($response)) {
3033 return ['success' => false, 'message' => $response->get_error_message()];
3034 }
3035
3036 $body = json_decode(wp_remote_retrieve_body($response), true);
3037
3038 if (!empty($body['id'])) {
3039 // Save order ID
3040 $this->bookingRepository->updatePaymentSessionId(
3041 (int) $params['booking_id'],
3042 $body['id']
3043 );
3044
3045 // Razorpay requires client-side integration, return data for JS
3046 // Store order details and redirect to a payment page
3047 $payment_url = add_query_arg([
3048 'razorpay_order' => $body['id'],
3049 'booking_ref' => $params['reference'],
3050 'key' => $key_id,
3051 'amount' => (int) ($params['amount'] * 100),
3052 'currency' => $params['currency'],
3053 'name' => get_bloginfo('name'),
3054 'description' => $params['trip_title'],
3055 'email' => $params['customer_email'],
3056 ], home_url('/yatra-payment/razorpay/'));
3057
3058 return ['success' => true, 'payment_url' => $payment_url];
3059 }
3060
3061 return ['success' => false, 'message' => $body['error']['description'] ?? 'Failed to create Razorpay order'];
3062 } catch (\Exception $e) {
3063 return ['success' => false, 'message' => $e->getMessage()];
3064 }
3065 }
3066
3067 /**
3068 * Process eSewa payment
3069 */
3070 private function processEsewaPayment(array $params, array $config, bool $is_test): array
3071 {
3072 $merchant_id = $config['merchant_id'] ?? '';
3073
3074 if (empty($merchant_id)) {
3075 return ['success' => false, 'message' => 'eSewa merchant ID not configured'];
3076 }
3077
3078 $base_url = $is_test ? 'https://uat.esewa.com.np/epay/main' : 'https://esewa.com.np/epay/main';
3079
3080 // eSewa uses form redirect, build URL with parameters
3081 $payment_url = add_query_arg([
3082 'amt' => $params['amount'],
3083 'psc' => 0,
3084 'pdc' => 0,
3085 'txAmt' => 0,
3086 'tAmt' => $params['amount'],
3087 'pid' => $params['reference'],
3088 'scd' => $merchant_id,
3089 'su' => add_query_arg(
3090 ['payment' => 'success', 'gateway' => 'esewa'],
3091 $this->getConfirmationUrl($params['reference'])
3092 ),
3093 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
3094 ], $base_url);
3095
3096 return ['success' => true, 'payment_url' => $payment_url];
3097 }
3098
3099 /**
3100 * Process Khalti payment
3101 */
3102 private function processKhaltiPayment(array $params, array $config, bool $is_test): array
3103 {
3104 $secret_key = $config['api_secret'] ?? '';
3105
3106 if (empty($secret_key)) {
3107 return ['success' => false, 'message' => 'Khalti secret key not configured'];
3108 }
3109
3110 $base_url = $is_test ? 'https://a.khalti.com/api/v2/epayment/initiate/' : 'https://khalti.com/api/v2/epayment/initiate/';
3111
3112 try {
3113 $response = wp_remote_post($base_url, [
3114 'headers' => [
3115 'Authorization' => 'Key ' . $secret_key,
3116 'Content-Type' => 'application/json',
3117 ],
3118 'body' => wp_json_encode([
3119 'return_url' => add_query_arg(
3120 ['payment' => 'success', 'gateway' => 'khalti'],
3121 $this->getConfirmationUrl($params['reference'])
3122 ),
3123 'website_url' => home_url(),
3124 'amount' => (int) ($params['amount'] * 100), // Amount in paisa
3125 'purchase_order_id' => $params['reference'],
3126 'purchase_order_name' => $params['trip_title'],
3127 'customer_info' => [
3128 'name' => $params['customer_name'],
3129 'email' => $params['customer_email'],
3130 ],
3131 ]),
3132 ]);
3133
3134 if (is_wp_error($response)) {
3135 return ['success' => false, 'message' => $response->get_error_message()];
3136 }
3137
3138 $body = json_decode(wp_remote_retrieve_body($response), true);
3139
3140 if (!empty($body['payment_url'])) {
3141 // Save pidx for verification
3142 $this->bookingRepository->updatePaymentSessionId(
3143 (int) $params['booking_id'],
3144 $body['pidx'] ?? ''
3145 );
3146
3147 return ['success' => true, 'payment_url' => $body['payment_url']];
3148 }
3149
3150 return ['success' => false, 'message' => $body['detail'] ?? 'Failed to initiate Khalti payment'];
3151 } catch (\Exception $e) {
3152 return ['success' => false, 'message' => $e->getMessage()];
3153 }
3154 }
3155
3156 /**
3157 * Process Authorize.net payment
3158 */
3159 private function processAuthorizeNetPayment(array $params, array $config, bool $is_test): array
3160 {
3161 // Authorize.net typically requires hosted payment page or client-side integration
3162 // Return URL for hosted payment page setup
3163 return [
3164 'success' => true,
3165 'payment_url' => add_query_arg([
3166 'booking_ref' => $params['reference'],
3167 'amount' => $params['amount'],
3168 'gateway' => 'authorize_net',
3169 ], home_url('/yatra-payment/authorize-net/'))
3170 ];
3171 }
3172
3173 /**
3174 * Get client IP address
3175 */
3176 private function getClientIp(): string
3177 {
3178 $ip_keys = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'];
3179
3180 foreach ($ip_keys as $key) {
3181 if (!empty($_SERVER[$key])) {
3182 $ip = sanitize_text_field($_SERVER[$key]);
3183 if (strpos($ip, ',') !== false) {
3184 $ip = trim(explode(',', $ip)[0]);
3185 }
3186 if (filter_var($ip, FILTER_VALIDATE_IP)) {
3187 return $ip;
3188 }
3189 }
3190 }
3191
3192 return '0.0.0.0';
3193 }
3194
3195 /**
3196 * Send booking confirmation email
3197 */
3198 private function sendBookingConfirmationEmail(int $booking_id, string $reference, object $trip, array $data): void
3199 {
3200 $contact = $data['contact'] ?? [];
3201 $travelers = $data['travelers'] ?? [];
3202 $travel_date = $data['travel_date'] ?? '';
3203 $total_amount = $data['total_amount'] ?? 0;
3204 $amount_due = $data['amount_due'] ?? 0;
3205 $payment_method = $data['payment_method'] ?? 'full';
3206 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
3207 $booking_status = $data['booking_status'] ?? 'pending';
3208 // Cancellation copy in the email now comes from the trip's
3209 // own cancellation_policy field (set per-trip on the Trip
3210 // editor), not from removed global settings. Falls back to
3211 // empty so the paragraph is silently omitted when the trip
3212 // doesn't have a policy set.
3213 $trip_cancellation_policy = isset($trip->cancellation_policy)
3214 ? wp_strip_all_tags((string) $trip->cancellation_policy)
3215 : '';
3216 $expiry_datetime = $data['expiry_datetime'] ?? null;
3217
3218 $customer_email = $contact['email'] ?? '';
3219 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
3220
3221 if (empty($customer_email)) {
3222 return;
3223 }
3224
3225 // Format prices using global currency settings
3226 $formatted_total = yatra_format_price($total_amount);
3227 $formatted_due = yatra_format_price($amount_due);
3228
3229 $intro_paragraph = $booking_status === 'confirmed'
3230 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
3231 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
3232 if ($booking_status === 'pending' && $expiry_datetime) {
3233 $intro_paragraph .= ' ' . sprintf(
3234 /* translators: %s: payment expiry date and time (formatted). */
3235 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
3236 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
3237 );
3238 }
3239
3240 ob_start();
3241 ?>
3242 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
3243 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
3244 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
3245 <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>
3246 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */
3247 echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
3248 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
3249 </div>
3250 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
3251 <p><?php /* translators: %s: total amount (formatted). */
3252 echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
3253 <?php if ($payment_method === 'deposit') : ?>
3254 <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3255 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>
3256 <?php elseif ($payment_method === 'partial') : ?>
3257 <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3258 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>
3259 <?php else : ?>
3260 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
3261 <?php endif; ?>
3262 <?php if ($payment_gateway === 'pay_later') : ?>
3263 <p><?php esc_html_e('Pay later — please contact us to arrange payment.', 'yatra'); ?></p>
3264 <?php elseif ($payment_gateway === 'bank_transfer') : ?>
3265 <p><?php esc_html_e('Bank transfer — you will receive bank details separately.', 'yatra'); ?></p>
3266 <?php endif; ?>
3267 <h3 style="font-size:16px;"><?php esc_html_e('Travelers', 'yatra'); ?></h3>
3268 <ul style="padding-left:20px;">
3269 <?php foreach ($travelers as $i => $traveler) : ?>
3270 <?php
3271 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
3272 ?>
3273 <li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */
3274 echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '')); ?></li>
3275 <?php endforeach; ?>
3276 </ul>
3277 <?php
3278 // Cancellation policy paragraph now sources from the trip's
3279 // per-trip cancellation_policy field (set on the Trip
3280 // editor). The previous version used global cancellation
3281 // settings that were display-only — they appeared here but
3282 // never enforced a real cancellation cutoff. We've removed
3283 // those settings; if the trip itself doesn't define a
3284 // policy, the whole section is silently omitted so the
3285 // email isn't padded with empty headings.
3286 if ($trip_cancellation_policy !== '') {
3287 ?>
3288 <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
3289 <p><?php echo esc_html($trip_cancellation_policy); ?></p>
3290 <?php
3291 }
3292 ?>
3293 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
3294 <ol style="padding-left:20px;">
3295 <li><?php esc_html_e('You will receive a detailed trip itinerary within 24–48 hours.', 'yatra'); ?></li>
3296 <li><?php esc_html_e('Our team will contact you to confirm any special requirements.', 'yatra'); ?></li>
3297 <li><?php esc_html_e('Please ensure travel documents meet entry requirements for your destination.', 'yatra'); ?></li>
3298 </ol>
3299 <p><?php esc_html_e('If you have any questions, contact us anytime.', 'yatra'); ?></p>
3300 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
3301 <?php
3302 $details_html = ob_get_clean();
3303
3304 // Seed from the canonical booking variables FIRST so every dynamic
3305 // merge tag — {{contact_*}} / {{emergency_*}} custom fields,
3306 // {{traveler_custom_fields_html}}, {{balance_due}}, payment/schedule
3307 // tags, etc. — resolves on this offline / pay-later path exactly like
3308 // the online-gateway path (BookingService::sendBookingConfirmationEmail).
3309 // Previously this method hand-built only ~18 core keys, so an operator
3310 // who customised the Booking Confirmation template with a custom-field
3311 // variable saw it render empty on offline bookings. The hand-built keys
3312 // below (the self-rendered details_html, intro, footer) intentionally
3313 // take precedence via array_merge ordering.
3314 $base_vars = [];
3315 $saved_booking = $this->bookingRepository->find($booking_id);
3316 if ($saved_booking) {
3317 $base_vars = TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
3318 }
3319
3320 $vars = array_merge($base_vars, [
3321 'customer_name' => $customer_name,
3322 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
3323 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
3324 'customer_email' => $customer_email,
3325 'customer_phone' => (string) ($contact['phone'] ?? ''),
3326 'booking_reference' => $reference,
3327 'booking_id' => (string) $booking_id,
3328 'trip_name' => (string) $trip->title,
3329 'trip_url' => home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) ($trip->slug ?? '')) . '/'),
3330 'travel_date' => date_i18n(get_option('date_format'), strtotime($travel_date)),
3331 'travelers_count' => (string) count($travelers),
3332 'total_amount_formatted' => $formatted_total,
3333 'amount_due_formatted' => $formatted_due,
3334 'currency' => SettingsService::getCurrency(),
3335 'intro_paragraph' => $intro_paragraph,
3336 'details_html' => $details_html,
3337 'details_html_only' => '1',
3338 /* translators: %s: site name. */
3339 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
3340 'transactional_context' => 'booking_created',
3341 ]);
3342
3343 TransactionalEmailTemplateService::sendIfEnabled(
3344 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
3345 $customer_email,
3346 $vars
3347 );
3348
3349 // Admin new-booking email is sent from NotificationService (yatra_booking_created) using
3350 // Email → Templates → Admin: New booking, to avoid duplicate messages.
3351 }
3352
3353 /**
3354 * Apply coupon code to booking session
3355 */
3356 public function apply_coupon(WP_REST_Request $request): WP_REST_Response
3357 {
3358 yatra_start_session();
3359
3360 $data = $request->get_json_params() ?? [];
3361
3362 // M-2: restore CSRF protection stripped by public_permission_callback.
3363 if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3364 return $blocked;
3365 }
3366
3367 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
3368
3369 if (empty($code)) {
3370 return new WP_REST_Response([
3371 'success' => false,
3372 'message' => __('Please enter a coupon code.', 'yatra'),
3373 ], 400);
3374 }
3375
3376 // Get current session — with token-rehydration fallback for REST
3377 // contexts where PHPSESSID isn't propagated (same shape as the
3378 // service-toggle / summary endpoints). Also writes back to $_SESSION
3379 // so the subsequent `yatra_set_booking_session()` persists alongside
3380 // the existing transient.
3381 $session = yatra_get_booking_session();
3382 if (empty($session) || empty($session['trip_id'])) {
3383 $token = null;
3384 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
3385 $token = sanitize_text_field((string) $data['booking_token']);
3386 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
3387 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
3388 }
3389 if ($token) {
3390 $transient_data = get_transient($token);
3391 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
3392 $session = $transient_data;
3393 $_SESSION['yatra_booking'] = $session;
3394 $_SESSION['yatra_booking_token'] = $token;
3395 }
3396 }
3397 }
3398 if (empty($session) || empty($session['trip_id'])) {
3399 return new WP_REST_Response([
3400 'success' => false,
3401 'message' => __('No active booking session found.', 'yatra'),
3402 ], 400);
3403 }
3404
3405 // Use DiscountService to calculate coupon discount
3406 $discountService = new \Yatra\Services\DiscountService();
3407 $total_amount = $this->calculateSessionTotal($session);
3408 $trip_id = (int) $session['trip_id'];
3409 $travelers_count = (int) ($session['travelers'] ?? 1);
3410 $traveler_counts = $session['traveler_counts'] ?? [];
3411
3412 $coupon_result = $discountService->calculateCouponDiscount(
3413 $code,
3414 $total_amount,
3415 $trip_id,
3416 $travelers_count,
3417 $traveler_counts
3418 );
3419
3420 // Check if discount was calculated (validation passed)
3421 if ($coupon_result['calculated_amount'] <= 0) {
3422 return new WP_REST_Response([
3423 'success' => false,
3424 'message' => __('This coupon is not valid for your booking.', 'yatra'),
3425 ], 400);
3426 }
3427
3428 $discount_amount = $coupon_result['calculated_amount'];
3429
3430
3431 // Store coupon in session (use the original $code variable, not from result)
3432 $session['coupon'] = [
3433 'code' => $code, // Use the actual code that was validated
3434 'type' => $coupon_result['type'],
3435 'amount' => $coupon_result['amount'],
3436 'discount_amount' => $discount_amount,
3437 'label' => $coupon_result['label'],
3438 ];
3439 $session['timestamp'] = time();
3440
3441 yatra_set_booking_session($session);
3442
3443
3444 return new WP_REST_Response([
3445 'success' => true,
3446 'message' => __('Coupon applied successfully!', 'yatra'),
3447 'data' => [
3448 'code' => $code,
3449 'type' => $coupon_result['type'],
3450 'discount_amount' => $discount_amount,
3451 'discount_formatted' => yatra_format_price($discount_amount),
3452 'new_total' => $total_amount - $discount_amount,
3453 'new_total_formatted' => yatra_format_price($total_amount - $discount_amount),
3454 ],
3455 ]);
3456 }
3457
3458 /**
3459 * Verify a guest's booking email via magic-link token.
3460 *
3461 * Flow:
3462 * 1. Validate the HMAC token (forgery, expiry, email-binding).
3463 * 2. Look up the booking; confirm it's in `pending_verification`.
3464 * 3. Flip status to `pending` (or `confirmed` when auto-confirm
3465 * pay-later is enabled for this site) and fire the standard
3466 * yatra_booking_status_changed action so inventory + email
3467 * automations resume normally.
3468 * 4. 302 redirect the browser to a continuation URL:
3469 * - If amount_due > 0: back to the booking page for the
3470 * payment step the customer skipped earlier.
3471 * - If amount_due == 0 / auto-confirm: to the booking
3472 * confirmation/thank-you page.
3473 * 5. On any failure, render a friendly HTML page (not JSON) so
3474 * the customer sees readable text in their browser tab.
3475 *
3476 * @return WP_REST_Response|WP_Error|void
3477 */
3478 public function verify_email(WP_REST_Request $request)
3479 {
3480 $token = (string) $request->get_param('token');
3481 $bookingRepo = new \Yatra\Repositories\BookingRepository();
3482
3483 // First decode just to extract the booking id (for the
3484 // expectedEmail lookup). Verify() is called again below
3485 // with the actual email so the email-binding check runs.
3486 $partsPreview = explode('.', $token);
3487 $bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0]))
3488 ? (int) $partsPreview[0]
3489 : 0;
3490 $booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null;
3491 $expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : '';
3492
3493 $result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail);
3494
3495 if (!$result['ok']) {
3496 $this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid'));
3497 }
3498 if ($booking === null) {
3499 $this->renderVerifyEmailErrorPage('booking_not_found');
3500 }
3501
3502 // Re-entrant: if the booking has already been verified, show the
3503 // same success page (idempotent) — pre-3.0.5 silently redirected
3504 // and the customer was left wondering whether anything happened.
3505 $currentStatus = (string) ($booking->status ?? '');
3506 $alreadyVerified = $currentStatus !== 'pending_verification';
3507
3508 if (!$alreadyVerified) {
3509 // Flip status to 'pending' so downstream hooks (inventory /
3510 // notification automations) see fresh data, then fire
3511 // yatra_booking_created so the listeners we deferred at
3512 // creation time (admin "new booking" notification + Pro
3513 // email-automation booking.created fan-out) run now — i.e.
3514 // *after* the customer has proven the email is theirs.
3515 $bookingRepo->updateStatus((int) $booking->id, 'pending');
3516 do_action('yatra_booking_email_verified', (int) $booking->id);
3517
3518 // Re-fetch so the post-verification action receives the
3519 // booking row with the new status, then fire the deferred
3520 // booking-created action. See BookingService::createBooking()
3521 // for the matching skip-on-pending-verification branch.
3522 $verifiedBooking = $bookingRepo->find((int) $booking->id);
3523 if (is_object($verifiedBooking)) {
3524 do_action(
3525 \Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED,
3526 (int) $verifiedBooking->id,
3527 $verifiedBooking
3528 );
3529 }
3530 }
3531
3532 $this->renderVerifyEmailSuccessPage(
3533 (int) $booking->id,
3534 (string) ($booking->reference ?? ''),
3535 $alreadyVerified
3536 );
3537 }
3538
3539 /**
3540 * Friendly HTML error page rendered when a verification link
3541 * is invalid / expired / tampered with. Avoids JSON in the
3542 * customer's browser tab (terrible UX). Reasons map to clear
3543 * messages so customers know what to do next.
3544 *
3545 * Emits raw HTML and exits. We can't return WP_REST_Response with an
3546 * HTML body because the REST server JSON-encodes the response data
3547 * regardless of the Content-Type header on the response object —
3548 * the customer would see `"<!doctype..."` (a JSON string) in their
3549 * browser tab. Echoing + exiting short-circuits the REST pipeline.
3550 *
3551 * @return never
3552 */
3553 private function renderVerifyEmailErrorPage(string $reason): void
3554 {
3555 $messages = [
3556 'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'),
3557 'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'),
3558 'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'),
3559 'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'),
3560 'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'),
3561 ];
3562 $message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra');
3563
3564 $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3565 $html = sprintf(
3566 '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3567 . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3568 . '<title>%2$s</title>'
3569 . '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3570 . '.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}'
3571 . 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}'
3572 . 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>'
3573 . '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>',
3574 esc_attr(get_locale()),
3575 esc_html__('Verification link issue', 'yatra'),
3576 esc_html__('Verification link issue', 'yatra'),
3577 esc_html($message),
3578 esc_url(home_url('/')),
3579 esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName))
3580 );
3581
3582 if (!headers_sent()) {
3583 status_header(200);
3584 nocache_headers();
3585 header('Content-Type: text/html; charset=UTF-8');
3586 }
3587 echo $html;
3588 exit;
3589 }
3590
3591 /**
3592 * Build the booking continuation URL (post-verification destination).
3593 *
3594 * If the site has a Yatra Bookings page, route through that with the
3595 * booking reference; otherwise fall back to the trip URL. Filterable
3596 * via `yatra_guest_verification_continuation_url` so integrations can
3597 * route to a custom thank-you page.
3598 */
3599 private function continuationUrl(int $bookingId, string $reference): string
3600 {
3601 return (string) apply_filters(
3602 'yatra_guest_verification_continuation_url',
3603 add_query_arg(
3604 ['booking_id' => $bookingId, 'verified' => '1'],
3605 home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/')
3606 ),
3607 $bookingId,
3608 $reference
3609 );
3610 }
3611
3612 /**
3613 * Friendly HTML success page rendered after the guest clicks the
3614 * email-verification magic link.
3615 *
3616 * Pre-3.0.5 this endpoint silently 302-redirected to the booking page,
3617 * which made guests believe nothing had happened — there was no visible
3618 * "verified" feedback before they landed on the next step. This page
3619 * gives them an unambiguous confirmation, the booking reference, and
3620 * three explicit CTAs:
3621 * - Continue to booking (primary, continuation URL)
3622 * - My Account (when logged in) / Sign in (when not)
3623 * - Go to homepage (fallback)
3624 *
3625 * Idempotent: when the booking was already verified (re-click on the
3626 * same link), the heading + copy switch to the "already verified"
3627 * variant but the CTAs stay the same.
3628 *
3629 * Emits raw HTML and exits — same reasoning as
3630 * {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response
3631 * JSON-encodes string bodies, so the customer would see
3632 * `"<!doctype..."` in their tab instead of the rendered page.
3633 *
3634 * @return never
3635 */
3636 private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void
3637 {
3638 // Booking is already persisted at this point and (for a fresh verify)
3639 // the status flip + booking-created fan-out have just fired. The
3640 // verified UI's primary action is therefore "View your booking
3641 // confirmation" — NOT "Continue Booking", which mislabelled the
3642 // booking as still in-progress and confused customers into thinking
3643 // they needed to re-submit the form.
3644 $confirmationUrl = function_exists('yatra_get_booking_confirmation_url')
3645 ? yatra_get_booking_confirmation_url($reference)
3646 : $this->continuationUrl($bookingId, $reference);
3647
3648 // Account / login URL — prefer Yatra's account page when present,
3649 // fall back to wp_login_url() so the page never points nowhere.
3650 $accountBase = \Yatra\Services\SettingsService::getAccountBase();
3651 $accountUrl = $accountBase !== ''
3652 ? home_url('/' . trim($accountBase, '/') . '/')
3653 : home_url('/');
3654 $isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in();
3655 $secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl);
3656 $secondaryLabel = $isLoggedIn
3657 ? __('Go to My Account', 'yatra')
3658 : __('Sign in', 'yatra');
3659
3660 $heading = $alreadyVerified
3661 ? __('Email Already Verified', 'yatra')
3662 : __('Email Verified', 'yatra');
3663 $message = $alreadyVerified
3664 ? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra')
3665 : __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra');
3666
3667 $primaryLabel = __('View Booking Confirmation', 'yatra');
3668 $homeLabel = __('Go to Homepage', 'yatra');
3669 $referenceLabel = __('Booking reference', 'yatra');
3670 $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3671
3672 $referenceLine = $reference !== ''
3673 ? sprintf(
3674 '<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>',
3675 esc_html($referenceLabel),
3676 esc_html($reference)
3677 )
3678 : '';
3679
3680 // Inline-only styling so the page renders correctly regardless of
3681 // theme stylesheet load order (REST → wp_die/raw HTML response).
3682 $html = sprintf(
3683 '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3684 . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3685 . '<title>%2$s · %3$s</title>'
3686 . '<style>'
3687 . 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3688 . '.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}'
3689 . '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}'
3690 . 'h1{font-size:24px;margin:0 0 12px;color:#065f46}'
3691 . 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}'
3692 . '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}'
3693 . '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}'
3694 . '.ref code{font-weight:600;color:#111827}'
3695 . '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}'
3696 . '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}'
3697 . '.btn-primary{background:#059669;color:#fff}'
3698 . '.btn-primary:hover{background:#047857}'
3699 . '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}'
3700 . '.btn-secondary:hover{background:#f9fafb}'
3701 . '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}'
3702 . '</style></head>'
3703 . '<body><div class="box">'
3704 . '<div class="tick" aria-hidden="true">'
3705 . '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
3706 . '<polyline points="20 6 9 17 4 12"></polyline></svg>'
3707 . '</div>'
3708 . '<h1>%2$s</h1>'
3709 . '<p>%4$s</p>'
3710 . '%5$s'
3711 . '<div class="actions">'
3712 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3713 . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3714 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3715 . '</div>'
3716 . '</div></body></html>',
3717 esc_attr(get_locale()),
3718 esc_html($heading),
3719 esc_html($brandName),
3720 esc_html($message),
3721 $referenceLine,
3722 esc_url($confirmationUrl),
3723 esc_html($primaryLabel),
3724 esc_url($secondaryUrl),
3725 esc_html($secondaryLabel),
3726 esc_url(home_url('/')),
3727 esc_html($homeLabel)
3728 );
3729
3730 if (!headers_sent()) {
3731 status_header(200);
3732 nocache_headers();
3733 header('Content-Type: text/html; charset=UTF-8');
3734 }
3735 echo $html;
3736 exit;
3737 }
3738
3739 /**
3740 * Calculate booking summary and return HTML for dynamic updates
3741 * Called via AJAX when traveler count, date, or coupon changes
3742 */
3743 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
3744 {
3745 yatra_start_session();
3746 $session = yatra_get_booking_session();
3747 $data = $request->get_json_params() ?? [];
3748
3749 // M-2: restore CSRF protection stripped by public_permission_callback.
3750 if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3751 return $blocked;
3752 }
3753
3754 // Same REST-context session-rehydration fallback as set_session() /
3755 // create_booking(): when PHPSESSID isn't propagated to the REST API
3756 // scope, look up the transient by `booking_token` (from request body
3757 // first, then ?booking_token=) so the partial summary refresh
3758 // doesn't 400. Without this, every service-toggle re-render fails
3759 // because trip_id resolves to 0.
3760 //
3761 // We also write the rehydrated session into $_SESSION so that the
3762 // downstream buildPricingHtml() — which calls yatra_get_booking_session()
3763 // again — sees the same data and doesn't fall back to its
3764 // "Pricing information not available" branch.
3765 if (empty($session) || empty($session['trip_id'])) {
3766 $token = null;
3767 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
3768 $token = sanitize_text_field((string) $data['booking_token']);
3769 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
3770 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
3771 }
3772 if ($token) {
3773 $transient_data = get_transient($token);
3774 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
3775 $session = $transient_data;
3776 $_SESSION['yatra_booking'] = $session;
3777 $_SESSION['yatra_booking_token'] = $token;
3778 }
3779 }
3780 }
3781
3782 // Get trip_id from session (required)
3783 $trip_id = (int) ($session['trip_id'] ?? 0);
3784
3785 // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session
3786 $traveler_counts = $data['traveler_counts'] ?? ($session['traveler_counts'] ?? []);
3787
3788 // Get other data from request or session
3789 $travel_date = sanitize_text_field($data['travel_date'] ?? ($session['travel_date'] ?? ''));
3790 $departure_time = sanitize_text_field($data['departure_time'] ?? ($session['departure_time'] ?? ''));
3791 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
3792 $pricing_type_from_request = sanitize_text_field($data['pricing_type'] ?? ($session['pricing_type'] ?? ''));
3793 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
3794 if ($payment_method === '') {
3795 $payment_method = 'full';
3796 }
3797 $selected_service_ids_from_request = $data['additional_services'] ?? ($session['additional_services'] ?? null);
3798
3799 // IMPORTANT: Always read coupon from SESSION (not request) to maintain applied discount
3800 $coupon_code = isset($session['coupon']['code']) ? sanitize_text_field($session['coupon']['code']) : '';
3801
3802
3803 if (empty($trip_id)) {
3804 return new WP_REST_Response([
3805 'success' => false,
3806 'message' => __('No active booking session found.', 'yatra'),
3807 ], 400);
3808 }
3809
3810 // Get trip data
3811 $trip = $this->tripRepository->findPublished($trip_id);
3812 if (!$trip) {
3813 return new WP_REST_Response([
3814 'success' => false,
3815 'message' => __('Trip not found.', 'yatra'),
3816 ], 404);
3817 }
3818
3819 // Ensure pricing fields are properly set
3820 $trip->original_price = (float) ($trip->original_price ?? 0);
3821 $trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0;
3822 $trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0;
3823
3824 yatra_start_session();
3825 $existing_session = yatra_get_booking_session();
3826
3827 global $wpdb;
3828
3829 $availability = null;
3830 if (!empty($travel_date)) {
3831 // Use the same resolver as the single-trip UI so rule slots, manual dates,
3832 // and flexible defaults all return a consistent object shape.
3833 try {
3834 $resolver = new \Yatra\Services\AvailabilityResolutionService();
3835 $availability = $resolver->resolveAvailabilityForDate(
3836 $trip_id,
3837 $travel_date,
3838 $departure_time !== '' ? $departure_time : null
3839 );
3840 } catch (\Throwable $e) {
3841 $availability = null;
3842 }
3843 } elseif (!empty($availability_id) && is_numeric($availability_id)) {
3844 // Back-compat: allow summary by numeric availability_id only.
3845 $availability = $this->availabilityService->getById((int) $availability_id);
3846 }
3847
3848 // Resolve pricing type and price_types via centralized TripPricingService
3849 $resolved_pricing_type = !empty($pricing_type_from_request)
3850 ? $pricing_type_from_request
3851 : \Yatra\Services\TripPricingService::resolvePricingType($trip);
3852
3853 $price_types = [];
3854
3855 // First priority: availability price_types (already includes trip fallback from AvailabilityResolutionService)
3856 if ($availability && !empty($availability->price_types)) {
3857 $avail_pts = is_string($availability->price_types)
3858 ? (json_decode($availability->price_types, true) ?: [])
3859 : $availability->price_types;
3860 if (!empty($avail_pts) && is_array($avail_pts)) {
3861 $price_types = array_map(function ($pt) { return (object) $pt; }, $avail_pts);
3862 }
3863 }
3864 // Second priority: trip's price_types via centralized normalizer
3865 if (empty($price_types)) {
3866 $normalized = \Yatra\Services\TripPricingService::resolvePriceTypes($trip);
3867 if (!empty($normalized)) {
3868 $price_types = array_map(function ($pt) { return (object) $pt; }, $normalized);
3869 }
3870 }
3871 // Auto-detect traveler_based if price_types are present
3872 if (!empty($price_types)) {
3873 $resolved_pricing_type = 'traveler_based';
3874 }
3875
3876 // Resolve pricing_mode / group-size limits authoritatively from the
3877 // TravelerCategory so the summary breakdown treats a per-group category
3878 // as a flat charge. No-op for per-person categories.
3879 if (!empty($price_types)) {
3880 $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
3881 }
3882
3883 // Enrich availability price_types with category labels if missing
3884 if (!empty($price_types)) {
3885 $missing_label_category_ids = [];
3886 foreach ($price_types as $pt) {
3887 $pt = (object) $pt;
3888 if (empty($pt->category_label) && !empty($pt->category_id)) {
3889 $missing_label_category_ids[] = (int) $pt->category_id;
3890 }
3891 }
3892
3893 $missing_label_category_ids = array_values(array_unique(array_filter($missing_label_category_ids)));
3894 if (!empty($missing_label_category_ids)) {
3895 // Use AvailabilityService to get traveler categories
3896 $cats = $this->availabilityService->getTravelerCategories($missing_label_category_ids);
3897
3898 $catIndex = [];
3899 foreach ($cats as $cat) {
3900 $catIndex[(int) $cat->id] = $cat;
3901 }
3902
3903 foreach ($price_types as &$pt) {
3904 $pt = (object) $pt;
3905 $catId = !empty($pt->category_id) ? (int) $pt->category_id : null;
3906 if ($catId && isset($catIndex[$catId])) {
3907 $cat = $catIndex[$catId];
3908 if (empty($pt->category_label)) {
3909 $pt->category_label = $cat->label;
3910 }
3911 if (empty($pt->category_slug)) {
3912 $pt->category_slug = $cat->slug;
3913 }
3914 if (!isset($pt->age_min)) {
3915 $pt->age_min = $cat->age_min ? (int) $cat->age_min : null;
3916 }
3917 if (!isset($pt->age_max)) {
3918 $pt->age_max = $cat->age_max ? (int) $cat->age_max : null;
3919 }
3920 }
3921 }
3922 unset($pt);
3923 }
3924 }
3925
3926 foreach ($price_types as $pt) {
3927 $pt->effective_price = $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
3928 }
3929
3930 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
3931 $spots_remaining = $availability ? (int) ($availability->spots_remaining ?? null) : null;
3932
3933 foreach ($price_types as $pt) {
3934 if (!isset($pt->effective_price)) {
3935 continue;
3936 }
3937 $price_before = $pt->effective_price;
3938 $pt_orig = (float) ($pt->original_price ?? 0);
3939 $pt->effective_price = apply_filters('yatra_trip_display_price', (float) $pt->effective_price, $trip_id, [
3940 'departure_date' => $travel_date ?: null,
3941 'spots_remaining' => $spots_remaining,
3942 'availability_id' => $availability_id,
3943 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
3944 'original_price' => $pt_orig > 0 ? $pt_orig : (float) $price_before,
3945 'discounted_price' => (float) $price_before,
3946 ]);
3947 }
3948 }
3949
3950 $is_traveler_based = $resolved_pricing_type === 'traveler_based' && !empty($price_types);
3951
3952 // Base trip price via centralized TripPricingService (single source of truth)
3953 $base_trip_price = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
3954
3955 // Override with availability pricing if available
3956 if ($availability) {
3957 $avail_price = !empty($availability->discounted_price) && (float) $availability->discounted_price > 0
3958 ? (float) $availability->discounted_price
3959 : (!empty($availability->original_price) && (float) $availability->original_price > 0
3960 ? (float) $availability->original_price : 0);
3961 if ($avail_price > 0) {
3962 $base_trip_price = $avail_price;
3963 }
3964 }
3965
3966 // Apply dynamic pricing filter (Pro DynamicPricingModule hooks here)
3967 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
3968 $list_for_dp = (float) ($trip->original_price ?? 0);
3969 if ($availability) {
3970 $ao = (float) ($availability->original_price ?? 0);
3971 if ($ao > 0) {
3972 $list_for_dp = $ao;
3973 }
3974 }
3975 $pre_dp_effective = (float) $base_trip_price;
3976 $base_trip_price = apply_filters('yatra_booking_trip_price', $base_trip_price, $trip_id, [
3977 'departure_date' => $travel_date,
3978 'spots_remaining' => $availability ? (int) ($availability->spots_remaining ?? null) : null,
3979 'availability_id' => $availability_id,
3980 'original_price' => $list_for_dp > 0 ? $list_for_dp : $pre_dp_effective,
3981 'discounted_price' => $pre_dp_effective,
3982 ]);
3983 }
3984
3985 // Calculate subtotals per category
3986 $category_breakdown = [];
3987 $subtotal = 0;
3988 $total_travelers = 0;
3989 $normalized_traveler_counts = [];
3990 if (!empty($traveler_counts) && is_array($traveler_counts)) {
3991 foreach ($traveler_counts as $k => $v) {
3992 $key = is_numeric($k) ? (int) $k : (string) $k;
3993 $normalized_traveler_counts[$key] = (int) $v;
3994 }
3995 }
3996
3997 if ($is_traveler_based && !empty($normalized_traveler_counts)) {
3998 foreach ($price_types as $pt) {
3999 $category_id = $pt->category_id;
4000 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
4001 if ($count > 0) {
4002 // Single source of truth for the line amount (per-person ×
4003 // count, flat per-group, or per-block group pricing).
4004 $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
4005 $category_subtotal = \Yatra\Services\TripPricingService::categoryLineSubtotal($pt, $count, (float) $pt->effective_price);
4006 $category_breakdown[] = [
4007 'category_id' => $category_id,
4008 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
4009 'count' => $count,
4010 'price' => (float) $pt->effective_price,
4011 'subtotal' => $category_subtotal,
4012 'pricing_mode' => $pt_pricing_mode,
4013 // Carry the group-size knobs so the reconciliation pass
4014 // below can re-derive the same per-block/flat subtotal.
4015 'max_pax' => isset($pt->max_pax) && $pt->max_pax !== null && $pt->max_pax !== '' ? (int) $pt->max_pax : null,
4016 'group_overflow' => $pt->group_overflow ?? 'block',
4017 ];
4018 $subtotal += $category_subtotal;
4019 $total_travelers += $count;
4020 }
4021 }
4022 } else {
4023 // Regular pricing
4024 $total_travelers = array_sum(array_map('intval', $normalized_traveler_counts));
4025 // If traveler_counts is empty, fallback to session 'travelers' count
4026 if ($total_travelers < 1) {
4027 $total_travelers = !empty($session['travelers']) ? (int) $session['travelers'] : 1;
4028 }
4029 $price_per_person = $base_trip_price;
4030 $subtotal = $price_per_person * $total_travelers;
4031
4032 }
4033
4034 // Calculate group discount
4035 $discountService = new \Yatra\Services\DiscountService();
4036 $travelerCountsForDiscount = [];
4037 if ($is_traveler_based) {
4038 foreach ($normalized_traveler_counts as $k => $v) {
4039 if (is_numeric($k)) {
4040 $travelerCountsForDiscount[(int) $k] = (int) $v;
4041 }
4042 }
4043 }
4044 if (empty($travelerCountsForDiscount)) {
4045 $travelerCountsForDiscount['default'] = $total_travelers;
4046 }
4047
4048 $priceTypesForDiscount = [];
4049 if ($is_traveler_based) {
4050 foreach ($price_types as $pt) {
4051 $pt = (array) $pt;
4052 // Keep pricing_mode / max_pax / group_overflow so the group
4053 // discount base honours flat and per-block group pricing
4054 // (not just category_id + effective_price).
4055 $pt['effective_price'] = $pt['effective_price'] ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
4056 $priceTypesForDiscount[] = $pt;
4057 }
4058 } else {
4059 $priceTypesForDiscount[] = [
4060 'category_id' => 'default',
4061 'effective_price' => $base_trip_price,
4062 ];
4063 }
4064
4065 $group_discount = $discountService->calculateGroupDiscount($trip_id, $travelerCountsForDiscount, $priceTypesForDiscount);
4066 $group_discount_amount = $group_discount['amount'] ?? 0;
4067 $group_discount_label = $group_discount['label'] ?? __('Group Discount', 'yatra');
4068 $group_discount_code = $group_discount['code'] ?? null;
4069
4070 // Calculate coupon discount using DiscountService
4071 $coupon_discount_amount = 0;
4072 $coupon_discount_label = '';
4073 $coupon_error = '';
4074
4075 if (!empty($coupon_code)) {
4076 $discountService = new \Yatra\Services\DiscountService();
4077 $subtotal_after_group = $subtotal - $group_discount_amount;
4078
4079 $coupon_result = $discountService->calculateCouponDiscount(
4080 $coupon_code,
4081 $subtotal_after_group,
4082 $trip_id,
4083 $total_travelers,
4084 $is_traveler_based ? $normalized_traveler_counts : []
4085 );
4086
4087 if ($coupon_result['calculated_amount'] > 0) {
4088 $coupon_discount_amount = $coupon_result['calculated_amount'];
4089 $coupon_discount_label = $coupon_result['label'];
4090 } else {
4091 $coupon_error = __('This coupon is not valid for your booking.', 'yatra');
4092 }
4093 }
4094
4095 // Use CalculationService for on-demand pricing calculation
4096 $calculationService = new CalculationService();
4097
4098 // The selected-service ids the customer just submitted live in
4099 // `$selected_service_ids_from_request` (line ~2635). The previous
4100 // version of this method initialised a fresh `$additional_services
4101 // = []` here and passed that empty list into calculateFromSession
4102 // — which made the Pro AdditionalServicesModule's
4103 // `addServicesToSubtotal` hook bail out at its `empty($selectedServiceIds)`
4104 // guard, so every AJAX summary refresh wiped services out of the
4105 // Trip Subtotal even though the sidebar still rendered the rows.
4106 // Pass the real selected ids instead so CalculationService →
4107 // Pro filter chain folds them into `$subtotal` correctly.
4108 $additional_services = is_array($selected_service_ids_from_request)
4109 ? array_values(array_map('intval', $selected_service_ids_from_request))
4110 : [];
4111
4112 // Create session-like data structure for calculation (trip data fetched from database)
4113 $session_like_data = [
4114 'trip_id' => $trip_id,
4115 'travelers' => $total_travelers,
4116 'traveler_counts' => $traveler_counts,
4117 'travel_date' => $travel_date,
4118 'departure_time' => $departure_time,
4119 'additional_services' => $additional_services
4120 ];
4121
4122 // Apply filter for pro plugins to modify summary calculation parameters
4123 $calculation_params = apply_filters('yatra_summary_calculation_params', [
4124 'session_data' => $session_like_data,
4125 'coupon_code' => $coupon_code,
4126 'payment_method' => $payment_method,
4127 ]);
4128
4129 $pricing = $calculationService->calculateFromSession(
4130 $calculation_params['session_data'],
4131 $calculation_params['coupon_code'],
4132 $calculation_params['payment_method']
4133 );
4134
4135 $total_amount = $pricing['final_total'];
4136 $amount_due = $pricing['amount_due'];
4137 $tax_calculation = $pricing['tax_calculation'];
4138 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
4139 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
4140 $tax_breakdown = $pricing['tax_calculation']['tax_breakdown'];
4141
4142 // ── Reconcile per-category display prices with CalculationService ─
4143 //
4144 // The $category_breakdown computed above (~line 3358) used a SEPARATE
4145 // DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled.
4146 // In certain AJAX-recompute contexts (e.g. switching payment_method)
4147 // that loop could miss DP — for example when a stored availability
4148 // row's price_types already had a pre-DP effective_price baked in,
4149 // or when a date-sensitive DP rule didn't fire because the request
4150 // didn't carry the same departure_date context.
4151 //
4152 // CalculationService is the single source of truth for booking math;
4153 // it already computed the correct post-DP per-category prices and
4154 // returned them as `category_prices_post_dp` (keyed by string
4155 // category_id). Reconcile the display breakdown against that map so
4156 // "Adult x 8 ($131.12 x 8)" can never disagree with the Trip
4157 // Subtotal the rest of the page is built from.
4158 if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) {
4159 $catPricesPostDp = $pricing['category_prices_post_dp'];
4160 $reconciledSubtotal = 0.0;
4161 foreach ($category_breakdown as &$cat) {
4162 $cid = isset($cat['category_id']) ? (string) $cat['category_id'] : '';
4163 if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
4164 $authoritativePrice = (float) $catPricesPostDp[$cid];
4165 $count = (int) ($cat['count'] ?? 0);
4166 $cat['price'] = $authoritativePrice;
4167 // Re-derive the line amount from the authoritative post-DP
4168 // price using the same rule as the charge (flat per-group,
4169 // per-block, or per-person × count).
4170 $cat['subtotal'] = \Yatra\Services\TripPricingService::categoryLineSubtotal($cat, $count, $authoritativePrice);
4171 }
4172 $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
4173 }
4174 unset($cat);
4175 // Keep the top-level $subtotal in sync with the reconciled
4176 // breakdown so any downstream renderers that read it (instead
4177 // of $pricing['base_amount']) still see consistent numbers.
4178 if ($reconciledSubtotal > 0) {
4179 $subtotal = $reconciledSubtotal;
4180 }
4181 }
4182
4183 /**
4184 * Filter: Get additional services for this trip
4185 * Allows premium modules to add extra services to the booking summary
4186 *
4187 * @param array $services Empty array by default
4188 * @param int $trip_id The trip ID
4189 * @param int $total_travelers Total number of travelers
4190 * @param array $traveler_counts Traveler counts by category
4191 * @param string $travel_date The travel date
4192 * @since 3.0.0
4193 */
4194 $additional_services = apply_filters('yatra_booking_additional_services', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
4195
4196 // Get selected services from request (priority) or session
4197 // If request has additional_services, use that; otherwise fall back to session
4198 if ($selected_service_ids_from_request !== null) {
4199 $selected_service_ids = $selected_service_ids_from_request;
4200 } else {
4201 $selected_service_ids = isset($existing_session['additional_services']) && is_array($existing_session['additional_services'])
4202 ? array_map('intval', $existing_session['additional_services'])
4203 : [];
4204 }
4205
4206 // Mark which services are selected and calculate their price based on price_per
4207 $duration_days = (int) ($trip->duration_days ?? 1);
4208 $default_services_total = 0.0;
4209 foreach ($additional_services as &$service) {
4210 $serviceId = (int) $service['id'];
4211 $isInRequest = in_array($serviceId, $selected_service_ids, true);
4212 $isRequired = !empty($service['is_required']);
4213 $isIncluded = !empty($service['is_included']);
4214 $service['selected'] = $isInRequest || $isRequired || $isIncluded;
4215
4216 // Calculate the price based on price_per (person, day, booking)
4217 $basePrice = (float) ($service['price'] ?? 0);
4218 $pricePer = $service['price_per'] ?? 'person';
4219
4220 switch ($pricePer) {
4221 case 'person':
4222 $service['calculated_price'] = $basePrice * $total_travelers;
4223 break;
4224 case 'day':
4225 $service['calculated_price'] = $basePrice * max(1, $duration_days);
4226 break;
4227 case 'booking':
4228 default:
4229 $service['calculated_price'] = $basePrice;
4230 break;
4231 }
4232
4233 if (!empty($service['selected']) && empty($service['is_included'])) {
4234 $default_services_total += (float) $service['calculated_price'];
4235 }
4236 }
4237 unset($service);
4238
4239 /**
4240 * Filter: Calculate additional services total
4241 * Allows premium modules to add services cost to the booking total
4242 *
4243 * @param float $services_total The services total (0 by default)
4244 * @param array $additional_services The services with 'selected' flag
4245 * @param int $trip_id The trip ID
4246 * @param int $total_travelers Total number of travelers
4247 * @param int $duration_days Trip duration in days
4248 * @since 3.0.0
4249 */
4250 $services_total = apply_filters('yatra_booking_services_total', (float) $default_services_total, $additional_services, $trip_id, $total_travelers, (int) ($trip->duration_days ?? 1));
4251
4252 // Get itinerary costs (separate from additional services)
4253 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
4254 $itinerary_costs_total = 0.0;
4255
4256 foreach ($itinerary_costs as $cost) {
4257 $basePrice = (float) ($cost['price'] ?? 0);
4258 $pricePer = $cost['price_per'] ?? 'person';
4259
4260 switch ($pricePer) {
4261 case 'person':
4262 $calculatedPrice = $basePrice * $total_travelers;
4263 break;
4264 case 'day':
4265 $calculatedPrice = $basePrice * $duration_days;
4266 break;
4267 case 'booking':
4268 default:
4269 $calculatedPrice = $basePrice;
4270 break;
4271 }
4272
4273 $itinerary_costs_total += $calculatedPrice;
4274 }
4275
4276 // Note: CalculationService already includes itinerary costs in final_total
4277 // No need to add itinerary_costs_total again - it's already included in $total_amount
4278
4279 // Calculate due amount based on payment method.
4280 //
4281 // Flow: compute a sensible default using the *percentage* filters (which
4282 // Pro can already override per-trip via trip.deposit_percentage), then
4283 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
4284 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
4285 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
4286 $context = ['trip_id' => $trip_id];
4287 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
4288 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4289 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4290
4291 $amount_due = $total_amount;
4292 if ($payment_method === 'deposit') {
4293 $amount_due = $total_amount * ($deposit_percentage / 100);
4294 } elseif ($payment_method === 'partial') {
4295 $amount_due = $total_amount * ($partial_percentage / 100);
4296 }
4297
4298 $amount_due = (float) apply_filters(
4299 'yatra_calculate_amount_due',
4300 $amount_due,
4301 $total_amount,
4302 $payment_method,
4303 $context
4304 );
4305
4306 Logger::debug('Yatra booking summary: payment method and amount due', [
4307 'context' => 'booking_summary_rest',
4308 'trip_id' => $trip_id,
4309 'flexible_payments_enabled' => $flexible_payments_enabled,
4310 'payment_method' => $payment_method,
4311 'total_amount' => round($total_amount, 4),
4312 'amount_due' => round($amount_due, 4),
4313 'deposit_percentage' => $deposit_percentage,
4314 'partial_percentage' => $partial_percentage,
4315 ]);
4316
4317 // Build pricing HTML for the summary section (using CalculationService data)
4318 $pricing_html = $this->buildPricingHtml([
4319 'is_traveler_based' => $is_traveler_based,
4320 'category_breakdown' => $category_breakdown,
4321 'price_per_person' => $price_per_person ?? $base_trip_price,
4322 'total_travelers' => $total_travelers,
4323 'gross_total' => $pricing['gross_total'] ?? $pricing['base_amount'],
4324 'subtotal' => $pricing['gross_total'] ?? $pricing['base_amount'],
4325 'taxable_amount' => $pricing['taxable_amount'] ?? 0,
4326 'group_discount_amount' => $pricing['group_discount']['amount'] ?? 0,
4327 'group_discount_label' => $pricing['group_discount']['label'] ?? '',
4328 'coupon_discount_amount' => $pricing['coupon_discount']['calculated_amount'] ?? 0,
4329 'coupon_discount_label' => $pricing['coupon_discount']['label'] ?? '',
4330 'coupon_code' => $pricing['coupon_discount']['code'] ?? '',
4331 'additional_services' => $additional_services,
4332 'services_total' => $services_total,
4333 'itinerary_costs' => $itinerary_costs,
4334 'itinerary_costs_total' => $itinerary_costs_total,
4335 'total_amount' => $total_amount,
4336 'amount_due' => $amount_due,
4337 'payment_method' => $payment_method,
4338 'deposit_percentage' => $deposit_percentage,
4339 'partial_percentage' => $partial_percentage,
4340 // Tax variables from centralized calculation
4341 'enable_tax' => $pricing['tax_calculation']['enable_tax'],
4342 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'],
4343 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'],
4344 'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'],
4345 // Dynamic-pricing data — without these, the AJAX-refreshed summary
4346 // would never carry the DP line items even though CalculationService
4347 // produces them on every recalculation.
4348 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null,
4349 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null,
4350 'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0,
4351 // Authoritative post-DP per-category map. Checkout::getCategoryBreakdown
4352 // prefers this over the session's $pt->effective_price (which can be
4353 // pre-DP after a stored availability row's price_types come in
4354 // pre-baked), so forwarding it here is what keeps the AJAX-rendered
4355 // "Adult x N ($X x N)" row in sync with the actual Trip Subtotal.
4356 'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [],
4357 // Currency for consistent formatting
4358 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
4359 ]);
4360
4361 // Build response
4362 return new WP_REST_Response([
4363 'success' => true,
4364 'data' => [
4365 'is_traveler_based' => $is_traveler_based,
4366 'category_breakdown' => $category_breakdown,
4367 'subtotal' => round($subtotal, 2),
4368 'subtotal_formatted' => yatra_format_price($subtotal),
4369 'total_travelers' => $total_travelers,
4370 'group_discount' => $group_discount ? [
4371 'amount' => round($group_discount_amount, 2),
4372 'amount_formatted' => yatra_format_price($group_discount_amount),
4373 'label' => $group_discount_label,
4374 'code' => $group_discount_code,
4375 'applied_categories' => $group_discount['applied_categories'] ?? [],
4376 ] : null,
4377 'coupon_discount' => $coupon_discount_amount > 0 ? [
4378 'amount' => round($coupon_discount_amount, 2),
4379 'amount_formatted' => yatra_format_price($coupon_discount_amount),
4380 'label' => $coupon_discount_label,
4381 'code' => $coupon_code,
4382 ] : null,
4383 'coupon_error' => $coupon_error,
4384 'total_discount' => round($group_discount_amount + $coupon_discount_amount, 2),
4385 'total_discount_formatted' => yatra_format_price($group_discount_amount + $coupon_discount_amount),
4386 // Additional services (premium feature)
4387 'additional_services' => $additional_services,
4388 'services_total' => round($services_total, 2),
4389 'services_total_formatted' => yatra_format_price($services_total),
4390 // Itinerary costs (separate from services)
4391 'itinerary_costs' => $itinerary_costs,
4392 'itinerary_costs_total' => round($itinerary_costs_total, 2),
4393 'itinerary_costs_total_formatted' => yatra_format_price($itinerary_costs_total),
4394 'total_amount' => round($total_amount, 2),
4395 'total_amount_formatted' => yatra_format_price($total_amount),
4396 'amount_due' => round($amount_due, 2),
4397 'amount_due_formatted' => yatra_format_price($amount_due),
4398 'deposit_percentage' => $deposit_percentage,
4399 'partial_percentage' => $partial_percentage,
4400 // HTML for the pricing section
4401 'pricing_html' => $pricing_html,
4402 ],
4403 ]);
4404 }
4405
4406 /**
4407 * Build HTML for the pricing summary section
4408 * This is returned via AJAX to update the pricing breakdown dynamically
4409 * Uses the pricing-summary.php template for rendering with Checkout model
4410 */
4411 private function buildPricingHtml(array $data): string
4412 {
4413 // Get session data to create Checkout model
4414 yatra_start_session();
4415 $session = yatra_get_booking_session();
4416
4417 // Get trip data
4418 $trip_id = (int) ($session['trip_id'] ?? 0);
4419 if (empty($trip_id)) {
4420 return '<p>' . __('Pricing information not available.', 'yatra') . '</p>';
4421 }
4422
4423 $tripRepository = new \Yatra\Repositories\TripRepository();
4424 $trip = $tripRepository->findPublished($trip_id);
4425 if (!$trip) {
4426 return '<p>' . __('Trip not found.', 'yatra') . '</p>';
4427 }
4428
4429 // Build pricing calculation array from data (centralized pricing)
4430 $resolvedCurrentPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
4431 $pricingCalculation = [
4432 'original_price' => $trip->original_price ?? 0,
4433 'discounted_price' => $resolvedCurrentPrice,
4434 'unit_price' => $data['price_per_person'] ?? $resolvedCurrentPrice,
4435 'pricing_type' => $session['pricing_type'] ?? 'regular',
4436 'base_amount' => $data['gross_total'] ?? 0,
4437 'subtotal' => $data['subtotal'] ?? $data['gross_total'] ?? 0,
4438 'taxable_amount' => $data['taxable_amount'] ?? 0,
4439 'gross_total' => $data['gross_total'] ?? 0,
4440 'final_total' => $data['total_amount'] ?? 0,
4441 'amount_due' => $data['amount_due'] ?? 0,
4442 'travelers_count' => $data['total_travelers'] ?? 1,
4443 'is_traveler_based' => $data['is_traveler_based'] ?? false,
4444 'category_breakdown' => $data['category_breakdown'] ?? [],
4445 'group_discount' => [
4446 'amount' => $data['group_discount_amount'] ?? 0,
4447 'label' => $data['group_discount_label'] ?? '',
4448 ],
4449 'coupon_discount' => [
4450 'code' => $data['coupon_code'] ?? '',
4451 'calculated_amount' => $data['coupon_discount_amount'] ?? 0,
4452 'label' => $data['coupon_discount_label'] ?? '',
4453 ],
4454 'total_discount_amount' => ($data['group_discount_amount'] ?? 0) + ($data['coupon_discount_amount'] ?? 0),
4455 'additional_services' => $data['additional_services'] ?? [],
4456 'services_total' => $data['services_total'] ?? 0,
4457 'itinerary_costs' => $data['itinerary_costs'] ?? [],
4458 'itinerary_costs_total' => $data['itinerary_costs_total'] ?? 0,
4459 'tax_calculation' => [
4460 'enable_tax' => $data['enable_tax'] ?? false,
4461 'tax_breakdown' => $data['tax_breakdown'] ?? [],
4462 'total_tax_amount' => $data['total_tax_amount'] ?? 0,
4463 'tax_inclusive' => $data['tax_inclusive'] ?? false,
4464 ],
4465 // Dynamic-pricing breakdown — without this, the AJAX-refreshed
4466 // sidebar would never show DP line items even though the page-load
4467 // path does. Keys match CalculationService's pricing_data shape so
4468 // Checkout::getDynamicPricing() returns identical results in both
4469 // render contexts.
4470 'dynamic_pricing' => $data['dynamic_pricing'] ?? null,
4471 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null,
4472 'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0,
4473 // Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown
4474 // keys off this to override the (potentially stale / pre-DP)
4475 // $pt->effective_price coming from session price_types — without
4476 // it, the AJAX recompute renders pre-DP rows while the rest of
4477 // the summary uses the post-DP base amount.
4478 'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [],
4479 'currency' => $data['currency'] ?? null,
4480 ];
4481
4482 // Update session with payment method if provided
4483 if (!empty($data['payment_method'])) {
4484 $session['payment_method'] = $data['payment_method'];
4485 }
4486 if (!empty($data['deposit_percentage'])) {
4487 $session['deposit_percentage'] = $data['deposit_percentage'];
4488 }
4489 if (!empty($data['partial_payment_percentage'])) {
4490 $session['partial_payment_percentage'] = $data['partial_payment_percentage'];
4491 }
4492 if (!empty($data['partial_percentage'])) {
4493 $session['partial_payment_percentage'] = $data['partial_percentage'];
4494 }
4495
4496 if (!empty($data['payment_method']) || !empty($data['deposit_percentage']) || !empty($data['partial_percentage']) || !empty($data['partial_payment_percentage'])) {
4497 yatra_set_booking_session($session);
4498 }
4499
4500 // Create Checkout model instance
4501 $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation);
4502
4503 // Surface dynamic-pricing breakdown to the template scope. The partial
4504 // reads `$dynamic_pricing` for the DP block; the page-load path
4505 // already sets this in booking-content.php.
4506 $dynamic_pricing = $pricingCalculation['dynamic_pricing'] ?? null;
4507 $currency = $pricingCalculation['currency'] ?? null;
4508
4509 // Load the template (uses $checkout model)
4510 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php';
4511
4512 if (!file_exists($template_path)) {
4513 return '<p>' . __('Template not found.', 'yatra') . '</p>';
4514 }
4515
4516 // Use output buffering to capture the template output
4517 ob_start();
4518 include $template_path;
4519 return ob_get_clean();
4520 }
4521
4522 /**
4523 * Remove coupon code from booking session
4524 */
4525 public function remove_coupon(WP_REST_Request $request): WP_REST_Response
4526 {
4527 yatra_start_session();
4528
4529 $data = $request->get_json_params() ?? [];
4530
4531 // M-2: restore CSRF protection stripped by public_permission_callback.
4532 if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4533 return $blocked;
4534 }
4535
4536 $session = yatra_get_booking_session();
4537
4538 // Same booking_token rehydration as apply_coupon — handle REST
4539 // requests that arrive without a propagated PHPSESSID.
4540 if (empty($session) || empty($session['trip_id'])) {
4541 $token = null;
4542 if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
4543 $token = sanitize_text_field((string) $data['booking_token']);
4544 } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
4545 $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
4546 }
4547 if ($token) {
4548 $transient_data = get_transient($token);
4549 if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
4550 $session = $transient_data;
4551 $_SESSION['yatra_booking'] = $session;
4552 $_SESSION['yatra_booking_token'] = $token;
4553 }
4554 }
4555 }
4556
4557 if (empty($session)) {
4558 return new WP_REST_Response([
4559 'success' => false,
4560 'message' => __('No active booking session found.', 'yatra'),
4561 ], 400);
4562 }
4563
4564 // Remove coupon directly from session to avoid array_merge issues
4565 if (isset($_SESSION['yatra_booking']['coupon'])) {
4566 unset($_SESSION['yatra_booking']['coupon']);
4567 }
4568 $_SESSION['yatra_booking']['timestamp'] = time();
4569
4570 // Also update local session array for calculation
4571 unset($session['coupon']);
4572 $session['timestamp'] = time();
4573
4574 // Persist the coupon removal to the transient too — without this,
4575 // the next /booking/summary AJAX (which the JS calls right after
4576 // this endpoint) re-reads the still-couponed transient and the
4577 // sidebar shows the coupon discount as if it never went away.
4578 // `yatra_set_booking_session()` array_merges `$_SESSION['yatra_booking']`
4579 // (already coupon-less above) with the passed data and writes the
4580 // result into the transient keyed by the existing booking token.
4581 yatra_set_booking_session($session);
4582
4583 // Ensure session data is written immediately
4584 if (session_status() === PHP_SESSION_ACTIVE) {
4585 session_write_close();
4586 }
4587
4588 $total_amount = $this->calculateSessionTotal($session);
4589
4590 return new WP_REST_Response([
4591 'success' => true,
4592 'message' => __('Coupon removed.', 'yatra'),
4593 'data' => [
4594 'new_total' => $total_amount,
4595 'new_total_formatted' => yatra_format_price($total_amount),
4596 ],
4597 ]);
4598 }
4599
4600 /**
4601 * Calculate total amount from session
4602 * Uses CalculationService to get accurate pricing (without coupon)
4603 */
4604 private function calculateSessionTotal(array $session): float
4605 {
4606 // Use CalculationService to get accurate base pricing
4607 $calculationService = new \Yatra\Services\CalculationService();
4608
4609 try {
4610 // Calculate pricing WITHOUT coupon (we're calculating this to apply coupon to it)
4611 $pricing = $calculationService->calculateFromSession($session, '');
4612
4613 // Return gross_total (base amount before discounts but after any group discounts)
4614 $total = $pricing['gross_total'] ?? 0;
4615
4616 return (float) $total;
4617 } catch (\Throwable $e) {
4618 return 0.0;
4619 }
4620 }
4621
4622 /**
4623 * @deprecated Use DiscountService::calculateCouponDiscount() instead
4624 * Calculate discount amount
4625 */
4626 private function calculateDiscountAmount(\stdClass $discount, float $total, array $session): float
4627 {
4628 $discount_amount = 0;
4629
4630 // Check if group discount applies
4631 if ($discount->is_group_discount && !empty($discount->min_group_size)) {
4632 $travelers = (int) ($session['travelers'] ?? 1);
4633 if ($travelers >= (int) $discount->min_group_size && !empty($discount->group_discount_amount)) {
4634 // Apply group discount
4635 if ($discount->group_discount_type === 'percentage') {
4636 $discount_amount = $total * ((float) $discount->group_discount_amount / 100);
4637 } else {
4638 $discount_amount = (float) $discount->group_discount_amount;
4639 }
4640 }
4641 }
4642
4643 // If no group discount, apply regular discount
4644 if ($discount_amount === 0) {
4645 if ($discount->type === 'percentage') {
4646 $discount_amount = $total * ((float) $discount->amount / 100);
4647 } else {
4648 $discount_amount = (float) $discount->amount;
4649 }
4650 }
4651
4652 // Apply max discount cap if set
4653 if (!empty($discount->max_discount_amount) && $discount_amount > (float) $discount->max_discount_amount) {
4654 $discount_amount = (float) $discount->max_discount_amount;
4655 }
4656
4657 // Ensure discount doesn't exceed total
4658 if ($discount_amount > $total) {
4659 $discount_amount = $total;
4660 }
4661
4662 return round($discount_amount, 2);
4663 }
4664
4665 /**
4666 * Get coupon usage count by user
4667 */
4668 private function getCouponUsageByUser(string $discount_code, int $user_id): int
4669 {
4670 // Use AvailabilityService to check discount code usage
4671 return $this->availabilityService->getDiscountCodeUsage($user_id, strtoupper(sanitize_text_field($discount_code)));
4672 }
4673 }
4674
4675