PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | app/Controllers/BookingSessionController.php +1032 -112 3.0.5.1 → 3.0.16 View file →
@@ -205,27 +205,91 @@
205 205 * Used by Square, and other gateways that tokenize on client
206 206 */
207 207 public function complete_gateway_payment(WP_REST_Request $request): WP_REST_Response
208 208 {
209 - $gateway_id = $request->get_param('gateway');
209 + $gateway_id = sanitize_key((string) $request->get_param('gateway'));
210 210 $data = $request->get_json_params();
211 -
212 - $booking_id = $data['booking_id'] ?? 0;
213 - $source_id = $data['source_id'] ?? '';
214 - $amount = $data['amount'] ?? 0;
215 - $currency = $data['currency'] ?? 'USD';
216 -
217 - 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 === '') {
218 221 return new WP_REST_Response([
219 222 'success' => false,
220 223 'message' => __('Missing required payment data.', 'yatra'),
221 224 ], 400);
222 225 }
223 -
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 +
224 288 // Get the gateway
225 289 $registry = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance();
226 290 $gateway = $registry->get($gateway_id);
227 -
291 +
228 292 if (!$gateway) {
229 293 return new WP_REST_Response([
230 294 'success' => false,
231 295 'message' => __('Invalid payment gateway.', 'yatra'),
@@ -230,9 +294,9 @@
230 294 'success' => false,
231 295 'message' => __('Invalid payment gateway.', 'yatra'),
232 296 ], 400);
233 297 }
234 -
298 +
235 299 // Check if gateway has createPayment method
236 300 if (!method_exists($gateway, 'createPayment')) {
237 301 return new WP_REST_Response([
238 302 'success' => false,
@@ -238,9 +302,9 @@
238 302 'success' => false,
239 303 'message' => __('Gateway does not support this payment method.', 'yatra'),
240 304 ], 400);
241 305 }
242 -
306 +
243 307 // Create the payment
244 308 $result = $gateway->createPayment([
245 309 'source_id' => $source_id,
246 310 'booking_id' => $booking_id,
@@ -246,9 +310,9 @@
246 310 'booking_id' => $booking_id,
247 311 'amount' => $amount,
248 312 'currency' => $currency,
249 313 ]);
250 -
314 +
251 315 if (!$result['success']) {
252 316 return new WP_REST_Response([
253 317 'success' => false,
254 318 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
@@ -253,46 +317,65 @@
253 317 'success' => false,
254 318 'message' => $result['error'] ?? __('Payment failed.', 'yatra'),
255 319 ], 400);
256 320 }
257 -
321 +
322 + $transaction_id = (string) ($result['transaction_id'] ?? '');
323 +
258 324 // Update booking payment status
259 - $bookingRepository = new \Yatra\Repositories\BookingRepository();
260 - $booking = $bookingRepository->find($booking_id);
261 -
262 325 if ($booking) {
263 - // Record the payment using PaymentRepository
264 326 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
265 - $paymentRepository->create([
266 - 'booking_id' => $booking_id,
267 - 'amount' => $amount,
268 - 'currency' => $currency,
269 - 'gateway' => $gateway_id,
270 - 'transaction_id' => $result['transaction_id'] ?? '',
271 - 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
272 - ]);
273 -
274 - // Update booking status if payment is complete
275 - if (($result['status'] ?? 'completed') === 'completed') {
276 - // Get total paid amount
277 - $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
278 - $total_amount = (float) $booking->total_amount;
279 -
280 - if ($total_paid >= $total_amount) {
281 - $prevStatus = (string) ($booking->status ?? 'pending');
282 - $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
283 - \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus);
284 - } else {
285 - $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
327 +
328 + // Idempotency guard: never double-record the same gateway transaction
329 + // for the same booking (e.g. a retried submit or a webhook racing this
330 + // call). Safe always-on — only blocks a duplicate, never a first payment.
331 + $alreadyRecorded = false;
332 + if ($transaction_id !== '' && method_exists($paymentRepository, 'findByTransactionId')) {
333 + $existing = $paymentRepository->findByTransactionId($transaction_id);
334 + $alreadyRecorded = $existing && (int) ($existing->booking_id ?? 0) === $booking_id;
335 + }
336 +
337 + if (!$alreadyRecorded) {
338 + // Record the payment using PaymentRepository
339 + $paymentRepository->create([
340 + 'booking_id' => $booking_id,
341 + 'amount' => $amount,
342 + 'currency' => $currency,
343 + 'gateway' => $gateway_id,
344 + 'transaction_id' => $transaction_id,
345 + 'status' => ($result['status'] ?? 'completed') === 'completed' ? 'completed' : 'pending',
346 + ]);
347 +
348 + // Update booking status if payment is complete
349 + if (($result['status'] ?? 'completed') === 'completed') {
350 + // Get total paid amount
351 + $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id);
352 + $total_amount = (float) $booking->total_amount;
353 +
354 + if ($total_paid >= $total_amount) {
355 + // Fully paid. Respect the Auto-Confirm mode (same as every
356 + // other payment-completion path) — only confirm when the
357 + // mode is 'online' or 'all'; otherwise record the payment
358 + // and leave the booking pending for manual confirmation.
359 + $prevStatus = (string) ($booking->status ?? 'pending');
360 + if (\yatra_should_confirm_booking_on_payment(true, (int) $booking_id)) {
361 + $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']);
362 + \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus, true);
363 + } else {
364 + $bookingRepository->update($booking_id, ['payment_status' => 'paid']);
365 + }
366 + } else {
367 + $bookingRepository->update($booking_id, ['payment_status' => 'partial']);
368 + }
286 369 }
287 370 }
288 371 }
289 -
372 +
290 373 return new WP_REST_Response([
291 374 'success' => true,
292 375 'message' => __('Payment completed successfully.', 'yatra'),
293 376 'data' => [
294 - 'transaction_id' => $result['transaction_id'] ?? '',
377 + 'transaction_id' => $transaction_id,
295 378 'status' => $result['status'] ?? 'completed',
296 379 ],
297 380 ]);
298 381 }
@@ -297,8 +380,48 @@
297 380 ]);
298 381 }
299 382
300 383 /**
384 + * Ownership check for booking-session mutations (H-1 / M-2).
385 + *
386 + * Mirrors {@see \Yatra\Controllers\PaymentGatewayController::get_payment_status()}:
387 + * - admins always pass;
388 + * - a registered-user booking requires the owning user;
389 + * - a guest booking (user_id NULL/0) requires the short-lived booking_token
390 + * transient whose stored `booking_id` matches — i.e. the same browser that
391 + * started this checkout. Honest guests always carry that token in the URL.
392 + *
393 + * @param object|null $booking Booking row, or null when not found.
394 + */
395 + private function requesterOwnsBooking(int $bookingId, $booking, string $bookingToken): bool
396 + {
397 + if (current_user_can('manage_options')) {
398 + return true;
399 + }
400 +
401 + if (!$booking) {
402 + return false;
403 + }
404 +
405 + $bookingUserId = (int) ($booking->user_id ?? 0);
406 + $currentUserId = (int) get_current_user_id();
407 +
408 + if ($bookingUserId > 0) {
409 + return $currentUserId === $bookingUserId;
410 + }
411 +
412 + // Guest booking: prove possession of the booking-session token bound to it.
413 + if ($bookingToken !== '') {
414 + $session = get_transient($bookingToken);
415 + if (is_array($session) && (int) ($session['booking_id'] ?? 0) === $bookingId) {
416 + return true;
417 + }
418 + }
419 +
420 + return false;
421 + }
422 +
423 + /**
301 424 * Set booking session data
302 425 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
303 426 */
304 427 public function set_session(WP_REST_Request $request): WP_REST_Response
@@ -304,11 +427,16 @@
304 427 public function set_session(WP_REST_Request $request): WP_REST_Response
305 428 {
306 429 // Ensure session is started for REST API requests
307 430 yatra_start_session();
308 -
431 +
309 432 $data = $request->get_json_params();
310 433
434 + // M-2: restore CSRF protection stripped by public_permission_callback.
435 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
436 + return $blocked;
437 + }
438 +
311 439 // Check if this is a partial update (updating travelers or services in existing session)
312 440 $existing_session = yatra_get_booking_session();
313 441
314 442 // REST requests don't always carry PHPSESSID into the WP session scope,
@@ -610,8 +738,14 @@
610 738 'coupon_code' => '',
611 739 'payment_method' => 'full',
612 740 ]);
613 741
742 + // Resolve pricing_mode / group-size limits authoritatively from the
743 + // TravelerCategory before persisting, so the checkout breakdown (which
744 + // reads these session price_types) renders a per-group category as a
745 + // flat charge. Per-person categories are unchanged.
746 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
747 +
614 748 // Prepare session data - essential trip data (pricing fetched from database on-demand)
615 749 $session_data = [
616 750 'trip_id' => (int) $trip->id,
617 751 'trip_title' => $trip->title,
@@ -725,8 +859,13 @@
725 859 * Clear booking session
726 860 */
727 861 public function clear_session(WP_REST_Request $request): WP_REST_Response
728 862 {
863 + // M-2: restore CSRF protection stripped by public_permission_callback.
864 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
865 + return $blocked;
866 + }
867 +
729 868 yatra_clear_booking_session();
730 869
731 870 return new WP_REST_Response([
732 871 'success' => true,
@@ -757,8 +896,11 @@
757 896 'slug' => $trip->slug,
758 897 'featured_image' => $trip->featured_image,
759 898 'duration_days' => (int) $trip->duration_days,
760 899 'duration_nights' => (int) $trip->duration_nights,
900 + // Hour-based day tours (0 on every day-based trip). Additive field:
901 + // existing consumers keep reading duration_days/duration_nights.
902 + 'duration_hours' => (int) ($trip->duration_hours ?? 0),
761 903 'difficulty_level' => $trip->difficulty_level,
762 904 'min_travelers' => (int) ($trip->min_travelers ?: 1),
763 905 'max_travelers' => (int) ($trip->max_travelers ?: 20),
764 906 'original_price' => (float) $trip->original_price,
@@ -812,8 +954,264 @@
812 954 }
813 955 return (bool) wp_verify_nonce($nonce, 'yatra_booking_action');
814 956 }
815 957
958 + /**
959 + * CSRF guard for the public booking-session mutations (M-2).
960 + *
961 + * `public_permission_callback` strips WP's REST cookie-nonce so guests can
962 + * reach these routes, which would otherwise leave them open to cross-site
963 + * forgery of a visitor's session. This restores protection by requiring at
964 + * least one signal that an honest same-origin checkout always carries:
965 + * - the booking-scoped nonce (`X-Yatra-Booking-Nonce`), or
966 + * - a valid WP REST nonce (`X-WP-Nonce`, the one that was stripped), or
967 + * - a booking_token transient, or
968 + * - an active PHP booking session.
969 + * A blind cross-site POST has none of these.
970 + *
971 + * Monitor-first: returns a 403 response ONLY when the guard is enforcing;
972 + * in monitor mode it logs and returns null so behaviour is unchanged.
973 + *
974 + * @param array<string, mixed>|null $data decoded JSON body (decoded here if null)
975 + * @return WP_REST_Response|null 403 response to short-circuit with, or null to proceed
976 + */
977 + private function guardPublicBookingMutation(WP_REST_Request $request, $data = null): ?WP_REST_Response
978 + {
979 + if ($data === null) {
980 + $data = $request->get_json_params();
981 + }
982 +
983 + // 1) booking-scoped nonce, or 2) the stripped WP REST nonce.
984 + if ($this->verifyBookingNonce($request, $data)) {
985 + return null;
986 + }
987 + $restNonce = (string) $request->get_header('X-WP-Nonce');
988 + if ($restNonce !== '' && wp_verify_nonce($restNonce, 'wp_rest')) {
989 + return null;
990 + }
991 +
992 + // 3) a booking-session token (body first, then ?booking_token=).
993 + $token = '';
994 + if (is_array($data) && !empty($data['booking_token']) && is_string($data['booking_token'])) {
995 + $token = sanitize_text_field((string) $data['booking_token']);
996 + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) {
997 + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token']));
998 + }
999 + if ($token !== '' && is_array(get_transient($token))) {
1000 + return null;
1001 + }
1002 +
1003 + // 4) an active server-side booking session.
1004 + if (function_exists('yatra_get_booking_session')) {
1005 + $session = yatra_get_booking_session();
1006 + if (!empty($session) && !empty($session['trip_id'])) {
1007 + return null;
1008 + }
1009 + }
1010 +
1011 + if (\Yatra\Security\Guard::denied('public_booking_csrf', [
1012 + 'route' => $request->get_route(),
1013 + ])) {
1014 + return new WP_REST_Response([
1015 + 'success' => false,
1016 + 'message' => __('Your session could not be verified. Please refresh the page and try again.', 'yatra'),
1017 + ], 403);
1018 + }
1019 +
1020 + return null;
1021 + }
1022 +
1023 + /**
1024 + * IDs of enabled email-type fields in a single form section.
1025 + *
1026 + * "Email type" follows the same rule as the admin form-builder's
1027 + * "form captures email" notice: a field with type === 'email' OR the
1028 + * conventional id === 'email'. Used so the booking email can be resolved
1029 + * from a CUSTOM email field (e.g. id 'work_email') and not only the locked
1030 + * core `email` field. On a default/un-customised form this returns
1031 + * ['email'] for the contact section and [] for the traveler section, so
1032 + * the downstream resolution collapses to the original behaviour.
1033 + *
1034 + * @param array<string,mixed> $section
1035 + * @return array<int,string>
1036 + */
1037 + private function emailFieldIds(array $section): array
1038 + {
1039 + if (empty($section['fields']) || !is_array($section['fields'])) {
1040 + return [];
1041 + }
1042 + $ids = [];
1043 + foreach ($section['fields'] as $field) {
1044 + if (!is_array($field)) {
1045 + continue;
1046 + }
1047 + $enabled = !isset($field['enabled']) || (bool) $field['enabled'];
1048 + $is_email = (($field['type'] ?? '') === 'email') || (($field['id'] ?? '') === 'email');
1049 + if ($enabled && $is_email && !empty($field['id'])) {
1050 + $ids[] = (string) $field['id'];
1051 + }
1052 + }
1053 + return $ids;
1054 + }
1055 +
1056 + /**
1057 + * Enforce required booking-form fields server-side (Dynamic Form module).
1058 + *
1059 + * Mirrors the frontend's required rules so a crafted request can't omit a
1060 + * required field (built-in or CUSTOM). Only enabled+required fields in
1061 + * enabled sections are checked, honouring the operator's saved config.
1062 + * `email` and contact `phone` are skipped — they have dedicated handling
1063 + * (email resolution + the contact-phone check). Returns an error message,
1064 + * or null when everything required is present.
1065 + *
1066 + * @param array<string,mixed> $form_config
1067 + * @param array<string,mixed> $data
1068 + * @param array<int,mixed> $travelers
1069 + */
1070 + private function validateRequiredFormFields(
1071 + array $form_config,
1072 + array $data,
1073 + array $travelers,
1074 + bool $contact_enabled,
1075 + bool $traveler_enabled
1076 + ): ?string {
1077 + $is_missing = static function ($value): bool {
1078 + return !is_scalar($value) || trim((string) $value) === '';
1079 + };
1080 +
1081 + // --- Contact section (flat contact_<id> keys) ---
1082 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1083 + foreach ($form_config['contact_form']['fields'] as $field) {
1084 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1085 + continue;
1086 + }
1087 + $id = (string) $field['id'];
1088 + if ($id === 'email' || $id === 'phone') {
1089 + continue; // handled by the email resolution + contact-phone check
1090 + }
1091 + if ($is_missing($data['contact_' . $id] ?? null)) {
1092 + /* translators: %s: form field label. */
1093 + return sprintf(__('%s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1094 + }
1095 + }
1096 + }
1097 +
1098 + // --- Emergency section (flat emergency_<id> keys) ---
1099 + $emergency = $form_config['emergency_contact_form'] ?? null;
1100 + $emergency_enabled = is_array($emergency) && (!isset($emergency['enabled']) || (bool) $emergency['enabled']);
1101 + if ($emergency_enabled && !empty($emergency['fields']) && is_array($emergency['fields'])) {
1102 + foreach ($emergency['fields'] as $field) {
1103 + if (!is_array($field) || empty($field['enabled']) || empty($field['required']) || empty($field['id']) || ($field['type'] ?? '') === 'text_block') {
1104 + continue;
1105 + }
1106 + $id = (string) $field['id'];
1107 + if ($is_missing($data['emergency_' . $id] ?? null)) {
1108 + /* translators: %s: emergency contact field label. */
1109 + return sprintf(__('Emergency contact: %s is required.', 'yatra'), (string) ($field['label'] ?? $id));
1110 + }
1111 + }
1112 + }
1113 +
1114 + // --- Traveler section (per-traveler travelers[i][<id>]) ---
1115 + // Skipped when the section is off (book-by-count synthesises travelers).
1116 + if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) {
1117 + $required_traveler_fields = [];
1118 + foreach ($form_config['traveler_form']['fields'] as $field) {
1119 + if (is_array($field) && !empty($field['enabled']) && !empty($field['required']) && !empty($field['id']) && ($field['type'] ?? '') !== 'text_block') {
1120 + $required_traveler_fields[(string) $field['id']] = [
1121 + 'label' => (string) ($field['label'] ?? $field['id']),
1122 + // "lead" fields are only required on the lead traveler;
1123 + // absent/"all" is required on every traveler (legacy).
1124 + 'applies_to' => ($field['applies_to'] ?? 'all'),
1125 + ];
1126 + }
1127 + }
1128 + if (!empty($required_traveler_fields)) {
1129 + $traveler_index = 0;
1130 + foreach ($travelers as $traveler) {
1131 + if (!is_array($traveler)) {
1132 + continue;
1133 + }
1134 + // Only real travelers; skip any contact/emergency pseudo-entries.
1135 + if (isset($traveler['type']) && $traveler['type'] !== 'traveler') {
1136 + continue;
1137 + }
1138 + $traveler_index++;
1139 + foreach ($required_traveler_fields as $fid => $meta) {
1140 + // Lead-only required fields apply to Traveler 1 only.
1141 + if (($meta['applies_to'] ?? 'all') === 'lead' && $traveler_index !== 1) {
1142 + continue;
1143 + }
1144 + if ($is_missing($traveler[$fid] ?? null)) {
1145 + /* translators: 1: traveler number, 2: field label. */
1146 + return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']);
1147 + }
1148 + }
1149 + }
1150 + }
1151 + }
1152 +
1153 + return null;
1154 + }
1155 +
1156 + /**
1157 + * Enforce per-group category size limits at booking time.
1158 + *
1159 + * A traveler category priced "per group" (pricing_mode === 'per_group')
1160 + * charges one flat price for the whole group, bounded by an optional group
1161 + * size range (min_pax / max_pax) configured on the category. This validates
1162 + * the selected headcount for each such category against that range.
1163 + *
1164 + * It is a strict no-op for per-person categories and for per-group
1165 + * categories that have no limit configured, so existing trips are
1166 + * unaffected. Categories that aren't selected (count 0) are skipped.
1167 + *
1168 + * @param array<int, mixed> $price_types Resolved price types (carry pricing_mode/min_pax/max_pax).
1169 + * @param array<int|string, mixed> $traveler_counts Selected count keyed by category id.
1170 + * @return string|null Error message when a limit is violated, otherwise null.
1171 + */
1172 + private function validateGroupSizeLimits(array $price_types, array $traveler_counts): ?string
1173 + {
1174 + foreach ($price_types as $pt) {
1175 + $pt = (array) $pt;
1176 +
1177 + if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') {
1178 + continue;
1179 + }
1180 +
1181 + $cid = $pt['category_id'] ?? null;
1182 + if ($cid === null) {
1183 + continue;
1184 + }
1185 +
1186 + // traveler_counts may be keyed by int or string category id.
1187 + $count = (int) ($traveler_counts[(int) $cid]
1188 + ?? $traveler_counts[(string) $cid]
1189 + ?? 0);
1190 + if ($count <= 0) {
1191 + continue; // category not selected — nothing to validate
1192 + }
1193 +
1194 + $label = $pt['category_label'] ?? ($pt['label'] ?? __('group', 'yatra'));
1195 + $min = (isset($pt['min_pax']) && $pt['min_pax'] !== null && $pt['min_pax'] !== '') ? (int) $pt['min_pax'] : null;
1196 + $max = (isset($pt['max_pax']) && $pt['max_pax'] !== null && $pt['max_pax'] !== '') ? (int) $pt['max_pax'] : null;
1197 + $overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block';
1198 +
1199 + if ($min !== null && $min > 0 && $count < $min) {
1200 + /* translators: 1: category label, 2: minimum group size. */
1201 + return sprintf(__('%1$s requires at least %2$d people.', 'yatra'), $label, $min);
1202 + }
1203 + // In "per_block" mode a party may exceed the max group size — it just
1204 + // buys additional group blocks — so only enforce the max for "block".
1205 + if ($overflow !== 'per_block' && $max !== null && $max > 0 && $count > $max) {
1206 + /* translators: 1: category label, 2: maximum group size. */
1207 + return sprintf(__('%1$s allows a maximum of %2$d people.', 'yatra'), $label, $max);
1208 + }
1209 + }
1210 +
1211 + return null;
1212 + }
1213 +
816 1214 public function create_booking(WP_REST_Request $request): WP_REST_Response
817 1215 {
818 1216 global $wpdb;
819 1217
@@ -818,8 +1216,23 @@
818 1216 global $wpdb;
819 1217
820 1218 $data = $request->get_json_params();
821 1219
1220 + // reCAPTCHA v3 — no-op unless the booking form is explicitly protected in
1221 + // settings (off by default so payment flows are never gated unless the
1222 + // operator opts in).
1223 + $recaptcha = \Yatra\Services\RecaptchaService::verifyForm(
1224 + 'booking',
1225 + (string) (($data['recaptcha_token'] ?? '') ?: ''),
1226 + $_SERVER['REMOTE_ADDR'] ?? null
1227 + );
1228 + if (empty($recaptcha['success'])) {
1229 + return new WP_REST_Response([
1230 + 'success' => false,
1231 + 'message' => $recaptcha['message'] ?? __('reCAPTCHA verification failed.', 'yatra'),
1232 + ], 400);
1233 + }
1234 +
822 1235 // ========================================
823 1236 // CSRF — booking-scoped action nonce
824 1237 // ========================================
825 1238 // The public_permission_callback on this route intentionally
@@ -877,9 +1290,9 @@
877 1290 // GET BOOKING SETTINGS
878 1291 // ========================================
879 1292 $settings = [
880 1293 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
881 - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1294 + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(),
882 1295 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
883 1296 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
884 1297 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
885 1298 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
@@ -958,42 +1371,163 @@
958 1371 'message' => __('No trip selected for booking.', 'yatra'),
959 1372 ], 400);
960 1373 }
961 1374
1375 + // Which booking-form sections are enabled (Pro Dynamic Form module).
1376 + // The default config has every section enabled, so on existing/un-customised
1377 + // sites $contact_enabled and $traveler_enabled are both true and the logic
1378 + // below behaves exactly as before — only disabled sections change anything.
1379 + // Scoped to the trip being booked — the same config the checkout
1380 + // rendered, so a field hidden for this trip is never treated as required.
1381 + $form_config = function_exists('yatra_get_booking_form_config')
1382 + ? yatra_get_booking_form_config($trip_id > 0 ? (int) $trip_id : null)
1383 + : [];
1384 + $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled'];
1385 + $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled'];
1386 +
962 1387 // Get contact email - handle both flat and nested formats
963 - $contact_email = $data['contact_email'] ?? '';
1388 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
964 1389 $contact_phone = $data['contact_phone'] ?? '';
1390 + // International phone widget: fold the chosen country (companion
1391 + // *_country field carrying the ISO) into the number as "+<dial><digits>".
1392 + // A no-op for legacy submissions with no companion field, an already
1393 + // "+"-prefixed value, or an unknown ISO — so existing data is never
1394 + // altered and nothing is invented.
1395 + $contact_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1396 + (string) $contact_phone,
1397 + (string) ($data['contact_phone_country'] ?? '')
1398 + );
965 1399 $contact_first_name = $data['contact_first_name'] ?? '';
966 1400 $contact_last_name = $data['contact_last_name'] ?? '';
967 1401 $contact_country = $data['contact_country'] ?? '';
968 -
969 - $contact_nationality = $data['contact_nationality'] ?? '';
1402 +
1403 + $contact_nationality = $data['contact_nationality'] ?? '';
970 1404 $contact_address = $data['contact_address'] ?? '';
971 -
1405 +
972 1406 // Emergency contact
973 1407 $emergency_name = $data['emergency_name'] ?? '';
974 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
975 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
976 -
1413 +
977 1414 // Travel details
978 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
979 1416 $travelers = $data['travelers'] ?? [];
980 -
981 - // Validate required fields
982 - if (empty($contact_email)) {
1417 +
1418 + // EMAIL RESOLUTION: prefer the Contact email. When it's missing — the
1419 + // Contact section is off, or the operator collects email through a
1420 + // CUSTOM email-type field rather than the locked core `email` field —
1421 + // resolve it from the form config instead, mirroring the admin
1422 + // "form captures email" notice (contact + traveler sections). At least
1423 + // one enabled form must capture an email; the form builder warns the
1424 + // operator about this too. On a default form the core `email` field
1425 + // already populated $contact_email, so none of the fallbacks run.
1426 +
1427 + // (a) Custom email-type field in the Contact section (submitted as
1428 + // contact_<id>). The core `email` field is already read above, so skip
1429 + // it here.
1430 + if ($contact_email === '' && $contact_enabled) {
1431 + foreach ($this->emailFieldIds($form_config['contact_form'] ?? []) as $fid) {
1432 + if ($fid === 'email') {
1433 + continue;
1434 + }
1435 + $val = trim((string) ($data['contact_' . $fid] ?? ''));
1436 + if ($val !== '' && is_email($val)) {
1437 + $contact_email = $val;
1438 + break;
1439 + }
1440 + }
1441 + }
1442 +
1443 + // (b) Fall back to a traveler email — the conventional `email` key OR
1444 + // any traveler email-type field — adopting the lead traveler's
1445 + // name/phone as the contact when the Contact section is off, so the
1446 + // booking/customer isn't nameless. On a default form this checks only
1447 + // $t['email'], identical to the original behaviour.
1448 + if ($contact_email === '' && is_array($travelers)) {
1449 + $traveler_email_ids = $traveler_enabled
1450 + ? $this->emailFieldIds($form_config['traveler_form'] ?? [])
1451 + : [];
1452 + if (!in_array('email', $traveler_email_ids, true)) {
1453 + $traveler_email_ids[] = 'email';
1454 + }
1455 + foreach ($travelers as $t) {
1456 + if (!is_array($t)) {
1457 + continue;
1458 + }
1459 + $found = '';
1460 + foreach ($traveler_email_ids as $fid) {
1461 + if (!empty($t[$fid]) && is_email((string) $t[$fid])) {
1462 + $found = trim((string) $t[$fid]);
1463 + break;
1464 + }
1465 + }
1466 + if ($found !== '') {
1467 + $contact_email = $found;
1468 + if ($contact_first_name === '') { $contact_first_name = (string) ($t['first_name'] ?? ''); }
1469 + if ($contact_last_name === '') { $contact_last_name = (string) ($t['last_name'] ?? ''); }
1470 + if (empty($contact_phone) && !empty($t['phone'])) { $contact_phone = (string) $t['phone']; }
1471 + break;
1472 + }
1473 + }
1474 + }
1475 +
1476 + // When the Traveler form is disabled there are no per-traveler fields, so
1477 + // build traveler rows from the selected count and use the lead contact as
1478 + // traveler 1 (book-by-count). Only runs when the section is off.
1479 + if (!$traveler_enabled && (empty($travelers) || !is_array($travelers))) {
1480 + $synth_count = (int) ($data['travelers_count']
1481 + ?? $session['travelers']
1482 + ?? (is_array($session['traveler_counts'] ?? null) ? array_sum(array_map('intval', $session['traveler_counts'])) : 0));
1483 + $synth_count = max(1, $synth_count);
1484 + $travelers = [];
1485 + for ($i = 1; $i <= $synth_count; $i++) {
1486 + $travelers[] = [
1487 + 'type' => 'traveler',
1488 + 'first_name' => $i === 1 ? $contact_first_name : '',
1489 + 'last_name' => $i === 1 ? $contact_last_name : '',
1490 + 'email' => $i === 1 ? $contact_email : '',
1491 + ];
1492 + }
1493 + }
1494 +
1495 + // Validate required fields — email is always required (resolved above).
1496 + if ($contact_email === '' || !is_email($contact_email)) {
983 1497 return new WP_REST_Response([
984 1498 'success' => false,
985 - 'message' => __('Email address is required.', 'yatra'),
1499 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
986 1500 ], 400);
987 1501 }
988 -
989 - if (empty($contact_phone)) {
1502 +
1503 + // Phone belongs to the Contact section. Require it only when that section
1504 + // is enabled AND the phone field is itself enabled+required in the config,
1505 + // so an operator who made phone optional (or disabled it) via the Dynamic
1506 + // Form module isn't blocked on a field the customer never saw. On a
1507 + // default form phone is locked+required, so this is unchanged for
1508 + // existing Free/Pro users.
1509 + $contact_phone_required = false;
1510 + if ($contact_enabled && !empty($form_config['contact_form']['fields']) && is_array($form_config['contact_form']['fields'])) {
1511 + foreach ($form_config['contact_form']['fields'] as $cf) {
1512 + if (is_array($cf) && ($cf['id'] ?? '') === 'phone') {
1513 + $cf_enabled = !isset($cf['enabled']) || (bool) $cf['enabled'];
1514 + $contact_phone_required = $cf_enabled && !empty($cf['required']);
1515 + break;
1516 + }
1517 + }
1518 + } elseif ($contact_enabled) {
1519 + // No field metadata available (legacy/edge): preserve the original
1520 + // "require phone when contact is on" behaviour.
1521 + $contact_phone_required = true;
1522 + }
1523 + if ($contact_phone_required && empty($contact_phone)) {
990 1524 return new WP_REST_Response([
991 1525 'success' => false,
992 1526 'message' => __('Phone number is required.', 'yatra'),
993 1527 ], 400);
994 1528 }
995 -
1529 +
996 1530 if (empty($travel_date)) {
997 1531 return new WP_REST_Response([
998 1532 'success' => false,
999 1533 'message' => __('Travel date is required.', 'yatra'),
@@ -998,9 +1532,9 @@
998 1532 'success' => false,
999 1533 'message' => __('Travel date is required.', 'yatra'),
1000 1534 ], 400);
1001 1535 }
1002 -
1536 +
1003 1537 if (empty($travelers) || !is_array($travelers)) {
1004 1538 return new WP_REST_Response([
1005 1539 'success' => false,
1006 1540 'message' => __('At least one traveler is required.', 'yatra'),
@@ -1006,14 +1540,27 @@
1006 1540 'message' => __('At least one traveler is required.', 'yatra'),
1007 1541 ], 400);
1008 1542 }
1009 1543
1010 - // Validate email
1011 - if (!is_email($contact_email)) {
1012 - return new WP_REST_Response([
1013 - 'success' => false,
1014 - 'message' => __('Invalid email address.', 'yatra'),
1015 - ], 400);
1544 + // Server-side enforcement of required form fields (incl. CUSTOM fields).
1545 + // Gated on the Dynamic Form Field module: free/default installs keep their
1546 + // existing validation untouched. Mirrors the frontend's required rules so
1547 + // a crafted request can't bypass them; respects the operator's config
1548 + // (only enabled+required fields in enabled sections are checked).
1549 + if (function_exists('apply_filters') && apply_filters('yatra_dynamic_form_field_enabled', false)) {
1550 + $required_error = $this->validateRequiredFormFields(
1551 + is_array($form_config) ? $form_config : [],
1552 + $data,
1553 + $travelers,
1554 + $contact_enabled,
1555 + $traveler_enabled
1556 + );
1557 + if ($required_error !== null) {
1558 + return new WP_REST_Response([
1559 + 'success' => false,
1560 + 'message' => $required_error,
1561 + ], 400);
1562 + }
1016 1563 }
1017 1564
1018 1565 // Get trip data
1019 1566 $trip = $this->tripRepository->findPublished($trip_id);
@@ -1148,8 +1695,20 @@
1148 1695 // Keep $pricing as-is; downstream guard will surface a clean error.
1149 1696 }
1150 1697 }
1151 1698
1699 + // Enforce per-group category size limits (min_pax / max_pax). A per-group
1700 + // category charges one flat price for a group within the configured
1701 + // range, so a selection outside that range must be rejected before we
1702 + // charge. No-op for per-person categories and categories with no limits.
1703 + $group_size_error = $this->validateGroupSizeLimits($pricing['price_types'] ?? [], $traveler_counts);
1704 + if ($group_size_error !== null) {
1705 + return new WP_REST_Response([
1706 + 'success' => false,
1707 + 'message' => $group_size_error,
1708 + ], 400);
1709 + }
1710 +
1152 1711 // Extract pricing results
1153 1712 $total_amount = $pricing['final_total'];
1154 1713 $amount_due = $pricing['amount_due'];
1155 1714 $amount_paid = $pricing['amount_paid'];
@@ -1183,9 +1742,9 @@
1183 1742 $isWaitlistCheckout = false;
1184 1743
1185 1744 if ($resolvedAvailabilityForWaitlist !== null) {
1186 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1187 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1188 1747 return new WP_REST_Response([
1189 1748 'success' => false,
1190 1749 'message' => __('This departure is not open for booking.', 'yatra'),
1191 1750 'code' => 'date_blocked',
@@ -1246,9 +1805,37 @@
1246 1805 'country' => sanitize_text_field($contact_country),
1247 1806 'nationality' => sanitize_text_field($contact_nationality),
1248 1807 'address' => sanitize_text_field($contact_address),
1249 1808 ];
1250 -
1809 + // Persist every submitted contact_* field (incl. CUSTOM fields the
1810 + // operator added to the form) so the data isn't lost and is usable as
1811 + // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1812 + foreach ($data as $field_key => $field_value) {
1813 + if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1814 + $field_id = substr($field_key, strlen('contact_'));
1815 + if ($field_id === '' || $field_id === 'data') {
1816 + continue;
1817 + }
1818 + // A phone widget's `<field>_country` companion is folded into the
1819 + // phone value below, not stored as its own field.
1820 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1821 + continue;
1822 + }
1823 + if (isset($contact_data[$field_id])) {
1824 + continue;
1825 + }
1826 + $field_string = (string) $field_value;
1827 + // Custom phone field: combine national number + country companion.
1828 + if (isset($data[$field_key . '_country'])) {
1829 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1830 + $field_string,
1831 + (string) $data[$field_key . '_country']
1832 + );
1833 + }
1834 + $contact_data[$field_id] = sanitize_text_field($field_string);
1835 + }
1836 + }
1837 +
1251 1838 // Prepare emergency contact data
1252 1839 $emergency_data = [
1253 1840 'name' => sanitize_text_field($emergency_name),
1254 1841 'phone' => sanitize_text_field($emergency_phone),
@@ -1253,8 +1840,31 @@
1253 1840 'name' => sanitize_text_field($emergency_name),
1254 1841 'phone' => sanitize_text_field($emergency_phone),
1255 1842 'relationship' => sanitize_text_field($emergency_relationship),
1256 1843 ];
1844 + // Same dynamic capture for emergency_* custom fields.
1845 + foreach ($data as $field_key => $field_value) {
1846 + if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1847 + $field_id = substr($field_key, strlen('emergency_'));
1848 + if ($field_id === '' || $field_id === 'contact') {
1849 + continue;
1850 + }
1851 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1852 + continue;
1853 + }
1854 + if (isset($emergency_data[$field_id])) {
1855 + continue;
1856 + }
1857 + $field_string = (string) $field_value;
1858 + if (isset($data[$field_key . '_country'])) {
1859 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1860 + $field_string,
1861 + (string) $data[$field_key . '_country']
1862 + );
1863 + }
1864 + $emergency_data[$field_id] = sanitize_text_field($field_string);
1865 + }
1866 + }
1257 1867
1258 1868 // Sanitize travelers data
1259 1869 $sanitized_travelers = [];
1260 1870 foreach ($travelers as $traveler) {
@@ -1261,8 +1871,13 @@
1261 1871 if (is_array($traveler)) {
1262 1872 $sanitized_traveler = [];
1263 1873 foreach ($traveler as $key => $value) {
1264 1874 $sk = sanitize_key((string) $key);
1875 + // Skip a phone widget's `<field>_country` companion; it is
1876 + // folded into the phone value in the pass below.
1877 + if (substr($sk, -8) === '_country' && isset($traveler[substr((string) $key, 0, -8)])) {
1878 + continue;
1879 + }
1265 1880 if (is_array($value)) {
1266 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1267 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1268 1883 }, $value);
@@ -1269,8 +1884,19 @@
1269 1884 } else {
1270 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1271 1886 }
1272 1887 }
1888 + // Combine each phone field with its country companion (national
1889 + // number + dial code → "+<dial><digits>").
1890 + foreach (array_keys($sanitized_traveler) as $tk) {
1891 + $companion = $tk . '_country';
1892 + if (isset($traveler[$companion]) && is_string($sanitized_traveler[$tk])) {
1893 + $sanitized_traveler[$tk] = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1894 + (string) $sanitized_traveler[$tk],
1895 + (string) $traveler[$companion]
1896 + );
1897 + }
1898 + }
1273 1899 $sanitized_travelers[] = $sanitized_traveler;
1274 1900 }
1275 1901 }
1276 1902
@@ -1455,10 +2081,20 @@
1455 2081
1456 2082 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1457 2083 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1458 2084 $booking_data['status'] = 'waitlist';
1459 - $booking_data['payment_gateway'] = 'pay_later';
1460 - $booking_data['payment_method'] = 'full';
2085 + // Preserve the customer's real OFFLINE gateway + deposit/partial
2086 + // choice (Bank Transfer / Pay Later). No charge is taken for a
2087 + // waitlisted slot regardless, and waitlist promotion only flips the
2088 + // status — it never restores the selection — so pinning to
2089 + // pay_later/full here would permanently drop the chosen gateway AND
2090 + // wipe the deposit (BookingService recomputes amount_due from
2091 + // payment_method). Online gateways stay deferred to pay_later/full
2092 + // since a card can't be charged for a non-guaranteed slot.
2093 + if (!$is_offline_gateway) {
2094 + $booking_data['payment_gateway'] = 'pay_later';
2095 + $booking_data['payment_method'] = 'full';
2096 + }
1461 2097 }
1462 2098
1463 2099 // Hold the booking in `pending_verification` until the guest
1464 2100 // clicks the magic link. Payment is initiated only after the
@@ -1470,10 +2106,21 @@
1470 2106 // until verification completes and the regular checkout
1471 2107 // resumes.
1472 2108 if ($needs_email_verification && !$isWaitlistCheckout) {
1473 2109 $booking_data['status'] = 'pending_verification';
1474 - $booking_data['payment_gateway'] = 'pay_later';
1475 - $booking_data['payment_method'] = 'full';
2110 + // Defer the gateway choice ONLY for online gateways: a real charge
2111 + // would otherwise lock the customer into a gateway before they have
2112 + // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2113 + // Later) there is no charge to defer, and the verify-email endpoint
2114 + // does not restore the selection afterwards — so pinning to
2115 + // pay_later/full here would permanently drop the customer's chosen
2116 + // gateway AND their deposit/partial amount (BookingService recomputes
2117 + // amount_due from payment_method, so 'full' wipes the deposit).
2118 + // Preserve the real selection for offline gateways.
2119 + if (!$is_offline_gateway) {
2120 + $booking_data['payment_gateway'] = 'pay_later';
2121 + $booking_data['payment_method'] = 'full';
2122 + }
1476 2123 }
1477 2124
1478 2125 try {
1479 2126 $booking = $booking_service->createBooking($booking_data);
@@ -1528,8 +2175,14 @@
1528 2175 * @param int $trip_id The trip ID
1529 2176 * @param array $data The booking request data (contains selected_services)
1530 2177 * @param int $travelers_count Total number of travelers
1531 2178 * @param int $duration_days Trip duration in days
2179 + * @param float $base_amount Trip base price (pre-services, pre-discount) —
2180 + * the authoritative base used by the pricing engine for this
2181 + * booking. Listeners persisting percentage-type services price
2182 + * them against this exact value so the saved line-items reconcile
2183 + * with the charged total. Added in a backward-compatible way:
2184 + * existing 5-arg listeners simply ignore it.
1532 2185 * @since 3.0.0
1533 2186 */
1534 2187 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1535 2188 if (!isset($data['selected_services'])) {
@@ -1540,9 +2193,9 @@
1540 2193 if (!is_array($data['selected_services'])) {
1541 2194 $data['selected_services'] = [];
1542 2195 }
1543 2196 $data['selected_services'] = array_map('intval', $data['selected_services']);
1544 - do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1));
2197 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
1545 2198
1546 2199 // ========================================
1547 2200 // SAVE TRAVELLERS TO NORMALIZED TABLES
1548 2201 // ========================================
@@ -1658,13 +2311,54 @@
1658 2311 $email_vars['expiry_notice_html'] = '<strong>'
1659 2312 . esc_html__('This link expires in 48 hours.', 'yatra')
1660 2313 . '</strong>';
1661 2314
1662 - \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2315 + // Guest-checkout verification prefers the operator's CONFIGURED
2316 + // customer verification template so their customisation is honoured
2317 + // (the guest system template was consolidated away — using the guest
2318 + // type always fell back to the built-in default and ignored the
2319 + // configured one). This email MUST still carry the verification link
2320 + // — a guest can't complete the booking without it — so we only fall
2321 + // back to the built-in GUEST default when the effective customer
2322 + // template would omit {{verification_link}} (an operator can, and on
2323 + // real sites does, customise that template and drop the tag). The
2324 + // check respects Pro-owned DB templates too. Booking copy is injected
2325 + // above via intro_paragraph / footer_note / expiry merge vars.
2326 + //
2327 + // Keep the booking-specific SUBJECT line ("Verify your email to
2328 + // complete your booking") that guests saw before the guest template
2329 + // was consolidated away — reusing the customer template body must not
2330 + // drag along the account-oriented "Verify your email address"
2331 + // subject. This is honoured additively by the renderer / Pro sender
2332 + // via the reserved `_subject_override` var, so only this guest send
2333 + // is affected. Computed before it is stored, so the render below
2334 + // resolves the clean guest subject (no self-reference).
2335 + $email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render(
1663 2336 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
1664 - (string) $contact_data['email'],
1665 2337 $email_vars
1666 - );
2338 + )['subject'];
2339 + $verificationEmailSent = false;
2340 + if (\Yatra\Services\TransactionalEmailTemplateService::templateRendersVerificationLink(
2341 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION
2342 + )) {
2343 + $verificationEmailSent = \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2344 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION,
2345 + (string) $contact_data['email'],
2346 + $email_vars
2347 + );
2348 + }
2349 + // Guarantee a verification email even if the customer template would
2350 + // drop the link OR its per-type toggle is disabled — a guest can't
2351 + // complete checkout without it. The built-in GUEST default always
2352 + // carries the link. sendIfEnabled() returns whether it actually sent,
2353 + // so this only fires when the preferred send did not (no double send).
2354 + if (!$verificationEmailSent) {
2355 + \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2356 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2357 + (string) $contact_data['email'],
2358 + $email_vars
2359 + );
2360 + }
1667 2361
1668 2362 return new WP_REST_Response([
1669 2363 'success' => true,
1670 2364 'code' => 'email_verification_required',
@@ -1750,21 +2444,29 @@
1750 2444
1751 2445 // ========================================
1752 2446 // DETERMINE BOOKING STATUS
1753 2447 // ========================================
1754 - // Priority:
1755 - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
1756 - // 2. For pay_later: auto_confirm_pay_later setting
1757 - // 3. For bank_transfer: always pending until verified
1758 -
2448 + // Priority (Auto-Confirm mode: none | online | all):
2449 + // - 'all' → confirm every booking here at checkout.
2450 + // - 'online' → confirm nothing at checkout; only a successful online
2451 + // gateway payment confirms later (offline stays pending).
2452 + // - 'none' → per-method: pay_later uses auto_confirm_pay_later,
2453 + // bank_transfer stays pending, everything else pending.
2454 +
1759 2455 $booking_status = 'pending';
1760 2456 $status_message = __('Booking received!', 'yatra');
1761 -
1762 - // Check if auto-confirm all bookings is enabled
1763 - if ($settings['auto_confirm_bookings']) {
1764 - // Auto-confirm is enabled - confirm immediately regardless of payment
2457 +
2458 + $auto_confirm_mode = $settings['auto_confirm_mode'] ?? 'none';
2459 + if ($auto_confirm_mode === 'all') {
2460 + // Confirm every booking immediately, regardless of payment.
1765 2461 $booking_status = 'confirmed';
1766 2462 $status_message = __('Booking confirmed!', 'yatra');
2463 + } elseif ($auto_confirm_mode === 'online') {
2464 + // Only successful online payments auto-confirm (at payment
2465 + // completion). Leave the booking pending at checkout; offline
2466 + // methods (bank transfer, pay-later) stay pending for the operator.
2467 + $booking_status = 'pending';
2468 + $status_message = __('Booking received!', 'yatra');
1767 2469 } elseif ($payment_gateway === 'pay_later') {
1768 2470 // Pay Later: Check the specific pay_later auto-confirm setting
1769 2471 if ($settings['auto_confirm_pay_later']) {
1770 2472 $booking_status = 'confirmed';
@@ -2184,11 +2886,15 @@
2184 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
2185 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2186 2888 // may still append their own query args on top of this URL.
2187 2889 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2890 + // Cancel returns must land on the booking-confirmation page (always resolvable);
2891 + // `home_url('/book/?...')` 404s under a custom booking base/page. Use the reference,
2892 + // falling back to the booking id so the confirmation route always has a token.
2893 + $cancelRef = $ref !== '' ? $ref : (string) ($params['booking_id'] ?? '');
2188 2894 $paymentData = array_merge($params, [
2189 2895 'description' => $params['trip_title'] ?? '',
2190 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2191 2897 'metadata' => [
2192 2898 'booking_id' => $params['booking_id'],
2193 2899 'reference' => $params['reference'] ?? ''
2194 2900 ]
@@ -2237,10 +2943,12 @@
2237 2943 ];
2238 2944 }
2239 2945
2240 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
2241 2949 return [
2242 - 'success' => true,
2950 + 'success' => true,
2243 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2244 2952 ];
2245 2953 }
2246 2954
@@ -2265,8 +2973,75 @@
2265 2973 /**
2266 2974 * Record payment from gateway result
2267 2975 * Matches Stripe's completePayment behavior
2268 2976 */
2977 + /**
2978 + * Record the awaited payment for an offline gateway (bank transfer, cash on
2979 + * arrival, pay later) as a PENDING ledger row.
2980 + *
2981 + * These gateways take no money at checkout, and previously wrote no payment
2982 + * row at all — so when the transfer finally landed there was nothing in the
2983 + * Payments screen for the operator to mark as received. The booking's own
2984 + * fields were the only record, and marking those by hand left the invoice
2985 + * reporting "Payment Pending" with nothing paid.
2986 + *
2987 + * The row is deliberately `pending`: no money has arrived yet, and
2988 + * getTotalPaidForBooking() counts only `completed`, so booking financials and
2989 + * every report are untouched until the operator confirms it.
2990 + */
2991 + private function recordOfflinePendingPayment(array $params, array $result, string $gatewayId): void
2992 + {
2993 + try {
2994 + $bookingId = (int) ($params['booking_id'] ?? 0);
2995 + $amount = (float) ($params['amount'] ?? 0);
2996 +
2997 + if ($bookingId <= 0 || $amount <= 0) {
2998 + return;
2999 + }
3000 +
3001 + // Only for gateways that settle out of band. Anything reporting a
3002 + // completed/succeeded status already records its own row.
3003 + $status = strtolower((string) ($result['status'] ?? ''));
3004 + if (!in_array($status, ['', 'pending', 'pending_verification'], true)) {
3005 + return;
3006 + }
3007 +
3008 + $booking = $this->bookingRepository->find($bookingId);
3009 + if (!$booking || ($booking->payment_status ?? '') === 'paid') {
3010 + return;
3011 + }
3012 +
3013 + $paymentRepository = new \Yatra\Repositories\PaymentRepository();
3014 +
3015 + // Idempotency: a retried checkout must not stack up duplicate rows.
3016 + foreach ($paymentRepository->findByBookingId($bookingId) as $existing) {
3017 + if ((string) ($existing->gateway ?? '') === $gatewayId
3018 + && in_array((string) ($existing->status ?? ''), ['pending', 'completed'], true)
3019 + ) {
3020 + return;
3021 + }
3022 + }
3023 +
3024 + $paymentRepository->create([
3025 + 'booking_id' => $bookingId,
3026 + 'amount' => $amount,
3027 + 'currency' => $params['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3028 + 'gateway' => $gatewayId,
3029 + 'status' => 'pending',
3030 + 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
3031 + 'notes' => __('Awaiting payment — mark as completed once received.', 'yatra'),
3032 + 'created_at' => current_time('mysql'),
3033 + ]);
3034 + } catch (\Throwable $e) {
3035 + // Never break a successful checkout over a bookkeeping row.
3036 + \Yatra\Utils\Logger::warning('Could not record pending offline payment', [
3037 + 'booking_id' => $params['booking_id'] ?? 0,
3038 + 'gateway' => $gatewayId,
3039 + 'error' => $e->getMessage(),
3040 + ]);
3041 + }
3042 + }
3043 +
2269 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2270 3045 {
2271 3046 global $wpdb;
2272 3047
@@ -2274,18 +3049,39 @@
2274 3049 $bookingId = (int) $params['booking_id'];
2275 3050 $amount = (float) ($params['amount'] ?? 0);
2276 3051 $currency = $params['currency'] ?? 'USD';
2277 3052 $transactionId = $result['transaction_id'] ?? '';
2278 -
3053 +
2279 3054 // Get booking
2280 3055 $booking = $this->bookingRepository->find($bookingId);
2281 - if (!$booking || $booking->payment_status === 'paid') {
3056 + if (!$booking) {
2282 3057 return;
2283 3058 }
2284 -
2285 - // Record the payment using PaymentRepository
3059 +
3060 + // Already settled in full — never apply another charge to it. A fresh
3061 + // booking is never already paid, so in practice this only guards a
3062 + // stray/duplicate completion call (with a different transaction id)
3063 + // against over-applying the ledger.
3064 + if (($booking->payment_status ?? '') === 'paid') {
3065 + return;
3066 + }
3067 +
2286 3068 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2287 - $payment_id = $paymentRepository->create([
3069 +
3070 + // Idempotency guard: skip if this gateway transaction is already
3071 + // recorded for this booking. Prevents duplicate ledger rows when a
3072 + // payment is submitted twice (the gateway uses a fresh idempotency
3073 + // key per call, so it won't dedupe a true retry). Mirrors
3074 + // PaymentGatewayController::handle_successful_payment().
3075 + if ($transactionId !== '') {
3076 + $existing = $paymentRepository->findByTransactionId($transactionId);
3077 + if ($existing && (int) ($existing->booking_id ?? 0) === $bookingId) {
3078 + return;
3079 + }
3080 + }
3081 +
3082 + // Record the payment
3083 + $paymentRepository->create([
2288 3084 'booking_id' => $bookingId,
2289 3085 'amount' => $amount,
2290 3086 'currency' => $currency,
2291 3087 'gateway' => $gatewayId,
@@ -2290,14 +3086,43 @@
2290 3086 'currency' => $currency,
2291 3087 'gateway' => $gatewayId,
2292 3088 'transaction_id' => $transactionId,
2293 3089 'status' => 'completed',
3090 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
2294 3091 'created_at' => current_time('mysql'),
2295 3092 ]);
2296 -
2297 - // Calculate total paid
2298 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2299 -
3093 +
3094 + // Update the booking ledger + status. The synchronous gateways
3095 + // (Square, Authorize.Net) reach this generic path but previously left
3096 + // the booking at pending/pending — only the payment row was written.
3097 + // This now matches handle_successful_payment(): accumulate amount_paid,
3098 + // recompute amount_due, set payment_status (paid vs partial), and
3099 + // confirm the booking only when "Auto-Confirm Bookings" is on
3100 + // (consistent with every gateway).
3101 + $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
3102 + $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
3103 + $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
3104 + $previousStatus = (string) ($booking->status ?? 'pending');
3105 +
3106 + // Only auto-confirm when "Auto-Confirm Bookings" is on; otherwise the
3107 + // booking stays pending for the operator to confirm manually,
3108 + // regardless of a successful (full or partial) payment.
3109 + $shouldConfirm = \yatra_should_confirm_booking_on_payment($newAmountDue <= 0.0, $bookingId);
3110 +
3111 + $bookingUpdate = [
3112 + 'amount_paid' => $newAmountPaid,
3113 + 'amount_due' => $newAmountDue,
3114 + 'payment_status' => $paymentStatus,
3115 + ];
3116 + if ($shouldConfirm) {
3117 + $bookingUpdate['status'] = 'confirmed';
3118 + }
3119 + $this->bookingRepository->update($bookingId, $bookingUpdate);
3120 +
3121 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
3122 + \yatra_trigger_booking_confirmed($bookingId, $previousStatus, true);
3123 + }
3124 +
2300 3125 // Fire payment completed action
2301 3126 do_action('yatra_payment_completed', [
2302 3127 'booking_id' => $bookingId,
2303 3128 'transaction_id' => $transactionId,
@@ -2304,11 +3129,12 @@
2304 3129 'amount' => $amount,
2305 3130 'currency' => $currency,
2306 3131 'gateway' => $gatewayId,
2307 3132 ]);
2308 -
2309 - } catch (\Exception $e) {
2310 - }
3133 + } catch (\Throwable $e) {
3134 + // Best-effort: the charge is already recorded; a confirmation-page
3135 + // reload / status reconciliation can recover if this update fails.
3136 + }
2311 3137 }
2312 3138
2313 3139 /**
2314 3140 * Process PayPal payment
@@ -2362,9 +3188,9 @@
2362 3188 'description' => $params['trip_title'],
2363 3189 ]],
2364 3190 'application_context' => [
2365 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2366 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2367 3193 ],
2368 3194 ]),
2369 3195 ]);
2370 3196
@@ -2483,9 +3309,12 @@
2483 3309 'su' => add_query_arg(
2484 3310 ['payment' => 'success', 'gateway' => 'esewa'],
2485 3311 $this->getConfirmationUrl($params['reference'])
2486 3312 ),
2487 - 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
3313 + 'fu' => add_query_arg(
3314 + ['payment' => 'failed', 'gateway' => 'esewa'],
3315 + $this->getConfirmationUrl($params['reference'])
3316 + ),
2488 3317 ], $base_url);
2489 3318
2490 3319 return ['success' => true, 'payment_url' => $payment_url];
2491 3320 }
@@ -2637,9 +3466,9 @@
2637 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
2638 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
2639 3468 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Travel date', 'yatra'); ?>:</strong> <?php echo esc_html(date_i18n(get_option('date_format'), strtotime($travel_date))); ?></p>
2640 3469 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Duration', 'yatra'); ?>:</strong> <?php /* translators: 1: number of days, 2: number of nights. */
2641 -echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p>
3470 +echo esc_html(yatra_format_duration((int) $trip->duration_days, (int) $trip->duration_nights, (int) ($trip->duration_hours ?? 0))); ?></p>
2642 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
2643 3472 </div>
2644 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
2645 3474 <p><?php /* translators: %s: total amount (formatted). */
@@ -2694,9 +3523,25 @@
2694 3523 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2695 3524 <?php
2696 3525 $details_html = ob_get_clean();
2697 3526
2698 - $vars = [
3527 + // Seed from the canonical booking variables FIRST so every dynamic
3528 + // merge tag — {{contact_*}} / {{emergency_*}} custom fields,
3529 + // {{traveler_custom_fields_html}}, {{balance_due}}, payment/schedule
3530 + // tags, etc. — resolves on this offline / pay-later path exactly like
3531 + // the online-gateway path (BookingService::sendBookingConfirmationEmail).
3532 + // Previously this method hand-built only ~18 core keys, so an operator
3533 + // who customised the Booking Confirmation template with a custom-field
3534 + // variable saw it render empty on offline bookings. The hand-built keys
3535 + // below (the self-rendered details_html, intro, footer) intentionally
3536 + // take precedence via array_merge ordering.
3537 + $base_vars = [];
3538 + $saved_booking = $this->bookingRepository->find($booking_id);
3539 + if ($saved_booking) {
3540 + $base_vars = TransactionalEmailTemplateService::variablesFromBooking($saved_booking);
3541 + }
3542 +
3543 + $vars = array_merge($base_vars, [
2699 3544 'customer_name' => $customer_name,
2700 3545 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2701 3546 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2702 3547 'customer_email' => $customer_email,
@@ -2715,9 +3560,9 @@
2715 3560 'details_html_only' => '1',
2716 3561 /* translators: %s: site name. */
2717 3562 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2718 3563 'transactional_context' => 'booking_created',
2719 - ];
3564 + ]);
2720 3565
2721 3566 TransactionalEmailTemplateService::sendIfEnabled(
2722 3567 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2723 3568 $customer_email,
@@ -2735,8 +3580,14 @@
2735 3580 {
2736 3581 yatra_start_session();
2737 3582
2738 3583 $data = $request->get_json_params() ?? [];
3584 +
3585 + // M-2: restore CSRF protection stripped by public_permission_callback.
3586 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
3587 + return $blocked;
3588 + }
3589 +
2739 3590 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2740 3591
2741 3592 if (empty($code)) {
2742 3593 return new WP_REST_Response([
@@ -2898,8 +3749,28 @@
2898 3749 (int) $verifiedBooking->id,
2899 3750 $verifiedBooking
2900 3751 );
2901 3752 }
3753 +
3754 + // Guest email-verification defers the customer booking-confirmation
3755 + // email: the checkout flow returns at the verification gate, before
3756 + // its send-site (~line 2465), so the confirmation is never sent for a
3757 + // verified guest booking. Send it now that the email is proven and the
3758 + // booking is live — gated by the same `booking_confirmation` option the
3759 + // checkout paths use. Only in this fresh-verify branch, so a re-clicked
3760 + // link never re-sends. TYPE_BOOKING_CONFIRMATION is skipped by the Pro
3761 + // booking.created fan-out, so this is the single source of the email.
3762 + if ((bool) \Yatra\Services\SettingsService::get('booking_confirmation', true)) {
3763 + try {
3764 + (new \Yatra\Services\BookingService())->sendNewBookingTransactionalConfirmation((int) $booking->id);
3765 + } catch (\Throwable $e) {
3766 + // A mail failure must never break the customer's "verified" page.
3767 + Logger::error('Post-verification booking confirmation email failed', [
3768 + 'booking_id' => (int) $booking->id,
3769 + 'error' => $e->getMessage(),
3770 + ]);
3771 + }
3772 + }
2902 3773 }
2903 3774
2904 3775 $this->renderVerifyEmailSuccessPage(
2905 3776 (int) $booking->id,
@@ -3028,8 +3899,22 @@
3028 3899 $secondaryLabel = $isLoggedIn
3029 3900 ? __('Go to My Account', 'yatra')
3030 3901 : __('Sign in', 'yatra');
3031 3902
3903 + // Logged-in customers always get "Go to My Account". A guest is only
3904 + // offered "Sign in" when an account is genuinely part of the flow —
3905 + // registration is enabled AND guest checkout is not the operating mode.
3906 + // This is a guest email-verification page (guest checkout is normally
3907 + // on), so with guest checkout enabled OR registration disabled there is
3908 + // no account to sign into; the CTA is hidden rather than dangling to a
3909 + // login the guest can't use.
3910 + $registrationEnabled = \Yatra\Services\SettingsService::isEnabled('customer_registration');
3911 + $guestCheckoutEnabled = \Yatra\Services\SettingsService::isEnabled('allow_guest_checkout');
3912 + $showSecondaryCta = $isLoggedIn || ($registrationEnabled && !$guestCheckoutEnabled);
3913 + $secondaryCta = $showSecondaryCta
3914 + ? '<a class="btn btn-secondary" href="' . esc_url($secondaryUrl) . '">' . esc_html($secondaryLabel) . '</a>'
3915 + : '';
3916 +
3032 3917 $heading = $alreadyVerified
3033 3918 ? __('Email Already Verified', 'yatra')
3034 3919 : __('Email Verified', 'yatra');
3035 3920 $message = $alreadyVerified
@@ -3081,9 +3966,9 @@
3081 3966 . '<p>%4$s</p>'
3082 3967 . '%5$s'
3083 3968 . '<div class="actions">'
3084 3969 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3085 - . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3970 + . '%8$s'
3086 3971 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3087 3972 . '</div>'
3088 3973 . '</div></body></html>',
3089 3974 esc_attr(get_locale()),
@@ -3092,10 +3977,13 @@
3092 3977 esc_html($message),
3093 3978 $referenceLine,
3094 3979 esc_url($confirmationUrl),
3095 3980 esc_html($primaryLabel),
3096 - esc_url($secondaryUrl),
3097 - esc_html($secondaryLabel),
3981 + // %8 is the fully-built secondary CTA (or '' when hidden — see
3982 + // $showSecondaryCta above). %9 is intentionally empty to keep the
3983 + // positional args aligned with %10/%11.
3984 + $secondaryCta,
3985 + '',
3098 3986 esc_url(home_url('/')),
3099 3987 esc_html($homeLabel)
3100 3988 );
3101 3989
@@ -3117,8 +4005,13 @@
3117 4005 yatra_start_session();
3118 4006 $session = yatra_get_booking_session();
3119 4007 $data = $request->get_json_params() ?? [];
3120 4008
4009 + // M-2: restore CSRF protection stripped by public_permission_callback.
4010 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4011 + return $blocked;
4012 + }
4013 +
3121 4014 // Same REST-context session-rehydration fallback as set_session() /
3122 4015 // create_booking(): when PHPSESSID isn't propagated to the REST API
3123 4016 // scope, look up the transient by `booking_token` (from request body
3124 4017 // first, then ?booking_token=) so the partial summary refresh
@@ -3239,8 +4132,15 @@
3239 4132 if (!empty($price_types)) {
3240 4133 $resolved_pricing_type = 'traveler_based';
3241 4134 }
3242 4135
4136 + // Resolve pricing_mode / group-size limits authoritatively from the
4137 + // TravelerCategory so the summary breakdown treats a per-group category
4138 + // as a flat charge. No-op for per-person categories.
4139 + if (!empty($price_types)) {
4140 + $price_types = \Yatra\Services\TripPricingService::applyCategoryPricingMeta($price_types);
4141 + }
4142 +
3243 4143 // Enrich availability price_types with category labels if missing
3244 4144 if (!empty($price_types)) {
3245 4145 $missing_label_category_ids = [];
3246 4146 foreach ($price_types as $pt) {
@@ -3358,9 +4258,12 @@
3358 4258 foreach ($price_types as $pt) {
3359 4259 $category_id = $pt->category_id;
3360 4260 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
3361 4261 if ($count > 0) {
3362 - $category_subtotal = (float) $pt->effective_price * $count;
4262 + // Single source of truth for the line amount (per-person ×
4263 + // count, flat per-group, or per-block group pricing).
4264 + $pt_pricing_mode = $pt->pricing_mode ?? 'per_person';
4265 + $category_subtotal = \Yatra\Services\TripPricingService::categoryLineSubtotal($pt, $count, (float) $pt->effective_price);
3363 4266 $category_breakdown[] = [
3364 4267 'category_id' => $category_id,
3365 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
3366 4269 'count' => $count,
@@ -3365,8 +4268,13 @@
3365 4268 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
3366 4269 'count' => $count,
3367 4270 'price' => (float) $pt->effective_price,
3368 4271 'subtotal' => $category_subtotal,
4272 + 'pricing_mode' => $pt_pricing_mode,
4273 + // Carry the group-size knobs so the reconciliation pass
4274 + // below can re-derive the same per-block/flat subtotal.
4275 + 'max_pax' => isset($pt->max_pax) && $pt->max_pax !== null && $pt->max_pax !== '' ? (int) $pt->max_pax : null,
4276 + 'group_overflow' => $pt->group_overflow ?? 'block',
3369 4277 ];
3370 4278 $subtotal += $category_subtotal;
3371 4279 $total_travelers += $count;
3372 4280 }
@@ -3399,13 +4307,14 @@
3399 4307
3400 4308 $priceTypesForDiscount = [];
3401 4309 if ($is_traveler_based) {
3402 4310 foreach ($price_types as $pt) {
3403 - $pt = (object) $pt;
3404 - $priceTypesForDiscount[] = [
3405 - 'category_id' => $pt->category_id ?? null,
3406 - 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt),
3407 - ];
4311 + $pt = (array) $pt;
4312 + // Keep pricing_mode / max_pax / group_overflow so the group
4313 + // discount base honours flat and per-block group pricing
4314 + // (not just category_id + effective_price).
4315 + $pt['effective_price'] = $pt['effective_price'] ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice($pt);
4316 + $priceTypesForDiscount[] = $pt;
3408 4317 }
3409 4318 } else {
3410 4319 $priceTypesForDiscount[] = [
3411 4320 'category_id' => 'default',
@@ -3514,9 +4423,12 @@
3514 4423 if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
3515 4424 $authoritativePrice = (float) $catPricesPostDp[$cid];
3516 4425 $count = (int) ($cat['count'] ?? 0);
3517 4426 $cat['price'] = $authoritativePrice;
3518 - $cat['subtotal'] = $authoritativePrice * $count;
4427 + // Re-derive the line amount from the authoritative post-DP
4428 + // price using the same rule as the charge (flat per-group,
4429 + // per-block, or per-person × count).
4430 + $cat['subtotal'] = \Yatra\Services\TripPricingService::categoryLineSubtotal($cat, $count, $authoritativePrice);
3519 4431 }
3520 4432 $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
3521 4433 }
3522 4434 unset($cat);
@@ -3630,9 +4542,11 @@
3630 4542 // Pro can already override per-trip via trip.deposit_percentage), then
3631 4543 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
3632 4544 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
3633 4545 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
3634 - $context = ['trip_id' => $trip_id];
4546 + // Tour start → Pro can force full payment when the tour is within the
4547 + // balance-due window (tour-anchored scheduled payments).
4548 + $context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')];
3635 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
3636 4550 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
3637 4551 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
3638 4552
@@ -3874,8 +4788,14 @@
3874 4788 {
3875 4789 yatra_start_session();
3876 4790
3877 4791 $data = $request->get_json_params() ?? [];
4792 +
4793 + // M-2: restore CSRF protection stripped by public_permission_callback.
4794 + if (($blocked = $this->guardPublicBookingMutation($request, $data)) !== null) {
4795 + return $blocked;
4796 + }
4797 +
3878 4798 $session = yatra_get_booking_session();
3879 4799
3880 4800 // Same booking_token rehydration as apply_coupon — handle REST
3881 4801 // requests that arrive without a propagated PHPSESSID.