PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.16
Yatra – Travel Booking & Tour Operator Software v3.0.16
3.0.16 3.0.15 3.0.14 3.0.14.1 3.0.14.2 3.0.12 3.0.13 3.0.11 3.0.10 3.0.9 3.0.8 3.0.7 3.0.6 3.0.5 3.0.5.1 3.0.4 3.0.3 3.0.2.9 3.0.2.7 3.0.2.8 3.0.2.6 trunk 1.0.0 2.0.0 2.0.1 All 84 releases
← All changes | includes/helpers.php +621 -56 3.0.3 → 3.0.16 View file →
@@ -54,9 +54,15 @@
54 54 * Get booking form configuration
55 55 *
56 56 * @return array
57 57 */
58 -function yatra_get_booking_form_config(): array
58 +/**
59 + * @param int|null $tripId Trip being booked. Pass it from every checkout-side
60 + * caller so per-trip field visibility (Pro) applies to
61 + * rendering, the AJAX re-render and server validation
62 + * alike. Omit it where the whole config is wanted.
63 + */
64 +function yatra_get_booking_form_config(?int $tripId = null): array
59 65 {
60 66 // Check if Dynamic Form Field module is enabled via Pro plugin
61 67 $is_dynamic_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
62 68
@@ -61,19 +67,47 @@
61 67 $is_dynamic_enabled = apply_filters('yatra_dynamic_form_field_enabled', false);
62 68
63 69 if ($is_dynamic_enabled) {
64 70 // Pro module is active — merged config from options (filtered in SettingsService::getBookingFormConfig)
65 - return SettingsService::getBookingFormConfig();
71 + return SettingsService::getBookingFormConfig($tripId);
66 72 }
67 73
68 74 // Module off: still allow filters to adjust defaults (tests / edge integrations)
69 75 return apply_filters(
70 76 'yatra_booking_form_config',
71 - SettingsService::getDefaultBookingFormConfig()
77 + SettingsService::getDefaultBookingFormConfig(),
78 + $tripId
72 79 );
73 80 }
74 81
75 82 /**
83 + * Translate a booking-form display string (label / title / description /
84 + * placeholder / option label) at render time.
85 + *
86 + * The default booking-form strings are registered for translation in
87 + * SettingsService::getDefaultBookingFormConfig() (literal __() calls, so they
88 + * land in the .pot for Loco Translate). This runtime pass additionally lets a
89 + * SAVED or CUSTOM label (Pro Dynamic Form module) resolve against the active
90 + * locale when a matching translation exists, and returns the original string
91 + * unchanged otherwise. Safe for empty/non-string input.
92 + *
93 + * @param mixed $string
94 + * @return string
95 + */
96 +function yatra_translate_form_string($string): string
97 +{
98 + $string = is_scalar($string) ? (string) $string : '';
99 + if ($string === '') {
100 + return '';
101 + }
102 +
103 + // Dynamic gettext: the literal source strings are registered for extraction
104 + // in SettingsService; this resolves them (and any matching custom label) at
105 + // runtime against the loaded 'yatra' text domain.
106 + return __($string, 'yatra'); // phpcs:ignore WordPress.WP.I18n.NonSingularStringLiteralText, WordPress.WP.I18n.NonSingularStringLiteralDomain
107 +}
108 +
109 +/**
76 110 * Check if user can leave a review for a trip
77 111 *
78 112 * @param int $trip_id Trip ID
79 113 * @param int|null $user_id User ID (defaults to current user)
@@ -200,11 +234,13 @@
200 234 $hours = floor($seconds_remaining / 3600);
201 235 $minutes = floor(($seconds_remaining % 3600) / 60);
202 236
203 237 if ($hours > 0) {
238 + /* translators: %d: number of hours remaining. */
204 239 return sprintf(_n('%d hour', '%d hours', $hours, 'yatra'), $hours);
205 240 }
206 -
241 +
242 + /* translators: %d: number of minutes remaining. */
207 243 return sprintf(_n('%d minute', '%d minutes', $minutes, 'yatra'), $minutes);
208 244 }
209 245
210 246 /**
@@ -335,11 +371,11 @@
335 371 }
336 372
337 373 // Get formatting settings from global settings
338 374 $currency_position = SettingsService::getCurrencyPosition();
339 - $decimal_places = SettingsService::getInt('decimal_places', 2);
340 - // Avoid absurd migrated values (e.g. 7+) breaking storefront display; cap at 4.
341 - $decimal_places = max(0, min(4, $decimal_places));
375 + // Single source of truth: honors the admin "Number of decimals" field and
376 + // stays in sync with the JS price formatter (already clamped to 0–4).
377 + $decimal_places = SettingsService::getPriceDecimals();
342 378 $thousand_separator = SettingsService::getString('thousand_separator', ',');
343 379 $decimal_separator = SettingsService::getString('decimal_separator', '.');
344 380
345 381 // Format the amount with proper separators
@@ -446,16 +482,28 @@
446 482 * @param int|null $nights Number of nights (optional)
447 483 * @return string Formatted duration
448 484 */
449 485 if (!function_exists('yatra_format_duration')) {
450 - function yatra_format_duration(int $days, ?int $nights = null): string
486 + function yatra_format_duration(int $days, ?int $nights = null, ?int $hours = null): string
451 487 {
488 + // Hour-based (single-day) tours take precedence when a positive hours
489 + // value is supplied. Optional trailing arg keeps every existing
490 + // two-argument call unchanged.
491 + if ($hours !== null && $hours > 0) {
492 + return sprintf(
493 + /* translators: %d: number of hours. */
494 + _n('%d hour', '%d hours', $hours, 'yatra'),
495 + $hours
496 + );
497 + }
498 +
452 499 if ($days > 0 && $nights !== null && $nights > 0) {
453 - /* translators: 1: days count, 2: nights count */
500 + /* translators: 1: number of days, 2: number of nights. */
454 501 return sprintf(__('%1$d days / %2$d nights', 'yatra'), $days, $nights);
455 502 }
456 503 if ($days > 0) {
457 504 return sprintf(
505 + /* translators: %d: number of days. */
458 506 _n('%d day', '%d days', $days, 'yatra'),
459 507 $days
460 508 );
461 509 }
@@ -909,36 +957,245 @@
909 957 }
910 958 }
911 959
912 960 /**
913 - * Public URL for the Yatra brand icon (admin menu + React sidebar). Empty if file is missing.
961 + * ============================================
962 + * BRAND / WHITE LABEL HELPERS (THIN WRAPPERS)
963 + * ============================================
964 + *
965 + * The free plugin owns the function NAMES (so callers in plugin row meta,
966 + * admin menu, PDF templates, etc. work without conditional `function_exists`
967 + * checks), but every override lives in Yatra Pro's White Label module.
968 + *
969 + * Each helper here just applies a filter; Pro's WhiteLabel module registers
970 + * filter callbacks when the module is enabled AND an Agency-tier license is
971 + * active. Without Pro, every filter no-ops and these return the defaults —
972 + * which is the correct behavior for a free-only install.
973 + *
974 + * Option storage, REST endpoints, sanitization, plugin-list rebranding,
975 + * brand-color CSS injection, and dependency-link rewriting all live in
976 + * yatra-pro/app/Modules/WhiteLabel/ — NOT here.
914 977 */
978 +
979 +/**
980 + * Public URL for the Yatra brand icon (admin menu + React sidebar).
981 + * Defaults to the bundled `yatra-icon.png`; Pro overrides via the
982 + * `yatra_brand_icon_url` filter when a White Label logo is configured.
983 + */
915 984 function yatra_get_brand_icon_url(): string
916 985 {
917 - if (!defined('YATRA_PLUGIN_PATH') || !defined('YATRA_PLUGIN_URL')) {
918 - return '';
986 + $default = '';
987 + if (defined('YATRA_PLUGIN_PATH') && defined('YATRA_PLUGIN_URL')) {
988 + $candidates = [
989 + 'assets/images/yatra-icon.png',
990 + 'assets/images/yara-icon.png',
991 + ];
992 + foreach ($candidates as $relative) {
993 + $file = YATRA_PLUGIN_PATH . $relative;
994 + if (!is_readable($file)) {
995 + continue;
996 + }
997 + $default = add_query_arg(
998 + 'ver',
999 + (string) filemtime($file),
1000 + YATRA_PLUGIN_URL . $relative
1001 + );
1002 + break;
1003 + }
919 1004 }
920 1005
921 - $candidates = [
922 - 'assets/images/yatra-icon.png',
923 - 'assets/images/yara-icon.png',
1006 + return (string) apply_filters('yatra_brand_icon_url', $default);
1007 +}
1008 +
1009 +/**
1010 + * Whether the Agency White Label module is active and may override branding.
1011 + * Pro returns true via the `yatra_white_label_active` filter when its
1012 + * WhiteLabel module is enabled AND the license tier is Agency.
1013 + */
1014 +function yatra_is_white_label_active(): bool
1015 +{
1016 + return (bool) apply_filters('yatra_white_label_active', false);
1017 +}
1018 +
1019 +/**
1020 + * Read a single white-label setting with a default fallback. Backed by a
1021 + * filter so option access stays in Pro.
1022 + *
1023 + * @param mixed $default
1024 + * @return mixed
1025 + */
1026 +function yatra_get_white_label_setting(string $key, $default = '')
1027 +{
1028 + return apply_filters('yatra_white_label_setting', $default, $key);
1029 +}
1030 +
1031 +/**
1032 + * @return array<string, mixed>
1033 + */
1034 +function yatra_get_white_label_settings(): array
1035 +{
1036 + $value = apply_filters('yatra_white_label_settings', []);
1037 + return is_array($value) ? $value : [];
1038 +}
1039 +
1040 +/**
1041 + * Branding for generated PDFs (invoice, voucher, itinerary).
1042 + *
1043 + * Free ships an unbranded default — the header keeps whatever colour the
1044 + * document already used and no logo is shown — so nothing changes for a site
1045 + * without Yatra Pro. The White Label module hooks these filters to supply the
1046 + * operator's own logo and colour, exactly as it already does for
1047 + * `yatra_brand_icon_url` and friends.
1048 + *
1049 + * Kept as filters rather than reading White Label options directly so free never
1050 + * depends on Pro, and so a site can brand its PDFs from a theme or snippet
1051 + * without the module at all.
1052 + *
1053 + * @param string $defaultHeaderColor The document's existing header colour, so
1054 + * each PDF keeps its own look when unbranded.
1055 + * @return array{logo_url: string, header_color: string}
1056 + */
1057 +function yatra_get_pdf_branding(string $defaultHeaderColor): array
1058 +{
1059 + $logo = (string) apply_filters('yatra_pdf_branding_logo_url', '');
1060 + $color = (string) apply_filters('yatra_pdf_branding_header_color', $defaultHeaderColor);
1061 +
1062 + // Only accept a well-formed hex colour; anything else falls back to the
1063 + // document default rather than emitting broken CSS into the PDF.
1064 + if (!preg_match('/^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/', $color)) {
1065 + $color = $defaultHeaderColor;
1066 + }
1067 +
1068 + $logo = esc_url_raw(trim($logo));
1069 +
1070 + return [
1071 + 'logo_url' => $logo,
1072 + 'header_color' => $color,
924 1073 ];
1074 +}
925 1075
926 - foreach ($candidates as $relative) {
927 - $file = YATRA_PLUGIN_PATH . $relative;
928 - if (!is_readable($file)) {
929 - continue;
1076 +/**
1077 + * Customer-facing label for a payment gateway id.
1078 + *
1079 + * Returns the same **Gateway Title** the checkout shows — the operator's custom
1080 + * title when they set one, otherwise the gateway's own translated title — so
1081 + * invoices, the confirmation page and the checkout all name a gateway the same
1082 + * way. Falls back to the prettified id (the historical behaviour) when the
1083 + * gateway is not registered any more, e.g. a Pro gateway while Pro is inactive,
1084 + * so an old document still reads sensibly.
1085 + *
1086 + * @param string|null $gatewayId Gateway id / slug as stored on the payment or booking.
1087 + * @param string $fallback Used when no id is stored at all.
1088 + */
1089 +function yatra_payment_gateway_label(?string $gatewayId, string $fallback = ''): string
1090 +{
1091 + $id = trim((string) $gatewayId);
1092 + if ($id === '') {
1093 + return $fallback;
1094 + }
1095 +
1096 + if (class_exists(\Yatra\PaymentGateways\PaymentGatewayRegistry::class)) {
1097 + $title = \Yatra\PaymentGateways\PaymentGatewayRegistry::getInstance()->resolveGatewayTitle($id);
1098 + if ($title !== '') {
1099 + return $title;
930 1100 }
1101 + }
931 1102
932 - $url = YATRA_PLUGIN_URL . $relative;
1103 + // Only a slug-shaped value is a gateway id we may prettify. Anything else
1104 + // is operator free text — a manually recorded payment stores whatever they
1105 + // typed ("Cash on arrival", "SEPA Direct Debit") — and is returned exactly
1106 + // as entered rather than re-cased.
1107 + if (!preg_match('/^[A-Za-z0-9_-]+$/', $id)) {
1108 + return $id;
1109 + }
933 1110
934 - return add_query_arg('ver', (string) filemtime($file), $url);
935 - }
1111 + return ucwords(str_replace(['_', '-'], ' ', $id));
1112 +}
936 1113
937 - return '';
1114 +/**
1115 + * Are partial payments possible on this site at all?
1116 + *
1117 + * True when deposits or partial payments are switched on globally. Used to
1118 + * decide whether part-payment specific features (such as the separate
1119 + * "part payment received" email template) are relevant — there is no point
1120 + * showing them to an operator who only ever takes payment in full.
1121 + */
1122 +function yatra_partial_payments_enabled(): bool
1123 +{
1124 + $enabled = \Yatra\Services\SettingsService::isEnabled('partial_payment')
1125 + || \Yatra\Services\SettingsService::isEnabled('enable_deposit')
1126 + || \Yatra\Services\SettingsService::isEnabled('deposit_required');
1127 +
1128 + return (bool) apply_filters('yatra_partial_payments_enabled', $enabled);
938 1129 }
939 1130
940 1131 /**
1132 + * How many stars to draw for an average rating.
1133 + *
1134 + * Rounds to the NEAREST half star rather than flooring. Flooring made a 4.9
1135 + * average draw four-and-a-half stars, which reads as a mistake sitting next to
1136 + * the printed "4.9" — a 4.9 is five stars to anyone looking at it.
1137 + *
1138 + * 4.9 -> 5 4.7 -> 4.5 4.4 -> 4.5 4.2 -> 4
1139 + *
1140 + * Returns the number of solid stars and whether a half star follows them, so
1141 + * every surface (confirmation page, reviews block, listing cards) draws the
1142 + * same rating identically.
1143 + *
1144 + * @return array{full:int, half:bool}
1145 + */
1146 +function yatra_rating_star_parts($rating): array
1147 +{
1148 + $rating = max(0.0, min(5.0, (float) $rating));
1149 +
1150 + // Work in half-star units so the rounding is a single, obvious step.
1151 + $halves = (int) round($rating * 2);
1152 +
1153 + return [
1154 + 'full' => intdiv($halves, 2),
1155 + 'half' => ($halves % 2) === 1,
1156 + ];
1157 +}
1158 +
1159 +/**
1160 + * Branded plugin name shown in admin menu, plugin list, and PDFs.
1161 + */
1162 +function yatra_get_brand_name(): string
1163 +{
1164 + return (string) apply_filters('yatra_brand_name', 'Yatra');
1165 +}
1166 +
1167 +/**
1168 + * Branded company/author name (replaces "MantraBrain").
1169 + */
1170 +function yatra_get_brand_company(): string
1171 +{
1172 + return (string) apply_filters('yatra_brand_company', 'MantraBrain');
1173 +}
1174 +
1175 +/**
1176 + * Public website URL for the branded product.
1177 + */
1178 +function yatra_get_brand_website_url(): string
1179 +{
1180 + $url = (string) apply_filters('yatra_brand_website_url', 'https://wpyatra.com/');
1181 + return $url !== '' ? esc_url_raw($url) : 'https://wpyatra.com/';
1182 +}
1183 +
1184 +/**
1185 + * Support URL surfaced in admin notices and the plugin row.
1186 + */
1187 +function yatra_get_brand_support_url(): string
1188 +{
1189 + $url = (string) apply_filters(
1190 + 'yatra_brand_support_url',
1191 + 'https://wordpress.org/support/plugin/yatra/reviews/?filter=5'
1192 + );
1193 + return $url !== '' ? esc_url_raw($url) : 'https://wordpress.org/support/plugin/yatra/reviews/?filter=5';
1194 +}
1195 +
1196 +
1197 +/**
941 1198 * ============================================
942 1199 * BOOKING SESSION MANAGEMENT
943 1200 * ============================================
944 1201 */
@@ -1093,12 +1350,23 @@
1093 1350 * Core always fired `yatra_booking_status_changed`; Pro modules (Trip Consent, Google Calendar) listen
1094 1351 * on this dedicated action. Call this after any code path that sets a booking to `confirmed` without
1095 1352 * going through {@see \Yatra\Services\BookingService::updateStatus()}.
1096 1353 *
1097 - * @param int $bookingId Booking ID.
1098 - * @param string $previousStatus Booking status in the database immediately before confirming.
1354 + * Async payment-completion paths (gateway webhooks / return handlers, scheduled
1355 + * payments) confirm the booking with a direct DB write, bypassing
1356 + * updateStatus(). Pass $fromDirectConfirm = true from those paths so this
1357 + * function replicates the customer-facing side effects updateStatus() would
1358 + * have run — the "booking confirmed" email AND the `yatra_booking_status_changed`
1359 + * action that status-based listeners (Pro Email Automation, cache invalidation,
1360 + * inventory sync) rely on. The manual / checkout / waitlist paths leave it false
1361 + * because they already run those side effects themselves; passing true there
1362 + * would double-fire them.
1363 + *
1364 + * @param int $bookingId Booking ID.
1365 + * @param string $previousStatus Booking status in the database immediately before confirming.
1366 + * @param bool $fromDirectConfirm True for confirmations that bypassed updateStatus().
1099 1367 */
1100 -function yatra_trigger_booking_confirmed(int $bookingId, string $previousStatus): void
1368 +function yatra_trigger_booking_confirmed(int $bookingId, string $previousStatus, bool $fromDirectConfirm = false): void
1101 1369 {
1102 1370 if ($bookingId < 1 || $previousStatus === 'confirmed') {
1103 1371 return;
1104 1372 }
@@ -1109,8 +1377,17 @@
1109 1377 if (!$booking || ($booking->status ?? '') !== 'confirmed') {
1110 1378 return;
1111 1379 }
1112 1380
1381 + if ($fromDirectConfirm) {
1382 + // Mirror BookingService::updateStatus(): send the confirmation email and
1383 + // fire the generic status-change action for status-based listeners. Only
1384 + // async/direct confirms reach here with true — the manual, checkout and
1385 + // waitlist paths fire these themselves, so this never double-fires.
1386 + (new \Yatra\Services\BookingService())->sendBookingConfirmedEmail($bookingId);
1387 + do_action('yatra_booking_status_changed', $bookingId, $previousStatus, 'confirmed');
1388 + }
1389 +
1113 1390 /**
1114 1391 * Booking reached confirmed status (was not confirmed before this transition).
1115 1392 *
1116 1393 * @param int $bookingId Booking ID.
@@ -1119,8 +1396,248 @@
1119 1396 do_action('yatra_booking_confirmed', $bookingId, $booking);
1120 1397 }
1121 1398
1122 1399 /**
1400 + * Fire `yatra_booking_cancelled` for a booking that has just been cancelled.
1401 + *
1402 + * The action is documented and listened to (Google Calendar removes its event,
1403 + * the Pro webhook `booking.cancelled` and the WhatsApp cancellation template are
1404 + * bound to it) but nothing in the plugin ever fired it: only Channel Manager's
1405 + * OTA ingest did, so an in-app cancellation reached none of those listeners.
1406 + *
1407 + * Call it from the specific transition sites — not from a global
1408 + * `yatra_booking_status_changed` listener — so the OTA path, which already
1409 + * fires this action itself, cannot double-fire.
1410 + *
1411 + * @param int $bookingId Booking ID.
1412 + * @param string $previousStatus Status before the transition.
1413 + */
1414 +function yatra_trigger_booking_cancelled(int $bookingId, string $previousStatus): void
1415 +{
1416 + if ($bookingId < 1 || $previousStatus === 'cancelled') {
1417 + return;
1418 + }
1419 +
1420 + $repo = new \Yatra\Repositories\BookingRepository();
1421 + $booking = $repo->findWithTrip($bookingId);
1422 +
1423 + // Only announce a cancellation that actually stuck.
1424 + if (!$booking || ($booking->status ?? '') !== 'cancelled') {
1425 + return;
1426 + }
1427 +
1428 + /**
1429 + * Booking reached cancelled status (was not cancelled before this transition).
1430 + *
1431 + * @param int $bookingId Booking ID.
1432 + * @param object $booking Row from {@see \Yatra\Repositories\BookingRepository::findWithTrip()}.
1433 + */
1434 + do_action('yatra_booking_cancelled', $bookingId, $booking);
1435 +}
1436 +
1437 +/**
1438 + * Resolve the "Auto-Confirm Bookings" mode.
1439 + *
1440 + * Modes:
1441 + * - 'none' — never auto-confirm; every booking stays pending for manual review.
1442 + * - 'online' — auto-confirm only when a successful ONLINE gateway payment
1443 + * (Stripe, PayPal, Razorpay, …) settles the balance in full.
1444 + * Deposits / partial payments and offline methods (bank transfer,
1445 + * pay-later) stay pending.
1446 + * - 'all' — auto-confirm every booking at checkout, paid or not.
1447 + *
1448 + * No migration is stored: the value is resolved on the fly. When the operator
1449 + * has never chosen a mode (no `yatra_auto_confirm_mode` option), we derive it
1450 + * from the legacy boolean `auto_confirm_bookings` so each site keeps its ACTUAL
1451 + * behaviour from the released (buggy) version:
1452 + * - true → 'all' (it confirmed every booking at checkout)
1453 + * - false → 'online' (online payments auto-confirmed anyway — that was the
1454 + * bug — while offline methods stayed pending)
1455 + * The first time the operator saves the setting, the chosen mode is stored and
1456 + * becomes authoritative. New installs default to 'online' (see the default in
1457 + * SettingsController / SettingsService).
1458 + *
1459 + * @return string One of: none | online | all.
1460 + */
1461 +function yatra_get_auto_confirm_mode(): string
1462 +{
1463 + $raw = get_option('yatra_auto_confirm_mode', null);
1464 + if (is_string($raw)) {
1465 + $mode = strtolower(trim($raw));
1466 + if (in_array($mode, ['none', 'online', 'all'], true)) {
1467 + return $mode;
1468 + }
1469 + }
1470 +
1471 + // Never configured: preserve the site's experienced behaviour.
1472 + return \Yatra\Services\SettingsService::isEnabled('auto_confirm_bookings') ? 'all' : 'online';
1473 +}
1474 +
1475 +/**
1476 + * How far ahead (in months) the storefront lets customers see and book dates.
1477 + *
1478 + * Reads `availability_horizon_months` (Settings → Booking). The default, 12, is
1479 + * the value that was hard-coded before it became configurable, so a site that
1480 + * never touches the setting behaves exactly as before. Anything outside 1–36
1481 + * falls back to 12 rather than blanking the calendar. Callers that pass their
1482 + * own explicit date range (REST `to_date`, the OTA inventory sync, admin
1483 + * previews) are not affected by this at all.
1484 + *
1485 + * Developers can adjust the horizon per request:
1486 + *
1487 + * add_filter('yatra_availability_horizon_months', fn($m) => is_page('summer') ? 6 : $m);
1488 + *
1489 + * @return int Months, 1–36.
1490 + */
1491 +function yatra_get_availability_horizon_months(): int
1492 +{
1493 + $months = (int) \Yatra\Services\SettingsService::getInt('availability_horizon_months', 12);
1494 + if ($months < 1 || $months > 36) {
1495 + $months = 12;
1496 + }
1497 +
1498 + /**
1499 + * Filter the storefront booking horizon.
1500 + *
1501 + * @param int $months Horizon in months (1–36).
1502 + */
1503 + $filtered = (int) apply_filters('yatra_availability_horizon_months', $months);
1504 +
1505 + return ($filtered < 1 || $filtered > 36) ? $months : $filtered;
1506 +}
1507 +
1508 +/**
1509 + * The last date (Y-m-d) the storefront offers: the start date plus the horizon.
1510 + *
1511 + * Mirrors the `date('Y-m-d', strtotime('+12 months'))` expression the callers
1512 + * used before, so with the default setting the result is byte-identical.
1513 + *
1514 + * @param string|null $fromDate Start date (Y-m-d). Defaults to today.
1515 + * @return string Y-m-d.
1516 + */
1517 +function yatra_get_availability_horizon_date(?string $fromDate = null): string
1518 +{
1519 + $base = ($fromDate !== null && $fromDate !== '' && strtotime($fromDate) !== false)
1520 + ? (int) strtotime($fromDate)
1521 + : time();
1522 + $ts = strtotime('+' . yatra_get_availability_horizon_months() . ' months', $base);
1523 +
1524 + return date('Y-m-d', $ts !== false ? $ts : (int) strtotime('+12 months', $base));
1525 +}
1526 +
1527 +/**
1528 + * Decide whether a successful payment should auto-confirm the booking.
1529 + *
1530 + * This runs on the ONLINE payment-completion path. It confirms when the
1531 + * "Auto-Confirm Bookings" mode is 'all', or when the mode is 'online' AND the
1532 + * payment settles the balance in full ($fullyPaid). Mode 'none' — and an
1533 + * 'online' deposit / partial payment — leaves the booking `pending`.
1534 + *
1535 + * The $fullyPaid flag is still passed to the `yatra_confirm_booking_on_payment`
1536 + * filter so an operator who wants the older "confirm once fully paid" behaviour
1537 + * can opt back in without touching core:
1538 + *
1539 + * add_filter('yatra_confirm_booking_on_payment',
1540 + * function ($shouldConfirm, $fullyPaid) { return $shouldConfirm || $fullyPaid; }, 10, 2);
1541 + *
1542 + * @param bool $fullyPaid Whether the booking's balance is now zero.
1543 + * @param int $bookingId Booking ID (passed to the filter for context).
1544 + * @return bool True to set the booking to `confirmed`.
1545 + */
1546 +function yatra_should_confirm_booking_on_payment(bool $fullyPaid, int $bookingId = 0): bool
1547 +{
1548 + $mode = yatra_get_auto_confirm_mode();
1549 + // 'all' -> always confirm on a successful payment.
1550 + // 'online' -> confirm only when the payment settles the balance in full;
1551 + // a deposit / partial online payment leaves it pending until
1552 + // the balance is paid.
1553 + // 'none' -> never.
1554 + $shouldConfirm = ($mode === 'all') || ($mode === 'online' && $fullyPaid);
1555 + // Backward compatibility for the filter's 4th argument: since 3.0.10 it has
1556 + // been the old on/off toggle's value. The toggle maps on → 'all' and
1557 + // off → 'online', so only 'all' may report true here — a legacy-off site
1558 + // (now 'online') must keep handing existing callbacks `false`. Read the
1559 + // full mode with yatra_get_auto_confirm_mode() instead of this flag.
1560 + $autoConfirm = ($mode === 'all');
1561 +
1562 + /**
1563 + * Filter whether a completed payment auto-confirms the booking.
1564 + *
1565 + * @param bool $shouldConfirm Default: true for mode 'all', or mode 'online' when $fullyPaid.
1566 + * @param bool $fullyPaid Whether the balance is now zero.
1567 + * @param int $bookingId Booking ID.
1568 + * @param bool $autoConfirm The old on/off toggle's value — true only for mode
1569 + * 'all' (unchanged meaning for callbacks written
1570 + * against 3.0.10–3.0.14). Use yatra_get_auto_confirm_mode()
1571 + * to distinguish 'online' from 'none'.
1572 + */
1573 + return (bool) apply_filters('yatra_confirm_booking_on_payment', $shouldConfirm, $fullyPaid, $bookingId, $autoConfirm);
1574 +}
1575 +
1576 +/**
1577 + * Determine whether a `yatra_payment_completed` payment settled the balance in
1578 + * full, from the raw action args.
1579 + *
1580 + * `yatra_payment_completed` fires with either an array payload carrying
1581 + * `booking_id`, or the ($bookingId, $gateway, $txnId, $array) signature — so we
1582 + * sniff the id out of the args, then read the booking's current `amount_due`.
1583 + * Uses the same `amount_due <= 0` test as the transactional payment email and
1584 + * the Email Automation event, so every channel agrees on partial vs full.
1585 + *
1586 + * @param array<int, mixed> $hookArgs Raw args the action passed.
1587 + * @return bool|null True = paid in full, false = partial/deposit, null = unknown.
1588 + */
1589 +function yatra_payment_completed_is_full(array $hookArgs): ?bool
1590 +{
1591 + $bookingId = 0;
1592 + foreach ($hookArgs as $a) {
1593 + if (is_array($a) && (int) ($a['booking_id'] ?? 0) > 0) {
1594 + $bookingId = (int) $a['booking_id'];
1595 + break;
1596 + }
1597 + if ($bookingId === 0 && is_numeric($a)) {
1598 + $bookingId = (int) $a;
1599 + }
1600 + }
1601 +
1602 + if ($bookingId < 1) {
1603 + return null;
1604 + }
1605 +
1606 + $booking = (new \Yatra\Repositories\BookingRepository())->find($bookingId);
1607 + if (!$booking) {
1608 + return null;
1609 + }
1610 +
1611 + return (float) ($booking->amount_due ?? 0) <= 0;
1612 +}
1613 +
1614 +/**
1615 + * Gate for the split payment events (`payment.received` = full,
1616 + * `payment.partial_received` = deposit) which both bind to
1617 + * `yatra_payment_completed`. Returns true when the given event should be
1618 + * delivered for this payment, so notification dispatchers (webhooks, WhatsApp)
1619 + * fire only the matching one. Non-payment events are never gated.
1620 + *
1621 + * @param array<int, mixed> $hookArgs Raw args the action passed.
1622 + */
1623 +function yatra_payment_event_applies(string $eventKey, array $hookArgs): bool
1624 +{
1625 + if ($eventKey !== 'payment.received' && $eventKey !== 'payment.partial_received') {
1626 + return true;
1627 + }
1628 +
1629 + $isFull = yatra_payment_completed_is_full($hookArgs);
1630 + if ($isFull === null) {
1631 + // Can't determine the balance — deliver the "received" (full) event and
1632 + // suppress the partial one, matching the historical default.
1633 + $isFull = true;
1634 + }
1635 +
1636 + return $eventKey === 'payment.received' ? $isFull : !$isFull;
1637 +}
1638 +
1639 +/**
1123 1640 * ============================================
1124 1641 * REMAINING PAYMENT SESSION MANAGEMENT
1125 1642 * ============================================
1126 1643 */
@@ -1297,9 +1814,13 @@
1297 1814 $booking_base = trim((string) SettingsService::getBookingBase(), '/');
1298 1815 if ($booking_base === '') {
1299 1816 $booking_base = 'book';
1300 1817 }
1301 - $virtual_base = home_url('/' . $booking_base . '/confirmation/');
1818 + $confirmSeg = trim((string) SettingsService::getPermalinkBases()['booking_flow_confirmation_segment'], '/');
1819 + if ($confirmSeg === '') {
1820 + $confirmSeg = 'confirmation';
1821 + }
1822 + $virtual_base = home_url('/' . $booking_base . '/' . $confirmSeg . '/');
1302 1823
1303 1824 /**
1304 1825 * Override the base URL for booking confirmation (before the reference path segment).
1305 1826 * Return a non-empty string to use a custom base (e.g. get_permalink( $page_id )).
@@ -1349,9 +1870,10 @@
1349 1870
1350 1871 if ($is_plain) {
1351 1872 $url = add_query_arg('yatra_verify_email', $t, home_url('/'));
1352 1873 } else {
1353 - $url = trailingslashit(home_url('/yatra-verify-email/' . $t . '/'));
1874 + $prefix = SettingsService::getPermalinkBases()['email_verification_prefix'];
1875 + $url = trailingslashit(home_url('/' . $prefix . '/' . $t . '/'));
1354 1876 }
1355 1877
1356 1878 /**
1357 1879 * Filter the customer email verification URL.
@@ -1622,8 +2144,10 @@
1622 2144 * @return string Destination permalink URL
1623 2145 */
1624 2146 function yatra_get_destination_permalink($destination): string
1625 2147 {
2148 + $original = $destination;
2149 +
1626 2150 if (is_numeric($destination)) {
1627 2151 global $wpdb;
1628 2152 $table = ClassificationsTable::getTableName();
1629 2153 $destination = $wpdb->get_row($wpdb->prepare(
@@ -1631,16 +2155,16 @@
1631 2155 (int) $destination,
1632 2156 ClassificationTypes::DESTINATION
1633 2157 ));
1634 2158 }
1635 -
2159 +
1636 2160 $slug = is_object($destination) ? ($destination->slug ?? '') : '';
1637 -
2161 +
1638 2162 if (empty($slug)) {
1639 2163 return '';
1640 2164 }
1641 -
1642 - $base = SettingsService::getString('destination_base', 'destination');
2165 +
2166 + $base = SettingsService::getDestinationBase();
1643 2167 $permalink_structure = get_option('permalink_structure');
1644 2168 $is_plain = empty($permalink_structure);
1645 2169
1646 2170 if ($is_plain) {
@@ -1645,12 +2169,15 @@
1645 2169
1646 2170 if ($is_plain) {
1647 2171 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'destination';
1648 2172
1649 - return add_query_arg([$key => $slug], home_url('/'));
2173 + $url = add_query_arg([$key => $slug], home_url('/'));
2174 + } else {
2175 + $url = home_url('/' . $base . '/' . $slug . '/');
1650 2176 }
1651 2177
1652 - return home_url('/' . $base . '/' . $slug . '/');
2178 + /** @var string $url Override full destination URL or path (plain/pretty handled above). Third arg: slug. */
2179 + return (string) apply_filters('yatra_destination_permalink', $url, $original, $slug);
1653 2180 }
1654 2181
1655 2182 /**
1656 2183 * Get activity permalink
@@ -1659,8 +2186,10 @@
1659 2186 * @return string Activity permalink URL
1660 2187 */
1661 2188 function yatra_get_activity_permalink($activity): string
1662 2189 {
2190 + $original = $activity;
2191 +
1663 2192 if (is_numeric($activity)) {
1664 2193 global $wpdb;
1665 2194 $table = ClassificationsTable::getTableName();
1666 2195 $activity = $wpdb->get_row($wpdb->prepare(
@@ -1668,16 +2197,16 @@
1668 2197 (int) $activity,
1669 2198 ClassificationTypes::ACTIVITY
1670 2199 ));
1671 2200 }
1672 -
2201 +
1673 2202 $slug = is_object($activity) ? ($activity->slug ?? '') : '';
1674 -
2203 +
1675 2204 if (empty($slug)) {
1676 2205 return '';
1677 2206 }
1678 -
1679 - $base = SettingsService::getString('activity_base', 'activity');
2207 +
2208 + $base = SettingsService::getActivityBase();
1680 2209 $permalink_structure = get_option('permalink_structure');
1681 2210 $is_plain = empty($permalink_structure);
1682 2211
1683 2212 if ($is_plain) {
@@ -1682,12 +2211,15 @@
1682 2211
1683 2212 if ($is_plain) {
1684 2213 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'activity';
1685 2214
1686 - return add_query_arg([$key => $slug], home_url('/'));
2215 + $url = add_query_arg([$key => $slug], home_url('/'));
2216 + } else {
2217 + $url = home_url('/' . $base . '/' . $slug . '/');
1687 2218 }
1688 2219
1689 - return home_url('/' . $base . '/' . $slug . '/');
2220 + /** @var string $url Override full activity URL. Third arg: slug. */
2221 + return (string) apply_filters('yatra_activity_permalink', $url, $original, $slug);
1690 2222 }
1691 2223
1692 2224 /**
1693 2225 * Get trip category permalink
@@ -1696,8 +2228,10 @@
1696 2228 * @return string Category permalink URL
1697 2229 */
1698 2230 function yatra_get_category_permalink($category): string
1699 2231 {
2232 + $original = $category;
2233 +
1700 2234 if (is_numeric($category)) {
1701 2235 global $wpdb;
1702 2236 $table = ClassificationsTable::getTableName();
1703 2237 $category = $wpdb->get_row($wpdb->prepare(
@@ -1705,16 +2239,16 @@
1705 2239 (int) $category,
1706 2240 ClassificationTypes::CATEGORY
1707 2241 ));
1708 2242 }
1709 -
2243 +
1710 2244 $slug = is_object($category) ? ($category->slug ?? '') : '';
1711 -
2245 +
1712 2246 if (empty($slug)) {
1713 2247 return '';
1714 2248 }
1715 -
1716 - $base = SettingsService::getString('trip_category_base', 'trip-category');
2249 +
2250 + $base = SettingsService::getTripCategoryBase();
1717 2251 $permalink_structure = get_option('permalink_structure');
1718 2252 $is_plain = empty($permalink_structure);
1719 2253
1720 2254 if ($is_plain) {
@@ -1719,12 +2253,15 @@
1719 2253
1720 2254 if ($is_plain) {
1721 2255 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip-category';
1722 2256
1723 - return add_query_arg([$key => $slug], home_url('/'));
2257 + $url = add_query_arg([$key => $slug], home_url('/'));
2258 + } else {
2259 + $url = home_url('/' . $base . '/' . $slug . '/');
1724 2260 }
1725 2261
1726 - return home_url('/' . $base . '/' . $slug . '/');
2262 + /** @var string $url Override full trip-category URL. Third arg: slug. */
2263 + return (string) apply_filters('yatra_category_permalink', $url, $original, $slug);
1727 2264 }
1728 2265
1729 2266 /**
1730 2267 * Get trip permalink
@@ -1733,8 +2270,10 @@
1733 2270 * @return string Trip permalink URL
1734 2271 */
1735 2272 function yatra_get_trip_permalink($trip): string
1736 2273 {
2274 + $original = $trip;
2275 +
1737 2276 if (is_numeric($trip)) {
1738 2277 global $wpdb;
1739 2278 $table = TripsTable::getTableName();
1740 2279 $trip = $wpdb->get_row($wpdb->prepare(
@@ -1741,26 +2280,29 @@
1741 2280 "SELECT slug FROM {$table} WHERE id = %d",
1742 2281 (int) $trip
1743 2282 ));
1744 2283 }
1745 -
2284 +
1746 2285 $slug = is_object($trip) ? ($trip->slug ?? '') : '';
1747 -
2286 +
1748 2287 if (empty($slug)) {
1749 2288 return '';
1750 2289 }
1751 -
2290 +
1752 2291 $base = SettingsService::getTripBase();
1753 2292 $permalink_structure = get_option('permalink_structure');
1754 2293 $is_plain = empty($permalink_structure);
1755 -
2294 +
1756 2295 if ($is_plain) {
1757 2296 $key = preg_replace('/[^a-z0-9_-]/i', '', $base) ?: 'trip';
1758 2297
1759 - return add_query_arg([$key => $slug], home_url('/'));
2298 + $url = add_query_arg([$key => $slug], home_url('/'));
2299 + } else {
2300 + $url = home_url('/' . $base . '/' . $slug . '/');
1760 2301 }
1761 -
1762 - return home_url('/' . $base . '/' . $slug . '/');
2302 +
2303 + /** @var string $url Override full trip URL. Third arg: slug. */
2304 + return (string) apply_filters('yatra_trip_permalink', $url, $original, $slug);
1763 2305 }
1764 2306
1765 2307 /**
1766 2308 * Canonical URL for the trip archive / filter listing (respects Settings trip base).
@@ -1789,11 +2331,11 @@
1789 2331 */
1790 2332 function yatra_get_taxonomy_listing_url(string $listing_type): string
1791 2333 {
1792 2334 $map = [
1793 - 'destination' => SettingsService::getString('destination_base', 'destination'),
1794 - 'activity' => SettingsService::getString('activity_base', 'activity'),
1795 - 'category' => SettingsService::getString('trip_category_base', 'trip-category'),
2335 + 'destination' => SettingsService::getDestinationBase(),
2336 + 'activity' => SettingsService::getActivityBase(),
2337 + 'category' => SettingsService::getTripCategoryBase(),
1796 2338 ];
1797 2339 $base = $map[$listing_type] ?? '';
1798 2340 $base = preg_replace('/[^a-zA-Z0-9_-]/', '', (string) $base) ?: 'destination';
1799 2341 $permalink_structure = (string) get_option('permalink_structure', '');
@@ -2669,9 +3211,21 @@
2669 3211 if ( ! function_exists( 'yatra_get_header' ) ) {
2670 3212
2671 3213 function yatra_get_header( $header_name = null ) {
2672 3214 global $wp_version;
3215 +
3216 + // When the template is being rendered as the body of the yatra/page-content
3217 + // server block inside a block-template canvas, the canvas already emits the
3218 + // doctype/html/head/body and the site header template part. Re-emitting them
3219 + // here would nest <html>/<body> and duplicate the header — so we no-op.
2673 3220 if (
3221 + class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
3222 + && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
3223 + ) {
3224 + return;
3225 + }
3226 +
3227 + if (
2674 3228 version_compare( $wp_version, '5.9', '>=' ) &&
2675 3229 function_exists( 'wp_is_block_theme' ) &&
2676 3230 wp_is_block_theme()
2677 3231 ) {
@@ -2733,8 +3287,19 @@
2733 3287 if ( ! function_exists( 'yatra_get_footer' ) ) {
2734 3288
2735 3289 function yatra_get_footer( $footer_name = null ) {
2736 3290 global $wp_version;
3291 +
3292 + // Mirror of yatra_get_header(): when rendered inside the FSE canvas via
3293 + // the yatra/page-content block, the canvas already emits the footer
3294 + // template part and closes <body>/<html>. No-op here to avoid duplicates.
3295 + if (
3296 + class_exists( '\\Yatra\\Core\\Template\\FseTemplates' )
3297 + && \Yatra\Core\Template\FseTemplates::isRenderingInsideCanvas()
3298 + ) {
3299 + return;
3300 + }
3301 +
2737 3302 if (
2738 3303 version_compare( $wp_version, '5.9', '>=' ) &&
2739 3304 function_exists( 'wp_is_block_theme' ) &&
2740 3305 wp_is_block_theme()