| @@ -351,11 +351,19 @@ | ||
| 351 | 351 | $total_paid = $paymentRepository->getTotalPaidForBooking($booking_id); |
| 352 | 352 | $total_amount = (float) $booking->total_amount; |
| 353 | 353 | |
| 354 | 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. | |
| 355 | 359 | $prevStatus = (string) ($booking->status ?? 'pending'); |
| 356 | - $bookingRepository->update($booking_id, ['status' => 'confirmed', 'payment_status' => 'paid']); | |
| 357 | - \yatra_trigger_booking_confirmed((int) $booking_id, $prevStatus); | |
| 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 | + } | |
| 358 | 366 | } else { |
| 359 | 367 | $bookingRepository->update($booking_id, ['payment_status' => 'partial']); |
| 360 | 368 | } |
| 361 | 369 | } |
| @@ -888,8 +896,11 @@ | ||
| 888 | 896 | 'slug' => $trip->slug, |
| 889 | 897 | 'featured_image' => $trip->featured_image, |
| 890 | 898 | 'duration_days' => (int) $trip->duration_days, |
| 891 | 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), | |
| 892 | 903 | 'difficulty_level' => $trip->difficulty_level, |
| 893 | 904 | 'min_travelers' => (int) ($trip->min_travelers ?: 1), |
| 894 | 905 | 'max_travelers' => (int) ($trip->max_travelers ?: 20), |
| 895 | 906 | 'original_price' => (float) $trip->original_price, |
| @@ -1205,8 +1216,23 @@ | ||
| 1205 | 1216 | global $wpdb; |
| 1206 | 1217 | |
| 1207 | 1218 | $data = $request->get_json_params(); |
| 1208 | 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 | + | |
| 1209 | 1235 | // ======================================== |
| 1210 | 1236 | // CSRF — booking-scoped action nonce |
| 1211 | 1237 | // ======================================== |
| 1212 | 1238 | // The public_permission_callback on this route intentionally |
| @@ -1264,9 +1290,9 @@ | ||
| 1264 | 1290 | // GET BOOKING SETTINGS |
| 1265 | 1291 | // ======================================== |
| 1266 | 1292 | $settings = [ |
| 1267 | 1293 | 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true), |
| 1268 | - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false), | |
| 1294 | + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(), | |
| 1269 | 1295 | 'require_login' => \Yatra\Services\SettingsService::get('require_login', false), |
| 1270 | 1296 | 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true), |
| 1271 | 1297 | 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24), |
| 1272 | 1298 | 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true), |
| @@ -1349,9 +1375,13 @@ | ||
| 1349 | 1375 | // Which booking-form sections are enabled (Pro Dynamic Form module). |
| 1350 | 1376 | // The default config has every section enabled, so on existing/un-customised |
| 1351 | 1377 | // sites $contact_enabled and $traveler_enabled are both true and the logic |
| 1352 | 1378 | // below behaves exactly as before — only disabled sections change anything. |
| 1353 | - $form_config = function_exists('yatra_get_booking_form_config') ? yatra_get_booking_form_config() : []; | |
| 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 | + : []; | |
| 1354 | 1384 | $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled']; |
| 1355 | 1385 | $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled']; |
| 1356 | 1386 | |
| 1357 | 1387 | // Get contact email - handle both flat and nested formats |
| @@ -1712,9 +1742,9 @@ | ||
| 1712 | 1742 | $isWaitlistCheckout = false; |
| 1713 | 1743 | |
| 1714 | 1744 | if ($resolvedAvailabilityForWaitlist !== null) { |
| 1715 | 1745 | $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available'); |
| 1716 | - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) { | |
| 1746 | + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) { | |
| 1717 | 1747 | return new WP_REST_Response([ |
| 1718 | 1748 | 'success' => false, |
| 1719 | 1749 | 'message' => __('This departure is not open for booking.', 'yatra'), |
| 1720 | 1750 | 'code' => 'date_blocked', |
| @@ -2414,21 +2444,29 @@ | ||
| 2414 | 2444 | |
| 2415 | 2445 | // ======================================== |
| 2416 | 2446 | // DETERMINE BOOKING STATUS |
| 2417 | 2447 | // ======================================== |
| 2418 | - // Priority: | |
| 2419 | - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically) | |
| 2420 | - // 2. For pay_later: auto_confirm_pay_later setting | |
| 2421 | - // 3. For bank_transfer: always pending until verified | |
| 2422 | - | |
| 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 | + | |
| 2423 | 2455 | $booking_status = 'pending'; |
| 2424 | 2456 | $status_message = __('Booking received!', 'yatra'); |
| 2425 | - | |
| 2426 | - // Check if auto-confirm all bookings is enabled | |
| 2427 | - if ($settings['auto_confirm_bookings']) { | |
| 2428 | - // 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. | |
| 2429 | 2461 | $booking_status = 'confirmed'; |
| 2430 | 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'); | |
| 2431 | 2469 | } elseif ($payment_gateway === 'pay_later') { |
| 2432 | 2470 | // Pay Later: Check the specific pay_later auto-confirm setting |
| 2433 | 2471 | if ($settings['auto_confirm_pay_later']) { |
| 2434 | 2472 | $booking_status = 'confirmed'; |
| @@ -2848,11 +2886,15 @@ | ||
| 2848 | 2886 | // Default return_url to the configured booking confirmation URL so redirect gateways |
| 2849 | 2887 | // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways |
| 2850 | 2888 | // may still append their own query args on top of this URL. |
| 2851 | 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'] ?? ''); | |
| 2852 | 2894 | $paymentData = array_merge($params, [ |
| 2853 | 2895 | 'description' => $params['trip_title'] ?? '', |
| 2854 | - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')), | |
| 2896 | + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)), | |
| 2855 | 2897 | 'metadata' => [ |
| 2856 | 2898 | 'booking_id' => $params['booking_id'], |
| 2857 | 2899 | 'reference' => $params['reference'] ?? '' |
| 2858 | 2900 | ] |
| @@ -2901,10 +2943,12 @@ | ||
| 2901 | 2943 | ]; |
| 2902 | 2944 | } |
| 2903 | 2945 | |
| 2904 | 2946 | // For offline gateways or successful direct payments without redirect |
| 2947 | + $this->recordOfflinePendingPayment($params, $result, $gatewayId); | |
| 2948 | + | |
| 2905 | 2949 | return [ |
| 2906 | - 'success' => true, | |
| 2950 | + 'success' => true, | |
| 2907 | 2951 | 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '') |
| 2908 | 2952 | ]; |
| 2909 | 2953 | } |
| 2910 | 2954 | |
| @@ -2929,8 +2973,75 @@ | ||
| 2929 | 2973 | /** |
| 2930 | 2974 | * Record payment from gateway result |
| 2931 | 2975 | * Matches Stripe's completePayment behavior |
| 2932 | 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 | + | |
| 2933 | 3044 | private function recordGatewayPayment(array $params, array $result, string $gatewayId): void |
| 2934 | 3045 | { |
| 2935 | 3046 | global $wpdb; |
| 2936 | 3047 | |
| @@ -2984,23 +3095,32 @@ | ||
| 2984 | 3095 | // (Square, Authorize.Net) reach this generic path but previously left |
| 2985 | 3096 | // the booking at pending/pending — only the payment row was written. |
| 2986 | 3097 | // This now matches handle_successful_payment(): accumulate amount_paid, |
| 2987 | 3098 | // recompute amount_due, set payment_status (paid vs partial), and |
| 2988 | - // confirm the booking (a deposit confirms too, consistent with Stripe). | |
| 3099 | + // confirm the booking only when "Auto-Confirm Bookings" is on | |
| 3100 | + // (consistent with every gateway). | |
| 2989 | 3101 | $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount; |
| 2990 | 3102 | $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid); |
| 2991 | 3103 | $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid'; |
| 2992 | 3104 | $previousStatus = (string) ($booking->status ?? 'pending'); |
| 2993 | 3105 | |
| 2994 | - $this->bookingRepository->update($bookingId, [ | |
| 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 = [ | |
| 2995 | 3112 | 'amount_paid' => $newAmountPaid, |
| 2996 | 3113 | 'amount_due' => $newAmountDue, |
| 2997 | 3114 | 'payment_status' => $paymentStatus, |
| 2998 | - 'status' => 'confirmed', | |
| 2999 | - ]); | |
| 3115 | + ]; | |
| 3116 | + if ($shouldConfirm) { | |
| 3117 | + $bookingUpdate['status'] = 'confirmed'; | |
| 3118 | + } | |
| 3119 | + $this->bookingRepository->update($bookingId, $bookingUpdate); | |
| 3000 | 3120 | |
| 3001 | - if (function_exists('yatra_trigger_booking_confirmed')) { | |
| 3002 | - \yatra_trigger_booking_confirmed($bookingId, $previousStatus); | |
| 3121 | + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) { | |
| 3122 | + \yatra_trigger_booking_confirmed($bookingId, $previousStatus, true); | |
| 3003 | 3123 | } |
| 3004 | 3124 | |
| 3005 | 3125 | // Fire payment completed action |
| 3006 | 3126 | do_action('yatra_payment_completed', [ |
| @@ -3068,9 +3188,9 @@ | ||
| 3068 | 3188 | 'description' => $params['trip_title'], |
| 3069 | 3189 | ]], |
| 3070 | 3190 | 'application_context' => [ |
| 3071 | 3191 | 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])), |
| 3072 | - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']), | |
| 3192 | + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])), | |
| 3073 | 3193 | ], |
| 3074 | 3194 | ]), |
| 3075 | 3195 | ]); |
| 3076 | 3196 | |
| @@ -3189,9 +3309,12 @@ | ||
| 3189 | 3309 | 'su' => add_query_arg( |
| 3190 | 3310 | ['payment' => 'success', 'gateway' => 'esewa'], |
| 3191 | 3311 | $this->getConfirmationUrl($params['reference']) |
| 3192 | 3312 | ), |
| 3193 | - '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 | + ), | |
| 3194 | 3317 | ], $base_url); |
| 3195 | 3318 | |
| 3196 | 3319 | return ['success' => true, 'payment_url' => $payment_url]; |
| 3197 | 3320 | } |
| @@ -3343,9 +3466,9 @@ | ||
| 3343 | 3466 | <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p> |
| 3344 | 3467 | <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p> |
| 3345 | 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> |
| 3346 | 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. */ |
| 3347 | -echo esc_html(sprintf(__('%1$d days / %2$d nights', 'yatra'), (int) $trip->duration_days, (int) $trip->duration_nights)); ?></p> | |
| 3470 | +echo esc_html(yatra_format_duration((int) $trip->duration_days, (int) $trip->duration_nights, (int) ($trip->duration_hours ?? 0))); ?></p> | |
| 3348 | 3471 | <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p> |
| 3349 | 3472 | </div> |
| 3350 | 3473 | <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3> |
| 3351 | 3474 | <p><?php /* translators: %s: total amount (formatted). */ |
| @@ -4419,9 +4542,11 @@ | ||
| 4419 | 4542 | // Pro can already override per-trip via trip.deposit_percentage), then |
| 4420 | 4543 | // hand off to `yatra_calculate_amount_due` so Pro can apply absolute |
| 4421 | 4544 | // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both |
| 4422 | 4545 | // keeps the math consistent with CalculationService::calculatePaymentAmounts(). |
| 4423 | - $context = ['trip_id' => $trip_id]; | |
| 4546 | + // Tour start → Pro can force full payment when the tour is within the | |
| 4547 | + // balance-due window (tour-anchored scheduled payments). | |
| 4548 | + $context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')]; | |
| 4424 | 4549 | $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false); |
| 4425 | 4550 | $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context); |
| 4426 | 4551 | $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context); |
| 4427 | 4552 | |