PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Controllers/BookingSessionController.php +1986 -217 3.0.2.6 → 3.0.16 View file →
@@ -149,8 +149,28 @@
149 149 'methods' => 'POST',
150 150 'callback' => [$this, 'create_booking'],
151 151 'permission_callback' => [$this, 'public_permission_callback'],
152 152 ]);
153 +
154 + // Verify a guest's booking email via magic-link token.
155 + // Public + GET so the customer's browser can hit it from a
156 + // plain email-client link. The token itself carries the
157 + // authorisation (HMAC-signed); permission_callback is
158 + // intentionally open. On success the booking transitions to
159 + // 'pending' and the browser is redirected to the continuation
160 + // URL (where the customer completes payment as normal).
161 + register_rest_route($this->namespace, '/booking/verify-email', [
162 + 'methods' => 'GET',
163 + 'callback' => [$this, 'verify_email'],
164 + 'permission_callback' => '__return_true',
165 + 'args' => [
166 + 'token' => [
167 + 'required' => true,
168 + 'type' => 'string',
169 + 'sanitize_callback' => 'sanitize_text_field',
170 + ],
171 + ],
172 + ]);
153 173
154 174 // Apply coupon code
155 175 register_rest_route($this->namespace, '/booking/coupon/apply', [
156 176 'methods' => 'POST',
@@ -185,27 +205,91 @@
185 205 * Used by Square, and other gateways that tokenize on client
186 206 */
187 207 public function complete_gateway_payment(WP_REST_Request $request): WP_REST_Response
188 208 {
189 - $gateway_id = $request->get_param('gateway');
209 + $gateway_id = sanitize_key((string) $request->get_param('gateway'));
190 210 $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)) {
211 + if (!is_array($data)) {
212 + $data = [];
213 + }
214 +
215 + $booking_id = (int) ($data['booking_id'] ?? 0);
216 + $source_id = sanitize_text_field((string) ($data['source_id'] ?? ''));
217 + $client_amount = (float) ($data['amount'] ?? 0);
218 + $client_currency = sanitize_text_field((string) ($data['currency'] ?? 'USD'));
219 +
220 + if ($booking_id <= 0 || $source_id === '') {
198 221 return new WP_REST_Response([
199 222 'success' => false,
200 223 'message' => __('Missing required payment data.', 'yatra'),
201 224 ], 400);
202 225 }
203 -
226 +
227 + $bookingRepository = new \Yatra\Repositories\BookingRepository();
228 + $booking = $bookingRepository->find($booking_id);
229 +
230 + // Resolve the guest booking-session token (body first, then ?booking_token=),
231 + // exactly as the other booking-session endpoints do.
232 + $booking_token = '';
233 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
234 + $booking_token = sanitize_text_field((string) $data['booking_token']);
235 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
236 + $booking_token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
237 + }
238 +
239 + // H-1: ownership gate (monitor-first). An honest caller either owns the
240 + // booking (logged-in user / admin) or carries the booking_token bound to
241 + // it; only a stranger targeting someone else's booking_id is rejected.
242 + // In monitor mode this just logs and proceeds (zero behaviour change).
243 + if (!$this->requesterOwnsBooking($booking_id, $booking, $booking_token)) {
244 + if (\Yatra\Security\Guard::denied('payment_complete_ownership', [
245 + 'booking_id' => $booking_id,
246 + 'user' => get_current_user_id(),
247 + 'gateway' => $gateway_id,
248 + ])) {
249 + return new WP_REST_Response([
250 + 'success' => false,
251 + 'message' => __('You are not allowed to complete this payment.', 'yatra'),
252 + ], 403);
253 + }
254 + }
255 +
256 + // H-1: server-authoritative amount/currency. Honest clients already send
257 + // the booking's due amount, so this is invisible to them; it removes the
258 + // ability to tamper the charged amount. Override only when enforcing.
259 + $amount = $client_amount;
260 + $currency = $client_currency;
261 + if ($booking) {
262 + $server_amount = (float) ($booking->amount_due ?? 0);
263 + if ($server_amount <= 0) {
264 + $server_amount = (float) ($booking->total_amount ?? 0);
265 + }
266 + $server_currency = (string) ($booking->currency ?? $client_currency);
267 +
268 + if ($server_amount > 0) {
269 + $mismatch = abs($server_amount - $client_amount) > 0.001
270 + || ($client_currency !== '' && $server_currency !== ''
271 + && strcasecmp($client_currency, $server_currency) !== 0);
272 +
273 + if ($mismatch) {
274 + \Yatra\Security\Guard::flag('payment_complete_amount_mismatch', [
275 + 'booking_id' => $booking_id,
276 + 'client_amount' => $client_amount,
277 + 'server_amount' => $server_amount,
278 + ]);
279 + }
280 +
281 + if (\Yatra\Security\Guard::enforcing()) {
282 + $amount = $server_amount;
283 + $currency = $server_currency;
284 + }
285 + }
286 + }
287 +
204 288 // Get the gateway
205 289 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
206 290 $gateway = $registry->get($gateway_id);
207 -
291 +
208 292 if (!$gateway) {
209 293 return new WP_REST_Response([
210 294 'success' => false,
211 295 'message' => __('Invalid payment gateway.', 'yatra'),
@@ -210,9 +294,9 @@
210 294 'success' => false,
211 295 'message' => __('Invalid payment gateway.', 'yatra'),
212 296 ], 400);
213 297 }
214 -
298 +
215 299 // Check if gateway has createPayment method
216 300 if (!method_exists($gateway, 'createPayment')) {
217 301 return new WP_REST_Response([
218 302 'success' => false,
@@ -218,9 +302,9 @@
218 302 'success' => false,
219 303 'message' => __('Gateway does not support this payment method.', 'yatra'),
220 304 ], 400);
221 305 }
222 -
306 +
223 307 // Create the payment
224 308 $result = $gateway->createPayment([
225 309 'source_id' => $source_id,
226 310 'booking_id' => $booking_id,
@@ -226,9 +310,9 @@
226 310 'booking_id' => $booking_id,
227 311 'amount' => $amount,
228 312 'currency' => $currency,
229 313 ]);
230 -
314 +
231 315 if (!$result['success']) {
232 316 return new WP_REST_Response([
233 317 'success' => false,
234 318 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
@@ -233,44 +317,65 @@
233 317 'success' => false,
234 318 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
235 319 ], 400);
236 320 }
237 -
321 +
322 + $transaction_id = (string) ($result['transaction_id'] ?? '');
323 +
238 324 // Update booking payment status
239 - $bookingRepository = new \Yatra\Repositories\BookingRepository();
240 - $booking = $bookingRepository->find($booking_id);
241 -
242 325 if ($booking) {
243 - // Record the payment using PaymentRepository
244 326 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
245 - $paymentRepository->create([
246 - 'booking_id' => $booking_id,
247 - 'amount' => $amount,
248 - 'currency' => $currency,
249 - 'gateway' => $gateway_id,
250 - 'transaction_id' => $result['transaction_id'] ?? '',
251 - 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
252 - ]);
253 -
254 - // Update booking status if payment is complete
255 - if (($result['status'] ?? 'completed') === 'completed') {
256 - // Get total paid amount
257 - $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
258 - $total_amount = (float) $booking->total_amount;
259 -
260 - if ($total_paid >= $total_amount) {
261 - $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
262 - } else {
263 - $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
327 +
328 + // Idempotency guard: never double-record the same gateway transaction
329 + // for the same booking (e.g. a retried submit or a webhook racing this
330 + // call). Safe always-on — only blocks a duplicate, never a first payment.
331 + $alreadyRecorded = false;
332 + if ($transaction_id !== '' && method_exists($paymentRepository, 'findByTransactionId')) {
333 + $existing = $paymentRepository->findByTransactionId($transaction_id);
334 + $alreadyRecorded = $existing && (int) ($existing->booking_id ?? 0) === $booking_id;
335 + }
336 +
337 + if (!$alreadyRecorded) {
338 + // Record the payment using PaymentRepository
339 + $paymentRepository->create([
340 + 'booking_id' => $booking_id,
341 + 'amount' => $amount,
342 + 'currency' => $currency,
343 + 'gateway' => $gateway_id,
344 + 'transaction_id' => $transaction_id,
345 + 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
346 + ]);
347 +
348 + // Update booking status if payment is complete
349 + if (($result['status'] ?? 'completed') === 'completed') {
350 + // Get total paid amount
351 + $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
352 + $total_amount = (float) $booking->total_amount;
353 +
354 + if ($total_paid >= $total_amount) {
355 + // Fully paid. Respect the Auto-Confirm mode (same as every
356 + // other payment-completion path) — only confirm when the
357 + // mode is 'online' or 'all'; otherwise record the payment
358 + // and leave the booking pending for manual confirmation.
359 + $prevStatus = (string) ($booking->status ?? 'pending');
360 + if (\yatra_should_confirm_booking_on_payment(true, (int) $booking_id)) {
361 + $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
362 + \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus, true);
363 + } else {
364 + $bookingRepository->update($booking_id, ['payment_status' => 'paid']);
365 + }
366 + } else {
367 + $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
368 + }
264 369 }
265 370 }
266 371 }
267 -
372 +
268 373 return new WP_REST_Response([
269 374 'success' => true,
270 375 'message' => __('Payment completed successfully.', 'yatra'),
271 376 'data' => [
272 - 'transaction_id' => $result['transaction_id'] ?? '',
377 + 'transaction_id' => $transaction_id,
273 378 'status' => $result['status'] ?? 'completed',
274 379 ],
275 380 ]);
276 381 }
@@ -275,8 +380,48 @@
275 380 ]);
276 381 }
277 382
278 383 /**
384 + * Ownership check for booking-session mutations (H-1 / M-2).
385 + *
386 + * Mirrors {@see \Yatra\Controllers\PaymentGatewayController::get_payment_status()}:
387 + * - admins always pass;
388 + * - a registered-user booking requires the owning user;
389 + * - a guest booking (user_id NULL/0) requires the short-lived booking_token
390 + * transient whose stored `booking_id` matches — i.e. the same browser that
391 + * started this checkout. Honest guests always carry that token in the URL.
392 + *
393 + * @param object|null $booking Booking row, or null when not found.
394 + */
395 + private function requesterOwnsBooking(int $bookingId, $booking, string $bookingToken): bool
396 + {
397 + if (current_user_can('manage_options')) {
398 + return true;
399 + }
400 +
401 + if (!$booking) {
402 + return false;
403 + }
404 +
405 + $bookingUserId = (int) ($booking->user_id ?? 0);
406 + $currentUserId = (int) get_current_user_id();
407 +
408 + if ($bookingUserId > 0) {
409 + return $currentUserId === $bookingUserId;
410 + }
411 +
412 + // Guest booking: prove possession of the booking-session token bound to it.
413 + if ($bookingToken !== '') {
414 + $session = get_transient($bookingToken);
415 + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
416 + return true;
417 + }
418 + }
419 +
420 + return false;
421 + }
422 +
423 + /**
279 424 * Set booking session data
280 425 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
281 426 */
282 427 public function set_session(WP_REST_Request $request): WP_REST_Response
@@ -282,14 +427,53 @@
282 427 public function set_session(WP_REST_Request $request): WP_REST_Response
283 428 {
284 429 // Ensure session is started for REST API requests
285 430 yatra_start_session();
286 -
431 +
287 432 $data = $request->get_json_params();
288 -
433 +
434 + // M-2: restore CSRF protection stripped by public_permission_callback.
435 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
436 + return $blocked;
437 + }
438 +
289 439 // Check if this is a partial update (updating travelers or services in existing session)
290 440 $existing_session = yatra_get_booking_session();
291 - $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) &&
441 +
442 + // REST requests don't always carry PHPSESSID into the WP session scope,
443 + // so for partial updates (where the JS only sends e.g.
444 + // `additional_services: [1]`) we may end up with an empty
445 + // `$existing_session` here and incorrectly bounce to the "trip_id
446 + // required" guard below. Same fix as create_booking: when the page
447 + // URL has a `?booking_token=…` (or the body carries it), look up the
448 + // matching transient and treat it as the session. Without this, every
449 + // service-toggle returns 400.
450 + if (empty($existing_session) || empty($existing_session['trip_id'])) {
451 + $token = null;
452 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
453 + $token = sanitize_text_field((string) $data['booking_token']);
454 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
455 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
456 + }
457 + if ($token) {
458 + $transient_data = get_transient($token);
459 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
460 + $existing_session = $transient_data;
461 + // CRITICAL: also seed $_SESSION with the rehydrated data
462 + // AND the original token so yatra_set_booking_session()
463 + // (called below in the partial-update branch) writes the
464 + // updated session BACK into the same transient. Without
465 + // this, the helper generates a fresh random token and
466 + // writes to a different transient — the user's URL token
467 + // never gets the new selection persisted, and the next
468 + // refresh shows stale data.
469 + $_SESSION['yatra_booking'] = $existing_session;
470 + $_SESSION['yatra_booking_token'] = $token;
471 + }
472 + }
473 + }
474 +
475 + $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) &&
292 476 (isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services']));
293 477
294 478 if ($is_partial_update) {
295 479 // Partial update: merge new data with existing session
@@ -306,11 +490,14 @@
306 490 $existing_session['additional_services'] = array_map('intval', $data['additional_services']);
307 491 }
308 492
309 493 // Recalculate taxes for partial updates
310 - $trip_price = $existing_session['trip_price'];
311 - $travelers_count = $existing_session['travelers'];
494 + $trip_id = (int) ($existing_session['trip_id'] ?? 0);
495 + $trip_price = (float) ($existing_session['trip_price'] ?? $existing_session['base_price'] ?? 0);
496 + $travelers_count = (int) ($existing_session['travelers'] ?? 1);
312 497 $additional_services = $existing_session['additional_services'] ?? [];
498 + $travel_date = (string) ($existing_session['travel_date'] ?? '');
499 + $departure_time = (string) ($existing_session['departure_time'] ?? '');
313 500
314 501 // Calculate base amount - handle traveler-based pricing
315 502 $base_amount = 0;
316 503 $pricing_type = $existing_session['pricing_type'] ?? 'regular';
@@ -340,26 +527,12 @@
340 527 // Regular pricing
341 528 $base_amount = $trip_price * $travelers_count;
342 529 }
343 530
344 - // Calculate additional services cost
345 - $services_cost = 0;
346 - if (!empty($additional_services)) {
347 - foreach ($additional_services as $service_id) {
348 - $service = $wpdb->get_row($wpdb->prepare(
349 - "SELECT price FROM {$wpdb->prefix}yatra_additional_services WHERE id = %d",
350 - $service_id
351 - ));
352 - if ($service && $service->price) {
353 - $services_cost += (float) $service->price;
354 - }
355 - }
356 - }
357 -
358 531 // Use CalculationService for pricing (fetches trip data from database)
359 532 $calculationService = new CalculationService();
360 533 $pricing = $calculationService->calculatePricing([
361 - 'trip_id' => (int) $trip->id,
534 + 'trip_id' => $trip_id,
362 535 'travelers_count' => $travelers_count,
363 536 'traveler_counts' => $traveler_counts,
364 537 'travel_date' => $travel_date,
365 538 'departure_time' => $departure_time,
@@ -366,12 +539,12 @@
366 539 'selected_services' => $additional_services,
367 540 'coupon_code' => '',
368 541 'payment_method' => 'full',
369 542 ]);
370 - $subtotal = $pricing['subtotal'];
371 - $total_with_tax = $pricing['final_total'];
372 - $tax_calculation = $pricing['tax_calculation'];
373 - $services_cost = $pricing['services_cost'];
543 + $subtotal = (float) ($pricing['subtotal'] ?? $base_amount);
544 + $total_with_tax = (float) ($pricing['final_total'] ?? $subtotal);
545 + $tax_calculation = (array) ($pricing['tax_calculation'] ?? []);
546 + $services_cost = (float) ($pricing['services_cost'] ?? $pricing['services_total'] ?? 0);
374 547
375 548 // Note: Pricing is calculated on-demand via CalculationService, not stored in session
376 549
377 550 $existing_session['timestamp'] = time();
@@ -406,9 +579,11 @@
406 579 global $wpdb;
407 580
408 581 // Get availability-specific data if date provided
409 582 $availability = null;
410 - $availability_id = !empty($data['availability_id']) ? sanitize_text_field($data['availability_id']) : null;
583 + // availability_id may be numeric (manual date row) or a synthetic string (rule/default).
584 + // Keep it as string in session and only cast to int when it is numeric.
585 + $availability_id = !empty($data['availability_id']) ? sanitize_text_field((string) $data['availability_id']) : null;
411 586 $travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : '';
412 587 $departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : '';
413 588
414 589 // Use centralized AvailabilityResolutionService to get resolved availability
@@ -429,9 +604,8 @@
429 604 $data['seats_available'] = $availability->seats_available;
430 605 $data['seats_total'] = $availability->seats_total;
431 606 }
432 607 } catch (\Exception $e) {
433 - error_log('Yatra Booking: Error resolving availability - ' . $e->getMessage());
434 608 $availability = null;
435 609 }
436 610 }
437 611
@@ -564,8 +738,14 @@
564 738 'coupon_code' => '',
565 739 'payment_method' => 'full',
566 740 ]);
567 741
742 + // Resolve pricing_mode / group-size limits authoritatively from the
743 + // TravelerCategory before persisting, so the checkout breakdown (which
744 + // reads these session price_types) renders a per-group category as a
745 + // flat charge. Per-person categories are unchanged.
746 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
747 +
568 748 // Prepare session data - essential trip data (pricing fetched from database on-demand)
569 749 $session_data = [
570 750 'trip_id' => (int) $trip->id,
571 751 'trip_title' => $trip->title,
@@ -639,9 +819,30 @@
639 819 public function get_session(WP_REST_Request $request): WP_REST_Response
640 820 {
641 821 $session_data = yatra_get_booking_session();
642 822
823 + // Recover from transient when PHPSESSID didn't propagate to REST.
824 + // The JS appends `?booking_token=…` to the GET URL specifically so
825 + // this branch can rehydrate after a page refresh — without it the
826 + // sidebar's applied-coupon UI would never re-show and the remove
827 + // button stayed hidden.
643 828 if (empty($session_data) || empty($session_data['trip_id'])) {
829 + $token_raw = $request->get_param('booking_token');
830 + if (empty($token_raw) && isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
831 + $token_raw = wp_unslash((string) $_GET['booking_token']);
832 + }
833 + if (!empty($token_raw) && is_string($token_raw)) {
834 + $token = sanitize_text_field($token_raw);
835 + $transient_data = get_transient($token);
836 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
837 + $session_data = $transient_data;
838 + $_SESSION['yatra_booking'] = $session_data;
839 + $_SESSION['yatra_booking_token'] = $token;
840 + }
841 + }
842 + }
843 +
844 + if (empty($session_data) || empty($session_data['trip_id'])) {
644 845 return new WP_REST_Response([
645 846 'success' => false,
646 847 'message' => __('No active booking session.', 'yatra'),
647 848 'data' => null,
@@ -658,8 +859,13 @@
658 859 * Clear booking session
659 860 */
660 861 public function clear_session(WP_REST_Request $request): WP_REST_Response
661 862 {
863 + // M-2: restore CSRF protection stripped by public_permission_callback.
864 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
865 + return $blocked;
866 + }
867 +
662 868 yatra_clear_booking_session();
663 869
664 870 return new WP_REST_Response([
665 871 'success' => true,
@@ -690,8 +896,11 @@
690 896 'slug' => $trip->slug,
691 897 'featured_image' => $trip->featured_image,
692 898 'duration_days' => (int) $trip->duration_days,
693 899 'duration_nights' => (int) $trip->duration_nights,
900 + // Hour-based day tours (0 on every day-based trip). Additive field:
901 + // existing consumers keep reading duration_days/duration_nights.
902 + 'duration_hours' => (int) ($trip->duration_hours ?? 0),
694 903 'difficulty_level' => $trip->difficulty_level,
695 904 'min_travelers' => (int) ($trip->min_travelers ?: 1),
696 905 'max_travelers' => (int) ($trip->max_travelers ?: 20),
697 906 'original_price' => (float) $trip->original_price,
@@ -717,15 +926,340 @@
717 926 *
718 927 * Remaining / balance payment: does not create a booking — only charges the existing row
719 928 * and records a payment on success via the same gateway completion paths.
720 929 */
930 + /**
931 + * Validate the booking-scoped CSRF nonce.
932 + *
933 + * Looks first in the `X-Yatra-Booking-Nonce` request header
934 + * (the JS frontend's path), then in JSON body keys used by
935 + * older or non-JS fallback flows. Returns true on a valid
936 + * nonce, false otherwise.
937 + *
938 + * @param WP_REST_Request $request
939 + * @param array<string, mixed>|null $data decoded JSON body
940 + */
941 + private function verifyBookingNonce(WP_REST_Request $request, $data): bool
942 + {
943 + $nonce = (string) $request->get_header('X-Yatra-Booking-Nonce');
944 + if ($nonce === '' && \is_array($data)) {
945 + $nonce = (string) (
946 + $data['_yatra_booking_nonce']
947 + ?? $data['yatra_booking_nonce']
948 + ?? $data['booking_nonce']
949 + ?? ''
950 + );
951 + }
952 + if ($nonce === '') {
953 + return false;
954 + }
955 + return (bool) wp_verify_nonce($nonce, 'yatra_booking_action');
956 + }
957 +
958 + /**
959 + * CSRF guard for the public booking-session mutations (M-2).
960 + *
961 + * `public_permission_callback` strips WP's REST cookie-nonce so guests can
962 + * reach these routes, which would otherwise leave them open to cross-site
963 + * forgery of a visitor's session. This restores protection by requiring at
964 + * least one signal that an honest same-origin checkout always carries:
965 + * - the booking-scoped nonce (`X-Yatra-Booking-Nonce`), or
966 + * - a valid WP REST nonce (`X-WP-Nonce`, the one that was stripped), or
967 + * - a booking_token transient, or
968 + * - an active PHP booking session.
969 + * A blind cross-site POST has none of these.
970 + *
971 + * Monitor-first: returns a 403 response ONLY when the guard is enforcing;
972 + * in monitor mode it logs and returns null so behaviour is unchanged.
973 + *
974 + * @param array<string, mixed>|null $data decoded JSON body (decoded here if null)
975 + * @return WP_REST_Response|null 403 response to short-circuit with, or null to proceed
976 + */
977 + private function guardPublicBookingMutation(WP_REST_Request $request, $data = null): ?WP_REST_Response
978 + {
979 + if ($data === null) {
980 + $data = $request->get_json_params();
981 + }
982 +
983 + // 1) booking-scoped nonce, or 2) the stripped WP REST nonce.
984 + if ($this->verifyBookingNonce($request, $data)) {
985 + return null;
986 + }
987 + $restNonce = (string) $request->get_header('X-WP-Nonce');
988 + if ($restNonce !== '' && wp_verify_nonce($restNonce, 'wp_rest')) {
989 + return null;
990 + }
991 +
992 + // 3) a booking-session token (body first, then ?booking_token=).
993 + $token = '';
994 + if (is_array($data) && !empty($data['booking_token']) && is_string($data['booking_token'])) {
995 + $token = sanitize_text_field((string) $data['booking_token']);
996 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
997 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
998 + }
999 + if ($token !== '' && is_array(get_transient($token))) {
1000 + return null;
1001 + }
1002 +
1003 + // 4) an active server-side booking session.
1004 + if (function_exists('yatra_get_booking_session')) {
1005 + $session = yatra_get_booking_session();
1006 + if (!empty($session) && !empty($session['trip_id'])) {
1007 + return null;
1008 + }
1009 + }
1010 +
1011 + if (\Yatra\Security\Guard::denied('public_booking_csrf', [
1012 + 'route' => $request->get_route(),
1013 + ])) {
1014 + return new WP_REST_Response([
1015 + 'success' => false,
1016 + 'message' => __('Your session could not be verified. Please refresh the page and try again.', 'yatra'),
1017 + ], 403);
1018 + }
1019 +
1020 + return null;
1021 + }
1022 +
1023 + /**
1024 + * IDs of enabled email-type fields in a single form section.
1025 + *
1026 + * "Email type" follows the same rule as the admin form-builder's
1027 + * "form captures email" notice: a field with type === 'email' OR the
1028 + * conventional id === 'email'. Used so the booking email can be resolved
1029 + * from a CUSTOM email field (e.g. id 'work_email') and not only the locked
1030 + * core `email` field. On a default/un-customised form this returns
1031 + * ['email'] for the contact section and [] for the traveler section, so
1032 + * the downstream resolution collapses to the original behaviour.
1033 + *
1034 + * @param array<string,mixed> $section
1035 + * @return array<int,string>
1036 + */
1037 + private function emailFieldIds(array $section): array
1038 + {
1039 + if (empty($section['fields']) || !is_array($section['fields'])) {
1040 + return [];
1041 + }
1042 + $ids = [];
1043 + foreach ($section['fields'] as $field) {
1044 + if (!is_array($field)) {
1045 + continue;
1046 + }
1047 + $enabled = !isset($field['enabled']) || (bool) $field['enabled'];
1048 + $is_email = (($field['type'] ?? '') === 'email') || (($field['id'] ?? '') === 'email');
1049 + if ($enabled && $is_email && !empty($field['id'])) {
1050 + $ids[] = (string) $field['id'];
1051 + }
1052 + }
1053 + return $ids;
1054 + }
1055 +
1056 + /**
1057 + * Enforce required booking-form fields server-side (Dynamic Form module).
1058 + *
1059 + * Mirrors the frontend's required rules so a crafted request can't omit a
1060 + * required field (built-in or CUSTOM). Only enabled+required fields in
1061 + * enabled sections are checked, honouring the operator's saved config.
1062 + * `email` and contact `phone` are skipped — they have dedicated handling
1063 + * (email resolution + the contact-phone check). Returns an error message,
1064 + * or null when everything required is present.
1065 + *
1066 + * @param array<string,mixed> $form_config
1067 + * @param array<string,mixed> $data
1068 + * @param array<int,mixed> $travelers
1069 + */
1070 + private function validateRequiredFormFields(
1071 + array $form_config,
1072 + array $data,
1073 + array $travelers,
1074 + bool $contact_enabled,
1075 + bool $traveler_enabled
1076 + ): ?string {
1077 + $is_missing = static function ($value): bool {
1078 + return !is_scalar($value) || trim((string) $value) === '';
1079 + };
1080 +
1081 + // --- Contact section (flat contact_<id> keys) ---
1082 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1083 + foreach ($form_config['contact_form']['fields'] as $field) {
1084 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1085 + continue;
1086 + }
1087 + $id = (string) $field['id'];
1088 + if ($id === 'email' || $id === 'phone') {
1089 + continue; // handled by the email resolution + contact-phone check
1090 + }
1091 + if ($is_missing($data['contact_' . $id] ?? null)) {
1092 + /* translators: %s: form field label. */
1093 + return sprintf(__('%s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1094 + }
1095 + }
1096 + }
1097 +
1098 + // --- Emergency section (flat emergency_<id> keys) ---
1099 + $emergency = $form_config['emergency_contact_form'] ?? null;
1100 + $emergency_enabled = is_array($emergency) && (!isset($emergency['enabled']) || (bool) $emergency['enabled']);
1101 + if ($emergency_enabled && !empty($emergency['fields']) && is_array($emergency['fields'])) {
1102 + foreach ($emergency['fields'] as $field) {
1103 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1104 + continue;
1105 + }
1106 + $id = (string) $field['id'];
1107 + if ($is_missing($data['emergency_' . $id] ?? null)) {
1108 + /* translators: %s: emergency contact field label. */
1109 + return sprintf(__('Emergency contact: %s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1110 + }
1111 + }
1112 + }
1113 +
1114 + // --- Traveler section (per-traveler travelers[i][<id>]) ---
1115 + // Skipped when the section is off (book-by-count synthesises travelers).
1116 + if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) {
1117 + $required_traveler_fields = [];
1118 + foreach ($form_config['traveler_form']['fields'] as $field) {
1119 + if (is_array($field) && !empty($field['enabled']) && !empty($field['required']) && !empty($field['id']) && ($field['type'] ?? '') !== 'text_block') {
1120 + $required_traveler_fields[(string) $field['id']] = [
1121 + 'label' => (string) ($field['label'] ?? $field['id']),
1122 + // "lead" fields are only required on the lead traveler;
1123 + // absent/"all" is required on every traveler (legacy).
1124 + 'applies_to' => ($field['applies_to'] ?? 'all'),
1125 + ];
1126 + }
1127 + }
1128 + if (!empty($required_traveler_fields)) {
1129 + $traveler_index = 0;
1130 + foreach ($travelers as $traveler) {
1131 + if (!is_array($traveler)) {
1132 + continue;
1133 + }
1134 + // Only real travelers; skip any contact/emergency pseudo-entries.
1135 + if (isset($traveler['type']) && $traveler['type'] !== 'traveler') {
1136 + continue;
1137 + }
1138 + $traveler_index++;
1139 + foreach ($required_traveler_fields as $fid => $meta) {
1140 + // Lead-only required fields apply to Traveler 1 only.
1141 + if (($meta['applies_to'] ?? 'all') === 'lead' && $traveler_index !== 1) {
1142 + continue;
1143 + }
1144 + if ($is_missing($traveler[$fid] ?? null)) {
1145 + /* translators: 1: traveler number, 2: field label. */
1146 + return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']);
1147 + }
1148 + }
1149 + }
1150 + }
1151 + }
1152 +
1153 + return null;
1154 + }
1155 +
1156 + /**
1157 + * Enforce per-group category size limits at booking time.
1158 + *
1159 + * A traveler category priced "per group" (pricing_mode === 'per_group')
1160 + * charges one flat price for the whole group, bounded by an optional group
1161 + * size range (min_pax / max_pax) configured on the category. This validates
1162 + * the selected headcount for each such category against that range.
1163 + *
1164 + * It is a strict no-op for per-person categories and for per-group
1165 + * categories that have no limit configured, so existing trips are
1166 + * unaffected. Categories that aren't selected (count 0) are skipped.
1167 + *
1168 + * @param array<int, mixed> $price_types Resolved price types (carry pricing_mode/min_pax/max_pax).
1169 + * @param array<int|string, mixed> $traveler_counts Selected count keyed by category id.
1170 + * @return string|null Error message when a limit is violated, otherwise null.
1171 + */
1172 + private function validateGroupSizeLimits(array $price_types, array $traveler_counts): ?string
1173 + {
1174 + foreach ($price_types as $pt) {
1175 + $pt = (array) $pt;
1176 +
1177 + if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') {
1178 + continue;
1179 + }
1180 +
1181 + $cid = $pt['category_id'] ?? null;
1182 + if ($cid === null) {
1183 + continue;
1184 + }
1185 +
1186 + // traveler_counts may be keyed by int or string category id.
1187 + $count = (int) ($traveler_counts[(int) $cid]
1188 + ?? $traveler_counts[(string) $cid]
1189 + ?? 0);
1190 + if ($count <= 0) {
1191 + continue; // category not selected — nothing to validate
1192 + }
1193 +
1194 + $label = $pt['category_label'] ?? ($pt['label'] ?? __('group', 'yatra'));
1195 + $min = (isset($pt['min_pax']) && $pt['min_pax'] !== null && $pt['min_pax'] !== '') ? (int) $pt['min_pax'] : null;
1196 + $max = (isset($pt['max_pax']) && $pt['max_pax'] !== null && $pt['max_pax'] !== '') ? (int) $pt['max_pax'] : null;
1197 + $overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block';
1198 +
1199 + if ($min !== null && $min > 0 && $count < $min) {
1200 + /* translators: 1: category label, 2: minimum group size. */
1201 + return sprintf(__('%1$s requires at least %2$d people.', 'yatra'), $label, $min);
1202 + }
1203 + // In "per_block" mode a party may exceed the max group size — it just
1204 + // buys additional group blocks — so only enforce the max for "block".
1205 + if ($overflow !== 'per_block' && $max !== null && $max > 0 && $count > $max) {
1206 + /* translators: 1: category label, 2: maximum group size. */
1207 + return sprintf(__('%1$s allows a maximum of %2$d people.', 'yatra'), $label, $max);
1208 + }
1209 + }
1210 +
1211 + return null;
1212 + }
1213 +
721 1214 public function create_booking(WP_REST_Request $request): WP_REST_Response
722 1215 {
723 1216 global $wpdb;
724 -
1217 +
725 1218 $data = $request->get_json_params();
726 1219
1220 + // reCAPTCHA v3 — no-op unless the booking form is explicitly protected in
1221 + // settings (off by default so payment flows are never gated unless the
1222 + // operator opts in).
1223 + $recaptcha = \Yatra\Services\RecaptchaService::verifyForm(
1224 + 'booking',
1225 + (string) (($data['recaptcha_token'] ?? '') ?: ''),
1226 + $_SERVER['REMOTE_ADDR'] ?? null
1227 + );
1228 + if (empty($recaptcha['success'])) {
1229 + return new WP_REST_Response([
1230 + 'success' => false,
1231 + 'message' => $recaptcha['message'] ?? __('reCAPTCHA verification failed.', 'yatra'),
1232 + ], 400);
1233 + }
1234 +
727 1235 // ========================================
1236 + // CSRF — booking-scoped action nonce
1237 + // ========================================
1238 + // The public_permission_callback on this route intentionally
1239 + // bypasses WP's default cookie/nonce check so guests can hit it
1240 + // at all. That bypass would otherwise leave the endpoint open
1241 + // to cross-site forgery (any third-party page could POST a
1242 + // booking using the visitor's session).
1243 + //
1244 + // We validate a booking-scoped action nonce here instead. The
1245 + // token is minted at page-render time (FrontendAssetsProvider
1246 + // injects it into `yatraBookingData.bookingNonce`) and the JS
1247 + // forwards it in `X-Yatra-Booking-Nonce`. We also accept it in
1248 + // the JSON body for any non-JS fallback flow.
1249 + //
1250 + // Returns 403 on failure — distinct from the 401 used by the
1251 + // login/guest-checkout gates so frontends can distinguish
1252 + // "security check failed" from "auth required".
1253 + if (!$this->verifyBookingNonce($request, $data)) {
1254 + return new WP_REST_Response([
1255 + 'success' => false,
1256 + 'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'),
1257 + 'code' => 'invalid_nonce',
1258 + ], 403);
1259 + }
1260 +
1261 + // ========================================
728 1262 // REMAINING PAYMENT vs NEW BOOKING
729 1263 // ========================================
730 1264 // A leftover PHP session from "pay remaining balance" must not hijack a normal
731 1265 // checkout POST (full traveler payload). Only treat as remaining-payment when the
@@ -756,13 +1290,11 @@
756 1290 // GET BOOKING SETTINGS
757 1291 // ========================================
758 1292 $settings = [
759 1293 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
760 - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1294 + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(),
761 1295 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
762 1296 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
763 - 'cancellation_policy' => \Yatra\Services\SettingsService::get('cancellation_policy', 'full_refund'),
764 - 'cancellation_days' => (int) \Yatra\Services\SettingsService::get('cancellation_days', 7),
765 1297 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
766 1298 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
767 1299 ];
768 1300
@@ -787,11 +1319,50 @@
787 1319 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
788 1320 ], 401);
789 1321 }
790 1322
1323 + // ========================================
1324 + // GUEST EMAIL VERIFICATION GATE
1325 + // ========================================
1326 + // When `require_guest_email_verification` is on AND the
1327 + // customer is not logged in, the booking goes through a
1328 + // two-step flow:
1329 + // 1. Booking row is created with status='pending_verification'
1330 + // so the operator sees the intent in the admin and the
1331 + // cron can purge unverified rows after N days.
1332 + // 2. A magic-link email is sent. Payment is NOT initiated
1333 + // until the customer clicks the link.
1334 + // 3. On click, /yatra/v1/booking/verify-email validates the
1335 + // HMAC token, flips status to 'pending', and redirects
1336 + // to the payment continuation URL.
1337 + // Logged-in users skip this entirely — their email is already
1338 + // verified by WordPress on registration.
1339 + $needs_email_verification = !is_user_logged_in()
1340 + && (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false);
1341 +
791 1342 // Get session data
792 1343 $session = yatra_get_booking_session();
793 -
1344 +
1345 + // REST requests don't always carry PHPSESSID in the same scope as the
1346 + // page that rendered the booking form (cookie path mismatches, output
1347 + // buffering before session_start, server-side caching, etc). When
1348 + // `$session` is empty we try to rehydrate from the transient backup that
1349 + // BookingPageHandler writes when the form is rendered — the JS submit
1350 + // now carries `booking_token` in the request body for exactly this
1351 + // recovery path. Without this, `traveler_counts` / `pricing_type` /
1352 + // `price_types` are lost and a `traveler_based` trip's
1353 + // calculatePricing() collapses to 0, then BookingService rejects with
1354 + // "Total amount must be greater than zero."
1355 + if ((empty($session) || empty($session['trip_id'])) && !empty($data['booking_token'])) {
1356 + $tokenFromBody = sanitize_text_field((string) $data['booking_token']);
1357 + if ($tokenFromBody !== '') {
1358 + $transient_data = get_transient($tokenFromBody);
1359 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
1360 + $session = $transient_data;
1361 + }
1362 + }
1363 + }
1364 +
794 1365 // Validate we have session data or direct booking data
795 1366 $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0);
796 1367
797 1368 if (!$trip_id) {
@@ -800,42 +1371,163 @@
800 1371 'message' => __('No trip selected for booking.', 'yatra'),
801 1372 ], 400);
802 1373 }
803 1374
1375 + // Which booking-form sections are enabled (Pro Dynamic Form module).
1376 + // The default config has every section enabled, so on existing/un-customised
1377 + // sites $contact_enabled and $traveler_enabled are both true and the logic
1378 + // below behaves exactly as before — only disabled sections change anything.
1379 + // Scoped to the trip being booked — the same config the checkout
1380 + // rendered, so a field hidden for this trip is never treated as required.
1381 + $form_config = function_exists('yatra_get_booking_form_config')
1382 + ? yatra_get_booking_form_config($trip_id > 0 ? (int) $trip_id : null)
1383 + : [];
1384 + $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled'];
1385 + $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled'];
1386 +
804 1387 // Get contact email - handle both flat and nested formats
805 - $contact_email = $data['contact_email'] ?? '';
1388 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
806 1389 $contact_phone = $data['contact_phone'] ?? '';
1390 + // International phone widget: fold the chosen country (companion
1391 + // *_country field carrying the ISO) into the number as "+<dial><digits>".
1392 + // A no-op for legacy submissions with no companion field, an already
1393 + // "+"-prefixed value, or an unknown ISO — so existing data is never
1394 + // altered and nothing is invented.
1395 + $contact_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1396 + (string) $contact_phone,
1397 + (string) ($data['contact_phone_country'] ?? '')
1398 + );
807 1399 $contact_first_name = $data['contact_first_name'] ?? '';
808 1400 $contact_last_name = $data['contact_last_name'] ?? '';
809 1401 $contact_country = $data['contact_country'] ?? '';
810 -
811 - $contact_nationality = $data['contact_nationality'] ?? '';
1402 +
1403 + $contact_nationality = $data['contact_nationality'] ?? '';
812 1404 $contact_address = $data['contact_address'] ?? '';
813 -
1405 +
814 1406 // Emergency contact
815 1407 $emergency_name = $data['emergency_name'] ?? '';
816 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
817 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
818 -
1413 +
819 1414 // Travel details
820 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
821 1416 $travelers = $data['travelers'] ?? [];
822 -
823 - // Validate required fields
824 - if (empty($contact_email)) {
1417 +
1418 + // EMAIL RESOLUTION: prefer the Contact email. When it's missing — the
1419 + // Contact section is off, or the operator collects email through a
1420 + // CUSTOM email-type field rather than the locked core `email` field —
1421 + // resolve it from the form config instead, mirroring the admin
1422 + // "form captures email" notice (contact + traveler sections). At least
1423 + // one enabled form must capture an email; the form builder warns the
1424 + // operator about this too. On a default form the core `email` field
1425 + // already populated $contact_email, so none of the fallbacks run.
1426 +
1427 + // (a) Custom email-type field in the Contact section (submitted as
1428 + // contact_<id>). The core `email` field is already read above, so skip
1429 + // it here.
1430 + if ($contact_email === '' && $contact_enabled) {
1431 + foreach ($this->emailFieldIds($form_config['contact_form'] ?? []) as $fid) {
1432 + if ($fid === 'email') {
1433 + continue;
1434 + }
1435 + $val = trim((string) ($data['contact_' . $fid] ?? ''));
1436 + if ($val !== '' && is_email($val)) {
1437 + $contact_email = $val;
1438 + break;
1439 + }
1440 + }
1441 + }
1442 +
1443 + // (b) Fall back to a traveler email — the conventional `email` key OR
1444 + // any traveler email-type field — adopting the lead traveler's
1445 + // name/phone as the contact when the Contact section is off, so the
1446 + // booking/customer isn't nameless. On a default form this checks only
1447 + // $t['email'], identical to the original behaviour.
1448 + if ($contact_email === '' && is_array($travelers)) {
1449 + $traveler_email_ids = $traveler_enabled
1450 + ? $this->emailFieldIds($form_config['traveler_form'] ?? [])
1451 + : [];
1452 + if (!in_array('email', $traveler_email_ids, true)) {
1453 + $traveler_email_ids[] = 'email';
1454 + }
1455 + foreach ($travelers as $t) {
1456 + if (!is_array($t)) {
1457 + continue;
1458 + }
1459 + $found = '';
1460 + foreach ($traveler_email_ids as $fid) {
1461 + if (!empty($t[$fid]) && is_email((string) $t[$fid])) {
1462 + $found = trim((string) $t[$fid]);
1463 + break;
1464 + }
1465 + }
1466 + if ($found !== '') {
1467 + $contact_email = $found;
1468 + if ($contact_first_name === '') { $contact_first_name = (string) ($t['first_name'] ?? ''); }
1469 + if ($contact_last_name === '') { $contact_last_name = (string) ($t['last_name'] ?? ''); }
1470 + if (empty($contact_phone) && !empty($t['phone'])) { $contact_phone = (string) $t['phone']; }
1471 + break;
1472 + }
1473 + }
1474 + }
1475 +
1476 + // When the Traveler form is disabled there are no per-traveler fields, so
1477 + // build traveler rows from the selected count and use the lead contact as
1478 + // traveler 1 (book-by-count). Only runs when the section is off.
1479 + if (!$traveler_enabled && (empty($travelers) || !is_array($travelers))) {
1480 + $synth_count = (int) ($data['travelers_count']
1481 + ?? $session['travelers']
1482 + ?? (is_array($session['traveler_counts'] ?? null) ? array_sum(array_map('intval', $session['traveler_counts'])) : 0));
1483 + $synth_count = max(1, $synth_count);
1484 + $travelers = [];
1485 + for ($i = 1; $i <= $synth_count; $i++) {
1486 + $travelers[] = [
1487 + 'type' => 'traveler',
1488 + 'first_name' => $i === 1 ? $contact_first_name : '',
1489 + 'last_name' => $i === 1 ? $contact_last_name : '',
1490 + 'email' => $i === 1 ? $contact_email : '',
1491 + ];
1492 + }
1493 + }
1494 +
1495 + // Validate required fields — email is always required (resolved above).
1496 + if ($contact_email === '' || !is_email($contact_email)) {
825 1497 return new WP_REST_Response([
826 1498 'success' => false,
827 - 'message' => __('Email address is required.', 'yatra'),
1499 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
828 1500 ], 400);
829 1501 }
830 -
831 - if (empty($contact_phone)) {
1502 +
1503 + // Phone belongs to the Contact section. Require it only when that section
1504 + // is enabled AND the phone field is itself enabled+required in the config,
1505 + // so an operator who made phone optional (or disabled it) via the Dynamic
1506 + // Form module isn't blocked on a field the customer never saw. On a
1507 + // default form phone is locked+required, so this is unchanged for
1508 + // existing Free/Pro users.
1509 + $contact_phone_required = false;
1510 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1511 + foreach ($form_config['contact_form']['fields'] as $cf) {
1512 + if (is_array($cf) && ($cf['id'] ?? '') === 'phone') {
1513 + $cf_enabled = !isset($cf['enabled']) || (bool) $cf['enabled'];
1514 + $contact_phone_required = $cf_enabled && !empty($cf['required']);
1515 + break;
1516 + }
1517 + }
1518 + } elseif ($contact_enabled) {
1519 + // No field metadata available (legacy/edge): preserve the original
1520 + // "require phone when contact is on" behaviour.
1521 + $contact_phone_required = true;
1522 + }
1523 + if ($contact_phone_required && empty($contact_phone)) {
832 1524 return new WP_REST_Response([
833 1525 'success' => false,
834 1526 'message' => __('Phone number is required.', 'yatra'),
835 1527 ], 400);
836 1528 }
837 -
1529 +
838 1530 if (empty($travel_date)) {
839 1531 return new WP_REST_Response([
840 1532 'success' => false,
841 1533 'message' => __('Travel date is required.', 'yatra'),
@@ -840,9 +1532,9 @@
840 1532 'success' => false,
841 1533 'message' => __('Travel date is required.', 'yatra'),
842 1534 ], 400);
843 1535 }
844 -
1536 +
845 1537 if (empty($travelers) || !is_array($travelers)) {
846 1538 return new WP_REST_Response([
847 1539 'success' => false,
848 1540 'message' => __('At least one traveler is required.', 'yatra'),
@@ -848,14 +1540,27 @@
848 1540 'message' => __('At least one traveler is required.', 'yatra'),
849 1541 ], 400);
850 1542 }
851 1543
852 - // Validate email
853 - if (!is_email($contact_email)) {
854 - return new WP_REST_Response([
855 - 'success' => false,
856 - 'message' => __('Invalid email address.', 'yatra'),
857 - ], 400);
1544 + // Server-side enforcement of required form fields (incl. CUSTOM fields).
1545 + // Gated on the Dynamic Form Field module: free/default installs keep their
1546 + // existing validation untouched. Mirrors the frontend's required rules so
1547 + // a crafted request can't bypass them; respects the operator's config
1548 + // (only enabled+required fields in enabled sections are checked).
1549 + if (function_exists('apply_filters') && apply_filters('yatra_dynamic_form_field_enabled', false)) {
1550 + $required_error = $this->validateRequiredFormFields(
1551 + is_array($form_config) ? $form_config : [],
1552 + $data,
1553 + $travelers,
1554 + $contact_enabled,
1555 + $traveler_enabled
1556 + );
1557 + if ($required_error !== null) {
1558 + return new WP_REST_Response([
1559 + 'success' => false,
1560 + 'message' => $required_error,
1561 + ], 400);
1562 + }
858 1563 }
859 1564
860 1565 // Get trip data
861 1566 $trip = $this->tripRepository->findPublished($trip_id);
@@ -919,22 +1624,91 @@
919 1624 $traveler_counts['default'] = $travelers_count;
920 1625 }
921 1626 }
922 1627
923 - // Use CalculationService as single source of truth
1628 + // Pricing single source of truth: ALWAYS use calculateFromSession.
1629 + //
1630 + // The booking session is kept in sync by the JS layer — every
1631 + // service toggle, traveler-count change, and coupon apply/remove
1632 + // POSTs to /booking/session, which updates the transient. The
1633 + // sidebar's `/booking/summary` then renders from
1634 + // `calculateFromSession($session, …)`. If we built a second pricing
1635 + // path here from form fields, any subtle drift (form missing a
1636 + // category_id, traveler_counts aggregated as 'default', etc) would
1637 + // produce a different total than the customer saw — they'd be
1638 + // charged something they didn't agree to. So we merge the latest
1639 + // session with any form-submitted overrides (services list,
1640 + // travel_date, availability_id) and let calculateFromSession do
1641 + // the math the same way the sidebar did.
924 1642 $calculationService = new CalculationService();
925 - $pricing = $calculationService->calculatePricing([
926 - 'trip_id' => $trip_id,
927 - 'travelers_count' => $travelers_count,
928 - 'traveler_counts' => $traveler_counts,
929 - 'travel_date' => $travel_date,
930 - 'departure_time' => $departure_time,
931 - 'selected_services' => $additional_services,
932 - 'availability_id' => $availability_id ? (int) $availability_id : null,
933 - 'coupon_code' => $coupon_code,
934 - 'payment_method' => $payment_method,
935 - ]);
936 -
1643 + $session_for_pricing = is_array($session) ? $session : [];
1644 + $session_for_pricing['trip_id'] = $trip_id;
1645 + $session_for_pricing['travelers'] = $travelers_count;
1646 + // Prefer per-category counts when we have them — otherwise let
1647 + // calculateFromSession fall back to its internal handling.
1648 + if (!empty($traveler_counts) && is_array($traveler_counts)) {
1649 + $session_for_pricing['traveler_counts'] = $traveler_counts;
1650 + }
1651 + if (!empty($travel_date)) {
1652 + $session_for_pricing['travel_date'] = $travel_date;
1653 + }
1654 + if (!empty($departure_time)) {
1655 + $session_for_pricing['departure_time'] = $departure_time;
1656 + }
1657 + if (!empty($availability_id) && is_numeric($availability_id)) {
1658 + $session_for_pricing['availability_id'] = (int) $availability_id;
1659 + }
1660 + if (is_array($additional_services)) {
1661 + $session_for_pricing['additional_services'] = array_values(array_map('intval', $additional_services));
1662 + }
1663 +
1664 + try {
1665 + $pricing = $calculationService->calculateFromSession(
1666 + $session_for_pricing,
1667 + $coupon_code,
1668 + $payment_method
1669 + );
1670 + } catch (\Throwable $e) {
1671 + $pricing = [];
1672 + }
1673 +
1674 + // If the session is so degenerate that even calculateFromSession
1675 + // couldn't make a positive total (e.g. no traveler_counts at all),
1676 + // surface that cleanly so the booking is rejected with a friendly
1677 + // error rather than silently charging $0.
1678 + if (((float) ($pricing['final_total'] ?? 0)) <= 0) {
1679 + try {
1680 + $sessionPricing = $calculationService->calculatePricing([
1681 + 'trip_id' => $trip_id,
1682 + 'travelers_count' => $travelers_count,
1683 + 'traveler_counts' => $traveler_counts,
1684 + 'travel_date' => $travel_date,
1685 + 'departure_time' => $departure_time,
1686 + 'selected_services' => $additional_services,
1687 + 'availability_id' => (!empty($availability_id) && is_numeric($availability_id)) ? (int) $availability_id : null,
1688 + 'coupon_code' => $coupon_code,
1689 + 'payment_method' => $payment_method,
1690 + ]);
1691 + if (((float) ($sessionPricing['final_total'] ?? 0)) > 0) {
1692 + $pricing = $sessionPricing;
1693 + }
1694 + } catch (\Throwable $e) {
1695 + // Keep $pricing as-is; downstream guard will surface a clean error.
1696 + }
1697 + }
1698 +
1699 + // Enforce per-group category size limits (min_pax / max_pax). A per-group
1700 + // category charges one flat price for a group within the configured
1701 + // range, so a selection outside that range must be rejected before we
1702 + // charge. No-op for per-person categories and categories with no limits.
1703 + $group_size_error = $this->validateGroupSizeLimits($pricing['price_types'] ?? [], $traveler_counts);
1704 + if ($group_size_error !== null) {
1705 + return new WP_REST_Response([
1706 + 'success' => false,
1707 + 'message' => $group_size_error,
1708 + ], 400);
1709 + }
1710 +
937 1711 // Extract pricing results
938 1712 $total_amount = $pricing['final_total'];
939 1713 $amount_due = $pricing['amount_due'];
940 1714 $amount_paid = $pricing['amount_paid'];
@@ -968,9 +1742,9 @@
968 1742 $isWaitlistCheckout = false;
969 1743
970 1744 if ($resolvedAvailabilityForWaitlist !== null) {
971 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
972 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
973 1747 return new WP_REST_Response([
974 1748 'success' => false,
975 1749 'message' => __('This departure is not open for booking.', 'yatra'),
976 1750 'code' => 'date_blocked',
@@ -1017,10 +1791,10 @@
1017 1791 'payment_method' => $payment_method,
1018 1792 'payment_gateway' => $payment_gateway,
1019 1793 'total_amount' => round((float) $total_amount, 4),
1020 1794 'amount_due' => round((float) $amount_due, 4),
1021 - 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20),
1022 - 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30),
1795 + 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20, ['trip_id' => $trip_id]),
1796 + 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30, ['trip_id' => $trip_id]),
1023 1797 ]);
1024 1798
1025 1799 // Prepare contact data
1026 1800 $contact_data = [
@@ -1031,9 +1805,37 @@
1031 1805 'country' => sanitize_text_field($contact_country),
1032 1806 'nationality' => sanitize_text_field($contact_nationality),
1033 1807 'address' => sanitize_text_field($contact_address),
1034 1808 ];
1035 -
1809 + // Persist every submitted contact_* field (incl. CUSTOM fields the
1810 + // operator added to the form) so the data isn't lost and is usable as
1811 + // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1812 + foreach ($data as $field_key => $field_value) {
1813 + if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1814 + $field_id = substr($field_key, strlen('contact_'));
1815 + if ($field_id === '' || $field_id === 'data') {
1816 + continue;
1817 + }
1818 + // A phone widget's `<field>_country` companion is folded into the
1819 + // phone value below, not stored as its own field.
1820 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1821 + continue;
1822 + }
1823 + if (isset($contact_data[$field_id])) {
1824 + continue;
1825 + }
1826 + $field_string = (string) $field_value;
1827 + // Custom phone field: combine national number + country companion.
1828 + if (isset($data[$field_key . '_country'])) {
1829 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1830 + $field_string,
1831 + (string) $data[$field_key . '_country']
1832 + );
1833 + }
1834 + $contact_data[$field_id] = sanitize_text_field($field_string);
1835 + }
1836 + }
1837 +
1036 1838 // Prepare emergency contact data
1037 1839 $emergency_data = [
1038 1840 'name' => sanitize_text_field($emergency_name),
1039 1841 'phone' => sanitize_text_field($emergency_phone),
@@ -1038,8 +1840,31 @@
1038 1840 'name' => sanitize_text_field($emergency_name),
1039 1841 'phone' => sanitize_text_field($emergency_phone),
1040 1842 'relationship' => sanitize_text_field($emergency_relationship),
1041 1843 ];
1844 + // Same dynamic capture for emergency_* custom fields.
1845 + foreach ($data as $field_key => $field_value) {
1846 + if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1847 + $field_id = substr($field_key, strlen('emergency_'));
1848 + if ($field_id === '' || $field_id === 'contact') {
1849 + continue;
1850 + }
1851 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1852 + continue;
1853 + }
1854 + if (isset($emergency_data[$field_id])) {
1855 + continue;
1856 + }
1857 + $field_string = (string) $field_value;
1858 + if (isset($data[$field_key . '_country'])) {
1859 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1860 + $field_string,
1861 + (string) $data[$field_key . '_country']
1862 + );
1863 + }
1864 + $emergency_data[$field_id] = sanitize_text_field($field_string);
1865 + }
1866 + }
1042 1867
1043 1868 // Sanitize travelers data
1044 1869 $sanitized_travelers = [];
1045 1870 foreach ($travelers as $traveler) {
@@ -1046,8 +1871,13 @@
1046 1871 if (is_array($traveler)) {
1047 1872 $sanitized_traveler = [];
1048 1873 foreach ($traveler as $key => $value) {
1049 1874 $sk = sanitize_key((string) $key);
1875 + // Skip a phone widget's `<field>_country` companion; it is
1876 + // folded into the phone value in the pass below.
1877 + if (substr($sk, -8) === '_country' && isset($traveler[substr((string) $key, 0, -8)])) {
1878 + continue;
1879 + }
1050 1880 if (is_array($value)) {
1051 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1052 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1053 1883 }, $value);
@@ -1054,8 +1884,19 @@
1054 1884 } else {
1055 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1056 1886 }
1057 1887 }
1888 + // Combine each phone field with its country companion (national
1889 + // number + dial code → "+<dial><digits>").
1890 + foreach (array_keys($sanitized_traveler) as $tk) {
1891 + $companion = $tk . '_country';
1892 + if (isset($traveler[$companion]) && is_string($sanitized_traveler[$tk])) {
1893 + $sanitized_traveler[$tk] = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1894 + (string) $sanitized_traveler[$tk],
1895 + (string) $traveler[$companion]
1896 + );
1897 + }
1898 + }
1058 1899 $sanitized_travelers[] = $sanitized_traveler;
1059 1900 }
1060 1901 }
1061 1902
@@ -1240,12 +2081,48 @@
1240 2081
1241 2082 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1242 2083 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1243 2084 $booking_data['status'] = 'waitlist';
1244 - $booking_data['payment_gateway'] = 'pay_later';
1245 - $booking_data['payment_method'] = 'full';
2085 + // Preserve the customer's real OFFLINE gateway + deposit/partial
2086 + // choice (Bank Transfer / Pay Later). No charge is taken for a
2087 + // waitlisted slot regardless, and waitlist promotion only flips the
2088 + // status — it never restores the selection — so pinning to
2089 + // pay_later/full here would permanently drop the chosen gateway AND
2090 + // wipe the deposit (BookingService recomputes amount_due from
2091 + // payment_method). Online gateways stay deferred to pay_later/full
2092 + // since a card can't be charged for a non-guaranteed slot.
2093 + if (!$is_offline_gateway) {
2094 + $booking_data['payment_gateway'] = 'pay_later';
2095 + $booking_data['payment_method'] = 'full';
2096 + }
1246 2097 }
1247 2098
2099 + // Hold the booking in `pending_verification` until the guest
2100 + // clicks the magic link. Payment is initiated only after the
2101 + // status flips to 'pending' (in the verify-email endpoint).
2102 + // We also pin the gateway to `pay_later` here because the
2103 + // payment selection at this point would otherwise lock the
2104 + // operator into a specific gateway before the customer has
2105 + // even confirmed their email — better to defer that choice
2106 + // until verification completes and the regular checkout
2107 + // resumes.
2108 + if ($needs_email_verification && !$isWaitlistCheckout) {
2109 + $booking_data['status'] = 'pending_verification';
2110 + // Defer the gateway choice ONLY for online gateways: a real charge
2111 + // would otherwise lock the customer into a gateway before they have
2112 + // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2113 + // Later) there is no charge to defer, and the verify-email endpoint
2114 + // does not restore the selection afterwards — so pinning to
2115 + // pay_later/full here would permanently drop the customer's chosen
2116 + // gateway AND their deposit/partial amount (BookingService recomputes
2117 + // amount_due from payment_method, so 'full' wipes the deposit).
2118 + // Preserve the real selection for offline gateways.
2119 + if (!$is_offline_gateway) {
2120 + $booking_data['payment_gateway'] = 'pay_later';
2121 + $booking_data['payment_method'] = 'full';
2122 + }
2123 + }
2124 +
1248 2125 try {
1249 2126 $booking = $booking_service->createBooking($booking_data);
1250 2127 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1251 2128 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
@@ -1298,11 +2175,27 @@
1298 2175 * @param int $trip_id The trip ID
1299 2176 * @param array $data The booking request data (contains selected_services)
1300 2177 * @param int $travelers_count Total number of travelers
1301 2178 * @param int $duration_days Trip duration in days
2179 + * @param float $base_amount Trip base price (pre-services, pre-discount) —
2180 + * the authoritative base used by the pricing engine for this
2181 + * booking. Listeners persisting percentage-type services price
2182 + * them against this exact value so the saved line-items reconcile
2183 + * with the charged total. Added in a backward-compatible way:
2184 + * existing 5-arg listeners simply ignore it.
1302 2185 * @since 3.0.0
1303 2186 */
1304 - do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
2187 + // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
2188 + if (!isset($data['selected_services'])) {
2189 + $data['selected_services'] = $data['additional_services']
2190 + ?? $session['additional_services']
2191 + ?? [];
2192 + }
2193 + if (!is_array($data['selected_services'])) {
2194 + $data['selected_services'] = [];
2195 + }
2196 + $data['selected_services'] = array_map('intval', $data['selected_services']);
2197 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
1305 2198
1306 2199 // ========================================
1307 2200 // SAVE TRAVELLERS TO NORMALIZED TABLES
1308 2201 // ========================================
@@ -1372,8 +2265,117 @@
1372 2265
1373 2266 // Clear booking session
1374 2267 yatra_clear_booking_session();
1375 2268
2269 + // ========================================
2270 + // GUEST EMAIL VERIFICATION — INTERCEPT
2271 + // ========================================
2272 + // Booking row + travelers + services are already saved at
2273 + // this point with status='pending_verification'. Send the
2274 + // magic-link email, return a structured "check your email"
2275 + // response, and DO NOT initiate payment. The customer's
2276 + // click on the verify-email endpoint transitions the booking
2277 + // to 'pending' and emits the payment-continuation URL.
2278 + if ($needs_email_verification) {
2279 + $verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl(
2280 + (int) $booking_id,
2281 + (string) $contact_data['email']
2282 + );
2283 +
2284 + // Variables piped into the template email. All standard
2285 + // booking merge tags resolve normally (the row exists);
2286 + // we also pass intro_paragraph + footer_note + the
2287 + // expiry banner so operators that haven't customised
2288 + // the template still get good defaults.
2289 + $email_vars = [];
2290 + if ($saved_booking !== null) {
2291 + $email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
2292 + }
2293 + // Belt-and-braces: ensure customer name + email are
2294 + // populated even when variablesFromBooking returned an
2295 + // empty shell (which shouldn't happen, but if it does
2296 + // we don't want the email to render "Hi ,").
2297 + $email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']);
2298 + $email_vars['customer_name'] = (string) ($email_vars['customer_name']
2299 + ?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? '')));
2300 + $email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name']
2301 + ?? ($contact_data['first_name'] ?? ''));
2302 + $email_vars['verification_link'] = $verify_url;
2303 + $email_vars['intro_paragraph'] = __(
2304 + "Thanks for booking with us! To confirm this is really your email, please click the button below. Your booking is held for you in the meantime — payment isn't taken until you verify.",
2305 + 'yatra'
2306 + );
2307 + $email_vars['footer_note'] = __(
2308 + "If you didn't make this booking, you can safely ignore this email — no charges have been made.",
2309 + 'yatra'
2310 + );
2311 + $email_vars['expiry_notice_html'] = '<strong>'
2312 + . esc_html__('This link expires in 48 hours.', 'yatra')
2313 + . '</strong>';
2314 +
2315 + // Guest-checkout verification prefers the operator's CONFIGURED
2316 + // customer verification template so their customisation is honoured
2317 + // (the guest system template was consolidated away — using the guest
2318 + // type always fell back to the built-in default and ignored the
2319 + // configured one). This email MUST still carry the verification link
2320 + // — a guest can't complete the booking without it — so we only fall
2321 + // back to the built-in GUEST default when the effective customer
2322 + // template would omit {{verification_link}} (an operator can, and on
2323 + // real sites does, customise that template and drop the tag). The
2324 + // check respects Pro-owned DB templates too. Booking copy is injected
2325 + // above via intro_paragraph / footer_note / expiry merge vars.
2326 + //
2327 + // Keep the booking-specific SUBJECT line ("Verify your email to
2328 + // complete your booking") that guests saw before the guest template
2329 + // was consolidated away — reusing the customer template body must not
2330 + // drag along the account-oriented "Verify your email address"
2331 + // subject. This is honoured additively by the renderer / Pro sender
2332 + // via the reserved `_subject_override` var, so only this guest send
2333 + // is affected. Computed before it is stored, so the render below
2334 + // resolves the clean guest subject (no self-reference).
2335 + $email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render(
2336 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2337 + $email_vars
2338 + )['subject'];
2339 + $verificationEmailSent = false;
2340 + if (\Yatra\Services\TransactionalEmailTemplateService::templateRendersVerificationLink(
2341 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION
2342 + )) {
2343 + $verificationEmailSent = \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2344 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION,
2345 + (string) $contact_data['email'],
2346 + $email_vars
2347 + );
2348 + }
2349 + // Guarantee a verification email even if the customer template would
2350 + // drop the link OR its per-type toggle is disabled — a guest can't
2351 + // complete checkout without it. The built-in GUEST default always
2352 + // carries the link. sendIfEnabled() returns whether it actually sent,
2353 + // so this only fires when the preferred send did not (no double send).
2354 + if (!$verificationEmailSent) {
2355 + \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2356 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2357 + (string) $contact_data['email'],
2358 + $email_vars
2359 + );
2360 + }
2361 +
2362 + return new WP_REST_Response([
2363 + 'success' => true,
2364 + 'code' => 'email_verification_required',
2365 + 'message' => __(
2366 + "We've sent a verification email to your address. Click the link in that email to complete your booking — your spot is being held while you verify.",
2367 + 'yatra'
2368 + ),
2369 + 'data' => [
2370 + 'booking_id' => $booking_id,
2371 + 'reference' => $booking_reference,
2372 + 'email' => $contact_data['email'],
2373 + 'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600),
2374 + ],
2375 + ]);
2376 + }
2377 +
1376 2378 // Check if this is an offline gateway
1377 2379 $is_offline = $is_offline_gateway;
1378 2380
1379 2381 // For online gateways, create payment intent and return redirect URL
@@ -1442,21 +2444,29 @@
1442 2444
1443 2445 // ========================================
1444 2446 // DETERMINE BOOKING STATUS
1445 2447 // ========================================
1446 - // Priority:
1447 - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
1448 - // 2. For pay_later: auto_confirm_pay_later setting
1449 - // 3. For bank_transfer: always pending until verified
1450 -
2448 + // Priority (Auto-Confirm mode: none | online | all):
2449 + // - 'all' → confirm every booking here at checkout.
2450 + // - 'online' → confirm nothing at checkout; only a successful online
2451 + // gateway payment confirms later (offline stays pending).
2452 + // - 'none' → per-method: pay_later uses auto_confirm_pay_later,
2453 + // bank_transfer stays pending, everything else pending.
2454 +
1451 2455 $booking_status = 'pending';
1452 2456 $status_message = __('Booking received!', 'yatra');
1453 -
1454 - // Check if auto-confirm all bookings is enabled
1455 - if ($settings['auto_confirm_bookings']) {
1456 - // Auto-confirm is enabled - confirm immediately regardless of payment
2457 +
2458 + $auto_confirm_mode = $settings['auto_confirm_mode'] ?? 'none';
2459 + if ($auto_confirm_mode === 'all') {
2460 + // Confirm every booking immediately, regardless of payment.
1457 2461 $booking_status = 'confirmed';
1458 2462 $status_message = __('Booking confirmed!', 'yatra');
2463 + } elseif ($auto_confirm_mode === 'online') {
2464 + // Only successful online payments auto-confirm (at payment
2465 + // completion). Leave the booking pending at checkout; offline
2466 + // methods (bank transfer, pay-later) stay pending for the operator.
2467 + $booking_status = 'pending';
2468 + $status_message = __('Booking received!', 'yatra');
1459 2469 } elseif ($payment_gateway === 'pay_later') {
1460 2470 // Pay Later: Check the specific pay_later auto-confirm setting
1461 2471 if ($settings['auto_confirm_pay_later']) {
1462 2472 $booking_status = 'confirmed';
@@ -1508,8 +2518,10 @@
1508 2518 if ($booking_status === 'confirmed') {
1509 2519 if (!apply_filters('yatra_skip_checkout_autoconfirm_status_changed_event', true, (int) $booking_id, 'pending', 'confirmed')) {
1510 2520 do_action('yatra_booking_status_changed', (int) $booking_id, 'pending', 'confirmed');
1511 2521 }
2522 + // Trip Consent / Google Calendar listen on `yatra_booking_confirmed` (not fired by status_changed skip above).
2523 + \yatra_trigger_booking_confirmed((int) $booking_id, 'pending');
1512 2524 }
1513 2525
1514 2526 // ========================================
1515 2527 // SEND CONFIRMATION EMAIL
@@ -1524,10 +2536,8 @@
1524 2536 'payment_gateway' => $payment_gateway,
1525 2537 'total_amount' => $total_amount,
1526 2538 'amount_due' => $amount_due,
1527 2539 'booking_status' => $booking_status,
1528 - 'cancellation_policy' => $settings['cancellation_policy'],
1529 - 'cancellation_days' => $settings['cancellation_days'],
1530 2540 'expiry_datetime' => $expiry_datetime,
1531 2541 ]);
1532 2542 }
1533 2543
@@ -1675,11 +2685,44 @@
1675 2685 }
1676 2686
1677 2687 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1678 2688
2689 + // Server-side guard: offline gateways (Pay Later, Bank Transfer, etc.) don't
2690 + // actually collect money. Letting them be selected for a remaining-balance
2691 + // payment leaves the booking unpaid while the customer thinks they finished
2692 + // the flow. The frontend already filters them out for this checkout — this
2693 + // catches a tampered client. Filterable so a custom Pay Later that does
2694 + // settle can opt back in.
2695 + $allow_offline_for_remaining = (bool) apply_filters(
2696 + 'yatra_remaining_payment_allow_offline_gateway',
2697 + false,
2698 + $payment_gateway,
2699 + $booking
2700 + );
2701 + if ($is_offline_gateway && !$allow_offline_for_remaining) {
2702 + return new WP_REST_Response([
2703 + 'success' => false,
2704 + 'message' => __('This payment method cannot be used to settle a remaining balance. Please choose a card-based gateway.', 'yatra'),
2705 + 'data' => [
2706 + 'rejected_gateway' => $payment_gateway,
2707 + 'reason' => 'offline_not_allowed_for_remaining_payment',
2708 + ],
2709 + ], 400);
2710 + }
2711 +
1679 2712 // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.).
1680 2713 // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely.
1681 2714 if (!$is_offline_gateway && $remaining_amount > 0) {
2715 + // The gateway needs an explicit return URL with the `balance=paid` flag so the
2716 + // confirmation page can show "balance just paid" content. Without setting it
2717 + // here, processPaymentWithGateway() would fall back to a plain confirmation URL
2718 + // and the customer would land on the generic post-booking template.
2719 + $remaining_return_url = add_query_arg(
2720 + 'balance',
2721 + 'paid',
2722 + $this->getConfirmationUrl($booking_reference)
2723 + );
2724 +
1682 2725 $payment_params = array_merge($data, [
1683 2726 'booking_id' => $booking_id,
1684 2727 'reference' => $booking_reference,
1685 2728 'amount' => $remaining_amount,
@@ -1686,23 +2729,44 @@
1686 2729 'currency' => $currency,
1687 2730 'customer_email' => $customer_email,
1688 2731 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email,
1689 2732 'trip_title' => $trip_title,
2733 + 'return_url' => $remaining_return_url,
1690 2734 ]);
1691 2735
1692 2736 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
1693 2737
1694 2738 if (!empty($payment_result['success'])) {
2739 + // Common identity fields the Stripe.js / PayPal SDK frontend expects on
2740 + // the response. process_remaining_payment is reached without going through
2741 + // the new-booking checkout, so the gateway result alone is missing
2742 + // customer_email / customer_name — re-attach them from the booking we
2743 + // already loaded above. Also overwrite confirmation_url AND redirect_url
2744 + // with the balance-tagged version so the post-payment landing page knows
2745 + // this was a remaining-balance flow.
2746 + //
2747 + // Why both fields: the Stripe.js helper buildConfirmationUrlFromBookingInfo()
2748 + // prefers bookingInfo.redirect_url and only falls back to rebuilding the URL
2749 + // from scratch (without query params) when redirect_url is absent. Without
2750 + // redirect_url being explicitly set here the `?balance=paid` flag would be
2751 + // lost on the final navigation after Stripe success.
2752 + $remaining_identity_fields = [
2753 + 'customer_email' => $customer_email,
2754 + 'customer_name' => $customer_name,
2755 + 'confirmation_url' => $remaining_return_url,
2756 + 'redirect_url' => $remaining_return_url,
2757 + ];
2758 +
1695 2759 if (!empty($payment_result['payment_url'])) {
1696 2760 return new WP_REST_Response([
1697 2761 'success' => true,
1698 2762 'message' => __('Redirecting to payment...', 'yatra'),
1699 - 'data' => [
2763 + 'data' => array_merge([
1700 2764 'booking_id' => $booking_id,
1701 2765 'reference' => $booking_reference,
1702 2766 'payment_url' => $payment_result['payment_url'],
1703 2767 'is_remaining_payment' => true,
1704 - ],
2768 + ], $remaining_identity_fields),
1705 2769 ]);
1706 2770 }
1707 2771
1708 2772 if (!empty($payment_result['requires_action'])) {
@@ -1714,9 +2778,10 @@
1714 2778 'booking_id' => $booking_id,
1715 2779 'reference' => $booking_reference,
1716 2780 'is_remaining_payment' => true,
1717 2781 ],
1718 - $payment_result
2782 + $payment_result,
2783 + $remaining_identity_fields
1719 2784 ),
1720 2785 ]);
1721 2786 }
1722 2787
@@ -1723,14 +2788,14 @@
1723 2788 if (!empty($payment_result['redirect_url'])) {
1724 2789 return new WP_REST_Response([
1725 2790 'success' => true,
1726 2791 'message' => __('Payment processed.', 'yatra'),
1727 - 'data' => [
2792 + 'data' => array_merge([
1728 2793 'booking_id' => $booking_id,
1729 2794 'reference' => $booking_reference,
1730 2795 'redirect_url' => $payment_result['redirect_url'],
1731 2796 'is_remaining_payment' => true,
1732 - ],
2797 + ], $remaining_identity_fields),
1733 2798 ]);
1734 2799 }
1735 2800 }
1736 2801
@@ -1745,11 +2810,20 @@
1745 2810 ],
1746 2811 ], 400);
1747 2812 }
1748 2813
1749 - // Offline gateways: no external redirect — confirmation page only
2814 + // Offline gateways: no external redirect — confirmation page only.
2815 + // Append `balance=paid` so the confirmation template renders the
2816 + // remaining-payment-specific banner ("balance received, fully paid")
2817 + // instead of the generic "booking confirmed" copy.
1750 2818 yatra_clear_remaining_session();
1751 2819
2820 + $offline_redirect = add_query_arg(
2821 + 'balance',
2822 + 'paid',
2823 + $this->getConfirmationUrl($booking_reference)
2824 + );
2825 +
1752 2826 return new WP_REST_Response([
1753 2827 'success' => true,
1754 2828 'message' => __('Continue to confirmation.', 'yatra'),
1755 2829 'data' => [
@@ -1761,9 +2835,9 @@
1761 2835 'currency' => $currency,
1762 2836 'amount' => $remaining_amount,
1763 2837 'customer_email' => $customer_email,
1764 2838 'customer_name' => $customer_name,
1765 - 'redirect_url' => $this->getConfirmationUrl($booking_reference),
2839 + 'redirect_url' => $offline_redirect,
1766 2840 'is_remaining_payment' => true,
1767 2841 ],
1768 2842 ]);
1769 2843 }
@@ -1812,11 +2886,15 @@
1812 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
1813 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
1814 2888 // may still append their own query args on top of this URL.
1815 2889 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2890 + // Cancel returns must land on the booking-confirmation page (always resolvable);
2891 + // `home_url('/book/?...')` 404s under a custom booking base/page. Use the reference,
2892 + // falling back to the booking id so the confirmation route always has a token.
2893 + $cancelRef = $ref !== '' ? $ref : (string) ($params['booking_id'] ?? '');
1816 2894 $paymentData = array_merge($params, [
1817 2895 'description' => $params['trip_title'] ?? '',
1818 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
1819 2897 'metadata' => [
1820 2898 'booking_id' => $params['booking_id'],
1821 2899 'reference' => $params['reference'] ?? ''
1822 2900 ]
@@ -1865,10 +2943,12 @@
1865 2943 ];
1866 2944 }
1867 2945
1868 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
1869 2949 return [
1870 - 'success' => true,
2950 + 'success' => true,
1871 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
1872 2952 ];
1873 2953 }
1874 2954
@@ -1893,8 +2973,75 @@
1893 2973 /**
1894 2974 * Record payment from gateway result
1895 2975 * Matches Stripe's completePayment behavior
1896 2976 */
2977 + /**
2978 + * Record the awaited payment for an offline gateway (bank transfer, cash on
2979 + * arrival, pay later) as a PENDING ledger row.
2980 + *
2981 + * These gateways take no money at checkout, and previously wrote no payment
2982 + * row at all — so when the transfer finally landed there was nothing in the
2983 + * Payments screen for the operator to mark as received. The booking's own
2984 + * fields were the only record, and marking those by hand left the invoice
2985 + * reporting "Payment Pending" with nothing paid.
2986 + *
2987 + * The row is deliberately `pending`: no money has arrived yet, and
2988 + * getTotalPaidForBooking() counts only `completed`, so booking financials and
2989 + * every report are untouched until the operator confirms it.
2990 + */
2991 + private function recordOfflinePendingPayment(array $params, array $result, string $gatewayId): void
2992 + {
2993 + try {
2994 + $bookingId = (int) ($params['booking_id'] ?? 0);
2995 + $amount = (float) ($params['amount'] ?? 0);
2996 +
2997 + if ($bookingId <= 0 || $amount <= 0) {
2998 + return;
2999 + }
3000 +
3001 + // Only for gateways that settle out of band. Anything reporting a
3002 + // completed/succeeded status already records its own row.
3003 + $status = strtolower((string) ($result['status'] ?? ''));
3004 + if (!in_array($status, ['', 'pending', 'pending_verification'], true)) {
3005 + return;
3006 + }
3007 +
3008 + $booking = $this->bookingRepository->find($bookingId);
3009 + if (!$booking || ($booking->payment_status ?? '') === 'paid') {
3010 + return;
3011 + }
3012 +
3013 + $paymentRepository = new \Yatra\Repositories\PaymentRepository();
3014 +
3015 + // Idempotency: a retried checkout must not stack up duplicate rows.
3016 + foreach ($paymentRepository->findByBookingId($bookingId) as $existing) {
3017 + if ((string) ($existing->gateway ?? '') === $gatewayId
3018 + && in_array((string) ($existing->status ?? ''), ['pending', 'completed'], true)
3019 + ) {
3020 + return;
3021 + }
3022 + }
3023 +
3024 + $paymentRepository->create([
3025 + 'booking_id' => $bookingId,
3026 + 'amount' => $amount,
3027 + 'currency' => $params['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3028 + 'gateway' => $gatewayId,
3029 + 'status' => 'pending',
3030 + 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
3031 + 'notes' => __('Awaiting payment — mark as completed once received.', 'yatra'),
3032 + 'created_at' => current_time('mysql'),
3033 + ]);
3034 + } catch (\Throwable $e) {
3035 + // Never break a successful checkout over a bookkeeping row.
3036 + \Yatra\Utils\Logger::warning('Could not record pending offline payment', [
3037 + 'booking_id' => $params['booking_id'] ?? 0,
3038 + 'gateway' => $gatewayId,
3039 + 'error' => $e->getMessage(),
3040 + ]);
3041 + }
3042 + }
3043 +
1897 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
1898 3045 {
1899 3046 global $wpdb;
1900 3047
@@ -1902,18 +3049,39 @@
1902 3049 $bookingId = (int) $params['booking_id'];
1903 3050 $amount = (float) ($params['amount'] ?? 0);
1904 3051 $currency = $params['currency'] ?? 'USD';
1905 3052 $transactionId = $result['transaction_id'] ?? '';
1906 -
3053 +
1907 3054 // Get booking
1908 3055 $booking = $this->bookingRepository->find($bookingId);
1909 - if (!$booking || $booking->payment_status === 'paid') {
3056 + if (!$booking) {
1910 3057 return;
1911 3058 }
1912 -
1913 - // Record the payment using PaymentRepository
3059 +
3060 + // Already settled in full — never apply another charge to it. A fresh
3061 + // booking is never already paid, so in practice this only guards a
3062 + // stray/duplicate completion call (with a different transaction id)
3063 + // against over-applying the ledger.
3064 + if (($booking->payment_status ?? '') === 'paid') {
3065 + return;
3066 + }
3067 +
1914 3068 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1915 - $payment_id = $paymentRepository->create([
3069 +
3070 + // Idempotency guard: skip if this gateway transaction is already
3071 + // recorded for this booking. Prevents duplicate ledger rows when a
3072 + // payment is submitted twice (the gateway uses a fresh idempotency
3073 + // key per call, so it won't dedupe a true retry). Mirrors
3074 + // PaymentGatewayController::handle_successful_payment().
3075 + if ($transactionId !== '') {
3076 + $existing = $paymentRepository->findByTransactionId($transactionId);
3077 + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
3078 + return;
3079 + }
3080 + }
3081 +
3082 + // Record the payment
3083 + $paymentRepository->create([
1916 3084 'booking_id' => $bookingId,
1917 3085 'amount' => $amount,
1918 3086 'currency' => $currency,
1919 3087 'gateway' => $gatewayId,
@@ -1918,14 +3086,43 @@
1918 3086 'currency' => $currency,
1919 3087 'gateway' => $gatewayId,
1920 3088 'transaction_id' => $transactionId,
1921 3089 'status' => 'completed',
3090 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
1922 3091 'created_at' => current_time('mysql'),
1923 3092 ]);
1924 -
1925 - // Calculate total paid
1926 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1927 -
3093 +
3094 + // Update the booking ledger + status. The synchronous gateways
3095 + // (Square, Authorize.Net) reach this generic path but previously left
3096 + // the booking at pending/pending — only the payment row was written.
3097 + // This now matches handle_successful_payment(): accumulate amount_paid,
3098 + // recompute amount_due, set payment_status (paid vs partial), and
3099 + // confirm the booking only when "Auto-Confirm Bookings" is on
3100 + // (consistent with every gateway).
3101 + $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
3102 + $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
3103 + $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
3104 + $previousStatus = (string) ($booking->status ?? 'pending');
3105 +
3106 + // Only auto-confirm when "Auto-Confirm Bookings" is on; otherwise the
3107 + // booking stays pending for the operator to confirm manually,
3108 + // regardless of a successful (full or partial) payment.
3109 + $shouldConfirm = \yatra_should_confirm_booking_on_payment($newAmountDue <= 0.0, $bookingId);
3110 +
3111 + $bookingUpdate = [
3112 + 'amount_paid' => $newAmountPaid,
3113 + 'amount_due' => $newAmountDue,
3114 + 'payment_status' => $paymentStatus,
3115 + ];
3116 + if ($shouldConfirm) {
3117 + $bookingUpdate['status'] = 'confirmed';
3118 + }
3119 + $this->bookingRepository->update($bookingId, $bookingUpdate);
3120 +
3121 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
3122 + \yatra_trigger_booking_confirmed($bookingId, $previousStatus, true);
3123 + }
3124 +
1928 3125 // Fire payment completed action
1929 3126 do_action('yatra_payment_completed', [
1930 3127 'booking_id' => $bookingId,
1931 3128 'transaction_id' => $transactionId,
@@ -1932,11 +3129,12 @@
1932 3129 'amount' => $amount,
1933 3130 'currency' => $currency,
1934 3131 'gateway' => $gatewayId,
1935 3132 ]);
1936 -
1937 - } catch (\Exception $e) {
1938 - }
3133 + } catch (\Throwable $e) {
3134 + // Best-effort: the charge is already recorded; a confirmation-page
3135 + // reload / status reconciliation can recover if this update fails.
3136 + }
1939 3137 }
1940 3138
1941 3139 /**
1942 3140 * Process PayPal payment
@@ -1990,9 +3188,9 @@
1990 3188 'description' => $params['trip_title'],
1991 3189 ]],
1992 3190 'application_context' => [
1993 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
1994 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
1995 3193 ],
1996 3194 ]),
1997 3195 ]);
1998 3196
@@ -2111,9 +3309,12 @@
2111 3309 'su' => add_query_arg(
2112 3310 ['payment' => 'success', 'gateway' => 'esewa'],
2113 3311 $this->getConfirmationUrl($params['reference'])
2114 3312 ),
2115 - 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
3313 + 'fu' => add_query_arg(
3314 + ['payment' => 'failed', 'gateway' => 'esewa'],
3315 + $this->getConfirmationUrl($params['reference'])
3316 + ),
2116 3317 ], $base_url);
2117 3318
2118 3319 return ['success' => true, 'payment_url' => $payment_url];
2119 3320 }
@@ -2226,10 +3427,16 @@
2226 3427 $amount_due = $data['amount_due'] ?? 0;
2227 3428 $payment_method = $data['payment_method'] ?? 'full';
2228 3429 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2229 3430 $booking_status = $data['booking_status'] ?? 'pending';
2230 - $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund';
2231 - $cancellation_days = $data['cancellation_days'] ?? 7;
3431 + // Cancellation copy in the email now comes from the trip's
3432 + // own cancellation_policy field (set per-trip on the Trip
3433 + // editor), not from removed global settings. Falls back to
3434 + // empty so the paragraph is silently omitted when the trip
3435 + // doesn't have a policy set.
3436 + $trip_cancellation_policy = isset($trip->cancellation_policy)
3437 + ? wp_strip_all_tags((string) $trip->cancellation_policy)
3438 + : '';
2232 3439 $expiry_datetime = $data['expiry_datetime'] ?? null;
2233 3440
2234 3441 $customer_email = $contact['email'] ?? '';
2235 3442 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
@@ -2246,8 +3453,9 @@
2246 3453 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2247 3454 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2248 3455 if ($booking_status === 'pending' && $expiry_datetime) {
2249 3456 $intro_paragraph .= ' ' . sprintf(
3457 + /* translators: %s: payment expiry date and time (formatted). */
2250 3458 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2251 3459 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2252 3460 );
2253 3461 }
@@ -2257,17 +3465,21 @@
2257 3465 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2258 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2259 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2260 3468 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Travel date', 'yatra'); ?>:</strong> <?php echo esc_html(date_i18n(get_option('date_format'), strtotime($travel_date))); ?></p>
2261 - <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php echo esc_html(sprintf(__('%d days / %d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
3469 + <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */
3470 +echo esc_html(yatra_format_duration((int) $trip->duration_days, (int) $trip->duration_nights, (int) ($trip->duration_hours ?? 0))); ?></p>
2262 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2263 3472 </div>
2264 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2265 - <p><?php echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
3474 + <p><?php /* translators: %s: total amount (formatted). */
3475 +echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
2266 3476 <?php if ($payment_method === 'deposit') : ?>
2267 - <p><?php echo esc_html(sprintf(__('Payment type: Deposit — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
3477 + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3478 +echo esc_html(sprintf(__('Payment type: Deposit — due now %1$s, remaining %2$s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
2268 3479 <?php elseif ($payment_method === 'partial') : ?>
2269 - <p><?php echo esc_html(sprintf(__('Payment type: Partial — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
3480 + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3481 +echo esc_html(sprintf(__('Payment type: Partial — due now %1$s, remaining %2$s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
2270 3482 <?php else : ?>
2271 3483 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2272 3484 <?php endif; ?>
2273 3485 <?php if ($payment_gateway === 'pay_later') : ?>
@@ -2280,26 +3492,26 @@
2280 3492 <?php foreach ($travelers as $i => $traveler) : ?>
2281 3493 <?php
2282 3494 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2283 3495 ?>
2284 - <li><?php echo esc_html(sprintf(__('Traveler %d: %s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li>
3496 + <li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */
3497 +echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li>
2285 3498 <?php endforeach; ?>
2286 3499 </ul>
2287 3500 <?php
2288 - $cancellation_policy_labels = [
2289 - 'full_refund' => __('Full refund available', 'yatra'),
2290 - 'partial_refund' => __('Partial refund available', 'yatra'),
2291 - 'no_refund' => __('No refund available', 'yatra'),
2292 - 'flexible' => __('Flexible cancellation', 'yatra'),
2293 - ];
2294 - $policy_label = $cancellation_policy_labels[$cancellation_policy] ?? __('Standard policy applies', 'yatra');
2295 - ?>
2296 - <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
2297 - <p><?php echo esc_html($policy_label); ?> — <?php echo esc_html(sprintf(__('free cancellation up to %d days before departure', 'yatra'), (int) $cancellation_days)); ?></p>
2298 - <?php
2299 - $custom_refund_policy = SettingsService::getString('refund_policy', '');
2300 - if ($custom_refund_policy !== '') {
2301 - echo '<p>' . esc_html($custom_refund_policy) . '</p>';
3501 + // Cancellation policy paragraph now sources from the trip's
3502 + // per-trip cancellation_policy field (set on the Trip
3503 + // editor). The previous version used global cancellation
3504 + // settings that were display-only — they appeared here but
3505 + // never enforced a real cancellation cutoff. We've removed
3506 + // those settings; if the trip itself doesn't define a
3507 + // policy, the whole section is silently omitted so the
3508 + // email isn't padded with empty headings.
3509 + if ($trip_cancellation_policy !== '') {
3510 + ?>
3511 + <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
3512 + <p><?php echo esc_html($trip_cancellation_policy); ?></p>
3513 + <?php
2302 3514 }
2303 3515 ?>
2304 3516 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2305 3517 <ol style="padding-left:20px;">
@@ -2311,9 +3523,25 @@
2311 3523 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2312 3524 <?php
2313 3525 $details_html = ob_get_clean();
2314 3526
2315 - $vars = [
3527 + // Seed from the canonical booking variables FIRST so every dynamic
3528 + // merge tag — {{contact_*}} / {{emergency_*}} custom fields,
3529 + // {{traveler_custom_fields_html}}, {{balance_due}}, payment/schedule
3530 + // tags, etc. — resolves on this offline / pay-later path exactly like
3531 + // the online-gateway path (BookingService::sendBookingConfirmationEmail).
3532 + // Previously this method hand-built only ~18 core keys, so an operator
3533 + // who customised the Booking Confirmation template with a custom-field
3534 + // variable saw it render empty on offline bookings. The hand-built keys
3535 + // below (the self-rendered details_html, intro, footer) intentionally
3536 + // take precedence via array_merge ordering.
3537 + $base_vars = [];
3538 + $saved_booking = $this->bookingRepository->find($booking_id);
3539 + if ($saved_booking) {
3540 + $base_vars = TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
3541 + }
3542 +
3543 + $vars = array_merge($base_vars, [
2316 3544 'customer_name' => $customer_name,
2317 3545 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2318 3546 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2319 3547 'customer_email' => $customer_email,
@@ -2329,11 +3557,12 @@
2329 3557 'currency' => SettingsService::getCurrency(),
2330 3558 'intro_paragraph' => $intro_paragraph,
2331 3559 'details_html' => $details_html,
2332 3560 'details_html_only' => '1',
3561 + /* translators: %s: site name. */
2333 3562 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2334 3563 'transactional_context' => 'booking_created',
2335 - ];
3564 + ]);
2336 3565
2337 3566 TransactionalEmailTemplateService::sendIfEnabled(
2338 3567 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2339 3568 $customer_email,
@@ -2349,12 +3578,18 @@
2349 3578 */
2350 3579 public function apply_coupon(WP_REST_Request $request): WP_REST_Response
2351 3580 {
2352 3581 yatra_start_session();
2353 -
2354 - $data = $request->get_json_params();
3582 +
3583 + $data = $request->get_json_params() ?? [];
3584 +
3585 + // M-2: restore CSRF protection stripped by public_permission_callback.
3586 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3587 + return $blocked;
3588 + }
3589 +
2355 3590 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2356 -
3591 +
2357 3592 if (empty($code)) {
2358 3593 return new WP_REST_Response([
2359 3594 'success' => false,
2360 3595 'message' => __('Please enter a coupon code.', 'yatra'),
@@ -2359,12 +3594,32 @@
2359 3594 'success' => false,
2360 3595 'message' => __('Please enter a coupon code.', 'yatra'),
2361 3596 ], 400);
2362 3597 }
2363 -
2364 - // Get current session
3598 +
3599 + // Get current session — with token-rehydration fallback for REST
3600 + // contexts where PHPSESSID isn't propagated (same shape as the
3601 + // service-toggle / summary endpoints). Also writes back to $_SESSION
3602 + // so the subsequent `yatra_set_booking_session()` persists alongside
3603 + // the existing transient.
2365 3604 $session = yatra_get_booking_session();
2366 3605 if (empty($session) || empty($session['trip_id'])) {
3606 + $token = null;
3607 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
3608 + $token = sanitize_text_field((string) $data['booking_token']);
3609 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
3610 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
3611 + }
3612 + if ($token) {
3613 + $transient_data = get_transient($token);
3614 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
3615 + $session = $transient_data;
3616 + $_SESSION['yatra_booking'] = $session;
3617 + $_SESSION['yatra_booking_token'] = $token;
3618 + }
3619 + }
3620 + }
3621 + if (empty($session) || empty($session['trip_id'])) {
2367 3622 return new WP_REST_Response([
2368 3623 'success' => false,
2369 3624 'message' => __('No active booking session found.', 'yatra'),
2370 3625 ], 400);
@@ -2394,11 +3649,9 @@
2394 3649 }
2395 3650
2396 3651 $discount_amount = $coupon_result['calculated_amount'];
2397 3652
2398 - error_log('apply_coupon - Coupon result: ' . print_r($coupon_result, true));
2399 - error_log('apply_coupon - Original code: ' . $code);
2400 -
3653 +
2401 3654 // Store coupon in session (use the original $code variable, not from result)
2402 3655 $session['coupon'] = [
2403 3656 'code' => $code, // Use the actual code that was validated
2404 3657 'type' => $coupon_result['type'],
@@ -2409,10 +3662,9 @@
2409 3662 $session['timestamp'] = time();
2410 3663
2411 3664 yatra_set_booking_session($session);
2412 3665
2413 - error_log('apply_coupon - Session coupon stored: ' . print_r($session['coupon'], true));
2414 -
3666 +
2415 3667 return new WP_REST_Response([
2416 3668 'success' => true,
2417 3669 'message' => __('Coupon applied successfully!', 'yatra'),
2418 3670 'data' => [
@@ -2426,8 +3678,326 @@
2426 3678 ]);
2427 3679 }
2428 3680
2429 3681 /**
3682 + * Verify a guest's booking email via magic-link token.
3683 + *
3684 + * Flow:
3685 + * 1. Validate the HMAC token (forgery, expiry, email-binding).
3686 + * 2. Look up the booking; confirm it's in `pending_verification`.
3687 + * 3. Flip status to `pending` (or `confirmed` when auto-confirm
3688 + * pay-later is enabled for this site) and fire the standard
3689 + * yatra_booking_status_changed action so inventory + email
3690 + * automations resume normally.
3691 + * 4. 302 redirect the browser to a continuation URL:
3692 + * - If amount_due > 0: back to the booking page for the
3693 + * payment step the customer skipped earlier.
3694 + * - If amount_due == 0 / auto-confirm: to the booking
3695 + * confirmation/thank-you page.
3696 + * 5. On any failure, render a friendly HTML page (not JSON) so
3697 + * the customer sees readable text in their browser tab.
3698 + *
3699 + * @return WP_REST_Response|WP_Error|void
3700 + */
3701 + public function verify_email(WP_REST_Request $request)
3702 + {
3703 + $token = (string) $request->get_param('token');
3704 + $bookingRepo = new \Yatra\Repositories\BookingRepository();
3705 +
3706 + // First decode just to extract the booking id (for the
3707 + // expectedEmail lookup). Verify() is called again below
3708 + // with the actual email so the email-binding check runs.
3709 + $partsPreview = explode('.', $token);
3710 + $bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0]))
3711 + ? (int) $partsPreview[0]
3712 + : 0;
3713 + $booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null;
3714 + $expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : '';
3715 +
3716 + $result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail);
3717 +
3718 + if (!$result['ok']) {
3719 + $this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid'));
3720 + }
3721 + if ($booking === null) {
3722 + $this->renderVerifyEmailErrorPage('booking_not_found');
3723 + }
3724 +
3725 + // Re-entrant: if the booking has already been verified, show the
3726 + // same success page (idempotent) — pre-3.0.5 silently redirected
3727 + // and the customer was left wondering whether anything happened.
3728 + $currentStatus = (string) ($booking->status ?? '');
3729 + $alreadyVerified = $currentStatus !== 'pending_verification';
3730 +
3731 + if (!$alreadyVerified) {
3732 + // Flip status to 'pending' so downstream hooks (inventory /
3733 + // notification automations) see fresh data, then fire
3734 + // yatra_booking_created so the listeners we deferred at
3735 + // creation time (admin "new booking" notification + Pro
3736 + // email-automation booking.created fan-out) run now — i.e.
3737 + // *after* the customer has proven the email is theirs.
3738 + $bookingRepo->updateStatus((int) $booking->id, 'pending');
3739 + do_action('yatra_booking_email_verified', (int) $booking->id);
3740 +
3741 + // Re-fetch so the post-verification action receives the
3742 + // booking row with the new status, then fire the deferred
3743 + // booking-created action. See BookingService::createBooking()
3744 + // for the matching skip-on-pending-verification branch.
3745 + $verifiedBooking = $bookingRepo->find((int) $booking->id);
3746 + if (is_object($verifiedBooking)) {
3747 + do_action(
3748 + \Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED,
3749 + (int) $verifiedBooking->id,
3750 + $verifiedBooking
3751 + );
3752 + }
3753 +
3754 + // Guest email-verification defers the customer booking-confirmation
3755 + // email: the checkout flow returns at the verification gate, before
3756 + // its send-site (~line 2465), so the confirmation is never sent for a
3757 + // verified guest booking. Send it now that the email is proven and the
3758 + // booking is live — gated by the same `booking_confirmation` option the
3759 + // checkout paths use. Only in this fresh-verify branch, so a re-clicked
3760 + // link never re-sends. TYPE_BOOKING_CONFIRMATION is skipped by the Pro
3761 + // booking.created fan-out, so this is the single source of the email.
3762 + if ((bool) \Yatra\Services\SettingsService::get('booking_confirmation', true)) {
3763 + try {
3764 + (new \Yatra\Services\BookingService())->sendNewBookingTransactionalConfirmation((int) $booking->id);
3765 + } catch (\Throwable $e) {
3766 + // A mail failure must never break the customer's "verified" page.
3767 + Logger::error('Post-verification booking confirmation email failed', [
3768 + 'booking_id' => (int) $booking->id,
3769 + 'error' => $e->getMessage(),
3770 + ]);
3771 + }
3772 + }
3773 + }
3774 +
3775 + $this->renderVerifyEmailSuccessPage(
3776 + (int) $booking->id,
3777 + (string) ($booking->reference ?? ''),
3778 + $alreadyVerified
3779 + );
3780 + }
3781 +
3782 + /**
3783 + * Friendly HTML error page rendered when a verification link
3784 + * is invalid / expired / tampered with. Avoids JSON in the
3785 + * customer's browser tab (terrible UX). Reasons map to clear
3786 + * messages so customers know what to do next.
3787 + *
3788 + * Emits raw HTML and exits. We can't return WP_REST_Response with an
3789 + * HTML body because the REST server JSON-encodes the response data
3790 + * regardless of the Content-Type header on the response object —
3791 + * the customer would see `"<!doctype..."` (a JSON string) in their
3792 + * browser tab. Echoing + exiting short-circuits the REST pipeline.
3793 + *
3794 + * @return never
3795 + */
3796 + private function renderVerifyEmailErrorPage(string $reason): void
3797 + {
3798 + $messages = [
3799 + 'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'),
3800 + 'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'),
3801 + 'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'),
3802 + 'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'),
3803 + 'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'),
3804 + ];
3805 + $message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra');
3806 +
3807 + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3808 + $html = sprintf(
3809 + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3810 + . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3811 + . '<title>%2$s</title>'
3812 + . '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3813 + . '.box{max-width:480px;margin:60px auto;background:#fff;border-radius:12px;padding:32px;box-shadow:0 1px 3px rgba(0,0,0,.1);text-align:center}'
3814 + . 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}'
3815 + . 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>'
3816 + . '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>',
3817 + esc_attr(get_locale()),
3818 + esc_html__('Verification link issue', 'yatra'),
3819 + esc_html__('Verification link issue', 'yatra'),
3820 + esc_html($message),
3821 + esc_url(home_url('/')),
3822 + esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName))
3823 + );
3824 +
3825 + if (!headers_sent()) {
3826 + status_header(200);
3827 + nocache_headers();
3828 + header('Content-Type: text/html; charset=UTF-8');
3829 + }
3830 + echo $html;
3831 + exit;
3832 + }
3833 +
3834 + /**
3835 + * Build the booking continuation URL (post-verification destination).
3836 + *
3837 + * If the site has a Yatra Bookings page, route through that with the
3838 + * booking reference; otherwise fall back to the trip URL. Filterable
3839 + * via `yatra_guest_verification_continuation_url` so integrations can
3840 + * route to a custom thank-you page.
3841 + */
3842 + private function continuationUrl(int $bookingId, string $reference): string
3843 + {
3844 + return (string) apply_filters(
3845 + 'yatra_guest_verification_continuation_url',
3846 + add_query_arg(
3847 + ['booking_id' => $bookingId, 'verified' => '1'],
3848 + home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/')
3849 + ),
3850 + $bookingId,
3851 + $reference
3852 + );
3853 + }
3854 +
3855 + /**
3856 + * Friendly HTML success page rendered after the guest clicks the
3857 + * email-verification magic link.
3858 + *
3859 + * Pre-3.0.5 this endpoint silently 302-redirected to the booking page,
3860 + * which made guests believe nothing had happened — there was no visible
3861 + * "verified" feedback before they landed on the next step. This page
3862 + * gives them an unambiguous confirmation, the booking reference, and
3863 + * three explicit CTAs:
3864 + * - Continue to booking (primary, continuation URL)
3865 + * - My Account (when logged in) / Sign in (when not)
3866 + * - Go to homepage (fallback)
3867 + *
3868 + * Idempotent: when the booking was already verified (re-click on the
3869 + * same link), the heading + copy switch to the "already verified"
3870 + * variant but the CTAs stay the same.
3871 + *
3872 + * Emits raw HTML and exits — same reasoning as
3873 + * {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response
3874 + * JSON-encodes string bodies, so the customer would see
3875 + * `"<!doctype..."` in their tab instead of the rendered page.
3876 + *
3877 + * @return never
3878 + */
3879 + private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void
3880 + {
3881 + // Booking is already persisted at this point and (for a fresh verify)
3882 + // the status flip + booking-created fan-out have just fired. The
3883 + // verified UI's primary action is therefore "View your booking
3884 + // confirmation" — NOT "Continue Booking", which mislabelled the
3885 + // booking as still in-progress and confused customers into thinking
3886 + // they needed to re-submit the form.
3887 + $confirmationUrl = function_exists('yatra_get_booking_confirmation_url')
3888 + ? yatra_get_booking_confirmation_url($reference)
3889 + : $this->continuationUrl($bookingId, $reference);
3890 +
3891 + // Account / login URL — prefer Yatra's account page when present,
3892 + // fall back to wp_login_url() so the page never points nowhere.
3893 + $accountBase = \Yatra\Services\SettingsService::getAccountBase();
3894 + $accountUrl = $accountBase !== ''
3895 + ? home_url('/' . trim($accountBase, '/') . '/')
3896 + : home_url('/');
3897 + $isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in();
3898 + $secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl);
3899 + $secondaryLabel = $isLoggedIn
3900 + ? __('Go to My Account', 'yatra')
3901 + : __('Sign in', 'yatra');
3902 +
3903 + // Logged-in customers always get "Go to My Account". A guest is only
3904 + // offered "Sign in" when an account is genuinely part of the flow —
3905 + // registration is enabled AND guest checkout is not the operating mode.
3906 + // This is a guest email-verification page (guest checkout is normally
3907 + // on), so with guest checkout enabled OR registration disabled there is
3908 + // no account to sign into; the CTA is hidden rather than dangling to a
3909 + // login the guest can't use.
3910 + $registrationEnabled = \Yatra\Services\SettingsService::isEnabled('customer_registration');
3911 + $guestCheckoutEnabled = \Yatra\Services\SettingsService::isEnabled('allow_guest_checkout');
3912 + $showSecondaryCta = $isLoggedIn || ($registrationEnabled && !$guestCheckoutEnabled);
3913 + $secondaryCta = $showSecondaryCta
3914 + ? '<a class="btn btn-secondary" href="' . esc_url($secondaryUrl) . '">' . esc_html($secondaryLabel) . '</a>'
3915 + : '';
3916 +
3917 + $heading = $alreadyVerified
3918 + ? __('Email Already Verified', 'yatra')
3919 + : __('Email Verified', 'yatra');
3920 + $message = $alreadyVerified
3921 + ? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra')
3922 + : __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra');
3923 +
3924 + $primaryLabel = __('View Booking Confirmation', 'yatra');
3925 + $homeLabel = __('Go to Homepage', 'yatra');
3926 + $referenceLabel = __('Booking reference', 'yatra');
3927 + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3928 +
3929 + $referenceLine = $reference !== ''
3930 + ? sprintf(
3931 + '<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>',
3932 + esc_html($referenceLabel),
3933 + esc_html($reference)
3934 + )
3935 + : '';
3936 +
3937 + // Inline-only styling so the page renders correctly regardless of
3938 + // theme stylesheet load order (REST → wp_die/raw HTML response).
3939 + $html = sprintf(
3940 + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3941 + . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3942 + . '<title>%2$s · %3$s</title>'
3943 + . '<style>'
3944 + . 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3945 + . '.box{max-width:520px;margin:60px auto;background:#fff;border-radius:12px;padding:36px 32px;box-shadow:0 1px 3px rgba(0,0,0,.1);text-align:center}'
3946 + . '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}'
3947 + . 'h1{font-size:24px;margin:0 0 12px;color:#065f46}'
3948 + . 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}'
3949 + . '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}'
3950 + . '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}'
3951 + . '.ref code{font-weight:600;color:#111827}'
3952 + . '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}'
3953 + . '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}'
3954 + . '.btn-primary{background:#059669;color:#fff}'
3955 + . '.btn-primary:hover{background:#047857}'
3956 + . '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}'
3957 + . '.btn-secondary:hover{background:#f9fafb}'
3958 + . '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}'
3959 + . '</style></head>'
3960 + . '<body><div class="box">'
3961 + . '<div class="tick" aria-hidden="true">'
3962 + . '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
3963 + . '<polyline points="20 6 9 17 4 12"></polyline></svg>'
3964 + . '</div>'
3965 + . '<h1>%2$s</h1>'
3966 + . '<p>%4$s</p>'
3967 + . '%5$s'
3968 + . '<div class="actions">'
3969 + . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3970 + . '%8$s'
3971 + . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3972 + . '</div>'
3973 + . '</div></body></html>',
3974 + esc_attr(get_locale()),
3975 + esc_html($heading),
3976 + esc_html($brandName),
3977 + esc_html($message),
3978 + $referenceLine,
3979 + esc_url($confirmationUrl),
3980 + esc_html($primaryLabel),
3981 + // %8 is the fully-built secondary CTA (or '' when hidden — see
3982 + // $showSecondaryCta above). %9 is intentionally empty to keep the
3983 + // positional args aligned with %10/%11.
3984 + $secondaryCta,
3985 + '',
3986 + esc_url(home_url('/')),
3987 + esc_html($homeLabel)
3988 + );
3989 +
3990 + if (!headers_sent()) {
3991 + status_header(200);
3992 + nocache_headers();
3993 + header('Content-Type: text/html; charset=UTF-8');
3994 + }
3995 + echo $html;
3996 + exit;
3997 + }
3998 +
3999 + /**
2430 4000 * Calculate booking summary and return HTML for dynamic updates
2431 4001 * Called via AJAX when traveler count, date, or coupon changes
2432 4002 */
2433 4003 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
@@ -2433,15 +4003,43 @@
2433 4003 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
2434 4004 {
2435 4005 yatra_start_session();
2436 4006 $session = yatra_get_booking_session();
2437 - $data = $request->get_json_params();
2438 -
2439 - error_log('=== calculate_summary CALLED ===');
2440 - error_log('Request data: ' . print_r($data, true));
2441 - error_log('Session traveler_counts: ' . print_r($session['traveler_counts'] ?? 'NOT SET', true));
2442 - error_log('Session travelers: ' . ($session['travelers'] ?? 'NOT SET'));
2443 -
4007 + $data = $request->get_json_params() ?? [];
4008 +
4009 + // M-2: restore CSRF protection stripped by public_permission_callback.
4010 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4011 + return $blocked;
4012 + }
4013 +
4014 + // Same REST-context session-rehydration fallback as set_session() /
4015 + // create_booking(): when PHPSESSID isn't propagated to the REST API
4016 + // scope, look up the transient by `booking_token` (from request body
4017 + // first, then ?booking_token=) so the partial summary refresh
4018 + // doesn't 400. Without this, every service-toggle re-render fails
4019 + // because trip_id resolves to 0.
4020 + //
4021 + // We also write the rehydrated session into $_SESSION so that the
4022 + // downstream buildPricingHtml() — which calls yatra_get_booking_session()
4023 + // again — sees the same data and doesn't fall back to its
4024 + // "Pricing information not available" branch.
4025 + if (empty($session) || empty($session['trip_id'])) {
4026 + $token = null;
4027 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
4028 + $token = sanitize_text_field((string) $data['booking_token']);
4029 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
4030 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
4031 + }
4032 + if ($token) {
4033 + $transient_data = get_transient($token);
4034 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
4035 + $session = $transient_data;
4036 + $_SESSION['yatra_booking'] = $session;
4037 + $_SESSION['yatra_booking_token'] = $token;
4038 + }
4039 + }
4040 + }
4041 +
2444 4042 // Get trip_id from session (required)
2445 4043 $trip_id = (int) ($session['trip_id'] ?? 0);
2446 4044
2447 4045 // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session
@@ -2460,11 +4058,9 @@
2460 4058
2461 4059 // IMPORTANT: Always read coupon from SESSION (not request) to maintain applied discount
2462 4060 $coupon_code = isset($session['coupon']['code']) ? sanitize_text_field($session['coupon']['code']) : '';
2463 4061
2464 - error_log('calculate_summary - Final traveler_counts: ' . print_r($traveler_counts, true));
2465 - error_log('calculate_summary - Coupon from session: ' . $coupon_code);
2466 -
4062 +
2467 4063 if (empty($trip_id)) {
2468 4064 return new WP_REST_Response([
2469 4065 'success' => false,
2470 4066 'message' => __('No active booking session found.', 'yatra'),
@@ -2490,14 +4086,24 @@
2490 4086
2491 4087 global $wpdb;
2492 4088
2493 4089 $availability = null;
2494 - if (!empty($availability_id) && is_numeric($availability_id)) {
2495 - // Only use getById if availability_id is numeric
4090 + if (!empty($travel_date)) {
4091 + // Use the same resolver as the single-trip UI so rule slots, manual dates,
4092 + // and flexible defaults all return a consistent object shape.
4093 + try {
4094 + $resolver = new \Yatra\Services\AvailabilityResolutionService();
4095 + $availability = $resolver->resolveAvailabilityForDate(
4096 + $trip_id,
4097 + $travel_date,
4098 + $departure_time !== '' ? $departure_time : null
4099 + );
4100 + } catch (\Throwable $e) {
4101 + $availability = null;
4102 + }
4103 + } elseif (!empty($availability_id) && is_numeric($availability_id)) {
4104 + // Back-compat: allow summary by numeric availability_id only.
2496 4105 $availability = $this->availabilityService->getById((int) $availability_id);
2497 - } elseif (!empty($travel_date)) {
2498 - // For string IDs or when no availability_id, use date+time lookup for day tours
2499 - $availability = $this->availabilityService->getByTripAndDateTime($trip_id, $travel_date, $departure_time ?: null);
2500 4106 }
2501 4107
2502 4108 // Resolve pricing type and price_types via centralized TripPricingService
2503 4109 $resolved_pricing_type = !empty($pricing_type_from_request)
@@ -2526,8 +4132,15 @@
2526 4132 if (!empty($price_types)) {
2527 4133 $resolved_pricing_type = 'traveler_based';
2528 4134 }
2529 4135
4136 + // Resolve pricing_mode / group-size limits authoritatively from the
4137 + // TravelerCategory so the summary breakdown treats a per-group category
4138 + // as a flat charge. No-op for per-person categories.
4139 + if (!empty($price_types)) {
4140 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
4141 + }
4142 +
2530 4143 // Enrich availability price_types with category labels if missing
2531 4144 if (!empty($price_types)) {
2532 4145 $missing_label_category_ids = [];
2533 4146 foreach ($price_types as $pt) {
@@ -2581,13 +4194,16 @@
2581 4194 if (!isset($pt->effective_price)) {
2582 4195 continue;
2583 4196 }
2584 4197 $price_before = $pt->effective_price;
4198 + $pt_orig = (float) ($pt->original_price ?? 0);
2585 4199 $pt->effective_price = apply_filters('yatra_trip_display_price', (float) $pt->effective_price, $trip_id, [
2586 4200 'departure_date' => $travel_date ?: null,
2587 4201 'spots_remaining' => $spots_remaining,
2588 4202 'availability_id' => $availability_id,
2589 4203 'price_type_id' => $pt->id ?? ($pt->price_type_id ?? null),
4204 + 'original_price' => $pt_orig > 0 ? $pt_orig : (float) $price_before,
4205 + 'discounted_price' => (float) $price_before,
2590 4206 ]);
2591 4207 }
2592 4208 }
2593 4209
@@ -2608,12 +4224,22 @@
2608 4224 }
2609 4225
2610 4226 // Apply dynamic pricing filter (Pro DynamicPricingModule hooks here)
2611 4227 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
4228 + $list_for_dp = (float) ($trip->original_price ?? 0);
4229 + if ($availability) {
4230 + $ao = (float) ($availability->original_price ?? 0);
4231 + if ($ao > 0) {
4232 + $list_for_dp = $ao;
4233 + }
4234 + }
4235 + $pre_dp_effective = (float) $base_trip_price;
2612 4236 $base_trip_price = apply_filters('yatra_booking_trip_price', $base_trip_price, $trip_id, [
2613 4237 'departure_date' => $travel_date,
2614 4238 'spots_remaining' => $availability ? (int) ($availability->spots_remaining ?? null) : null,
2615 4239 'availability_id' => $availability_id,
4240 + 'original_price' => $list_for_dp > 0 ? $list_for_dp : $pre_dp_effective,
4241 + 'discounted_price' => $pre_dp_effective,
2616 4242 ]);
2617 4243 }
2618 4244
2619 4245 // Calculate subtotals per category
@@ -2632,9 +4258,12 @@
2632 4258 foreach ($price_types as $pt) {
2633 4259 $category_id = $pt->category_id;
2634 4260 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
2635 4261 if ($count > 0) {
2636 - $category_subtotal = (float) $pt->effective_price * $count;
4262 + // Single source of truth for the line amount (per-person ×
4263 + // count, flat per-group, or per-block group pricing).
4264 + $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
4265 + $category_subtotal = \Yatra\Services\TripPricingService::categoryLineSubtotal($pt, $count, (float) $pt->effective_price);
2637 4266 $category_breakdown[] = [
2638 4267 'category_id' => $category_id,
2639 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2640 4269 'count' => $count,
@@ -2639,8 +4268,13 @@
2639 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2640 4269 'count' => $count,
2641 4270 'price' => (float) $pt->effective_price,
2642 4271 'subtotal' => $category_subtotal,
4272 + 'pricing_mode' => $pt_pricing_mode,
4273 + // Carry the group-size knobs so the reconciliation pass
4274 + // below can re-derive the same per-block/flat subtotal.
4275 + 'max_pax' => isset($pt->max_pax) && $pt->max_pax !== null && $pt->max_pax !== '' ? (int) $pt->max_pax : null,
4276 + 'group_overflow' => $pt->group_overflow ?? 'block',
2643 4277 ];
2644 4278 $subtotal += $category_subtotal;
2645 4279 $total_travelers += $count;
2646 4280 }
@@ -2654,9 +4288,8 @@
2654 4288 }
2655 4289 $price_per_person = $base_trip_price;
2656 4290 $subtotal = $price_per_person * $total_travelers;
2657 4291
2658 - error_log('calculate_summary - Regular pricing: total_travelers=' . $total_travelers . ', subtotal=' . $subtotal);
2659 4292 }
2660 4293
2661 4294 // Calculate group discount
2662 4295 $discountService = new \Yatra\Services\DiscountService();
@@ -2674,13 +4307,14 @@
2674 4307
2675 4308 $priceTypesForDiscount = [];
2676 4309 if ($is_traveler_based) {
2677 4310 foreach ($price_types as $pt) {
2678 - $pt = (object) $pt;
2679 - $priceTypesForDiscount[] = [
2680 - 'category_id' => $pt->category_id ?? null,
2681 - 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
2682 - ];
4311 + $pt = (array) $pt;
4312 + // Keep pricing_mode / max_pax / group_overflow so the group
4313 + // discount base honours flat and per-block group pricing
4314 + // (not just category_id + effective_price).
4315 + $pt['effective_price'] = $pt['effective_price'] ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
4316 + $priceTypesForDiscount[] = $pt;
2683 4317 }
2684 4318 } else {
2685 4319 $priceTypesForDiscount[] = [
2686 4320 'category_id' => 'default',
@@ -2719,12 +4353,23 @@
2719 4353 }
2720 4354
2721 4355 // Use CalculationService for on-demand pricing calculation
2722 4356 $calculationService = new CalculationService();
2723 -
2724 - // Initialize additional services (will be populated later via filter)
2725 - $additional_services = [];
2726 -
4357 +
4358 + // The selected-service ids the customer just submitted live in
4359 + // `$selected_service_ids_from_request` (line ~2635). The previous
4360 + // version of this method initialised a fresh `$additional_services
4361 + // = []` here and passed that empty list into calculateFromSession
4362 + // — which made the Pro AdditionalServicesModule's
4363 + // `addServicesToSubtotal` hook bail out at its `empty($selectedServiceIds)`
4364 + // guard, so every AJAX summary refresh wiped services out of the
4365 + // Trip Subtotal even though the sidebar still rendered the rows.
4366 + // Pass the real selected ids instead so CalculationService →
4367 + // Pro filter chain folds them into `$subtotal` correctly.
4368 + $additional_services = is_array($selected_service_ids_from_request)
4369 + ? array_values(array_map('intval', $selected_service_ids_from_request))
4370 + : [];
4371 +
2727 4372 // Create session-like data structure for calculation (trip data fetched from database)
2728 4373 $session_like_data = [
2729 4374 'trip_id' => $trip_id,
2730 4375 'travelers' => $total_travelers,
@@ -2741,13 +4386,13 @@
2741 4386 'payment_method' => $payment_method,
2742 4387 ]);
2743 4388
2744 4389 $pricing = $calculationService->calculateFromSession(
2745 - $calculation_params['session_data'],
2746 - $calculation_params['coupon_code'],
4390 + $calculation_params['session_data'],
4391 + $calculation_params['coupon_code'],
2747 4392 $calculation_params['payment_method']
2748 4393 );
2749 -
4394 +
2750 4395 $total_amount = $pricing['final_total'];
2751 4396 $amount_due = $pricing['amount_due'];
2752 4397 $tax_calculation = $pricing['tax_calculation'];
2753 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
@@ -2752,8 +4397,49 @@
2752 4397 $tax_calculation = $pricing['tax_calculation'];
2753 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
2754 4399 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
2755 4400 $tax_breakdown = $pricing['tax_calculation']['tax_breakdown'];
4401 +
4402 + // ── Reconcile per-category display prices with CalculationService ─
4403 + //
4404 + // The $category_breakdown computed above (~line 3358) used a SEPARATE
4405 + // DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled.
4406 + // In certain AJAX-recompute contexts (e.g. switching payment_method)
4407 + // that loop could miss DP — for example when a stored availability
4408 + // row's price_types already had a pre-DP effective_price baked in,
4409 + // or when a date-sensitive DP rule didn't fire because the request
4410 + // didn't carry the same departure_date context.
4411 + //
4412 + // CalculationService is the single source of truth for booking math;
4413 + // it already computed the correct post-DP per-category prices and
4414 + // returned them as `category_prices_post_dp` (keyed by string
4415 + // category_id). Reconcile the display breakdown against that map so
4416 + // "Adult x 8 ($131.12 x 8)" can never disagree with the Trip
4417 + // Subtotal the rest of the page is built from.
4418 + if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) {
4419 + $catPricesPostDp = $pricing['category_prices_post_dp'];
4420 + $reconciledSubtotal = 0.0;
4421 + foreach ($category_breakdown as &$cat) {
4422 + $cid = isset($cat['category_id']) ? (string) $cat['category_id'] : '';
4423 + if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
4424 + $authoritativePrice = (float) $catPricesPostDp[$cid];
4425 + $count = (int) ($cat['count'] ?? 0);
4426 + $cat['price'] = $authoritativePrice;
4427 + // Re-derive the line amount from the authoritative post-DP
4428 + // price using the same rule as the charge (flat per-group,
4429 + // per-block, or per-person × count).
4430 + $cat['subtotal'] = \Yatra\Services\TripPricingService::categoryLineSubtotal($cat, $count, $authoritativePrice);
4431 + }
4432 + $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
4433 + }
4434 + unset($cat);
4435 + // Keep the top-level $subtotal in sync with the reconciled
4436 + // breakdown so any downstream renderers that read it (instead
4437 + // of $pricing['base_amount']) still see consistent numbers.
4438 + if ($reconciledSubtotal > 0) {
4439 + $subtotal = $reconciledSubtotal;
4440 + }
4441 + }
2756 4442
2757 4443 /**
2758 4444 * Filter: Get additional services for this trip
2759 4445 * Allows premium modules to add extra services to the booking summary
@@ -2849,14 +4535,22 @@
2849 4535
2850 4536 // Note: CalculationService already includes itinerary costs in final_total
2851 4537 // No need to add itinerary_costs_total again - it's already included in $total_amount
2852 4538
2853 - // Calculate due amount based on payment method
2854 - // Use filters for flexible payment settings (Pro feature)
4539 + // Calculate due amount based on payment method.
4540 + //
4541 + // Flow: compute a sensible default using the *percentage* filters (which
4542 + // Pro can already override per-trip via trip.deposit_percentage), then
4543 + // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
4544 + // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
4545 + // keeps the math consistent with CalculationService::calculatePaymentAmounts().
4546 + // Tour start → Pro can force full payment when the tour is within the
4547 + // balance-due window (tour-anchored scheduled payments).
4548 + $context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')];
2855 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
2856 - $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20);
2857 - $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30);
2858 -
4550 + $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4551 + $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4552 +
2859 4553 $amount_due = $total_amount;
2860 4554 if ($payment_method === 'deposit') {
2861 4555 $amount_due = $total_amount * ($deposit_percentage / 100);
2862 4556 } elseif ($payment_method === 'partial') {
@@ -2862,8 +4556,16 @@
2862 4556 } elseif ($payment_method === 'partial') {
2863 4557 $amount_due = $total_amount * ($partial_percentage / 100);
2864 4558 }
2865 4559
4560 + $amount_due = (float) apply_filters(
4561 + 'yatra_calculate_amount_due',
4562 + $amount_due,
4563 + $total_amount,
4564 + $payment_method,
4565 + $context
4566 + );
4567 +
2866 4568 Logger::debug('Yatra booking summary: payment method and amount due', [
2867 4569 'context' => 'booking_summary_rest',
2868 4570 'trip_id' => $trip_id,
2869 4571 'flexible_payments_enabled' => $flexible_payments_enabled,
@@ -2901,8 +4603,20 @@
2901 4603 'enable_tax' => $pricing['tax_calculation']['enable_tax'],
2902 4604 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'],
2903 4605 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'],
2904 4606 'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'],
4607 + // Dynamic-pricing data — without these, the AJAX-refreshed summary
4608 + // would never carry the DP line items even though CalculationService
4609 + // produces them on every recalculation.
4610 + 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null,
4611 + 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null,
4612 + 'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0,
4613 + // Authoritative post-DP per-category map. Checkout::getCategoryBreakdown
4614 + // prefers this over the session's $pt->effective_price (which can be
4615 + // pre-DP after a stored availability row's price_types come in
4616 + // pre-baked), so forwarding it here is what keeps the AJAX-rendered
4617 + // "Adult x N ($X x N)" row in sync with the actual Trip Subtotal.
4618 + 'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [],
2905 4619 // Currency for consistent formatting
2906 4620 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2907 4621 ]);
2908 4622
@@ -3009,8 +4723,22 @@
3009 4723 'tax_breakdown' => $data['tax_breakdown'] ?? [],
3010 4724 'total_tax_amount' => $data['total_tax_amount'] ?? 0,
3011 4725 'tax_inclusive' => $data['tax_inclusive'] ?? false,
3012 4726 ],
4727 + // Dynamic-pricing breakdown — without this, the AJAX-refreshed
4728 + // sidebar would never show DP line items even though the page-load
4729 + // path does. Keys match CalculationService's pricing_data shape so
4730 + // Checkout::getDynamicPricing() returns identical results in both
4731 + // render contexts.
4732 + 'dynamic_pricing' => $data['dynamic_pricing'] ?? null,
4733 + 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null,
4734 + 'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0,
4735 + // Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown
4736 + // keys off this to override the (potentially stale / pre-DP)
4737 + // $pt->effective_price coming from session price_types — without
4738 + // it, the AJAX recompute renders pre-DP rows while the rest of
4739 + // the summary uses the post-DP base amount.
4740 + 'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [],
3013 4741 'currency' => $data['currency'] ?? null,
3014 4742 ];
3015 4743
3016 4744 // Update session with payment method if provided
@@ -3032,9 +4760,15 @@
3032 4760 }
3033 4761
3034 4762 // Create Checkout model instance
3035 4763 $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation);
3036 -
4764 +
4765 + // Surface dynamic-pricing breakdown to the template scope. The partial
4766 + // reads `$dynamic_pricing` for the DP block; the page-load path
4767 + // already sets this in booking-content.php.
4768 + $dynamic_pricing = $pricingCalculation['dynamic_pricing'] ?? null;
4769 + $currency = $pricingCalculation['currency'] ?? null;
4770 +
3037 4771 // Load the template (uses $checkout model)
3038 4772 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php';
3039 4773
3040 4774 if (!file_exists($template_path)) {
@@ -3052,10 +4786,37 @@
3052 4786 */
3053 4787 public function remove_coupon(WP_REST_Request $request): WP_REST_Response
3054 4788 {
3055 4789 yatra_start_session();
3056 -
4790 +
4791 + $data = $request->get_json_params() ?? [];
4792 +
4793 + // M-2: restore CSRF protection stripped by public_permission_callback.
4794 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4795 + return $blocked;
4796 + }
4797 +
3057 4798 $session = yatra_get_booking_session();
4799 +
4800 + // Same booking_token rehydration as apply_coupon — handle REST
4801 + // requests that arrive without a propagated PHPSESSID.
4802 + if (empty($session) || empty($session['trip_id'])) {
4803 + $token = null;
4804 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
4805 + $token = sanitize_text_field((string) $data['booking_token']);
4806 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
4807 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
4808 + }
4809 + if ($token) {
4810 + $transient_data = get_transient($token);
4811 + if (is_array($transient_data) && !empty($transient_data['trip_id'])) {
4812 + $session = $transient_data;
4813 + $_SESSION['yatra_booking'] = $session;
4814 + $_SESSION['yatra_booking_token'] = $token;
4815 + }
4816 + }
4817 + }
4818 +
3058 4819 if (empty($session)) {
3059 4820 return new WP_REST_Response([
3060 4821 'success' => false,
3061 4822 'message' => __('No active booking session found.', 'yatra'),
@@ -3066,18 +4827,27 @@
3066 4827 if (isset($_SESSION['yatra_booking']['coupon'])) {
3067 4828 unset($_SESSION['yatra_booking']['coupon']);
3068 4829 }
3069 4830 $_SESSION['yatra_booking']['timestamp'] = time();
3070 -
4831 +
3071 4832 // Also update local session array for calculation
3072 4833 unset($session['coupon']);
3073 4834 $session['timestamp'] = time();
3074 -
4835 +
4836 + // Persist the coupon removal to the transient too — without this,
4837 + // the next /booking/summary AJAX (which the JS calls right after
4838 + // this endpoint) re-reads the still-couponed transient and the
4839 + // sidebar shows the coupon discount as if it never went away.
4840 + // `yatra_set_booking_session()` array_merges `$_SESSION['yatra_booking']`
4841 + // (already coupon-less above) with the passed data and writes the
4842 + // result into the transient keyed by the existing booking token.
4843 + yatra_set_booking_session($session);
4844 +
3075 4845 // Ensure session data is written immediately
3076 4846 if (session_status() === PHP_SESSION_ACTIVE) {
3077 4847 session_write_close();
3078 4848 }
3079 -
4849 +
3080 4850 $total_amount = $this->calculateSessionTotal($session);
3081 4851
3082 4852 return new WP_REST_Response([
3083 4853 'success' => true,
@@ -3106,9 +4876,8 @@
3106 4876 $total = $pricing['gross_total'] ?? 0;
3107 4877
3108 4878 return (float) $total;
3109 4879 } catch (\Throwable $e) {
3110 - error_log('calculateSessionTotal - ERROR: ' . $e->getMessage());
3111 4880 return 0.0;
3112 4881 }
3113 4882 }
3114 4883