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 +404 -7 3.0.8 → 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,15 +67,16 @@
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 /**
@@ -475,10 +482,21 @@
475 482 * @param int|null $nights Number of nights (optional)
476 483 * @return string Formatted duration
477 484 */
478 485 if (!function_exists('yatra_format_duration')) {
479 - function yatra_format_duration(int $days, ?int $nights = null): string
486 + function yatra_format_duration(int $days, ?int $nights = null, ?int $hours = null): string
480 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 +
481 499 if ($days > 0 && $nights !== null && $nights > 0) {
482 500 /* translators: 1: number of days, 2: number of nights. */
483 501 return sprintf(__('%1$d days / %2$d nights', 'yatra'), $days, $nights);
484 502 }
@@ -1019,8 +1037,127 @@
1019 1037 return is_array($value) ? $value : [];
1020 1038 }
1021 1039
1022 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,
1073 + ];
1074 +}
1075 +
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;
1100 + }
1101 + }
1102 +
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 + }
1110 +
1111 + return ucwords(str_replace(['_', '-'], ' ', $id));
1112 +}
1113 +
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);
1129 +}
1130 +
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 +/**
1023 1160 * Branded plugin name shown in admin menu, plugin list, and PDFs.
1024 1161 */
1025 1162 function yatra_get_brand_name(): string
1026 1163 {
@@ -1213,12 +1350,23 @@
1213 1350 * Core always fired `yatra_booking_status_changed`; Pro modules (Trip Consent, Google Calendar) listen
1214 1351 * on this dedicated action. Call this after any code path that sets a booking to `confirmed` without
1215 1352 * going through {@see \Yatra\Services\BookingService::updateStatus()}.
1216 1353 *
1217 - * @param int $bookingId Booking ID.
1218 - * @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().
1219 1367 */
1220 -function yatra_trigger_booking_confirmed(int $bookingId, string $previousStatus): void
1368 +function yatra_trigger_booking_confirmed(int $bookingId, string $previousStatus, bool $fromDirectConfirm = false): void
1221 1369 {
1222 1370 if ($bookingId < 1 || $previousStatus === 'confirmed') {
1223 1371 return;
1224 1372 }
@@ -1229,8 +1377,17 @@
1229 1377 if (!$booking || ($booking->status ?? '') !== 'confirmed') {
1230 1378 return;
1231 1379 }
1232 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 +
1233 1390 /**
1234 1391 * Booking reached confirmed status (was not confirmed before this transition).
1235 1392 *
1236 1393 * @param int $bookingId Booking ID.
@@ -1236,8 +1393,248 @@
1236 1393 * @param int $bookingId Booking ID.
1237 1394 * @param object $booking Row from {@see \Yatra\Repositories\BookingRepository::findWithTrip()}.
1238 1395 */
1239 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;
1240 1637 }
1241 1638
1242 1639 /**
1243 1640 * ============================================