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

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

3,178 lines 139.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Repositories\TravellerRepository;
11 use Yatra\Repositories\CustomerRepository;
12 use Yatra\Repositories\TripRepository;
13 use Yatra\Repositories\BookingRepository;
14 use Yatra\Services\SettingsService;
15 use Yatra\Services\TransactionalEmailTemplateService;
16 use Yatra\Services\EmailService;
17 use Yatra\Services\DepartureService;
18 use Yatra\Services\AvailabilityService;
19 use Yatra\Services\CalculationService;
20 use Yatra\Repositories\DepartureRepository;
21 use Yatra\Repositories\BookingDepartureRepository;
22 use Yatra\PaymentGateways\GatewayUserMessages;
23 use Yatra\PaymentGateways\PaymentGatewayRegistry;
24 use Yatra\Utils\Logger;
25
26 /**
27 * Booking Session REST API Controller
28 * Manages booking session data via REST API
29 */
30 class BookingSessionController extends BaseController
31 {
32 /**
33 * @var TravellerRepository
34 */
35 private TravellerRepository $travellerRepository;
36
37 /**
38 * @var CustomerRepository
39 */
40 private CustomerRepository $customerRepository;
41
42 /**
43 * @var TripRepository
44 */
45 private TripRepository $tripRepository;
46
47 /**
48 * @var BookingRepository
49 */
50 private BookingRepository $bookingRepository;
51
52 /**
53 * @var DepartureService
54 */
55 private DepartureService $departureService;
56
57 /**
58 * @var AvailabilityService
59 */
60 private AvailabilityService $availabilityService;
61
62 /**
63 * @var \Yatra\Repositories\DiscountRepository
64 */
65 private $discountRepository;
66
67 /**
68 * Constructor
69 */
70 public function __construct()
71 {
72 $this->travellerRepository = new TravellerRepository();
73 $this->customerRepository = new CustomerRepository();
74 $this->tripRepository = new TripRepository();
75 $this->bookingRepository = new BookingRepository();
76 $this->departureService = new DepartureService(
77 new DepartureRepository(),
78 new \Yatra\Repositories\BookingDepartureRepository(),
79 $this->bookingRepository,
80 $this->tripRepository
81 );
82 $this->availabilityService = new AvailabilityService(
83 new \Yatra\Repositories\AvailabilityRepository()
84 );
85 $this->discountRepository = new \Yatra\Repositories\DiscountRepository();
86 }
87
88 /**
89 * Public permission callback for REST API routes
90 * Allows public access to booking endpoints
91 *
92 * Removes cookie validation to prevent "Cookie check failed" errors for logged-out users
93 */
94 public function public_permission_callback(?WP_REST_Request $request = null): bool
95 {
96 // Remove cookie validation requirement for this endpoint
97 // This allows guest users to access the endpoint without nonce validation
98 remove_filter('rest_authentication_errors', 'rest_cookie_check_errors', 100);
99 return true;
100 }
101
102 /**
103 * REST API namespace
104 */
105 protected string $namespace = 'yatra/v1';
106
107 /**
108 * Register REST API routes
109 */
110 public function register_routes(): void
111 {
112 // Set booking session
113 register_rest_route($this->namespace, '/booking/session', [
114 'methods' => 'POST',
115 'callback' => [$this, 'set_session'],
116 'permission_callback' => [$this, 'public_permission_callback'],
117 ]);
118
119 // Get booking session
120 register_rest_route($this->namespace, '/booking/session', [
121 'methods' => 'GET',
122 'callback' => [$this, 'get_session'],
123 'permission_callback' => [$this, 'public_permission_callback'],
124 ]);
125
126 // Clear booking session
127 register_rest_route($this->namespace, '/booking/session', [
128 'methods' => 'DELETE',
129 'callback' => [$this, 'clear_session'],
130 'permission_callback' => [$this, 'public_permission_callback'],
131 ]);
132
133 // Get trip data for booking
134 register_rest_route($this->namespace, '/booking/trip/(?P<id>\d+)', [
135 'methods' => 'GET',
136 'callback' => [$this, 'get_trip_for_booking'],
137 'permission_callback' => [$this, 'public_permission_callback'],
138 'args' => [
139 'id' => [
140 'required' => true,
141 'type' => 'integer',
142 'sanitize_callback' => 'absint',
143 ],
144 ],
145 ]);
146
147 // Create booking
148 register_rest_route($this->namespace, '/booking/create', [
149 'methods' => 'POST',
150 'callback' => [$this, 'create_booking'],
151 'permission_callback' => [$this, 'public_permission_callback'],
152 ]);
153
154 // Apply coupon code
155 register_rest_route($this->namespace, '/booking/coupon/apply', [
156 'methods' => 'POST',
157 'callback' => [$this, 'apply_coupon'],
158 'permission_callback' => [$this, 'public_permission_callback'],
159 ]);
160
161 // Remove coupon code
162 register_rest_route($this->namespace, '/booking/coupon/remove', [
163 'methods' => 'POST',
164 'callback' => [$this, 'remove_coupon'],
165 'permission_callback' => [$this, 'public_permission_callback'],
166 ]);
167
168 // Calculate booking summary (AJAX endpoint for dynamic updates)
169 register_rest_route($this->namespace, '/booking/summary', [
170 'methods' => 'POST',
171 'callback' => [$this, 'calculate_summary'],
172 'permission_callback' => [$this, 'public_permission_callback'],
173 ]);
174
175 // Complete payment for client-side gateways (Square, etc.)
176 register_rest_route($this->namespace, '/payment/(?P<gateway>[a-z_]+)/complete', [
177 'methods' => 'POST',
178 'callback' => [$this, 'complete_gateway_payment'],
179 'permission_callback' => [$this, 'public_permission_callback'],
180 ]);
181 }
182
183 /**
184 * Complete payment for client-side payment gateways
185 * Used by Square, and other gateways that tokenize on client
186 */
187 public function complete_gateway_payment(WP_REST_Request $request): WP_REST_Response
188 {
189 $gateway_id = $request->get_param('gateway');
190 $data = $request->get_json_params();
191
192 $booking_id = $data['booking_id'] ?? 0;
193 $source_id = $data['source_id'] ?? '';
194 $amount = $data['amount'] ?? 0;
195 $currency = $data['currency'] ?? 'USD';
196
197 if (empty($booking_id) || empty($source_id)) {
198 return new WP_REST_Response([
199 'success' => false,
200 'message' => __('Missing required payment data.', 'yatra'),
201 ], 400);
202 }
203
204 // Get the gateway
205 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
206 $gateway = $registry->get($gateway_id);
207
208 if (!$gateway) {
209 return new WP_REST_Response([
210 'success' => false,
211 'message' => __('Invalid payment gateway.', 'yatra'),
212 ], 400);
213 }
214
215 // Check if gateway has createPayment method
216 if (!method_exists($gateway, 'createPayment')) {
217 return new WP_REST_Response([
218 'success' => false,
219 'message' => __('Gateway does not support this payment method.', 'yatra'),
220 ], 400);
221 }
222
223 // Create the payment
224 $result = $gateway->createPayment([
225 'source_id' => $source_id,
226 'booking_id' => $booking_id,
227 'amount' => $amount,
228 'currency' => $currency,
229 ]);
230
231 if (!$result['success']) {
232 return new WP_REST_Response([
233 'success' => false,
234 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
235 ], 400);
236 }
237
238 // Update booking payment status
239 $bookingRepository = new \Yatra\Repositories\BookingRepository();
240 $booking = $bookingRepository->find($booking_id);
241
242 if ($booking) {
243 // Record the payment using PaymentRepository
244 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
245 $paymentRepository->create([
246 'booking_id' => $booking_id,
247 'amount' => $amount,
248 'currency' => $currency,
249 'gateway' => $gateway_id,
250 'transaction_id' => $result['transaction_id'] ?? '',
251 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
252 ]);
253
254 // Update booking status if payment is complete
255 if (($result['status'] ?? 'completed') === 'completed') {
256 // Get total paid amount
257 $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
258 $total_amount = (float) $booking->total_amount;
259
260 if ($total_paid >= $total_amount) {
261 $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
262 } else {
263 $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
264 }
265 }
266 }
267
268 return new WP_REST_Response([
269 'success' => true,
270 'message' => __('Payment completed successfully.', 'yatra'),
271 'data' => [
272 'transaction_id' => $result['transaction_id'] ?? '',
273 'status' => $result['status'] ?? 'completed',
274 ],
275 ]);
276 }
277
278 /**
279 * Set booking session data
280 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
281 */
282 public function set_session(WP_REST_Request $request): WP_REST_Response
283 {
284 // Ensure session is started for REST API requests
285 yatra_start_session();
286
287 $data = $request->get_json_params();
288
289 // Check if this is a partial update (updating travelers or services in existing session)
290 $existing_session = yatra_get_booking_session();
291 $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) &&
292 (isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services']));
293
294 if ($is_partial_update) {
295 // Partial update: merge new data with existing session
296 if (isset($data['travelers'])) {
297 $existing_session['travelers'] = max(1, (int) $data['travelers']);
298 }
299 if (isset($data['traveler_counts']) && is_array($data['traveler_counts'])) {
300 $existing_session['traveler_counts'] = $data['traveler_counts'];
301 // Recalculate total travelers from counts
302 $existing_session['travelers'] = max(1, array_sum(array_map('intval', $data['traveler_counts'])));
303 }
304 // Handle additional services selection
305 if (isset($data['additional_services']) && is_array($data['additional_services'])) {
306 $existing_session['additional_services'] = array_map('intval', $data['additional_services']);
307 }
308
309 // Recalculate taxes for partial updates
310 $trip_price = $existing_session['trip_price'];
311 $travelers_count = $existing_session['travelers'];
312 $additional_services = $existing_session['additional_services'] ?? [];
313
314 // Calculate base amount - handle traveler-based pricing
315 $base_amount = 0;
316 $pricing_type = $existing_session['pricing_type'] ?? 'regular';
317 $price_types = $existing_session['price_types'] ?? [];
318 $traveler_counts = $existing_session['traveler_counts'] ?? [];
319
320 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
321 // Calculate for traveler-based pricing
322 foreach ($price_types as $pt) {
323 $pt = (array) $pt;
324 $category_id = $pt['category_id'] ?? 0;
325 $pricing_mode = $pt['pricing_mode'] ?? 'per_person';
326 $category_price = isset($pt['effective_price']) ? (float) $pt['effective_price'] : \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
327 $count = isset($traveler_counts[$category_id]) ? (int) $traveler_counts[$category_id] : 0;
328
329 if ($pricing_mode === 'per_group') {
330 // Per group: charge flat price once if any travelers in this category
331 if ($count > 0) {
332 $base_amount += $category_price;
333 }
334 } else {
335 // Per person: charge per traveler
336 $base_amount += $category_price * $count;
337 }
338 }
339 } else {
340 // Regular pricing
341 $base_amount = $trip_price * $travelers_count;
342 }
343
344 // Calculate additional services cost
345 $services_cost = 0;
346 if (!empty($additional_services)) {
347 foreach ($additional_services as $service_id) {
348 $service = $wpdb->get_row($wpdb->prepare(
349 "SELECT price FROM {$wpdb->prefix}yatra_additional_services WHERE id = %d",
350 $service_id
351 ));
352 if ($service && $service->price) {
353 $services_cost += (float) $service->price;
354 }
355 }
356 }
357
358 // Use CalculationService for pricing (fetches trip data from database)
359 $calculationService = new CalculationService();
360 $pricing = $calculationService->calculatePricing([
361 'trip_id' => (int) $trip->id,
362 'travelers_count' => $travelers_count,
363 'traveler_counts' => $traveler_counts,
364 'travel_date' => $travel_date,
365 'departure_time' => $departure_time,
366 'selected_services' => $additional_services,
367 'coupon_code' => '',
368 'payment_method' => 'full',
369 ]);
370 $subtotal = $pricing['subtotal'];
371 $total_with_tax = $pricing['final_total'];
372 $tax_calculation = $pricing['tax_calculation'];
373 $services_cost = $pricing['services_cost'];
374
375 // Note: Pricing is calculated on-demand via CalculationService, not stored in session
376
377 $existing_session['timestamp'] = time();
378
379 // Save updated session
380 yatra_set_booking_session($existing_session);
381
382 return new WP_REST_Response([
383 'success' => true,
384 'message' => __('Booking session updated.', 'yatra'),
385 'data' => $existing_session,
386 ]);
387 }
388
389 if (empty($data['trip_id'])) {
390 return new WP_REST_Response([
391 'success' => false,
392 'message' => __('Trip ID is required.', 'yatra'),
393 ], 400);
394 }
395
396 // Validate trip exists
397 $trip = $this->tripRepository->findPublished((int) $data['trip_id']);
398
399 if (!$trip) {
400 return new WP_REST_Response([
401 'success' => false,
402 'message' => __('Trip not found.', 'yatra'),
403 ], 404);
404 }
405
406 global $wpdb;
407
408 // Get availability-specific data if date provided
409 $availability = null;
410 $availability_id = !empty($data['availability_id']) ? sanitize_text_field($data['availability_id']) : null;
411 $travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : '';
412 $departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : '';
413
414 // Use centralized AvailabilityResolutionService to get resolved availability
415 // Priority: Availability Dates → Recurring Rules → Trip Default (specific rows override patterns)
416 // Pass departure_time for day tours with multiple time slots on the same date
417 $availability = null;
418 if ($travel_date) {
419 try {
420 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
421 $availability = $resolutionService->resolveAvailabilityForDate(
422 (int) $data['trip_id'],
423 $travel_date,
424 $departure_time ?: null
425 );
426
427 if ($availability) {
428 $data['availability_id'] = $availability->id;
429 $data['seats_available'] = $availability->seats_available;
430 $data['seats_total'] = $availability->seats_total;
431 }
432 } catch (\Exception $e) {
433 error_log('Yatra Booking: Error resolving availability - ' . $e->getMessage());
434 $availability = null;
435 }
436 }
437
438 // Resolve pricing_type and price_types via centralized TripPricingService
439 // Priority: frontend data → availability → trip defaults
440 $pricing_type = !empty($data['pricing_type'])
441 ? sanitize_text_field($data['pricing_type'])
442 : \Yatra\Services\TripPricingService::resolvePricingType($trip);
443
444 $price_types = [];
445
446 // First priority: price_types sent from frontend (from availability card)
447 if (!empty($data['price_types']) && is_array($data['price_types'])) {
448 $price_types = $data['price_types'];
449 }
450 // Second priority: availability price_types (already includes trip fallback from AvailabilityResolutionService)
451 elseif ($availability && !empty($availability->price_types)) {
452 $price_types = is_array($availability->price_types) ? $availability->price_types : [];
453 }
454 // Third priority: trip's price_types via centralized normalizer
455 if (empty($price_types)) {
456 $price_types = \Yatra\Services\TripPricingService::resolvePriceTypes($trip);
457 }
458 // Auto-detect traveler_based if price_types are present
459 if (!empty($price_types) && $pricing_type === 'regular') {
460 $pricing_type = 'traveler_based';
461 }
462 // NOTE: Actual pricing calculation is handled entirely by CalculationService below
463
464 // Enrich price_types with category labels if needed (some might already have them from frontend)
465 if (!empty($price_types)) {
466 $needsEnrichment = false;
467 foreach ($price_types as $pt) {
468 $pt = (array) $pt;
469 if (empty($pt['category_label']) && !empty($pt['category_id'])) {
470 $needsEnrichment = true;
471 break;
472 }
473 }
474
475 if ($needsEnrichment) {
476 $categoryIds = array_filter(array_map(function($p) {
477 $p = (array) $p;
478 return isset($p['category_id']) ? (int) $p['category_id'] : null;
479 }, $price_types));
480
481 if (!empty($categoryIds)) {
482 // Use AvailabilityService to get traveler categories
483 $cats = $this->availabilityService->getTravelerCategories($categoryIds);
484
485 $catIndex = [];
486 foreach ($cats as $cat) {
487 $catIndex[(int) $cat->id] = $cat;
488 }
489
490 foreach ($price_types as &$pt) {
491 $pt = (array) $pt;
492 $catId = isset($pt['category_id']) ? (int) $pt['category_id'] : null;
493 if ($catId && isset($catIndex[$catId]) && empty($pt['category_label'])) {
494 $cat = $catIndex[$catId];
495 $pt['category_label'] = $cat->label;
496 $pt['category_slug'] = $cat->slug;
497 $pt['age_min'] = $cat->age_min ? (int) $cat->age_min : null;
498 $pt['age_max'] = $cat->age_max ? (int) $cat->age_max : null;
499 }
500 // Ensure effective price is set via centralized resolver
501 if (!isset($pt['effective_price'])) {
502 $pt['effective_price'] = \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
503 }
504 }
505 }
506 }
507 }
508
509 // Parse traveler_counts if provided (for traveler-based pricing)
510 $traveler_counts = [];
511 if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) {
512 $traveler_counts = $data['traveler_counts'];
513 }
514
515 // Calculate total travelers
516 $travelers_count = isset($data['travelers']) ? (int) $data['travelers'] : 0;
517 if (!empty($traveler_counts)) {
518 $travelers_count = array_sum(array_map('intval', $traveler_counts));
519 }
520
521 if ($travelers_count < 1) {
522 return new WP_REST_Response([
523 'success' => false,
524 'message' => __('Please select at least 1 traveler to continue.', 'yatra'),
525 ], 400);
526 }
527
528 // Determine if day trip - prefer frontend value, fallback to trip duration
529 $is_day_trip = isset($data['is_day_trip']) ? (bool) $data['is_day_trip'] : (($trip->duration_days ?? 1) <= 1);
530
531 // Get additional services from request (selected in popup)
532 $additional_services = [];
533 if (!empty($data['additional_services']) && is_array($data['additional_services'])) {
534 $additional_services = array_map('intval', $data['additional_services']);
535 }
536
537 // Resolve enabled gateways for this session (use registry to respect availability)
538 $gatewayRegistry = PaymentGatewayRegistry::getInstance();
539 $availableGateways = $gatewayRegistry->getForCheckout();
540 $enabled_gateways = [];
541 foreach ($availableGateways as $gateway) {
542 if (!empty($gateway['id'])) {
543 $enabled_gateways[$gateway['id']] = $gateway;
544 }
545 }
546 // Fallback: if registry returned nothing, use saved settings gateways
547 if (empty($enabled_gateways)) {
548 $settings_gateways = SettingsService::get('payment_gateways', []);
549 if (is_array($settings_gateways)) {
550 $enabled_gateways = $settings_gateways;
551 }
552 }
553
554 // Use CalculationService as single source of truth for all pricing
555 $calculationService = new CalculationService();
556 $pricing = $calculationService->calculatePricing([
557 'trip_id' => (int) $trip->id,
558 'travelers_count' => $travelers_count,
559 'traveler_counts' => $traveler_counts,
560 'travel_date' => $travel_date,
561 'departure_time' => $departure_time,
562 'selected_services' => $additional_services,
563 'availability_id' => $availability_id ? (int) $availability_id : null,
564 'coupon_code' => '',
565 'payment_method' => 'full',
566 ]);
567
568 // Prepare session data - essential trip data (pricing fetched from database on-demand)
569 $session_data = [
570 'trip_id' => (int) $trip->id,
571 'trip_title' => $trip->title,
572 'trip_slug' => $trip->slug,
573 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
574 'min_travelers' => (int) ($trip->min_travelers ?: 1),
575 'max_travelers' => (int) ($trip->max_travelers ?: 20),
576 'duration_days' => (int) ($trip->duration_days ?: 1),
577 'travelers' => $travelers_count,
578 'travel_date' => $travel_date,
579 'departure_time' => $departure_time,
580 'timestamp' => time(),
581 // Availability-specific data
582 'availability_id' => $availability_id,
583 'pricing_type' => $pricing['pricing_type'] ?? $pricing_type,
584 'price_types' => $price_types,
585 'traveler_counts' => $traveler_counts,
586 'is_day_trip' => $is_day_trip,
587 // Additional services (selected in popup)
588 'additional_services' => $additional_services,
589 // Payment gateways available for checkout UI
590 'enabled_gateways' => $enabled_gateways,
591 // Note: NO pricing data stored - fetched from database on-demand via CalculationService
592 ];
593
594 if (!empty($data['is_remaining_payment'])) {
595 $session_data['is_remaining_payment'] = true;
596 $session_data['existing_booking_id'] = (int) ($data['existing_booking_id'] ?? 0);
597 $session_data['booking_reference'] = $data['booking_reference'] ?? '';
598 $session_data['remaining_amount'] = isset($data['remaining_amount']) ? (float) $data['remaining_amount'] : null;
599 $session_data['amount_paid'] = isset($data['amount_paid']) ? (float) $data['amount_paid'] : null;
600 $session_data['total_amount'] = isset($data['total_amount']) ? (float) $data['total_amount'] : null;
601 }
602
603 // Reset any previous session to avoid stale flags
604 yatra_clear_booking_session();
605
606 // Set session
607 yatra_set_booking_session($session_data);
608
609 // Get the booking token directly from session (it's added by yatra_set_booking_session)
610 yatra_start_session();
611 $booking_token = $_SESSION['yatra_booking_token'] ?? null;
612
613
614 // Fire hook when trip is added to booking session (for Pro modules)
615 do_action('yatra_trip_added_to_session', $session_data['trip_id'], $session_data);
616
617 // Get redirect URL
618 $redirect_url = yatra_get_checkout_url();
619
620 // Add booking token to URL for REST API → page load session restoration
621 if ($booking_token) {
622 $redirect_url = add_query_arg('booking_token', $booking_token, $redirect_url);
623 }
624
625 // Add booking_token to response data for debugging
626 $session_data['booking_token'] = $booking_token;
627
628 return new WP_REST_Response([
629 'success' => true,
630 'message' => __('Booking session created.', 'yatra'),
631 'data' => $session_data,
632 'redirect_url' => $redirect_url,
633 ]);
634 }
635
636 /**
637 * Get booking session data
638 */
639 public function get_session(WP_REST_Request $request): WP_REST_Response
640 {
641 $session_data = yatra_get_booking_session();
642
643 if (empty($session_data) || empty($session_data['trip_id'])) {
644 return new WP_REST_Response([
645 'success' => false,
646 'message' => __('No active booking session.', 'yatra'),
647 'data' => null,
648 ]);
649 }
650
651 return new WP_REST_Response([
652 'success' => true,
653 'data' => $session_data,
654 ]);
655 }
656
657 /**
658 * Clear booking session
659 */
660 public function clear_session(WP_REST_Request $request): WP_REST_Response
661 {
662 yatra_clear_booking_session();
663
664 return new WP_REST_Response([
665 'success' => true,
666 'message' => __('Booking session cleared.', 'yatra'),
667 ]);
668 }
669
670 /**
671 * Get trip data for booking page
672 */
673 public function get_trip_for_booking(WP_REST_Request $request): WP_REST_Response
674 {
675 $trip_id = (int) $request->get_param('id');
676
677 $trip = $this->tripRepository->findPublished($trip_id);
678
679 if (!$trip) {
680 return new WP_REST_Response([
681 'success' => false,
682 'message' => __('Trip not found.', 'yatra'),
683 ], 404);
684 }
685
686 // Format trip data for booking
687 $trip_data = [
688 'id' => (int) $trip->id,
689 'title' => $trip->title,
690 'slug' => $trip->slug,
691 'featured_image' => $trip->featured_image,
692 'duration_days' => (int) $trip->duration_days,
693 'duration_nights' => (int) $trip->duration_nights,
694 'difficulty_level' => $trip->difficulty_level,
695 'min_travelers' => (int) ($trip->min_travelers ?: 1),
696 'max_travelers' => (int) ($trip->max_travelers ?: 20),
697 'original_price' => (float) $trip->original_price,
698 'sale_price' => (float) $trip->sale_price,
699 'price' => !empty($trip->discounted_price) ? (float) $trip->discounted_price : (float) $trip->original_price,
700 'currency' => \Yatra\Services\SettingsService::getCurrency(),
701 'starting_location' => $trip->starting_location,
702 'ending_location' => $trip->ending_location,
703 ];
704
705 return new WP_REST_Response([
706 'success' => true,
707 'data' => $trip_data,
708 ]);
709 }
710
711 /**
712 * Create a new booking OR process remaining payment (same REST route).
713 *
714 * Regular checkout: persists the booking first (BookingService::createBooking), then starts
715 * online payment if needed; payment rows are written when the gateway confirms success
716 * (confirm endpoint, webhooks, IPN, or recordGatewayPayment for immediate captures).
717 *
718 * Remaining / balance payment: does not create a booking — only charges the existing row
719 * and records a payment on success via the same gateway completion paths.
720 */
721 public function create_booking(WP_REST_Request $request): WP_REST_Response
722 {
723 global $wpdb;
724
725 $data = $request->get_json_params();
726
727 // ========================================
728 // REMAINING PAYMENT vs NEW BOOKING
729 // ========================================
730 // A leftover PHP session from "pay remaining balance" must not hijack a normal
731 // checkout POST (full traveler payload). Only treat as remaining-payment when the
732 // client is actually on that flow (hidden field) or sends no new-booking travelers.
733 // Early return: process_remaining_payment() — no createBooking().
734 if (yatra_has_remaining_session()) {
735 $is_remaining_checkout = !empty($data['is_remaining_payment']);
736 $travelers_payload = $data['travelers'] ?? null;
737 $has_new_booking_travelers = is_array($travelers_payload) && count($travelers_payload) > 0;
738
739 if ($is_remaining_checkout) {
740 return $this->process_remaining_payment($request);
741 }
742
743 if ($has_new_booking_travelers) {
744 yatra_clear_remaining_session();
745 } else {
746 return $this->process_remaining_payment($request);
747 }
748 }
749
750 // ========================================
751 // NEW BOOKING CHECKOUT (not pay-remaining)
752 // ========================================
753 // Below: createBooking() runs once; payment is initiated afterward if amount_due > 0.
754
755 // ========================================
756 // GET BOOKING SETTINGS
757 // ========================================
758 $settings = [
759 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
760 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
761 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
762 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
763 'cancellation_policy' => \Yatra\Services\SettingsService::get('cancellation_policy', 'full_refund'),
764 'cancellation_days' => (int) \Yatra\Services\SettingsService::get('cancellation_days', 7),
765 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
766 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
767 ];
768
769 // ========================================
770 // CHECK LOGIN REQUIREMENT
771 // ========================================
772 if ($settings['require_login'] && !is_user_logged_in()) {
773 return new WP_REST_Response([
774 'success' => false,
775 'message' => __('You must be logged in to make a booking.', 'yatra'),
776 'code' => 'login_required',
777 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
778 ], 401);
779 }
780
781 // Check guest checkout
782 if (!$settings['allow_guest_checkout'] && !is_user_logged_in()) {
783 return new WP_REST_Response([
784 'success' => false,
785 'message' => __('Guest checkout is not allowed. Please log in or create an account.', 'yatra'),
786 'code' => 'guest_not_allowed',
787 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
788 ], 401);
789 }
790
791 // Get session data
792 $session = yatra_get_booking_session();
793
794 // Validate we have session data or direct booking data
795 $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0);
796
797 if (!$trip_id) {
798 return new WP_REST_Response([
799 'success' => false,
800 'message' => __('No trip selected for booking.', 'yatra'),
801 ], 400);
802 }
803
804 // Get contact email - handle both flat and nested formats
805 $contact_email = $data['contact_email'] ?? '';
806 $contact_phone = $data['contact_phone'] ?? '';
807 $contact_first_name = $data['contact_first_name'] ?? '';
808 $contact_last_name = $data['contact_last_name'] ?? '';
809 $contact_country = $data['contact_country'] ?? '';
810
811 $contact_nationality = $data['contact_nationality'] ?? '';
812 $contact_address = $data['contact_address'] ?? '';
813
814 // Emergency contact
815 $emergency_name = $data['emergency_name'] ?? '';
816 $emergency_phone = $data['emergency_phone'] ?? '';
817 $emergency_relationship = $data['emergency_relationship'] ?? '';
818
819 // Travel details
820 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
821 $travelers = $data['travelers'] ?? [];
822
823 // Validate required fields
824 if (empty($contact_email)) {
825 return new WP_REST_Response([
826 'success' => false,
827 'message' => __('Email address is required.', 'yatra'),
828 ], 400);
829 }
830
831 if (empty($contact_phone)) {
832 return new WP_REST_Response([
833 'success' => false,
834 'message' => __('Phone number is required.', 'yatra'),
835 ], 400);
836 }
837
838 if (empty($travel_date)) {
839 return new WP_REST_Response([
840 'success' => false,
841 'message' => __('Travel date is required.', 'yatra'),
842 ], 400);
843 }
844
845 if (empty($travelers) || !is_array($travelers)) {
846 return new WP_REST_Response([
847 'success' => false,
848 'message' => __('At least one traveler is required.', 'yatra'),
849 ], 400);
850 }
851
852 // Validate email
853 if (!is_email($contact_email)) {
854 return new WP_REST_Response([
855 'success' => false,
856 'message' => __('Invalid email address.', 'yatra'),
857 ], 400);
858 }
859
860 // Get trip data
861 $trip = $this->tripRepository->findPublished($trip_id);
862
863 if (!$trip) {
864 return new WP_REST_Response([
865 'success' => false,
866 'message' => __('Trip not found.', 'yatra'),
867 ], 404);
868 }
869
870 // ========================================
871 // PRICING via CalculationService (single source of truth)
872 // ========================================
873 // Count only actual travelers (exclude contact and emergency contact)
874 $travelers_count = 0;
875 foreach ($travelers as $traveler) {
876 if (isset($traveler['type']) && $traveler['type'] === 'traveler') {
877 $travelers_count++;
878 }
879 }
880 // Fallback if no type is set (legacy data)
881 if ($travelers_count === 0) {
882 $travelers_count = count($travelers);
883 }
884
885 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
886 if ($payment_method === '') {
887 $payment_method = 'full';
888 }
889 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
890
891 // Get coupon code from request OR session
892 $coupon_code = sanitize_text_field($data['coupon_code'] ?? '');
893 if (empty($coupon_code) && !empty($session['coupon']['code'])) {
894 $coupon_code = sanitize_text_field($session['coupon']['code']);
895 }
896
897 $departure_time = $data['departure_time'] ?? ($session['departure_time'] ?? '');
898 $additional_services = $data['additional_services'] ?? ($session['additional_services'] ?? []);
899 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
900
901 // Build traveler_counts from travelers array or session
902 $traveler_counts = [];
903 if (!empty($data['traveler_counts']) && is_array($data['traveler_counts'])) {
904 $traveler_counts = $data['traveler_counts'];
905 } elseif (!empty($session['traveler_counts']) && is_array($session['traveler_counts'])) {
906 $traveler_counts = $session['traveler_counts'];
907 } else {
908 // Build from travelers array if category_id is present
909 foreach ($travelers as $traveler) {
910 $categoryId = $traveler['category_id'] ?? null;
911 if ($categoryId) {
912 if (!isset($traveler_counts[$categoryId])) {
913 $traveler_counts[$categoryId] = 0;
914 }
915 $traveler_counts[$categoryId]++;
916 }
917 }
918 if (empty($traveler_counts) && $travelers_count > 0) {
919 $traveler_counts['default'] = $travelers_count;
920 }
921 }
922
923 // Use CalculationService as single source of truth
924 $calculationService = new CalculationService();
925 $pricing = $calculationService->calculatePricing([
926 'trip_id' => $trip_id,
927 'travelers_count' => $travelers_count,
928 'traveler_counts' => $traveler_counts,
929 'travel_date' => $travel_date,
930 'departure_time' => $departure_time,
931 'selected_services' => $additional_services,
932 'availability_id' => $availability_id ? (int) $availability_id : null,
933 'coupon_code' => $coupon_code,
934 'payment_method' => $payment_method,
935 ]);
936
937 // Extract pricing results
938 $total_amount = $pricing['final_total'];
939 $amount_due = $pricing['amount_due'];
940 $amount_paid = $pricing['amount_paid'];
941 $tax_calculation = $pricing['tax_calculation'];
942 $tax_breakdown = $tax_calculation['tax_breakdown'];
943 $total_tax_amount = $tax_calculation['total_tax_amount'];
944
945 $tax_inclusive = $tax_calculation['tax_inclusive'];
946 $tax_rate = $tax_calculation['tax_rate'];
947 $subtotal_before_discount = $pricing['subtotal'];
948 $discount_amount = $pricing['total_discount_amount'];
949 $discount_code = $pricing['group_discount']['code'] ?? ($pricing['coupon_discount']['code'] ?? null);
950
951 // ========================================
952 // CAPACITY / WAITLIST
953 // ========================================
954 $resolvedAvailabilityForWaitlist = null;
955 if ($travel_date !== '') {
956 try {
957 $availResolutionSvc = new \Yatra\Services\AvailabilityResolutionService();
958 $resolvedAvailabilityForWaitlist = $availResolutionSvc->resolveAvailabilityForDate(
959 $trip_id,
960 $travel_date,
961 $departure_time !== '' ? $departure_time : null
962 );
963 } catch (\Throwable $e) {
964 // Leave null; legacy checkout without strict availability still works
965 }
966 }
967
968 $isWaitlistCheckout = false;
969
970 if ($resolvedAvailabilityForWaitlist !== null) {
971 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
972 if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
973 return new WP_REST_Response([
974 'success' => false,
975 'message' => __('This departure is not open for booking.', 'yatra'),
976 'code' => 'date_blocked',
977 ], 400);
978 }
979 // Respect explicit sold_out even if seats_available is stale — waitlist only if allowed
980 if ($availStatus === 'sold_out') {
981 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
982 $isWaitlistCheckout = true;
983 } else {
984 return new WP_REST_Response([
985 'success' => false,
986 'message' => __('This departure is sold out.', 'yatra'),
987 'code' => 'sold_out',
988 ], 400);
989 }
990 }
991 }
992
993 if (
994 !$isWaitlistCheckout
995 && \Yatra\Services\WaitlistService::isInsufficientSeats($resolvedAvailabilityForWaitlist, $travelers_count)
996 ) {
997 if (\Yatra\Services\WaitlistService::canJoinWaitlist($trip, $resolvedAvailabilityForWaitlist, $travelers_count)) {
998 $isWaitlistCheckout = true;
999 } else {
1000 return new WP_REST_Response([
1001 'success' => false,
1002 'message' => __('This departure is full. Waitlist is not available for this trip or date.', 'yatra'),
1003 'code' => 'sold_out',
1004 ], 400);
1005 }
1006 }
1007
1008 // Generate booking reference using the same method as BookingService
1009 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1010 $booking_reference = $bookingRepository->generateReference();
1011
1012 Logger::debug('Yatra booking create: pricing and payment selection', [
1013 'context' => 'booking_create_rest',
1014 'trip_id' => $trip_id,
1015 'booking_reference' => $booking_reference,
1016 'flexible_payments_enabled' => (bool) apply_filters('yatra_flexible_payments_enabled', false),
1017 'payment_method' => $payment_method,
1018 'payment_gateway' => $payment_gateway,
1019 'total_amount' => round((float) $total_amount, 4),
1020 'amount_due' => round((float) $amount_due, 4),
1021 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20),
1022 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30),
1023 ]);
1024
1025 // Prepare contact data
1026 $contact_data = [
1027 'first_name' => sanitize_text_field($contact_first_name),
1028 'last_name' => sanitize_text_field($contact_last_name),
1029 'email' => sanitize_email($contact_email),
1030 'phone' => sanitize_text_field($contact_phone),
1031 'country' => sanitize_text_field($contact_country),
1032 'nationality' => sanitize_text_field($contact_nationality),
1033 'address' => sanitize_text_field($contact_address),
1034 ];
1035
1036 // Prepare emergency contact data
1037 $emergency_data = [
1038 'name' => sanitize_text_field($emergency_name),
1039 'phone' => sanitize_text_field($emergency_phone),
1040 'relationship' => sanitize_text_field($emergency_relationship),
1041 ];
1042
1043 // Sanitize travelers data
1044 $sanitized_travelers = [];
1045 foreach ($travelers as $traveler) {
1046 if (is_array($traveler)) {
1047 $sanitized_traveler = [];
1048 foreach ($traveler as $key => $value) {
1049 $sk = sanitize_key((string) $key);
1050 if (is_array($value)) {
1051 $sanitized_traveler[$sk] = array_map(static function ($v) {
1052 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1053 }, $value);
1054 } else {
1055 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1056 }
1057 }
1058 $sanitized_travelers[] = $sanitized_traveler;
1059 }
1060 }
1061
1062 // ========================================
1063 // CREATE WORDPRESS USER ACCOUNT (if requested)
1064 // ========================================
1065 $user_id = get_current_user_id();
1066 $create_account = !empty($data['create_account']) && !empty($data['account_password']);
1067
1068 if (!$user_id && $create_account && !empty($data['account_password'])) {
1069 $account_password = sanitize_text_field($data['account_password']);
1070 $account_password_confirm = sanitize_text_field($data['account_password_confirm'] ?? '');
1071
1072 // Validate password
1073 if (strlen($account_password) < 8) {
1074 return new WP_REST_Response([
1075 'success' => false,
1076 'message' => __('Password must be at least 8 characters long.', 'yatra'),
1077 ], 400);
1078 }
1079
1080 if ($account_password !== $account_password_confirm) {
1081 return new WP_REST_Response([
1082 'success' => false,
1083 'message' => __('Passwords do not match.', 'yatra'),
1084 ], 400);
1085 }
1086
1087 // Check if user already exists
1088 if (email_exists($contact_email)) {
1089 return new WP_REST_Response([
1090 'success' => false,
1091 'message' => __('An account with this email already exists. Please log in.', 'yatra'),
1092 ], 400);
1093 }
1094
1095 // Create WordPress user
1096 $username = sanitize_user(current(explode('@', $contact_email)));
1097 $original_username = $username;
1098 $counter = 1;
1099
1100 while (username_exists($username)) {
1101 $username = $original_username . $counter;
1102 $counter++;
1103 }
1104
1105 $user_id = wp_create_user($username, $account_password, $contact_email);
1106
1107 if (is_wp_error($user_id)) {
1108 return new WP_REST_Response([
1109 'success' => false,
1110 'message' => wp_strip_all_tags($user_id->get_error_message()),
1111 ], 400);
1112 }
1113
1114 // Assign Yatra Customer role
1115 $user = new \WP_User($user_id);
1116 $user->set_role('yatra_customer');
1117
1118 // Update user meta with contact information
1119 wp_update_user([
1120 'ID' => $user_id,
1121 'first_name' => $contact_data['first_name'],
1122 'last_name' => $contact_data['last_name'],
1123 'display_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
1124 ]);
1125
1126 if (!empty($contact_phone)) {
1127 update_user_meta($user_id, 'billing_phone', $contact_phone);
1128 update_user_meta($user_id, 'phone', $contact_phone);
1129 }
1130
1131 if (!empty($contact_country)) {
1132 update_user_meta($user_id, 'billing_country', $contact_country);
1133 }
1134
1135 if (!empty($contact_address)) {
1136 update_user_meta($user_id, 'billing_address_1', $contact_address);
1137 }
1138
1139 // Auto-login the user
1140 wp_set_current_user($user_id);
1141 wp_set_auth_cookie($user_id);
1142 }
1143
1144 // ========================================
1145 // CREATE OR UPDATE CUSTOMER
1146 // ========================================
1147 // Customers are separate from WordPress users - this is for CRM purposes
1148 $customer_id = null;
1149 try {
1150 $customer_id = $this->customerRepository->findOrCreate([
1151 'user_id' => $user_id ?: null,
1152 'first_name' => $contact_data['first_name'],
1153 'last_name' => $contact_data['last_name'],
1154 'email' => $contact_data['email'],
1155 'phone' => $contact_data['phone'],
1156 'address' => $contact_data['address'],
1157 'country' => $contact_data['country'],
1158 'nationality' => $contact_data['nationality'],
1159 'emergency_name' => $emergency_data['name'],
1160 'emergency_phone' => $emergency_data['phone'],
1161 'emergency_relationship' => $emergency_data['relationship'],
1162 'newsletter_optin' => !empty($data['subscribe_newsletter']),
1163 'total_spent' => $total_amount,
1164 'source' => 'booking',
1165 ]);
1166 } catch (\Exception $e) {
1167 // Log error but continue - customer creation is not critical
1168 }
1169
1170 // Create booking using BookingService
1171 $booking_service = new \Yatra\Services\BookingService();
1172
1173 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1174
1175 // Ensure DB availability row is linked so inventory sync can subtract seats after booking.
1176 if (!$isWaitlistCheckout && empty($availability_id) && $trip_id > 0 && $travel_date !== '') {
1177 try {
1178 $dtForResolve = is_string($departure_time) ? trim($departure_time) : '';
1179 $dtForResolve = $dtForResolve !== '' ? $dtForResolve : null;
1180 $resolvedCheckout = (new \Yatra\Services\AvailabilityResolutionService())->resolveAvailabilityForDate(
1181 $trip_id,
1182 sanitize_text_field($travel_date),
1183 $dtForResolve
1184 );
1185 if (is_object($resolvedCheckout)
1186 && ($resolvedCheckout->source ?? '') === 'availability_date'
1187 && isset($resolvedCheckout->id)
1188 && is_numeric($resolvedCheckout->id)
1189 && (int) $resolvedCheckout->id > 0) {
1190 $availability_id = (int) $resolvedCheckout->id;
1191 }
1192 } catch (\Throwable $e) {
1193 // Recurring / trip-default checkout: no single availability_dates row
1194 }
1195 }
1196
1197 // Always defer createBooking's copy: session sends one rich confirmation at the end, or
1198 // transactional confirmation before payment redirect (see processPaymentGateway returns).
1199 $booking_data = [
1200 'reference' => $booking_reference,
1201 'trip_id' => $trip_id,
1202 'customer_id' => $customer_id,
1203 'user_id' => $user_id ?: null,
1204 'contact_first_name' => $contact_data['first_name'],
1205 'contact_last_name' => $contact_data['last_name'],
1206 'contact_email' => $contact_data['email'],
1207 'contact_phone' => $contact_data['phone'],
1208 'contact_country' => $contact_data['country'],
1209 'contact_data' => wp_json_encode($contact_data),
1210 'emergency_contact' => wp_json_encode($emergency_data),
1211 'travel_date' => sanitize_text_field($travel_date),
1212 'availability_id' => !empty($availability_id) ? (int) $availability_id : null,
1213 'departure_time' => is_string($departure_time) ? trim($departure_time) : '',
1214 'travelers_count' => $travelers_count,
1215 'total_amount' => $total_amount,
1216 'amount_paid' => 0,
1217 'amount_due' => $amount_due,
1218 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1219 'discount_amount' => $discount_amount,
1220 'discount_code' => $discount_code,
1221 'payment_method' => $payment_method,
1222 'payment_gateway' => $payment_gateway,
1223 'status' => 'pending',
1224 'special_requests' => sanitize_textarea_field($data['special_requests'] ?? ''),
1225 'newsletter_optin' => !empty($data['subscribe_newsletter']) ? 1 : 0,
1226 'ip_address' => $this->getClientIp(),
1227 'created_at' => current_time('mysql'),
1228 'updated_at' => current_time('mysql'),
1229 // Tax fields
1230 'subtotal' => $subtotal_before_discount,
1231 'tax_amount' => $total_tax_amount,
1232 'tax_rate' => $tax_rate,
1233 'tax_inclusive' => $tax_inclusive,
1234 'tax_details' => wp_json_encode($tax_breakdown),
1235 // Itinerary costs
1236 'itinerary_costs' => wp_json_encode($pricing['itinerary_costs'] ?? []),
1237 'itinerary_costs_total' => ($pricing['itinerary_costs_total'] ?? 0),
1238 'skip_initial_customer_confirmation' => true,
1239 ];
1240
1241 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1242 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1243 $booking_data['status'] = 'waitlist';
1244 $booking_data['payment_gateway'] = 'pay_later';
1245 $booking_data['payment_method'] = 'full';
1246 }
1247
1248 try {
1249 $booking = $booking_service->createBooking($booking_data);
1250 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1251 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
1252 if (empty($booking['success'])) {
1253 return new WP_REST_Response([
1254 'success' => false,
1255 'message' => $booking['message'] ?? __('Failed to create booking. Please try again.', 'yatra'),
1256 'error' => $booking['message'] ?? '',
1257 'errors' => $booking['errors'] ?? null,
1258 ], 500);
1259 }
1260 } catch (\Exception $e) {
1261 return new WP_REST_Response([
1262 'success' => false,
1263 'message' => __('Failed to create booking. Please try again.', 'yatra'),
1264 'error' => $e->getMessage(),
1265 ], 500);
1266 }
1267
1268 if (empty($booking_id)) {
1269 return new WP_REST_Response([
1270 'success' => false,
1271 'message' => __('Failed to create booking. Please try again.', 'yatra'),
1272 'error' => __('Booking ID was not generated.', 'yatra'),
1273 ], 500);
1274 }
1275
1276 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1277 \Yatra\Services\WaitlistService::incrementAvailabilityWaitlistCount(
1278 (int) $resolvedAvailabilityForWaitlist->id,
1279 $travelers_count
1280 );
1281 }
1282
1283 // Get the actual booking reference from database
1284 $bookingRepository = new \Yatra\Repositories\BookingRepository();
1285 $saved_booking = $bookingRepository->find($booking_id);
1286 if ($saved_booking && !empty($saved_booking->reference)) {
1287 $booking_reference = $saved_booking->reference;
1288 }
1289
1290 // ========================================
1291 // SAVE ADDITIONAL SERVICES (Premium Feature)
1292 // ========================================
1293 /**
1294 * Action: Save additional services for the booking
1295 * Allows premium modules to save selected services with the booking
1296 *
1297 * @param int $booking_id The booking ID
1298 * @param int $trip_id The trip ID
1299 * @param array $data The booking request data (contains selected_services)
1300 * @param int $travelers_count Total number of travelers
1301 * @param int $duration_days Trip duration in days
1302 * @since 3.0.0
1303 */
1304 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1305 if (!isset($data['selected_services'])) {
1306 $data['selected_services'] = $data['additional_services']
1307 ?? $session['additional_services']
1308 ?? [];
1309 }
1310 if (!is_array($data['selected_services'])) {
1311 $data['selected_services'] = [];
1312 }
1313 $data['selected_services'] = array_map('intval', $data['selected_services']);
1314 do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
1315
1316 // ========================================
1317 // SAVE TRAVELLERS TO NORMALIZED TABLES
1318 // ========================================
1319 // Each traveller is saved to yatra_booking_travellers table
1320 // Their dynamic fields are saved to yatra_booking_traveller_meta table
1321 foreach ($sanitized_travelers as $index => $traveler_fields) {
1322 // First traveller (index 0) is always the lead traveller
1323 $is_lead = ($index === 0);
1324
1325 // Create traveller record with all their fields stored in meta
1326 $this->travellerRepository->create(
1327 $booking_id,
1328 $index,
1329 $is_lead,
1330 $traveler_fields
1331 );
1332 }
1333
1334 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1335 yatra_clear_booking_session();
1336 if ($settings['booking_confirmation']) {
1337 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1338 }
1339
1340 return new WP_REST_Response([
1341 'success' => true,
1342 'message' => __('You are on the waitlist. We will contact you if a space opens up.', 'yatra'),
1343 'data' => [
1344 'booking_id' => $booking_id,
1345 'reference' => $booking_reference,
1346 'status' => 'waitlist',
1347 'waitlist' => true,
1348 'redirect_url' => $this->getConfirmationUrl($booking_reference),
1349 'customer_email' => $contact_data['email'],
1350 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
1351 'trip_id' => $trip_id,
1352 'trip_date' => $travel_date,
1353 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1354 'total_amount' => $total_amount,
1355 'amount_due' => $amount_due,
1356 ],
1357 ]);
1358 }
1359
1360 // Departure link + booked_count: handled inside BookingService::createBooking (and inventory sync hooks).
1361 // Persist travel window on the booking row for reporting (optional columns).
1362 if (!empty($travel_date)) {
1363 try {
1364 $start_date = $travel_date;
1365 $duration_days = !empty($trip->duration_days) ? (int) $trip->duration_days : 1;
1366 $end_date = date('Y-m-d', strtotime($start_date . ' + ' . ($duration_days - 1) . ' days'));
1367 $bookingColumns = $this->bookingRepository->getTableColumns();
1368 $bookingUpdateData = [];
1369 if (in_array('start_date', $bookingColumns, true)) {
1370 $bookingUpdateData['start_date'] = $start_date;
1371 }
1372 if (in_array('end_date', $bookingColumns, true)) {
1373 $bookingUpdateData['end_date'] = $end_date;
1374 }
1375 if ($bookingUpdateData !== []) {
1376 $this->bookingRepository->update($booking_id, $bookingUpdateData);
1377 }
1378 } catch (\Exception $e) {
1379 // Non-fatal
1380 }
1381 }
1382
1383 // Clear booking session
1384 yatra_clear_booking_session();
1385
1386 // Check if this is an offline gateway
1387 $is_offline = $is_offline_gateway;
1388
1389 // For online gateways, create payment intent and return redirect URL
1390 if (!$is_offline && $amount_due > 0) {
1391 // Build payment params - merge with request data so gateways can access their own tokens
1392 $payment_params = array_merge($data, [
1393 'booking_id' => $booking_id,
1394 'reference' => $booking_reference,
1395 'amount' => $amount_due,
1396 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1397 'customer_email' => $contact_data['email'],
1398 'customer_name' => $contact_data['first_name'] . ' ' . $contact_data['last_name'],
1399 'trip_title' => $trip->title,
1400 ]);
1401
1402 // Process payment based on gateway
1403 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
1404
1405 if ($payment_result['success']) {
1406 // Handle redirect-based gateways (PayPal, eSewa, Khalti, etc.)
1407 if (!empty($payment_result['payment_url'])) {
1408 if ($settings['booking_confirmation']) {
1409 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1410 }
1411 return new WP_REST_Response([
1412 'success' => true,
1413 'message' => __('Booking created. Redirecting to payment...', 'yatra'),
1414 'data' => [
1415 'booking_id' => $booking_id,
1416 'reference' => $booking_reference,
1417 'payment_url' => $payment_result['payment_url'],
1418 ],
1419 ]);
1420 }
1421
1422 // Handle client-side payment gateways (Stripe, Razorpay, Square, etc.)
1423 if (!empty($payment_result['requires_action'])) {
1424 if ($settings['booking_confirmation']) {
1425 $booking_service->sendNewBookingTransactionalConfirmation((int) $booking_id);
1426 }
1427 return new WP_REST_Response([
1428 'success' => true,
1429 'message' => __('Booking created. Complete payment...', 'yatra'),
1430 'data' => array_merge([
1431 'booking_id' => $booking_id,
1432 'reference' => $booking_reference,
1433 ], $payment_result),
1434 ]);
1435 }
1436 }
1437
1438 // If payment processing failed, return error so user can fix the issue
1439 if (!$payment_result['success']) {
1440 $errorMessage = $payment_result['error'] ?? $payment_result['message'] ?? __('Payment processing failed. Please try again.', 'yatra');
1441 return new WP_REST_Response([
1442 'success' => false,
1443 'message' => $errorMessage,
1444 'data' => [
1445 'booking_id' => $booking_id,
1446 'reference' => $booking_reference,
1447 'payment_error' => true,
1448 ],
1449 ]);
1450 }
1451 }
1452
1453 // ========================================
1454 // DETERMINE BOOKING STATUS
1455 // ========================================
1456 // Priority:
1457 // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
1458 // 2. For pay_later: auto_confirm_pay_later setting
1459 // 3. For bank_transfer: always pending until verified
1460
1461 $booking_status = 'pending';
1462 $status_message = __('Booking received!', 'yatra');
1463
1464 // Check if auto-confirm all bookings is enabled
1465 if ($settings['auto_confirm_bookings']) {
1466 // Auto-confirm is enabled - confirm immediately regardless of payment
1467 $booking_status = 'confirmed';
1468 $status_message = __('Booking confirmed!', 'yatra');
1469 } elseif ($payment_gateway === 'pay_later') {
1470 // Pay Later: Check the specific pay_later auto-confirm setting
1471 if ($settings['auto_confirm_pay_later']) {
1472 $booking_status = 'confirmed';
1473 $status_message = __('Booking confirmed! Payment will be collected later.', 'yatra');
1474 } else {
1475 $booking_status = 'pending';
1476 $status_message = __('Booking received! We will contact you to arrange payment.', 'yatra');
1477 }
1478 } elseif ($payment_gateway === 'bank_transfer') {
1479 // Bank Transfer: Always pending until payment is verified by admin
1480 $booking_status = 'pending';
1481 $status_message = __('Booking received! Please complete the bank transfer. We will confirm once payment is verified.', 'yatra');
1482 }
1483
1484 // Calculate booking expiry time for pending bookings
1485 $expiry_datetime = null;
1486 if ($booking_status === 'pending' && $settings['booking_expiry_hours'] > 0) {
1487 $expiry_datetime = date('Y-m-d H:i:s', strtotime('+' . $settings['booking_expiry_hours'] . ' hours'));
1488 }
1489
1490 // Set confirmed_at if auto-confirmed
1491 $confirmed_at = ($booking_status === 'confirmed') ? current_time('mysql') : null;
1492
1493 // Update booking status with additional metadata
1494 $update_data = [
1495 'status' => $booking_status,
1496 'payment_status' => 'pending', // No payment made yet for offline gateways
1497 ];
1498
1499 if ($confirmed_at) {
1500 $update_data['confirmed_at'] = $confirmed_at;
1501 }
1502
1503 if ($expiry_datetime) {
1504 $update_data['expires_at'] = $expiry_datetime;
1505 }
1506
1507 // Use repository to update booking
1508 $this->bookingRepository->update($booking_id, $update_data);
1509
1510 /**
1511 * yatra_booking_created already fired from BookingService::createBooking — do not fire again here
1512 * (duplicate admin + Pro automation).
1513 *
1514 * Synthetic pending→confirmed on the same request duplicates Pro "booking.confirmed" sequences with the
1515 * checkout confirmation email. Skip by default; restore with:
1516 * add_filter('yatra_skip_checkout_autoconfirm_status_changed_event', '__return_false');
1517 */
1518 if ($booking_status === 'confirmed') {
1519 if (!apply_filters('yatra_skip_checkout_autoconfirm_status_changed_event', true, (int) $booking_id, 'pending', 'confirmed')) {
1520 do_action('yatra_booking_status_changed', (int) $booking_id, 'pending', 'confirmed');
1521 }
1522 }
1523
1524 // ========================================
1525 // SEND CONFIRMATION EMAIL
1526 // ========================================
1527 if ($settings['booking_confirmation']) {
1528 $this->sendBookingConfirmationEmail($booking_id, $booking_reference, $trip, [
1529 'contact' => $contact_data,
1530 'emergency' => $emergency_data,
1531 'travelers' => $sanitized_travelers,
1532 'travel_date' => $travel_date,
1533 'payment_method' => $payment_method,
1534 'payment_gateway' => $payment_gateway,
1535 'total_amount' => $total_amount,
1536 'amount_due' => $amount_due,
1537 'booking_status' => $booking_status,
1538 'cancellation_policy' => $settings['cancellation_policy'],
1539 'cancellation_days' => $settings['cancellation_days'],
1540 'expiry_datetime' => $expiry_datetime,
1541 ]);
1542 }
1543
1544 return new WP_REST_Response([
1545 'success' => true,
1546 'message' => $status_message,
1547 'data' => [
1548 'booking_id' => $booking_id,
1549 'reference' => $booking_reference,
1550 'status' => $booking_status,
1551 'payment_status' => 'pending',
1552 'redirect_url' => $this->getConfirmationUrl($booking_reference),
1553 'customer_email' => $contact_data['email'],
1554 'customer_name' => trim($contact_data['first_name'] . ' ' . $contact_data['last_name']),
1555 'trip_id' => $trip_id,
1556 'trip_date' => $travel_date,
1557 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
1558 'amount' => $amount_due,
1559 'subtotal' => $subtotal_before_discount,
1560 'discount_amount' => $discount_amount,
1561 'discount_code' => $discount_code,
1562 'total_amount' => $total_amount,
1563 ],
1564 ]);
1565 }
1566
1567
1568 /**
1569 * Get confirmation page URL (see yatra_get_booking_confirmation_url()).
1570 */
1571 private function getConfirmationUrl(string $reference): string
1572 {
1573 return yatra_get_booking_confirmation_url($reference);
1574 }
1575
1576 /**
1577 * Whether the gateway completes without an external payment step (registry flag + fallback).
1578 */
1579 private function isOfflineGateway(string $gatewayId): bool
1580 {
1581 try {
1582 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
1583 $gateway = $registry->get($gatewayId);
1584 if ($gateway) {
1585 return $gateway->isOffline();
1586 }
1587 } catch (\Throwable $e) {
1588 // Fall through to legacy IDs
1589 }
1590
1591 return in_array($gatewayId, ['pay_later', 'bank_transfer'], true);
1592 }
1593
1594 /**
1595 * Pay balance due on an existing booking only.
1596 *
1597 * Does not call BookingService::createBooking() or insert a second booking row.
1598 * Initiates gateway flow with the stored booking_id; on success, payment is recorded
1599 * via PaymentGatewayController::handle_successful_payment, webhooks, or recordGatewayPayment
1600 * — same completion paths as initial checkout.
1601 */
1602 private function process_remaining_payment(WP_REST_Request $request): WP_REST_Response
1603 {
1604 $data = $request->get_json_params();
1605 if (!is_array($data)) {
1606 $data = [];
1607 }
1608 $payment_gateway = strtolower(trim(sanitize_text_field($data['payment_gateway'] ?? 'pay_later')));
1609
1610 $session = yatra_get_remaining_session();
1611
1612 $booking_id = (int) ($session['booking_id'] ?? 0);
1613 $booking_reference = (string) ($session['booking_reference'] ?? '');
1614 $currency = (string) ($session['currency'] ?? '');
1615 $contact_email = (string) ($session['contact_email'] ?? '');
1616 $contact_first_name = (string) ($session['contact_first_name'] ?? '');
1617 $contact_last_name = (string) ($session['contact_last_name'] ?? '');
1618 $trip_id = (int) ($session['trip_id'] ?? 0);
1619 $trip_title = (string) ($session['trip_title'] ?? '');
1620 $travel_date = (string) ($session['travel_date'] ?? '');
1621
1622 if ($booking_id <= 0) {
1623 return new WP_REST_Response([
1624 'success' => false,
1625 'message' => __('Invalid booking for remaining payment.', 'yatra'),
1626 ], 400);
1627 }
1628
1629 $booking = $this->bookingRepository->find($booking_id);
1630 if (!$booking) {
1631 yatra_clear_remaining_session();
1632 return new WP_REST_Response([
1633 'success' => false,
1634 'message' => __('Booking not found.', 'yatra'),
1635 ], 404);
1636 }
1637
1638 if ($booking_reference === '' && !empty($booking->reference)) {
1639 $booking_reference = (string) $booking->reference;
1640 }
1641
1642 // Authoritative balance from DB (do not rely on session alone)
1643 $remaining_amount = (float) ($booking->amount_due ?? 0);
1644 if ($remaining_amount <= 0 && isset($booking->total_amount)) {
1645 $remaining_amount = max(
1646 0,
1647 (float) $booking->total_amount - (float) ($booking->amount_paid ?? 0)
1648 );
1649 }
1650
1651 if ($remaining_amount <= 0) {
1652 yatra_clear_remaining_session();
1653 return new WP_REST_Response([
1654 'success' => false,
1655 'message' => __('This booking is already fully paid.', 'yatra'),
1656 ], 400);
1657 }
1658
1659 // Verify user owns this booking
1660 $current_user = get_current_user_id();
1661 if ($current_user && (int) $booking->user_id !== $current_user) {
1662 yatra_clear_remaining_session();
1663 return new WP_REST_Response([
1664 'success' => false,
1665 'message' => __('You do not have permission to pay for this booking.', 'yatra'),
1666 ], 403);
1667 }
1668
1669 if ($currency === '') {
1670 $currency = (string) ($booking->currency ?? SettingsService::getCurrency());
1671 }
1672
1673 // Use contact info from session or booking
1674 $customer_email = $contact_email !== '' ? $contact_email : (string) ($booking->contact_email ?? $booking->customer_email ?? '');
1675 $customer_name = trim($contact_first_name . ' ' . $contact_last_name);
1676 if ($customer_name === '') {
1677 $customer_name = trim(($booking->contact_first_name ?? '') . ' ' . ($booking->contact_last_name ?? ''));
1678 }
1679
1680 if ($customer_email === '') {
1681 return new WP_REST_Response([
1682 'success' => false,
1683 'message' => __('Email address is required.', 'yatra'),
1684 ], 400);
1685 }
1686
1687 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1688
1689 // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.).
1690 // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely.
1691 if (!$is_offline_gateway && $remaining_amount > 0) {
1692 $payment_params = array_merge($data, [
1693 'booking_id' => $booking_id,
1694 'reference' => $booking_reference,
1695 'amount' => $remaining_amount,
1696 'currency' => $currency,
1697 'customer_email' => $customer_email,
1698 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email,
1699 'trip_title' => $trip_title,
1700 ]);
1701
1702 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
1703
1704 if (!empty($payment_result['success'])) {
1705 if (!empty($payment_result['payment_url'])) {
1706 return new WP_REST_Response([
1707 'success' => true,
1708 'message' => __('Redirecting to payment...', 'yatra'),
1709 'data' => [
1710 'booking_id' => $booking_id,
1711 'reference' => $booking_reference,
1712 'payment_url' => $payment_result['payment_url'],
1713 'is_remaining_payment' => true,
1714 ],
1715 ]);
1716 }
1717
1718 if (!empty($payment_result['requires_action'])) {
1719 return new WP_REST_Response([
1720 'success' => true,
1721 'message' => __('Complete payment...', 'yatra'),
1722 'data' => array_merge(
1723 [
1724 'booking_id' => $booking_id,
1725 'reference' => $booking_reference,
1726 'is_remaining_payment' => true,
1727 ],
1728 $payment_result
1729 ),
1730 ]);
1731 }
1732
1733 if (!empty($payment_result['redirect_url'])) {
1734 return new WP_REST_Response([
1735 'success' => true,
1736 'message' => __('Payment processed.', 'yatra'),
1737 'data' => [
1738 'booking_id' => $booking_id,
1739 'reference' => $booking_reference,
1740 'redirect_url' => $payment_result['redirect_url'],
1741 'is_remaining_payment' => true,
1742 ],
1743 ]);
1744 }
1745 }
1746
1747 $err = $payment_result['message'] ?? $payment_result['error'] ?? __('Payment processing failed. Please try again.', 'yatra');
1748
1749 return new WP_REST_Response([
1750 'success' => false,
1751 'message' => $err,
1752 'data' => [
1753 'payment_error' => true,
1754 'booking_id' => $booking_id,
1755 ],
1756 ], 400);
1757 }
1758
1759 // Offline gateways: no external redirect — confirmation page only
1760 yatra_clear_remaining_session();
1761
1762 return new WP_REST_Response([
1763 'success' => true,
1764 'message' => __('Continue to confirmation.', 'yatra'),
1765 'data' => [
1766 'booking_id' => $booking_id,
1767 'reference' => $booking_reference,
1768 'trip_id' => $trip_id,
1769 'trip_title' => $trip_title,
1770 'trip_date' => $travel_date,
1771 'currency' => $currency,
1772 'amount' => $remaining_amount,
1773 'customer_email' => $customer_email,
1774 'customer_name' => $customer_name,
1775 'redirect_url' => $this->getConfirmationUrl($booking_reference),
1776 'is_remaining_payment' => true,
1777 ],
1778 ]);
1779 }
1780
1781 /**
1782 * Process payment through the selected gateway
1783 */
1784 private function processPaymentGateway(string $gateway, array $params): array
1785 {
1786 // Debug logging
1787 if (defined('WP_DEBUG') && WP_DEBUG) {
1788 }
1789
1790 // All gateways use the unified gateway system
1791 return $this->processPaymentWithGateway($gateway, $params);
1792 }
1793
1794 /**
1795 * Process payment using the proper gateway system
1796 */
1797 private function processPaymentWithGateway(string $gatewayId, array $params): array
1798 {
1799 try {
1800 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
1801 $gateway = $registry->get($gatewayId);
1802
1803 if (!$gateway) {
1804 return ['success' => false, 'message' => "Payment gateway '{$gatewayId}' not found"];
1805 }
1806
1807 if (!$gateway->isEnabled()) {
1808 return [
1809 'success' => false,
1810 'message' => __('This payment method is not available.', 'yatra'),
1811 ];
1812 }
1813
1814 if (!$gateway->isProperlyConfigured()) {
1815 return [
1816 'success' => false,
1817 'message' => GatewayUserMessages::gatewayNotConfigured($gateway),
1818 ];
1819 }
1820
1821 // Prepare payment data - pass all params, gateways extract what they need.
1822 // Default return_url to the configured booking confirmation URL so redirect gateways
1823 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
1824 // may still append their own query args on top of this URL.
1825 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
1826 $paymentData = array_merge($params, [
1827 'description' => $params['trip_title'] ?? '',
1828 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
1829 'metadata' => [
1830 'booking_id' => $params['booking_id'],
1831 'reference' => $params['reference'] ?? ''
1832 ]
1833 ]);
1834 if ($ref !== '' && empty($paymentData['return_url'])) {
1835 $paymentData['return_url'] = $this->getConfirmationUrl($ref);
1836 }
1837
1838 // Process the payment through the gateway
1839 $result = $gateway->processPayment($paymentData);
1840
1841 // Debug logging
1842 if (defined('WP_DEBUG') && WP_DEBUG) {
1843 }
1844
1845 if ($result['success']) {
1846 // Save transaction ID for tracking
1847 if (!empty($result['transaction_id'])) {
1848 $this->bookingRepository->updatePaymentSessionId(
1849 (int) $params['booking_id'],
1850 $result['transaction_id']
1851 );
1852 }
1853
1854 // For gateways that require client-side action (Stripe, Razorpay, etc.)
1855 // Payment will be recorded after client completes the action
1856 if (!empty($result['requires_action'])) {
1857 return array_merge(['success' => true], $result);
1858 }
1859
1860 // For gateways that return a redirect URL for external payment (PayPal, eSewa, Khalti)
1861 // Payment will be recorded on callback/return
1862 if (!empty($result['redirect_url']) || !empty($result['payment_url'])) {
1863 // Check if this is a completed payment with redirect (like Square)
1864 // vs pending external payment (like PayPal)
1865 $isCompletedPayment = !empty($result['transaction_id']) &&
1866 (($result['status'] ?? '') === 'completed' || ($result['status'] ?? '') === 'succeeded');
1867
1868 if ($isCompletedPayment) {
1869 $this->recordGatewayPayment($params, $result, $gatewayId);
1870 }
1871
1872 return [
1873 'success' => true,
1874 'payment_url' => $result['redirect_url'] ?? $result['payment_url']
1875 ];
1876 }
1877
1878 // For offline gateways or successful direct payments without redirect
1879 return [
1880 'success' => true,
1881 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
1882 ];
1883 }
1884
1885 // Log the payment failure for debugging
1886 if (defined('WP_DEBUG') && WP_DEBUG) {
1887 }
1888
1889 return [
1890 'success' => false,
1891 'message' => $result['message'] ?? $result['error'] ?? 'Payment processing failed. Please try again.'
1892 ];
1893
1894 } catch (\Exception $e) {
1895 // Log the exception for debugging
1896 return [
1897 'success' => false,
1898 'message' => 'An unexpected error occurred. Please try again or contact support.'
1899 ];
1900 }
1901 }
1902
1903 /**
1904 * Record payment from gateway result
1905 * Matches Stripe's completePayment behavior
1906 */
1907 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
1908 {
1909 global $wpdb;
1910
1911 try {
1912 $bookingId = (int) $params['booking_id'];
1913 $amount = (float) ($params['amount'] ?? 0);
1914 $currency = $params['currency'] ?? 'USD';
1915 $transactionId = $result['transaction_id'] ?? '';
1916
1917 // Get booking
1918 $booking = $this->bookingRepository->find($bookingId);
1919 if (!$booking || $booking->payment_status === 'paid') {
1920 return;
1921 }
1922
1923 // Record the payment using PaymentRepository
1924 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1925 $payment_id = $paymentRepository->create([
1926 'booking_id' => $bookingId,
1927 'amount' => $amount,
1928 'currency' => $currency,
1929 'gateway' => $gatewayId,
1930 'transaction_id' => $transactionId,
1931 'status' => 'completed',
1932 'created_at' => current_time('mysql'),
1933 ]);
1934
1935 // Calculate total paid
1936 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1937
1938 // Fire payment completed action
1939 do_action('yatra_payment_completed', [
1940 'booking_id' => $bookingId,
1941 'transaction_id' => $transactionId,
1942 'amount' => $amount,
1943 'currency' => $currency,
1944 'gateway' => $gatewayId,
1945 ]);
1946
1947 } catch (\Exception $e) {
1948 }
1949 }
1950
1951 /**
1952 * Process PayPal payment
1953 */
1954 private function processPayPalPayment(array $params, array $config, bool $is_test): array
1955 {
1956 $client_id = $config['client_id'] ?? '';
1957 $client_secret = $config['client_secret'] ?? '';
1958
1959 if (empty($client_id) || empty($client_secret)) {
1960 return ['success' => false, 'message' => 'PayPal credentials not configured'];
1961 }
1962
1963 $base_url = $is_test ? 'https://api-m.sandbox.paypal.com' : 'https://api-m.paypal.com';
1964
1965 try {
1966 // Get access token
1967 $auth_response = wp_remote_post($base_url . '/v1/oauth2/token', [
1968 'headers' => [
1969 'Authorization' => 'Basic ' . base64_encode($client_id . ':' . $client_secret),
1970 'Content-Type' => 'application/x-www-form-urlencoded',
1971 ],
1972 'body' => 'grant_type=client_credentials',
1973 ]);
1974
1975 if (is_wp_error($auth_response)) {
1976 return ['success' => false, 'message' => $auth_response->get_error_message()];
1977 }
1978
1979 $auth_body = json_decode(wp_remote_retrieve_body($auth_response), true);
1980 $access_token = $auth_body['access_token'] ?? '';
1981
1982 if (empty($access_token)) {
1983 return ['success' => false, 'message' => 'Failed to get PayPal access token'];
1984 }
1985
1986 // Create order
1987 $order_response = wp_remote_post($base_url . '/v2/checkout/orders', [
1988 'headers' => [
1989 'Authorization' => 'Bearer ' . $access_token,
1990 'Content-Type' => 'application/json',
1991 ],
1992 'body' => wp_json_encode([
1993 'intent' => 'CAPTURE',
1994 'purchase_units' => [[
1995 'reference_id' => $params['reference'],
1996 'amount' => [
1997 'currency_code' => $params['currency'],
1998 'value' => number_format($params['amount'], 2, '.', ''),
1999 ],
2000 'description' => $params['trip_title'],
2001 ]],
2002 'application_context' => [
2003 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2004 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
2005 ],
2006 ]),
2007 ]);
2008
2009 if (is_wp_error($order_response)) {
2010 return ['success' => false, 'message' => $order_response->get_error_message()];
2011 }
2012
2013 $order_body = json_decode(wp_remote_retrieve_body($order_response), true);
2014
2015 // Find approval link
2016 foreach ($order_body['links'] ?? [] as $link) {
2017 if ($link['rel'] === 'approve') {
2018 // Save order ID for capture later
2019 $this->bookingRepository->updatePaymentSessionId(
2020 (int) $params['booking_id'],
2021 $order_body['id'] ?? ''
2022 );
2023
2024 return ['success' => true, 'payment_url' => $link['href']];
2025 }
2026 }
2027
2028 return ['success' => false, 'message' => 'Failed to create PayPal order'];
2029 } catch (\Exception $e) {
2030 return ['success' => false, 'message' => $e->getMessage()];
2031 }
2032 }
2033
2034 /**
2035 * Process Razorpay payment
2036 */
2037 private function processRazorpayPayment(array $params, array $config, bool $is_test): array
2038 {
2039 $key_id = $config['api_key'] ?? '';
2040 $key_secret = $config['api_secret'] ?? '';
2041
2042 if (empty($key_id) || empty($key_secret)) {
2043 return ['success' => false, 'message' => 'Razorpay credentials not configured'];
2044 }
2045
2046 try {
2047 // Create Razorpay order
2048 $response = wp_remote_post('https://api.razorpay.com/v1/orders', [
2049 'headers' => [
2050 'Authorization' => 'Basic ' . base64_encode($key_id . ':' . $key_secret),
2051 'Content-Type' => 'application/json',
2052 ],
2053 'body' => wp_json_encode([
2054 'amount' => (int) ($params['amount'] * 100), // Amount in paise
2055 'currency' => $params['currency'],
2056 'receipt' => $params['reference'],
2057 'notes' => [
2058 'booking_id' => $params['booking_id'],
2059 'trip' => $params['trip_title'],
2060 ],
2061 ]),
2062 ]);
2063
2064 if (is_wp_error($response)) {
2065 return ['success' => false, 'message' => $response->get_error_message()];
2066 }
2067
2068 $body = json_decode(wp_remote_retrieve_body($response), true);
2069
2070 if (!empty($body['id'])) {
2071 // Save order ID
2072 $this->bookingRepository->updatePaymentSessionId(
2073 (int) $params['booking_id'],
2074 $body['id']
2075 );
2076
2077 // Razorpay requires client-side integration, return data for JS
2078 // Store order details and redirect to a payment page
2079 $payment_url = add_query_arg([
2080 'razorpay_order' => $body['id'],
2081 'booking_ref' => $params['reference'],
2082 'key' => $key_id,
2083 'amount' => (int) ($params['amount'] * 100),
2084 'currency' => $params['currency'],
2085 'name' => get_bloginfo('name'),
2086 'description' => $params['trip_title'],
2087 'email' => $params['customer_email'],
2088 ], home_url('/yatra-payment/razorpay/'));
2089
2090 return ['success' => true, 'payment_url' => $payment_url];
2091 }
2092
2093 return ['success' => false, 'message' => $body['error']['description'] ?? 'Failed to create Razorpay order'];
2094 } catch (\Exception $e) {
2095 return ['success' => false, 'message' => $e->getMessage()];
2096 }
2097 }
2098
2099 /**
2100 * Process eSewa payment
2101 */
2102 private function processEsewaPayment(array $params, array $config, bool $is_test): array
2103 {
2104 $merchant_id = $config['merchant_id'] ?? '';
2105
2106 if (empty($merchant_id)) {
2107 return ['success' => false, 'message' => 'eSewa merchant ID not configured'];
2108 }
2109
2110 $base_url = $is_test ? 'https://uat.esewa.com.np/epay/main' : 'https://esewa.com.np/epay/main';
2111
2112 // eSewa uses form redirect, build URL with parameters
2113 $payment_url = add_query_arg([
2114 'amt' => $params['amount'],
2115 'psc' => 0,
2116 'pdc' => 0,
2117 'txAmt' => 0,
2118 'tAmt' => $params['amount'],
2119 'pid' => $params['reference'],
2120 'scd' => $merchant_id,
2121 'su' => add_query_arg(
2122 ['payment' => 'success', 'gateway' => 'esewa'],
2123 $this->getConfirmationUrl($params['reference'])
2124 ),
2125 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
2126 ], $base_url);
2127
2128 return ['success' => true, 'payment_url' => $payment_url];
2129 }
2130
2131 /**
2132 * Process Khalti payment
2133 */
2134 private function processKhaltiPayment(array $params, array $config, bool $is_test): array
2135 {
2136 $secret_key = $config['api_secret'] ?? '';
2137
2138 if (empty($secret_key)) {
2139 return ['success' => false, 'message' => 'Khalti secret key not configured'];
2140 }
2141
2142 $base_url = $is_test ? 'https://a.khalti.com/api/v2/epayment/initiate/' : 'https://khalti.com/api/v2/epayment/initiate/';
2143
2144 try {
2145 $response = wp_remote_post($base_url, [
2146 'headers' => [
2147 'Authorization' => 'Key ' . $secret_key,
2148 'Content-Type' => 'application/json',
2149 ],
2150 'body' => wp_json_encode([
2151 'return_url' => add_query_arg(
2152 ['payment' => 'success', 'gateway' => 'khalti'],
2153 $this->getConfirmationUrl($params['reference'])
2154 ),
2155 'website_url' => home_url(),
2156 'amount' => (int) ($params['amount'] * 100), // Amount in paisa
2157 'purchase_order_id' => $params['reference'],
2158 'purchase_order_name' => $params['trip_title'],
2159 'customer_info' => [
2160 'name' => $params['customer_name'],
2161 'email' => $params['customer_email'],
2162 ],
2163 ]),
2164 ]);
2165
2166 if (is_wp_error($response)) {
2167 return ['success' => false, 'message' => $response->get_error_message()];
2168 }
2169
2170 $body = json_decode(wp_remote_retrieve_body($response), true);
2171
2172 if (!empty($body['payment_url'])) {
2173 // Save pidx for verification
2174 $this->bookingRepository->updatePaymentSessionId(
2175 (int) $params['booking_id'],
2176 $body['pidx'] ?? ''
2177 );
2178
2179 return ['success' => true, 'payment_url' => $body['payment_url']];
2180 }
2181
2182 return ['success' => false, 'message' => $body['detail'] ?? 'Failed to initiate Khalti payment'];
2183 } catch (\Exception $e) {
2184 return ['success' => false, 'message' => $e->getMessage()];
2185 }
2186 }
2187
2188 /**
2189 * Process Authorize.net payment
2190 */
2191 private function processAuthorizeNetPayment(array $params, array $config, bool $is_test): array
2192 {
2193 // Authorize.net typically requires hosted payment page or client-side integration
2194 // Return URL for hosted payment page setup
2195 return [
2196 'success' => true,
2197 'payment_url' => add_query_arg([
2198 'booking_ref' => $params['reference'],
2199 'amount' => $params['amount'],
2200 'gateway' => 'authorize_net',
2201 ], home_url('/yatra-payment/authorize-net/'))
2202 ];
2203 }
2204
2205 /**
2206 * Get client IP address
2207 */
2208 private function getClientIp(): string
2209 {
2210 $ip_keys = ['HTTP_CLIENT_IP', 'HTTP_X_FORWARDED_FOR', 'HTTP_X_FORWARDED', 'HTTP_FORWARDED_FOR', 'HTTP_FORWARDED', 'REMOTE_ADDR'];
2211
2212 foreach ($ip_keys as $key) {
2213 if (!empty($_SERVER[$key])) {
2214 $ip = sanitize_text_field($_SERVER[$key]);
2215 if (strpos($ip, ',') !== false) {
2216 $ip = trim(explode(',', $ip)[0]);
2217 }
2218 if (filter_var($ip, FILTER_VALIDATE_IP)) {
2219 return $ip;
2220 }
2221 }
2222 }
2223
2224 return '0.0.0.0';
2225 }
2226
2227 /**
2228 * Send booking confirmation email
2229 */
2230 private function sendBookingConfirmationEmail(int $booking_id, string $reference, object $trip, array $data): void
2231 {
2232 $contact = $data['contact'] ?? [];
2233 $travelers = $data['travelers'] ?? [];
2234 $travel_date = $data['travel_date'] ?? '';
2235 $total_amount = $data['total_amount'] ?? 0;
2236 $amount_due = $data['amount_due'] ?? 0;
2237 $payment_method = $data['payment_method'] ?? 'full';
2238 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2239 $booking_status = $data['booking_status'] ?? 'pending';
2240 $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund';
2241 $cancellation_days = $data['cancellation_days'] ?? 7;
2242 $expiry_datetime = $data['expiry_datetime'] ?? null;
2243
2244 $customer_email = $contact['email'] ?? '';
2245 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
2246
2247 if (empty($customer_email)) {
2248 return;
2249 }
2250
2251 // Format prices using global currency settings
2252 $formatted_total = yatra_format_price($total_amount);
2253 $formatted_due = yatra_format_price($amount_due);
2254
2255 $intro_paragraph = $booking_status === 'confirmed'
2256 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2257 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2258 if ($booking_status === 'pending' && $expiry_datetime) {
2259 $intro_paragraph .= ' ' . sprintf(
2260 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2261 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2262 );
2263 }
2264
2265 ob_start();
2266 ?>
2267 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2268 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2269 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2270 <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>
2271 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php echo esc_html(sprintf(__('%d days / %d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
2272 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2273 </div>
2274 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2275 <p><?php echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
2276 <?php if ($payment_method === 'deposit') : ?>
2277 <p><?php echo esc_html(sprintf(__('Payment type: Deposit — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
2278 <?php elseif ($payment_method === 'partial') : ?>
2279 <p><?php echo esc_html(sprintf(__('Payment type: Partial — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
2280 <?php else : ?>
2281 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2282 <?php endif; ?>
2283 <?php if ($payment_gateway === 'pay_later') : ?>
2284 <p><?php esc_html_e('Pay later — please contact us to arrange payment.', 'yatra'); ?></p>
2285 <?php elseif ($payment_gateway === 'bank_transfer') : ?>
2286 <p><?php esc_html_e('Bank transfer — you will receive bank details separately.', 'yatra'); ?></p>
2287 <?php endif; ?>
2288 <h3 style="font-size:16px;"><?php esc_html_e('Travelers', 'yatra'); ?></h3>
2289 <ul style="padding-left:20px;">
2290 <?php foreach ($travelers as $i => $traveler) : ?>
2291 <?php
2292 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2293 ?>
2294 <li><?php echo esc_html(sprintf(__('Traveler %d: %s', 'yatra'), $i + 1, $traveler_name ?: '')); ?></li>
2295 <?php endforeach; ?>
2296 </ul>
2297 <?php
2298 $cancellation_policy_labels = [
2299 'full_refund' => __('Full refund available', 'yatra'),
2300 'partial_refund' => __('Partial refund available', 'yatra'),
2301 'no_refund' => __('No refund available', 'yatra'),
2302 'flexible' => __('Flexible cancellation', 'yatra'),
2303 ];
2304 $policy_label = $cancellation_policy_labels[$cancellation_policy] ?? __('Standard policy applies', 'yatra');
2305 ?>
2306 <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
2307 <p><?php echo esc_html($policy_label); ?> <?php echo esc_html(sprintf(__('free cancellation up to %d days before departure', 'yatra'), (int) $cancellation_days)); ?></p>
2308 <?php
2309 $custom_refund_policy = SettingsService::getString('refund_policy', '');
2310 if ($custom_refund_policy !== '') {
2311 echo '<p>' . esc_html($custom_refund_policy) . '</p>';
2312 }
2313 ?>
2314 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2315 <ol style="padding-left:20px;">
2316 <li><?php esc_html_e('You will receive a detailed trip itinerary within 24–48 hours.', 'yatra'); ?></li>
2317 <li><?php esc_html_e('Our team will contact you to confirm any special requirements.', 'yatra'); ?></li>
2318 <li><?php esc_html_e('Please ensure travel documents meet entry requirements for your destination.', 'yatra'); ?></li>
2319 </ol>
2320 <p><?php esc_html_e('If you have any questions, contact us anytime.', 'yatra'); ?></p>
2321 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2322 <?php
2323 $details_html = ob_get_clean();
2324
2325 $vars = [
2326 'customer_name' => $customer_name,
2327 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2328 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2329 'customer_email' => $customer_email,
2330 'customer_phone' => (string) ($contact['phone'] ?? ''),
2331 'booking_reference' => $reference,
2332 'booking_id' => (string) $booking_id,
2333 'trip_name' => (string) $trip->title,
2334 'trip_url' => home_url('/' . SettingsService::getTripBase() . '/' . rawurlencode((string) ($trip->slug ?? '')) . '/'),
2335 'travel_date' => date_i18n(get_option('date_format'), strtotime($travel_date)),
2336 'travelers_count' => (string) count($travelers),
2337 'total_amount_formatted' => $formatted_total,
2338 'amount_due_formatted' => $formatted_due,
2339 'currency' => SettingsService::getCurrency(),
2340 'intro_paragraph' => $intro_paragraph,
2341 'details_html' => $details_html,
2342 'details_html_only' => '1',
2343 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2344 'transactional_context' => 'booking_created',
2345 ];
2346
2347 TransactionalEmailTemplateService::sendIfEnabled(
2348 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2349 $customer_email,
2350 $vars
2351 );
2352
2353 // Admin new-booking email is sent from NotificationService (yatra_booking_created) using
2354 // Email → Templates → Admin: New booking, to avoid duplicate messages.
2355 }
2356
2357 /**
2358 * Apply coupon code to booking session
2359 */
2360 public function apply_coupon(WP_REST_Request $request): WP_REST_Response
2361 {
2362 yatra_start_session();
2363
2364 $data = $request->get_json_params();
2365 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2366
2367 if (empty($code)) {
2368 return new WP_REST_Response([
2369 'success' => false,
2370 'message' => __('Please enter a coupon code.', 'yatra'),
2371 ], 400);
2372 }
2373
2374 // Get current session
2375 $session = yatra_get_booking_session();
2376 if (empty($session) || empty($session['trip_id'])) {
2377 return new WP_REST_Response([
2378 'success' => false,
2379 'message' => __('No active booking session found.', 'yatra'),
2380 ], 400);
2381 }
2382
2383 // Use DiscountService to calculate coupon discount
2384 $discountService = new \Yatra\Services\DiscountService();
2385 $total_amount = $this->calculateSessionTotal($session);
2386 $trip_id = (int) $session['trip_id'];
2387 $travelers_count = (int) ($session['travelers'] ?? 1);
2388 $traveler_counts = $session['traveler_counts'] ?? [];
2389
2390 $coupon_result = $discountService->calculateCouponDiscount(
2391 $code,
2392 $total_amount,
2393 $trip_id,
2394 $travelers_count,
2395 $traveler_counts
2396 );
2397
2398 // Check if discount was calculated (validation passed)
2399 if ($coupon_result['calculated_amount'] <= 0) {
2400 return new WP_REST_Response([
2401 'success' => false,
2402 'message' => __('This coupon is not valid for your booking.', 'yatra'),
2403 ], 400);
2404 }
2405
2406 $discount_amount = $coupon_result['calculated_amount'];
2407
2408 error_log('apply_coupon - Coupon result: ' . print_r($coupon_result, true));
2409 error_log('apply_coupon - Original code: ' . $code);
2410
2411 // Store coupon in session (use the original $code variable, not from result)
2412 $session['coupon'] = [
2413 'code' => $code, // Use the actual code that was validated
2414 'type' => $coupon_result['type'],
2415 'amount' => $coupon_result['amount'],
2416 'discount_amount' => $discount_amount,
2417 'label' => $coupon_result['label'],
2418 ];
2419 $session['timestamp'] = time();
2420
2421 yatra_set_booking_session($session);
2422
2423 error_log('apply_coupon - Session coupon stored: ' . print_r($session['coupon'], true));
2424
2425 return new WP_REST_Response([
2426 'success' => true,
2427 'message' => __('Coupon applied successfully!', 'yatra'),
2428 'data' => [
2429 'code' => $code,
2430 'type' => $coupon_result['type'],
2431 'discount_amount' => $discount_amount,
2432 'discount_formatted' => yatra_format_price($discount_amount),
2433 'new_total' => $total_amount - $discount_amount,
2434 'new_total_formatted' => yatra_format_price($total_amount - $discount_amount),
2435 ],
2436 ]);
2437 }
2438
2439 /**
2440 * Calculate booking summary and return HTML for dynamic updates
2441 * Called via AJAX when traveler count, date, or coupon changes
2442 */
2443 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
2444 {
2445 yatra_start_session();
2446 $session = yatra_get_booking_session();
2447 $data = $request->get_json_params();
2448
2449 error_log('=== calculate_summary CALLED ===');
2450 error_log('Request data: ' . print_r($data, true));
2451 error_log('Session traveler_counts: ' . print_r($session['traveler_counts'] ?? 'NOT SET', true));
2452 error_log('Session travelers: ' . ($session['travelers'] ?? 'NOT SET'));
2453
2454 // Get trip_id from session (required)
2455 $trip_id = (int) ($session['trip_id'] ?? 0);
2456
2457 // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session
2458 $traveler_counts = $data['traveler_counts'] ?? ($session['traveler_counts'] ?? []);
2459
2460 // Get other data from request or session
2461 $travel_date = sanitize_text_field($data['travel_date'] ?? ($session['travel_date'] ?? ''));
2462 $departure_time = sanitize_text_field($data['departure_time'] ?? ($session['departure_time'] ?? ''));
2463 $availability_id = $data['availability_id'] ?? ($session['availability_id'] ?? null);
2464 $pricing_type_from_request = sanitize_text_field($data['pricing_type'] ?? ($session['pricing_type'] ?? ''));
2465 $payment_method = strtolower(trim(sanitize_text_field($data['payment_method'] ?? ($session['payment_method'] ?? 'full'))));
2466 if ($payment_method === '') {
2467 $payment_method = 'full';
2468 }
2469 $selected_service_ids_from_request = $data['additional_services'] ?? ($session['additional_services'] ?? null);
2470
2471 // IMPORTANT: Always read coupon from SESSION (not request) to maintain applied discount
2472 $coupon_code = isset($session['coupon']['code']) ? sanitize_text_field($session['coupon']['code']) : '';
2473
2474 error_log('calculate_summary - Final traveler_counts: ' . print_r($traveler_counts, true));
2475 error_log('calculate_summary - Coupon from session: ' . $coupon_code);
2476
2477 if (empty($trip_id)) {
2478 return new WP_REST_Response([
2479 'success' => false,
2480 'message' => __('No active booking session found.', 'yatra'),
2481 ], 400);
2482 }
2483
2484 // Get trip data
2485 $trip = $this->tripRepository->findPublished($trip_id);
2486 if (!$trip) {
2487 return new WP_REST_Response([
2488 'success' => false,
2489 'message' => __('Trip not found.', 'yatra'),
2490 ], 404);
2491 }
2492
2493 // Ensure pricing fields are properly set
2494 $trip->original_price = (float) ($trip->original_price ?? 0);
2495 $trip->discounted_price = !empty($trip->discounted_price) ? (float) $trip->discounted_price : 0;
2496 $trip->sale_price = !empty($trip->sale_price) ? (float) $trip->sale_price : 0;
2497
2498 yatra_start_session();
2499 $existing_session = yatra_get_booking_session();
2500
2501 global $wpdb;
2502
2503 $availability = null;
2504 if (!empty($availability_id) && is_numeric($availability_id)) {
2505 // Only use getById if availability_id is numeric
2506 $availability = $this->availabilityService->getById((int) $availability_id);
2507 } elseif (!empty($travel_date)) {
2508 // For string IDs or when no availability_id, use date+time lookup for day tours
2509 $availability = $this->availabilityService->getByTripAndDateTime($trip_id, $travel_date, $departure_time ?: null);
2510 }
2511
2512 // Resolve pricing type and price_types via centralized TripPricingService
2513 $resolved_pricing_type = !empty($pricing_type_from_request)
2514 ? $pricing_type_from_request
2515 : \Yatra\Services\TripPricingService::resolvePricingType($trip);
2516
2517 $price_types = [];
2518
2519 // First priority: availability price_types (already includes trip fallback from AvailabilityResolutionService)
2520 if ($availability && !empty($availability->price_types)) {
2521 $avail_pts = is_string($availability->price_types)
2522 ? (json_decode($availability->price_types, true) ?: [])
2523 : $availability->price_types;
2524 if (!empty($avail_pts) && is_array($avail_pts)) {
2525 $price_types = array_map(function ($pt) { return (object) $pt; }, $avail_pts);
2526 }
2527 }
2528 // Second priority: trip's price_types via centralized normalizer
2529 if (empty($price_types)) {
2530 $normalized = \Yatra\Services\TripPricingService::resolvePriceTypes($trip);
2531 if (!empty($normalized)) {
2532 $price_types = array_map(function ($pt) { return (object) $pt; }, $normalized);
2533 }
2534 }
2535 // Auto-detect traveler_based if price_types are present
2536 if (!empty($price_types)) {
2537 $resolved_pricing_type = 'traveler_based';
2538 }
2539
2540 // Enrich availability price_types with category labels if missing
2541 if (!empty($price_types)) {
2542 $missing_label_category_ids = [];
2543 foreach ($price_types as $pt) {
2544 $pt = (object) $pt;
2545 if (empty($pt->category_label) && !empty($pt->category_id)) {
2546 $missing_label_category_ids[] = (int) $pt->category_id;
2547 }
2548 }
2549
2550 $missing_label_category_ids = array_values(array_unique(array_filter($missing_label_category_ids)));
2551 if (!empty($missing_label_category_ids)) {
2552 // Use AvailabilityService to get traveler categories
2553 $cats = $this->availabilityService->getTravelerCategories($missing_label_category_ids);
2554
2555 $catIndex = [];
2556 foreach ($cats as $cat) {
2557 $catIndex[(int) $cat->id] = $cat;
2558 }
2559
2560 foreach ($price_types as &$pt) {
2561 $pt = (object) $pt;
2562 $catId = !empty($pt->category_id) ? (int) $pt->category_id : null;
2563 if ($catId && isset($catIndex[$catId])) {
2564 $cat = $catIndex[$catId];
2565 if (empty($pt->category_label)) {
2566 $pt->category_label = $cat->label;
2567 }
2568 if (empty($pt->category_slug)) {
2569 $pt->category_slug = $cat->slug;
2570 }
2571 if (!isset($pt->age_min)) {
2572 $pt->age_min = $cat->age_min ? (int) $cat->age_min : null;
2573 }
2574 if (!isset($pt->age_max)) {
2575 $pt->age_max = $cat->age_max ? (int) $cat->age_max : null;
2576 }
2577 }
2578 }
2579 unset($pt);
2580 }
2581 }
2582
2583 foreach ($price_types as $pt) {
2584 $pt->effective_price = $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt);
2585 }
2586
2587 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
2588 $spots_remaining = $availability ? (int) ($availability->spots_remaining ?? null) : null;
2589
2590 foreach ($price_types as $pt) {
2591 if (!isset($pt->effective_price)) {
2592 continue;
2593 }
2594 $price_before = $pt->effective_price;
2595 $pt->effective_price = apply_filters('yatra_trip_display_price', (float) $pt->effective_price, $trip_id, [
2596 'departure_date' => $travel_date ?: null,
2597 'spots_remaining' => $spots_remaining,
2598 'availability_id' => $availability_id,
2599 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
2600 ]);
2601 }
2602 }
2603
2604 $is_traveler_based = $resolved_pricing_type === 'traveler_based' && !empty($price_types);
2605
2606 // Base trip price via centralized TripPricingService (single source of truth)
2607 $base_trip_price = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
2608
2609 // Override with availability pricing if available
2610 if ($availability) {
2611 $avail_price = !empty($availability->discounted_price) && (float) $availability->discounted_price > 0
2612 ? (float) $availability->discounted_price
2613 : (!empty($availability->original_price) && (float) $availability->original_price > 0
2614 ? (float) $availability->original_price : 0);
2615 if ($avail_price > 0) {
2616 $base_trip_price = $avail_price;
2617 }
2618 }
2619
2620 // Apply dynamic pricing filter (Pro DynamicPricingModule hooks here)
2621 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
2622 $base_trip_price = apply_filters('yatra_booking_trip_price', $base_trip_price, $trip_id, [
2623 'departure_date' => $travel_date,
2624 'spots_remaining' => $availability ? (int) ($availability->spots_remaining ?? null) : null,
2625 'availability_id' => $availability_id,
2626 ]);
2627 }
2628
2629 // Calculate subtotals per category
2630 $category_breakdown = [];
2631 $subtotal = 0;
2632 $total_travelers = 0;
2633 $normalized_traveler_counts = [];
2634 if (!empty($traveler_counts) && is_array($traveler_counts)) {
2635 foreach ($traveler_counts as $k => $v) {
2636 $key = is_numeric($k) ? (int) $k : (string) $k;
2637 $normalized_traveler_counts[$key] = (int) $v;
2638 }
2639 }
2640
2641 if ($is_traveler_based && !empty($normalized_traveler_counts)) {
2642 foreach ($price_types as $pt) {
2643 $category_id = $pt->category_id;
2644 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
2645 if ($count > 0) {
2646 $category_subtotal = (float) $pt->effective_price * $count;
2647 $category_breakdown[] = [
2648 'category_id' => $category_id,
2649 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2650 'count' => $count,
2651 'price' => (float) $pt->effective_price,
2652 'subtotal' => $category_subtotal,
2653 ];
2654 $subtotal += $category_subtotal;
2655 $total_travelers += $count;
2656 }
2657 }
2658 } else {
2659 // Regular pricing
2660 $total_travelers = array_sum(array_map('intval', $normalized_traveler_counts));
2661 // If traveler_counts is empty, fallback to session 'travelers' count
2662 if ($total_travelers < 1) {
2663 $total_travelers = !empty($session['travelers']) ? (int) $session['travelers'] : 1;
2664 }
2665 $price_per_person = $base_trip_price;
2666 $subtotal = $price_per_person * $total_travelers;
2667
2668 error_log('calculate_summary - Regular pricing: total_travelers=' . $total_travelers . ', subtotal=' . $subtotal);
2669 }
2670
2671 // Calculate group discount
2672 $discountService = new \Yatra\Services\DiscountService();
2673 $travelerCountsForDiscount = [];
2674 if ($is_traveler_based) {
2675 foreach ($normalized_traveler_counts as $k => $v) {
2676 if (is_numeric($k)) {
2677 $travelerCountsForDiscount[(int) $k] = (int) $v;
2678 }
2679 }
2680 }
2681 if (empty($travelerCountsForDiscount)) {
2682 $travelerCountsForDiscount['default'] = $total_travelers;
2683 }
2684
2685 $priceTypesForDiscount = [];
2686 if ($is_traveler_based) {
2687 foreach ($price_types as $pt) {
2688 $pt = (object) $pt;
2689 $priceTypesForDiscount[] = [
2690 'category_id' => $pt->category_id ?? null,
2691 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
2692 ];
2693 }
2694 } else {
2695 $priceTypesForDiscount[] = [
2696 'category_id' => 'default',
2697 'effective_price' => $base_trip_price,
2698 ];
2699 }
2700
2701 $group_discount = $discountService->calculateGroupDiscount($trip_id, $travelerCountsForDiscount, $priceTypesForDiscount);
2702 $group_discount_amount = $group_discount['amount'] ?? 0;
2703 $group_discount_label = $group_discount['label'] ?? __('Group Discount', 'yatra');
2704 $group_discount_code = $group_discount['code'] ?? null;
2705
2706 // Calculate coupon discount using DiscountService
2707 $coupon_discount_amount = 0;
2708 $coupon_discount_label = '';
2709 $coupon_error = '';
2710
2711 if (!empty($coupon_code)) {
2712 $discountService = new \Yatra\Services\DiscountService();
2713 $subtotal_after_group = $subtotal - $group_discount_amount;
2714
2715 $coupon_result = $discountService->calculateCouponDiscount(
2716 $coupon_code,
2717 $subtotal_after_group,
2718 $trip_id,
2719 $total_travelers,
2720 $is_traveler_based ? $normalized_traveler_counts : []
2721 );
2722
2723 if ($coupon_result['calculated_amount'] > 0) {
2724 $coupon_discount_amount = $coupon_result['calculated_amount'];
2725 $coupon_discount_label = $coupon_result['label'];
2726 } else {
2727 $coupon_error = __('This coupon is not valid for your booking.', 'yatra');
2728 }
2729 }
2730
2731 // Use CalculationService for on-demand pricing calculation
2732 $calculationService = new CalculationService();
2733
2734 // Initialize additional services (will be populated later via filter)
2735 $additional_services = [];
2736
2737 // Create session-like data structure for calculation (trip data fetched from database)
2738 $session_like_data = [
2739 'trip_id' => $trip_id,
2740 'travelers' => $total_travelers,
2741 'traveler_counts' => $traveler_counts,
2742 'travel_date' => $travel_date,
2743 'departure_time' => $departure_time,
2744 'additional_services' => $additional_services
2745 ];
2746
2747 // Apply filter for pro plugins to modify summary calculation parameters
2748 $calculation_params = apply_filters('yatra_summary_calculation_params', [
2749 'session_data' => $session_like_data,
2750 'coupon_code' => $coupon_code,
2751 'payment_method' => $payment_method,
2752 ]);
2753
2754 $pricing = $calculationService->calculateFromSession(
2755 $calculation_params['session_data'],
2756 $calculation_params['coupon_code'],
2757 $calculation_params['payment_method']
2758 );
2759
2760 $total_amount = $pricing['final_total'];
2761 $amount_due = $pricing['amount_due'];
2762 $tax_calculation = $pricing['tax_calculation'];
2763 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
2764 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
2765 $tax_breakdown = $pricing['tax_calculation']['tax_breakdown'];
2766
2767 /**
2768 * Filter: Get additional services for this trip
2769 * Allows premium modules to add extra services to the booking summary
2770 *
2771 * @param array $services Empty array by default
2772 * @param int $trip_id The trip ID
2773 * @param int $total_travelers Total number of travelers
2774 * @param array $traveler_counts Traveler counts by category
2775 * @param string $travel_date The travel date
2776 * @since 3.0.0
2777 */
2778 $additional_services = apply_filters('yatra_booking_additional_services', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
2779
2780 // Get selected services from request (priority) or session
2781 // If request has additional_services, use that; otherwise fall back to session
2782 if ($selected_service_ids_from_request !== null) {
2783 $selected_service_ids = $selected_service_ids_from_request;
2784 } else {
2785 $selected_service_ids = isset($existing_session['additional_services']) && is_array($existing_session['additional_services'])
2786 ? array_map('intval', $existing_session['additional_services'])
2787 : [];
2788 }
2789
2790 // Mark which services are selected and calculate their price based on price_per
2791 $duration_days = (int) ($trip->duration_days ?? 1);
2792 $default_services_total = 0.0;
2793 foreach ($additional_services as &$service) {
2794 $serviceId = (int) $service['id'];
2795 $isInRequest = in_array($serviceId, $selected_service_ids, true);
2796 $isRequired = !empty($service['is_required']);
2797 $isIncluded = !empty($service['is_included']);
2798 $service['selected'] = $isInRequest || $isRequired || $isIncluded;
2799
2800 // Calculate the price based on price_per (person, day, booking)
2801 $basePrice = (float) ($service['price'] ?? 0);
2802 $pricePer = $service['price_per'] ?? 'person';
2803
2804 switch ($pricePer) {
2805 case 'person':
2806 $service['calculated_price'] = $basePrice * $total_travelers;
2807 break;
2808 case 'day':
2809 $service['calculated_price'] = $basePrice * max(1, $duration_days);
2810 break;
2811 case 'booking':
2812 default:
2813 $service['calculated_price'] = $basePrice;
2814 break;
2815 }
2816
2817 if (!empty($service['selected']) && empty($service['is_included'])) {
2818 $default_services_total += (float) $service['calculated_price'];
2819 }
2820 }
2821 unset($service);
2822
2823 /**
2824 * Filter: Calculate additional services total
2825 * Allows premium modules to add services cost to the booking total
2826 *
2827 * @param float $services_total The services total (0 by default)
2828 * @param array $additional_services The services with 'selected' flag
2829 * @param int $trip_id The trip ID
2830 * @param int $total_travelers Total number of travelers
2831 * @param int $duration_days Trip duration in days
2832 * @since 3.0.0
2833 */
2834 $services_total = apply_filters('yatra_booking_services_total', (float) $default_services_total, $additional_services, $trip_id, $total_travelers, (int) ($trip->duration_days ?? 1));
2835
2836 // Get itinerary costs (separate from additional services)
2837 $itinerary_costs = apply_filters('yatra_booking_itinerary_costs', [], $trip_id, $total_travelers, $traveler_counts, $travel_date);
2838 $itinerary_costs_total = 0.0;
2839
2840 foreach ($itinerary_costs as $cost) {
2841 $basePrice = (float) ($cost['price'] ?? 0);
2842 $pricePer = $cost['price_per'] ?? 'person';
2843
2844 switch ($pricePer) {
2845 case 'person':
2846 $calculatedPrice = $basePrice * $total_travelers;
2847 break;
2848 case 'day':
2849 $calculatedPrice = $basePrice * $duration_days;
2850 break;
2851 case 'booking':
2852 default:
2853 $calculatedPrice = $basePrice;
2854 break;
2855 }
2856
2857 $itinerary_costs_total += $calculatedPrice;
2858 }
2859
2860 // Note: CalculationService already includes itinerary costs in final_total
2861 // No need to add itinerary_costs_total again - it's already included in $total_amount
2862
2863 // Calculate due amount based on payment method
2864 // Use filters for flexible payment settings (Pro feature)
2865 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
2866 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20);
2867 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30);
2868
2869 $amount_due = $total_amount;
2870 if ($payment_method === 'deposit') {
2871 $amount_due = $total_amount * ($deposit_percentage / 100);
2872 } elseif ($payment_method === 'partial') {
2873 $amount_due = $total_amount * ($partial_percentage / 100);
2874 }
2875
2876 Logger::debug('Yatra booking summary: payment method and amount due', [
2877 'context' => 'booking_summary_rest',
2878 'trip_id' => $trip_id,
2879 'flexible_payments_enabled' => $flexible_payments_enabled,
2880 'payment_method' => $payment_method,
2881 'total_amount' => round($total_amount, 4),
2882 'amount_due' => round($amount_due, 4),
2883 'deposit_percentage' => $deposit_percentage,
2884 'partial_percentage' => $partial_percentage,
2885 ]);
2886
2887 // Build pricing HTML for the summary section (using CalculationService data)
2888 $pricing_html = $this->buildPricingHtml([
2889 'is_traveler_based' => $is_traveler_based,
2890 'category_breakdown' => $category_breakdown,
2891 'price_per_person' => $price_per_person ?? $base_trip_price,
2892 'total_travelers' => $total_travelers,
2893 'gross_total' => $pricing['gross_total'] ?? $pricing['base_amount'],
2894 'subtotal' => $pricing['gross_total'] ?? $pricing['base_amount'],
2895 'taxable_amount' => $pricing['taxable_amount'] ?? 0,
2896 'group_discount_amount' => $pricing['group_discount']['amount'] ?? 0,
2897 'group_discount_label' => $pricing['group_discount']['label'] ?? '',
2898 'coupon_discount_amount' => $pricing['coupon_discount']['calculated_amount'] ?? 0,
2899 'coupon_discount_label' => $pricing['coupon_discount']['label'] ?? '',
2900 'coupon_code' => $pricing['coupon_discount']['code'] ?? '',
2901 'additional_services' => $additional_services,
2902 'services_total' => $services_total,
2903 'itinerary_costs' => $itinerary_costs,
2904 'itinerary_costs_total' => $itinerary_costs_total,
2905 'total_amount' => $total_amount,
2906 'amount_due' => $amount_due,
2907 'payment_method' => $payment_method,
2908 'deposit_percentage' => $deposit_percentage,
2909 'partial_percentage' => $partial_percentage,
2910 // Tax variables from centralized calculation
2911 'enable_tax' => $pricing['tax_calculation']['enable_tax'],
2912 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'],
2913 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'],
2914 'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'],
2915 // Currency for consistent formatting
2916 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2917 ]);
2918
2919 // Build response
2920 return new WP_REST_Response([
2921 'success' => true,
2922 'data' => [
2923 'is_traveler_based' => $is_traveler_based,
2924 'category_breakdown' => $category_breakdown,
2925 'subtotal' => round($subtotal, 2),
2926 'subtotal_formatted' => yatra_format_price($subtotal),
2927 'total_travelers' => $total_travelers,
2928 'group_discount' => $group_discount ? [
2929 'amount' => round($group_discount_amount, 2),
2930 'amount_formatted' => yatra_format_price($group_discount_amount),
2931 'label' => $group_discount_label,
2932 'code' => $group_discount_code,
2933 'applied_categories' => $group_discount['applied_categories'] ?? [],
2934 ] : null,
2935 'coupon_discount' => $coupon_discount_amount > 0 ? [
2936 'amount' => round($coupon_discount_amount, 2),
2937 'amount_formatted' => yatra_format_price($coupon_discount_amount),
2938 'label' => $coupon_discount_label,
2939 'code' => $coupon_code,
2940 ] : null,
2941 'coupon_error' => $coupon_error,
2942 'total_discount' => round($group_discount_amount + $coupon_discount_amount, 2),
2943 'total_discount_formatted' => yatra_format_price($group_discount_amount + $coupon_discount_amount),
2944 // Additional services (premium feature)
2945 'additional_services' => $additional_services,
2946 'services_total' => round($services_total, 2),
2947 'services_total_formatted' => yatra_format_price($services_total),
2948 // Itinerary costs (separate from services)
2949 'itinerary_costs' => $itinerary_costs,
2950 'itinerary_costs_total' => round($itinerary_costs_total, 2),
2951 'itinerary_costs_total_formatted' => yatra_format_price($itinerary_costs_total),
2952 'total_amount' => round($total_amount, 2),
2953 'total_amount_formatted' => yatra_format_price($total_amount),
2954 'amount_due' => round($amount_due, 2),
2955 'amount_due_formatted' => yatra_format_price($amount_due),
2956 'deposit_percentage' => $deposit_percentage,
2957 'partial_percentage' => $partial_percentage,
2958 // HTML for the pricing section
2959 'pricing_html' => $pricing_html,
2960 ],
2961 ]);
2962 }
2963
2964 /**
2965 * Build HTML for the pricing summary section
2966 * This is returned via AJAX to update the pricing breakdown dynamically
2967 * Uses the pricing-summary.php template for rendering with Checkout model
2968 */
2969 private function buildPricingHtml(array $data): string
2970 {
2971 // Get session data to create Checkout model
2972 yatra_start_session();
2973 $session = yatra_get_booking_session();
2974
2975 // Get trip data
2976 $trip_id = (int) ($session['trip_id'] ?? 0);
2977 if (empty($trip_id)) {
2978 return '<p>' . __('Pricing information not available.', 'yatra') . '</p>';
2979 }
2980
2981 $tripRepository = new \Yatra\Repositories\TripRepository();
2982 $trip = $tripRepository->findPublished($trip_id);
2983 if (!$trip) {
2984 return '<p>' . __('Trip not found.', 'yatra') . '</p>';
2985 }
2986
2987 // Build pricing calculation array from data (centralized pricing)
2988 $resolvedCurrentPrice = \Yatra\Services\TripPricingService::resolveRegularCurrentPrice($trip);
2989 $pricingCalculation = [
2990 'original_price' => $trip->original_price ?? 0,
2991 'discounted_price' => $resolvedCurrentPrice,
2992 'unit_price' => $data['price_per_person'] ?? $resolvedCurrentPrice,
2993 'pricing_type' => $session['pricing_type'] ?? 'regular',
2994 'base_amount' => $data['gross_total'] ?? 0,
2995 'subtotal' => $data['subtotal'] ?? $data['gross_total'] ?? 0,
2996 'taxable_amount' => $data['taxable_amount'] ?? 0,
2997 'gross_total' => $data['gross_total'] ?? 0,
2998 'final_total' => $data['total_amount'] ?? 0,
2999 'amount_due' => $data['amount_due'] ?? 0,
3000 'travelers_count' => $data['total_travelers'] ?? 1,
3001 'is_traveler_based' => $data['is_traveler_based'] ?? false,
3002 'category_breakdown' => $data['category_breakdown'] ?? [],
3003 'group_discount' => [
3004 'amount' => $data['group_discount_amount'] ?? 0,
3005 'label' => $data['group_discount_label'] ?? '',
3006 ],
3007 'coupon_discount' => [
3008 'code' => $data['coupon_code'] ?? '',
3009 'calculated_amount' => $data['coupon_discount_amount'] ?? 0,
3010 'label' => $data['coupon_discount_label'] ?? '',
3011 ],
3012 'total_discount_amount' => ($data['group_discount_amount'] ?? 0) + ($data['coupon_discount_amount'] ?? 0),
3013 'additional_services' => $data['additional_services'] ?? [],
3014 'services_total' => $data['services_total'] ?? 0,
3015 'itinerary_costs' => $data['itinerary_costs'] ?? [],
3016 'itinerary_costs_total' => $data['itinerary_costs_total'] ?? 0,
3017 'tax_calculation' => [
3018 'enable_tax' => $data['enable_tax'] ?? false,
3019 'tax_breakdown' => $data['tax_breakdown'] ?? [],
3020 'total_tax_amount' => $data['total_tax_amount'] ?? 0,
3021 'tax_inclusive' => $data['tax_inclusive'] ?? false,
3022 ],
3023 'currency' => $data['currency'] ?? null,
3024 ];
3025
3026 // Update session with payment method if provided
3027 if (!empty($data['payment_method'])) {
3028 $session['payment_method'] = $data['payment_method'];
3029 }
3030 if (!empty($data['deposit_percentage'])) {
3031 $session['deposit_percentage'] = $data['deposit_percentage'];
3032 }
3033 if (!empty($data['partial_payment_percentage'])) {
3034 $session['partial_payment_percentage'] = $data['partial_payment_percentage'];
3035 }
3036 if (!empty($data['partial_percentage'])) {
3037 $session['partial_payment_percentage'] = $data['partial_percentage'];
3038 }
3039
3040 if (!empty($data['payment_method']) || !empty($data['deposit_percentage']) || !empty($data['partial_percentage']) || !empty($data['partial_payment_percentage'])) {
3041 yatra_set_booking_session($session);
3042 }
3043
3044 // Create Checkout model instance
3045 $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation);
3046
3047 // Load the template (uses $checkout model)
3048 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php';
3049
3050 if (!file_exists($template_path)) {
3051 return '<p>' . __('Template not found.', 'yatra') . '</p>';
3052 }
3053
3054 // Use output buffering to capture the template output
3055 ob_start();
3056 include $template_path;
3057 return ob_get_clean();
3058 }
3059
3060 /**
3061 * Remove coupon code from booking session
3062 */
3063 public function remove_coupon(WP_REST_Request $request): WP_REST_Response
3064 {
3065 yatra_start_session();
3066
3067 $session = yatra_get_booking_session();
3068 if (empty($session)) {
3069 return new WP_REST_Response([
3070 'success' => false,
3071 'message' => __('No active booking session found.', 'yatra'),
3072 ], 400);
3073 }
3074
3075 // Remove coupon directly from session to avoid array_merge issues
3076 if (isset($_SESSION['yatra_booking']['coupon'])) {
3077 unset($_SESSION['yatra_booking']['coupon']);
3078 }
3079 $_SESSION['yatra_booking']['timestamp'] = time();
3080
3081 // Also update local session array for calculation
3082 unset($session['coupon']);
3083 $session['timestamp'] = time();
3084
3085 // Ensure session data is written immediately
3086 if (session_status() === PHP_SESSION_ACTIVE) {
3087 session_write_close();
3088 }
3089
3090 $total_amount = $this->calculateSessionTotal($session);
3091
3092 return new WP_REST_Response([
3093 'success' => true,
3094 'message' => __('Coupon removed.', 'yatra'),
3095 'data' => [
3096 'new_total' => $total_amount,
3097 'new_total_formatted' => yatra_format_price($total_amount),
3098 ],
3099 ]);
3100 }
3101
3102 /**
3103 * Calculate total amount from session
3104 * Uses CalculationService to get accurate pricing (without coupon)
3105 */
3106 private function calculateSessionTotal(array $session): float
3107 {
3108 // Use CalculationService to get accurate base pricing
3109 $calculationService = new \Yatra\Services\CalculationService();
3110
3111 try {
3112 // Calculate pricing WITHOUT coupon (we're calculating this to apply coupon to it)
3113 $pricing = $calculationService->calculateFromSession($session, '');
3114
3115 // Return gross_total (base amount before discounts but after any group discounts)
3116 $total = $pricing['gross_total'] ?? 0;
3117
3118 return (float) $total;
3119 } catch (\Throwable $e) {
3120 error_log('calculateSessionTotal - ERROR: ' . $e->getMessage());
3121 return 0.0;
3122 }
3123 }
3124
3125 /**
3126 * @deprecated Use DiscountService::calculateCouponDiscount() instead
3127 * Calculate discount amount
3128 */
3129 private function calculateDiscountAmount(\stdClass $discount, float $total, array $session): float
3130 {
3131 $discount_amount = 0;
3132
3133 // Check if group discount applies
3134 if ($discount->is_group_discount && !empty($discount->min_group_size)) {
3135 $travelers = (int) ($session['travelers'] ?? 1);
3136 if ($travelers >= (int) $discount->min_group_size && !empty($discount->group_discount_amount)) {
3137 // Apply group discount
3138 if ($discount->group_discount_type === 'percentage') {
3139 $discount_amount = $total * ((float) $discount->group_discount_amount / 100);
3140 } else {
3141 $discount_amount = (float) $discount->group_discount_amount;
3142 }
3143 }
3144 }
3145
3146 // If no group discount, apply regular discount
3147 if ($discount_amount === 0) {
3148 if ($discount->type === 'percentage') {
3149 $discount_amount = $total * ((float) $discount->amount / 100);
3150 } else {
3151 $discount_amount = (float) $discount->amount;
3152 }
3153 }
3154
3155 // Apply max discount cap if set
3156 if (!empty($discount->max_discount_amount) && $discount_amount > (float) $discount->max_discount_amount) {
3157 $discount_amount = (float) $discount->max_discount_amount;
3158 }
3159
3160 // Ensure discount doesn't exceed total
3161 if ($discount_amount > $total) {
3162 $discount_amount = $total;
3163 }
3164
3165 return round($discount_amount, 2);
3166 }
3167
3168 /**
3169 * Get coupon usage count by user
3170 */
3171 private function getCouponUsageByUser(string $discount_code, int $user_id): int
3172 {
3173 // Use AvailabilityService to check discount code usage
3174 return $this->availabilityService->getDiscountCodeUsage($user_id, strtoupper(sanitize_text_field($discount_code)));
3175 }
3176 }
3177
3178