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

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

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