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 +543 -26 3.0.4 → 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.
@@ -1116,8 +1393,248 @@
1116 1393 * @param int $bookingId Booking ID.
1117 1394 * @param object $booking Row from {@see \Yatra\Repositories\BookingRepository::findWithTrip()}.
1118 1395 */
1119 1396 do_action('yatra_booking_confirmed', $bookingId, $booking);
1397 +}
1398 +
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;
1120 1637 }
1121 1638
1122 1639 /**
1123 1640 * ============================================