PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 2.0.10 All 83 releases
← All changes | app/Controllers/BookingSessionController.php +299 -37 3.0.8trunk View file →
@@ -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
@@ -1356,8 +1386,17 @@
1356 1386
1357 1387 // Get contact email - handle both flat and nested formats
1358 1388 $contact_email = trim((string) ($data['contact_email'] ?? ''));
1359 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 + );
1360 1399 $contact_first_name = $data['contact_first_name'] ?? '';
1361 1400 $contact_last_name = $data['contact_last_name'] ?? '';
1362 1401 $contact_country = $data['contact_country'] ?? '';
1363 1402
@@ -1365,9 +1404,12 @@
1365 1404 $contact_address = $data['contact_address'] ?? '';
1366 1405
1367 1406 // Emergency contact
1368 1407 $emergency_name = $data['emergency_name'] ?? '';
1369 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
1370 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
1371 1413
1372 1414 // Travel details
1373 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
@@ -1700,9 +1742,9 @@
1700 1742 $isWaitlistCheckout = false;
1701 1743
1702 1744 if ($resolvedAvailabilityForWaitlist !== null) {
1703 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1704 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1705 1747 return new WP_REST_Response([
1706 1748 'success' => false,
1707 1749 'message' => __('This departure is not open for booking.', 'yatra'),
1708 1750 'code' => 'date_blocked',
@@ -1769,11 +1811,28 @@
1769 1811 // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1770 1812 foreach ($data as $field_key => $field_value) {
1771 1813 if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1772 1814 $field_id = substr($field_key, strlen('contact_'));
1773 - if ($field_id !== '' && $field_id !== 'data' && !isset($contact_data[$field_id])) {
1774 - $contact_data[$field_id] = sanitize_text_field((string) $field_value);
1815 + if ($field_id === '' || $field_id === 'data') {
1816 + continue;
1775 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);
1776 1835 }
1777 1836 }
1778 1837
1779 1838 // Prepare emergency contact data
@@ -1785,11 +1844,25 @@
1785 1844 // Same dynamic capture for emergency_* custom fields.
1786 1845 foreach ($data as $field_key => $field_value) {
1787 1846 if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1788 1847 $field_id = substr($field_key, strlen('emergency_'));
1789 - if ($field_id !== '' && $field_id !== 'contact' && !isset($emergency_data[$field_id])) {
1790 - $emergency_data[$field_id] = sanitize_text_field((string) $field_value);
1848 + if ($field_id === '' || $field_id === 'contact') {
1849 + continue;
1791 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);
1792 1865 }
1793 1866 }
1794 1867
1795 1868 // Sanitize travelers data
@@ -1798,8 +1871,13 @@
1798 1871 if (is_array($traveler)) {
1799 1872 $sanitized_traveler = [];
1800 1873 foreach ($traveler as $key => $value) {
1801 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 + }
1802 1880 if (is_array($value)) {
1803 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1804 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1805 1883 }, $value);
@@ -1806,8 +1884,19 @@
1806 1884 } else {
1807 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1808 1886 }
1809 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 + }
1810 1899 $sanitized_travelers[] = $sanitized_traveler;
1811 1900 }
1812 1901 }
1813 1902
@@ -2222,13 +2311,54 @@
2222 2311 $email_vars['expiry_notice_html'] = '<strong>'
2223 2312 . esc_html__('This link expires in 48 hours.', 'yatra')
2224 2313 . '</strong>';
2225 2314
2226 - \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2315 + // Guest-checkout verification prefers the operator's CONFIGURED
2316 + // customer verification template so their customisation is honoured
2317 + // (the guest system template was consolidated away — using the guest
2318 + // type always fell back to the built-in default and ignored the
2319 + // configured one). This email MUST still carry the verification link
2320 + // — a guest can't complete the booking without it — so we only fall
2321 + // back to the built-in GUEST default when the effective customer
2322 + // template would omit {{verification_link}} (an operator can, and on
2323 + // real sites does, customise that template and drop the tag). The
2324 + // check respects Pro-owned DB templates too. Booking copy is injected
2325 + // above via intro_paragraph / footer_note / expiry merge vars.
2326 + //
2327 + // Keep the booking-specific SUBJECT line ("Verify your email to
2328 + // complete your booking") that guests saw before the guest template
2329 + // was consolidated away — reusing the customer template body must not
2330 + // drag along the account-oriented "Verify your email address"
2331 + // subject. This is honoured additively by the renderer / Pro sender
2332 + // via the reserved `_subject_override` var, so only this guest send
2333 + // is affected. Computed before it is stored, so the render below
2334 + // resolves the clean guest subject (no self-reference).
2335 + $email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render(
2227 2336 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2228 - (string) $contact_data['email'],
2229 2337 $email_vars
2230 - );
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 + }
2231 2361
2232 2362 return new WP_REST_Response([
2233 2363 'success' => true,
2234 2364 'code' => 'email_verification_required',
@@ -2314,21 +2444,29 @@
2314 2444
2315 2445 // ========================================
2316 2446 // DETERMINE BOOKING STATUS
2317 2447 // ========================================
2318 - // Priority:
2319 - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
2320 - // 2. For pay_later: auto_confirm_pay_later setting
2321 - // 3. For bank_transfer: always pending until verified
2322 -
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 +
2323 2455 $booking_status = 'pending';
2324 2456 $status_message = __('Booking received!', 'yatra');
2325 -
2326 - // Check if auto-confirm all bookings is enabled
2327 - if ($settings['auto_confirm_bookings']) {
2328 - // 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.
2329 2461 $booking_status = 'confirmed';
2330 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');
2331 2469 } elseif ($payment_gateway === 'pay_later') {
2332 2470 // Pay Later: Check the specific pay_later auto-confirm setting
2333 2471 if ($settings['auto_confirm_pay_later']) {
2334 2472 $booking_status = 'confirmed';
@@ -2748,11 +2886,15 @@
2748 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
2749 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2750 2888 // may still append their own query args on top of this URL.
2751 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'] ?? '');
2752 2894 $paymentData = array_merge($params, [
2753 2895 'description' => $params['trip_title'] ?? '',
2754 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2755 2897 'metadata' => [
2756 2898 'booking_id' => $params['booking_id'],
2757 2899 'reference' => $params['reference'] ?? ''
2758 2900 ]
@@ -2801,10 +2943,12 @@
2801 2943 ];
2802 2944 }
2803 2945
2804 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
2805 2949 return [
2806 - 'success' => true,
2950 + 'success' => true,
2807 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2808 2952 ];
2809 2953 }
2810 2954
@@ -2829,8 +2973,75 @@
2829 2973 /**
2830 2974 * Record payment from gateway result
2831 2975 * Matches Stripe's completePayment behavior
2832 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 +
2833 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2834 3045 {
2835 3046 global $wpdb;
2836 3047
@@ -2884,23 +3095,32 @@
2884 3095 // (Square, Authorize.Net) reach this generic path but previously left
2885 3096 // the booking at pending/pending — only the payment row was written.
2886 3097 // This now matches handle_successful_payment(): accumulate amount_paid,
2887 3098 // recompute amount_due, set payment_status (paid vs partial), and
2888 - // 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).
2889 3101 $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
2890 3102 $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
2891 3103 $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
2892 3104 $previousStatus = (string) ($booking->status ?? 'pending');
2893 3105
2894 - $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 = [
2895 3112 'amount_paid' => $newAmountPaid,
2896 3113 'amount_due' => $newAmountDue,
2897 3114 'payment_status' => $paymentStatus,
2898 - 'status' => 'confirmed',
2899 - ]);
3115 + ];
3116 + if ($shouldConfirm) {
3117 + $bookingUpdate['status'] = 'confirmed';
3118 + }
3119 + $this->bookingRepository->update($bookingId, $bookingUpdate);
2900 3120
2901 - if (function_exists('yatra_trigger_booking_confirmed')) {
2902 - \yatra_trigger_booking_confirmed($bookingId, $previousStatus);
3121 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
3122 + \yatra_trigger_booking_confirmed($bookingId, $previousStatus, true);
2903 3123 }
2904 3124
2905 3125 // Fire payment completed action
2906 3126 do_action('yatra_payment_completed', [
@@ -2968,9 +3188,9 @@
2968 3188 'description' => $params['trip_title'],
2969 3189 ]],
2970 3190 'application_context' => [
2971 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2972 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2973 3193 ],
2974 3194 ]),
2975 3195 ]);
2976 3196
@@ -3089,9 +3309,12 @@
3089 3309 'su' => add_query_arg(
3090 3310 ['payment' => 'success', 'gateway' => 'esewa'],
3091 3311 $this->getConfirmationUrl($params['reference'])
3092 3312 ),
3093 - '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 + ),
3094 3317 ], $base_url);
3095 3318
3096 3319 return ['success' => true, 'payment_url' => $payment_url];
3097 3320 }
@@ -3243,9 +3466,9 @@
3243 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
3244 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
3245 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>
3246 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. */
3247 -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>
3248 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
3249 3472 </div>
3250 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
3251 3474 <p><?php /* translators: %s: total amount (formatted). */
@@ -3526,8 +3749,28 @@
3526 3749 (int) $verifiedBooking->id,
3527 3750 $verifiedBooking
3528 3751 );
3529 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 + }
3530 3773 }
3531 3774
3532 3775 $this->renderVerifyEmailSuccessPage(
3533 3776 (int) $booking->id,
@@ -3656,8 +3899,22 @@
3656 3899 $secondaryLabel = $isLoggedIn
3657 3900 ? __('Go to My Account', 'yatra')
3658 3901 : __('Sign in', 'yatra');
3659 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 +
3660 3917 $heading = $alreadyVerified
3661 3918 ? __('Email Already Verified', 'yatra')
3662 3919 : __('Email Verified', 'yatra');
3663 3920 $message = $alreadyVerified
@@ -3709,9 +3966,9 @@
3709 3966 . '<p>%4$s</p>'
3710 3967 . '%5$s'
3711 3968 . '<div class="actions">'
3712 3969 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3713 - . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3970 + . '%8$s'
3714 3971 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3715 3972 . '</div>'
3716 3973 . '</div></body></html>',
3717 3974 esc_attr(get_locale()),
@@ -3720,10 +3977,13 @@
3720 3977 esc_html($message),
3721 3978 $referenceLine,
3722 3979 esc_url($confirmationUrl),
3723 3980 esc_html($primaryLabel),
3724 - esc_url($secondaryUrl),
3725 - esc_html($secondaryLabel),
3981 + // %8 is the fully-built secondary CTA (or '' when hidden — see
3982 + // $showSecondaryCta above). %9 is intentionally empty to keep the
3983 + // positional args aligned with %10/%11.
3984 + $secondaryCta,
3985 + '',
3726 3986 esc_url(home_url('/')),
3727 3987 esc_html($homeLabel)
3728 3988 );
3729 3989
@@ -4282,9 +4542,11 @@
4282 4542 // Pro can already override per-trip via trip.deposit_percentage), then
4283 4543 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
4284 4544 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
4285 4545 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
4286 - $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 ?? '')];
4287 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
4288 4550 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4289 4551 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4290 4552