| @@ -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. |