| @@ -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,65 @@ | ||
| 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 | + // 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 | + } | |
| 266 | 369 | } |
| 267 | 370 | } |
| 268 | 371 | } |
| 269 | - | |
| 372 | + | |
| 270 | 373 | return new WP_REST_Response([ |
| 271 | 374 | 'success' => true, |
| 272 | 375 | 'message' => __('Payment completed successfully.', 'yatra'), |
| 273 | 376 | 'data' => [ |
| 274 | - 'transaction_id' => $result['transaction_id'] ?? '', | |
| 377 | + 'transaction_id' => $transaction_id, | |
| 275 | 378 | 'status' => $result['status'] ?? 'completed', |
| 276 | 379 | ], |
| 277 | 380 | ]); |
| 278 | 381 | } |
| @@ -277,8 +380,48 @@ | ||
| 277 | 380 | ]); |
| 278 | 381 | } |
| 279 | 382 | |
| 280 | 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 | + /** | |
| 281 | 424 | * Set booking session data |
| 282 | 425 | * Supports full creation (requires trip_id) or partial updates (travelers, traveler_counts) |
| 283 | 426 | */ |
| 284 | 427 | public function set_session(WP_REST_Request $request): WP_REST_Response |
| @@ -284,14 +427,53 @@ | ||
| 284 | 427 | public function set_session(WP_REST_Request $request): WP_REST_Response |
| 285 | 428 | { |
| 286 | 429 | // Ensure session is started for REST API requests |
| 287 | 430 | yatra_start_session(); |
| 288 | - | |
| 431 | + | |
| 289 | 432 | $data = $request->get_json_params(); |
| 290 | - | |
| 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 | + | |
| 291 | 439 | // Check if this is a partial update (updating travelers or services in existing session) |
| 292 | 440 | $existing_session = yatra_get_booking_session(); |
| 293 | - $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) && | |
| 441 | + | |
| 442 | + // REST requests don't always carry PHPSESSID into the WP session scope, | |
| 443 | + // so for partial updates (where the JS only sends e.g. | |
| 444 | + // `additional_services: [1]`) we may end up with an empty | |
| 445 | + // `$existing_session` here and incorrectly bounce to the "trip_id | |
| 446 | + // required" guard below. Same fix as create_booking: when the page | |
| 447 | + // URL has a `?booking_token=…` (or the body carries it), look up the | |
| 448 | + // matching transient and treat it as the session. Without this, every | |
| 449 | + // service-toggle returns 400. | |
| 450 | + if (empty($existing_session) || empty($existing_session['trip_id'])) { | |
| 451 | + $token = null; | |
| 452 | + if (!empty($data['booking_token']) && is_string($data['booking_token'])) { | |
| 453 | + $token = sanitize_text_field((string) $data['booking_token']); | |
| 454 | + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { | |
| 455 | + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); | |
| 456 | + } | |
| 457 | + if ($token) { | |
| 458 | + $transient_data = get_transient($token); | |
| 459 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 460 | + $existing_session = $transient_data; | |
| 461 | + // CRITICAL: also seed $_SESSION with the rehydrated data | |
| 462 | + // AND the original token so yatra_set_booking_session() | |
| 463 | + // (called below in the partial-update branch) writes the | |
| 464 | + // updated session BACK into the same transient. Without | |
| 465 | + // this, the helper generates a fresh random token and | |
| 466 | + // writes to a different transient — the user's URL token | |
| 467 | + // never gets the new selection persisted, and the next | |
| 468 | + // refresh shows stale data. | |
| 469 | + $_SESSION['yatra_booking'] = $existing_session; | |
| 470 | + $_SESSION['yatra_booking_token'] = $token; | |
| 471 | + } | |
| 472 | + } | |
| 473 | + } | |
| 474 | + | |
| 475 | + $is_partial_update = empty($data['trip_id']) && !empty($existing_session['trip_id']) && | |
| 294 | 476 | (isset($data['travelers']) || isset($data['traveler_counts']) || isset($data['additional_services'])); |
| 295 | 477 | |
| 296 | 478 | if ($is_partial_update) { |
| 297 | 479 | // Partial update: merge new data with existing session |
| @@ -397,9 +579,11 @@ | ||
| 397 | 579 | global $wpdb; |
| 398 | 580 | |
| 399 | 581 | // Get availability-specific data if date provided |
| 400 | 582 | $availability = null; |
| 401 | - $availability_id = !empty($data['availability_id']) ? sanitize_text_field($data['availability_id']) : null; | |
| 583 | + // availability_id may be numeric (manual date row) or a synthetic string (rule/default). | |
| 584 | + // Keep it as string in session and only cast to int when it is numeric. | |
| 585 | + $availability_id = !empty($data['availability_id']) ? sanitize_text_field((string) $data['availability_id']) : null; | |
| 402 | 586 | $travel_date = !empty($data['travel_date']) ? sanitize_text_field($data['travel_date']) : ''; |
| 403 | 587 | $departure_time = !empty($data['departure_time']) ? sanitize_text_field($data['departure_time']) : ''; |
| 404 | 588 | |
| 405 | 589 | // Use centralized AvailabilityResolutionService to get resolved availability |
| @@ -554,8 +738,14 @@ | ||
| 554 | 738 | 'coupon_code' => '', |
| 555 | 739 | 'payment_method' => 'full', |
| 556 | 740 | ]); |
| 557 | 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 | + | |
| 558 | 748 | // Prepare session data - essential trip data (pricing fetched from database on-demand) |
| 559 | 749 | $session_data = [ |
| 560 | 750 | 'trip_id' => (int) $trip->id, |
| 561 | 751 | 'trip_title' => $trip->title, |
| @@ -629,9 +819,30 @@ | ||
| 629 | 819 | public function get_session(WP_REST_Request $request): WP_REST_Response |
| 630 | 820 | { |
| 631 | 821 | $session_data = yatra_get_booking_session(); |
| 632 | 822 | |
| 823 | + // Recover from transient when PHPSESSID didn't propagate to REST. | |
| 824 | + // The JS appends `?booking_token=…` to the GET URL specifically so | |
| 825 | + // this branch can rehydrate after a page refresh — without it the | |
| 826 | + // sidebar's applied-coupon UI would never re-show and the remove | |
| 827 | + // button stayed hidden. | |
| 633 | 828 | if (empty($session_data) || empty($session_data['trip_id'])) { |
| 829 | + $token_raw = $request->get_param('booking_token'); | |
| 830 | + if (empty($token_raw) && isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { | |
| 831 | + $token_raw = wp_unslash((string) $_GET['booking_token']); | |
| 832 | + } | |
| 833 | + if (!empty($token_raw) && is_string($token_raw)) { | |
| 834 | + $token = sanitize_text_field($token_raw); | |
| 835 | + $transient_data = get_transient($token); | |
| 836 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 837 | + $session_data = $transient_data; | |
| 838 | + $_SESSION['yatra_booking'] = $session_data; | |
| 839 | + $_SESSION['yatra_booking_token'] = $token; | |
| 840 | + } | |
| 841 | + } | |
| 842 | + } | |
| 843 | + | |
| 844 | + if (empty($session_data) || empty($session_data['trip_id'])) { | |
| 634 | 845 | return new WP_REST_Response([ |
| 635 | 846 | 'success' => false, |
| 636 | 847 | 'message' => __('No active booking session.', 'yatra'), |
| 637 | 848 | 'data' => null, |
| @@ -648,8 +859,13 @@ | ||
| 648 | 859 | * Clear booking session |
| 649 | 860 | */ |
| 650 | 861 | public function clear_session(WP_REST_Request $request): WP_REST_Response |
| 651 | 862 | { |
| 863 | + // M-2: restore CSRF protection stripped by public_permission_callback. | |
| 864 | + if (($blocked = $this->guardPublicBookingMutation($request)) !== null) { | |
| 865 | + return $blocked; | |
| 866 | + } | |
| 867 | + | |
| 652 | 868 | yatra_clear_booking_session(); |
| 653 | 869 | |
| 654 | 870 | return new WP_REST_Response([ |
| 655 | 871 | 'success' => true, |
| @@ -680,8 +896,11 @@ | ||
| 680 | 896 | 'slug' => $trip->slug, |
| 681 | 897 | 'featured_image' => $trip->featured_image, |
| 682 | 898 | 'duration_days' => (int) $trip->duration_days, |
| 683 | 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), | |
| 684 | 903 | 'difficulty_level' => $trip->difficulty_level, |
| 685 | 904 | 'min_travelers' => (int) ($trip->min_travelers ?: 1), |
| 686 | 905 | 'max_travelers' => (int) ($trip->max_travelers ?: 20), |
| 687 | 906 | 'original_price' => (float) $trip->original_price, |
| @@ -707,15 +926,340 @@ | ||
| 707 | 926 | * |
| 708 | 927 | * Remaining / balance payment: does not create a booking — only charges the existing row |
| 709 | 928 | * and records a payment on success via the same gateway completion paths. |
| 710 | 929 | */ |
| 930 | + /** | |
| 931 | + * Validate the booking-scoped CSRF nonce. | |
| 932 | + * | |
| 933 | + * Looks first in the `X-Yatra-Booking-Nonce` request header | |
| 934 | + * (the JS frontend's path), then in JSON body keys used by | |
| 935 | + * older or non-JS fallback flows. Returns true on a valid | |
| 936 | + * nonce, false otherwise. | |
| 937 | + * | |
| 938 | + * @param WP_REST_Request $request | |
| 939 | + * @param array<string, mixed>|null $data decoded JSON body | |
| 940 | + */ | |
| 941 | + private function verifyBookingNonce(WP_REST_Request $request, $data): bool | |
| 942 | + { | |
| 943 | + $nonce = (string) $request->get_header('X-Yatra-Booking-Nonce'); | |
| 944 | + if ($nonce === '' && \is_array($data)) { | |
| 945 | + $nonce = (string) ( | |
| 946 | + $data['_yatra_booking_nonce'] | |
| 947 | + ?? $data['yatra_booking_nonce'] | |
| 948 | + ?? $data['booking_nonce'] | |
| 949 | + ?? '' | |
| 950 | + ); | |
| 951 | + } | |
| 952 | + if ($nonce === '') { | |
| 953 | + return false; | |
| 954 | + } | |
| 955 | + return (bool) wp_verify_nonce($nonce, 'yatra_booking_action'); | |
| 956 | + } | |
| 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 | + | |
| 711 | 1214 | public function create_booking(WP_REST_Request $request): WP_REST_Response |
| 712 | 1215 | { |
| 713 | 1216 | global $wpdb; |
| 714 | - | |
| 1217 | + | |
| 715 | 1218 | $data = $request->get_json_params(); |
| 716 | 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 | + | |
| 717 | 1235 | // ======================================== |
| 1236 | + // CSRF — booking-scoped action nonce | |
| 1237 | + // ======================================== | |
| 1238 | + // The public_permission_callback on this route intentionally | |
| 1239 | + // bypasses WP's default cookie/nonce check so guests can hit it | |
| 1240 | + // at all. That bypass would otherwise leave the endpoint open | |
| 1241 | + // to cross-site forgery (any third-party page could POST a | |
| 1242 | + // booking using the visitor's session). | |
| 1243 | + // | |
| 1244 | + // We validate a booking-scoped action nonce here instead. The | |
| 1245 | + // token is minted at page-render time (FrontendAssetsProvider | |
| 1246 | + // injects it into `yatraBookingData.bookingNonce`) and the JS | |
| 1247 | + // forwards it in `X-Yatra-Booking-Nonce`. We also accept it in | |
| 1248 | + // the JSON body for any non-JS fallback flow. | |
| 1249 | + // | |
| 1250 | + // Returns 403 on failure — distinct from the 401 used by the | |
| 1251 | + // login/guest-checkout gates so frontends can distinguish | |
| 1252 | + // "security check failed" from "auth required". | |
| 1253 | + if (!$this->verifyBookingNonce($request, $data)) { | |
| 1254 | + return new WP_REST_Response([ | |
| 1255 | + 'success' => false, | |
| 1256 | + 'message' => __('Security check failed. Please refresh the page and try again.', 'yatra'), | |
| 1257 | + 'code' => 'invalid_nonce', | |
| 1258 | + ], 403); | |
| 1259 | + } | |
| 1260 | + | |
| 1261 | + // ======================================== | |
| 718 | 1262 | // REMAINING PAYMENT vs NEW BOOKING |
| 719 | 1263 | // ======================================== |
| 720 | 1264 | // A leftover PHP session from "pay remaining balance" must not hijack a normal |
| 721 | 1265 | // checkout POST (full traveler payload). Only treat as remaining-payment when the |
| @@ -746,13 +1290,11 @@ | ||
| 746 | 1290 | // GET BOOKING SETTINGS |
| 747 | 1291 | // ======================================== |
| 748 | 1292 | $settings = [ |
| 749 | 1293 | 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true), |
| 750 | - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false), | |
| 1294 | + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(), | |
| 751 | 1295 | 'require_login' => \Yatra\Services\SettingsService::get('require_login', false), |
| 752 | 1296 | 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true), |
| 753 | - 'cancellation_policy' => \Yatra\Services\SettingsService::get('cancellation_policy', 'full_refund'), | |
| 754 | - 'cancellation_days' => (int) \Yatra\Services\SettingsService::get('cancellation_days', 7), | |
| 755 | 1297 | 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24), |
| 756 | 1298 | 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true), |
| 757 | 1299 | ]; |
| 758 | 1300 | |
| @@ -777,11 +1319,50 @@ | ||
| 777 | 1319 | 'login_url' => wp_login_url(home_url($_SERVER['REQUEST_URI'] ?? '')), |
| 778 | 1320 | ], 401); |
| 779 | 1321 | } |
| 780 | 1322 | |
| 1323 | + // ======================================== | |
| 1324 | + // GUEST EMAIL VERIFICATION GATE | |
| 1325 | + // ======================================== | |
| 1326 | + // When `require_guest_email_verification` is on AND the | |
| 1327 | + // customer is not logged in, the booking goes through a | |
| 1328 | + // two-step flow: | |
| 1329 | + // 1. Booking row is created with status='pending_verification' | |
| 1330 | + // so the operator sees the intent in the admin and the | |
| 1331 | + // cron can purge unverified rows after N days. | |
| 1332 | + // 2. A magic-link email is sent. Payment is NOT initiated | |
| 1333 | + // until the customer clicks the link. | |
| 1334 | + // 3. On click, /yatra/v1/booking/verify-email validates the | |
| 1335 | + // HMAC token, flips status to 'pending', and redirects | |
| 1336 | + // to the payment continuation URL. | |
| 1337 | + // Logged-in users skip this entirely — their email is already | |
| 1338 | + // verified by WordPress on registration. | |
| 1339 | + $needs_email_verification = !is_user_logged_in() | |
| 1340 | + && (bool) \Yatra\Services\SettingsService::get('require_guest_email_verification', false); | |
| 1341 | + | |
| 781 | 1342 | // Get session data |
| 782 | 1343 | $session = yatra_get_booking_session(); |
| 783 | - | |
| 1344 | + | |
| 1345 | + // REST requests don't always carry PHPSESSID in the same scope as the | |
| 1346 | + // page that rendered the booking form (cookie path mismatches, output | |
| 1347 | + // buffering before session_start, server-side caching, etc). When | |
| 1348 | + // `$session` is empty we try to rehydrate from the transient backup that | |
| 1349 | + // BookingPageHandler writes when the form is rendered — the JS submit | |
| 1350 | + // now carries `booking_token` in the request body for exactly this | |
| 1351 | + // recovery path. Without this, `traveler_counts` / `pricing_type` / | |
| 1352 | + // `price_types` are lost and a `traveler_based` trip's | |
| 1353 | + // calculatePricing() collapses to 0, then BookingService rejects with | |
| 1354 | + // "Total amount must be greater than zero." | |
| 1355 | + if ((empty($session) || empty($session['trip_id'])) && !empty($data['booking_token'])) { | |
| 1356 | + $tokenFromBody = sanitize_text_field((string) $data['booking_token']); | |
| 1357 | + if ($tokenFromBody !== '') { | |
| 1358 | + $transient_data = get_transient($tokenFromBody); | |
| 1359 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 1360 | + $session = $transient_data; | |
| 1361 | + } | |
| 1362 | + } | |
| 1363 | + } | |
| 1364 | + | |
| 784 | 1365 | // Validate we have session data or direct booking data |
| 785 | 1366 | $trip_id = !empty($data['trip_id']) ? (int) $data['trip_id'] : ($session['trip_id'] ?? 0); |
| 786 | 1367 | |
| 787 | 1368 | if (!$trip_id) { |
| @@ -790,42 +1371,163 @@ | ||
| 790 | 1371 | 'message' => __('No trip selected for booking.', 'yatra'), |
| 791 | 1372 | ], 400); |
| 792 | 1373 | } |
| 793 | 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 | + | |
| 794 | 1387 | // Get contact email - handle both flat and nested formats |
| 795 | - $contact_email = $data['contact_email'] ?? ''; | |
| 1388 | + $contact_email = trim((string) ($data['contact_email'] ?? '')); | |
| 796 | 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 | + ); | |
| 797 | 1399 | $contact_first_name = $data['contact_first_name'] ?? ''; |
| 798 | 1400 | $contact_last_name = $data['contact_last_name'] ?? ''; |
| 799 | 1401 | $contact_country = $data['contact_country'] ?? ''; |
| 800 | - | |
| 801 | - $contact_nationality = $data['contact_nationality'] ?? ''; | |
| 1402 | + | |
| 1403 | + $contact_nationality = $data['contact_nationality'] ?? ''; | |
| 802 | 1404 | $contact_address = $data['contact_address'] ?? ''; |
| 803 | - | |
| 1405 | + | |
| 804 | 1406 | // Emergency contact |
| 805 | 1407 | $emergency_name = $data['emergency_name'] ?? ''; |
| 806 | - $emergency_phone = $data['emergency_phone'] ?? ''; | |
| 1408 | + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone( | |
| 1409 | + (string) ($data['emergency_phone'] ?? ''), | |
| 1410 | + (string) ($data['emergency_phone_country'] ?? '') | |
| 1411 | + ); | |
| 807 | 1412 | $emergency_relationship = $data['emergency_relationship'] ?? ''; |
| 808 | - | |
| 1413 | + | |
| 809 | 1414 | // Travel details |
| 810 | 1415 | $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? ''); |
| 811 | 1416 | $travelers = $data['travelers'] ?? []; |
| 812 | - | |
| 813 | - // Validate required fields | |
| 814 | - 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)) { | |
| 815 | 1497 | return new WP_REST_Response([ |
| 816 | 1498 | 'success' => false, |
| 817 | - 'message' => __('Email address is required.', 'yatra'), | |
| 1499 | + 'message' => __('A valid email address is required to complete this booking.', 'yatra'), | |
| 818 | 1500 | ], 400); |
| 819 | 1501 | } |
| 820 | - | |
| 821 | - 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)) { | |
| 822 | 1524 | return new WP_REST_Response([ |
| 823 | 1525 | 'success' => false, |
| 824 | 1526 | 'message' => __('Phone number is required.', 'yatra'), |
| 825 | 1527 | ], 400); |
| 826 | 1528 | } |
| 827 | - | |
| 1529 | + | |
| 828 | 1530 | if (empty($travel_date)) { |
| 829 | 1531 | return new WP_REST_Response([ |
| 830 | 1532 | 'success' => false, |
| 831 | 1533 | 'message' => __('Travel date is required.', 'yatra'), |
| @@ -830,9 +1532,9 @@ | ||
| 830 | 1532 | 'success' => false, |
| 831 | 1533 | 'message' => __('Travel date is required.', 'yatra'), |
| 832 | 1534 | ], 400); |
| 833 | 1535 | } |
| 834 | - | |
| 1536 | + | |
| 835 | 1537 | if (empty($travelers) || !is_array($travelers)) { |
| 836 | 1538 | return new WP_REST_Response([ |
| 837 | 1539 | 'success' => false, |
| 838 | 1540 | 'message' => __('At least one traveler is required.', 'yatra'), |
| @@ -838,14 +1540,27 @@ | ||
| 838 | 1540 | 'message' => __('At least one traveler is required.', 'yatra'), |
| 839 | 1541 | ], 400); |
| 840 | 1542 | } |
| 841 | 1543 | |
| 842 | - // Validate email | |
| 843 | - if (!is_email($contact_email)) { | |
| 844 | - return new WP_REST_Response([ | |
| 845 | - 'success' => false, | |
| 846 | - 'message' => __('Invalid email address.', 'yatra'), | |
| 847 | - ], 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 | + } | |
| 848 | 1563 | } |
| 849 | 1564 | |
| 850 | 1565 | // Get trip data |
| 851 | 1566 | $trip = $this->tripRepository->findPublished($trip_id); |
| @@ -909,22 +1624,91 @@ | ||
| 909 | 1624 | $traveler_counts['default'] = $travelers_count; |
| 910 | 1625 | } |
| 911 | 1626 | } |
| 912 | 1627 | |
| 913 | - // Use CalculationService as single source of truth | |
| 1628 | + // Pricing single source of truth: ALWAYS use calculateFromSession. | |
| 1629 | + // | |
| 1630 | + // The booking session is kept in sync by the JS layer — every | |
| 1631 | + // service toggle, traveler-count change, and coupon apply/remove | |
| 1632 | + // POSTs to /booking/session, which updates the transient. The | |
| 1633 | + // sidebar's `/booking/summary` then renders from | |
| 1634 | + // `calculateFromSession($session, …)`. If we built a second pricing | |
| 1635 | + // path here from form fields, any subtle drift (form missing a | |
| 1636 | + // category_id, traveler_counts aggregated as 'default', etc) would | |
| 1637 | + // produce a different total than the customer saw — they'd be | |
| 1638 | + // charged something they didn't agree to. So we merge the latest | |
| 1639 | + // session with any form-submitted overrides (services list, | |
| 1640 | + // travel_date, availability_id) and let calculateFromSession do | |
| 1641 | + // the math the same way the sidebar did. | |
| 914 | 1642 | $calculationService = new CalculationService(); |
| 915 | - $pricing = $calculationService->calculatePricing([ | |
| 916 | - 'trip_id' => $trip_id, | |
| 917 | - 'travelers_count' => $travelers_count, | |
| 918 | - 'traveler_counts' => $traveler_counts, | |
| 919 | - 'travel_date' => $travel_date, | |
| 920 | - 'departure_time' => $departure_time, | |
| 921 | - 'selected_services' => $additional_services, | |
| 922 | - 'availability_id' => $availability_id ? (int) $availability_id : null, | |
| 923 | - 'coupon_code' => $coupon_code, | |
| 924 | - 'payment_method' => $payment_method, | |
| 925 | - ]); | |
| 926 | - | |
| 1643 | + $session_for_pricing = is_array($session) ? $session : []; | |
| 1644 | + $session_for_pricing['trip_id'] = $trip_id; | |
| 1645 | + $session_for_pricing['travelers'] = $travelers_count; | |
| 1646 | + // Prefer per-category counts when we have them — otherwise let | |
| 1647 | + // calculateFromSession fall back to its internal handling. | |
| 1648 | + if (!empty($traveler_counts) && is_array($traveler_counts)) { | |
| 1649 | + $session_for_pricing['traveler_counts'] = $traveler_counts; | |
| 1650 | + } | |
| 1651 | + if (!empty($travel_date)) { | |
| 1652 | + $session_for_pricing['travel_date'] = $travel_date; | |
| 1653 | + } | |
| 1654 | + if (!empty($departure_time)) { | |
| 1655 | + $session_for_pricing['departure_time'] = $departure_time; | |
| 1656 | + } | |
| 1657 | + if (!empty($availability_id) && is_numeric($availability_id)) { | |
| 1658 | + $session_for_pricing['availability_id'] = (int) $availability_id; | |
| 1659 | + } | |
| 1660 | + if (is_array($additional_services)) { | |
| 1661 | + $session_for_pricing['additional_services'] = array_values(array_map('intval', $additional_services)); | |
| 1662 | + } | |
| 1663 | + | |
| 1664 | + try { | |
| 1665 | + $pricing = $calculationService->calculateFromSession( | |
| 1666 | + $session_for_pricing, | |
| 1667 | + $coupon_code, | |
| 1668 | + $payment_method | |
| 1669 | + ); | |
| 1670 | + } catch (\Throwable $e) { | |
| 1671 | + $pricing = []; | |
| 1672 | + } | |
| 1673 | + | |
| 1674 | + // If the session is so degenerate that even calculateFromSession | |
| 1675 | + // couldn't make a positive total (e.g. no traveler_counts at all), | |
| 1676 | + // surface that cleanly so the booking is rejected with a friendly | |
| 1677 | + // error rather than silently charging $0. | |
| 1678 | + if (((float) ($pricing['final_total'] ?? 0)) <= 0) { | |
| 1679 | + try { | |
| 1680 | + $sessionPricing = $calculationService->calculatePricing([ | |
| 1681 | + 'trip_id' => $trip_id, | |
| 1682 | + 'travelers_count' => $travelers_count, | |
| 1683 | + 'traveler_counts' => $traveler_counts, | |
| 1684 | + 'travel_date' => $travel_date, | |
| 1685 | + 'departure_time' => $departure_time, | |
| 1686 | + 'selected_services' => $additional_services, | |
| 1687 | + 'availability_id' => (!empty($availability_id) && is_numeric($availability_id)) ? (int) $availability_id : null, | |
| 1688 | + 'coupon_code' => $coupon_code, | |
| 1689 | + 'payment_method' => $payment_method, | |
| 1690 | + ]); | |
| 1691 | + if (((float) ($sessionPricing['final_total'] ?? 0)) > 0) { | |
| 1692 | + $pricing = $sessionPricing; | |
| 1693 | + } | |
| 1694 | + } catch (\Throwable $e) { | |
| 1695 | + // Keep $pricing as-is; downstream guard will surface a clean error. | |
| 1696 | + } | |
| 1697 | + } | |
| 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 | + | |
| 927 | 1711 | // Extract pricing results |
| 928 | 1712 | $total_amount = $pricing['final_total']; |
| 929 | 1713 | $amount_due = $pricing['amount_due']; |
| 930 | 1714 | $amount_paid = $pricing['amount_paid']; |
| @@ -958,9 +1742,9 @@ | ||
| 958 | 1742 | $isWaitlistCheckout = false; |
| 959 | 1743 | |
| 960 | 1744 | if ($resolvedAvailabilityForWaitlist !== null) { |
| 961 | 1745 | $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available'); |
| 962 | - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) { | |
| 1746 | + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) { | |
| 963 | 1747 | return new WP_REST_Response([ |
| 964 | 1748 | 'success' => false, |
| 965 | 1749 | 'message' => __('This departure is not open for booking.', 'yatra'), |
| 966 | 1750 | 'code' => 'date_blocked', |
| @@ -1007,10 +1791,10 @@ | ||
| 1007 | 1791 | 'payment_method' => $payment_method, |
| 1008 | 1792 | 'payment_gateway' => $payment_gateway, |
| 1009 | 1793 | 'total_amount' => round((float) $total_amount, 4), |
| 1010 | 1794 | 'amount_due' => round((float) $amount_due, 4), |
| 1011 | - 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20), | |
| 1012 | - 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30), | |
| 1795 | + 'deposit_percentage' => (int) apply_filters('yatra_deposit_percentage', 20, ['trip_id' => $trip_id]), | |
| 1796 | + 'partial_percentage' => (int) apply_filters('yatra_partial_payment_percentage', 30, ['trip_id' => $trip_id]), | |
| 1013 | 1797 | ]); |
| 1014 | 1798 | |
| 1015 | 1799 | // Prepare contact data |
| 1016 | 1800 | $contact_data = [ |
| @@ -1021,9 +1805,37 @@ | ||
| 1021 | 1805 | 'country' => sanitize_text_field($contact_country), |
| 1022 | 1806 | 'nationality' => sanitize_text_field($contact_nationality), |
| 1023 | 1807 | 'address' => sanitize_text_field($contact_address), |
| 1024 | 1808 | ]; |
| 1025 | - | |
| 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 | + | |
| 1026 | 1838 | // Prepare emergency contact data |
| 1027 | 1839 | $emergency_data = [ |
| 1028 | 1840 | 'name' => sanitize_text_field($emergency_name), |
| 1029 | 1841 | 'phone' => sanitize_text_field($emergency_phone), |
| @@ -1028,8 +1840,31 @@ | ||
| 1028 | 1840 | 'name' => sanitize_text_field($emergency_name), |
| 1029 | 1841 | 'phone' => sanitize_text_field($emergency_phone), |
| 1030 | 1842 | 'relationship' => sanitize_text_field($emergency_relationship), |
| 1031 | 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 | + } | |
| 1032 | 1867 | |
| 1033 | 1868 | // Sanitize travelers data |
| 1034 | 1869 | $sanitized_travelers = []; |
| 1035 | 1870 | foreach ($travelers as $traveler) { |
| @@ -1036,8 +1871,13 @@ | ||
| 1036 | 1871 | if (is_array($traveler)) { |
| 1037 | 1872 | $sanitized_traveler = []; |
| 1038 | 1873 | foreach ($traveler as $key => $value) { |
| 1039 | 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 | + } | |
| 1040 | 1880 | if (is_array($value)) { |
| 1041 | 1881 | $sanitized_traveler[$sk] = array_map(static function ($v) { |
| 1042 | 1882 | return sanitize_text_field(is_scalar($v) ? (string) $v : ''); |
| 1043 | 1883 | }, $value); |
| @@ -1044,8 +1884,19 @@ | ||
| 1044 | 1884 | } else { |
| 1045 | 1885 | $sanitized_traveler[$sk] = sanitize_text_field((string) $value); |
| 1046 | 1886 | } |
| 1047 | 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 | + } | |
| 1048 | 1899 | $sanitized_travelers[] = $sanitized_traveler; |
| 1049 | 1900 | } |
| 1050 | 1901 | } |
| 1051 | 1902 | |
| @@ -1230,12 +2081,48 @@ | ||
| 1230 | 2081 | |
| 1231 | 2082 | if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) { |
| 1232 | 2083 | $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id; |
| 1233 | 2084 | $booking_data['status'] = 'waitlist'; |
| 1234 | - $booking_data['payment_gateway'] = 'pay_later'; | |
| 1235 | - $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 | + } | |
| 1236 | 2097 | } |
| 1237 | 2098 | |
| 2099 | + // Hold the booking in `pending_verification` until the guest | |
| 2100 | + // clicks the magic link. Payment is initiated only after the | |
| 2101 | + // status flips to 'pending' (in the verify-email endpoint). | |
| 2102 | + // We also pin the gateway to `pay_later` here because the | |
| 2103 | + // payment selection at this point would otherwise lock the | |
| 2104 | + // operator into a specific gateway before the customer has | |
| 2105 | + // even confirmed their email — better to defer that choice | |
| 2106 | + // until verification completes and the regular checkout | |
| 2107 | + // resumes. | |
| 2108 | + if ($needs_email_verification && !$isWaitlistCheckout) { | |
| 2109 | + $booking_data['status'] = 'pending_verification'; | |
| 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 | + } | |
| 2123 | + } | |
| 2124 | + | |
| 1238 | 2125 | try { |
| 1239 | 2126 | $booking = $booking_service->createBooking($booking_data); |
| 1240 | 2127 | // BookingService returns ['success'=>bool, 'booking_id'=>int, ...] |
| 1241 | 2128 | $booking_id = $booking['booking_id'] ?? $booking['id'] ?? null; |
| @@ -1288,8 +2175,14 @@ | ||
| 1288 | 2175 | * @param int $trip_id The trip ID |
| 1289 | 2176 | * @param array $data The booking request data (contains selected_services) |
| 1290 | 2177 | * @param int $travelers_count Total number of travelers |
| 1291 | 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. | |
| 1292 | 2185 | * @since 3.0.0 |
| 1293 | 2186 | */ |
| 1294 | 2187 | // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services'] |
| 1295 | 2188 | if (!isset($data['selected_services'])) { |
| @@ -1300,9 +2193,9 @@ | ||
| 1300 | 2193 | if (!is_array($data['selected_services'])) { |
| 1301 | 2194 | $data['selected_services'] = []; |
| 1302 | 2195 | } |
| 1303 | 2196 | $data['selected_services'] = array_map('intval', $data['selected_services']); |
| 1304 | - 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)); | |
| 1305 | 2198 | |
| 1306 | 2199 | // ======================================== |
| 1307 | 2200 | // SAVE TRAVELLERS TO NORMALIZED TABLES |
| 1308 | 2201 | // ======================================== |
| @@ -1372,8 +2265,117 @@ | ||
| 1372 | 2265 | |
| 1373 | 2266 | // Clear booking session |
| 1374 | 2267 | yatra_clear_booking_session(); |
| 1375 | 2268 | |
| 2269 | + // ======================================== | |
| 2270 | + // GUEST EMAIL VERIFICATION — INTERCEPT | |
| 2271 | + // ======================================== | |
| 2272 | + // Booking row + travelers + services are already saved at | |
| 2273 | + // this point with status='pending_verification'. Send the | |
| 2274 | + // magic-link email, return a structured "check your email" | |
| 2275 | + // response, and DO NOT initiate payment. The customer's | |
| 2276 | + // click on the verify-email endpoint transitions the booking | |
| 2277 | + // to 'pending' and emits the payment-continuation URL. | |
| 2278 | + if ($needs_email_verification) { | |
| 2279 | + $verify_url = \Yatra\Services\GuestVerificationTokenService::buildVerifyUrl( | |
| 2280 | + (int) $booking_id, | |
| 2281 | + (string) $contact_data['email'] | |
| 2282 | + ); | |
| 2283 | + | |
| 2284 | + // Variables piped into the template email. All standard | |
| 2285 | + // booking merge tags resolve normally (the row exists); | |
| 2286 | + // we also pass intro_paragraph + footer_note + the | |
| 2287 | + // expiry banner so operators that haven't customised | |
| 2288 | + // the template still get good defaults. | |
| 2289 | + $email_vars = []; | |
| 2290 | + if ($saved_booking !== null) { | |
| 2291 | + $email_vars = \Yatra\Services\TransactionalEmailTemplateService::variablesFromBooking($saved_booking); | |
| 2292 | + } | |
| 2293 | + // Belt-and-braces: ensure customer name + email are | |
| 2294 | + // populated even when variablesFromBooking returned an | |
| 2295 | + // empty shell (which shouldn't happen, but if it does | |
| 2296 | + // we don't want the email to render "Hi ,"). | |
| 2297 | + $email_vars['customer_email'] = (string) ($email_vars['customer_email'] ?? $contact_data['email']); | |
| 2298 | + $email_vars['customer_name'] = (string) ($email_vars['customer_name'] | |
| 2299 | + ?? trim(($contact_data['first_name'] ?? '') . ' ' . ($contact_data['last_name'] ?? ''))); | |
| 2300 | + $email_vars['customer_first_name'] = (string) ($email_vars['customer_first_name'] | |
| 2301 | + ?? ($contact_data['first_name'] ?? '')); | |
| 2302 | + $email_vars['verification_link'] = $verify_url; | |
| 2303 | + $email_vars['intro_paragraph'] = __( | |
| 2304 | + "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.", | |
| 2305 | + 'yatra' | |
| 2306 | + ); | |
| 2307 | + $email_vars['footer_note'] = __( | |
| 2308 | + "If you didn't make this booking, you can safely ignore this email — no charges have been made.", | |
| 2309 | + 'yatra' | |
| 2310 | + ); | |
| 2311 | + $email_vars['expiry_notice_html'] = '<strong>' | |
| 2312 | + . esc_html__('This link expires in 48 hours.', 'yatra') | |
| 2313 | + . '</strong>'; | |
| 2314 | + | |
| 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( | |
| 2336 | + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION, | |
| 2337 | + $email_vars | |
| 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 | + } | |
| 2361 | + | |
| 2362 | + return new WP_REST_Response([ | |
| 2363 | + 'success' => true, | |
| 2364 | + 'code' => 'email_verification_required', | |
| 2365 | + 'message' => __( | |
| 2366 | + "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.", | |
| 2367 | + 'yatra' | |
| 2368 | + ), | |
| 2369 | + 'data' => [ | |
| 2370 | + 'booking_id' => $booking_id, | |
| 2371 | + 'reference' => $booking_reference, | |
| 2372 | + 'email' => $contact_data['email'], | |
| 2373 | + 'expires_in_seconds' => (int) apply_filters('yatra_guest_verification_ttl_seconds', 48 * 3600), | |
| 2374 | + ], | |
| 2375 | + ]); | |
| 2376 | + } | |
| 2377 | + | |
| 1376 | 2378 | // Check if this is an offline gateway |
| 1377 | 2379 | $is_offline = $is_offline_gateway; |
| 1378 | 2380 | |
| 1379 | 2381 | // For online gateways, create payment intent and return redirect URL |
| @@ -1442,21 +2444,29 @@ | ||
| 1442 | 2444 | |
| 1443 | 2445 | // ======================================== |
| 1444 | 2446 | // DETERMINE BOOKING STATUS |
| 1445 | 2447 | // ======================================== |
| 1446 | - // Priority: | |
| 1447 | - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically) | |
| 1448 | - // 2. For pay_later: auto_confirm_pay_later setting | |
| 1449 | - // 3. For bank_transfer: always pending until verified | |
| 1450 | - | |
| 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 | + | |
| 1451 | 2455 | $booking_status = 'pending'; |
| 1452 | 2456 | $status_message = __('Booking received!', 'yatra'); |
| 1453 | - | |
| 1454 | - // Check if auto-confirm all bookings is enabled | |
| 1455 | - if ($settings['auto_confirm_bookings']) { | |
| 1456 | - // 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. | |
| 1457 | 2461 | $booking_status = 'confirmed'; |
| 1458 | 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'); | |
| 1459 | 2469 | } elseif ($payment_gateway === 'pay_later') { |
| 1460 | 2470 | // Pay Later: Check the specific pay_later auto-confirm setting |
| 1461 | 2471 | if ($settings['auto_confirm_pay_later']) { |
| 1462 | 2472 | $booking_status = 'confirmed'; |
| @@ -1526,10 +2536,8 @@ | ||
| 1526 | 2536 | 'payment_gateway' => $payment_gateway, |
| 1527 | 2537 | 'total_amount' => $total_amount, |
| 1528 | 2538 | 'amount_due' => $amount_due, |
| 1529 | 2539 | 'booking_status' => $booking_status, |
| 1530 | - 'cancellation_policy' => $settings['cancellation_policy'], | |
| 1531 | - 'cancellation_days' => $settings['cancellation_days'], | |
| 1532 | 2540 | 'expiry_datetime' => $expiry_datetime, |
| 1533 | 2541 | ]); |
| 1534 | 2542 | } |
| 1535 | 2543 | |
| @@ -1677,11 +2685,44 @@ | ||
| 1677 | 2685 | } |
| 1678 | 2686 | |
| 1679 | 2687 | $is_offline_gateway = $this->isOfflineGateway($payment_gateway); |
| 1680 | 2688 | |
| 2689 | + // Server-side guard: offline gateways (Pay Later, Bank Transfer, etc.) don't | |
| 2690 | + // actually collect money. Letting them be selected for a remaining-balance | |
| 2691 | + // payment leaves the booking unpaid while the customer thinks they finished | |
| 2692 | + // the flow. The frontend already filters them out for this checkout — this | |
| 2693 | + // catches a tampered client. Filterable so a custom Pay Later that does | |
| 2694 | + // settle can opt back in. | |
| 2695 | + $allow_offline_for_remaining = (bool) apply_filters( | |
| 2696 | + 'yatra_remaining_payment_allow_offline_gateway', | |
| 2697 | + false, | |
| 2698 | + $payment_gateway, | |
| 2699 | + $booking | |
| 2700 | + ); | |
| 2701 | + if ($is_offline_gateway && !$allow_offline_for_remaining) { | |
| 2702 | + return new WP_REST_Response([ | |
| 2703 | + 'success' => false, | |
| 2704 | + 'message' => __('This payment method cannot be used to settle a remaining balance. Please choose a card-based gateway.', 'yatra'), | |
| 2705 | + 'data' => [ | |
| 2706 | + 'rejected_gateway' => $payment_gateway, | |
| 2707 | + 'reason' => 'offline_not_allowed_for_remaining_payment', | |
| 2708 | + ], | |
| 2709 | + ], 400); | |
| 2710 | + } | |
| 2711 | + | |
| 1681 | 2712 | // Online gateways: delegate to the same flow as new-booking checkout (PayPal redirect, Stripe intent, etc.). |
| 1682 | 2713 | // Do not put confirmation URL in redirect_url here — that caused the browser to skip payment entirely. |
| 1683 | 2714 | if (!$is_offline_gateway && $remaining_amount > 0) { |
| 2715 | + // The gateway needs an explicit return URL with the `balance=paid` flag so the | |
| 2716 | + // confirmation page can show "balance just paid" content. Without setting it | |
| 2717 | + // here, processPaymentWithGateway() would fall back to a plain confirmation URL | |
| 2718 | + // and the customer would land on the generic post-booking template. | |
| 2719 | + $remaining_return_url = add_query_arg( | |
| 2720 | + 'balance', | |
| 2721 | + 'paid', | |
| 2722 | + $this->getConfirmationUrl($booking_reference) | |
| 2723 | + ); | |
| 2724 | + | |
| 1684 | 2725 | $payment_params = array_merge($data, [ |
| 1685 | 2726 | 'booking_id' => $booking_id, |
| 1686 | 2727 | 'reference' => $booking_reference, |
| 1687 | 2728 | 'amount' => $remaining_amount, |
| @@ -1688,23 +2729,44 @@ | ||
| 1688 | 2729 | 'currency' => $currency, |
| 1689 | 2730 | 'customer_email' => $customer_email, |
| 1690 | 2731 | 'customer_name' => $customer_name !== '' ? $customer_name : $customer_email, |
| 1691 | 2732 | 'trip_title' => $trip_title, |
| 2733 | + 'return_url' => $remaining_return_url, | |
| 1692 | 2734 | ]); |
| 1693 | 2735 | |
| 1694 | 2736 | $payment_result = $this->processPaymentGateway($payment_gateway, $payment_params); |
| 1695 | 2737 | |
| 1696 | 2738 | if (!empty($payment_result['success'])) { |
| 2739 | + // Common identity fields the Stripe.js / PayPal SDK frontend expects on | |
| 2740 | + // the response. process_remaining_payment is reached without going through | |
| 2741 | + // the new-booking checkout, so the gateway result alone is missing | |
| 2742 | + // customer_email / customer_name — re-attach them from the booking we | |
| 2743 | + // already loaded above. Also overwrite confirmation_url AND redirect_url | |
| 2744 | + // with the balance-tagged version so the post-payment landing page knows | |
| 2745 | + // this was a remaining-balance flow. | |
| 2746 | + // | |
| 2747 | + // Why both fields: the Stripe.js helper buildConfirmationUrlFromBookingInfo() | |
| 2748 | + // prefers bookingInfo.redirect_url and only falls back to rebuilding the URL | |
| 2749 | + // from scratch (without query params) when redirect_url is absent. Without | |
| 2750 | + // redirect_url being explicitly set here the `?balance=paid` flag would be | |
| 2751 | + // lost on the final navigation after Stripe success. | |
| 2752 | + $remaining_identity_fields = [ | |
| 2753 | + 'customer_email' => $customer_email, | |
| 2754 | + 'customer_name' => $customer_name, | |
| 2755 | + 'confirmation_url' => $remaining_return_url, | |
| 2756 | + 'redirect_url' => $remaining_return_url, | |
| 2757 | + ]; | |
| 2758 | + | |
| 1697 | 2759 | if (!empty($payment_result['payment_url'])) { |
| 1698 | 2760 | return new WP_REST_Response([ |
| 1699 | 2761 | 'success' => true, |
| 1700 | 2762 | 'message' => __('Redirecting to payment...', 'yatra'), |
| 1701 | - 'data' => [ | |
| 2763 | + 'data' => array_merge([ | |
| 1702 | 2764 | 'booking_id' => $booking_id, |
| 1703 | 2765 | 'reference' => $booking_reference, |
| 1704 | 2766 | 'payment_url' => $payment_result['payment_url'], |
| 1705 | 2767 | 'is_remaining_payment' => true, |
| 1706 | - ], | |
| 2768 | + ], $remaining_identity_fields), | |
| 1707 | 2769 | ]); |
| 1708 | 2770 | } |
| 1709 | 2771 | |
| 1710 | 2772 | if (!empty($payment_result['requires_action'])) { |
| @@ -1716,9 +2778,10 @@ | ||
| 1716 | 2778 | 'booking_id' => $booking_id, |
| 1717 | 2779 | 'reference' => $booking_reference, |
| 1718 | 2780 | 'is_remaining_payment' => true, |
| 1719 | 2781 | ], |
| 1720 | - $payment_result | |
| 2782 | + $payment_result, | |
| 2783 | + $remaining_identity_fields | |
| 1721 | 2784 | ), |
| 1722 | 2785 | ]); |
| 1723 | 2786 | } |
| 1724 | 2787 | |
| @@ -1725,14 +2788,14 @@ | ||
| 1725 | 2788 | if (!empty($payment_result['redirect_url'])) { |
| 1726 | 2789 | return new WP_REST_Response([ |
| 1727 | 2790 | 'success' => true, |
| 1728 | 2791 | 'message' => __('Payment processed.', 'yatra'), |
| 1729 | - 'data' => [ | |
| 2792 | + 'data' => array_merge([ | |
| 1730 | 2793 | 'booking_id' => $booking_id, |
| 1731 | 2794 | 'reference' => $booking_reference, |
| 1732 | 2795 | 'redirect_url' => $payment_result['redirect_url'], |
| 1733 | 2796 | 'is_remaining_payment' => true, |
| 1734 | - ], | |
| 2797 | + ], $remaining_identity_fields), | |
| 1735 | 2798 | ]); |
| 1736 | 2799 | } |
| 1737 | 2800 | } |
| 1738 | 2801 | |
| @@ -1747,11 +2810,20 @@ | ||
| 1747 | 2810 | ], |
| 1748 | 2811 | ], 400); |
| 1749 | 2812 | } |
| 1750 | 2813 | |
| 1751 | - // Offline gateways: no external redirect — confirmation page only | |
| 2814 | + // Offline gateways: no external redirect — confirmation page only. | |
| 2815 | + // Append `balance=paid` so the confirmation template renders the | |
| 2816 | + // remaining-payment-specific banner ("balance received, fully paid") | |
| 2817 | + // instead of the generic "booking confirmed" copy. | |
| 1752 | 2818 | yatra_clear_remaining_session(); |
| 1753 | 2819 | |
| 2820 | + $offline_redirect = add_query_arg( | |
| 2821 | + 'balance', | |
| 2822 | + 'paid', | |
| 2823 | + $this->getConfirmationUrl($booking_reference) | |
| 2824 | + ); | |
| 2825 | + | |
| 1754 | 2826 | return new WP_REST_Response([ |
| 1755 | 2827 | 'success' => true, |
| 1756 | 2828 | 'message' => __('Continue to confirmation.', 'yatra'), |
| 1757 | 2829 | 'data' => [ |
| @@ -1763,9 +2835,9 @@ | ||
| 1763 | 2835 | 'currency' => $currency, |
| 1764 | 2836 | 'amount' => $remaining_amount, |
| 1765 | 2837 | 'customer_email' => $customer_email, |
| 1766 | 2838 | 'customer_name' => $customer_name, |
| 1767 | - 'redirect_url' => $this->getConfirmationUrl($booking_reference), | |
| 2839 | + 'redirect_url' => $offline_redirect, | |
| 1768 | 2840 | 'is_remaining_payment' => true, |
| 1769 | 2841 | ], |
| 1770 | 2842 | ]); |
| 1771 | 2843 | } |
| @@ -1814,11 +2886,15 @@ | ||
| 1814 | 2886 | // Default return_url to the configured booking confirmation URL so redirect gateways |
| 1815 | 2887 | // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways |
| 1816 | 2888 | // may still append their own query args on top of this URL. |
| 1817 | 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'] ?? ''); | |
| 1818 | 2894 | $paymentData = array_merge($params, [ |
| 1819 | 2895 | 'description' => $params['trip_title'] ?? '', |
| 1820 | - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')), | |
| 2896 | + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)), | |
| 1821 | 2897 | 'metadata' => [ |
| 1822 | 2898 | 'booking_id' => $params['booking_id'], |
| 1823 | 2899 | 'reference' => $params['reference'] ?? '' |
| 1824 | 2900 | ] |
| @@ -1867,10 +2943,12 @@ | ||
| 1867 | 2943 | ]; |
| 1868 | 2944 | } |
| 1869 | 2945 | |
| 1870 | 2946 | // For offline gateways or successful direct payments without redirect |
| 2947 | + $this->recordOfflinePendingPayment($params, $result, $gatewayId); | |
| 2948 | + | |
| 1871 | 2949 | return [ |
| 1872 | - 'success' => true, | |
| 2950 | + 'success' => true, | |
| 1873 | 2951 | 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '') |
| 1874 | 2952 | ]; |
| 1875 | 2953 | } |
| 1876 | 2954 | |
| @@ -1895,8 +2973,75 @@ | ||
| 1895 | 2973 | /** |
| 1896 | 2974 | * Record payment from gateway result |
| 1897 | 2975 | * Matches Stripe's completePayment behavior |
| 1898 | 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 | + | |
| 1899 | 3044 | private function recordGatewayPayment(array $params, array $result, string $gatewayId): void |
| 1900 | 3045 | { |
| 1901 | 3046 | global $wpdb; |
| 1902 | 3047 | |
| @@ -1904,18 +3049,39 @@ | ||
| 1904 | 3049 | $bookingId = (int) $params['booking_id']; |
| 1905 | 3050 | $amount = (float) ($params['amount'] ?? 0); |
| 1906 | 3051 | $currency = $params['currency'] ?? 'USD'; |
| 1907 | 3052 | $transactionId = $result['transaction_id'] ?? ''; |
| 1908 | - | |
| 3053 | + | |
| 1909 | 3054 | // Get booking |
| 1910 | 3055 | $booking = $this->bookingRepository->find($bookingId); |
| 1911 | - if (!$booking || $booking->payment_status === 'paid') { | |
| 3056 | + if (!$booking) { | |
| 1912 | 3057 | return; |
| 1913 | 3058 | } |
| 1914 | - | |
| 1915 | - // 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 | + | |
| 1916 | 3068 | $paymentRepository = new \Yatra\Repositories\PaymentRepository(); |
| 1917 | - $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([ | |
| 1918 | 3084 | 'booking_id' => $bookingId, |
| 1919 | 3085 | 'amount' => $amount, |
| 1920 | 3086 | 'currency' => $currency, |
| 1921 | 3087 | 'gateway' => $gatewayId, |
| @@ -1920,14 +3086,43 @@ | ||
| 1920 | 3086 | 'currency' => $currency, |
| 1921 | 3087 | 'gateway' => $gatewayId, |
| 1922 | 3088 | 'transaction_id' => $transactionId, |
| 1923 | 3089 | 'status' => 'completed', |
| 3090 | + 'customer_id' => $booking->customer_id ? (int) $booking->customer_id : null, | |
| 1924 | 3091 | 'created_at' => current_time('mysql'), |
| 1925 | 3092 | ]); |
| 1926 | - | |
| 1927 | - // Calculate total paid | |
| 1928 | - $paymentRepository = new \Yatra\Repositories\PaymentRepository(); | |
| 1929 | - | |
| 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 | + | |
| 1930 | 3125 | // Fire payment completed action |
| 1931 | 3126 | do_action('yatra_payment_completed', [ |
| 1932 | 3127 | 'booking_id' => $bookingId, |
| 1933 | 3128 | 'transaction_id' => $transactionId, |
| @@ -1934,11 +3129,12 @@ | ||
| 1934 | 3129 | 'amount' => $amount, |
| 1935 | 3130 | 'currency' => $currency, |
| 1936 | 3131 | 'gateway' => $gatewayId, |
| 1937 | 3132 | ]); |
| 1938 | - | |
| 1939 | - } catch (\Exception $e) { | |
| 1940 | - } | |
| 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 | + } | |
| 1941 | 3137 | } |
| 1942 | 3138 | |
| 1943 | 3139 | /** |
| 1944 | 3140 | * Process PayPal payment |
| @@ -1992,9 +3188,9 @@ | ||
| 1992 | 3188 | 'description' => $params['trip_title'], |
| 1993 | 3189 | ]], |
| 1994 | 3190 | 'application_context' => [ |
| 1995 | 3191 | 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])), |
| 1996 | - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']), | |
| 3192 | + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])), | |
| 1997 | 3193 | ], |
| 1998 | 3194 | ]), |
| 1999 | 3195 | ]); |
| 2000 | 3196 | |
| @@ -2113,9 +3309,12 @@ | ||
| 2113 | 3309 | 'su' => add_query_arg( |
| 2114 | 3310 | ['payment' => 'success', 'gateway' => 'esewa'], |
| 2115 | 3311 | $this->getConfirmationUrl($params['reference']) |
| 2116 | 3312 | ), |
| 2117 | - '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 | + ), | |
| 2118 | 3317 | ], $base_url); |
| 2119 | 3318 | |
| 2120 | 3319 | return ['success' => true, 'payment_url' => $payment_url]; |
| 2121 | 3320 | } |
| @@ -2228,10 +3427,16 @@ | ||
| 2228 | 3427 | $amount_due = $data['amount_due'] ?? 0; |
| 2229 | 3428 | $payment_method = $data['payment_method'] ?? 'full'; |
| 2230 | 3429 | $payment_gateway = $data['payment_gateway'] ?? 'pay_later'; |
| 2231 | 3430 | $booking_status = $data['booking_status'] ?? 'pending'; |
| 2232 | - $cancellation_policy = $data['cancellation_policy'] ?? 'full_refund'; | |
| 2233 | - $cancellation_days = $data['cancellation_days'] ?? 7; | |
| 3431 | + // Cancellation copy in the email now comes from the trip's | |
| 3432 | + // own cancellation_policy field (set per-trip on the Trip | |
| 3433 | + // editor), not from removed global settings. Falls back to | |
| 3434 | + // empty so the paragraph is silently omitted when the trip | |
| 3435 | + // doesn't have a policy set. | |
| 3436 | + $trip_cancellation_policy = isset($trip->cancellation_policy) | |
| 3437 | + ? wp_strip_all_tags((string) $trip->cancellation_policy) | |
| 3438 | + : ''; | |
| 2234 | 3439 | $expiry_datetime = $data['expiry_datetime'] ?? null; |
| 2235 | 3440 | |
| 2236 | 3441 | $customer_email = $contact['email'] ?? ''; |
| 2237 | 3442 | $customer_name = trim(($contact['first_name'] ?? '') . ' ' . ($contact['last_name'] ?? '')); |
| @@ -2248,8 +3453,9 @@ | ||
| 2248 | 3453 | ? __('Thank you for your booking! Your reservation has been confirmed.', 'yatra') |
| 2249 | 3454 | : __('Thank you for your booking! Your reservation has been received and is pending confirmation.', 'yatra'); |
| 2250 | 3455 | if ($booking_status === 'pending' && $expiry_datetime) { |
| 2251 | 3456 | $intro_paragraph .= ' ' . sprintf( |
| 3457 | + /* translators: %s: payment expiry date and time (formatted). */ | |
| 2252 | 3458 | __('Please complete your payment before %s to avoid automatic cancellation.', 'yatra'), |
| 2253 | 3459 | date_i18n(get_option('date_format') . ' ' . get_option('time_format'), strtotime($expiry_datetime)) |
| 2254 | 3460 | ); |
| 2255 | 3461 | } |
| @@ -2259,17 +3465,21 @@ | ||
| 2259 | 3465 | <div style="background:#f3f4f6;padding:20px;border-radius:8px;margin:16px 0;"> |
| 2260 | 3466 | <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p> |
| 2261 | 3467 | <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p> |
| 2262 | 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> |
| 2263 | - <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> | |
| 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. */ | |
| 3470 | +echo esc_html(yatra_format_duration((int) $trip->duration_days, (int) $trip->duration_nights, (int) ($trip->duration_hours ?? 0))); ?></p> | |
| 2264 | 3471 | <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p> |
| 2265 | 3472 | </div> |
| 2266 | 3473 | <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3> |
| 2267 | - <p><?php echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p> | |
| 3474 | + <p><?php /* translators: %s: total amount (formatted). */ | |
| 3475 | +echo esc_html(sprintf(__('Total: %s', 'yatra'), $formatted_total)); ?></p> | |
| 2268 | 3476 | <?php if ($payment_method === 'deposit') : ?> |
| 2269 | - <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> | |
| 3477 | + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */ | |
| 3478 | +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> | |
| 2270 | 3479 | <?php elseif ($payment_method === 'partial') : ?> |
| 2271 | - <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> | |
| 3480 | + <p><?php /* translators: 1: amount due now (formatted), 2: remaining amount (formatted). */ | |
| 3481 | +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> | |
| 2272 | 3482 | <?php else : ?> |
| 2273 | 3483 | <p><?php esc_html_e('Payment type: Full payment', 'yatra'); ?></p> |
| 2274 | 3484 | <?php endif; ?> |
| 2275 | 3485 | <?php if ($payment_gateway === 'pay_later') : ?> |
| @@ -2282,26 +3492,26 @@ | ||
| 2282 | 3492 | <?php foreach ($travelers as $i => $traveler) : ?> |
| 2283 | 3493 | <?php |
| 2284 | 3494 | $traveler_name = trim(($traveler['first_name'] ?? '') . ' ' . ($traveler['last_name'] ?? '')); |
| 2285 | 3495 | ?> |
| 2286 | - <li><?php echo esc_html(sprintf(__('Traveler %d: %s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li> | |
| 3496 | + <li><?php /* translators: 1: traveler number (1-based), 2: traveler full name. */ | |
| 3497 | +echo esc_html(sprintf(__('Traveler %1$d: %2$s', 'yatra'), $i + 1, $traveler_name ?: '—')); ?></li> | |
| 2287 | 3498 | <?php endforeach; ?> |
| 2288 | 3499 | </ul> |
| 2289 | 3500 | <?php |
| 2290 | - $cancellation_policy_labels = [ | |
| 2291 | - 'full_refund' => __('Full refund available', 'yatra'), | |
| 2292 | - 'partial_refund' => __('Partial refund available', 'yatra'), | |
| 2293 | - 'no_refund' => __('No refund available', 'yatra'), | |
| 2294 | - 'flexible' => __('Flexible cancellation', 'yatra'), | |
| 2295 | - ]; | |
| 2296 | - $policy_label = $cancellation_policy_labels[$cancellation_policy] ?? __('Standard policy applies', 'yatra'); | |
| 2297 | - ?> | |
| 2298 | - <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3> | |
| 2299 | - <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> | |
| 2300 | - <?php | |
| 2301 | - $custom_refund_policy = SettingsService::getString('refund_policy', ''); | |
| 2302 | - if ($custom_refund_policy !== '') { | |
| 2303 | - echo '<p>' . esc_html($custom_refund_policy) . '</p>'; | |
| 3501 | + // Cancellation policy paragraph now sources from the trip's | |
| 3502 | + // per-trip cancellation_policy field (set on the Trip | |
| 3503 | + // editor). The previous version used global cancellation | |
| 3504 | + // settings that were display-only — they appeared here but | |
| 3505 | + // never enforced a real cancellation cutoff. We've removed | |
| 3506 | + // those settings; if the trip itself doesn't define a | |
| 3507 | + // policy, the whole section is silently omitted so the | |
| 3508 | + // email isn't padded with empty headings. | |
| 3509 | + if ($trip_cancellation_policy !== '') { | |
| 3510 | + ?> | |
| 3511 | + <h3 style="font-size:16px;"><?php esc_html_e('Cancellation policy', 'yatra'); ?></h3> | |
| 3512 | + <p><?php echo esc_html($trip_cancellation_policy); ?></p> | |
| 3513 | + <?php | |
| 2304 | 3514 | } |
| 2305 | 3515 | ?> |
| 2306 | 3516 | <h3 style="font-size:16px;"><?php esc_html_e('What’s next?', 'yatra'); ?></h3> |
| 2307 | 3517 | <ol style="padding-left:20px;"> |
| @@ -2313,9 +3523,25 @@ | ||
| 2313 | 3523 | <p><a href="<?php echo esc_url(home_url('/')); ?>"><?php echo esc_html(home_url('/')); ?></a></p> |
| 2314 | 3524 | <?php |
| 2315 | 3525 | $details_html = ob_get_clean(); |
| 2316 | 3526 | |
| 2317 | - $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, [ | |
| 2318 | 3544 | 'customer_name' => $customer_name, |
| 2319 | 3545 | 'customer_first_name' => (string) ($contact['first_name'] ?? ''), |
| 2320 | 3546 | 'customer_last_name' => (string) ($contact['last_name'] ?? ''), |
| 2321 | 3547 | 'customer_email' => $customer_email, |
| @@ -2331,11 +3557,12 @@ | ||
| 2331 | 3557 | 'currency' => SettingsService::getCurrency(), |
| 2332 | 3558 | 'intro_paragraph' => $intro_paragraph, |
| 2333 | 3559 | 'details_html' => $details_html, |
| 2334 | 3560 | 'details_html_only' => '1', |
| 3561 | + /* translators: %s: site name. */ | |
| 2335 | 3562 | 'footer_note' => sprintf(__('— %s', 'yatra'), get_bloginfo('name')), |
| 2336 | 3563 | 'transactional_context' => 'booking_created', |
| 2337 | - ]; | |
| 3564 | + ]); | |
| 2338 | 3565 | |
| 2339 | 3566 | TransactionalEmailTemplateService::sendIfEnabled( |
| 2340 | 3567 | TransactionalEmailTemplateService::TYPE_BOOKING_CONFIRMATION, |
| 2341 | 3568 | $customer_email, |
| @@ -2351,12 +3578,18 @@ | ||
| 2351 | 3578 | */ |
| 2352 | 3579 | public function apply_coupon(WP_REST_Request $request): WP_REST_Response |
| 2353 | 3580 | { |
| 2354 | 3581 | yatra_start_session(); |
| 2355 | - | |
| 2356 | - $data = $request->get_json_params(); | |
| 3582 | + | |
| 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 | + | |
| 2357 | 3590 | $code = isset($data['code']) ? strtoupper(sanitize_text_field($data['code'])) : ''; |
| 2358 | - | |
| 3591 | + | |
| 2359 | 3592 | if (empty($code)) { |
| 2360 | 3593 | return new WP_REST_Response([ |
| 2361 | 3594 | 'success' => false, |
| 2362 | 3595 | 'message' => __('Please enter a coupon code.', 'yatra'), |
| @@ -2361,12 +3594,32 @@ | ||
| 2361 | 3594 | 'success' => false, |
| 2362 | 3595 | 'message' => __('Please enter a coupon code.', 'yatra'), |
| 2363 | 3596 | ], 400); |
| 2364 | 3597 | } |
| 2365 | - | |
| 2366 | - // Get current session | |
| 3598 | + | |
| 3599 | + // Get current session — with token-rehydration fallback for REST | |
| 3600 | + // contexts where PHPSESSID isn't propagated (same shape as the | |
| 3601 | + // service-toggle / summary endpoints). Also writes back to $_SESSION | |
| 3602 | + // so the subsequent `yatra_set_booking_session()` persists alongside | |
| 3603 | + // the existing transient. | |
| 2367 | 3604 | $session = yatra_get_booking_session(); |
| 2368 | 3605 | if (empty($session) || empty($session['trip_id'])) { |
| 3606 | + $token = null; | |
| 3607 | + if (!empty($data['booking_token']) && is_string($data['booking_token'])) { | |
| 3608 | + $token = sanitize_text_field((string) $data['booking_token']); | |
| 3609 | + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { | |
| 3610 | + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); | |
| 3611 | + } | |
| 3612 | + if ($token) { | |
| 3613 | + $transient_data = get_transient($token); | |
| 3614 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 3615 | + $session = $transient_data; | |
| 3616 | + $_SESSION['yatra_booking'] = $session; | |
| 3617 | + $_SESSION['yatra_booking_token'] = $token; | |
| 3618 | + } | |
| 3619 | + } | |
| 3620 | + } | |
| 3621 | + if (empty($session) || empty($session['trip_id'])) { | |
| 2369 | 3622 | return new WP_REST_Response([ |
| 2370 | 3623 | 'success' => false, |
| 2371 | 3624 | 'message' => __('No active booking session found.', 'yatra'), |
| 2372 | 3625 | ], 400); |
| @@ -2425,8 +3678,326 @@ | ||
| 2425 | 3678 | ]); |
| 2426 | 3679 | } |
| 2427 | 3680 | |
| 2428 | 3681 | /** |
| 3682 | + * Verify a guest's booking email via magic-link token. | |
| 3683 | + * | |
| 3684 | + * Flow: | |
| 3685 | + * 1. Validate the HMAC token (forgery, expiry, email-binding). | |
| 3686 | + * 2. Look up the booking; confirm it's in `pending_verification`. | |
| 3687 | + * 3. Flip status to `pending` (or `confirmed` when auto-confirm | |
| 3688 | + * pay-later is enabled for this site) and fire the standard | |
| 3689 | + * yatra_booking_status_changed action so inventory + email | |
| 3690 | + * automations resume normally. | |
| 3691 | + * 4. 302 redirect the browser to a continuation URL: | |
| 3692 | + * - If amount_due > 0: back to the booking page for the | |
| 3693 | + * payment step the customer skipped earlier. | |
| 3694 | + * - If amount_due == 0 / auto-confirm: to the booking | |
| 3695 | + * confirmation/thank-you page. | |
| 3696 | + * 5. On any failure, render a friendly HTML page (not JSON) so | |
| 3697 | + * the customer sees readable text in their browser tab. | |
| 3698 | + * | |
| 3699 | + * @return WP_REST_Response|WP_Error|void | |
| 3700 | + */ | |
| 3701 | + public function verify_email(WP_REST_Request $request) | |
| 3702 | + { | |
| 3703 | + $token = (string) $request->get_param('token'); | |
| 3704 | + $bookingRepo = new \Yatra\Repositories\BookingRepository(); | |
| 3705 | + | |
| 3706 | + // First decode just to extract the booking id (for the | |
| 3707 | + // expectedEmail lookup). Verify() is called again below | |
| 3708 | + // with the actual email so the email-binding check runs. | |
| 3709 | + $partsPreview = explode('.', $token); | |
| 3710 | + $bookingIdGuess = (\count($partsPreview) >= 1 && ctype_digit($partsPreview[0])) | |
| 3711 | + ? (int) $partsPreview[0] | |
| 3712 | + : 0; | |
| 3713 | + $booking = $bookingIdGuess > 0 ? $bookingRepo->find($bookingIdGuess) : null; | |
| 3714 | + $expectedEmail = $booking ? (string) ($booking->contact_email ?? '') : ''; | |
| 3715 | + | |
| 3716 | + $result = \Yatra\Services\GuestVerificationTokenService::verify($token, $expectedEmail); | |
| 3717 | + | |
| 3718 | + if (!$result['ok']) { | |
| 3719 | + $this->renderVerifyEmailErrorPage((string) ($result['reason'] ?? 'invalid')); | |
| 3720 | + } | |
| 3721 | + if ($booking === null) { | |
| 3722 | + $this->renderVerifyEmailErrorPage('booking_not_found'); | |
| 3723 | + } | |
| 3724 | + | |
| 3725 | + // Re-entrant: if the booking has already been verified, show the | |
| 3726 | + // same success page (idempotent) — pre-3.0.5 silently redirected | |
| 3727 | + // and the customer was left wondering whether anything happened. | |
| 3728 | + $currentStatus = (string) ($booking->status ?? ''); | |
| 3729 | + $alreadyVerified = $currentStatus !== 'pending_verification'; | |
| 3730 | + | |
| 3731 | + if (!$alreadyVerified) { | |
| 3732 | + // Flip status to 'pending' so downstream hooks (inventory / | |
| 3733 | + // notification automations) see fresh data, then fire | |
| 3734 | + // yatra_booking_created so the listeners we deferred at | |
| 3735 | + // creation time (admin "new booking" notification + Pro | |
| 3736 | + // email-automation booking.created fan-out) run now — i.e. | |
| 3737 | + // *after* the customer has proven the email is theirs. | |
| 3738 | + $bookingRepo->updateStatus((int) $booking->id, 'pending'); | |
| 3739 | + do_action('yatra_booking_email_verified', (int) $booking->id); | |
| 3740 | + | |
| 3741 | + // Re-fetch so the post-verification action receives the | |
| 3742 | + // booking row with the new status, then fire the deferred | |
| 3743 | + // booking-created action. See BookingService::createBooking() | |
| 3744 | + // for the matching skip-on-pending-verification branch. | |
| 3745 | + $verifiedBooking = $bookingRepo->find((int) $booking->id); | |
| 3746 | + if (is_object($verifiedBooking)) { | |
| 3747 | + do_action( | |
| 3748 | + \Yatra\Hooks\TelemetryHookNames::BOOKING_CREATED, | |
| 3749 | + (int) $verifiedBooking->id, | |
| 3750 | + $verifiedBooking | |
| 3751 | + ); | |
| 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 | + } | |
| 3773 | + } | |
| 3774 | + | |
| 3775 | + $this->renderVerifyEmailSuccessPage( | |
| 3776 | + (int) $booking->id, | |
| 3777 | + (string) ($booking->reference ?? ''), | |
| 3778 | + $alreadyVerified | |
| 3779 | + ); | |
| 3780 | + } | |
| 3781 | + | |
| 3782 | + /** | |
| 3783 | + * Friendly HTML error page rendered when a verification link | |
| 3784 | + * is invalid / expired / tampered with. Avoids JSON in the | |
| 3785 | + * customer's browser tab (terrible UX). Reasons map to clear | |
| 3786 | + * messages so customers know what to do next. | |
| 3787 | + * | |
| 3788 | + * Emits raw HTML and exits. We can't return WP_REST_Response with an | |
| 3789 | + * HTML body because the REST server JSON-encodes the response data | |
| 3790 | + * regardless of the Content-Type header on the response object — | |
| 3791 | + * the customer would see `"<!doctype..."` (a JSON string) in their | |
| 3792 | + * browser tab. Echoing + exiting short-circuits the REST pipeline. | |
| 3793 | + * | |
| 3794 | + * @return never | |
| 3795 | + */ | |
| 3796 | + private function renderVerifyEmailErrorPage(string $reason): void | |
| 3797 | + { | |
| 3798 | + $messages = [ | |
| 3799 | + 'expired' => __('This verification link has expired. Please make a new booking — we keep the link valid for 48 hours.', 'yatra'), | |
| 3800 | + 'invalid_signature' => __('This verification link is invalid or has been tampered with. Please make a new booking.', 'yatra'), | |
| 3801 | + 'malformed_token' => __('This verification link is malformed. Please make a new booking.', 'yatra'), | |
| 3802 | + 'email_changed' => __('The email on this booking has changed since the link was sent. Please contact support.', 'yatra'), | |
| 3803 | + 'booking_not_found' => __('We could not find a booking for this verification link. Please make a new booking.', 'yatra'), | |
| 3804 | + ]; | |
| 3805 | + $message = $messages[$reason] ?? __('This verification link is no longer valid.', 'yatra'); | |
| 3806 | + | |
| 3807 | + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra'; | |
| 3808 | + $html = sprintf( | |
| 3809 | + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">' | |
| 3810 | + . '<meta name="viewport" content="width=device-width,initial-scale=1">' | |
| 3811 | + . '<title>%2$s</title>' | |
| 3812 | + . '<style>body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}' | |
| 3813 | + . '.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}' | |
| 3814 | + . 'h1{font-size:20px;margin:0 0 12px}p{color:#4b5563;line-height:1.6;margin:0 0 20px}' | |
| 3815 | + . 'a{display:inline-block;background:#2563eb;color:#fff;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600}</style></head>' | |
| 3816 | + . '<body><div class="box"><h1>%3$s</h1><p>%4$s</p><a href="%5$s">%6$s</a></div></body></html>', | |
| 3817 | + esc_attr(get_locale()), | |
| 3818 | + esc_html__('Verification link issue', 'yatra'), | |
| 3819 | + esc_html__('Verification link issue', 'yatra'), | |
| 3820 | + esc_html($message), | |
| 3821 | + esc_url(home_url('/')), | |
| 3822 | + esc_html(sprintf(/* translators: %s: brand name */ __('Return to %s', 'yatra'), $brandName)) | |
| 3823 | + ); | |
| 3824 | + | |
| 3825 | + if (!headers_sent()) { | |
| 3826 | + status_header(200); | |
| 3827 | + nocache_headers(); | |
| 3828 | + header('Content-Type: text/html; charset=UTF-8'); | |
| 3829 | + } | |
| 3830 | + echo $html; | |
| 3831 | + exit; | |
| 3832 | + } | |
| 3833 | + | |
| 3834 | + /** | |
| 3835 | + * Build the booking continuation URL (post-verification destination). | |
| 3836 | + * | |
| 3837 | + * If the site has a Yatra Bookings page, route through that with the | |
| 3838 | + * booking reference; otherwise fall back to the trip URL. Filterable | |
| 3839 | + * via `yatra_guest_verification_continuation_url` so integrations can | |
| 3840 | + * route to a custom thank-you page. | |
| 3841 | + */ | |
| 3842 | + private function continuationUrl(int $bookingId, string $reference): string | |
| 3843 | + { | |
| 3844 | + return (string) apply_filters( | |
| 3845 | + 'yatra_guest_verification_continuation_url', | |
| 3846 | + add_query_arg( | |
| 3847 | + ['booking_id' => $bookingId, 'verified' => '1'], | |
| 3848 | + home_url('/' . \Yatra\Services\SettingsService::getBookingBase() . '/') | |
| 3849 | + ), | |
| 3850 | + $bookingId, | |
| 3851 | + $reference | |
| 3852 | + ); | |
| 3853 | + } | |
| 3854 | + | |
| 3855 | + /** | |
| 3856 | + * Friendly HTML success page rendered after the guest clicks the | |
| 3857 | + * email-verification magic link. | |
| 3858 | + * | |
| 3859 | + * Pre-3.0.5 this endpoint silently 302-redirected to the booking page, | |
| 3860 | + * which made guests believe nothing had happened — there was no visible | |
| 3861 | + * "verified" feedback before they landed on the next step. This page | |
| 3862 | + * gives them an unambiguous confirmation, the booking reference, and | |
| 3863 | + * three explicit CTAs: | |
| 3864 | + * - Continue to booking (primary, continuation URL) | |
| 3865 | + * - My Account (when logged in) / Sign in (when not) | |
| 3866 | + * - Go to homepage (fallback) | |
| 3867 | + * | |
| 3868 | + * Idempotent: when the booking was already verified (re-click on the | |
| 3869 | + * same link), the heading + copy switch to the "already verified" | |
| 3870 | + * variant but the CTAs stay the same. | |
| 3871 | + * | |
| 3872 | + * Emits raw HTML and exits — same reasoning as | |
| 3873 | + * {@see self::renderVerifyEmailErrorPage()}: WP_REST_Response | |
| 3874 | + * JSON-encodes string bodies, so the customer would see | |
| 3875 | + * `"<!doctype..."` in their tab instead of the rendered page. | |
| 3876 | + * | |
| 3877 | + * @return never | |
| 3878 | + */ | |
| 3879 | + private function renderVerifyEmailSuccessPage(int $bookingId, string $reference, bool $alreadyVerified): void | |
| 3880 | + { | |
| 3881 | + // Booking is already persisted at this point and (for a fresh verify) | |
| 3882 | + // the status flip + booking-created fan-out have just fired. The | |
| 3883 | + // verified UI's primary action is therefore "View your booking | |
| 3884 | + // confirmation" — NOT "Continue Booking", which mislabelled the | |
| 3885 | + // booking as still in-progress and confused customers into thinking | |
| 3886 | + // they needed to re-submit the form. | |
| 3887 | + $confirmationUrl = function_exists('yatra_get_booking_confirmation_url') | |
| 3888 | + ? yatra_get_booking_confirmation_url($reference) | |
| 3889 | + : $this->continuationUrl($bookingId, $reference); | |
| 3890 | + | |
| 3891 | + // Account / login URL — prefer Yatra's account page when present, | |
| 3892 | + // fall back to wp_login_url() so the page never points nowhere. | |
| 3893 | + $accountBase = \Yatra\Services\SettingsService::getAccountBase(); | |
| 3894 | + $accountUrl = $accountBase !== '' | |
| 3895 | + ? home_url('/' . trim($accountBase, '/') . '/') | |
| 3896 | + : home_url('/'); | |
| 3897 | + $isLoggedIn = function_exists('is_user_logged_in') && is_user_logged_in(); | |
| 3898 | + $secondaryUrl = $isLoggedIn ? $accountUrl : wp_login_url($confirmationUrl); | |
| 3899 | + $secondaryLabel = $isLoggedIn | |
| 3900 | + ? __('Go to My Account', 'yatra') | |
| 3901 | + : __('Sign in', 'yatra'); | |
| 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 | + | |
| 3917 | + $heading = $alreadyVerified | |
| 3918 | + ? __('Email Already Verified', 'yatra') | |
| 3919 | + : __('Email Verified', 'yatra'); | |
| 3920 | + $message = $alreadyVerified | |
| 3921 | + ? __('Your booking email is already verified. You can view your booking confirmation, head to your account, or return to the homepage.', 'yatra') | |
| 3922 | + : __('Thanks! Your booking email has been verified and your booking is confirmed. View the full confirmation below.', 'yatra'); | |
| 3923 | + | |
| 3924 | + $primaryLabel = __('View Booking Confirmation', 'yatra'); | |
| 3925 | + $homeLabel = __('Go to Homepage', 'yatra'); | |
| 3926 | + $referenceLabel = __('Booking reference', 'yatra'); | |
| 3927 | + $brandName = function_exists('yatra_get_brand_name') ? yatra_get_brand_name() : 'Yatra'; | |
| 3928 | + | |
| 3929 | + $referenceLine = $reference !== '' | |
| 3930 | + ? sprintf( | |
| 3931 | + '<div class="ref"><span class="ref-label">%s</span><code>%s</code></div>', | |
| 3932 | + esc_html($referenceLabel), | |
| 3933 | + esc_html($reference) | |
| 3934 | + ) | |
| 3935 | + : ''; | |
| 3936 | + | |
| 3937 | + // Inline-only styling so the page renders correctly regardless of | |
| 3938 | + // theme stylesheet load order (REST → wp_die/raw HTML response). | |
| 3939 | + $html = sprintf( | |
| 3940 | + '<!doctype html><html lang="%1$s"><head><meta charset="utf-8">' | |
| 3941 | + . '<meta name="viewport" content="width=device-width,initial-scale=1">' | |
| 3942 | + . '<title>%2$s · %3$s</title>' | |
| 3943 | + . '<style>' | |
| 3944 | + . 'body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;background:#f9fafb;margin:0;padding:40px 20px;color:#111827}' | |
| 3945 | + . '.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}' | |
| 3946 | + . '.tick{display:inline-flex;align-items:center;justify-content:center;width:72px;height:72px;border-radius:50%%;background:#d1fae5;margin:0 auto 20px}' | |
| 3947 | + . 'h1{font-size:24px;margin:0 0 12px;color:#065f46}' | |
| 3948 | + . 'p{color:#4b5563;line-height:1.6;margin:0 0 24px}' | |
| 3949 | + . '.ref{display:inline-flex;align-items:center;gap:8px;background:#f3f4f6;border-radius:6px;padding:8px 12px;margin:0 0 24px}' | |
| 3950 | + . '.ref-label{font-size:12px;color:#6b7280;text-transform:uppercase;letter-spacing:.04em}' | |
| 3951 | + . '.ref code{font-weight:600;color:#111827}' | |
| 3952 | + . '.actions{display:flex;flex-direction:column;gap:10px;margin-top:8px}' | |
| 3953 | + . '.btn{display:inline-block;padding:12px 24px;border-radius:8px;text-decoration:none;font-weight:600;text-align:center}' | |
| 3954 | + . '.btn-primary{background:#059669;color:#fff}' | |
| 3955 | + . '.btn-primary:hover{background:#047857}' | |
| 3956 | + . '.btn-secondary{background:#fff;color:#1f2937;border:1px solid #d1d5db}' | |
| 3957 | + . '.btn-secondary:hover{background:#f9fafb}' | |
| 3958 | + . '.btn-tertiary{color:#4b5563;padding:8px 12px;font-weight:500}' | |
| 3959 | + . '</style></head>' | |
| 3960 | + . '<body><div class="box">' | |
| 3961 | + . '<div class="tick" aria-hidden="true">' | |
| 3962 | + . '<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">' | |
| 3963 | + . '<polyline points="20 6 9 17 4 12"></polyline></svg>' | |
| 3964 | + . '</div>' | |
| 3965 | + . '<h1>%2$s</h1>' | |
| 3966 | + . '<p>%4$s</p>' | |
| 3967 | + . '%5$s' | |
| 3968 | + . '<div class="actions">' | |
| 3969 | + . '<a class="btn btn-primary" href="%6$s">%7$s</a>' | |
| 3970 | + . '%8$s' | |
| 3971 | + . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>' | |
| 3972 | + . '</div>' | |
| 3973 | + . '</div></body></html>', | |
| 3974 | + esc_attr(get_locale()), | |
| 3975 | + esc_html($heading), | |
| 3976 | + esc_html($brandName), | |
| 3977 | + esc_html($message), | |
| 3978 | + $referenceLine, | |
| 3979 | + esc_url($confirmationUrl), | |
| 3980 | + esc_html($primaryLabel), | |
| 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 | + '', | |
| 3986 | + esc_url(home_url('/')), | |
| 3987 | + esc_html($homeLabel) | |
| 3988 | + ); | |
| 3989 | + | |
| 3990 | + if (!headers_sent()) { | |
| 3991 | + status_header(200); | |
| 3992 | + nocache_headers(); | |
| 3993 | + header('Content-Type: text/html; charset=UTF-8'); | |
| 3994 | + } | |
| 3995 | + echo $html; | |
| 3996 | + exit; | |
| 3997 | + } | |
| 3998 | + | |
| 3999 | + /** | |
| 2429 | 4000 | * Calculate booking summary and return HTML for dynamic updates |
| 2430 | 4001 | * Called via AJAX when traveler count, date, or coupon changes |
| 2431 | 4002 | */ |
| 2432 | 4003 | public function calculate_summary(WP_REST_Request $request): WP_REST_Response |
| @@ -2432,10 +4003,43 @@ | ||
| 2432 | 4003 | public function calculate_summary(WP_REST_Request $request): WP_REST_Response |
| 2433 | 4004 | { |
| 2434 | 4005 | yatra_start_session(); |
| 2435 | 4006 | $session = yatra_get_booking_session(); |
| 2436 | - $data = $request->get_json_params(); | |
| 4007 | + $data = $request->get_json_params() ?? []; | |
| 2437 | 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 | + | |
| 4014 | + // Same REST-context session-rehydration fallback as set_session() / | |
| 4015 | + // create_booking(): when PHPSESSID isn't propagated to the REST API | |
| 4016 | + // scope, look up the transient by `booking_token` (from request body | |
| 4017 | + // first, then ?booking_token=) so the partial summary refresh | |
| 4018 | + // doesn't 400. Without this, every service-toggle re-render fails | |
| 4019 | + // because trip_id resolves to 0. | |
| 4020 | + // | |
| 4021 | + // We also write the rehydrated session into $_SESSION so that the | |
| 4022 | + // downstream buildPricingHtml() — which calls yatra_get_booking_session() | |
| 4023 | + // again — sees the same data and doesn't fall back to its | |
| 4024 | + // "Pricing information not available" branch. | |
| 4025 | + if (empty($session) || empty($session['trip_id'])) { | |
| 4026 | + $token = null; | |
| 4027 | + if (!empty($data['booking_token']) && is_string($data['booking_token'])) { | |
| 4028 | + $token = sanitize_text_field((string) $data['booking_token']); | |
| 4029 | + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { | |
| 4030 | + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); | |
| 4031 | + } | |
| 4032 | + if ($token) { | |
| 4033 | + $transient_data = get_transient($token); | |
| 4034 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 4035 | + $session = $transient_data; | |
| 4036 | + $_SESSION['yatra_booking'] = $session; | |
| 4037 | + $_SESSION['yatra_booking_token'] = $token; | |
| 4038 | + } | |
| 4039 | + } | |
| 4040 | + } | |
| 4041 | + | |
| 2438 | 4042 | // Get trip_id from session (required) |
| 2439 | 4043 | $trip_id = (int) ($session['trip_id'] ?? 0); |
| 2440 | 4044 | |
| 2441 | 4045 | // Get traveler_counts from REQUEST (for dynamic updates) or fallback to session |
| @@ -2482,14 +4086,24 @@ | ||
| 2482 | 4086 | |
| 2483 | 4087 | global $wpdb; |
| 2484 | 4088 | |
| 2485 | 4089 | $availability = null; |
| 2486 | - if (!empty($availability_id) && is_numeric($availability_id)) { | |
| 2487 | - // Only use getById if availability_id is numeric | |
| 4090 | + if (!empty($travel_date)) { | |
| 4091 | + // Use the same resolver as the single-trip UI so rule slots, manual dates, | |
| 4092 | + // and flexible defaults all return a consistent object shape. | |
| 4093 | + try { | |
| 4094 | + $resolver = new \Yatra\Services\AvailabilityResolutionService(); | |
| 4095 | + $availability = $resolver->resolveAvailabilityForDate( | |
| 4096 | + $trip_id, | |
| 4097 | + $travel_date, | |
| 4098 | + $departure_time !== '' ? $departure_time : null | |
| 4099 | + ); | |
| 4100 | + } catch (\Throwable $e) { | |
| 4101 | + $availability = null; | |
| 4102 | + } | |
| 4103 | + } elseif (!empty($availability_id) && is_numeric($availability_id)) { | |
| 4104 | + // Back-compat: allow summary by numeric availability_id only. | |
| 2488 | 4105 | $availability = $this->availabilityService->getById((int) $availability_id); |
| 2489 | - } elseif (!empty($travel_date)) { | |
| 2490 | - // For string IDs or when no availability_id, use date+time lookup for day tours | |
| 2491 | - $availability = $this->availabilityService->getByTripAndDateTime($trip_id, $travel_date, $departure_time ?: null); | |
| 2492 | 4106 | } |
| 2493 | 4107 | |
| 2494 | 4108 | // Resolve pricing type and price_types via centralized TripPricingService |
| 2495 | 4109 | $resolved_pricing_type = !empty($pricing_type_from_request) |
| @@ -2518,8 +4132,15 @@ | ||
| 2518 | 4132 | if (!empty($price_types)) { |
| 2519 | 4133 | $resolved_pricing_type = 'traveler_based'; |
| 2520 | 4134 | } |
| 2521 | 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 | + | |
| 2522 | 4143 | // Enrich availability price_types with category labels if missing |
| 2523 | 4144 | if (!empty($price_types)) { |
| 2524 | 4145 | $missing_label_category_ids = []; |
| 2525 | 4146 | foreach ($price_types as $pt) { |
| @@ -2637,9 +4258,12 @@ | ||
| 2637 | 4258 | foreach ($price_types as $pt) { |
| 2638 | 4259 | $category_id = $pt->category_id; |
| 2639 | 4260 | $count = (int) ($normalized_traveler_counts[(int) $category_id] ?? ($normalized_traveler_counts[(string) $category_id] ?? 0)); |
| 2640 | 4261 | if ($count > 0) { |
| 2641 | - $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); | |
| 2642 | 4266 | $category_breakdown[] = [ |
| 2643 | 4267 | 'category_id' => $category_id, |
| 2644 | 4268 | 'label' => $pt->category_label ?? __('Traveler', 'yatra'), |
| 2645 | 4269 | 'count' => $count, |
| @@ -2644,8 +4268,13 @@ | ||
| 2644 | 4268 | 'label' => $pt->category_label ?? __('Traveler', 'yatra'), |
| 2645 | 4269 | 'count' => $count, |
| 2646 | 4270 | 'price' => (float) $pt->effective_price, |
| 2647 | 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', | |
| 2648 | 4277 | ]; |
| 2649 | 4278 | $subtotal += $category_subtotal; |
| 2650 | 4279 | $total_travelers += $count; |
| 2651 | 4280 | } |
| @@ -2678,13 +4307,14 @@ | ||
| 2678 | 4307 | |
| 2679 | 4308 | $priceTypesForDiscount = []; |
| 2680 | 4309 | if ($is_traveler_based) { |
| 2681 | 4310 | foreach ($price_types as $pt) { |
| 2682 | - $pt = (object) $pt; | |
| 2683 | - $priceTypesForDiscount[] = [ | |
| 2684 | - 'category_id' => $pt->category_id ?? null, | |
| 2685 | - 'effective_price' => $pt->effective_price ?? \Yatra\Services\TripPricingService::resolveCategoryEffectivePrice((array) $pt), | |
| 2686 | - ]; | |
| 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; | |
| 2687 | 4317 | } |
| 2688 | 4318 | } else { |
| 2689 | 4319 | $priceTypesForDiscount[] = [ |
| 2690 | 4320 | 'category_id' => 'default', |
| @@ -2723,12 +4353,23 @@ | ||
| 2723 | 4353 | } |
| 2724 | 4354 | |
| 2725 | 4355 | // Use CalculationService for on-demand pricing calculation |
| 2726 | 4356 | $calculationService = new CalculationService(); |
| 2727 | - | |
| 2728 | - // Initialize additional services (will be populated later via filter) | |
| 2729 | - $additional_services = []; | |
| 2730 | - | |
| 4357 | + | |
| 4358 | + // The selected-service ids the customer just submitted live in | |
| 4359 | + // `$selected_service_ids_from_request` (line ~2635). The previous | |
| 4360 | + // version of this method initialised a fresh `$additional_services | |
| 4361 | + // = []` here and passed that empty list into calculateFromSession | |
| 4362 | + // — which made the Pro AdditionalServicesModule's | |
| 4363 | + // `addServicesToSubtotal` hook bail out at its `empty($selectedServiceIds)` | |
| 4364 | + // guard, so every AJAX summary refresh wiped services out of the | |
| 4365 | + // Trip Subtotal even though the sidebar still rendered the rows. | |
| 4366 | + // Pass the real selected ids instead so CalculationService → | |
| 4367 | + // Pro filter chain folds them into `$subtotal` correctly. | |
| 4368 | + $additional_services = is_array($selected_service_ids_from_request) | |
| 4369 | + ? array_values(array_map('intval', $selected_service_ids_from_request)) | |
| 4370 | + : []; | |
| 4371 | + | |
| 2731 | 4372 | // Create session-like data structure for calculation (trip data fetched from database) |
| 2732 | 4373 | $session_like_data = [ |
| 2733 | 4374 | 'trip_id' => $trip_id, |
| 2734 | 4375 | 'travelers' => $total_travelers, |
| @@ -2745,13 +4386,13 @@ | ||
| 2745 | 4386 | 'payment_method' => $payment_method, |
| 2746 | 4387 | ]); |
| 2747 | 4388 | |
| 2748 | 4389 | $pricing = $calculationService->calculateFromSession( |
| 2749 | - $calculation_params['session_data'], | |
| 2750 | - $calculation_params['coupon_code'], | |
| 4390 | + $calculation_params['session_data'], | |
| 4391 | + $calculation_params['coupon_code'], | |
| 2751 | 4392 | $calculation_params['payment_method'] |
| 2752 | 4393 | ); |
| 2753 | - | |
| 4394 | + | |
| 2754 | 4395 | $total_amount = $pricing['final_total']; |
| 2755 | 4396 | $amount_due = $pricing['amount_due']; |
| 2756 | 4397 | $tax_calculation = $pricing['tax_calculation']; |
| 2757 | 4398 | $total_tax_amount = $pricing['tax_calculation']['total_tax_amount']; |
| @@ -2756,8 +4397,49 @@ | ||
| 2756 | 4397 | $tax_calculation = $pricing['tax_calculation']; |
| 2757 | 4398 | $total_tax_amount = $pricing['tax_calculation']['total_tax_amount']; |
| 2758 | 4399 | $tax_inclusive = $pricing['tax_calculation']['tax_inclusive']; |
| 2759 | 4400 | $tax_breakdown = $pricing['tax_calculation']['tax_breakdown']; |
| 4401 | + | |
| 4402 | + // ── Reconcile per-category display prices with CalculationService ─ | |
| 4403 | + // | |
| 4404 | + // The $category_breakdown computed above (~line 3358) used a SEPARATE | |
| 4405 | + // DP filter pass (~line 3290) that's gated on yatra_dynamic_pricing_enabled. | |
| 4406 | + // In certain AJAX-recompute contexts (e.g. switching payment_method) | |
| 4407 | + // that loop could miss DP — for example when a stored availability | |
| 4408 | + // row's price_types already had a pre-DP effective_price baked in, | |
| 4409 | + // or when a date-sensitive DP rule didn't fire because the request | |
| 4410 | + // didn't carry the same departure_date context. | |
| 4411 | + // | |
| 4412 | + // CalculationService is the single source of truth for booking math; | |
| 4413 | + // it already computed the correct post-DP per-category prices and | |
| 4414 | + // returned them as `category_prices_post_dp` (keyed by string | |
| 4415 | + // category_id). Reconcile the display breakdown against that map so | |
| 4416 | + // "Adult x 8 ($131.12 x 8)" can never disagree with the Trip | |
| 4417 | + // Subtotal the rest of the page is built from. | |
| 4418 | + if (!empty($category_breakdown) && !empty($pricing['category_prices_post_dp']) && is_array($pricing['category_prices_post_dp'])) { | |
| 4419 | + $catPricesPostDp = $pricing['category_prices_post_dp']; | |
| 4420 | + $reconciledSubtotal = 0.0; | |
| 4421 | + foreach ($category_breakdown as &$cat) { | |
| 4422 | + $cid = isset($cat['category_id']) ? (string) $cat['category_id'] : ''; | |
| 4423 | + if ($cid !== '' && array_key_exists($cid, $catPricesPostDp)) { | |
| 4424 | + $authoritativePrice = (float) $catPricesPostDp[$cid]; | |
| 4425 | + $count = (int) ($cat['count'] ?? 0); | |
| 4426 | + $cat['price'] = $authoritativePrice; | |
| 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); | |
| 4431 | + } | |
| 4432 | + $reconciledSubtotal += (float) ($cat['subtotal'] ?? 0); | |
| 4433 | + } | |
| 4434 | + unset($cat); | |
| 4435 | + // Keep the top-level $subtotal in sync with the reconciled | |
| 4436 | + // breakdown so any downstream renderers that read it (instead | |
| 4437 | + // of $pricing['base_amount']) still see consistent numbers. | |
| 4438 | + if ($reconciledSubtotal > 0) { | |
| 4439 | + $subtotal = $reconciledSubtotal; | |
| 4440 | + } | |
| 4441 | + } | |
| 2760 | 4442 | |
| 2761 | 4443 | /** |
| 2762 | 4444 | * Filter: Get additional services for this trip |
| 2763 | 4445 | * Allows premium modules to add extra services to the booking summary |
| @@ -2853,14 +4535,22 @@ | ||
| 2853 | 4535 | |
| 2854 | 4536 | // Note: CalculationService already includes itinerary costs in final_total |
| 2855 | 4537 | // No need to add itinerary_costs_total again - it's already included in $total_amount |
| 2856 | 4538 | |
| 2857 | - // Calculate due amount based on payment method | |
| 2858 | - // Use filters for flexible payment settings (Pro feature) | |
| 4539 | + // Calculate due amount based on payment method. | |
| 4540 | + // | |
| 4541 | + // Flow: compute a sensible default using the *percentage* filters (which | |
| 4542 | + // Pro can already override per-trip via trip.deposit_percentage), then | |
| 4543 | + // hand off to `yatra_calculate_amount_due` so Pro can apply absolute | |
| 4544 | + // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both | |
| 4545 | + // keeps the math consistent with CalculationService::calculatePaymentAmounts(). | |
| 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 ?? '')]; | |
| 2859 | 4549 | $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false); |
| 2860 | - $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20); | |
| 2861 | - $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30); | |
| 2862 | - | |
| 4550 | + $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context); | |
| 4551 | + $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context); | |
| 4552 | + | |
| 2863 | 4553 | $amount_due = $total_amount; |
| 2864 | 4554 | if ($payment_method === 'deposit') { |
| 2865 | 4555 | $amount_due = $total_amount * ($deposit_percentage / 100); |
| 2866 | 4556 | } elseif ($payment_method === 'partial') { |
| @@ -2866,8 +4556,16 @@ | ||
| 2866 | 4556 | } elseif ($payment_method === 'partial') { |
| 2867 | 4557 | $amount_due = $total_amount * ($partial_percentage / 100); |
| 2868 | 4558 | } |
| 2869 | 4559 | |
| 4560 | + $amount_due = (float) apply_filters( | |
| 4561 | + 'yatra_calculate_amount_due', | |
| 4562 | + $amount_due, | |
| 4563 | + $total_amount, | |
| 4564 | + $payment_method, | |
| 4565 | + $context | |
| 4566 | + ); | |
| 4567 | + | |
| 2870 | 4568 | Logger::debug('Yatra booking summary: payment method and amount due', [ |
| 2871 | 4569 | 'context' => 'booking_summary_rest', |
| 2872 | 4570 | 'trip_id' => $trip_id, |
| 2873 | 4571 | 'flexible_payments_enabled' => $flexible_payments_enabled, |
| @@ -2905,8 +4603,20 @@ | ||
| 2905 | 4603 | 'enable_tax' => $pricing['tax_calculation']['enable_tax'], |
| 2906 | 4604 | 'tax_breakdown' => $pricing['tax_calculation']['tax_breakdown'], |
| 2907 | 4605 | 'total_tax_amount' => $pricing['tax_calculation']['total_tax_amount'], |
| 2908 | 4606 | 'tax_inclusive' => $pricing['tax_calculation']['tax_inclusive'], |
| 4607 | + // Dynamic-pricing data — without these, the AJAX-refreshed summary | |
| 4608 | + // would never carry the DP line items even though CalculationService | |
| 4609 | + // produces them on every recalculation. | |
| 4610 | + 'dynamic_pricing' => $pricing['dynamic_pricing'] ?? null, | |
| 4611 | + 'unit_price_before_dp' => $pricing['unit_price_before_dp'] ?? null, | |
| 4612 | + 'dp_total_adjustment' => $pricing['dp_total_adjustment'] ?? 0, | |
| 4613 | + // Authoritative post-DP per-category map. Checkout::getCategoryBreakdown | |
| 4614 | + // prefers this over the session's $pt->effective_price (which can be | |
| 4615 | + // pre-DP after a stored availability row's price_types come in | |
| 4616 | + // pre-baked), so forwarding it here is what keeps the AJAX-rendered | |
| 4617 | + // "Adult x N ($X x N)" row in sync with the actual Trip Subtotal. | |
| 4618 | + 'category_prices_post_dp' => $pricing['category_prices_post_dp'] ?? [], | |
| 2909 | 4619 | // Currency for consistent formatting |
| 2910 | 4620 | 'currency' => $pricing['currency'] ?? \Yatra\Services\SettingsService::getCurrency(), |
| 2911 | 4621 | ]); |
| 2912 | 4622 | |
| @@ -3013,8 +4723,22 @@ | ||
| 3013 | 4723 | 'tax_breakdown' => $data['tax_breakdown'] ?? [], |
| 3014 | 4724 | 'total_tax_amount' => $data['total_tax_amount'] ?? 0, |
| 3015 | 4725 | 'tax_inclusive' => $data['tax_inclusive'] ?? false, |
| 3016 | 4726 | ], |
| 4727 | + // Dynamic-pricing breakdown — without this, the AJAX-refreshed | |
| 4728 | + // sidebar would never show DP line items even though the page-load | |
| 4729 | + // path does. Keys match CalculationService's pricing_data shape so | |
| 4730 | + // Checkout::getDynamicPricing() returns identical results in both | |
| 4731 | + // render contexts. | |
| 4732 | + 'dynamic_pricing' => $data['dynamic_pricing'] ?? null, | |
| 4733 | + 'unit_price_before_dp' => $data['unit_price_before_dp'] ?? null, | |
| 4734 | + 'dp_total_adjustment' => $data['dp_total_adjustment'] ?? 0, | |
| 4735 | + // Authoritative post-DP per-category prices. Checkout::getCategoryBreakdown | |
| 4736 | + // keys off this to override the (potentially stale / pre-DP) | |
| 4737 | + // $pt->effective_price coming from session price_types — without | |
| 4738 | + // it, the AJAX recompute renders pre-DP rows while the rest of | |
| 4739 | + // the summary uses the post-DP base amount. | |
| 4740 | + 'category_prices_post_dp' => $data['category_prices_post_dp'] ?? [], | |
| 3017 | 4741 | 'currency' => $data['currency'] ?? null, |
| 3018 | 4742 | ]; |
| 3019 | 4743 | |
| 3020 | 4744 | // Update session with payment method if provided |
| @@ -3036,9 +4760,15 @@ | ||
| 3036 | 4760 | } |
| 3037 | 4761 | |
| 3038 | 4762 | // Create Checkout model instance |
| 3039 | 4763 | $checkout = new \Yatra\Models\Checkout($trip, $session, $pricingCalculation); |
| 3040 | - | |
| 4764 | + | |
| 4765 | + // Surface dynamic-pricing breakdown to the template scope. The partial | |
| 4766 | + // reads `$dynamic_pricing` for the DP block; the page-load path | |
| 4767 | + // already sets this in booking-content.php. | |
| 4768 | + $dynamic_pricing = $pricingCalculation['dynamic_pricing'] ?? null; | |
| 4769 | + $currency = $pricingCalculation['currency'] ?? null; | |
| 4770 | + | |
| 3041 | 4771 | // Load the template (uses $checkout model) |
| 3042 | 4772 | $template_path = YATRA_PLUGIN_PATH . 'templates/partials/pricing-summary.php'; |
| 3043 | 4773 | |
| 3044 | 4774 | if (!file_exists($template_path)) { |
| @@ -3056,10 +4786,37 @@ | ||
| 3056 | 4786 | */ |
| 3057 | 4787 | public function remove_coupon(WP_REST_Request $request): WP_REST_Response |
| 3058 | 4788 | { |
| 3059 | 4789 | yatra_start_session(); |
| 3060 | - | |
| 4790 | + | |
| 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 | + | |
| 3061 | 4798 | $session = yatra_get_booking_session(); |
| 4799 | + | |
| 4800 | + // Same booking_token rehydration as apply_coupon — handle REST | |
| 4801 | + // requests that arrive without a propagated PHPSESSID. | |
| 4802 | + if (empty($session) || empty($session['trip_id'])) { | |
| 4803 | + $token = null; | |
| 4804 | + if (!empty($data['booking_token']) && is_string($data['booking_token'])) { | |
| 4805 | + $token = sanitize_text_field((string) $data['booking_token']); | |
| 4806 | + } elseif (isset($_GET['booking_token']) && is_string($_GET['booking_token'])) { | |
| 4807 | + $token = sanitize_text_field((string) wp_unslash($_GET['booking_token'])); | |
| 4808 | + } | |
| 4809 | + if ($token) { | |
| 4810 | + $transient_data = get_transient($token); | |
| 4811 | + if (is_array($transient_data) && !empty($transient_data['trip_id'])) { | |
| 4812 | + $session = $transient_data; | |
| 4813 | + $_SESSION['yatra_booking'] = $session; | |
| 4814 | + $_SESSION['yatra_booking_token'] = $token; | |
| 4815 | + } | |
| 4816 | + } | |
| 4817 | + } | |
| 4818 | + | |
| 3062 | 4819 | if (empty($session)) { |
| 3063 | 4820 | return new WP_REST_Response([ |
| 3064 | 4821 | 'success' => false, |
| 3065 | 4822 | 'message' => __('No active booking session found.', 'yatra'), |
| @@ -3070,18 +4827,27 @@ | ||
| 3070 | 4827 | if (isset($_SESSION['yatra_booking']['coupon'])) { |
| 3071 | 4828 | unset($_SESSION['yatra_booking']['coupon']); |
| 3072 | 4829 | } |
| 3073 | 4830 | $_SESSION['yatra_booking']['timestamp'] = time(); |
| 3074 | - | |
| 4831 | + | |
| 3075 | 4832 | // Also update local session array for calculation |
| 3076 | 4833 | unset($session['coupon']); |
| 3077 | 4834 | $session['timestamp'] = time(); |
| 3078 | - | |
| 4835 | + | |
| 4836 | + // Persist the coupon removal to the transient too — without this, | |
| 4837 | + // the next /booking/summary AJAX (which the JS calls right after | |
| 4838 | + // this endpoint) re-reads the still-couponed transient and the | |
| 4839 | + // sidebar shows the coupon discount as if it never went away. | |
| 4840 | + // `yatra_set_booking_session()` array_merges `$_SESSION['yatra_booking']` | |
| 4841 | + // (already coupon-less above) with the passed data and writes the | |
| 4842 | + // result into the transient keyed by the existing booking token. | |
| 4843 | + yatra_set_booking_session($session); | |
| 4844 | + | |
| 3079 | 4845 | // Ensure session data is written immediately |
| 3080 | 4846 | if (session_status() === PHP_SESSION_ACTIVE) { |
| 3081 | 4847 | session_write_close(); |
| 3082 | 4848 | } |
| 3083 | - | |
| 4849 | + | |
| 3084 | 4850 | $total_amount = $this->calculateSessionTotal($session); |
| 3085 | 4851 | |
| 3086 | 4852 | return new WP_REST_Response([ |
| 3087 | 4853 | 'success' => true, |