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