PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 2.0.11 All 82 releases
← All changes | app/Controllers/BookingSessionController.php +1532 -121 3.0.4trunk 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,57 @@
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 + $prevStatus = (string) ($booking->status ?? 'pending');
356 + $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
357 + \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus);
358 + } else {
359 + $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
360 + }
266 361 }
267 362 }
268 363 }
269 -
364 +
270 365 return new WP_REST_Response([
271 366 'success' => true,
272 367 'message' => __('Payment completed successfully.', 'yatra'),
273 368 'data' => [
274 - 'transaction_id' => $result['transaction_id'] ?? '',
369 + 'transaction_id' => $transaction_id,
275 370 'status' => $result['status'] ?? 'completed',
276 371 ],
277 372 ]);
278 373 }
@@ -277,8 +372,48 @@
277 372 ]);
278 373 }
279 374
280 375 /**
376 + * Ownership check for booking-session mutations (H-1 / M-2).
377 + *
378 + * Mirrors {@see \Yatra\Controllers\PaymentGatewayController::get_payment_status()}:
379 + * - admins always pass;
380 + * - a registered-user booking requires the owning user;
381 + * - a guest booking (user_id NULL/0) requires the short-lived booking_token
382 + * transient whose stored `booking_id` matches — i.e. the same browser that
383 + * started this checkout. Honest guests always carry that token in the URL.
384 + *
385 + * @param object|null $booking Booking row, or null when not found.
386 + */
387 + private function requesterOwnsBooking(int $bookingId, $booking, string $bookingToken): bool
388 + {
389 + if (current_user_can('manage_options')) {
390 + return true;
391 + }
392 +
393 + if (!$booking) {
394 + return false;
395 + }
396 +
397 + $bookingUserId = (int) ($booking->user_id ?? 0);
398 + $currentUserId = (int) get_current_user_id();
399 +
400 + if ($bookingUserId > 0) {
401 + return $currentUserId === $bookingUserId;
402 + }
403 +
404 + // Guest booking: prove possession of the booking-session token bound to it.
405 + if ($bookingToken !== '') {
406 + $session = get_transient($bookingToken);
407 + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
408 + return true;
409 + }
410 + }
411 +
412 + return false;
413 + }
414 +
415 + /**
281 416 * Set booking session data
282 417 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
283 418 */
284 419 public function set_session(WP_REST_Request $request): WP_REST_Response
@@ -284,11 +419,16 @@
284 419 public function set_session(WP_REST_Request $request): WP_REST_Response
285 420 {
286 421 // Ensure session is started for REST API requests
287 422 yatra_start_session();
288 -
423 +
289 424 $data = $request->get_json_params();
290 425
426 + // M-2: restore CSRF protection stripped by public_permission_callback.
427 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
428 + return $blocked;
429 + }
430 +
291 431 // Check if this is a partial update (updating travelers or services in existing session)
292 432 $existing_session = yatra_get_booking_session();
293 433
294 434 // REST requests don't always carry PHPSESSID into the WP session scope,
@@ -590,8 +730,14 @@
590 730 'coupon_code' => '',
591 731 'payment_method' => 'full',
592 732 ]);
593 733
734 + // Resolve pricing_mode / group-size limits authoritatively from the
735 + // TravelerCategory before persisting, so the checkout breakdown (which
736 + // reads these session price_types) renders a per-group category as a
737 + // flat charge. Per-person categories are unchanged.
738 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
739 +
594 740 // Prepare session data - essential trip data (pricing fetched from database on-demand)
595 741 $session_data = [
596 742 'trip_id' => (int) $trip->id,
597 743 'trip_title' => $trip->title,
@@ -705,8 +851,13 @@
705 851 * Clear booking session
706 852 */
707 853 public function clear_session(WP_REST_Request $request): WP_REST_Response
708 854 {
855 + // M-2: restore CSRF protection stripped by public_permission_callback.
856 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
857 + return $blocked;
858 + }
859 +
709 860 yatra_clear_booking_session();
710 861
711 862 return new WP_REST_Response([
712 863 'success' => true,
@@ -764,15 +915,340 @@
764 915 *
765 916 * Remaining / balance payment: does not create a booking — only charges the existing row
766 917 * and records a payment on success via the same gateway completion paths.
767 918 */
919 + /**
920 + * Validate the booking-scoped CSRF nonce.
921 + *
922 + * Looks first in the `X-Yatra-Booking-Nonce` request header
923 + * (the JS frontend's path), then in JSON body keys used by
924 + * older or non-JS fallback flows. Returns true on a valid
925 + * nonce, false otherwise.
926 + *
927 + * @param WP_REST_Request $request
928 + * @param array<string, mixed>|null $data decoded JSON body
929 + */
930 + private function verifyBookingNonce(WP_REST_Request $request, $data): bool
931 + {
932 + $nonce = (string) $request->get_header('X-Yatra-Booking-Nonce');
933 + if ($nonce === '' && \is_array($data)) {
934 + $nonce = (string) (
935 + $data['_yatra_booking_nonce']
936 + ?? $data['yatra_booking_nonce']
937 + ?? $data['booking_nonce']
938 + ?? ''
939 + );
940 + }
941 + if ($nonce === '') {
942 + return false;
943 + }
944 + return (bool) wp_verify_nonce($nonce, 'yatra_booking_action');
945 + }
946 +
947 + /**
948 + * CSRF guard for the public booking-session mutations (M-2).
949 + *
950 + * `public_permission_callback` strips WP's REST cookie-nonce so guests can
951 + * reach these routes, which would otherwise leave them open to cross-site
952 + * forgery of a visitor's session. This restores protection by requiring at
953 + * least one signal that an honest same-origin checkout always carries:
954 + * - the booking-scoped nonce (`X-Yatra-Booking-Nonce`), or
955 + * - a valid WP REST nonce (`X-WP-Nonce`, the one that was stripped), or
956 + * - a booking_token transient, or
957 + * - an active PHP booking session.
958 + * A blind cross-site POST has none of these.
959 + *
960 + * Monitor-first: returns a 403 response ONLY when the guard is enforcing;
961 + * in monitor mode it logs and returns null so behaviour is unchanged.
962 + *
963 + * @param array<string, mixed>|null $data decoded JSON body (decoded here if null)
964 + * @return WP_REST_Response|null 403 response to short-circuit with, or null to proceed
965 + */
966 + private function guardPublicBookingMutation(WP_REST_Request $request, $data = null): ?WP_REST_Response
967 + {
968 + if ($data === null) {
969 + $data = $request->get_json_params();
970 + }
971 +
972 + // 1) booking-scoped nonce, or 2) the stripped WP REST nonce.
973 + if ($this->verifyBookingNonce($request, $data)) {
974 + return null;
975 + }
976 + $restNonce = (string) $request->get_header('X-WP-Nonce');
977 + if ($restNonce !== '' && wp_verify_nonce($restNonce, 'wp_rest')) {
978 + return null;
979 + }
980 +
981 + // 3) a booking-session token (body first, then ?booking_token=).
982 + $token = '';
983 + if (is_array($data) && !empty($data['booking_token']) && is_string($data['booking_token'])) {
984 + $token = sanitize_text_field((string) $data['booking_token']);
985 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
986 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
987 + }
988 + if ($token !== '' && is_array(get_transient($token))) {
989 + return null;
990 + }
991 +
992 + // 4) an active server-side booking session.
993 + if (function_exists('yatra_get_booking_session')) {
994 + $session = yatra_get_booking_session();
995 + if (!empty($session) && !empty($session['trip_id'])) {
996 + return null;
997 + }
998 + }
999 +
1000 + if (\Yatra\Security\Guard::denied('public_booking_csrf', [
1001 + 'route' => $request->get_route(),
1002 + ])) {
1003 + return new WP_REST_Response([
1004 + 'success' => false,
1005 + 'message' => __('Your session could not be verified. Please refresh the page and try again.', 'yatra'),
1006 + ], 403);
1007 + }
1008 +
1009 + return null;
1010 + }
1011 +
1012 + /**
1013 + * IDs of enabled email-type fields in a single form section.
1014 + *
1015 + * "Email type" follows the same rule as the admin form-builder's
1016 + * "form captures email" notice: a field with type === 'email' OR the
1017 + * conventional id === 'email'. Used so the booking email can be resolved
1018 + * from a CUSTOM email field (e.g. id 'work_email') and not only the locked
1019 + * core `email` field. On a default/un-customised form this returns
1020 + * ['email'] for the contact section and [] for the traveler section, so
1021 + * the downstream resolution collapses to the original behaviour.
1022 + *
1023 + * @param array<string,mixed> $section
1024 + * @return array<int,string>
1025 + */
1026 + private function emailFieldIds(array $section): array
1027 + {
1028 + if (empty($section['fields']) || !is_array($section['fields'])) {
1029 + return [];
1030 + }
1031 + $ids = [];
1032 + foreach ($section['fields'] as $field) {
1033 + if (!is_array($field)) {
1034 + continue;
1035 + }
1036 + $enabled = !isset($field['enabled']) || (bool) $field['enabled'];
1037 + $is_email = (($field['type'] ?? '') === 'email') || (($field['id'] ?? '') === 'email');
1038 + if ($enabled && $is_email && !empty($field['id'])) {
1039 + $ids[] = (string) $field['id'];
1040 + }
1041 + }
1042 + return $ids;
1043 + }
1044 +
1045 + /**
1046 + * Enforce required booking-form fields server-side (Dynamic Form module).
1047 + *
1048 + * Mirrors the frontend's required rules so a crafted request can't omit a
1049 + * required field (built-in or CUSTOM). Only enabled+required fields in
1050 + * enabled sections are checked, honouring the operator's saved config.
1051 + * `email` and contact `phone` are skipped — they have dedicated handling
1052 + * (email resolution + the contact-phone check). Returns an error message,
1053 + * or null when everything required is present.
1054 + *
1055 + * @param array<string,mixed> $form_config
1056 + * @param array<string,mixed> $data
1057 + * @param array<int,mixed> $travelers
1058 + */
1059 + private function validateRequiredFormFields(
1060 + array $form_config,
1061 + array $data,
1062 + array $travelers,
1063 + bool $contact_enabled,
1064 + bool $traveler_enabled
1065 + ): ?string {
1066 + $is_missing = static function ($value): bool {
1067 + return !is_scalar($value) || trim((string) $value) === '';
1068 + };
1069 +
1070 + // --- Contact section (flat contact_<id> keys) ---
1071 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1072 + foreach ($form_config['contact_form']['fields'] as $field) {
1073 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1074 + continue;
1075 + }
1076 + $id = (string) $field['id'];
1077 + if ($id === 'email' || $id === 'phone') {
1078 + continue; // handled by the email resolution + contact-phone check
1079 + }
1080 + if ($is_missing($data['contact_' . $id] ?? null)) {
1081 + /* translators: %s: form field label. */
1082 + return sprintf(__('%s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1083 + }
1084 + }
1085 + }
1086 +
1087 + // --- Emergency section (flat emergency_<id> keys) ---
1088 + $emergency = $form_config['emergency_contact_form'] ?? null;
1089 + $emergency_enabled = is_array($emergency) && (!isset($emergency['enabled']) || (bool) $emergency['enabled']);
1090 + if ($emergency_enabled && !empty($emergency['fields']) && is_array($emergency['fields'])) {
1091 + foreach ($emergency['fields'] as $field) {
1092 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1093 + continue;
1094 + }
1095 + $id = (string) $field['id'];
1096 + if ($is_missing($data['emergency_' . $id] ?? null)) {
1097 + /* translators: %s: emergency contact field label. */
1098 + return sprintf(__('Emergency contact: %s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1099 + }
1100 + }
1101 + }
1102 +
1103 + // --- Traveler section (per-traveler travelers[i][<id>]) ---
1104 + // Skipped when the section is off (book-by-count synthesises travelers).
1105 + if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) {
1106 + $required_traveler_fields = [];
1107 + foreach ($form_config['traveler_form']['fields'] as $field) {
1108 + if (is_array($field) && !empty($field['enabled']) && !empty($field['required']) && !empty($field['id']) && ($field['type'] ?? '') !== 'text_block') {
1109 + $required_traveler_fields[(string) $field['id']] = [
1110 + 'label' => (string) ($field['label'] ?? $field['id']),
1111 + // "lead" fields are only required on the lead traveler;
1112 + // absent/"all" is required on every traveler (legacy).
1113 + 'applies_to' => ($field['applies_to'] ?? 'all'),
1114 + ];
1115 + }
1116 + }
1117 + if (!empty($required_traveler_fields)) {
1118 + $traveler_index = 0;
1119 + foreach ($travelers as $traveler) {
1120 + if (!is_array($traveler)) {
1121 + continue;
1122 + }
1123 + // Only real travelers; skip any contact/emergency pseudo-entries.
1124 + if (isset($traveler['type']) && $traveler['type'] !== 'traveler') {
1125 + continue;
1126 + }
1127 + $traveler_index++;
1128 + foreach ($required_traveler_fields as $fid => $meta) {
1129 + // Lead-only required fields apply to Traveler 1 only.
1130 + if (($meta['applies_to'] ?? 'all') === 'lead' && $traveler_index !== 1) {
1131 + continue;
1132 + }
1133 + if ($is_missing($traveler[$fid] ?? null)) {
1134 + /* translators: 1: traveler number, 2: field label. */
1135 + return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']);
1136 + }
1137 + }
1138 + }
1139 + }
1140 + }
1141 +
1142 + return null;
1143 + }
1144 +
1145 + /**
1146 + * Enforce per-group category size limits at booking time.
1147 + *
1148 + * A traveler category priced "per group" (pricing_mode === 'per_group')
1149 + * charges one flat price for the whole group, bounded by an optional group
1150 + * size range (min_pax / max_pax) configured on the category. This validates
1151 + * the selected headcount for each such category against that range.
1152 + *
1153 + * It is a strict no-op for per-person categories and for per-group
1154 + * categories that have no limit configured, so existing trips are
1155 + * unaffected. Categories that aren't selected (count 0) are skipped.
1156 + *
1157 + * @param array<int, mixed> $price_types Resolved price types (carry pricing_mode/min_pax/max_pax).
1158 + * @param array<int|string, mixed> $traveler_counts Selected count keyed by category id.
1159 + * @return string|null Error message when a limit is violated, otherwise null.
1160 + */
1161 + private function validateGroupSizeLimits(array $price_types, array $traveler_counts): ?string
1162 + {
1163 + foreach ($price_types as $pt) {
1164 + $pt = (array) $pt;
1165 +
1166 + if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') {
1167 + continue;
1168 + }
1169 +
1170 + $cid = $pt['category_id'] ?? null;
1171 + if ($cid === null) {
1172 + continue;
1173 + }
1174 +
1175 + // traveler_counts may be keyed by int or string category id.
1176 + $count = (int) ($traveler_counts[(int) $cid]
1177 + ?? $traveler_counts[(string) $cid]
1178 + ?? 0);
1179 + if ($count <= 0) {
1180 + continue; // category not selected — nothing to validate
1181 + }
1182 +
1183 + $label = $pt['category_label'] ?? ($pt['label'] ?? __('group', 'yatra'));
1184 + $min = (isset($pt['min_pax']) && $pt['min_pax'] !== null && $pt['min_pax'] !== '') ? (int) $pt['min_pax'] : null;
1185 + $max = (isset($pt['max_pax']) && $pt['max_pax'] !== null && $pt['max_pax'] !== '') ? (int) $pt['max_pax'] : null;
1186 + $overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block';
1187 +
1188 + if ($min !== null && $min > 0 && $count < $min) {
1189 + /* translators: 1: category label, 2: minimum group size. */
1190 + return sprintf(__('%1$s requires at least %2$d people.', 'yatra'), $label, $min);
1191 + }
1192 + // In "per_block" mode a party may exceed the max group size — it just
1193 + // buys additional group blocks — so only enforce the max for "block".
1194 + if ($overflow !== 'per_block' && $max !== null && $max > 0 && $count > $max) {
1195 + /* translators: 1: category label, 2: maximum group size. */
1196 + return sprintf(__('%1$s allows a maximum of %2$d people.', 'yatra'), $label, $max);
1197 + }
1198 + }
1199 +
1200 + return null;
1201 + }
1202 +
768 1203 public function create_booking(WP_REST_Request $request): WP_REST_Response
769 1204 {
770 1205 global $wpdb;
771 -
1206 +
772 1207 $data = $request->get_json_params();
773 1208
1209 + // reCAPTCHA v3 — no-op unless the booking form is explicitly protected in
1210 + // settings (off by default so payment flows are never gated unless the
1211 + // operator opts in).
1212 + $recaptcha = \Yatra\Services\RecaptchaService::verifyForm(
1213 + 'booking',
1214 + (string) (($data['recaptcha_token'] ?? '') ?: ''),
1215 + $_SERVER['REMOTE_ADDR'] ?? null
1216 + );
1217 + if (empty($recaptcha['success'])) {
1218 + return new WP_REST_Response([
1219 + 'success' => false,
1220 + 'message' => $recaptcha['message'] ?? __('reCAPTCHA verification failed.', 'yatra'),
1221 + ], 400);
1222 + }
1223 +
774 1224 // ========================================
1225 + // CSRF — booking-scoped action nonce
1226 + // ========================================
1227 + // The public_permission_callback on this route intentionally
1228 + // bypasses WP's default cookie/nonce check so guests can hit it
1229 + // at all. That bypass would otherwise leave the endpoint open
1230 + // to cross-site forgery (any third-party page could POST a
1231 + // booking using the visitor's session).
1232 + //
1233 + // We validate a booking-scoped action nonce here instead. The
1234 + // token is minted at page-render time (FrontendAssetsProvider
1235 + // injects it into `yatraBookingData.bookingNonce`) and the JS
1236 + // forwards it in `X-Yatra-Booking-Nonce`. We also accept it in
1237 + // the JSON body for any non-JS fallback flow.
1238 + //
1239 + // Returns 403 on failure — distinct from the 401 used by the
1240 + // login/guest-checkout gates so frontends can distinguish
1241 + // "security check failed" from "auth required".
1242 + if (!$this->verifyBookingNonce($request, $data)) {
1243 + return new WP_REST_Response([
1244 + 'success' => false,
1245 + 'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'),
1246 + 'code' => 'invalid_nonce',
1247 + ], 403);
1248 + }
1249 +
1250 + // ========================================
775 1251 // REMAINING PAYMENT vs NEW BOOKING
776 1252 // ========================================
777 1253 // A leftover PHP session from "pay remaining balance" must not hijack a normal
778 1254 // checkout POST (full traveler payload). Only treat as remaining-payment when the
@@ -806,10 +1282,8 @@
806 1282 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
807 1283 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
808 1284 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
809 1285 '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 1286 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
813 1287 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
814 1288 ];
815 1289
@@ -834,8 +1308,27 @@
834 1308 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')),
835 1309 ], 401);
836 1310 }
837 1311
1312 + // ========================================
1313 + // GUEST EMAIL VERIFICATION GATE
1314 + // ========================================
1315 + // When `require_guest_email_verification` is on AND the
1316 + // customer is not logged in, the booking goes through a
1317 + // two-step flow:
1318 + // 1. Booking row is created with status='pending_verification'
1319 + // so the operator sees the intent in the admin and the
1320 + // cron can purge unverified rows after N days.
1321 + // 2. A magic-link email is sent. Payment is NOT initiated
1322 + // until the customer clicks the link.
1323 + // 3. On click, /yatra/v1/booking/verify-email validates the
1324 + // HMAC token, flips status to 'pending', and redirects
1325 + // to the payment continuation URL.
1326 + // Logged-in users skip this entirely — their email is already
1327 + // verified by WordPress on registration.
1328 + $needs_email_verification = !is_user_logged_in()
1329 + && (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false);
1330 +
838 1331 // Get session data
839 1332 $session = yatra_get_booking_session();
840 1333
841 1334 // REST requests don't always carry PHPSESSID in the same scope as the
@@ -867,42 +1360,159 @@
867 1360 'message' => __('No trip selected for booking.', 'yatra'),
868 1361 ], 400);
869 1362 }
870 1363
1364 + // Which booking-form sections are enabled (Pro Dynamic Form module).
1365 + // The default config has every section enabled, so on existing/un-customised
1366 + // sites $contact_enabled and $traveler_enabled are both true and the logic
1367 + // below behaves exactly as before — only disabled sections change anything.
1368 + $form_config = function_exists('yatra_get_booking_form_config') ? yatra_get_booking_form_config() : [];
1369 + $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled'];
1370 + $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled'];
1371 +
871 1372 // Get contact email - handle both flat and nested formats
872 - $contact_email = $data['contact_email'] ?? '';
1373 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
873 1374 $contact_phone = $data['contact_phone'] ?? '';
1375 + // International phone widget: fold the chosen country (companion
1376 + // *_country field carrying the ISO) into the number as "+<dial><digits>".
1377 + // A no-op for legacy submissions with no companion field, an already
1378 + // "+"-prefixed value, or an unknown ISO — so existing data is never
1379 + // altered and nothing is invented.
1380 + $contact_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1381 + (string) $contact_phone,
1382 + (string) ($data['contact_phone_country'] ?? '')
1383 + );
874 1384 $contact_first_name = $data['contact_first_name'] ?? '';
875 1385 $contact_last_name = $data['contact_last_name'] ?? '';
876 1386 $contact_country = $data['contact_country'] ?? '';
877 -
878 - $contact_nationality = $data['contact_nationality'] ?? '';
1387 +
1388 + $contact_nationality = $data['contact_nationality'] ?? '';
879 1389 $contact_address = $data['contact_address'] ?? '';
880 -
1390 +
881 1391 // Emergency contact
882 1392 $emergency_name = $data['emergency_name'] ?? '';
883 - $emergency_phone = $data['emergency_phone'] ?? '';
1393 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1394 + (string) ($data['emergency_phone'] ?? ''),
1395 + (string) ($data['emergency_phone_country'] ?? '')
1396 + );
884 1397 $emergency_relationship = $data['emergency_relationship'] ?? '';
885 -
1398 +
886 1399 // Travel details
887 1400 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
888 1401 $travelers = $data['travelers'] ?? [];
889 -
890 - // Validate required fields
891 - if (empty($contact_email)) {
1402 +
1403 + // EMAIL RESOLUTION: prefer the Contact email. When it's missing — the
1404 + // Contact section is off, or the operator collects email through a
1405 + // CUSTOM email-type field rather than the locked core `email` field —
1406 + // resolve it from the form config instead, mirroring the admin
1407 + // "form captures email" notice (contact + traveler sections). At least
1408 + // one enabled form must capture an email; the form builder warns the
1409 + // operator about this too. On a default form the core `email` field
1410 + // already populated $contact_email, so none of the fallbacks run.
1411 +
1412 + // (a) Custom email-type field in the Contact section (submitted as
1413 + // contact_<id>). The core `email` field is already read above, so skip
1414 + // it here.
1415 + if ($contact_email === '' && $contact_enabled) {
1416 + foreach ($this->emailFieldIds($form_config['contact_form'] ?? []) as $fid) {
1417 + if ($fid === 'email') {
1418 + continue;
1419 + }
1420 + $val = trim((string) ($data['contact_' . $fid] ?? ''));
1421 + if ($val !== '' && is_email($val)) {
1422 + $contact_email = $val;
1423 + break;
1424 + }
1425 + }
1426 + }
1427 +
1428 + // (b) Fall back to a traveler email — the conventional `email` key OR
1429 + // any traveler email-type field — adopting the lead traveler's
1430 + // name/phone as the contact when the Contact section is off, so the
1431 + // booking/customer isn't nameless. On a default form this checks only
1432 + // $t['email'], identical to the original behaviour.
1433 + if ($contact_email === '' && is_array($travelers)) {
1434 + $traveler_email_ids = $traveler_enabled
1435 + ? $this->emailFieldIds($form_config['traveler_form'] ?? [])
1436 + : [];
1437 + if (!in_array('email', $traveler_email_ids, true)) {
1438 + $traveler_email_ids[] = 'email';
1439 + }
1440 + foreach ($travelers as $t) {
1441 + if (!is_array($t)) {
1442 + continue;
1443 + }
1444 + $found = '';
1445 + foreach ($traveler_email_ids as $fid) {
1446 + if (!empty($t[$fid]) && is_email((string) $t[$fid])) {
1447 + $found = trim((string) $t[$fid]);
1448 + break;
1449 + }
1450 + }
1451 + if ($found !== '') {
1452 + $contact_email = $found;
1453 + if ($contact_first_name === '') { $contact_first_name = (string) ($t['first_name'] ?? ''); }
1454 + if ($contact_last_name === '') { $contact_last_name = (string) ($t['last_name'] ?? ''); }
1455 + if (empty($contact_phone) && !empty($t['phone'])) { $contact_phone = (string) $t['phone']; }
1456 + break;
1457 + }
1458 + }
1459 + }
1460 +
1461 + // When the Traveler form is disabled there are no per-traveler fields, so
1462 + // build traveler rows from the selected count and use the lead contact as
1463 + // traveler 1 (book-by-count). Only runs when the section is off.
1464 + if (!$traveler_enabled && (empty($travelers) || !is_array($travelers))) {
1465 + $synth_count = (int) ($data['travelers_count']
1466 + ?? $session['travelers']
1467 + ?? (is_array($session['traveler_counts'] ?? null) ? array_sum(array_map('intval', $session['traveler_counts'])) : 0));
1468 + $synth_count = max(1, $synth_count);
1469 + $travelers = [];
1470 + for ($i = 1; $i <= $synth_count; $i++) {
1471 + $travelers[] = [
1472 + 'type' => 'traveler',
1473 + 'first_name' => $i === 1 ? $contact_first_name : '',
1474 + 'last_name' => $i === 1 ? $contact_last_name : '',
1475 + 'email' => $i === 1 ? $contact_email : '',
1476 + ];
1477 + }
1478 + }
1479 +
1480 + // Validate required fields — email is always required (resolved above).
1481 + if ($contact_email === '' || !is_email($contact_email)) {
892 1482 return new WP_REST_Response([
893 1483 'success' => false,
894 - 'message' => __('Email address is required.', 'yatra'),
1484 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
895 1485 ], 400);
896 1486 }
897 -
898 - if (empty($contact_phone)) {
1487 +
1488 + // Phone belongs to the Contact section. Require it only when that section
1489 + // is enabled AND the phone field is itself enabled+required in the config,
1490 + // so an operator who made phone optional (or disabled it) via the Dynamic
1491 + // Form module isn't blocked on a field the customer never saw. On a
1492 + // default form phone is locked+required, so this is unchanged for
1493 + // existing Free/Pro users.
1494 + $contact_phone_required = false;
1495 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1496 + foreach ($form_config['contact_form']['fields'] as $cf) {
1497 + if (is_array($cf) && ($cf['id'] ?? '') === 'phone') {
1498 + $cf_enabled = !isset($cf['enabled']) || (bool) $cf['enabled'];
1499 + $contact_phone_required = $cf_enabled && !empty($cf['required']);
1500 + break;
1501 + }
1502 + }
1503 + } elseif ($contact_enabled) {
1504 + // No field metadata available (legacy/edge): preserve the original
1505 + // "require phone when contact is on" behaviour.
1506 + $contact_phone_required = true;
1507 + }
1508 + if ($contact_phone_required && empty($contact_phone)) {
899 1509 return new WP_REST_Response([
900 1510 'success' => false,
901 1511 'message' => __('Phone number is required.', 'yatra'),
902 1512 ], 400);
903 1513 }
904 -
1514 +
905 1515 if (empty($travel_date)) {
906 1516 return new WP_REST_Response([
907 1517 'success' => false,
908 1518 'message' => __('Travel date is required.', 'yatra'),
@@ -907,9 +1517,9 @@
907 1517 'success' => false,
908 1518 'message' => __('Travel date is required.', 'yatra'),
909 1519 ], 400);
910 1520 }
911 -
1521 +
912 1522 if (empty($travelers) || !is_array($travelers)) {
913 1523 return new WP_REST_Response([
914 1524 'success' => false,
915 1525 'message' => __('At least one traveler is required.', 'yatra'),
@@ -915,14 +1525,27 @@
915 1525 'message' => __('At least one traveler is required.', 'yatra'),
916 1526 ], 400);
917 1527 }
918 1528
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);
1529 + // Server-side enforcement of required form fields (incl. CUSTOM fields).
1530 + // Gated on the Dynamic Form Field module: free/default installs keep their
1531 + // existing validation untouched. Mirrors the frontend's required rules so
1532 + // a crafted request can't bypass them; respects the operator's config
1533 + // (only enabled+required fields in enabled sections are checked).
1534 + if (function_exists('apply_filters') && apply_filters('yatra_dynamic_form_field_enabled', false)) {
1535 + $required_error = $this->validateRequiredFormFields(
1536 + is_array($form_config) ? $form_config : [],
1537 + $data,
1538 + $travelers,
1539 + $contact_enabled,
1540 + $traveler_enabled
1541 + );
1542 + if ($required_error !== null) {
1543 + return new WP_REST_Response([
1544 + 'success' => false,
1545 + 'message' => $required_error,
1546 + ], 400);
1547 + }
925 1548 }
926 1549
927 1550 // Get trip data
928 1551 $trip = $this->tripRepository->findPublished($trip_id);
@@ -1057,8 +1680,20 @@
1057 1680 // Keep $pricing as-is; downstream guard will surface a clean error.
1058 1681 }
1059 1682 }
1060 1683
1684 + // Enforce per-group category size limits (min_pax / max_pax). A per-group
1685 + // category charges one flat price for a group within the configured
1686 + // range, so a selection outside that range must be rejected before we
1687 + // charge. No-op for per-person categories and categories with no limits.
1688 + $group_size_error = $this->validateGroupSizeLimits($pricing['price_types'] ?? [], $traveler_counts);
1689 + if ($group_size_error !== null) {
1690 + return new WP_REST_Response([
1691 + 'success' => false,
1692 + 'message' => $group_size_error,
1693 + ], 400);
1694 + }
1695 +
1061 1696 // Extract pricing results
1062 1697 $total_amount = $pricing['final_total'];
1063 1698 $amount_due = $pricing['amount_due'];
1064 1699 $amount_paid = $pricing['amount_paid'];
@@ -1092,9 +1727,9 @@
1092 1727 $isWaitlistCheckout = false;
1093 1728
1094 1729 if ($resolvedAvailabilityForWaitlist !== null) {
1095 1730 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1096 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1731 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1097 1732 return new WP_REST_Response([
1098 1733 'success' => false,
1099 1734 'message' => __('This departure is not open for booking.', 'yatra'),
1100 1735 'code' => 'date_blocked',
@@ -1155,9 +1790,37 @@
1155 1790 'country' => sanitize_text_field($contact_country),
1156 1791 'nationality' => sanitize_text_field($contact_nationality),
1157 1792 'address' => sanitize_text_field($contact_address),
1158 1793 ];
1159 -
1794 + // Persist every submitted contact_* field (incl. CUSTOM fields the
1795 + // operator added to the form) so the data isn't lost and is usable as
1796 + // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1797 + foreach ($data as $field_key => $field_value) {
1798 + if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1799 + $field_id = substr($field_key, strlen('contact_'));
1800 + if ($field_id === '' || $field_id === 'data') {
1801 + continue;
1802 + }
1803 + // A phone widget's `<field>_country` companion is folded into the
1804 + // phone value below, not stored as its own field.
1805 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1806 + continue;
1807 + }
1808 + if (isset($contact_data[$field_id])) {
1809 + continue;
1810 + }
1811 + $field_string = (string) $field_value;
1812 + // Custom phone field: combine national number + country companion.
1813 + if (isset($data[$field_key . '_country'])) {
1814 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1815 + $field_string,
1816 + (string) $data[$field_key . '_country']
1817 + );
1818 + }
1819 + $contact_data[$field_id] = sanitize_text_field($field_string);
1820 + }
1821 + }
1822 +
1160 1823 // Prepare emergency contact data
1161 1824 $emergency_data = [
1162 1825 'name' => sanitize_text_field($emergency_name),
1163 1826 'phone' => sanitize_text_field($emergency_phone),
@@ -1162,8 +1825,31 @@
1162 1825 'name' => sanitize_text_field($emergency_name),
1163 1826 'phone' => sanitize_text_field($emergency_phone),
1164 1827 'relationship' => sanitize_text_field($emergency_relationship),
1165 1828 ];
1829 + // Same dynamic capture for emergency_* custom fields.
1830 + foreach ($data as $field_key => $field_value) {
1831 + if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1832 + $field_id = substr($field_key, strlen('emergency_'));
1833 + if ($field_id === '' || $field_id === 'contact') {
1834 + continue;
1835 + }
1836 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1837 + continue;
1838 + }
1839 + if (isset($emergency_data[$field_id])) {
1840 + continue;
1841 + }
1842 + $field_string = (string) $field_value;
1843 + if (isset($data[$field_key . '_country'])) {
1844 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1845 + $field_string,
1846 + (string) $data[$field_key . '_country']
1847 + );
1848 + }
1849 + $emergency_data[$field_id] = sanitize_text_field($field_string);
1850 + }
1851 + }
1166 1852
1167 1853 // Sanitize travelers data
1168 1854 $sanitized_travelers = [];
1169 1855 foreach ($travelers as $traveler) {
@@ -1170,8 +1856,13 @@
1170 1856 if (is_array($traveler)) {
1171 1857 $sanitized_traveler = [];
1172 1858 foreach ($traveler as $key => $value) {
1173 1859 $sk = sanitize_key((string) $key);
1860 + // Skip a phone widget's `<field>_country` companion; it is
1861 + // folded into the phone value in the pass below.
1862 + if (substr($sk, -8) === '_country' && isset($traveler[substr((string) $key, 0, -8)])) {
1863 + continue;
1864 + }
1174 1865 if (is_array($value)) {
1175 1866 $sanitized_traveler[$sk] = array_map(static function ($v) {
1176 1867 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1177 1868 }, $value);
@@ -1178,8 +1869,19 @@
1178 1869 } else {
1179 1870 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1180 1871 }
1181 1872 }
1873 + // Combine each phone field with its country companion (national
1874 + // number + dial code → "+<dial><digits>").
1875 + foreach (array_keys($sanitized_traveler) as $tk) {
1876 + $companion = $tk . '_country';
1877 + if (isset($traveler[$companion]) && is_string($sanitized_traveler[$tk])) {
1878 + $sanitized_traveler[$tk] = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1879 + (string) $sanitized_traveler[$tk],
1880 + (string) $traveler[$companion]
1881 + );
1882 + }
1883 + }
1182 1884 $sanitized_travelers[] = $sanitized_traveler;
1183 1885 }
1184 1886 }
1185 1887
@@ -1364,12 +2066,48 @@
1364 2066
1365 2067 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1366 2068 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1367 2069 $booking_data['status'] = 'waitlist';
1368 - $booking_data['payment_gateway'] = 'pay_later';
1369 - $booking_data['payment_method'] = 'full';
2070 + // Preserve the customer's real OFFLINE gateway + deposit/partial
2071 + // choice (Bank Transfer / Pay Later). No charge is taken for a
2072 + // waitlisted slot regardless, and waitlist promotion only flips the
2073 + // status — it never restores the selection — so pinning to
2074 + // pay_later/full here would permanently drop the chosen gateway AND
2075 + // wipe the deposit (BookingService recomputes amount_due from
2076 + // payment_method). Online gateways stay deferred to pay_later/full
2077 + // since a card can't be charged for a non-guaranteed slot.
2078 + if (!$is_offline_gateway) {
2079 + $booking_data['payment_gateway'] = 'pay_later';
2080 + $booking_data['payment_method'] = 'full';
2081 + }
1370 2082 }
1371 2083
2084 + // Hold the booking in `pending_verification` until the guest
2085 + // clicks the magic link. Payment is initiated only after the
2086 + // status flips to 'pending' (in the verify-email endpoint).
2087 + // We also pin the gateway to `pay_later` here because the
2088 + // payment selection at this point would otherwise lock the
2089 + // operator into a specific gateway before the customer has
2090 + // even confirmed their email — better to defer that choice
2091 + // until verification completes and the regular checkout
2092 + // resumes.
2093 + if ($needs_email_verification && !$isWaitlistCheckout) {
2094 + $booking_data['status'] = 'pending_verification';
2095 + // Defer the gateway choice ONLY for online gateways: a real charge
2096 + // would otherwise lock the customer into a gateway before they have
2097 + // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2098 + // Later) there is no charge to defer, and the verify-email endpoint
2099 + // does not restore the selection afterwards — so pinning to
2100 + // pay_later/full here would permanently drop the customer's chosen
2101 + // gateway AND their deposit/partial amount (BookingService recomputes
2102 + // amount_due from payment_method, so 'full' wipes the deposit).
2103 + // Preserve the real selection for offline gateways.
2104 + if (!$is_offline_gateway) {
2105 + $booking_data['payment_gateway'] = 'pay_later';
2106 + $booking_data['payment_method'] = 'full';
2107 + }
2108 + }
2109 +
1372 2110 try {
1373 2111 $booking = $booking_service->createBooking($booking_data);
1374 2112 // BookingService returns ['success'=>bool, 'booking_id'=>int, ...]
1375 2113 $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null;
@@ -1422,8 +2160,14 @@
1422 2160 * @param int $trip_id The trip ID
1423 2161 * @param array $data The booking request data (contains selected_services)
1424 2162 * @param int $travelers_count Total number of travelers
1425 2163 * @param int $duration_days Trip duration in days
2164 + * @param float $base_amount Trip base price (pre-services, pre-discount) —
2165 + * the authoritative base used by the pricing engine for this
2166 + * booking. Listeners persisting percentage-type services price
2167 + * them against this exact value so the saved line-items reconcile
2168 + * with the charged total. Added in a backward-compatible way:
2169 + * existing 5-arg listeners simply ignore it.
1426 2170 * @since 3.0.0
1427 2171 */
1428 2172 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1429 2173 if (!isset($data['selected_services'])) {
@@ -1434,9 +2178,9 @@
1434 2178 if (!is_array($data['selected_services'])) {
1435 2179 $data['selected_services'] = [];
1436 2180 }
1437 2181 $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));
2182 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
1439 2183
1440 2184 // ========================================
1441 2185 // SAVE TRAVELLERS TO NORMALIZED TABLES
1442 2186 // ========================================
@@ -1506,8 +2250,117 @@
1506 2250
1507 2251 // Clear booking session
1508 2252 yatra_clear_booking_session();
1509 2253
2254 + // ========================================
2255 + // GUEST EMAIL VERIFICATION — INTERCEPT
2256 + // ========================================
2257 + // Booking row + travelers + services are already saved at
2258 + // this point with status='pending_verification'. Send the
2259 + // magic-link email, return a structured "check your email"
2260 + // response, and DO NOT initiate payment. The customer's
2261 + // click on the verify-email endpoint transitions the booking
2262 + // to 'pending' and emits the payment-continuation URL.
2263 + if ($needs_email_verification) {
2264 + $verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl(
2265 + (int) $booking_id,
2266 + (string) $contact_data['email']
2267 + );
2268 +
2269 + // Variables piped into the template email. All standard
2270 + // booking merge tags resolve normally (the row exists);
2271 + // we also pass intro_paragraph + footer_note + the
2272 + // expiry banner so operators that haven't customised
2273 + // the template still get good defaults.
2274 + $email_vars = [];
2275 + if ($saved_booking !== null) {
2276 + $email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
2277 + }
2278 + // Belt-and-braces: ensure customer name + email are
2279 + // populated even when variablesFromBooking returned an
2280 + // empty shell (which shouldn't happen, but if it does
2281 + // we don't want the email to render "Hi ,").
2282 + $email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']);
2283 + $email_vars['customer_name'] = (string) ($email_vars['customer_name']
2284 + ?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? '')));
2285 + $email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name']
2286 + ?? ($contact_data['first_name'] ?? ''));
2287 + $email_vars['verification_link'] = $verify_url;
2288 + $email_vars['intro_paragraph'] = __(
2289 + "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.",
2290 + 'yatra'
2291 + );
2292 + $email_vars['footer_note'] = __(
2293 + "If you didn't make this booking, you can safely ignore this email — no charges have been made.",
2294 + 'yatra'
2295 + );
2296 + $email_vars['expiry_notice_html'] = '<strong>'
2297 + . esc_html__('This link expires in 48 hours.', 'yatra')
2298 + . '</strong>';
2299 +
2300 + // Guest-checkout verification prefers the operator's CONFIGURED
2301 + // customer verification template so their customisation is honoured
2302 + // (the guest system template was consolidated away — using the guest
2303 + // type always fell back to the built-in default and ignored the
2304 + // configured one). This email MUST still carry the verification link
2305 + // — a guest can't complete the booking without it — so we only fall
2306 + // back to the built-in GUEST default when the effective customer
2307 + // template would omit {{verification_link}} (an operator can, and on
2308 + // real sites does, customise that template and drop the tag). The
2309 + // check respects Pro-owned DB templates too. Booking copy is injected
2310 + // above via intro_paragraph / footer_note / expiry merge vars.
2311 + //
2312 + // Keep the booking-specific SUBJECT line ("Verify your email to
2313 + // complete your booking") that guests saw before the guest template
2314 + // was consolidated away — reusing the customer template body must not
2315 + // drag along the account-oriented "Verify your email address"
2316 + // subject. This is honoured additively by the renderer / Pro sender
2317 + // via the reserved `_subject_override` var, so only this guest send
2318 + // is affected. Computed before it is stored, so the render below
2319 + // resolves the clean guest subject (no self-reference).
2320 + $email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render(
2321 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2322 + $email_vars
2323 + )['subject'];
2324 + $verificationEmailSent = false;
2325 + if (\Yatra\Services\TransactionalEmailTemplateService::templateRendersVerificationLink(
2326 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION
2327 + )) {
2328 + $verificationEmailSent = \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2329 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION,
2330 + (string) $contact_data['email'],
2331 + $email_vars
2332 + );
2333 + }
2334 + // Guarantee a verification email even if the customer template would
2335 + // drop the link OR its per-type toggle is disabled — a guest can't
2336 + // complete checkout without it. The built-in GUEST default always
2337 + // carries the link. sendIfEnabled() returns whether it actually sent,
2338 + // so this only fires when the preferred send did not (no double send).
2339 + if (!$verificationEmailSent) {
2340 + \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2341 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2342 + (string) $contact_data['email'],
2343 + $email_vars
2344 + );
2345 + }
2346 +
2347 + return new WP_REST_Response([
2348 + 'success' => true,
2349 + 'code' => 'email_verification_required',
2350 + 'message' => __(
2351 + "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.",
2352 + 'yatra'
2353 + ),
2354 + 'data' => [
2355 + 'booking_id' => $booking_id,
2356 + 'reference' => $booking_reference,
2357 + 'email' => $contact_data['email'],
2358 + 'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600),
2359 + ],
2360 + ]);
2361 + }
2362 +
1510 2363 // Check if this is an offline gateway
1511 2364 $is_offline = $is_offline_gateway;
1512 2365
1513 2366 // For online gateways, create payment intent and return redirect URL
@@ -1660,10 +2513,8 @@
1660 2513 'payment_gateway' => $payment_gateway,
1661 2514 'total_amount' => $total_amount,
1662 2515 'amount_due' => $amount_due,
1663 2516 'booking_status' => $booking_status,
1664 - 'cancellation_policy' => $settings['cancellation_policy'],
1665 - 'cancellation_days' => $settings['cancellation_days'],
1666 2517 'expiry_datetime' => $expiry_datetime,
1667 2518 ]);
1668 2519 }
1669 2520
@@ -2012,11 +2863,15 @@
2012 2863 // Default return_url to the configured booking confirmation URL so redirect gateways
2013 2864 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2014 2865 // may still append their own query args on top of this URL.
2015 2866 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2867 + // Cancel returns must land on the booking-confirmation page (always resolvable);
2868 + // `home_url('/book/?...')` 404s under a custom booking base/page. Use the reference,
2869 + // falling back to the booking id so the confirmation route always has a token.
2870 + $cancelRef = $ref !== '' ? $ref : (string) ($params['booking_id'] ?? '');
2016 2871 $paymentData = array_merge($params, [
2017 2872 'description' => $params['trip_title'] ?? '',
2018 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2873 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2019 2874 'metadata' => [
2020 2875 'booking_id' => $params['booking_id'],
2021 2876 'reference' => $params['reference'] ?? ''
2022 2877 ]
@@ -2065,10 +2920,12 @@
2065 2920 ];
2066 2921 }
2067 2922
2068 2923 // For offline gateways or successful direct payments without redirect
2924 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2925 +
2069 2926 return [
2070 - 'success' => true,
2927 + 'success' => true,
2071 2928 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2072 2929 ];
2073 2930 }
2074 2931
@@ -2093,8 +2950,75 @@
2093 2950 /**
2094 2951 * Record payment from gateway result
2095 2952 * Matches Stripe's completePayment behavior
2096 2953 */
2954 + /**
2955 + * Record the awaited payment for an offline gateway (bank transfer, cash on
2956 + * arrival, pay later) as a PENDING ledger row.
2957 + *
2958 + * These gateways take no money at checkout, and previously wrote no payment
2959 + * row at all — so when the transfer finally landed there was nothing in the
2960 + * Payments screen for the operator to mark as received. The booking's own
2961 + * fields were the only record, and marking those by hand left the invoice
2962 + * reporting "Payment Pending" with nothing paid.
2963 + *
2964 + * The row is deliberately `pending`: no money has arrived yet, and
2965 + * getTotalPaidForBooking() counts only `completed`, so booking financials and
2966 + * every report are untouched until the operator confirms it.
2967 + */
2968 + private function recordOfflinePendingPayment(array $params, array $result, string $gatewayId): void
2969 + {
2970 + try {
2971 + $bookingId = (int) ($params['booking_id'] ?? 0);
2972 + $amount = (float) ($params['amount'] ?? 0);
2973 +
2974 + if ($bookingId <= 0 || $amount <= 0) {
2975 + return;
2976 + }
2977 +
2978 + // Only for gateways that settle out of band. Anything reporting a
2979 + // completed/succeeded status already records its own row.
2980 + $status = strtolower((string) ($result['status'] ?? ''));
2981 + if (!in_array($status, ['', 'pending', 'pending_verification'], true)) {
2982 + return;
2983 + }
2984 +
2985 + $booking = $this->bookingRepository->find($bookingId);
2986 + if (!$booking || ($booking->payment_status ?? '') === 'paid') {
2987 + return;
2988 + }
2989 +
2990 + $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2991 +
2992 + // Idempotency: a retried checkout must not stack up duplicate rows.
2993 + foreach ($paymentRepository->findByBookingId($bookingId) as $existing) {
2994 + if ((string) ($existing->gateway ?? '') === $gatewayId
2995 + && in_array((string) ($existing->status ?? ''), ['pending', 'completed'], true)
2996 + ) {
2997 + return;
2998 + }
2999 + }
3000 +
3001 + $paymentRepository->create([
3002 + 'booking_id' => $bookingId,
3003 + 'amount' => $amount,
3004 + 'currency' => $params['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3005 + 'gateway' => $gatewayId,
3006 + 'status' => 'pending',
3007 + 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
3008 + 'notes' => __('Awaiting payment — mark as completed once received.', 'yatra'),
3009 + 'created_at' => current_time('mysql'),
3010 + ]);
3011 + } catch (\Throwable $e) {
3012 + // Never break a successful checkout over a bookkeeping row.
3013 + \Yatra\Utils\Logger::warning('Could not record pending offline payment', [
3014 + 'booking_id' => $params['booking_id'] ?? 0,
3015 + 'gateway' => $gatewayId,
3016 + 'error' => $e->getMessage(),
3017 + ]);
3018 + }
3019 + }
3020 +
2097 3021 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2098 3022 {
2099 3023 global $wpdb;
2100 3024
@@ -2102,18 +3026,39 @@
2102 3026 $bookingId = (int) $params['booking_id'];
2103 3027 $amount = (float) ($params['amount'] ?? 0);
2104 3028 $currency = $params['currency'] ?? 'USD';
2105 3029 $transactionId = $result['transaction_id'] ?? '';
2106 -
3030 +
2107 3031 // Get booking
2108 3032 $booking = $this->bookingRepository->find($bookingId);
2109 - if (!$booking || $booking->payment_status === 'paid') {
3033 + if (!$booking) {
2110 3034 return;
2111 3035 }
2112 -
2113 - // Record the payment using PaymentRepository
3036 +
3037 + // Already settled in full — never apply another charge to it. A fresh
3038 + // booking is never already paid, so in practice this only guards a
3039 + // stray/duplicate completion call (with a different transaction id)
3040 + // against over-applying the ledger.
3041 + if (($booking->payment_status ?? '') === 'paid') {
3042 + return;
3043 + }
3044 +
2114 3045 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2115 - $payment_id = $paymentRepository->create([
3046 +
3047 + // Idempotency guard: skip if this gateway transaction is already
3048 + // recorded for this booking. Prevents duplicate ledger rows when a
3049 + // payment is submitted twice (the gateway uses a fresh idempotency
3050 + // key per call, so it won't dedupe a true retry). Mirrors
3051 + // PaymentGatewayController::handle_successful_payment().
3052 + if ($transactionId !== '') {
3053 + $existing = $paymentRepository->findByTransactionId($transactionId);
3054 + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
3055 + return;
3056 + }
3057 + }
3058 +
3059 + // Record the payment
3060 + $paymentRepository->create([
2116 3061 'booking_id' => $bookingId,
2117 3062 'amount' => $amount,
2118 3063 'currency' => $currency,
2119 3064 'gateway' => $gatewayId,
@@ -2118,14 +3063,42 @@
2118 3063 'currency' => $currency,
2119 3064 'gateway' => $gatewayId,
2120 3065 'transaction_id' => $transactionId,
2121 3066 'status' => 'completed',
3067 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
2122 3068 'created_at' => current_time('mysql'),
2123 3069 ]);
2124 -
2125 - // Calculate total paid
2126 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2127 -
3070 +
3071 + // Update the booking ledger + status. The synchronous gateways
3072 + // (Square, Authorize.Net) reach this generic path but previously left
3073 + // the booking at pending/pending — only the payment row was written.
3074 + // This now matches handle_successful_payment(): accumulate amount_paid,
3075 + // recompute amount_due, set payment_status (paid vs partial), and
3076 + // confirm the booking (a deposit confirms too, consistent with Stripe).
3077 + $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
3078 + $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
3079 + $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
3080 + $previousStatus = (string) ($booking->status ?? 'pending');
3081 +
3082 + // Only auto-confirm when the operator allows it (or fully paid). A
3083 + // deposit / partial payment must not confirm when "Auto-Confirm
3084 + // Bookings" is off.
3085 + $shouldConfirm = \yatra_should_confirm_booking_on_payment($newAmountDue <= 0.0, $bookingId);
3086 +
3087 + $bookingUpdate = [
3088 + 'amount_paid' => $newAmountPaid,
3089 + 'amount_due' => $newAmountDue,
3090 + 'payment_status' => $paymentStatus,
3091 + ];
3092 + if ($shouldConfirm) {
3093 + $bookingUpdate['status'] = 'confirmed';
3094 + }
3095 + $this->bookingRepository->update($bookingId, $bookingUpdate);
3096 +
3097 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
3098 + \yatra_trigger_booking_confirmed($bookingId, $previousStatus);
3099 + }
3100 +
2128 3101 // Fire payment completed action
2129 3102 do_action('yatra_payment_completed', [
2130 3103 'booking_id' => $bookingId,
2131 3104 'transaction_id' => $transactionId,
@@ -2132,11 +3105,12 @@
2132 3105 'amount' => $amount,
2133 3106 'currency' => $currency,
2134 3107 'gateway' => $gatewayId,
2135 3108 ]);
2136 -
2137 - } catch (\Exception $e) {
2138 - }
3109 + } catch (\Throwable $e) {
3110 + // Best-effort: the charge is already recorded; a confirmation-page
3111 + // reload / status reconciliation can recover if this update fails.
3112 + }
2139 3113 }
2140 3114
2141 3115 /**
2142 3116 * Process PayPal payment
@@ -2190,9 +3164,9 @@
2190 3164 'description' => $params['trip_title'],
2191 3165 ]],
2192 3166 'application_context' => [
2193 3167 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2194 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3168 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2195 3169 ],
2196 3170 ]),
2197 3171 ]);
2198 3172
@@ -2311,9 +3285,12 @@
2311 3285 'su' => add_query_arg(
2312 3286 ['payment' => 'success', 'gateway' => 'esewa'],
2313 3287 $this->getConfirmationUrl($params['reference'])
2314 3288 ),
2315 - 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
3289 + 'fu' => add_query_arg(
3290 + ['payment' => 'failed', 'gateway' => 'esewa'],
3291 + $this->getConfirmationUrl($params['reference'])
3292 + ),
2316 3293 ], $base_url);
2317 3294
2318 3295 return ['success' => true, 'payment_url' => $payment_url];
2319 3296 }
@@ -2426,10 +3403,16 @@
2426 3403 $amount_due = $data['amount_due'] ?? 0;
2427 3404 $payment_method = $data['payment_method'] ?? 'full';
2428 3405 $payment_gateway = $data['payment_gateway'] ?? 'pay_later';
2429 3406 $booking_status = $data['booking_status'] ?? 'pending';
2430 - $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund';
2431 - $cancellation_days = $data['cancellation_days'] ?? 7;
3407 + // Cancellation copy in the email now comes from the trip's
3408 + // own cancellation_policy field (set per-trip on the Trip
3409 + // editor), not from removed global settings. Falls back to
3410 + // empty so the paragraph is silently omitted when the trip
3411 + // doesn't have a policy set.
3412 + $trip_cancellation_policy = isset($trip->cancellation_policy)
3413 + ? wp_strip_all_tags((string) $trip->cancellation_policy)
3414 + : '';
2432 3415 $expiry_datetime = $data['expiry_datetime'] ?? null;
2433 3416
2434 3417 $customer_email = $contact['email'] ?? '';
2435 3418 $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? ''));
@@ -2446,8 +3429,9 @@
2446 3429 ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra')
2447 3430 : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra');
2448 3431 if ($booking_status === 'pending' && $expiry_datetime) {
2449 3432 $intro_paragraph .= ' ' . sprintf(
3433 + /* translators: %s: payment expiry date and time (formatted). */
2450 3434 __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'),
2451 3435 date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime))
2452 3436 );
2453 3437 }
@@ -2457,17 +3441,21 @@
2457 3441 <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;">
2458 3442 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2459 3443 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2460 3444 <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>
3445 + <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */
3446 +echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
2462 3447 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2463 3448 </div>
2464 3449 <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>
3450 + <p><?php /* translators: %s: total amount (formatted). */
3451 +echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p>
2466 3452 <?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>
3453 + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3454 +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 3455 <?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>
3456 + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */
3457 +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 3458 <?php else : ?>
2471 3459 <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p>
2472 3460 <?php endif; ?>
2473 3461 <?php if ($payment_gateway === 'pay_later') : ?>
@@ -2480,26 +3468,26 @@
2480 3468 <?php foreach ($travelers as $i => $traveler) : ?>
2481 3469 <?php
2482 3470 $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? ''));
2483 3471 ?>
2484 - <li><?php echo esc_html(sprintf(__('Traveler %d: %s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li>
3472 + <li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */
3473 +echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li>
2485 3474 <?php endforeach; ?>
2486 3475 </ul>
2487 3476 <?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>';
3477 + // Cancellation policy paragraph now sources from the trip's
3478 + // per-trip cancellation_policy field (set on the Trip
3479 + // editor). The previous version used global cancellation
3480 + // settings that were display-only — they appeared here but
3481 + // never enforced a real cancellation cutoff. We've removed
3482 + // those settings; if the trip itself doesn't define a
3483 + // policy, the whole section is silently omitted so the
3484 + // email isn't padded with empty headings.
3485 + if ($trip_cancellation_policy !== '') {
3486 + ?>
3487 + <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3>
3488 + <p><?php echo esc_html($trip_cancellation_policy); ?></p>
3489 + <?php
2502 3490 }
2503 3491 ?>
2504 3492 <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3>
2505 3493 <ol style="padding-left:20px;">
@@ -2511,9 +3499,25 @@
2511 3499 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2512 3500 <?php
2513 3501 $details_html = ob_get_clean();
2514 3502
2515 - $vars = [
3503 + // Seed from the canonical booking variables FIRST so every dynamic
3504 + // merge tag — {{contact_*}} / {{emergency_*}} custom fields,
3505 + // {{traveler_custom_fields_html}}, {{balance_due}}, payment/schedule
3506 + // tags, etc. — resolves on this offline / pay-later path exactly like
3507 + // the online-gateway path (BookingService::sendBookingConfirmationEmail).
3508 + // Previously this method hand-built only ~18 core keys, so an operator
3509 + // who customised the Booking Confirmation template with a custom-field
3510 + // variable saw it render empty on offline bookings. The hand-built keys
3511 + // below (the self-rendered details_html, intro, footer) intentionally
3512 + // take precedence via array_merge ordering.
3513 + $base_vars = [];
3514 + $saved_booking = $this->bookingRepository->find($booking_id);
3515 + if ($saved_booking) {
3516 + $base_vars = TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
3517 + }
3518 +
3519 + $vars = array_merge($base_vars, [
2516 3520 'customer_name' => $customer_name,
2517 3521 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2518 3522 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2519 3523 'customer_email' => $customer_email,
@@ -2529,11 +3533,12 @@
2529 3533 'currency' => SettingsService::getCurrency(),
2530 3534 'intro_paragraph' => $intro_paragraph,
2531 3535 'details_html' => $details_html,
2532 3536 'details_html_only' => '1',
3537 + /* translators: %s: site name. */
2533 3538 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2534 3539 'transactional_context' => 'booking_created',
2535 - ];
3540 + ]);
2536 3541
2537 3542 TransactionalEmailTemplateService::sendIfEnabled(
2538 3543 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2539 3544 $customer_email,
@@ -2551,8 +3556,14 @@
2551 3556 {
2552 3557 yatra_start_session();
2553 3558
2554 3559 $data = $request->get_json_params() ?? [];
3560 +
3561 + // M-2: restore CSRF protection stripped by public_permission_callback.
3562 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3563 + return $blocked;
3564 + }
3565 +
2555 3566 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2556 3567
2557 3568 if (empty($code)) {
2558 3569 return new WP_REST_Response([
@@ -2643,8 +3654,326 @@
2643 3654 ]);
2644 3655 }
2645 3656
2646 3657 /**
3658 + * Verify a guest's booking email via magic-link token.
3659 + *
3660 + * Flow:
3661 + * 1. Validate the HMAC token (forgery, expiry, email-binding).
3662 + * 2. Look up the booking; confirm it's in `pending_verification`.
3663 + * 3. Flip status to `pending` (or `confirmed` when auto-confirm
3664 + * pay-later is enabled for this site) and fire the standard
3665 + * yatra_booking_status_changed action so inventory + email
3666 + * automations resume normally.
3667 + * 4. 302 redirect the browser to a continuation URL:
3668 + * - If amount_due > 0: back to the booking page for the
3669 + * payment step the customer skipped earlier.
3670 + * - If amount_due == 0 / auto-confirm: to the booking
3671 + * confirmation/thank-you page.
3672 + * 5. On any failure, render a friendly HTML page (not JSON) so
3673 + * the customer sees readable text in their browser tab.
3674 + *
3675 + * @return WP_REST_Response|WP_Error|void
3676 + */
3677 + public function verify_email(WP_REST_Request $request)
3678 + {
3679 + $token = (string) $request->get_param('token');
3680 + $bookingRepo = new \Yatra\Repositories\BookingRepository();
3681 +
3682 + // First decode just to extract the booking id (for the
3683 + // expectedEmail lookup). Verify() is called again below
3684 + // with the actual email so the email-binding check runs.
3685 + $partsPreview = explode('.', $token);
3686 + $bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0]))
3687 + ? (int) $partsPreview[0]
3688 + : 0;
3689 + $booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null;
3690 + $expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : '';
3691 +
3692 + $result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail);
3693 +
3694 + if (!$result['ok']) {
3695 + $this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid'));
3696 + }
3697 + if ($booking === null) {
3698 + $this->renderVerifyEmailErrorPage('booking_not_found');
3699 + }
3700 +
3701 + // Re-entrant: if the booking has already been verified, show the
3702 + // same success page (idempotent) — pre-3.0.5 silently redirected
3703 + // and the customer was left wondering whether anything happened.
3704 + $currentStatus = (string) ($booking->status ?? '');
3705 + $alreadyVerified = $currentStatus !== 'pending_verification';
3706 +
3707 + if (!$alreadyVerified) {
3708 + // Flip status to 'pending' so downstream hooks (inventory /
3709 + // notification automations) see fresh data, then fire
3710 + // yatra_booking_created so the listeners we deferred at
3711 + // creation time (admin "new booking" notification + Pro
3712 + // email-automation booking.created fan-out) run now — i.e.
3713 + // *after* the customer has proven the email is theirs.
3714 + $bookingRepo->updateStatus((int) $booking->id, 'pending');
3715 + do_action('yatra_booking_email_verified', (int) $booking->id);
3716 +
3717 + // Re-fetch so the post-verification action receives the
3718 + // booking row with the new status, then fire the deferred
3719 + // booking-created action. See BookingService::createBooking()
3720 + // for the matching skip-on-pending-verification branch.
3721 + $verifiedBooking = $bookingRepo->find((int) $booking->id);
3722 + if (is_object($verifiedBooking)) {
3723 + do_action(
3724 + \Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED,
3725 + (int) $verifiedBooking->id,
3726 + $verifiedBooking
3727 + );
3728 + }
3729 +
3730 + // Guest email-verification defers the customer booking-confirmation
3731 + // email: the checkout flow returns at the verification gate, before
3732 + // its send-site (~line 2465), so the confirmation is never sent for a
3733 + // verified guest booking. Send it now that the email is proven and the
3734 + // booking is live — gated by the same `booking_confirmation` option the
3735 + // checkout paths use. Only in this fresh-verify branch, so a re-clicked
3736 + // link never re-sends. TYPE_BOOKING_CONFIRMATION is skipped by the Pro
3737 + // booking.created fan-out, so this is the single source of the email.
3738 + if ((bool) \Yatra\Services\SettingsService::get('booking_confirmation', true)) {
3739 + try {
3740 + (new \Yatra\Services\BookingService())->sendNewBookingTransactionalConfirmation((int) $booking->id);
3741 + } catch (\Throwable $e) {
3742 + // A mail failure must never break the customer's "verified" page.
3743 + Logger::error('Post-verification booking confirmation email failed', [
3744 + 'booking_id' => (int) $booking->id,
3745 + 'error' => $e->getMessage(),
3746 + ]);
3747 + }
3748 + }
3749 + }
3750 +
3751 + $this->renderVerifyEmailSuccessPage(
3752 + (int) $booking->id,
3753 + (string) ($booking->reference ?? ''),
3754 + $alreadyVerified
3755 + );
3756 + }
3757 +
3758 + /**
3759 + * Friendly HTML error page rendered when a verification link
3760 + * is invalid / expired / tampered with. Avoids JSON in the
3761 + * customer's browser tab (terrible UX). Reasons map to clear
3762 + * messages so customers know what to do next.
3763 + *
3764 + * Emits raw HTML and exits. We can't return WP_REST_Response with an
3765 + * HTML body because the REST server JSON-encodes the response data
3766 + * regardless of the Content-Type header on the response object —
3767 + * the customer would see `"<!doctype..."` (a JSON string) in their
3768 + * browser tab. Echoing + exiting short-circuits the REST pipeline.
3769 + *
3770 + * @return never
3771 + */
3772 + private function renderVerifyEmailErrorPage(string $reason): void
3773 + {
3774 + $messages = [
3775 + 'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'),
3776 + 'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'),
3777 + 'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'),
3778 + 'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'),
3779 + 'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'),
3780 + ];
3781 + $message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra');
3782 +
3783 + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3784 + $html = sprintf(
3785 + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3786 + . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3787 + . '<title>%2$s</title>'
3788 + . '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3789 + . '.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}'
3790 + . 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}'
3791 + . 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>'
3792 + . '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>',
3793 + esc_attr(get_locale()),
3794 + esc_html__('Verification link issue', 'yatra'),
3795 + esc_html__('Verification link issue', 'yatra'),
3796 + esc_html($message),
3797 + esc_url(home_url('/')),
3798 + esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName))
3799 + );
3800 +
3801 + if (!headers_sent()) {
3802 + status_header(200);
3803 + nocache_headers();
3804 + header('Content-Type: text/html; charset=UTF-8');
3805 + }
3806 + echo $html;
3807 + exit;
3808 + }
3809 +
3810 + /**
3811 + * Build the booking continuation URL (post-verification destination).
3812 + *
3813 + * If the site has a Yatra Bookings page, route through that with the
3814 + * booking reference; otherwise fall back to the trip URL. Filterable
3815 + * via `yatra_guest_verification_continuation_url` so integrations can
3816 + * route to a custom thank-you page.
3817 + */
3818 + private function continuationUrl(int $bookingId, string $reference): string
3819 + {
3820 + return (string) apply_filters(
3821 + 'yatra_guest_verification_continuation_url',
3822 + add_query_arg(
3823 + ['booking_id' => $bookingId, 'verified' => '1'],
3824 + home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/')
3825 + ),
3826 + $bookingId,
3827 + $reference
3828 + );
3829 + }
3830 +
3831 + /**
3832 + * Friendly HTML success page rendered after the guest clicks the
3833 + * email-verification magic link.
3834 + *
3835 + * Pre-3.0.5 this endpoint silently 302-redirected to the booking page,
3836 + * which made guests believe nothing had happened — there was no visible
3837 + * "verified" feedback before they landed on the next step. This page
3838 + * gives them an unambiguous confirmation, the booking reference, and
3839 + * three explicit CTAs:
3840 + * - Continue to booking (primary, continuation URL)
3841 + * - My Account (when logged in) / Sign in (when not)
3842 + * - Go to homepage (fallback)
3843 + *
3844 + * Idempotent: when the booking was already verified (re-click on the
3845 + * same link), the heading + copy switch to the "already verified"
3846 + * variant but the CTAs stay the same.
3847 + *
3848 + * Emits raw HTML and exits — same reasoning as
3849 + * {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response
3850 + * JSON-encodes string bodies, so the customer would see
3851 + * `"<!doctype..."` in their tab instead of the rendered page.
3852 + *
3853 + * @return never
3854 + */
3855 + private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void
3856 + {
3857 + // Booking is already persisted at this point and (for a fresh verify)
3858 + // the status flip + booking-created fan-out have just fired. The
3859 + // verified UI's primary action is therefore "View your booking
3860 + // confirmation" — NOT "Continue Booking", which mislabelled the
3861 + // booking as still in-progress and confused customers into thinking
3862 + // they needed to re-submit the form.
3863 + $confirmationUrl = function_exists('yatra_get_booking_confirmation_url')
3864 + ? yatra_get_booking_confirmation_url($reference)
3865 + : $this->continuationUrl($bookingId, $reference);
3866 +
3867 + // Account / login URL — prefer Yatra's account page when present,
3868 + // fall back to wp_login_url() so the page never points nowhere.
3869 + $accountBase = \Yatra\Services\SettingsService::getAccountBase();
3870 + $accountUrl = $accountBase !== ''
3871 + ? home_url('/' . trim($accountBase, '/') . '/')
3872 + : home_url('/');
3873 + $isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in();
3874 + $secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl);
3875 + $secondaryLabel = $isLoggedIn
3876 + ? __('Go to My Account', 'yatra')
3877 + : __('Sign in', 'yatra');
3878 +
3879 + // Logged-in customers always get "Go to My Account". A guest is only
3880 + // offered "Sign in" when an account is genuinely part of the flow —
3881 + // registration is enabled AND guest checkout is not the operating mode.
3882 + // This is a guest email-verification page (guest checkout is normally
3883 + // on), so with guest checkout enabled OR registration disabled there is
3884 + // no account to sign into; the CTA is hidden rather than dangling to a
3885 + // login the guest can't use.
3886 + $registrationEnabled = \Yatra\Services\SettingsService::isEnabled('customer_registration');
3887 + $guestCheckoutEnabled = \Yatra\Services\SettingsService::isEnabled('allow_guest_checkout');
3888 + $showSecondaryCta = $isLoggedIn || ($registrationEnabled && !$guestCheckoutEnabled);
3889 + $secondaryCta = $showSecondaryCta
3890 + ? '<a class="btn btn-secondary" href="' . esc_url($secondaryUrl) . '">' . esc_html($secondaryLabel) . '</a>'
3891 + : '';
3892 +
3893 + $heading = $alreadyVerified
3894 + ? __('Email Already Verified', 'yatra')
3895 + : __('Email Verified', 'yatra');
3896 + $message = $alreadyVerified
3897 + ? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra')
3898 + : __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra');
3899 +
3900 + $primaryLabel = __('View Booking Confirmation', 'yatra');
3901 + $homeLabel = __('Go to Homepage', 'yatra');
3902 + $referenceLabel = __('Booking reference', 'yatra');
3903 + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra';
3904 +
3905 + $referenceLine = $reference !== ''
3906 + ? sprintf(
3907 + '<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>',
3908 + esc_html($referenceLabel),
3909 + esc_html($reference)
3910 + )
3911 + : '';
3912 +
3913 + // Inline-only styling so the page renders correctly regardless of
3914 + // theme stylesheet load order (REST → wp_die/raw HTML response).
3915 + $html = sprintf(
3916 + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">'
3917 + . '<meta name="viewport" content="width=device-width,initial-scale=1">'
3918 + . '<title>%2$s · %3$s</title>'
3919 + . '<style>'
3920 + . 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}'
3921 + . '.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}'
3922 + . '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}'
3923 + . 'h1{font-size:24px;margin:0 0 12px;color:#065f46}'
3924 + . 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}'
3925 + . '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}'
3926 + . '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}'
3927 + . '.ref code{font-weight:600;color:#111827}'
3928 + . '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}'
3929 + . '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}'
3930 + . '.btn-primary{background:#059669;color:#fff}'
3931 + . '.btn-primary:hover{background:#047857}'
3932 + . '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}'
3933 + . '.btn-secondary:hover{background:#f9fafb}'
3934 + . '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}'
3935 + . '</style></head>'
3936 + . '<body><div class="box">'
3937 + . '<div class="tick" aria-hidden="true">'
3938 + . '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">'
3939 + . '<polyline points="20 6 9 17 4 12"></polyline></svg>'
3940 + . '</div>'
3941 + . '<h1>%2$s</h1>'
3942 + . '<p>%4$s</p>'
3943 + . '%5$s'
3944 + . '<div class="actions">'
3945 + . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3946 + . '%8$s'
3947 + . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3948 + . '</div>'
3949 + . '</div></body></html>',
3950 + esc_attr(get_locale()),
3951 + esc_html($heading),
3952 + esc_html($brandName),
3953 + esc_html($message),
3954 + $referenceLine,
3955 + esc_url($confirmationUrl),
3956 + esc_html($primaryLabel),
3957 + // %8 is the fully-built secondary CTA (or '' when hidden — see
3958 + // $showSecondaryCta above). %9 is intentionally empty to keep the
3959 + // positional args aligned with %10/%11.
3960 + $secondaryCta,
3961 + '',
3962 + esc_url(home_url('/')),
3963 + esc_html($homeLabel)
3964 + );
3965 +
3966 + if (!headers_sent()) {
3967 + status_header(200);
3968 + nocache_headers();
3969 + header('Content-Type: text/html; charset=UTF-8');
3970 + }
3971 + echo $html;
3972 + exit;
3973 + }
3974 +
3975 + /**
2647 3976 * Calculate booking summary and return HTML for dynamic updates
2648 3977 * Called via AJAX when traveler count, date, or coupon changes
2649 3978 */
2650 3979 public function calculate_summary(WP_REST_Request $request): WP_REST_Response
@@ -2652,8 +3981,13 @@
2652 3981 yatra_start_session();
2653 3982 $session = yatra_get_booking_session();
2654 3983 $data = $request->get_json_params() ?? [];
2655 3984
3985 + // M-2: restore CSRF protection stripped by public_permission_callback.
3986 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3987 + return $blocked;
3988 + }
3989 +
2656 3990 // Same REST-context session-rehydration fallback as set_session() /
2657 3991 // create_booking(): when PHPSESSID isn't propagated to the REST API
2658 3992 // scope, look up the transient by `booking_token` (from request body
2659 3993 // first, then ?booking_token=) so the partial summary refresh
@@ -2774,8 +4108,15 @@
2774 4108 if (!empty($price_types)) {
2775 4109 $resolved_pricing_type = 'traveler_based';
2776 4110 }
2777 4111
4112 + // Resolve pricing_mode / group-size limits authoritatively from the
4113 + // TravelerCategory so the summary breakdown treats a per-group category
4114 + // as a flat charge. No-op for per-person categories.
4115 + if (!empty($price_types)) {
4116 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
4117 + }
4118 +
2778 4119 // Enrich availability price_types with category labels if missing
2779 4120 if (!empty($price_types)) {
2780 4121 $missing_label_category_ids = [];
2781 4122 foreach ($price_types as $pt) {
@@ -2893,9 +4234,12 @@
2893 4234 foreach ($price_types as $pt) {
2894 4235 $category_id = $pt->category_id;
2895 4236 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
2896 4237 if ($count > 0) {
2897 - $category_subtotal = (float) $pt->effective_price * $count;
4238 + // Single source of truth for the line amount (per-person ×
4239 + // count, flat per-group, or per-block group pricing).
4240 + $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
4241 + $category_subtotal = \Yatra\Services\TripPricingService::categoryLineSubtotal($pt, $count, (float) $pt->effective_price);
2898 4242 $category_breakdown[] = [
2899 4243 'category_id' => $category_id,
2900 4244 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2901 4245 'count' => $count,
@@ -2900,8 +4244,13 @@
2900 4244 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
2901 4245 'count' => $count,
2902 4246 'price' => (float) $pt->effective_price,
2903 4247 'subtotal' => $category_subtotal,
4248 + 'pricing_mode' => $pt_pricing_mode,
4249 + // Carry the group-size knobs so the reconciliation pass
4250 + // below can re-derive the same per-block/flat subtotal.
4251 + 'max_pax' => isset($pt->max_pax) && $pt->max_pax !== null && $pt->max_pax !== '' ? (int) $pt->max_pax : null,
4252 + 'group_overflow' => $pt->group_overflow ?? 'block',
2904 4253 ];
2905 4254 $subtotal += $category_subtotal;
2906 4255 $total_travelers += $count;
2907 4256 }
@@ -2934,13 +4283,14 @@
2934 4283
2935 4284 $priceTypesForDiscount = [];
2936 4285 if ($is_traveler_based) {
2937 4286 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 - ];
4287 + $pt = (array) $pt;
4288 + // Keep pricing_mode / max_pax / group_overflow so the group
4289 + // discount base honours flat and per-block group pricing
4290 + // (not just category_id + effective_price).
4291 + $pt['effective_price'] = $pt['effective_price'] ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
4292 + $priceTypesForDiscount[] = $pt;
2943 4293 }
2944 4294 } else {
2945 4295 $priceTypesForDiscount[] = [
2946 4296 'category_id' => 'default',
@@ -3012,13 +4362,13 @@
3012 4362 'payment_method' => $payment_method,
3013 4363 ]);
3014 4364
3015 4365 $pricing = $calculationService->calculateFromSession(
3016 - $calculation_params['session_data'],
3017 - $calculation_params['coupon_code'],
4366 + $calculation_params['session_data'],
4367 + $calculation_params['coupon_code'],
3018 4368 $calculation_params['payment_method']
3019 4369 );
3020 -
4370 +
3021 4371 $total_amount = $pricing['final_total'];
3022 4372 $amount_due = $pricing['amount_due'];
3023 4373 $tax_calculation = $pricing['tax_calculation'];
3024 4374 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
@@ -3023,8 +4373,49 @@
3023 4373 $tax_calculation = $pricing['tax_calculation'];
3024 4374 $total_tax_amount = $pricing['tax_calculation']['total_tax_amount'];
3025 4375 $tax_inclusive = $pricing['tax_calculation']['tax_inclusive'];
3026 4376 $tax_breakdown = $pricing['tax_calculation']['tax_breakdown'];
4377 +
4378 + // ── Reconcile per-category display prices with CalculationService ─
4379 + //
4380 + // The $category_breakdown computed above (~line 3358) used a SEPARATE
4381 + // DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled.
4382 + // In certain AJAX-recompute contexts (e.g. switching payment_method)
4383 + // that loop could miss DP — for example when a stored availability
4384 + // row's price_types already had a pre-DP effective_price baked in,
4385 + // or when a date-sensitive DP rule didn't fire because the request
4386 + // didn't carry the same departure_date context.
4387 + //
4388 + // CalculationService is the single source of truth for booking math;
4389 + // it already computed the correct post-DP per-category prices and
4390 + // returned them as `category_prices_post_dp` (keyed by string
4391 + // category_id). Reconcile the display breakdown against that map so
4392 + // "Adult x 8 ($131.12 x 8)" can never disagree with the Trip
4393 + // Subtotal the rest of the page is built from.
4394 + if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) {
4395 + $catPricesPostDp = $pricing['category_prices_post_dp'];
4396 + $reconciledSubtotal = 0.0;
4397 + foreach ($category_breakdown as &$cat) {
4398 + $cid = isset($cat['category_id']) ? (string) $cat['category_id'] : '';
4399 + if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
4400 + $authoritativePrice = (float) $catPricesPostDp[$cid];
4401 + $count = (int) ($cat['count'] ?? 0);
4402 + $cat['price'] = $authoritativePrice;
4403 + // Re-derive the line amount from the authoritative post-DP
4404 + // price using the same rule as the charge (flat per-group,
4405 + // per-block, or per-person × count).
4406 + $cat['subtotal'] = \Yatra\Services\TripPricingService::categoryLineSubtotal($cat, $count, $authoritativePrice);
4407 + }
4408 + $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
4409 + }
4410 + unset($cat);
4411 + // Keep the top-level $subtotal in sync with the reconciled
4412 + // breakdown so any downstream renderers that read it (instead
4413 + // of $pricing['base_amount']) still see consistent numbers.
4414 + if ($reconciledSubtotal > 0) {
4415 + $subtotal = $reconciledSubtotal;
4416 + }
4417 + }
3027 4418
3028 4419 /**
3029 4420 * Filter: Get additional services for this trip
3030 4421 * Allows premium modules to add extra services to the booking summary
@@ -3127,9 +4518,11 @@
3127 4518 // Pro can already override per-trip via trip.deposit_percentage), then
3128 4519 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
3129 4520 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
3130 4521 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
3131 - $context = ['trip_id' => $trip_id];
4522 + // Tour start → Pro can force full payment when the tour is within the
4523 + // balance-due window (tour-anchored scheduled payments).
4524 + $context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')];
3132 4525 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
3133 4526 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
3134 4527 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
3135 4528
@@ -3192,8 +4585,14 @@
3192 4585 // produces them on every recalculation.
3193 4586 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null,
3194 4587 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null,
3195 4588 'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0,
4589 + // Authoritative post-DP per-category map. Checkout::getCategoryBreakdown
4590 + // prefers this over the session's $pt->effective_price (which can be
4591 + // pre-DP after a stored availability row's price_types come in
4592 + // pre-baked), so forwarding it here is what keeps the AJAX-rendered
4593 + // "Adult x N ($X x N)" row in sync with the actual Trip Subtotal.
4594 + 'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [],
3196 4595 // Currency for consistent formatting
3197 4596 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3198 4597 ]);
3199 4598
@@ -3308,8 +4707,14 @@
3308 4707 // render contexts.
3309 4708 'dynamic_pricing' => $data['dynamic_pricing'] ?? null,
3310 4709 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null,
3311 4710 'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0,
4711 + // Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown
4712 + // keys off this to override the (potentially stale / pre-DP)
4713 + // $pt->effective_price coming from session price_types — without
4714 + // it, the AJAX recompute renders pre-DP rows while the rest of
4715 + // the summary uses the post-DP base amount.
4716 + 'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [],
3312 4717 'currency' => $data['currency'] ?? null,
3313 4718 ];
3314 4719
3315 4720 // Update session with payment method if provided
@@ -3359,8 +4764,14 @@
3359 4764 {
3360 4765 yatra_start_session();
3361 4766
3362 4767 $data = $request->get_json_params() ?? [];
4768 +
4769 + // M-2: restore CSRF protection stripped by public_permission_callback.
4770 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4771 + return $blocked;
4772 + }
4773 +
3363 4774 $session = yatra_get_booking_session();
3364 4775
3365 4776 // Same booking_token rehydration as apply_coupon — handle REST
3366 4777 // requests that arrive without a propagated PHPSESSID.