| @@ -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 | |