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 +997 -101 3.0.6trunk 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,57 @@
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 + $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 + }
286 361 }
287 362 }
288 363 }
289 -
364 +
290 365 return new WP_REST_Response([
291 366 'success' => true,
292 367 'message' => __('Payment completed successfully.', 'yatra'),
293 368 'data' => [
294 - 'transaction_id' => $result['transaction_id'] ?? '',
369 + 'transaction_id' => $transaction_id,
295 370 'status' => $result['status'] ?? 'completed',
296 371 ],
297 372 ]);
298 373 }
@@ -297,8 +372,48 @@
297 372 ]);
298 373 }
299 374
300 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 + /**
301 416 * Set booking session data
302 417 * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts)
303 418 */
304 419 public function set_session(WP_REST_Request $request): WP_REST_Response
@@ -304,11 +419,16 @@
304 419 public function set_session(WP_REST_Request $request): WP_REST_Response
305 420 {
306 421 // Ensure session is started for REST API requests
307 422 yatra_start_session();
308 -
423 +
309 424 $data = $request->get_json_params();
310 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 +
311 431 // Check if this is a partial update (updating travelers or services in existing session)
312 432 $existing_session = yatra_get_booking_session();
313 433
314 434 // REST requests don't always carry PHPSESSID into the WP session scope,
@@ -610,8 +730,14 @@
610 730 'coupon_code' => '',
611 731 'payment_method' => 'full',
612 732 ]);
613 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 +
614 740 // Prepare session data - essential trip data (pricing fetched from database on-demand)
615 741 $session_data = [
616 742 'trip_id' => (int) $trip->id,
617 743 'trip_title' => $trip->title,
@@ -725,8 +851,13 @@
725 851 * Clear booking session
726 852 */
727 853 public function clear_session(WP_REST_Request $request): WP_REST_Response
728 854 {
855 + // M-2: restore CSRF protection stripped by public_permission_callback.
856 + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) {
857 + return $blocked;
858 + }
859 +
729 860 yatra_clear_booking_session();
730 861
731 862 return new WP_REST_Response([
732 863 'success' => true,
@@ -812,8 +943,264 @@
812 943 }
813 944 return (bool) wp_verify_nonce($nonce, 'yatra_booking_action');
814 945 }
815 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 +
816 1203 public function create_booking(WP_REST_Request $request): WP_REST_Response
817 1204 {
818 1205 global $wpdb;
819 1206
@@ -818,8 +1205,23 @@
818 1205 global $wpdb;
819 1206
820 1207 $data = $request->get_json_params();
821 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 +
822 1224 // ========================================
823 1225 // CSRF — booking-scoped action nonce
824 1226 // ========================================
825 1227 // The public_permission_callback on this route intentionally
@@ -958,42 +1360,159 @@
958 1360 'message' => __('No trip selected for booking.', 'yatra'),
959 1361 ], 400);
960 1362 }
961 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 +
962 1372 // Get contact email - handle both flat and nested formats
963 - $contact_email = $data['contact_email'] ?? '';
1373 + $contact_email = trim((string) ($data['contact_email'] ?? ''));
964 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 + );
965 1384 $contact_first_name = $data['contact_first_name'] ?? '';
966 1385 $contact_last_name = $data['contact_last_name'] ?? '';
967 1386 $contact_country = $data['contact_country'] ?? '';
968 -
969 - $contact_nationality = $data['contact_nationality'] ?? '';
1387 +
1388 + $contact_nationality = $data['contact_nationality'] ?? '';
970 1389 $contact_address = $data['contact_address'] ?? '';
971 -
1390 +
972 1391 // Emergency contact
973 1392 $emergency_name = $data['emergency_name'] ?? '';
974 - $emergency_phone = $data['emergency_phone'] ?? '';
1393 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1394 + (string) ($data['emergency_phone'] ?? ''),
1395 + (string) ($data['emergency_phone_country'] ?? '')
1396 + );
975 1397 $emergency_relationship = $data['emergency_relationship'] ?? '';
976 -
1398 +
977 1399 // Travel details
978 1400 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
979 1401 $travelers = $data['travelers'] ?? [];
980 -
981 - // Validate required fields
982 - 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)) {
983 1482 return new WP_REST_Response([
984 1483 'success' => false,
985 - 'message' => __('Email address is required.', 'yatra'),
1484 + 'message' => __('A valid email address is required to complete this booking.', 'yatra'),
986 1485 ], 400);
987 1486 }
988 -
989 - 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)) {
990 1509 return new WP_REST_Response([
991 1510 'success' => false,
992 1511 'message' => __('Phone number is required.', 'yatra'),
993 1512 ], 400);
994 1513 }
995 -
1514 +
996 1515 if (empty($travel_date)) {
997 1516 return new WP_REST_Response([
998 1517 'success' => false,
999 1518 'message' => __('Travel date is required.', 'yatra'),
@@ -998,9 +1517,9 @@
998 1517 'success' => false,
999 1518 'message' => __('Travel date is required.', 'yatra'),
1000 1519 ], 400);
1001 1520 }
1002 -
1521 +
1003 1522 if (empty($travelers) || !is_array($travelers)) {
1004 1523 return new WP_REST_Response([
1005 1524 'success' => false,
1006 1525 'message' => __('At least one traveler is required.', 'yatra'),
@@ -1006,14 +1525,27 @@
1006 1525 'message' => __('At least one traveler is required.', 'yatra'),
1007 1526 ], 400);
1008 1527 }
1009 1528
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);
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 + }
1016 1548 }
1017 1549
1018 1550 // Get trip data
1019 1551 $trip = $this->tripRepository->findPublished($trip_id);
@@ -1148,8 +1680,20 @@
1148 1680 // Keep $pricing as-is; downstream guard will surface a clean error.
1149 1681 }
1150 1682 }
1151 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 +
1152 1696 // Extract pricing results
1153 1697 $total_amount = $pricing['final_total'];
1154 1698 $amount_due = $pricing['amount_due'];
1155 1699 $amount_paid = $pricing['amount_paid'];
@@ -1183,9 +1727,9 @@
1183 1727 $isWaitlistCheckout = false;
1184 1728
1185 1729 if ($resolvedAvailabilityForWaitlist !== null) {
1186 1730 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1187 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1731 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1188 1732 return new WP_REST_Response([
1189 1733 'success' => false,
1190 1734 'message' => __('This departure is not open for booking.', 'yatra'),
1191 1735 'code' => 'date_blocked',
@@ -1246,9 +1790,37 @@
1246 1790 'country' => sanitize_text_field($contact_country),
1247 1791 'nationality' => sanitize_text_field($contact_nationality),
1248 1792 'address' => sanitize_text_field($contact_address),
1249 1793 ];
1250 -
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 +
1251 1823 // Prepare emergency contact data
1252 1824 $emergency_data = [
1253 1825 'name' => sanitize_text_field($emergency_name),
1254 1826 'phone' => sanitize_text_field($emergency_phone),
@@ -1253,8 +1825,31 @@
1253 1825 'name' => sanitize_text_field($emergency_name),
1254 1826 'phone' => sanitize_text_field($emergency_phone),
1255 1827 'relationship' => sanitize_text_field($emergency_relationship),
1256 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 + }
1257 1852
1258 1853 // Sanitize travelers data
1259 1854 $sanitized_travelers = [];
1260 1855 foreach ($travelers as $traveler) {
@@ -1261,8 +1856,13 @@
1261 1856 if (is_array($traveler)) {
1262 1857 $sanitized_traveler = [];
1263 1858 foreach ($traveler as $key => $value) {
1264 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 + }
1265 1865 if (is_array($value)) {
1266 1866 $sanitized_traveler[$sk] = array_map(static function ($v) {
1267 1867 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1268 1868 }, $value);
@@ -1269,8 +1869,19 @@
1269 1869 } else {
1270 1870 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1271 1871 }
1272 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 + }
1273 1884 $sanitized_travelers[] = $sanitized_traveler;
1274 1885 }
1275 1886 }
1276 1887
@@ -1455,10 +2066,20 @@
1455 2066
1456 2067 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1457 2068 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1458 2069 $booking_data['status'] = 'waitlist';
1459 - $booking_data['payment_gateway'] = 'pay_later';
1460 - $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 + }
1461 2082 }
1462 2083
1463 2084 // Hold the booking in `pending_verification` until the guest
1464 2085 // clicks the magic link. Payment is initiated only after the
@@ -1470,10 +2091,21 @@
1470 2091 // until verification completes and the regular checkout
1471 2092 // resumes.
1472 2093 if ($needs_email_verification && !$isWaitlistCheckout) {
1473 2094 $booking_data['status'] = 'pending_verification';
1474 - $booking_data['payment_gateway'] = 'pay_later';
1475 - $booking_data['payment_method'] = 'full';
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 + }
1476 2108 }
1477 2109
1478 2110 try {
1479 2111 $booking = $booking_service->createBooking($booking_data);
@@ -1528,8 +2160,14 @@
1528 2160 * @param int $trip_id The trip ID
1529 2161 * @param array $data The booking request data (contains selected_services)
1530 2162 * @param int $travelers_count Total number of travelers
1531 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.
1532 2170 * @since 3.0.0
1533 2171 */
1534 2172 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
1535 2173 if (!isset($data['selected_services'])) {
@@ -1540,9 +2178,9 @@
1540 2178 if (!is_array($data['selected_services'])) {
1541 2179 $data['selected_services'] = [];
1542 2180 }
1543 2181 $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));
2182 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
1545 2183
1546 2184 // ========================================
1547 2185 // SAVE TRAVELLERS TO NORMALIZED TABLES
1548 2186 // ========================================
@@ -1658,13 +2296,54 @@
1658 2296 $email_vars['expiry_notice_html'] = '<strong>'
1659 2297 . esc_html__('This link expires in 48 hours.', 'yatra')
1660 2298 . '</strong>';
1661 2299
1662 - \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
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(
1663 2321 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
1664 - (string) $contact_data['email'],
1665 2322 $email_vars
1666 - );
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 + }
1667 2346
1668 2347 return new WP_REST_Response([
1669 2348 'success' => true,
1670 2349 'code' => 'email_verification_required',
@@ -2184,11 +2863,15 @@
2184 2863 // Default return_url to the configured booking confirmation URL so redirect gateways
2185 2864 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2186 2865 // may still append their own query args on top of this URL.
2187 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'] ?? '');
2188 2871 $paymentData = array_merge($params, [
2189 2872 'description' => $params['trip_title'] ?? '',
2190 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2873 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2191 2874 'metadata' => [
2192 2875 'booking_id' => $params['booking_id'],
2193 2876 'reference' => $params['reference'] ?? ''
2194 2877 ]
@@ -2237,10 +2920,12 @@
2237 2920 ];
2238 2921 }
2239 2922
2240 2923 // For offline gateways or successful direct payments without redirect
2924 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2925 +
2241 2926 return [
2242 - 'success' => true,
2927 + 'success' => true,
2243 2928 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2244 2929 ];
2245 2930 }
2246 2931
@@ -2265,8 +2950,75 @@
2265 2950 /**
2266 2951 * Record payment from gateway result
2267 2952 * Matches Stripe's completePayment behavior
2268 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 +
2269 3021 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2270 3022 {
2271 3023 global $wpdb;
2272 3024
@@ -2274,18 +3026,39 @@
2274 3026 $bookingId = (int) $params['booking_id'];
2275 3027 $amount = (float) ($params['amount'] ?? 0);
2276 3028 $currency = $params['currency'] ?? 'USD';
2277 3029 $transactionId = $result['transaction_id'] ?? '';
2278 -
3030 +
2279 3031 // Get booking
2280 3032 $booking = $this->bookingRepository->find($bookingId);
2281 - if (!$booking || $booking->payment_status === 'paid') {
3033 + if (!$booking) {
2282 3034 return;
2283 3035 }
2284 -
2285 - // 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 +
2286 3045 $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2287 - $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([
2288 3061 'booking_id' => $bookingId,
2289 3062 'amount' => $amount,
2290 3063 'currency' => $currency,
2291 3064 'gateway' => $gatewayId,
@@ -2290,14 +3063,42 @@
2290 3063 'currency' => $currency,
2291 3064 'gateway' => $gatewayId,
2292 3065 'transaction_id' => $transactionId,
2293 3066 'status' => 'completed',
3067 + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null,
2294 3068 'created_at' => current_time('mysql'),
2295 3069 ]);
2296 -
2297 - // Calculate total paid
2298 - $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2299 -
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 +
2300 3101 // Fire payment completed action
2301 3102 do_action('yatra_payment_completed', [
2302 3103 'booking_id' => $bookingId,
2303 3104 'transaction_id' => $transactionId,
@@ -2304,11 +3105,12 @@
2304 3105 'amount' => $amount,
2305 3106 'currency' => $currency,
2306 3107 'gateway' => $gatewayId,
2307 3108 ]);
2308 -
2309 - } catch (\Exception $e) {
2310 - }
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 + }
2311 3113 }
2312 3114
2313 3115 /**
2314 3116 * Process PayPal payment
@@ -2362,9 +3164,9 @@
2362 3164 'description' => $params['trip_title'],
2363 3165 ]],
2364 3166 'application_context' => [
2365 3167 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2366 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3168 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2367 3169 ],
2368 3170 ]),
2369 3171 ]);
2370 3172
@@ -2483,9 +3285,12 @@
2483 3285 'su' => add_query_arg(
2484 3286 ['payment' => 'success', 'gateway' => 'esewa'],
2485 3287 $this->getConfirmationUrl($params['reference'])
2486 3288 ),
2487 - '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 + ),
2488 3293 ], $base_url);
2489 3294
2490 3295 return ['success' => true, 'payment_url' => $payment_url];
2491 3296 }
@@ -2694,9 +3499,25 @@
2694 3499 <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p>
2695 3500 <?php
2696 3501 $details_html = ob_get_clean();
2697 3502
2698 - $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, [
2699 3520 'customer_name' => $customer_name,
2700 3521 'customer_first_name' => (string) ($contact['first_name'] ?? ''),
2701 3522 'customer_last_name' => (string) ($contact['last_name'] ?? ''),
2702 3523 'customer_email' => $customer_email,
@@ -2715,9 +3536,9 @@
2715 3536 'details_html_only' => '1',
2716 3537 /* translators: %s: site name. */
2717 3538 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')),
2718 3539 'transactional_context' => 'booking_created',
2719 - ];
3540 + ]);
2720 3541
2721 3542 TransactionalEmailTemplateService::sendIfEnabled(
2722 3543 TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION,
2723 3544 $customer_email,
@@ -2735,8 +3556,14 @@
2735 3556 {
2736 3557 yatra_start_session();
2737 3558
2738 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 +
2739 3566 $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : '';
2740 3567
2741 3568 if (empty($code)) {
2742 3569 return new WP_REST_Response([
@@ -2898,8 +3725,28 @@
2898 3725 (int) $verifiedBooking->id,
2899 3726 $verifiedBooking
2900 3727 );
2901 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 + }
2902 3749 }
2903 3750
2904 3751 $this->renderVerifyEmailSuccessPage(
2905 3752 (int) $booking->id,
@@ -3028,8 +3875,22 @@
3028 3875 $secondaryLabel = $isLoggedIn
3029 3876 ? __('Go to My Account', 'yatra')
3030 3877 : __('Sign in', 'yatra');
3031 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 +
3032 3893 $heading = $alreadyVerified
3033 3894 ? __('Email Already Verified', 'yatra')
3034 3895 : __('Email Verified', 'yatra');
3035 3896 $message = $alreadyVerified
@@ -3081,9 +3942,9 @@
3081 3942 . '<p>%4$s</p>'
3082 3943 . '%5$s'
3083 3944 . '<div class="actions">'
3084 3945 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3085 - . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3946 + . '%8$s'
3086 3947 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3087 3948 . '</div>'
3088 3949 . '</div></body></html>',
3089 3950 esc_attr(get_locale()),
@@ -3092,10 +3953,13 @@
3092 3953 esc_html($message),
3093 3954 $referenceLine,
3094 3955 esc_url($confirmationUrl),
3095 3956 esc_html($primaryLabel),
3096 - esc_url($secondaryUrl),
3097 - esc_html($secondaryLabel),
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 + '',
3098 3962 esc_url(home_url('/')),
3099 3963 esc_html($homeLabel)
3100 3964 );
3101 3965
@@ -3117,8 +3981,13 @@
3117 3981 yatra_start_session();
3118 3982 $session = yatra_get_booking_session();
3119 3983 $data = $request->get_json_params() ?? [];
3120 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 +
3121 3990 // Same REST-context session-rehydration fallback as set_session() /
3122 3991 // create_booking(): when PHPSESSID isn't propagated to the REST API
3123 3992 // scope, look up the transient by `booking_token` (from request body
3124 3993 // first, then ?booking_token=) so the partial summary refresh
@@ -3239,8 +4108,15 @@
3239 4108 if (!empty($price_types)) {
3240 4109 $resolved_pricing_type = 'traveler_based';
3241 4110 }
3242 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 +
3243 4119 // Enrich availability price_types with category labels if missing
3244 4120 if (!empty($price_types)) {
3245 4121 $missing_label_category_ids = [];
3246 4122 foreach ($price_types as $pt) {
@@ -3358,9 +4234,12 @@
3358 4234 foreach ($price_types as $pt) {
3359 4235 $category_id = $pt->category_id;
3360 4236 $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0));
3361 4237 if ($count > 0) {
3362 - $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);
3363 4242 $category_breakdown[] = [
3364 4243 'category_id' => $category_id,
3365 4244 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
3366 4245 'count' => $count,
@@ -3365,8 +4244,13 @@
3365 4244 'label' => $pt->category_label ?? __('Traveler', 'yatra'),
3366 4245 'count' => $count,
3367 4246 'price' => (float) $pt->effective_price,
3368 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',
3369 4253 ];
3370 4254 $subtotal += $category_subtotal;
3371 4255 $total_travelers += $count;
3372 4256 }
@@ -3399,13 +4283,14 @@
3399 4283
3400 4284 $priceTypesForDiscount = [];
3401 4285 if ($is_traveler_based) {
3402 4286 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 - ];
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;
3408 4293 }
3409 4294 } else {
3410 4295 $priceTypesForDiscount[] = [
3411 4296 'category_id' => 'default',
@@ -3514,9 +4399,12 @@
3514 4399 if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) {
3515 4400 $authoritativePrice = (float) $catPricesPostDp[$cid];
3516 4401 $count = (int) ($cat['count'] ?? 0);
3517 4402 $cat['price'] = $authoritativePrice;
3518 - $cat['subtotal'] = $authoritativePrice * $count;
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);
3519 4407 }
3520 4408 $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0);
3521 4409 }
3522 4410 unset($cat);
@@ -3630,9 +4518,11 @@
3630 4518 // Pro can already override per-trip via trip.deposit_percentage), then
3631 4519 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
3632 4520 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
3633 4521 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
3634 - $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 ?? '')];
3635 4525 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
3636 4526 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
3637 4527 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
3638 4528
@@ -3874,8 +4764,14 @@
3874 4764 {
3875 4765 yatra_start_session();
3876 4766
3877 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 +
3878 4774 $session = yatra_get_booking_session();
3879 4775
3880 4776 // Same booking_token rehydration as apply_coupon — handle REST
3881 4777 // requests that arrive without a propagated PHPSESSID.