PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 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 All 84 releases
← All changes | app/Controllers/BookingSessionController.php +343 -45 3.0.7 → 3.0.16 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,
@@ -1105,9 +1116,14 @@
1105 1116 if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) {
1106 1117 $required_traveler_fields = [];
1107 1118 foreach ($form_config['traveler_form']['fields'] as $field) {
1108 1119 if (is_array($field) && !empty($field['enabled']) && !empty($field['required']) && !empty($field['id']) && ($field['type'] ?? '') !== 'text_block') {
1109 - $required_traveler_fields[(string) $field['id']] = (string) ($field['label'] ?? $field['id']);
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 + ];
1110 1126 }
1111 1127 }
1112 1128 if (!empty($required_traveler_fields)) {
1113 1129 $traveler_index = 0;
@@ -1119,12 +1135,16 @@
1119 1135 if (isset($traveler['type']) && $traveler['type'] !== 'traveler') {
1120 1136 continue;
1121 1137 }
1122 1138 $traveler_index++;
1123 - foreach ($required_traveler_fields as $fid => $flabel) {
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 + }
1124 1144 if ($is_missing($traveler[$fid] ?? null)) {
1125 1145 /* translators: 1: traveler number, 2: field label. */
1126 - return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $flabel);
1146 + return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']);
1127 1147 }
1128 1148 }
1129 1149 }
1130 1150 }
@@ -1196,8 +1216,23 @@
1196 1216 global $wpdb;
1197 1217
1198 1218 $data = $request->get_json_params();
1199 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 +
1200 1235 // ========================================
1201 1236 // CSRF — booking-scoped action nonce
1202 1237 // ========================================
1203 1238 // The public_permission_callback on this route intentionally
@@ -1255,9 +1290,9 @@
1255 1290 // GET BOOKING SETTINGS
1256 1291 // ========================================
1257 1292 $settings = [
1258 1293 'booking_confirmation' => \Yatra\Services\SettingsService::get('booking_confirmation', true),
1259 - 'auto_confirm_bookings' => \Yatra\Services\SettingsService::get('auto_confirm_bookings', false),
1294 + 'auto_confirm_mode' => \yatra_get_auto_confirm_mode(),
1260 1295 'require_login' => \Yatra\Services\SettingsService::get('require_login', false),
1261 1296 'allow_guest_checkout' => \Yatra\Services\SettingsService::get('allow_guest_checkout', true),
1262 1297 'booking_expiry_hours' => (int) \Yatra\Services\SettingsService::get('booking_expiry_hours', 24),
1263 1298 'auto_confirm_pay_later' => \Yatra\Services\SettingsService::get('auto_confirm_pay_later', true),
@@ -1340,9 +1375,13 @@
1340 1375 // Which booking-form sections are enabled (Pro Dynamic Form module).
1341 1376 // The default config has every section enabled, so on existing/un-customised
1342 1377 // sites $contact_enabled and $traveler_enabled are both true and the logic
1343 1378 // below behaves exactly as before — only disabled sections change anything.
1344 - $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 + : [];
1345 1384 $contact_enabled = !isset($form_config['contact_form']['enabled']) || (bool) $form_config['contact_form']['enabled'];
1346 1385 $traveler_enabled = !isset($form_config['traveler_form']['enabled']) || (bool) $form_config['traveler_form']['enabled'];
1347 1386
1348 1387 // Get contact email - handle both flat and nested formats
@@ -1347,8 +1386,17 @@
1347 1386
1348 1387 // Get contact email - handle both flat and nested formats
1349 1388 $contact_email = trim((string) ($data['contact_email'] ?? ''));
1350 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 + );
1351 1399 $contact_first_name = $data['contact_first_name'] ?? '';
1352 1400 $contact_last_name = $data['contact_last_name'] ?? '';
1353 1401 $contact_country = $data['contact_country'] ?? '';
1354 1402
@@ -1356,9 +1404,12 @@
1356 1404 $contact_address = $data['contact_address'] ?? '';
1357 1405
1358 1406 // Emergency contact
1359 1407 $emergency_name = $data['emergency_name'] ?? '';
1360 - $emergency_phone = $data['emergency_phone'] ?? '';
1408 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1409 + (string) ($data['emergency_phone'] ?? ''),
1410 + (string) ($data['emergency_phone_country'] ?? '')
1411 + );
1361 1412 $emergency_relationship = $data['emergency_relationship'] ?? '';
1362 1413
1363 1414 // Travel details
1364 1415 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
@@ -1691,9 +1742,9 @@
1691 1742 $isWaitlistCheckout = false;
1692 1743
1693 1744 if ($resolvedAvailabilityForWaitlist !== null) {
1694 1745 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1695 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1746 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1696 1747 return new WP_REST_Response([
1697 1748 'success' => false,
1698 1749 'message' => __('This departure is not open for booking.', 'yatra'),
1699 1750 'code' => 'date_blocked',
@@ -1760,11 +1811,28 @@
1760 1811 // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1761 1812 foreach ($data as $field_key => $field_value) {
1762 1813 if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1763 1814 $field_id = substr($field_key, strlen('contact_'));
1764 - if ($field_id !== '' && $field_id !== 'data' && !isset($contact_data[$field_id])) {
1765 - $contact_data[$field_id] = sanitize_text_field((string) $field_value);
1815 + if ($field_id === '' || $field_id === 'data') {
1816 + continue;
1766 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);
1767 1835 }
1768 1836 }
1769 1837
1770 1838 // Prepare emergency contact data
@@ -1776,11 +1844,25 @@
1776 1844 // Same dynamic capture for emergency_* custom fields.
1777 1845 foreach ($data as $field_key => $field_value) {
1778 1846 if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1779 1847 $field_id = substr($field_key, strlen('emergency_'));
1780 - if ($field_id !== '' && $field_id !== 'contact' && !isset($emergency_data[$field_id])) {
1781 - $emergency_data[$field_id] = sanitize_text_field((string) $field_value);
1848 + if ($field_id === '' || $field_id === 'contact') {
1849 + continue;
1782 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);
1783 1865 }
1784 1866 }
1785 1867
1786 1868 // Sanitize travelers data
@@ -1789,8 +1871,13 @@
1789 1871 if (is_array($traveler)) {
1790 1872 $sanitized_traveler = [];
1791 1873 foreach ($traveler as $key => $value) {
1792 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 + }
1793 1880 if (is_array($value)) {
1794 1881 $sanitized_traveler[$sk] = array_map(static function ($v) {
1795 1882 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1796 1883 }, $value);
@@ -1797,8 +1884,19 @@
1797 1884 } else {
1798 1885 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1799 1886 }
1800 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 + }
1801 1899 $sanitized_travelers[] = $sanitized_traveler;
1802 1900 }
1803 1901 }
1804 1902
@@ -1983,10 +2081,20 @@
1983 2081
1984 2082 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1985 2083 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1986 2084 $booking_data['status'] = 'waitlist';
1987 - $booking_data['payment_gateway'] = 'pay_later';
1988 - $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 + }
1989 2097 }
1990 2098
1991 2099 // Hold the booking in `pending_verification` until the guest
1992 2100 // clicks the magic link. Payment is initiated only after the
@@ -1998,10 +2106,21 @@
1998 2106 // until verification completes and the regular checkout
1999 2107 // resumes.
2000 2108 if ($needs_email_verification && !$isWaitlistCheckout) {
2001 2109 $booking_data['status'] = 'pending_verification';
2002 - $booking_data['payment_gateway'] = 'pay_later';
2003 - $booking_data['payment_method'] = 'full';
2110 + // Defer the gateway choice ONLY for online gateways: a real charge
2111 + // would otherwise lock the customer into a gateway before they have
2112 + // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2113 + // Later) there is no charge to defer, and the verify-email endpoint
2114 + // does not restore the selection afterwards — so pinning to
2115 + // pay_later/full here would permanently drop the customer's chosen
2116 + // gateway AND their deposit/partial amount (BookingService recomputes
2117 + // amount_due from payment_method, so 'full' wipes the deposit).
2118 + // Preserve the real selection for offline gateways.
2119 + if (!$is_offline_gateway) {
2120 + $booking_data['payment_gateway'] = 'pay_later';
2121 + $booking_data['payment_method'] = 'full';
2122 + }
2004 2123 }
2005 2124
2006 2125 try {
2007 2126 $booking = $booking_service->createBooking($booking_data);
@@ -2056,8 +2175,14 @@
2056 2175 * @param int $trip_id The trip ID
2057 2176 * @param array $data The booking request data (contains selected_services)
2058 2177 * @param int $travelers_count Total number of travelers
2059 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.
2060 2185 * @since 3.0.0
2061 2186 */
2062 2187 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
2063 2188 if (!isset($data['selected_services'])) {
@@ -2068,9 +2193,9 @@
2068 2193 if (!is_array($data['selected_services'])) {
2069 2194 $data['selected_services'] = [];
2070 2195 }
2071 2196 $data['selected_services'] = array_map('intval', $data['selected_services']);
2072 - 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));
2073 2198
2074 2199 // ========================================
2075 2200 // SAVE TRAVELLERS TO NORMALIZED TABLES
2076 2201 // ========================================
@@ -2186,13 +2311,54 @@
2186 2311 $email_vars['expiry_notice_html'] = '<strong>'
2187 2312 . esc_html__('This link expires in 48 hours.', 'yatra')
2188 2313 . '</strong>';
2189 2314
2190 - \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(
2191 2336 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2192 - (string) $contact_data['email'],
2193 2337 $email_vars
2194 - );
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 + }
2195 2361
2196 2362 return new WP_REST_Response([
2197 2363 'success' => true,
2198 2364 'code' => 'email_verification_required',
@@ -2278,21 +2444,29 @@
2278 2444
2279 2445 // ========================================
2280 2446 // DETERMINE BOOKING STATUS
2281 2447 // ========================================
2282 - // Priority:
2283 - // 1. auto_confirm_bookings setting (confirms ALL bookings automatically)
2284 - // 2. For pay_later: auto_confirm_pay_later setting
2285 - // 3. For bank_transfer: always pending until verified
2286 -
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 +
2287 2455 $booking_status = 'pending';
2288 2456 $status_message = __('Booking received!', 'yatra');
2289 -
2290 - // Check if auto-confirm all bookings is enabled
2291 - if ($settings['auto_confirm_bookings']) {
2292 - // 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.
2293 2461 $booking_status = 'confirmed';
2294 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');
2295 2469 } elseif ($payment_gateway === 'pay_later') {
2296 2470 // Pay Later: Check the specific pay_later auto-confirm setting
2297 2471 if ($settings['auto_confirm_pay_later']) {
2298 2472 $booking_status = 'confirmed';
@@ -2712,11 +2886,15 @@
2712 2886 // Default return_url to the configured booking confirmation URL so redirect gateways
2713 2887 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2714 2888 // may still append their own query args on top of this URL.
2715 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'] ?? '');
2716 2894 $paymentData = array_merge($params, [
2717 2895 'description' => $params['trip_title'] ?? '',
2718 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2896 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2719 2897 'metadata' => [
2720 2898 'booking_id' => $params['booking_id'],
2721 2899 'reference' => $params['reference'] ?? ''
2722 2900 ]
@@ -2765,10 +2943,12 @@
2765 2943 ];
2766 2944 }
2767 2945
2768 2946 // For offline gateways or successful direct payments without redirect
2947 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2948 +
2769 2949 return [
2770 - 'success' => true,
2950 + 'success' => true,
2771 2951 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2772 2952 ];
2773 2953 }
2774 2954
@@ -2793,8 +2973,75 @@
2793 2973 /**
2794 2974 * Record payment from gateway result
2795 2975 * Matches Stripe's completePayment behavior
2796 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 +
2797 3044 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2798 3045 {
2799 3046 global $wpdb;
2800 3047
@@ -2848,23 +3095,32 @@
2848 3095 // (Square, Authorize.Net) reach this generic path but previously left
2849 3096 // the booking at pending/pending — only the payment row was written.
2850 3097 // This now matches handle_successful_payment(): accumulate amount_paid,
2851 3098 // recompute amount_due, set payment_status (paid vs partial), and
2852 - // 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).
2853 3101 $newAmountPaid = (float) ($booking->amount_paid ?? 0) + $amount;
2854 3102 $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
2855 3103 $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
2856 3104 $previousStatus = (string) ($booking->status ?? 'pending');
2857 3105
2858 - $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 = [
2859 3112 'amount_paid' => $newAmountPaid,
2860 3113 'amount_due' => $newAmountDue,
2861 3114 'payment_status' => $paymentStatus,
2862 - 'status' => 'confirmed',
2863 - ]);
3115 + ];
3116 + if ($shouldConfirm) {
3117 + $bookingUpdate['status'] = 'confirmed';
3118 + }
3119 + $this->bookingRepository->update($bookingId, $bookingUpdate);
2864 3120
2865 - if (function_exists('yatra_trigger_booking_confirmed')) {
2866 - \yatra_trigger_booking_confirmed($bookingId, $previousStatus);
3121 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
3122 + \yatra_trigger_booking_confirmed($bookingId, $previousStatus, true);
2867 3123 }
2868 3124
2869 3125 // Fire payment completed action
2870 3126 do_action('yatra_payment_completed', [
@@ -2932,9 +3188,9 @@
2932 3188 'description' => $params['trip_title'],
2933 3189 ]],
2934 3190 'application_context' => [
2935 3191 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2936 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3192 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2937 3193 ],
2938 3194 ]),
2939 3195 ]);
2940 3196
@@ -3053,9 +3309,12 @@
3053 3309 'su' => add_query_arg(
3054 3310 ['payment' => 'success', 'gateway' => 'esewa'],
3055 3311 $this->getConfirmationUrl($params['reference'])
3056 3312 ),
3057 - '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 + ),
3058 3317 ], $base_url);
3059 3318
3060 3319 return ['success' => true, 'payment_url' => $payment_url];
3061 3320 }
@@ -3207,9 +3466,9 @@
3207 3466 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Booking reference', 'yatra'); ?>:</strong> <?php echo esc_html($reference); ?></p>
3208 3467 <p style="margin:0 0 8px;"><strong><?php esc_html_e('Trip', 'yatra'); ?>:</strong> <?php echo esc_html($trip->title); ?></p>
3209 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>
3210 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. */
3211 -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>
3212 3471 <p style="margin:0;"><strong><?php esc_html_e('Travelers', 'yatra'); ?>:</strong> <?php echo esc_html((string) count($travelers)); ?></p>
3213 3472 </div>
3214 3473 <h3 style="font-size:16px;"><?php esc_html_e('Payment details', 'yatra'); ?></h3>
3215 3474 <p><?php /* translators: %s: total amount (formatted). */
@@ -3490,8 +3749,28 @@
3490 3749 (int) $verifiedBooking->id,
3491 3750 $verifiedBooking
3492 3751 );
3493 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 + }
3494 3773 }
3495 3774
3496 3775 $this->renderVerifyEmailSuccessPage(
3497 3776 (int) $booking->id,
@@ -3620,8 +3899,22 @@
3620 3899 $secondaryLabel = $isLoggedIn
3621 3900 ? __('Go to My Account', 'yatra')
3622 3901 : __('Sign in', 'yatra');
3623 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 +
3624 3917 $heading = $alreadyVerified
3625 3918 ? __('Email Already Verified', 'yatra')
3626 3919 : __('Email Verified', 'yatra');
3627 3920 $message = $alreadyVerified
@@ -3673,9 +3966,9 @@
3673 3966 . '<p>%4$s</p>'
3674 3967 . '%5$s'
3675 3968 . '<div class="actions">'
3676 3969 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3677 - . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3970 + . '%8$s'
3678 3971 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3679 3972 . '</div>'
3680 3973 . '</div></body></html>',
3681 3974 esc_attr(get_locale()),
@@ -3684,10 +3977,13 @@
3684 3977 esc_html($message),
3685 3978 $referenceLine,
3686 3979 esc_url($confirmationUrl),
3687 3980 esc_html($primaryLabel),
3688 - esc_url($secondaryUrl),
3689 - 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 + '',
3690 3986 esc_url(home_url('/')),
3691 3987 esc_html($homeLabel)
3692 3988 );
3693 3989
@@ -4246,9 +4542,11 @@
4246 4542 // Pro can already override per-trip via trip.deposit_percentage), then
4247 4543 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
4248 4544 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
4249 4545 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
4250 - $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 ?? '')];
4251 4549 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
4252 4550 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4253 4551 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4254 4552