PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
← All changes | app/Controllers/BookingSessionController.php +1947 -181 3.0.3trunk 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,46 +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 - $prevStatus = (string) ($booking->status ?? 'pending');
262 - $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
263 - \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus);
264 - } else {
265 - $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
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 + }
266 369 }
267 370 }
268 371 }
269 -
372 +
270 373 return new WP_REST_Response([
271 374 'success' => true,
272 375 'message' => __('Payment completed successfully.', 'yatra'),
273 376 'data' => [
274 - 'transaction_id' => $result['transaction_id'] ?? '',
377 + 'transaction_id' => $transaction_id,
275 378 'status' => $result['status'] ?? 'completed',
276 379 ],
277 380 ]);
278 381 }
@@ -277,8 +380,48 @@
277 380 ]);
278 381 }
279 382
280 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 + /**
281 424 * Set booking session data
282 425 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
283 426 */
284 427 public function set_session(WP_REST_Request $request): WP_REST_Response
@@ -284,14 +427,53 @@
284 427 public function set_session(WP_REST_Request $request): WP_REST_Response
285 428 {
286 429 // Ensure session is started for REST API requests
287 430 yatra_start_session();
288 -
431 +
289 432 $data = $request->get_json_params();
290 -
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 +
291 439 // Check if this is a partial update (updating travelers or services in existing session)
292 440 $existing_session = yatra_get_booking_session();
293 - $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']) &&
294 476 (isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services']));
295 477
296 478 if ($is_partial_update) {
297 479 // Partial update: merge new data with existing session
@@ -397,9 +579,11 @@
397 579 global $wpdb;
398 580
399 581 // Get availability-specific data if date provided
400 582 $availability = null;
401 - $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;
402 586 $travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : '';
403 587 $departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : '';
404 588
405 589 // Use centralized AvailabilityResolutionService to get resolved availability
@@ -554,8 +738,14 @@
554 738 'coupon_code' => '',
555 739 'payment_method' => 'full',
556 740 ]);
557 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 +
558 748 // Prepare session data - essential trip data (pricing fetched from database on-demand)
559 749 $session_data = [
560 750 'trip_id' => (int) $trip->id,
561 751 'trip_title' => $trip->title,
@@ -629,9 +819,30 @@
629 819 public function get_session(WP_REST_Request $request): WP_REST_Response
630 820 {
631 821 $session_data = yatra_get_booking_session();
632 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.
633 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'])) {
634 845 return new WP_REST_Response([
635 846 'success' => false,
636 847 'message' => __('No active booking session.', 'yatra'),
637 848 'data' => null,
@@ -648,8 +859,13 @@
648 859 * Clear booking session
649 860 */
650 861 public function clear_session(WP_REST_Request $request): WP_REST_Response
651 862 {
863 + // M-2: restore CSRF protection stripped by public_permission_callback.
864 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
865 + return $blocked;
866 + }
867 +
652 868 yatra_clear_booking_session();
653 869
654 870 return new WP_REST_Response([
655 871 'success' => true,
@@ -680,8 +896,11 @@
680 896 'slug' => $trip->slug,
681 897 'featured_image' => $trip->featured_image,
682 898 'duration_days' => (int) $trip->duration_days,
683 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),
684 903 'difficulty_level' => $trip->difficulty_level,
685 904 'min_travelers' => (int) ($trip->min_travelers ?: 1),
686 905 'max_travelers' => (int) ($trip->max_travelers ?: 20),
687 906 'original_price' => (float) $trip->original_price,
@@ -707,15 +926,340 @@
707 926 *
708 927 * Remaining / balance payment: does not create a booking — only charges the existing row
709 928 * and records a payment on success via the same gateway completion paths.
710 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 +
711 1214 public function create_booking(WP_REST_Request $request): WP_REST_Response
712 1215 {
713 1216 global $wpdb;
714 -
1217 +
715 1218 $data = $request->get_json_params();
716 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 +
717 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 + // ========================================
718 1262 // REMAINING PAYMENT vs NEW BOOKING
719 1263 // ========================================
720 1264 // A leftover PHP session from "pay remaining balance" must not hijack a normal
721 1265 // checkout POST (full traveler payload). Only treat as remaining-payment when the
@@ -746,13 +1290,11 @@
746 1290 // GET BOOKING SETTINGS
747 1291 // ========================================
748 1292 $settings = [
749 1293 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
750 - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1294 + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(),
751 1295 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
752 1296 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
753 - 'cancellation_policy' => \Yatra\Services\SettingsService::get('cancellation_policy', 'full_refund'),
754 - 'cancellation_days' => (int) \Yatra\Services\SettingsService::get('cancellation_days', 7),
755 1297 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
756 1298 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
757 1299 ];
758 1300
@@ -777,11 +1319,50 @@
777 1319 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
778 1320 ], 401);
779 1321 }
780 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 +
781 1342 // Get session data
782 1343 $session = yatra_get_booking_session();
783 -
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 +
784 1365 // Validate we have session data or direct booking data
785 1366 $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0);
786 1367
787 1368 if (!$trip_id) {
@@ -790,42 +1371,163 @@
790 1371 'message' => __('No trip selected for booking.', 'yatra'),
791 1372 ], 400);
792 1373 }
793 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 +
794 1387 // Get contact email - handle both flat and nested formats
795 - $contact_email = $data['contact_email'] ?? '';
1388 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
796 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 + );
797 1399 $contact_first_name = $data['contact_first_name'] ?? '';
798 1400 $contact_last_name = $data['contact_last_name'] ?? '';
799 1401 $contact_country = $data['contact_country'] ?? '';
800 -
801 - $contact_nationality = $data['contact_nationality'] ?? '';
1402 +
1403 + $contact_nationality = $data['contact_nationality'] ?? '';
802 1404 $contact_address = $data['contact_address'] ?? '';
803 -
1405 +
804 1406 // Emergency contact
805 1407 $emergency_name = $data['emergency_name'] ?? '';
806 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
807 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
808 -
1413 +
809 1414 // Travel details
810 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
811 1416 $travelers = $data['travelers'] ?? [];
812 -
813 - // Validate required fields
814 - 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)) {
815 1497 return new WP_REST_Response([
816 1498 'success' => false,
817 - 'message' => __('Email address is required.', 'yatra'),
1499 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
818 1500 ], 400);
819 1501 }
820 -
821 - 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)) {
822 1524 return new WP_REST_Response([
823 1525 'success' => false,
824 1526 'message' => __('Phone number is required.', 'yatra'),
825 1527 ], 400);
826 1528 }
827 -
1529 +
828 1530 if (empty($travel_date)) {
829 1531 return new WP_REST_Response([
830 1532 'success' => false,
831 1533 'message' => __('Travel date is required.', 'yatra'),
@@ -830,9 +1532,9 @@
830 1532 'success' => false,
831 1533 'message' => __('Travel date is required.', 'yatra'),
832 1534 ], 400);
833 1535 }
834 -
1536 +
835 1537 if (empty($travelers) || !is_array($travelers)) {
836 1538 return new WP_REST_Response([
837 1539 'success' => false,
838 1540 'message' => __('At least one traveler is required.', 'yatra'),
@@ -838,14 +1540,27 @@
838 1540 'message' => __('At least one traveler is required.', 'yatra'),
839 1541 ], 400);
840 1542 }
841 1543
842 - // Validate email
843 - if (!is_email($contact_email)) {
844 - return new WP_REST_Response([
845 - 'success' => false,
846 - 'message' => __('Invalid email address.', 'yatra'),
847 - ], 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 + }
848 1563 }
849 1564
850 1565 // Get trip data
851 1566 $trip = $this->tripRepository->findPublished($trip_id);
@@ -909,22 +1624,91 @@
909 1624 $traveler_counts['default'] = $travelers_count;
910 1625 }
911 1626 }
912 1627
913 - // 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.
914 1642 $calculationService = new CalculationService();
915 - $pricing = $calculationService->calculatePricing([
916 - 'trip_id' => $trip_id,
917 - 'travelers_count' => $travelers_count,
918 - 'traveler_counts' => $traveler_counts,
919 - 'travel_date' => $travel_date,
920 - 'departure_time' => $departure_time,
921 - 'selected_services' => $additional_services,
922 - 'availability_id' => $availability_id ? (int) $availability_id : null,
923 - 'coupon_code' => $coupon_code,
924 - 'payment_method' => $payment_method,
925 - ]);
926 -
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 +
927 1711 // Extract pricing results
928 1712 $total_amount = $pricing['final_total'];
929 1713 $amount_due = $pricing['amount_due'];
930 1714 $amount_paid = $pricing['amount_paid'];
@@ -958,9 +1742,9 @@
958 1742 $isWaitlistCheckout = false;
959 1743
960 1744 if ($resolvedAvailabilityForWaitlist !== null) {
961 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
962 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
963 1747 return new WP_REST_Response([
964 1748 'success' => false,
965 1749 'message' => __('This departure is not open for booking.', 'yatra'),
966 1750 'code' => 'date_blocked',
@@ -1007,10 +1791,10 @@
1007 1791 'payment_method' => $payment_method,
1008 1792 'payment_gateway' => $payment_gateway,
1009 1793 'total_amount' => round((float) $total_amount, 4),
1010 1794 'amount_due' => round((float) $amount_due, 4),
1011 - 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20),
1012 - '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]),
1013 1797 ]);
1014 1798
1015 1799 // Prepare contact data
1016 1800 $contact_data = [
@@ -1021,9 +1805,37 @@
1021 1805 'country' => sanitize_text_field($contact_country),
1022 1806 'nationality' => sanitize_text_field($contact_nationality),
1023 1807 'address' => sanitize_text_field($contact_address),
1024 1808 ];
1025 -
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 +
1026 1838 // Prepare emergency contact data
1027 1839 $emergency_data = [
1028 1840 'name' => sanitize_text_field($emergency_name),
1029 1841 'phone' => sanitize_text_field($emergency_phone),
@@ -1028,8 +1840,31 @@
1028 1840 'name' => sanitize_text_field($emergency_name),
1029 1841 'phone' => sanitize_text_field($emergency_phone),
1030 1842 'relationship' => sanitize_text_field($emergency_relationship),
1031 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 + }
1032 1867
1033 1868 // Sanitize travelers data
1034 1869 $sanitized_travelers = [];
1035 1870 foreach ($travelers as $traveler) {
@@ -1036,8 +1871,13 @@
1036 1871 if (is_array($traveler)) {
1037 1872 $sanitized_traveler = [];
1038 1873 foreach ($traveler as $key => $value) {
1039 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 + }
1040 1880 if (is_array($value)) {
1041 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1042 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1043 1883 }, $value);
@@ -1044,8 +1884,19 @@
1044 1884 } else {
1045 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1046 1886 }
1047 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 + }
1048 1899 $sanitized_travelers[] = $sanitized_traveler;
1049 1900 }
1050 1901 }
1051 1902
@@ -1230,12 +2081,48 @@
1230 2081
1231 2082 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1232 2083 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1233 2084 $booking_data['status'] = 'waitlist';
1234 - $booking_data['payment_gateway'] = 'pay_later';
1235 - $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 + }
1236 2097 }
1237 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 +
1238 2125 try {
1239 2126 $booking = $booking_service->createBooking($booking_data);
1240 2127 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1241 2128 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
@@ -1288,8 +2175,14 @@
1288 2175 * @param int $trip_id The trip ID
1289 2176 * @param array $data The booking request data (contains selected_services)
1290 2177 * @param int $travelers_count Total number of travelers
1291 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.
1292 2185 * @since 3.0.0
1293 2186 */
1294 2187 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1295 2188 if (!isset($data['selected_services'])) {
@@ -1300,9 +2193,9 @@
1300 2193 if (!is_array($data['selected_services'])) {
1301 2194 $data['selected_services'] = [];
1302 2195 }
1303 2196 $data['selected_services'] = array_map('intval', $data['selected_services']);
1304 - do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
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';
@@ -1526,10 +2536,8 @@
1526 2536 'payment_gateway' => $payment_gateway,
1527 2537 'total_amount' => $total_amount,
1528 2538 'amount_due' => $amount_due,
1529 2539 'booking_status' => $booking_status,
1530 - 'cancellation_policy' => $settings['cancellation_policy'],
1531 - 'cancellation_days' => $settings['cancellation_days'],
1532 2540 'expiry_datetime' => $expiry_datetime,
1533 2541 ]);
1534 2542 }
1535 2543
@@ -1677,11 +2685,44 @@
1677 2685 }
1678 2686
1679 2687 $is_offline_gateway = $this->isOfflineGateway($payment_gateway);
1680 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 +
1681 2712 // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.).
1682 2713 // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely.
1683 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 +
1684 2725 $payment_params = array_merge($data, [
1685 2726 'booking_id' => $booking_id,
1686 2727 'reference' => $booking_reference,
1687 2728 'amount' => $remaining_amount,
@@ -1688,23 +2729,44 @@
1688 2729 'currency' => $currency,
1689 2730 'customer_email' => $customer_email,
1690 2731 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email,
1691 2732 'trip_title' => $trip_title,
2733 + 'return_url' => $remaining_return_url,
1692 2734 ]);
1693 2735
1694 2736 $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params);
1695 2737
1696 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 +
1697 2759 if (!empty($payment_result['payment_url'])) {
1698 2760 return new WP_REST_Response([
1699 2761 'success' => true,
1700 2762 'message' => __('Redirecting to payment...', 'yatra'),
1701 - 'data' => [
2763 + 'data' => array_merge([
1702 2764 'booking_id' => $booking_id,
1703 2765 'reference' => $booking_reference,
1704 2766 'payment_url' => $payment_result['payment_url'],
1705 2767 'is_remaining_payment' => true,
1706 - ],
2768 + ], $remaining_identity_fields),
1707 2769 ]);
1708 2770 }
1709 2771
1710 2772 if (!empty($payment_result['requires_action'])) {
@@ -1716,9 +2778,10 @@
1716 2778 'booking_id' => $booking_id,
1717 2779 'reference' => $booking_reference,
1718 2780 'is_remaining_payment' => true,
1719 2781 ],
1720 - $payment_result
2782 + $payment_result,
2783 + $remaining_identity_fields
1721 2784 ),
1722 2785 ]);
1723 2786 }
1724 2787
@@ -1725,14 +2788,14 @@
1725 2788 if (!empty($payment_result['redirect_url'])) {
1726 2789 return new WP_REST_Response([
1727 2790 'success' => true,
1728 2791 'message' => __('Payment processed.', 'yatra'),
1729 - 'data' => [
2792 + 'data' => array_merge([
1730 2793 'booking_id' => $booking_id,
1731 2794 'reference' => $booking_reference,
1732 2795 'redirect_url' => $payment_result['redirect_url'],
1733 2796 'is_remaining_payment' => true,
1734 - ],
2797 + ], $remaining_identity_fields),
1735 2798 ]);
1736 2799 }
1737 2800 }
1738 2801
@@ -1747,11 +2810,20 @@
1747 2810 ],
1748 2811 ], 400);
1749 2812 }
1750 2813
1751 - // 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.
1752 2818 yatra_clear_remaining_session();
1753 2819
2820 + $offline_redirect = add_query_arg(
2821 + 'balance',
2822 + 'paid',
2823 + $this->getConfirmationUrl($booking_reference)
2824 + );
2825 +
1754 2826 return new WP_REST_Response([
1755 2827 'success' => true,
1756 2828 'message' => __('Continue to confirmation.', 'yatra'),
1757 2829 'data' => [
@@ -1763,9 +2835,9 @@
1763 2835 'currency' => $currency,
1764 2836 'amount' => $remaining_amount,
1765 2837 'customer_email' => $customer_email,
1766 2838 'customer_name' => $customer_name,
1767 - 'redirect_url' => $this->getConfirmationUrl($booking_reference),
2839 + 'redirect_url' => $offline_redirect,
1768 2840 'is_remaining_payment' => true,
1769 2841 ],
1770 2842 ]);
1771 2843 }
@@ -1814,11 +2886,15 @@
1814 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
1815 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
1816 2888 // may still append their own query args on top of this URL.
1817 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'] ?? '');
1818 2894 $paymentData = array_merge($params, [
1819 2895 'description' => $params['trip_title'] ?? '',
1820 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
1821 2897 'metadata' => [
1822 2898 'booking_id' => $params['booking_id'],
1823 2899 'reference' => $params['reference'] ?? ''
1824 2900 ]
@@ -1867,10 +2943,12 @@
1867 2943 ];
1868 2944 }
1869 2945
1870 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
1871 2949 return [
1872 - 'success' => true,
2950 + 'success' => true,
1873 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
1874 2952 ];
1875 2953 }
1876 2954
@@ -1895,8 +2973,75 @@
1895 2973 /**
1896 2974 * Record payment from gateway result
1897 2975 * Matches Stripe's completePayment behavior
1898 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 +
1899 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
1900 3045 {
1901 3046 global $wpdb;
1902 3047
@@ -1904,18 +3049,39 @@
1904 3049 $bookingId = (int) $params['booking_id'];
1905 3050 $amount = (float) ($params['amount'] ?? 0);
1906 3051 $currency = $params['currency'] ?? 'USD';
1907 3052 $transactionId = $result['transaction_id'] ?? '';
1908 -
3053 +
1909 3054 // Get booking
1910 3055 $booking = $this->bookingRepository->find($bookingId);
1911 - if (!$booking || $booking->payment_status === 'paid') {
3056 + if (!$booking) {
1912 3057 return;
1913 3058 }
1914 -
1915 - // 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 +
1916 3068 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1917 - $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([
1918 3084 'booking_id' => $bookingId,
1919 3085 'amount' => $amount,
1920 3086 'currency' => $currency,
1921 3087 'gateway' => $gatewayId,
@@ -1920,14 +3086,43 @@
1920 3086 'currency' => $currency,
1921 3087 'gateway' => $gatewayId,
1922 3088 'transaction_id' => $transactionId,
1923 3089 'status' => 'completed',
3090 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
1924 3091 'created_at' => current_time('mysql'),
1925 3092 ]);
1926 -
1927 - // Calculate total paid
1928 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
1929 -
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 +
1930 3125 // Fire payment completed action
1931 3126 do_action('yatra_payment_completed', [
1932 3127 'booking_id' => $bookingId,
1933 3128 'transaction_id' => $transactionId,
@@ -1934,11 +3129,12 @@
1934 3129 'amount' => $amount,
1935 3130 'currency' => $currency,
1936 3131 'gateway' => $gatewayId,
1937 3132 ]);
1938 -
1939 - } catch (\Exception $e) {
1940 - }
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 + }
1941 3137 }
1942 3138
1943 3139 /**
1944 3140 * Process PayPal payment
@@ -1992,9 +3188,9 @@
1992 3188 'description' => $params['trip_title'],
1993 3189 ]],
1994 3190 'application_context' => [
1995 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
1996 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
1997 3193 ],
1998 3194 ]),
1999 3195 ]);
2000 3196
@@ -2113,9 +3309,12 @@
2113 3309 'su' => add_query_arg(
2114 3310 ['payment' => 'success', 'gateway' => 'esewa'],
2115 3311 $this->getConfirmationUrl($params['reference'])
2116 3312 ),
2117 - '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 + ),
2118 3317 ], $base_url);
2119 3318
2120 3319 return ['success' => true, 'payment_url' => $payment_url];
2121 3320 }
@@ -2228,10 +3427,16 @@
2228 3427 $amount_due = $data['amount_due'] ?? 0;
2229 3428 $payment_method = $data['payment_method'] ?? 'full';
2230 3429 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2231 3430 $booking_status = $data['booking_status'] ?? 'pending';
2232 - $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund';
2233 - $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 + : '';
2234 3439 $expiry_datetime = $data['expiry_datetime'] ?? null;
2235 3440
2236 3441 $customer_email = $contact['email'] ?? '';
2237 3442 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
@@ -2248,8 +3453,9 @@
2248 3453 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2249 3454 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2250 3455 if ($booking_status === 'pending' && $expiry_datetime) {
2251 3456 $intro_paragraph .= ' ' . sprintf(
3457 + /* translators: %s: payment expiry date and time (formatted). */
2252 3458 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2253 3459 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2254 3460 );
2255 3461 }
@@ -2259,17 +3465,21 @@
2259 3465 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2260 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2261 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2262 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>
2263 - <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>
2264 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2265 3472 </div>
2266 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2267 - <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>
2268 3476 <?php if ($payment_method === 'deposit') : ?>
2269 - <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>
2270 3479 <?php elseif ($payment_method === 'partial') : ?>
2271 - <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>
2272 3482 <?php else : ?>
2273 3483 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2274 3484 <?php endif; ?>
2275 3485 <?php if ($payment_gateway === 'pay_later') : ?>
@@ -2282,26 +3492,26 @@
2282 3492 <?php foreach ($travelers as $i => $traveler) : ?>
2283 3493 <?php
2284 3494 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2285 3495 ?>
2286 - <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>
2287 3498 <?php endforeach; ?>
2288 3499 </ul>
2289 3500 <?php
2290 - $cancellation_policy_labels = [
2291 - 'full_refund' => __('Full refund available', 'yatra'),
2292 - 'partial_refund' => __('Partial refund available', 'yatra'),
2293 - 'no_refund' => __('No refund available', 'yatra'),
2294 - 'flexible' => __('Flexible cancellation', 'yatra'),
2295 - ];
2296 - $policy_label = $cancellation_policy_labels[$cancellation_policy] ?? __('Standard policy applies', 'yatra');
2297 - ?>
2298 - <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
2299 - <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>
2300 - <?php
2301 - $custom_refund_policy = SettingsService::getString('refund_policy', '');
2302 - if ($custom_refund_policy !== '') {
2303 - 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
2304 3514 }
2305 3515 ?>
2306 3516 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2307 3517 <ol style="padding-left:20px;">
@@ -2313,9 +3523,25 @@
2313 3523 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2314 3524 <?php
2315 3525 $details_html = ob_get_clean();
2316 3526
2317 - $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, [
2318 3544 'customer_name' => $customer_name,
2319 3545 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2320 3546 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2321 3547 'customer_email' => $customer_email,
@@ -2331,11 +3557,12 @@
2331 3557 'currency' => SettingsService::getCurrency(),
2332 3558 'intro_paragraph' => $intro_paragraph,
2333 3559 'details_html' => $details_html,
2334 3560 'details_html_only' => '1',
3561 + /* translators: %s: site name. */
2335 3562 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2336 3563 'transactional_context' => 'booking_created',
2337 - ];
3564 + ]);
2338 3565
2339 3566 TransactionalEmailTemplateService::sendIfEnabled(
2340 3567 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2341 3568 $customer_email,
@@ -2351,12 +3578,18 @@
2351 3578 */
2352 3579 public function apply_coupon(WP_REST_Request $request): WP_REST_Response
2353 3580 {
2354 3581 yatra_start_session();
2355 -
2356 - $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 +
2357 3590 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2358 -
3591 +
2359 3592 if (empty($code)) {
2360 3593 return new WP_REST_Response([
2361 3594 'success' => false,
2362 3595 'message' => __('Please enter a coupon code.', 'yatra'),
@@ -2361,12 +3594,32 @@
2361 3594 'success' => false,
2362 3595 'message' => __('Please enter a coupon code.', 'yatra'),
2363 3596 ], 400);
2364 3597 }
2365 -
2366 - // 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.
2367 3604 $session = yatra_get_booking_session();
2368 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'])) {
2369 3622 return new WP_REST_Response([
2370 3623 'success' => false,
2371 3624 'message' => __('No active booking session found.', 'yatra'),
2372 3625 ], 400);
@@ -2425,8 +3678,326 @@
2425 3678 ]);
2426 3679 }
2427 3680
2428 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 + /**
2429 4000 * Calculate booking summary and return HTML for dynamic updates
2430 4001 * Called via AJAX when traveler count, date, or coupon changes
2431 4002 */
2432 4003 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
@@ -2432,10 +4003,43 @@
2432 4003 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
2433 4004 {
2434 4005 yatra_start_session();
2435 4006 $session = yatra_get_booking_session();
2436 - $data = $request->get_json_params();
4007 + $data = $request->get_json_params() ?? [];
2437 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 +
2438 4042 // Get trip_id from session (required)
2439 4043 $trip_id = (int) ($session['trip_id'] ?? 0);
2440 4044
2441 4045 // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session
@@ -2482,14 +4086,24 @@
2482 4086
2483 4087 global $wpdb;
2484 4088
2485 4089 $availability = null;
2486 - if (!empty($availability_id) && is_numeric($availability_id)) {
2487 - // 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.
2488 4105 $availability = $this->availabilityService->getById((int) $availability_id);
2489 - } elseif (!empty($travel_date)) {
2490 - // For string IDs or when no availability_id, use date+time lookup for day tours
2491 - $availability = $this->availabilityService->getByTripAndDateTime($trip_id, $travel_date, $departure_time ?: null);
2492 4106 }
2493 4107
2494 4108 // Resolve pricing type and price_types via centralized TripPricingService
2495 4109 $resolved_pricing_type = !empty($pricing_type_from_request)
@@ -2518,8 +4132,15 @@
2518 4132 if (!empty($price_types)) {
2519 4133 $resolved_pricing_type = 'traveler_based';
2520 4134 }
2521 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 +
2522 4143 // Enrich availability price_types with category labels if missing
2523 4144 if (!empty($price_types)) {
2524 4145 $missing_label_category_ids = [];
2525 4146 foreach ($price_types as $pt) {
@@ -2637,9 +4258,12 @@
2637 4258 foreach ($price_types as $pt) {
2638 4259 $category_id = $pt->category_id;
2639 4260 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
2640 4261 if ($count > 0) {
2641 - $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);
2642 4266 $category_breakdown[] = [
2643 4267 'category_id' => $category_id,
2644 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2645 4269 'count' => $count,
@@ -2644,8 +4268,13 @@
2644 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2645 4269 'count' => $count,
2646 4270 'price' => (float) $pt->effective_price,
2647 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',
2648 4277 ];
2649 4278 $subtotal += $category_subtotal;
2650 4279 $total_travelers += $count;
2651 4280 }
@@ -2678,13 +4307,14 @@
2678 4307
2679 4308 $priceTypesForDiscount = [];
2680 4309 if ($is_traveler_based) {
2681 4310 foreach ($price_types as $pt) {
2682 - $pt = (object) $pt;
2683 - $priceTypesForDiscount[] = [
2684 - 'category_id' => $pt->category_id ?? null,
2685 - 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
2686 - ];
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;
2687 4317 }
2688 4318 } else {
2689 4319 $priceTypesForDiscount[] = [
2690 4320 'category_id' => 'default',
@@ -2723,12 +4353,23 @@
2723 4353 }
2724 4354
2725 4355 // Use CalculationService for on-demand pricing calculation
2726 4356 $calculationService = new CalculationService();
2727 -
2728 - // Initialize additional services (will be populated later via filter)
2729 - $additional_services = [];
2730 -
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 +
2731 4372 // Create session-like data structure for calculation (trip data fetched from database)
2732 4373 $session_like_data = [
2733 4374 'trip_id' => $trip_id,
2734 4375 'travelers' => $total_travelers,
@@ -2745,13 +4386,13 @@
2745 4386 'payment_method' => $payment_method,
2746 4387 ]);
2747 4388
2748 4389 $pricing = $calculationService->calculateFromSession(
2749 - $calculation_params['session_data'],
2750 - $calculation_params['coupon_code'],
4390 + $calculation_params['session_data'],
4391 + $calculation_params['coupon_code'],
2751 4392 $calculation_params['payment_method']
2752 4393 );
2753 -
4394 +
2754 4395 $total_amount = $pricing['final_total'];
2755 4396 $amount_due = $pricing['amount_due'];
2756 4397 $tax_calculation = $pricing['tax_calculation'];
2757 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
@@ -2756,8 +4397,49 @@
2756 4397 $tax_calculation = $pricing['tax_calculation'];
2757 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
2758 4399 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
2759 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 + }
2760 4442
2761 4443 /**
2762 4444 * Filter: Get additional services for this trip
2763 4445 * Allows premium modules to add extra services to the booking summary
@@ -2853,14 +4535,22 @@
2853 4535
2854 4536 // Note: CalculationService already includes itinerary costs in final_total
2855 4537 // No need to add itinerary_costs_total again - it's already included in $total_amount
2856 4538
2857 - // Calculate due amount based on payment method
2858 - // 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 ?? '')];
2859 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
2860 - $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20);
2861 - $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30);
2862 -
4550 + $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4551 + $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4552 +
2863 4553 $amount_due = $total_amount;
2864 4554 if ($payment_method === 'deposit') {
2865 4555 $amount_due = $total_amount * ($deposit_percentage / 100);
2866 4556 } elseif ($payment_method === 'partial') {
@@ -2866,8 +4556,16 @@
2866 4556 } elseif ($payment_method === 'partial') {
2867 4557 $amount_due = $total_amount * ($partial_percentage / 100);
2868 4558 }
2869 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 +
2870 4568 Logger::debug('Yatra booking summary: payment method and amount due', [
2871 4569 'context' => 'booking_summary_rest',
2872 4570 'trip_id' => $trip_id,
2873 4571 'flexible_payments_enabled' => $flexible_payments_enabled,
@@ -2905,8 +4603,20 @@
2905 4603 'enable_tax' => $pricing['tax_calculation']['enable_tax'],
2906 4604 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'],
2907 4605 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'],
2908 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'] ?? [],
2909 4619 // Currency for consistent formatting
2910 4620 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
2911 4621 ]);
2912 4622
@@ -3013,8 +4723,22 @@
3013 4723 'tax_breakdown' => $data['tax_breakdown'] ?? [],
3014 4724 'total_tax_amount' => $data['total_tax_amount'] ?? 0,
3015 4725 'tax_inclusive' => $data['tax_inclusive'] ?? false,
3016 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'] ?? [],
3017 4741 'currency' => $data['currency'] ?? null,
3018 4742 ];
3019 4743
3020 4744 // Update session with payment method if provided
@@ -3036,9 +4760,15 @@
3036 4760 }
3037 4761
3038 4762 // Create Checkout model instance
3039 4763 $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation);
3040 -
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 +
3041 4771 // Load the template (uses $checkout model)
3042 4772 $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php';
3043 4773
3044 4774 if (!file_exists($template_path)) {
@@ -3056,10 +4786,37 @@
3056 4786 */
3057 4787 public function remove_coupon(WP_REST_Request $request): WP_REST_Response
3058 4788 {
3059 4789 yatra_start_session();
3060 -
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 +
3061 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 +
3062 4819 if (empty($session)) {
3063 4820 return new WP_REST_Response([
3064 4821 'success' => false,
3065 4822 'message' => __('No active booking session found.', 'yatra'),
@@ -3070,18 +4827,27 @@
3070 4827 if (isset($_SESSION['yatra_booking']['coupon'])) {
3071 4828 unset($_SESSION['yatra_booking']['coupon']);
3072 4829 }
3073 4830 $_SESSION['yatra_booking']['timestamp'] = time();
3074 -
4831 +
3075 4832 // Also update local session array for calculation
3076 4833 unset($session['coupon']);
3077 4834 $session['timestamp'] = time();
3078 -
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 +
3079 4845 // Ensure session data is written immediately
3080 4846 if (session_status() === PHP_SESSION_ACTIVE) {
3081 4847 session_write_close();
3082 4848 }
3083 -
4849 +
3084 4850 $total_amount = $this->calculateSessionTotal($session);
3085 4851
3086 4852 return new WP_REST_Response([
3087 4853 'success' => true,