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

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