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

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