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

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

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