PluginProbe
Yatra – Travel Booking & Tour Operator Software / trunk
Yatra – Travel Booking & Tour Operator Software vtrunk
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 2.0.11 All 82 releases
← All changes | app/Controllers/BookingSessionController.php +303 -29 3.0.7trunk View file →
@@ -1105,9 +1105,14 @@
1105 1105 if ($traveler_enabled && !empty($form_config['traveler_form']['fields']) && is_array($form_config['traveler_form']['fields'])) {
1106 1106 $required_traveler_fields = [];
1107 1107 foreach ($form_config['traveler_form']['fields'] as $field) {
1108 1108 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']);
1109 + $required_traveler_fields[(string) $field['id']] = [
1110 + 'label' => (string) ($field['label'] ?? $field['id']),
1111 + // "lead" fields are only required on the lead traveler;
1112 + // absent/"all" is required on every traveler (legacy).
1113 + 'applies_to' => ($field['applies_to'] ?? 'all'),
1114 + ];
1110 1115 }
1111 1116 }
1112 1117 if (!empty($required_traveler_fields)) {
1113 1118 $traveler_index = 0;
@@ -1119,12 +1124,16 @@
1119 1124 if (isset($traveler['type']) && $traveler['type'] !== 'traveler') {
1120 1125 continue;
1121 1126 }
1122 1127 $traveler_index++;
1123 - foreach ($required_traveler_fields as $fid => $flabel) {
1128 + foreach ($required_traveler_fields as $fid => $meta) {
1129 + // Lead-only required fields apply to Traveler 1 only.
1130 + if (($meta['applies_to'] ?? 'all') === 'lead' && $traveler_index !== 1) {
1131 + continue;
1132 + }
1124 1133 if ($is_missing($traveler[$fid] ?? null)) {
1125 1134 /* translators: 1: traveler number, 2: field label. */
1126 - return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $flabel);
1135 + return sprintf(__('Traveler %1$d: %2$s is required.', 'yatra'), $traveler_index, $meta['label']);
1127 1136 }
1128 1137 }
1129 1138 }
1130 1139 }
@@ -1196,8 +1205,23 @@
1196 1205 global $wpdb;
1197 1206
1198 1207 $data = $request->get_json_params();
1199 1208
1209 + // reCAPTCHA v3 — no-op unless the booking form is explicitly protected in
1210 + // settings (off by default so payment flows are never gated unless the
1211 + // operator opts in).
1212 + $recaptcha = \Yatra\Services\RecaptchaService::verifyForm(
1213 + 'booking',
1214 + (string) (($data['recaptcha_token'] ?? '') ?: ''),
1215 + $_SERVER['REMOTE_ADDR'] ?? null
1216 + );
1217 + if (empty($recaptcha['success'])) {
1218 + return new WP_REST_Response([
1219 + 'success' => false,
1220 + 'message' => $recaptcha['message'] ?? __('reCAPTCHA verification failed.', 'yatra'),
1221 + ], 400);
1222 + }
1223 +
1200 1224 // ========================================
1201 1225 // CSRF — booking-scoped action nonce
1202 1226 // ========================================
1203 1227 // The public_permission_callback on this route intentionally
@@ -1347,8 +1371,17 @@
1347 1371
1348 1372 // Get contact email - handle both flat and nested formats
1349 1373 $contact_email = trim((string) ($data['contact_email'] ?? ''));
1350 1374 $contact_phone = $data['contact_phone'] ?? '';
1375 + // International phone widget: fold the chosen country (companion
1376 + // *_country field carrying the ISO) into the number as "+<dial><digits>".
1377 + // A no-op for legacy submissions with no companion field, an already
1378 + // "+"-prefixed value, or an unknown ISO — so existing data is never
1379 + // altered and nothing is invented.
1380 + $contact_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1381 + (string) $contact_phone,
1382 + (string) ($data['contact_phone_country'] ?? '')
1383 + );
1351 1384 $contact_first_name = $data['contact_first_name'] ?? '';
1352 1385 $contact_last_name = $data['contact_last_name'] ?? '';
1353 1386 $contact_country = $data['contact_country'] ?? '';
1354 1387
@@ -1356,9 +1389,12 @@
1356 1389 $contact_address = $data['contact_address'] ?? '';
1357 1390
1358 1391 // Emergency contact
1359 1392 $emergency_name = $data['emergency_name'] ?? '';
1360 - $emergency_phone = $data['emergency_phone'] ?? '';
1393 + $emergency_phone = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1394 + (string) ($data['emergency_phone'] ?? ''),
1395 + (string) ($data['emergency_phone_country'] ?? '')
1396 + );
1361 1397 $emergency_relationship = $data['emergency_relationship'] ?? '';
1362 1398
1363 1399 // Travel details
1364 1400 $travel_date = $data['travel_date'] ?? ($session['travel_date'] ?? '');
@@ -1691,9 +1727,9 @@
1691 1727 $isWaitlistCheckout = false;
1692 1728
1693 1729 if ($resolvedAvailabilityForWaitlist !== null) {
1694 1730 $availStatus = (string) ($resolvedAvailabilityForWaitlist->status ?? 'available');
1695 - if (in_array($availStatus, ['blocked', 'closed', 'cancelled'], true)) {
1731 + if (in_array($availStatus, ['blocked', 'closed', 'cancelled', 'unavailable'], true)) {
1696 1732 return new WP_REST_Response([
1697 1733 'success' => false,
1698 1734 'message' => __('This departure is not open for booking.', 'yatra'),
1699 1735 'code' => 'date_blocked',
@@ -1760,11 +1796,28 @@
1760 1796 // {{contact_<id>}} email variables. Built-in keys above are not overwritten.
1761 1797 foreach ($data as $field_key => $field_value) {
1762 1798 if (is_string($field_key) && strpos($field_key, 'contact_') === 0 && is_scalar($field_value)) {
1763 1799 $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);
1800 + if ($field_id === '' || $field_id === 'data') {
1801 + continue;
1766 1802 }
1803 + // A phone widget's `<field>_country` companion is folded into the
1804 + // phone value below, not stored as its own field.
1805 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1806 + continue;
1807 + }
1808 + if (isset($contact_data[$field_id])) {
1809 + continue;
1810 + }
1811 + $field_string = (string) $field_value;
1812 + // Custom phone field: combine national number + country companion.
1813 + if (isset($data[$field_key . '_country'])) {
1814 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1815 + $field_string,
1816 + (string) $data[$field_key . '_country']
1817 + );
1818 + }
1819 + $contact_data[$field_id] = sanitize_text_field($field_string);
1767 1820 }
1768 1821 }
1769 1822
1770 1823 // Prepare emergency contact data
@@ -1776,11 +1829,25 @@
1776 1829 // Same dynamic capture for emergency_* custom fields.
1777 1830 foreach ($data as $field_key => $field_value) {
1778 1831 if (is_string($field_key) && strpos($field_key, 'emergency_') === 0 && is_scalar($field_value)) {
1779 1832 $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);
1833 + if ($field_id === '' || $field_id === 'contact') {
1834 + continue;
1782 1835 }
1836 + if (substr($field_id, -8) === '_country' && isset($data[substr($field_key, 0, -8)])) {
1837 + continue;
1838 + }
1839 + if (isset($emergency_data[$field_id])) {
1840 + continue;
1841 + }
1842 + $field_string = (string) $field_value;
1843 + if (isset($data[$field_key . '_country'])) {
1844 + $field_string = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1845 + $field_string,
1846 + (string) $data[$field_key . '_country']
1847 + );
1848 + }
1849 + $emergency_data[$field_id] = sanitize_text_field($field_string);
1783 1850 }
1784 1851 }
1785 1852
1786 1853 // Sanitize travelers data
@@ -1789,8 +1856,13 @@
1789 1856 if (is_array($traveler)) {
1790 1857 $sanitized_traveler = [];
1791 1858 foreach ($traveler as $key => $value) {
1792 1859 $sk = sanitize_key((string) $key);
1860 + // Skip a phone widget's `<field>_country` companion; it is
1861 + // folded into the phone value in the pass below.
1862 + if (substr($sk, -8) === '_country' && isset($traveler[substr((string) $key, 0, -8)])) {
1863 + continue;
1864 + }
1793 1865 if (is_array($value)) {
1794 1866 $sanitized_traveler[$sk] = array_map(static function ($v) {
1795 1867 return sanitize_text_field(is_scalar($v) ? (string) $v : '');
1796 1868 }, $value);
@@ -1797,8 +1869,19 @@
1797 1869 } else {
1798 1870 $sanitized_traveler[$sk] = sanitize_text_field((string) $value);
1799 1871 }
1800 1872 }
1873 + // Combine each phone field with its country companion (national
1874 + // number + dial code → "+<dial><digits>").
1875 + foreach (array_keys($sanitized_traveler) as $tk) {
1876 + $companion = $tk . '_country';
1877 + if (isset($traveler[$companion]) && is_string($sanitized_traveler[$tk])) {
1878 + $sanitized_traveler[$tk] = \Yatra\Helpers\FormatHelper::combineInternationalPhone(
1879 + (string) $sanitized_traveler[$tk],
1880 + (string) $traveler[$companion]
1881 + );
1882 + }
1883 + }
1801 1884 $sanitized_travelers[] = $sanitized_traveler;
1802 1885 }
1803 1886 }
1804 1887
@@ -1983,10 +2066,20 @@
1983 2066
1984 2067 if ($isWaitlistCheckout && $resolvedAvailabilityForWaitlist) {
1985 2068 $booking_data['availability_id'] = (int) $resolvedAvailabilityForWaitlist->id;
1986 2069 $booking_data['status'] = 'waitlist';
1987 - $booking_data['payment_gateway'] = 'pay_later';
1988 - $booking_data['payment_method'] = 'full';
2070 + // Preserve the customer's real OFFLINE gateway + deposit/partial
2071 + // choice (Bank Transfer / Pay Later). No charge is taken for a
2072 + // waitlisted slot regardless, and waitlist promotion only flips the
2073 + // status — it never restores the selection — so pinning to
2074 + // pay_later/full here would permanently drop the chosen gateway AND
2075 + // wipe the deposit (BookingService recomputes amount_due from
2076 + // payment_method). Online gateways stay deferred to pay_later/full
2077 + // since a card can't be charged for a non-guaranteed slot.
2078 + if (!$is_offline_gateway) {
2079 + $booking_data['payment_gateway'] = 'pay_later';
2080 + $booking_data['payment_method'] = 'full';
2081 + }
1989 2082 }
1990 2083
1991 2084 // Hold the booking in `pending_verification` until the guest
1992 2085 // clicks the magic link. Payment is initiated only after the
@@ -1998,10 +2091,21 @@
1998 2091 // until verification completes and the regular checkout
1999 2092 // resumes.
2000 2093 if ($needs_email_verification && !$isWaitlistCheckout) {
2001 2094 $booking_data['status'] = 'pending_verification';
2002 - $booking_data['payment_gateway'] = 'pay_later';
2003 - $booking_data['payment_method'] = 'full';
2095 + // Defer the gateway choice ONLY for online gateways: a real charge
2096 + // would otherwise lock the customer into a gateway before they have
2097 + // confirmed their email. For OFFLINE gateways (Bank Transfer / Pay
2098 + // Later) there is no charge to defer, and the verify-email endpoint
2099 + // does not restore the selection afterwards — so pinning to
2100 + // pay_later/full here would permanently drop the customer's chosen
2101 + // gateway AND their deposit/partial amount (BookingService recomputes
2102 + // amount_due from payment_method, so 'full' wipes the deposit).
2103 + // Preserve the real selection for offline gateways.
2104 + if (!$is_offline_gateway) {
2105 + $booking_data['payment_gateway'] = 'pay_later';
2106 + $booking_data['payment_method'] = 'full';
2107 + }
2004 2108 }
2005 2109
2006 2110 try {
2007 2111 $booking = $booking_service->createBooking($booking_data);
@@ -2056,8 +2160,14 @@
2056 2160 * @param int $trip_id The trip ID
2057 2161 * @param array $data The booking request data (contains selected_services)
2058 2162 * @param int $travelers_count Total number of travelers
2059 2163 * @param int $duration_days Trip duration in days
2164 + * @param float $base_amount Trip base price (pre-services, pre-discount) —
2165 + * the authoritative base used by the pricing engine for this
2166 + * booking. Listeners persisting percentage-type services price
2167 + * them against this exact value so the saved line-items reconcile
2168 + * with the charged total. Added in a backward-compatible way:
2169 + * existing 5-arg listeners simply ignore it.
2060 2170 * @since 3.0.0
2061 2171 */
2062 2172 // Normalise: Pro module reads $data['selected_services'], frontend sends $data['additional_services']
2063 2173 if (!isset($data['selected_services'])) {
@@ -2068,9 +2178,9 @@
2068 2178 if (!is_array($data['selected_services'])) {
2069 2179 $data['selected_services'] = [];
2070 2180 }
2071 2181 $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));
2182 + do_action('yatra_booking_save_services', $booking_id, $trip_id, $data, $travelers_count, (int) ($trip->duration_days ?? 1), (float) ($pricing['base_amount'] ?? 0));
2073 2183
2074 2184 // ========================================
2075 2185 // SAVE TRAVELLERS TO NORMALIZED TABLES
2076 2186 // ========================================
@@ -2186,13 +2296,54 @@
2186 2296 $email_vars['expiry_notice_html'] = '<strong>'
2187 2297 . esc_html__('This link expires in 48 hours.', 'yatra')
2188 2298 . '</strong>';
2189 2299
2190 - \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2300 + // Guest-checkout verification prefers the operator's CONFIGURED
2301 + // customer verification template so their customisation is honoured
2302 + // (the guest system template was consolidated away — using the guest
2303 + // type always fell back to the built-in default and ignored the
2304 + // configured one). This email MUST still carry the verification link
2305 + // — a guest can't complete the booking without it — so we only fall
2306 + // back to the built-in GUEST default when the effective customer
2307 + // template would omit {{verification_link}} (an operator can, and on
2308 + // real sites does, customise that template and drop the tag). The
2309 + // check respects Pro-owned DB templates too. Booking copy is injected
2310 + // above via intro_paragraph / footer_note / expiry merge vars.
2311 + //
2312 + // Keep the booking-specific SUBJECT line ("Verify your email to
2313 + // complete your booking") that guests saw before the guest template
2314 + // was consolidated away — reusing the customer template body must not
2315 + // drag along the account-oriented "Verify your email address"
2316 + // subject. This is honoured additively by the renderer / Pro sender
2317 + // via the reserved `_subject_override` var, so only this guest send
2318 + // is affected. Computed before it is stored, so the render below
2319 + // resolves the clean guest subject (no self-reference).
2320 + $email_vars['_subject_override'] = \Yatra\Services\TransactionalEmailTemplateService::render(
2191 2321 \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2192 - (string) $contact_data['email'],
2193 2322 $email_vars
2194 - );
2323 + )['subject'];
2324 + $verificationEmailSent = false;
2325 + if (\Yatra\Services\TransactionalEmailTemplateService::templateRendersVerificationLink(
2326 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION
2327 + )) {
2328 + $verificationEmailSent = \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2329 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_CUSTOMER_EMAIL_VERIFICATION,
2330 + (string) $contact_data['email'],
2331 + $email_vars
2332 + );
2333 + }
2334 + // Guarantee a verification email even if the customer template would
2335 + // drop the link OR its per-type toggle is disabled — a guest can't
2336 + // complete checkout without it. The built-in GUEST default always
2337 + // carries the link. sendIfEnabled() returns whether it actually sent,
2338 + // so this only fires when the preferred send did not (no double send).
2339 + if (!$verificationEmailSent) {
2340 + \Yatra\Services\TransactionalEmailTemplateService::sendIfEnabled(
2341 + \Yatra\Services\TransactionalEmailTemplateService::TYPE_GUEST_EMAIL_VERIFICATION,
2342 + (string) $contact_data['email'],
2343 + $email_vars
2344 + );
2345 + }
2195 2346
2196 2347 return new WP_REST_Response([
2197 2348 'success' => true,
2198 2349 'code' => 'email_verification_required',
@@ -2712,11 +2863,15 @@
2712 2863 // Default return_url to the configured booking confirmation URL so redirect gateways
2713 2864 // (e.g. PayPal Advanced, Mollie, Paystack) do not fall back to wrong paths; gateways
2714 2865 // may still append their own query args on top of this URL.
2715 2866 $ref = isset($params['reference']) ? trim((string) $params['reference']) : '';
2867 + // Cancel returns must land on the booking-confirmation page (always resolvable);
2868 + // `home_url('/book/?...')` 404s under a custom booking base/page. Use the reference,
2869 + // falling back to the booking id so the confirmation route always has a token.
2870 + $cancelRef = $ref !== '' ? $ref : (string) ($params['booking_id'] ?? '');
2716 2871 $paymentData = array_merge($params, [
2717 2872 'description' => $params['trip_title'] ?? '',
2718 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . ($params['reference'] ?? '')),
2873 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($cancelRef)),
2719 2874 'metadata' => [
2720 2875 'booking_id' => $params['booking_id'],
2721 2876 'reference' => $params['reference'] ?? ''
2722 2877 ]
@@ -2765,10 +2920,12 @@
2765 2920 ];
2766 2921 }
2767 2922
2768 2923 // For offline gateways or successful direct payments without redirect
2924 + $this->recordOfflinePendingPayment($params, $result, $gatewayId);
2925 +
2769 2926 return [
2770 - 'success' => true,
2927 + 'success' => true,
2771 2928 'redirect_url' => $this->getConfirmationUrl($params['reference'] ?? '')
2772 2929 ];
2773 2930 }
2774 2931
@@ -2793,8 +2950,75 @@
2793 2950 /**
2794 2951 * Record payment from gateway result
2795 2952 * Matches Stripe's completePayment behavior
2796 2953 */
2954 + /**
2955 + * Record the awaited payment for an offline gateway (bank transfer, cash on
2956 + * arrival, pay later) as a PENDING ledger row.
2957 + *
2958 + * These gateways take no money at checkout, and previously wrote no payment
2959 + * row at all — so when the transfer finally landed there was nothing in the
2960 + * Payments screen for the operator to mark as received. The booking's own
2961 + * fields were the only record, and marking those by hand left the invoice
2962 + * reporting "Payment Pending" with nothing paid.
2963 + *
2964 + * The row is deliberately `pending`: no money has arrived yet, and
2965 + * getTotalPaidForBooking() counts only `completed`, so booking financials and
2966 + * every report are untouched until the operator confirms it.
2967 + */
2968 + private function recordOfflinePendingPayment(array $params, array $result, string $gatewayId): void
2969 + {
2970 + try {
2971 + $bookingId = (int) ($params['booking_id'] ?? 0);
2972 + $amount = (float) ($params['amount'] ?? 0);
2973 +
2974 + if ($bookingId <= 0 || $amount <= 0) {
2975 + return;
2976 + }
2977 +
2978 + // Only for gateways that settle out of band. Anything reporting a
2979 + // completed/succeeded status already records its own row.
2980 + $status = strtolower((string) ($result['status'] ?? ''));
2981 + if (!in_array($status, ['', 'pending', 'pending_verification'], true)) {
2982 + return;
2983 + }
2984 +
2985 + $booking = $this->bookingRepository->find($bookingId);
2986 + if (!$booking || ($booking->payment_status ?? '') === 'paid') {
2987 + return;
2988 + }
2989 +
2990 + $paymentRepository = new \Yatra\Repositories\PaymentRepository();
2991 +
2992 + // Idempotency: a retried checkout must not stack up duplicate rows.
2993 + foreach ($paymentRepository->findByBookingId($bookingId) as $existing) {
2994 + if ((string) ($existing->gateway ?? '') === $gatewayId
2995 + && in_array((string) ($existing->status ?? ''), ['pending', 'completed'], true)
2996 + ) {
2997 + return;
2998 + }
2999 + }
3000 +
3001 + $paymentRepository->create([
3002 + 'booking_id' => $bookingId,
3003 + 'amount' => $amount,
3004 + 'currency' => $params['currency'] ?? \Yatra\Services\SettingsService::getCurrency(),
3005 + 'gateway' => $gatewayId,
3006 + 'status' => 'pending',
3007 + 'customer_id' => !empty($booking->customer_id) ? (int) $booking->customer_id : null,
3008 + 'notes' => __('Awaiting payment — mark as completed once received.', 'yatra'),
3009 + 'created_at' => current_time('mysql'),
3010 + ]);
3011 + } catch (\Throwable $e) {
3012 + // Never break a successful checkout over a bookkeeping row.
3013 + \Yatra\Utils\Logger::warning('Could not record pending offline payment', [
3014 + 'booking_id' => $params['booking_id'] ?? 0,
3015 + 'gateway' => $gatewayId,
3016 + 'error' => $e->getMessage(),
3017 + ]);
3018 + }
3019 + }
3020 +
2797 3021 private function recordGatewayPayment(array $params, array $result, string $gatewayId): void
2798 3022 {
2799 3023 global $wpdb;
2800 3024
@@ -2854,16 +3078,24 @@
2854 3078 $newAmountDue = max(0.0, (float) ($booking->total_amount ?? 0) - $newAmountPaid);
2855 3079 $paymentStatus = $newAmountDue > 0.0 ? 'partial' : 'paid';
2856 3080 $previousStatus = (string) ($booking->status ?? 'pending');
2857 3081
2858 - $this->bookingRepository->update($bookingId, [
3082 + // Only auto-confirm when the operator allows it (or fully paid). A
3083 + // deposit / partial payment must not confirm when "Auto-Confirm
3084 + // Bookings" is off.
3085 + $shouldConfirm = \yatra_should_confirm_booking_on_payment($newAmountDue <= 0.0, $bookingId);
3086 +
3087 + $bookingUpdate = [
2859 3088 'amount_paid' => $newAmountPaid,
2860 3089 'amount_due' => $newAmountDue,
2861 3090 'payment_status' => $paymentStatus,
2862 - 'status' => 'confirmed',
2863 - ]);
3091 + ];
3092 + if ($shouldConfirm) {
3093 + $bookingUpdate['status'] = 'confirmed';
3094 + }
3095 + $this->bookingRepository->update($bookingId, $bookingUpdate);
2864 3096
2865 - if (function_exists('yatra_trigger_booking_confirmed')) {
3097 + if ($shouldConfirm && function_exists('yatra_trigger_booking_confirmed')) {
2866 3098 \yatra_trigger_booking_confirmed($bookingId, $previousStatus);
2867 3099 }
2868 3100
2869 3101 // Fire payment completed action
@@ -2932,9 +3164,9 @@
2932 3164 'description' => $params['trip_title'],
2933 3165 ]],
2934 3166 'application_context' => [
2935 3167 'return_url' => add_query_arg('payment', 'success', $this->getConfirmationUrl($params['reference'])),
2936 - 'cancel_url' => home_url('/book/?payment=cancelled&ref=' . $params['reference']),
3168 + 'cancel_url' => add_query_arg('payment', 'cancelled', $this->getConfirmationUrl($params['reference'])),
2937 3169 ],
2938 3170 ]),
2939 3171 ]);
2940 3172
@@ -3053,9 +3285,12 @@
3053 3285 'su' => add_query_arg(
3054 3286 ['payment' => 'success', 'gateway' => 'esewa'],
3055 3287 $this->getConfirmationUrl($params['reference'])
3056 3288 ),
3057 - 'fu' => home_url('/book/?payment=failed&ref=' . $params['reference']),
3289 + 'fu' => add_query_arg(
3290 + ['payment' => 'failed', 'gateway' => 'esewa'],
3291 + $this->getConfirmationUrl($params['reference'])
3292 + ),
3058 3293 ], $base_url);
3059 3294
3060 3295 return ['success' => true, 'payment_url' => $payment_url];
3061 3296 }
@@ -3490,8 +3725,28 @@
3490 3725 (int) $verifiedBooking->id,
3491 3726 $verifiedBooking
3492 3727 );
3493 3728 }
3729 +
3730 + // Guest email-verification defers the customer booking-confirmation
3731 + // email: the checkout flow returns at the verification gate, before
3732 + // its send-site (~line 2465), so the confirmation is never sent for a
3733 + // verified guest booking. Send it now that the email is proven and the
3734 + // booking is live — gated by the same `booking_confirmation` option the
3735 + // checkout paths use. Only in this fresh-verify branch, so a re-clicked
3736 + // link never re-sends. TYPE_BOOKING_CONFIRMATION is skipped by the Pro
3737 + // booking.created fan-out, so this is the single source of the email.
3738 + if ((bool) \Yatra\Services\SettingsService::get('booking_confirmation', true)) {
3739 + try {
3740 + (new \Yatra\Services\BookingService())->sendNewBookingTransactionalConfirmation((int) $booking->id);
3741 + } catch (\Throwable $e) {
3742 + // A mail failure must never break the customer's "verified" page.
3743 + Logger::error('Post-verification booking confirmation email failed', [
3744 + 'booking_id' => (int) $booking->id,
3745 + 'error' => $e->getMessage(),
3746 + ]);
3747 + }
3748 + }
3494 3749 }
3495 3750
3496 3751 $this->renderVerifyEmailSuccessPage(
3497 3752 (int) $booking->id,
@@ -3620,8 +3875,22 @@
3620 3875 $secondaryLabel = $isLoggedIn
3621 3876 ? __('Go to My Account', 'yatra')
3622 3877 : __('Sign in', 'yatra');
3623 3878
3879 + // Logged-in customers always get "Go to My Account". A guest is only
3880 + // offered "Sign in" when an account is genuinely part of the flow —
3881 + // registration is enabled AND guest checkout is not the operating mode.
3882 + // This is a guest email-verification page (guest checkout is normally
3883 + // on), so with guest checkout enabled OR registration disabled there is
3884 + // no account to sign into; the CTA is hidden rather than dangling to a
3885 + // login the guest can't use.
3886 + $registrationEnabled = \Yatra\Services\SettingsService::isEnabled('customer_registration');
3887 + $guestCheckoutEnabled = \Yatra\Services\SettingsService::isEnabled('allow_guest_checkout');
3888 + $showSecondaryCta = $isLoggedIn || ($registrationEnabled && !$guestCheckoutEnabled);
3889 + $secondaryCta = $showSecondaryCta
3890 + ? '<a class="btn btn-secondary" href="' . esc_url($secondaryUrl) . '">' . esc_html($secondaryLabel) . '</a>'
3891 + : '';
3892 +
3624 3893 $heading = $alreadyVerified
3625 3894 ? __('Email Already Verified', 'yatra')
3626 3895 : __('Email Verified', 'yatra');
3627 3896 $message = $alreadyVerified
@@ -3673,9 +3942,9 @@
3673 3942 . '<p>%4$s</p>'
3674 3943 . '%5$s'
3675 3944 . '<div class="actions">'
3676 3945 . '<a class="btn btn-primary" href="%6$s">%7$s</a>'
3677 - . '<a class="btn btn-secondary" href="%8$s">%9$s</a>'
3946 + . '%8$s'
3678 3947 . '<a class="btn btn-tertiary" href="%10$s">%11$s</a>'
3679 3948 . '</div>'
3680 3949 . '</div></body></html>',
3681 3950 esc_attr(get_locale()),
@@ -3684,10 +3953,13 @@
3684 3953 esc_html($message),
3685 3954 $referenceLine,
3686 3955 esc_url($confirmationUrl),
3687 3956 esc_html($primaryLabel),
3688 - esc_url($secondaryUrl),
3689 - esc_html($secondaryLabel),
3957 + // %8 is the fully-built secondary CTA (or '' when hidden — see
3958 + // $showSecondaryCta above). %9 is intentionally empty to keep the
3959 + // positional args aligned with %10/%11.
3960 + $secondaryCta,
3961 + '',
3690 3962 esc_url(home_url('/')),
3691 3963 esc_html($homeLabel)
3692 3964 );
3693 3965
@@ -4246,9 +4518,11 @@
4246 4518 // Pro can already override per-trip via trip.deposit_percentage), then
4247 4519 // hand off to `yatra_calculate_amount_due` so Pro can apply absolute
4248 4520 // overrides too (e.g. trip.deposit_amount as a fixed cap). Doing both
4249 4521 // keeps the math consistent with CalculationService::calculatePaymentAmounts().
4250 - $context = ['trip_id' => $trip_id];
4522 + // Tour start → Pro can force full payment when the tour is within the
4523 + // balance-due window (tour-anchored scheduled payments).
4524 + $context = ['trip_id' => $trip_id, 'travel_date' => (string) ($travel_date ?? '')];
4251 4525 $flexible_payments_enabled = apply_filters('yatra_flexible_payments_enabled', false);
4252 4526 $deposit_percentage = (int) apply_filters('yatra_deposit_percentage', 20, $context);
4253 4527 $partial_percentage = (int) apply_filters('yatra_partial_payment_percentage', 30, $context);
4254 4528