PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Controllers/BookingSessionController.php +1566 -131 3.0.4 → 3.0.16 View file →
@@ -149,8 +149,28 @@
149 149 'methods' => 'POST',
150 150 'callback' => [$this, 'create_booking'],
151 151 'permission_callback' => [$this, 'public_permission_callback'],
152 152 ]);
153 +
154 + // Verify a guest's booking email via magic-link token.
155 + // Public + GET so the customer's browser can hit it from a
156 + // plain email-client link. The token itself carries the
157 + // authorisation (HMAC-signed); permission_callback is
158 + // intentionally open. On success the booking transitions to
159 + // 'pending' and the browser is redirected to the continuation
160 + // URL (where the customer completes payment as normal).
161 + register_rest_route($this->namespace, '/booking/verify-email', [
162 + 'methods' => 'GET',
163 + 'callback' => [$this, 'verify_email'],
164 + 'permission_callback' => '__return_true',
165 + 'args' => [
166 + 'token' => [
167 + 'required' => true,
168 + 'type' => 'string',
169 + 'sanitize_callback' => 'sanitize_text_field',
170 + ],
171 + ],
172 + ]);
153 173
154 174 // Apply coupon code
155 175 register_rest_route($this->namespace, '/booking/coupon/apply', [
156 176 'methods' => 'POST',
@@ -185,27 +205,91 @@
185 205 * Used by Square, and other gateways that tokenize on client
186 206 */
187 207 public function complete_gateway_payment(WP_REST_Request $request): WP_REST_Response
188 208 {
189 - $gateway_id = $request->get_param('gateway');
209 + $gateway_id = sanitize_key((string) $request->get_param('gateway'));
190 210 $data = $request->get_json_params();
191 -
192 - $booking_id = $data['booking_id'] ?? 0;
193 - $source_id = $data['source_id'] ?? '';
194 - $amount = $data['amount'] ?? 0;
195 - $currency = $data['currency'] ?? 'USD';
196 -
197 - if (empty($booking_id) || empty($source_id)) {
211 + if (!is_array($data)) {
212 + $data = [];
213 + }
214 +
215 + $booking_id = (int) ($data['booking_id'] ?? 0);
216 + $source_id = sanitize_text_field((string) ($data['source_id'] ?? ''));
217 + $client_amount = (float) ($data['amount'] ?? 0);
218 + $client_currency = sanitize_text_field((string) ($data['currency'] ?? 'USD'));
219 +
220 + if ($booking_id <= 0 || $source_id === '') {
198 221 return new WP_REST_Response([
199 222 'success' => false,
200 223 'message' => __('Missing required payment data.', 'yatra'),
201 224 ], 400);
202 225 }
203 -
226 +
227 + $bookingRepository = new \Yatra\Repositories\BookingRepository();
228 + $booking = $bookingRepository->find($booking_id);
229 +
230 + // Resolve the guest booking-session token (body first, then ?booking_token=),
231 + // exactly as the other booking-session endpoints do.
232 + $booking_token = '';
233 + if (!empty($data['booking_token']) && is_string($data['booking_token'])) {
234 + $booking_token = sanitize_text_field((string) $data['booking_token']);
235 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
236 + $booking_token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
237 + }
238 +
239 + // H-1: ownership gate (monitor-first). An honest caller either owns the
240 + // booking (logged-in user / admin) or carries the booking_token bound to
241 + // it; only a stranger targeting someone else's booking_id is rejected.
242 + // In monitor mode this just logs and proceeds (zero behaviour change).
243 + if (!$this->requesterOwnsBooking($booking_id, $booking, $booking_token)) {
244 + if (\Yatra\Security\Guard::denied('payment_complete_ownership', [
245 + 'booking_id' => $booking_id,
246 + 'user' => get_current_user_id(),
247 + 'gateway' => $gateway_id,
248 + ])) {
249 + return new WP_REST_Response([
250 + 'success' => false,
251 + 'message' => __('You are not allowed to complete this payment.', 'yatra'),
252 + ], 403);
253 + }
254 + }
255 +
256 + // H-1: server-authoritative amount/currency. Honest clients already send
257 + // the booking's due amount, so this is invisible to them; it removes the
258 + // ability to tamper the charged amount. Override only when enforcing.
259 + $amount = $client_amount;
260 + $currency = $client_currency;
261 + if ($booking) {
262 + $server_amount = (float) ($booking->amount_due ?? 0);
263 + if ($server_amount <= 0) {
264 + $server_amount = (float) ($booking->total_amount ?? 0);
265 + }
266 + $server_currency = (string) ($booking->currency ?? $client_currency);
267 +
268 + if ($server_amount > 0) {
269 + $mismatch = abs($server_amount - $client_amount) > 0.001
270 + || ($client_currency !== '' && $server_currency !== ''
271 + && strcasecmp($client_currency, $server_currency) !== 0);
272 +
273 + if ($mismatch) {
274 + \Yatra\Security\Guard::flag('payment_complete_amount_mismatch', [
275 + 'booking_id' => $booking_id,
276 + 'client_amount' => $client_amount,
277 + 'server_amount' => $server_amount,
278 + ]);
279 + }
280 +
281 + if (\Yatra\Security\Guard::enforcing()) {
282 + $amount = $server_amount;
283 + $currency = $server_currency;
284 + }
285 + }
286 + }
287 +
204 288 // Get the gateway
205 289 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
206 290 $gateway = $registry->get($gateway_id);
207 -
291 +
208 292 if (!$gateway) {
209 293 return new WP_REST_Response([
210 294 'success' => false,
211 295 'message' => __('Invalid payment gateway.', 'yatra'),
@@ -210,9 +294,9 @@
210 294 'success' => false,
211 295 'message' => __('Invalid payment gateway.', 'yatra'),
212 296 ], 400);
213 297 }
214 -
298 +
215 299 // Check if gateway has createPayment method
216 300 if (!method_exists($gateway, 'createPayment')) {
217 301 return new WP_REST_Response([
218 302 'success' => false,
@@ -218,9 +302,9 @@
218 302 'success' => false,
219 303 'message' => __('Gateway does not support this payment method.', 'yatra'),
220 304 ], 400);
221 305 }
222 -
306 +
223 307 // Create the payment
224 308 $result = $gateway->createPayment([
225 309 'source_id' => $source_id,
226 310 'booking_id' => $booking_id,
@@ -226,9 +310,9 @@
226 310 'booking_id' => $booking_id,
227 311 'amount' => $amount,
228 312 'currency' => $currency,
229 313 ]);
230 -
314 +
231 315 if (!$result['success']) {
232 316 return new WP_REST_Response([
233 317 'success' => false,
234 318 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
@@ -233,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,11 +427,16 @@
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 441
294 442 // REST requests don't always carry PHPSESSID into the WP session scope,
@@ -590,8 +738,14 @@
590 738 'coupon_code' => '',
591 739 'payment_method' => 'full',
592 740 ]);
593 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 +
594 748 // Prepare session data - essential trip data (pricing fetched from database on-demand)
595 749 $session_data = [
596 750 'trip_id' => (int) $trip->id,
597 751 'trip_title' => $trip->title,
@@ -705,8 +859,13 @@
705 859 * Clear booking session
706 860 */
707 861 public function clear_session(WP_REST_Request $request): WP_REST_Response
708 862 {
863 + // M-2: restore CSRF protection stripped by public_permission_callback.
864 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
865 + return $blocked;
866 + }
867 +
709 868 yatra_clear_booking_session();
710 869
711 870 return new WP_REST_Response([
712 871 'success' => true,
@@ -737,8 +896,11 @@
737 896 'slug' => $trip->slug,
738 897 'featured_image' => $trip->featured_image,
739 898 'duration_days' => (int) $trip->duration_days,
740 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),
741 903 'difficulty_level' => $trip->difficulty_level,
742 904 'min_travelers' => (int) ($trip->min_travelers ?: 1),
743 905 'max_travelers' => (int) ($trip->max_travelers ?: 20),
744 906 'original_price' => (float) $trip->original_price,
@@ -764,15 +926,340 @@
764 926 *
765 927 * Remaining / balance payment: does not create a booking — only charges the existing row
766 928 * and records a payment on success via the same gateway completion paths.
767 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 +
768 1214 public function create_booking(WP_REST_Request $request): WP_REST_Response
769 1215 {
770 1216 global $wpdb;
771 -
1217 +
772 1218 $data = $request->get_json_params();
773 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 +
774 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 + // ========================================
775 1262 // REMAINING PAYMENT vs NEW BOOKING
776 1263 // ========================================
777 1264 // A leftover PHP session from "pay remaining balance" must not hijack a normal
778 1265 // checkout POST (full traveler payload). Only treat as remaining-payment when the
@@ -803,13 +1290,11 @@
803 1290 // GET BOOKING SETTINGS
804 1291 // ========================================
805 1292 $settings = [
806 1293 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
807 - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1294 + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(),
808 1295 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
809 1296 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
810 - 'cancellation_policy' => \Yatra\Services\SettingsService::get('cancellation_policy', 'full_refund'),
811 - 'cancellation_days' => (int) \Yatra\Services\SettingsService::get('cancellation_days', 7),
812 1297 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
813 1298 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
814 1299 ];
815 1300
@@ -834,8 +1319,27 @@
834 1319 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
835 1320 ], 401);
836 1321 }
837 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 +
838 1342 // Get session data
839 1343 $session = yatra_get_booking_session();
840 1344
841 1345 // REST requests don't always carry PHPSESSID in the same scope as the
@@ -867,42 +1371,163 @@
867 1371 'message' => __('No trip selected for booking.', 'yatra'),
868 1372 ], 400);
869 1373 }
870 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 +
871 1387 // Get contact email - handle both flat and nested formats
872 - $contact_email = $data['contact_email'] ?? '';
1388 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
873 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 + );
874 1399 $contact_first_name = $data['contact_first_name'] ?? '';
875 1400 $contact_last_name = $data['contact_last_name'] ?? '';
876 1401 $contact_country = $data['contact_country'] ?? '';
877 -
878 - $contact_nationality = $data['contact_nationality'] ?? '';
1402 +
1403 + $contact_nationality = $data['contact_nationality'] ?? '';
879 1404 $contact_address = $data['contact_address'] ?? '';
880 -
1405 +
881 1406 // Emergency contact
882 1407 $emergency_name = $data['emergency_name'] ?? '';
883 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
884 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
885 -
1413 +
886 1414 // Travel details
887 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
888 1416 $travelers = $data['travelers'] ?? [];
889 -
890 - // Validate required fields
891 - 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)) {
892 1497 return new WP_REST_Response([
893 1498 'success' => false,
894 - 'message' => __('Email address is required.', 'yatra'),
1499 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
895 1500 ], 400);
896 1501 }
897 -
898 - 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)) {
899 1524 return new WP_REST_Response([
900 1525 'success' => false,
901 1526 'message' => __('Phone number is required.', 'yatra'),
902 1527 ], 400);
903 1528 }
904 -
1529 +
905 1530 if (empty($travel_date)) {
906 1531 return new WP_REST_Response([
907 1532 'success' => false,
908 1533 'message' => __('Travel date is required.', 'yatra'),
@@ -907,9 +1532,9 @@
907 1532 'success' => false,
908 1533 'message' => __('Travel date is required.', 'yatra'),
909 1534 ], 400);
910 1535 }
911 -
1536 +
912 1537 if (empty($travelers) || !is_array($travelers)) {
913 1538 return new WP_REST_Response([
914 1539 'success' => false,
915 1540 'message' => __('At least one traveler is required.', 'yatra'),
@@ -915,14 +1540,27 @@
915 1540 'message' => __('At least one traveler is required.', 'yatra'),
916 1541 ], 400);
917 1542 }
918 1543
919 - // Validate email
920 - if (!is_email($contact_email)) {
921 - return new WP_REST_Response([
922 - 'success' => false,
923 - 'message' => __('Invalid email address.', 'yatra'),
924 - ], 400);
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 + }
925 1563 }
926 1564
927 1565 // Get trip data
928 1566 $trip = $this->tripRepository->findPublished($trip_id);
@@ -1057,8 +1695,20 @@
1057 1695 // Keep $pricing as-is; downstream guard will surface a clean error.
1058 1696 }
1059 1697 }
1060 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 +
1061 1711 // Extract pricing results
1062 1712 $total_amount = $pricing['final_total'];
1063 1713 $amount_due = $pricing['amount_due'];
1064 1714 $amount_paid = $pricing['amount_paid'];
@@ -1092,9 +1742,9 @@
1092 1742 $isWaitlistCheckout = false;
1093 1743
1094 1744 if ($resolvedAvailabilityForWaitlist !== null) {
1095 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1096 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1097 1747 return new WP_REST_Response([
1098 1748 'success' => false,
1099 1749 'message' => __('This departure is not open for booking.', 'yatra'),
1100 1750 'code' => 'date_blocked',
@@ -1155,9 +1805,37 @@
1155 1805 'country' => sanitize_text_field($contact_country),
1156 1806 'nationality' => sanitize_text_field($contact_nationality),
1157 1807 'address' => sanitize_text_field($contact_address),
1158 1808 ];
1159 -
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 +
1160 1838 // Prepare emergency contact data
1161 1839 $emergency_data = [
1162 1840 'name' => sanitize_text_field($emergency_name),
1163 1841 'phone' => sanitize_text_field($emergency_phone),
@@ -1162,8 +1840,31 @@
1162 1840 'name' => sanitize_text_field($emergency_name),
1163 1841 'phone' => sanitize_text_field($emergency_phone),
1164 1842 'relationship' => sanitize_text_field($emergency_relationship),
1165 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 + }
1166 1867
1167 1868 // Sanitize travelers data
1168 1869 $sanitized_travelers = [];
1169 1870 foreach ($travelers as $traveler) {
@@ -1170,8 +1871,13 @@
1170 1871 if (is_array($traveler)) {
1171 1872 $sanitized_traveler = [];
1172 1873 foreach ($traveler as $key => $value) {
1173 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 + }
1174 1880 if (is_array($value)) {
1175 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1176 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1177 1883 }, $value);
@@ -1178,8 +1884,19 @@
1178 1884 } else {
1179 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1180 1886 }
1181 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 + }
1182 1899 $sanitized_travelers[] = $sanitized_traveler;
1183 1900 }
1184 1901 }
1185 1902
@@ -1364,12 +2081,48 @@
1364 2081
1365 2082 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1366 2083 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1367 2084 $booking_data['status'] = 'waitlist';
1368 - $booking_data['payment_gateway'] = 'pay_later';
1369 - $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 + }
1370 2097 }
1371 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 +
1372 2125 try {
1373 2126 $booking = $booking_service->createBooking($booking_data);
1374 2127 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1375 2128 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
@@ -1422,8 +2175,14 @@
1422 2175 * @param int $trip_id The trip ID
1423 2176 * @param array $data The booking request data (contains selected_services)
1424 2177 * @param int $travelers_count Total number of travelers
1425 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.
1426 2185 * @since 3.0.0
1427 2186 */
1428 2187 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1429 2188 if (!isset($data['selected_services'])) {
@@ -1434,9 +2193,9 @@
1434 2193 if (!is_array($data['selected_services'])) {
1435 2194 $data['selected_services'] = [];
1436 2195 }
1437 2196 $data['selected_services'] = array_map('intval', $data['selected_services']);
1438 - do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
2197 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
1439 2198
1440 2199 // ========================================
1441 2200 // SAVE TRAVELLERS TO NORMALIZED TABLES
1442 2201 // ========================================
@@ -1506,8 +2265,117 @@
1506 2265
1507 2266 // Clear booking session
1508 2267 yatra_clear_booking_session();
1509 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 +
1510 2378 // Check if this is an offline gateway
1511 2379 $is_offline = $is_offline_gateway;
1512 2380
1513 2381 // For online gateways, create payment intent and return redirect URL
@@ -1576,21 +2444,29 @@
1576 2444
1577 2445 // ========================================
1578 2446 // DETERMINE BOOKING STATUS
1579 2447 // ========================================
1580 - // Priority:
1581 - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
1582 - // 2. For pay_later: auto_confirm_pay_later setting
1583 - // 3. For bank_transfer: always pending until verified
1584 -
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 +
1585 2455 $booking_status = 'pending';
1586 2456 $status_message = __('Booking received!', 'yatra');
1587 -
1588 - // Check if auto-confirm all bookings is enabled
1589 - if ($settings['auto_confirm_bookings']) {
1590 - // Auto-confirm is enabled - confirm immediately regardless of payment
2457 +
2458 + $auto_confirm_mode = $settings['auto_confirm_mode'] ?? 'none';
2459 + if ($auto_confirm_mode === 'all') {
2460 + // Confirm every booking immediately, regardless of payment.
1591 2461 $booking_status = 'confirmed';
1592 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');
1593 2469 } elseif ($payment_gateway === 'pay_later') {
1594 2470 // Pay Later: Check the specific pay_later auto-confirm setting
1595 2471 if ($settings['auto_confirm_pay_later']) {
1596 2472 $booking_status = 'confirmed';
@@ -1660,10 +2536,8 @@
1660 2536 'payment_gateway' => $payment_gateway,
1661 2537 'total_amount' => $total_amount,
1662 2538 'amount_due' => $amount_due,
1663 2539 'booking_status' => $booking_status,
1664 - 'cancellation_policy' => $settings['cancellation_policy'],
1665 - 'cancellation_days' => $settings['cancellation_days'],
1666 2540 'expiry_datetime' => $expiry_datetime,
1667 2541 ]);
1668 2542 }
1669 2543
@@ -2012,11 +2886,15 @@
2012 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
2013 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2014 2888 // may still append their own query args on top of this URL.
2015 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'] ?? '');
2016 2894 $paymentData = array_merge($params, [
2017 2895 'description' => $params['trip_title'] ?? '',
2018 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2019 2897 'metadata' => [
2020 2898 'booking_id' => $params['booking_id'],
2021 2899 'reference' => $params['reference'] ?? ''
2022 2900 ]
@@ -2065,10 +2943,12 @@
2065 2943 ];
2066 2944 }
2067 2945
2068 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
2069 2949 return [
2070 - 'success' => true,
2950 + 'success' => true,
2071 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2072 2952 ];
2073 2953 }
2074 2954
@@ -2093,8 +2973,75 @@
2093 2973 /**
2094 2974 * Record payment from gateway result
2095 2975 * Matches Stripe's completePayment behavior
2096 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 +
2097 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2098 3045 {
2099 3046 global $wpdb;
2100 3047
@@ -2102,18 +3049,39 @@
2102 3049 $bookingId = (int) $params['booking_id'];
2103 3050 $amount = (float) ($params['amount'] ?? 0);
2104 3051 $currency = $params['currency'] ?? 'USD';
2105 3052 $transactionId = $result['transaction_id'] ?? '';
2106 -
3053 +
2107 3054 // Get booking
2108 3055 $booking = $this->bookingRepository->find($bookingId);
2109 - if (!$booking || $booking->payment_status === 'paid') {
3056 + if (!$booking) {
2110 3057 return;
2111 3058 }
2112 -
2113 - // 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 +
2114 3068 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2115 - $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([
2116 3084 'booking_id' => $bookingId,
2117 3085 'amount' => $amount,
2118 3086 'currency' => $currency,
2119 3087 'gateway' => $gatewayId,
@@ -2118,14 +3086,43 @@
2118 3086 'currency' => $currency,
2119 3087 'gateway' => $gatewayId,
2120 3088 'transaction_id' => $transactionId,
2121 3089 'status' => 'completed',
3090 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
2122 3091 'created_at' => current_time('mysql'),
2123 3092 ]);
2124 -
2125 - // Calculate total paid
2126 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2127 -
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 +
2128 3125 // Fire payment completed action
2129 3126 do_action('yatra_payment_completed', [
2130 3127 'booking_id' => $bookingId,
2131 3128 'transaction_id' => $transactionId,
@@ -2132,11 +3129,12 @@
2132 3129 'amount' => $amount,
2133 3130 'currency' => $currency,
2134 3131 'gateway' => $gatewayId,
2135 3132 ]);
2136 -
2137 - } catch (\Exception $e) {
2138 - }
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 + }
2139 3137 }
2140 3138
2141 3139 /**
2142 3140 * Process PayPal payment
@@ -2190,9 +3188,9 @@
2190 3188 'description' => $params['trip_title'],
2191 3189 ]],
2192 3190 'application_context' => [
2193 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2194 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2195 3193 ],
2196 3194 ]),
2197 3195 ]);
2198 3196
@@ -2311,9 +3309,12 @@
2311 3309 'su' => add_query_arg(
2312 3310 ['payment' => 'success', 'gateway' => 'esewa'],
2313 3311 $this->getConfirmationUrl($params['reference'])
2314 3312 ),
2315 - '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 + ),
2316 3317 ], $base_url);
2317 3318
2318 3319 return ['success' => true, 'payment_url' => $payment_url];
2319 3320 }
@@ -2426,10 +3427,16 @@
2426 3427 $amount_due = $data['amount_due'] ?? 0;
2427 3428 $payment_method = $data['payment_method'] ?? 'full';
2428 3429 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2429 3430 $booking_status = $data['booking_status'] ?? 'pending';
2430 - $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund';
2431 - $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 + : '';
2432 3439 $expiry_datetime = $data['expiry_datetime'] ?? null;
2433 3440
2434 3441 $customer_email = $contact['email'] ?? '';
2435 3442 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
@@ -2446,8 +3453,9 @@
2446 3453 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2447 3454 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2448 3455 if ($booking_status === 'pending' && $expiry_datetime) {
2449 3456 $intro_paragraph .= ' ' . sprintf(
3457 + /* translators: %s: payment expiry date and time (formatted). */
2450 3458 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2451 3459 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2452 3460 );
2453 3461 }
@@ -2457,17 +3465,21 @@
2457 3465 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2458 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2459 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2460 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>
2461 - <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php echo esc_html(sprintf(__('%d days / %d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
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>
2462 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2463 3472 </div>
2464 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2465 - <p><?php echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
3474 + <p><?php /* translators: %s: total amount (formatted). */
3475 +echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
2466 3476 <?php if ($payment_method === 'deposit') : ?>
2467 - <p><?php echo esc_html(sprintf(__('Payment type: Deposit — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
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>
2468 3479 <?php elseif ($payment_method === 'partial') : ?>
2469 - <p><?php echo esc_html(sprintf(__('Payment type: Partial — due now %s, remaining %s', 'yatra'), $formatted_due, yatra_format_price($total_amount - $amount_due))); ?></p>
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>
2470 3482 <?php else : ?>
2471 3483 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2472 3484 <?php endif; ?>
2473 3485 <?php if ($payment_gateway === 'pay_later') : ?>
@@ -2480,26 +3492,26 @@
2480 3492 <?php foreach ($travelers as $i => $traveler) : ?>
2481 3493 <?php
2482 3494 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2483 3495 ?>
2484 - <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>
2485 3498 <?php endforeach; ?>
2486 3499 </ul>
2487 3500 <?php
2488 - $cancellation_policy_labels = [
2489 - 'full_refund' => __('Full refund available', 'yatra'),
2490 - 'partial_refund' => __('Partial refund available', 'yatra'),
2491 - 'no_refund' => __('No refund available', 'yatra'),
2492 - 'flexible' => __('Flexible cancellation', 'yatra'),
2493 - ];
2494 - $policy_label = $cancellation_policy_labels[$cancellation_policy] ?? __('Standard policy applies', 'yatra');
2495 - ?>
2496 - <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
2497 - <p><?php echo esc_html($policy_label); ?> — <?php echo esc_html(sprintf(__('free cancellation up to %d days before departure', 'yatra'), (int) $cancellation_days)); ?></p>
2498 - <?php
2499 - $custom_refund_policy = SettingsService::getString('refund_policy', '');
2500 - if ($custom_refund_policy !== '') {
2501 - echo '<p>' . esc_html($custom_refund_policy) . '</p>';
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
2502 3514 }
2503 3515 ?>
2504 3516 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2505 3517 <ol style="padding-left:20px;">
@@ -2511,9 +3523,25 @@
2511 3523 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2512 3524 <?php
2513 3525 $details_html = ob_get_clean();
2514 3526
2515 - $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, [
2516 3544 'customer_name' => $customer_name,
2517 3545 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2518 3546 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2519 3547 'customer_email' => $customer_email,
@@ -2529,11 +3557,12 @@
2529 3557 'currency' => SettingsService::getCurrency(),
2530 3558 'intro_paragraph' => $intro_paragraph,
2531 3559 'details_html' => $details_html,
2532 3560 'details_html_only' => '1',
3561 + /* translators: %s: site name. */
2533 3562 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2534 3563 'transactional_context' => 'booking_created',
2535 - ];
3564 + ]);
2536 3565
2537 3566 TransactionalEmailTemplateService::sendIfEnabled(
2538 3567 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2539 3568 $customer_email,
@@ -2551,8 +3580,14 @@
2551 3580 {
2552 3581 yatra_start_session();
2553 3582
2554 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 +
2555 3590 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2556 3591
2557 3592 if (empty($code)) {
2558 3593 return new WP_REST_Response([
@@ -2643,8 +3678,326 @@
2643 3678 ]);
2644 3679 }
2645 3680
2646 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 + /**
2647 4000 * Calculate booking summary and return HTML for dynamic updates
2648 4001 * Called via AJAX when traveler count, date, or coupon changes
2649 4002 */
2650 4003 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
@@ -2652,8 +4005,13 @@
2652 4005 yatra_start_session();
2653 4006 $session = yatra_get_booking_session();
2654 4007 $data = $request->get_json_params() ?? [];
2655 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 +
2656 4014 // Same REST-context session-rehydration fallback as set_session() /
2657 4015 // create_booking(): when PHPSESSID isn't propagated to the REST API
2658 4016 // scope, look up the transient by `booking_token` (from request body
2659 4017 // first, then ?booking_token=) so the partial summary refresh
@@ -2774,8 +4132,15 @@
2774 4132 if (!empty($price_types)) {
2775 4133 $resolved_pricing_type = 'traveler_based';
2776 4134 }
2777 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 +
2778 4143 // Enrich availability price_types with category labels if missing
2779 4144 if (!empty($price_types)) {
2780 4145 $missing_label_category_ids = [];
2781 4146 foreach ($price_types as $pt) {
@@ -2893,9 +4258,12 @@
2893 4258 foreach ($price_types as $pt) {
2894 4259 $category_id = $pt->category_id;
2895 4260 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
2896 4261 if ($count > 0) {
2897 - $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);
2898 4266 $category_breakdown[] = [
2899 4267 'category_id' => $category_id,
2900 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2901 4269 'count' => $count,
@@ -2900,8 +4268,13 @@
2900 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2901 4269 'count' => $count,
2902 4270 'price' => (float) $pt->effective_price,
2903 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',
2904 4277 ];
2905 4278 $subtotal += $category_subtotal;
2906 4279 $total_travelers += $count;
2907 4280 }
@@ -2934,13 +4307,14 @@
2934 4307
2935 4308 $priceTypesForDiscount = [];
2936 4309 if ($is_traveler_based) {
2937 4310 foreach ($price_types as $pt) {
2938 - $pt = (object) $pt;
2939 - $priceTypesForDiscount[] = [
2940 - 'category_id' => $pt->category_id ?? null,
2941 - 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
2942 - ];
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;
2943 4317 }
2944 4318 } else {
2945 4319 $priceTypesForDiscount[] = [
2946 4320 'category_id' => 'default',
@@ -3012,13 +4386,13 @@
3012 4386 'payment_method' => $payment_method,
3013 4387 ]);
3014 4388
3015 4389 $pricing = $calculationService->calculateFromSession(
3016 - $calculation_params['session_data'],
3017 - $calculation_params['coupon_code'],
4390 + $calculation_params['session_data'],
4391 + $calculation_params['coupon_code'],
3018 4392 $calculation_params['payment_method']
3019 4393 );
3020 -
4394 +
3021 4395 $total_amount = $pricing['final_total'];
3022 4396 $amount_due = $pricing['amount_due'];
3023 4397 $tax_calculation = $pricing['tax_calculation'];
3024 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
@@ -3023,8 +4397,49 @@
3023 4397 $tax_calculation = $pricing['tax_calculation'];
3024 4398 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
3025 4399 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
3026 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 + }
3027 4442
3028 4443 /**
3029 4444 * Filter: Get additional services for this trip
3030 4445 * Allows premium modules to add extra services to the booking summary
@@ -3127,9 +4542,11 @@
3127 4542 // Pro can already override per-trip via trip.deposit_percentage), then
3128 4543 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
3129 4544 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
3130 4545 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
3131 - $context = ['trip_id' => $trip_id];
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 ?? '')];
3132 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
3133 4550 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
3134 4551 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
3135 4552
@@ -3192,8 +4609,14 @@
3192 4609 // produces them on every recalculation.
3193 4610 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null,
3194 4611 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null,
3195 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'] ?? [],
3196 4619 // Currency for consistent formatting
3197 4620 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3198 4621 ]);
3199 4622
@@ -3308,8 +4731,14 @@
3308 4731 // render contexts.
3309 4732 'dynamic_pricing' => $data['dynamic_pricing'] ?? null,
3310 4733 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null,
3311 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'] ?? [],
3312 4741 'currency' => $data['currency'] ?? null,
3313 4742 ];
3314 4743
3315 4744 // Update session with payment method if provided
@@ -3359,8 +4788,14 @@
3359 4788 {
3360 4789 yatra_start_session();
3361 4790
3362 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 +
3363 4798 $session = yatra_get_booking_session();
3364 4799
3365 4800 // Same booking_token rehydration as apply_coupon — handle REST
3366 4801 // requests that arrive without a propagated PHPSESSID.