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