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