PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.15
Yatra – Travel Booking & Tour Operator Software v3.0.15
3.0.15 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 All 83 releases
yatra / app / Controllers / BookingSessionController.php

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

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