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 | app/Controllers/TripController.php +355 -107 3.0.2.6 → 3.0.16 View file →
@@ -61,23 +61,23 @@
61 61 register_rest_route($namespace, '/' . $base, [
62 62 [
63 63 'methods' => \WP_REST_Server::READABLE,
64 64 'callback' => [$this, 'get_items'],
65 - 'permission_callback' => [$this, 'check_read_permission'],
65 + 'permission_callback' => [$this, 'check_view_permission'],
66 66 ],
67 67 [
68 68 'methods' => \WP_REST_Server::CREATABLE,
69 69 'callback' => [$this, 'create_item'],
70 - 'permission_callback' => [$this, 'check_permission'],
70 + 'permission_callback' => [$this, 'check_create_permission'],
71 71 ],
72 72 ]);
73 73
74 - // Duplicate trip: POST /trips/{id}/duplicate
74 + // Duplicate is a create — produces a new trip row.
75 75 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/duplicate', [
76 76 [
77 77 'methods' => \WP_REST_Server::CREATABLE,
78 78 'callback' => [$this, 'duplicate_item'],
79 - 'permission_callback' => [$this, 'check_permission'],
79 + 'permission_callback' => [$this, 'check_create_permission'],
80 80 ],
81 81 ]);
82 82
83 83 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
@@ -83,46 +83,54 @@
83 83 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)', [
84 84 [
85 85 'methods' => \WP_REST_Server::READABLE,
86 86 'callback' => [$this, 'get_item'],
87 - 'permission_callback' => [$this, 'check_read_permission'],
87 + 'permission_callback' => [$this, 'check_view_permission'],
88 88 ],
89 89 [
90 + // EDITABLE covers both content edits AND publish/unpublish
91 + // state changes (the React form sends both via PUT).
92 + // We accept either edit OR publish cap — handlers should
93 + // refuse to change `status` when the user holds only the
94 + // edit cap, but the route gate lets both through.
90 95 'methods' => \WP_REST_Server::EDITABLE,
91 96 'callback' => [$this, 'update_item'],
92 - 'permission_callback' => [$this, 'check_permission'],
97 + 'permission_callback' => [$this, 'check_edit_or_publish_permission'],
93 98 ],
94 99 [
100 + // Soft-delete (trash) → edit cap. Trash is reversible
101 + // and is the day-to-day "remove from catalogue" action.
95 102 'methods' => \WP_REST_Server::DELETABLE,
96 103 'callback' => [$this, 'delete_item'],
97 - 'permission_callback' => [$this, 'check_permission'],
104 + 'permission_callback' => [$this, 'check_edit_permission'],
98 105 ],
99 106 ]);
100 107
101 - // Permanent delete endpoint
108 + // Permanent delete — bypasses trash. High-sensitivity action,
109 + // gated on the dedicated delete cap.
102 110 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/permanent-delete', [
103 111 [
104 112 'methods' => \WP_REST_Server::DELETABLE,
105 113 'callback' => [$this, 'permanent_delete_item'],
106 - 'permission_callback' => [$this, 'check_permission'],
114 + 'permission_callback' => [$this, 'check_delete_permission'],
107 115 ],
108 116 ]);
109 117
110 - // Search endpoint
118 + // Search endpoint — view cap.
111 119 register_rest_route($namespace, '/' . $base . '/search', [
112 120 [
113 121 'methods' => \WP_REST_Server::READABLE,
114 122 'callback' => [$this, 'search_items'],
115 - 'permission_callback' => [$this, 'check_read_permission'],
123 + 'permission_callback' => [$this, 'check_view_permission'],
116 124 ],
117 125 ]);
118 126
119 - // Revisions endpoints
127 + // Revisions list — view cap.
120 128 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions', [
121 129 [
122 130 'methods' => \WP_REST_Server::READABLE,
123 131 'callback' => [$this, 'get_revisions'],
124 - 'permission_callback' => [$this, 'check_read_permission'],
132 + 'permission_callback' => [$this, 'check_view_permission'],
125 133 ],
126 134 ]);
127 135
128 136 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions/(?P<revision_id>[\d]+)', [
@@ -128,14 +136,15 @@
128 136 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/revisions/(?P<revision_id>[\d]+)', [
129 137 [
130 138 'methods' => \WP_REST_Server::READABLE,
131 139 'callback' => [$this, 'get_revision'],
132 - 'permission_callback' => [$this, 'check_read_permission'],
140 + 'permission_callback' => [$this, 'check_view_permission'],
133 141 ],
134 142 [
143 + // Restoring a revision overwrites the live trip → edit cap.
135 144 'methods' => \WP_REST_Server::EDITABLE,
136 145 'callback' => [$this, 'restore_revision'],
137 - 'permission_callback' => [$this, 'check_permission'],
146 + 'permission_callback' => [$this, 'check_edit_permission'],
138 147 ],
139 148 ]);
140 149
141 150 // Availability template endpoint (public, no auth required)
@@ -165,34 +174,35 @@
165 174 'permission_callback' => '__return_true', // Public endpoint
166 175 ],
167 176 ]);
168 177
169 - // Status statistics for admin views
178 + // Status statistics for admin views — view cap.
170 179 register_rest_route($namespace, '/' . $base . '/stats', [
171 180 [
172 181 'methods' => \WP_REST_Server::READABLE,
173 182 'callback' => [$this, 'getStats'],
174 - 'permission_callback' => [$this, 'check_permission'],
183 + 'permission_callback' => [$this, 'check_view_permission'],
175 184 ],
176 185 ]);
177 186
178 - // Trip attributes endpoints (admin only — never expose unauthenticated read/write)
187 + // Test endpoint — view cap (read-only diagnostic).
179 188 register_rest_route($namespace, '/' . $base . '/test', [
180 189 'methods' => \WP_REST_Server::READABLE,
181 190 'callback' => [$this, 'test_endpoint'],
182 - 'permission_callback' => [$this, 'check_permission'],
191 + 'permission_callback' => [$this, 'check_view_permission'],
183 192 ]);
184 193
194 + // Trip-attribute assignments — trip-taxonomy edits go here.
185 195 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes', [
186 196 [
187 197 'methods' => \WP_REST_Server::READABLE,
188 198 'callback' => [$this, 'get_trip_attributes'],
189 - 'permission_callback' => [$this, 'check_read_permission'],
199 + 'permission_callback' => [$this, 'check_view_permission'],
190 200 ],
191 201 [
192 202 'methods' => \WP_REST_Server::CREATABLE,
193 203 'callback' => [$this, 'update_trip_attributes'],
194 - 'permission_callback' => [$this, 'check_permission'],
204 + 'permission_callback' => [$this, 'check_taxonomy_permission'],
195 205 ],
196 206 ]);
197 207
198 208 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes/(?P<attribute_id>[\d]+)', [
@@ -198,14 +208,58 @@
198 208 register_rest_route($namespace, '/' . $base . '/(?P<id>[\d]+)/attributes/(?P<attribute_id>[\d]+)', [
199 209 [
200 210 'methods' => \WP_REST_Server::DELETABLE,
201 211 'callback' => [$this, 'delete_trip_attribute'],
202 - 'permission_callback' => [$this, 'check_permission'],
212 + 'permission_callback' => [$this, 'check_taxonomy_permission'],
203 213 ],
204 214 ]);
205 215 }
206 216
207 217 /**
218 + * Granular cap checks for every Trip endpoint. Overrides the
219 + * BaseController defaults (which gate everything on `manage_options`
220 + * and locked out every yatra_* role from the trips REST surface).
221 + * WP admins pass via the Team module's admin-fallback filter.
222 + */
223 + public function check_view_permission(?WP_REST_Request $request = null): bool
224 + {
225 + return current_user_can('yatra_view_trips');
226 + }
227 +
228 + public function check_create_permission(?WP_REST_Request $request = null): bool
229 + {
230 + return current_user_can('yatra_create_trips');
231 + }
232 +
233 + public function check_edit_permission(?WP_REST_Request $request = null): bool
234 + {
235 + return current_user_can('yatra_edit_trips');
236 + }
237 +
238 + /**
239 + * EDITABLE / PUT routes that may carry either a content edit or a
240 + * status change pass when the caller holds EITHER cap. The actual
241 + * handler should refuse to change `status` when only `edit` is
242 + * held — that's a future hardening, but the route gate already
243 + * keeps non-trip-staff out.
244 + */
245 + public function check_edit_or_publish_permission(?WP_REST_Request $request = null): bool
246 + {
247 + return current_user_can('yatra_edit_trips')
248 + || current_user_can('yatra_publish_trips');
249 + }
250 +
251 + public function check_delete_permission(?WP_REST_Request $request = null): bool
252 + {
253 + return current_user_can('yatra_delete_trips');
254 + }
255 +
256 + public function check_taxonomy_permission(?WP_REST_Request $request = null): bool
257 + {
258 + return current_user_can('yatra_manage_trip_taxonomies');
259 + }
260 +
261 + /**
208 262 * Get statistics for admin trip views (status counts)
209 263 */
210 264 public function getStats(WP_REST_Request $request)
211 265 {
@@ -333,8 +387,17 @@
333 387 }, $items);
334 388 $tripIds = array_values(array_filter($tripIds));
335 389
336 390 if (!empty($tripIds)) {
391 + // Attach bookings_count computed from bookings table (trips.bookings_count is not reliably maintained)
392 + $bookingsCountMap = $this->service->getBookingsCountMap($tripIds);
393 + foreach ($items as $item) {
394 + $tId = isset($item->id) ? (int) $item->id : 0;
395 + if ($tId > 0) {
396 + $item->bookings_count = (int) ($bookingsCountMap[$tId] ?? 0);
397 + }
398 + }
399 +
337 400 // Destinations
338 401 $destByTrip = [];
339 402 foreach ($tripIds as $id) {
340 403 $destinations = $this->service->getTripDestinations($id);
@@ -608,17 +671,10 @@
608 671 if (isset($data['testimonial_review_ids'])) {
609 672 $data['testimonial_review_ids'] = is_string($data['testimonial_review_ids']) ? $data['testimonial_review_ids'] : wp_json_encode($data['testimonial_review_ids']);
610 673 }
611 674 if (isset($data['default_time_slots'])) {
612 - if (defined('WP_DEBUG') && WP_DEBUG) {
613 - error_log('Yatra DEBUG: default_time_slots received: ' . print_r($data['default_time_slots'], true));
614 - }
615 675 $data['default_time_slots'] = is_string($data['default_time_slots']) ? $data['default_time_slots'] : wp_json_encode($data['default_time_slots']);
616 - if (defined('WP_DEBUG') && WP_DEBUG) {
617 - error_log('Yatra DEBUG: default_time_slots after encoding: ' . $data['default_time_slots']);
618 - }
619 - } elseif (defined('WP_DEBUG') && WP_DEBUG) {
620 - error_log('Yatra DEBUG: default_time_slots NOT in request data');
676 +
621 677 }
622 678
623 679 // Handle featured_priority field (already in $data for update)
624 680 // Remove legacy/removed columns not present in trips table
@@ -886,10 +942,15 @@
886 942 */
887 943 public function restore_revision(WP_REST_Request $request)
888 944 {
889 945 try {
890 - // Check permissions
891 - if (!current_user_can('yatra_edit_trips')) {
946 + // Check permissions — admin fallback ensures site owners
947 + // always pass even when the Team module isn't active and
948 + // the yatra_edit_trips cap isn't on the admin role.
949 + if (
950 + !current_user_can('manage_options')
951 + && !current_user_can('yatra_edit_trips')
952 + ) {
892 953 return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403);
893 954 }
894 955
895 956 $id = (int) $request->get_param('id');
@@ -996,8 +1057,9 @@
996 1057 'transportation_included',
997 1058 'international_flights_included',
998 1059 'domestic_flights_included',
999 1060 'is_featured',
1061 + 'has_default_time_slots',
1000 1062 ];
1001 1063
1002 1064 foreach ($booleanFields as $field) {
1003 1065 if (isset($data[$field])) {
@@ -1267,8 +1329,20 @@
1267 1329 $images = is_array($decoded) ? $decoded : [];
1268 1330 }
1269 1331 }
1270 1332
1333 + // Decode gallery JSON column on the entry so the React form
1334 + // can re-populate the gallery picker without an extra fetch.
1335 + $gallery = [];
1336 + if (isset($entry->gallery)) {
1337 + if (is_array($entry->gallery)) {
1338 + $gallery = $entry->gallery;
1339 + } elseif (is_string($entry->gallery) && $entry->gallery !== '') {
1340 + $decoded = json_decode($entry->gallery, true);
1341 + $gallery = is_array($decoded) ? $decoded : [];
1342 + }
1343 + }
1344 +
1271 1345 return [
1272 1346 'id' => isset($entry->id) ? (int) $entry->id : null,
1273 1347 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null,
1274 1348 'time' => $entry->time ?? '',
@@ -1277,8 +1351,16 @@
1277 1351 'time_type' => $entry->time_type ?? 'exact',
1278 1352 'title' => $entry->title ?? '',
1279 1353 'description' => $entry->description ?? '',
1280 1354 'location' => $entry->location ?? '',
1355 + // The entries table has lat/lng/gallery/video_url + an `order`
1356 + // smallint column — but until this serializer included them, the
1357 + // /trips/{id} response never carried them. The React activity
1358 + // load mapper sorts by `entry.order`; without it, every entry
1359 + // arrived with order=null, the sort fell through to id-order,
1360 + // and drag-sort reorders never appeared to persist on reload.
1361 + 'location_latitude' => isset($entry->location_latitude) ? $entry->location_latitude : null,
1362 + 'location_longitude' => isset($entry->location_longitude) ? $entry->location_longitude : null,
1281 1363 'duration' => $entry->duration ?? '',
1282 1364 'cost' => isset($entry->cost) ? (float) $entry->cost : null,
1283 1365 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false,
1284 1366 'notes' => $entry->notes ?? '',
@@ -1284,8 +1366,11 @@
1284 1366 'notes' => $entry->notes ?? '',
1285 1367 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null,
1286 1368 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null,
1287 1369 'status' => $entry->status ?? 'active',
1370 + 'order' => isset($entry->order) ? (int) $entry->order : 0,
1371 + 'gallery' => $gallery,
1372 + 'video_url' => $entry->video_url ?? '',
1288 1373 'created_at' => $entry->created_at ?? '',
1289 1374 'updated_at' => $entry->updated_at ?? '',
1290 1375 'included_items' => $includedItems,
1291 1376 'excluded_items' => $excludedItems,
@@ -1430,18 +1515,16 @@
1430 1515
1431 1516 // Fetch availability dates using centralized resolution service
1432 1517 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
1433 1518
1434 - // Always show all dates from today onwards (selected_date is only for highlighting)
1519 + // Always show all dates from today onwards (selected_date is only for
1520 + // highlighting), up to the configurable booking horizon (Settings →
1521 + // Booking; 12 months unless changed — the previous hard-coded value).
1435 1522 $fromDate = date('Y-m-d');
1436 - $toDate = date('Y-m-d', strtotime('+12 months'));
1523 + $toDate = yatra_get_availability_horizon_date($fromDate);
1437 1524
1438 - $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate);
1525 + $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out'));
1439 1526
1440 - if (defined('WP_DEBUG') && WP_DEBUG) {
1441 - error_log('Yatra Availability Debug: Trip ID ' . $id . ' has ' . count($availability_dates) . ' availability dates from centralized service');
1442 - }
1443 -
1444 1527 // Determine if this is a day trip
1445 1528 $is_single_day = ($trip->duration_days ?? 1) <= 1;
1446 1529
1447 1530 // Auto-select month and date
@@ -1512,8 +1595,11 @@
1512 1595 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0,
1513 1596 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0,
1514 1597 'currency' => SettingsService::getCurrency(),
1515 1598 'duration_days' => isset($trip->duration_days) ? (int) $trip->duration_days : 1,
1599 + // Hour-based day tours show "8 Hours" instead of "1 Day" on the
1600 + // availability cards. 0 for every existing (day-based) trip.
1601 + 'duration_hours' => isset($trip->duration_hours) ? (int) $trip->duration_hours : 0,
1516 1602 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20,
1517 1603 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1,
1518 1604 'pricing_type' => $trip->pricing_type ?? 'regular',
1519 1605 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing
@@ -1653,8 +1739,36 @@
1653 1739
1654 1740 // Build cards from real availability data or use sample data
1655 1741 $availability_cards = [];
1656 1742 $month_filters = [];
1743 +
1744 + // Availability priority (same as the resolver):
1745 + // 1) manual availability dates, 2) recurring rules, 3) trip defaults.
1746 + // For UI counts + filters we want the list to reflect that priority (not a mixed set).
1747 + $availability_dates_for_render = $has_availability ? $trip_data->availability_dates : [];
1748 + if ($has_availability) {
1749 + $by_source = [
1750 + 'availability_date' => [],
1751 + 'recurring_rule' => [],
1752 + 'trip_default' => [],
1753 + ];
1754 + foreach ($trip_data->availability_dates as $a) {
1755 + if (!is_object($a)) {
1756 + continue;
1757 + }
1758 + $src = strtolower(trim((string) ($a->source ?? '')));
1759 + if (isset($by_source[$src])) {
1760 + $by_source[$src][] = $a;
1761 + }
1762 + }
1763 + if (!empty($by_source['availability_date'])) {
1764 + $availability_dates_for_render = $by_source['availability_date'];
1765 + } elseif (!empty($by_source['recurring_rule'])) {
1766 + $availability_dates_for_render = $by_source['recurring_rule'];
1767 + } elseif (!empty($by_source['trip_default'])) {
1768 + $availability_dates_for_render = $by_source['trip_default'];
1769 + }
1770 + }
1657 1771
1658 1772 // Determine if this is a day trip (duration <= 1 day)
1659 1773 $is_single_day = ($trip_data->duration_days ?? 1) <= 1;
1660 1774
@@ -1693,9 +1807,9 @@
1693 1807 $add_category_ids($trip_data->price_types);
1694 1808 }
1695 1809
1696 1810 if ($has_availability) {
1697 - foreach ($trip_data->availability_dates as $avail_for_cats) {
1811 + foreach ($availability_dates_for_render as $avail_for_cats) {
1698 1812 if (!empty($avail_for_cats->price_types)) {
1699 1813 $add_category_ids($avail_for_cats->price_types);
1700 1814 }
1701 1815 if (!empty($avail_for_cats->traveler_pricing)) {
@@ -1726,8 +1840,9 @@
1726 1840 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
1727 1841 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
1728 1842 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
1729 1843 'max_pax' => isset($meta['max_pax']) ? (int) $meta['max_pax'] : null,
1844 + 'group_overflow' => isset($meta['group_overflow']) && in_array($meta['group_overflow'], ['block', 'per_block'], true) ? $meta['group_overflow'] : 'block',
1730 1845 ];
1731 1846 }
1732 1847 }
1733 1848
@@ -1777,8 +1892,9 @@
1777 1892 if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min'];
1778 1893 if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max'];
1779 1894 if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax'];
1780 1895 if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax'];
1896 + $pt['group_overflow'] = $meta['group_overflow'] ?? 'block';
1781 1897 }
1782 1898
1783 1899 // Payable amount (honors price / sale_price / discounted_price like TripPricingService)
1784 1900 if (!isset($pt['effective_price'])) {
@@ -1804,13 +1920,19 @@
1804 1920
1805 1921 if (!empty($trip_data->price_types)) {
1806 1922 $trip_data->price_types = $enrich_price_types($trip_data->price_types);
1807 1923 }
1924 +
1925 + $dp_display_settings = apply_filters('yatra_get_dynamic_pricing_display_settings', [
1926 + 'show_original_price' => true,
1927 + 'show_savings_badge' => true,
1928 + 'show_urgency_messages' => false,
1929 + ]);
1808 1930
1809 1931 if ($has_availability) {
1810 1932 $current_time = time();
1811 -
1812 - foreach ($trip_data->availability_dates as $avail) {
1933 +
1934 + foreach ($availability_dates_for_render as $avail) {
1813 1935 if (empty($avail->departure_date)) {
1814 1936 // Skip entries without a valid departure date
1815 1937 continue;
1816 1938 }
@@ -1844,36 +1966,53 @@
1844 1966 // Store base prices before dynamic pricing
1845 1967 $base_original_price = $original_price;
1846 1968 $base_sale_price = $sale_price;
1847 1969
1848 - // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here)
1970 + // Apply dynamic pricing if enabled (Pro DynamicPricingModule hooks here).
1971 + // Single pass on the effective sale price; list/original stays for strikethrough. Context supplies both for "regular vs discounted" rule base.
1849 1972 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1850 1973 $dp_context = [
1851 1974 'departure_date' => $avail->departure_date ?? null,
1852 1975 'spots_remaining' => $seats,
1853 1976 'availability_id' => $avail->id ?? null,
1977 + 'original_price' => $base_original_price,
1978 + 'discounted_price' => $base_sale_price,
1854 1979 ];
1855 - $original_price = apply_filters('yatra_availability_price', $original_price, $trip_data->id, $dp_context);
1856 - $sale_price = apply_filters('yatra_availability_price', $sale_price, $trip_data->id, $dp_context);
1980 + $sale_price = apply_filters('yatra_availability_price', $base_sale_price, $trip_data->id, $dp_context);
1857 1981 }
1858 1982
1859 - // Calculate discount/surge pricing badge
1860 - $discount_percent = $cardPricing['discount_percentage'];
1861 - $discount_text = '';
1862 -
1863 - if ($discount_percent > 0) {
1864 - $discount_text = sprintf(__('%d%% OFF', 'yatra'), $discount_percent);
1983 + // Savings badge: surge vs pre-DP sale first when DP raises price; else total % off vs list
1984 + // (covers regular + traveler-based + date-level pricing; DP stacked on sale is reflected in final vs list).
1985 + $discount_text = $this->computeAvailabilitySavingsBadgeText(
1986 + $base_original_price,
1987 + $base_sale_price,
1988 + $sale_price,
1989 + (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
1990 + );
1991 +
1992 + // Dynamic Pricing → Display: hide savings / surge % badge on card when disabled.
1993 + if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
1994 + $discount_text = '';
1865 1995 }
1866 - // Check if dynamic pricing increased the price (surge)
1867 - elseif ($base_sale_price > 0 && $sale_price > $base_sale_price) {
1868 - $surge_percent = round((($sale_price - $base_sale_price) / $base_sale_price) * 100);
1869 - $discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1870 - }
1871 -
1996 +
1997 + $dp_card_fields = $this->buildAvailabilityDynamicPricingCardFields(
1998 + $dp_display_settings,
1999 + (int) $trip_data->id,
2000 + [
2001 + 'departure_date' => $avail->departure_date ?? null,
2002 + 'spots_remaining' => $seats,
2003 + 'availability_id' => $avail->id ?? null,
2004 + 'base_sale_price' => $base_sale_price,
2005 + 'base_original_price' => $base_original_price,
2006 + 'sale_price' => $sale_price,
2007 + 'original_price' => $original_price,
2008 + ]
2009 + );
2010 +
1872 2011 // Use month-based filters for both day trips and multi-day trips for better navigation
1873 2012 // This prevents overwhelming users with too many individual date filters
1874 2013 $month_key = strtolower(date('M-Y', $departure_date));
1875 - $month_filters[$month_key] = date('M Y', $departure_date);
2014 + $month_filters[$month_key] = date_i18n('M Y', $departure_date);
1876 2015
1877 2016 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
1878 2017 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
1879 2018
@@ -1880,53 +2019,53 @@
1880 2019 // For day trips, format time; for multi-day trips, format date
1881 2020 $departure_time = !empty($avail->departure_time) ? $avail->departure_time : null;
1882 2021 $arrival_time = !empty($avail->arrival_time) ? $avail->arrival_time : null;
1883 2022
1884 - // Format display strings based on trip type
2023 + // Format display strings based on trip type (respect Yatra Settings date/time formats)
2024 + $yatra_date_format = \Yatra\Services\SettingsService::getString('date_format', 'Y-m-d');
2025 + $yatra_time_format = \Yatra\Services\SettingsService::getString('time_format', 'H:i');
2026 +
1885 2027 if ($is_single_day && $departure_time) {
1886 2028 // Day trip: Show time as main value, date as sub-label
1887 - $from_display = date_i18n('g:i A', strtotime($departure_time)); // e.g., "9:00 AM"
1888 - $to_display = $arrival_time ? date_i18n('g:i A', strtotime($arrival_time)) : ''; // e.g., "5:00 PM"
1889 - $date_display = date_i18n('l, j M Y', $departure_date); // e.g., "Saturday, 30 Nov 2025"
2029 + $from_display = date_i18n($yatra_time_format, strtotime($departure_time)); // e.g., "14:30" or "2:30 PM"
2030 + $to_display = $arrival_time ? date_i18n($yatra_time_format, strtotime($arrival_time)) : '';
2031 + // Show day-trip header date using configured format
2032 + $date_display = date_i18n($yatra_date_format, $departure_date);
1890 2033 $from_label = __('Start', 'yatra');
1891 2034 $to_label = __('End', 'yatra');
1892 2035 } else {
1893 2036 // Multi-day trip: Show dates
1894 - $from_display = date_i18n('j M Y', $departure_date);
1895 - $to_display = date_i18n('j M Y', $return_date);
2037 + $from_display = date_i18n($yatra_date_format, $departure_date);
2038 + $to_display = date_i18n($yatra_date_format, $return_date);
1896 2039 $date_display = ''; // Not needed for multi-day
1897 2040 $from_label = __('Departure', 'yatra');
1898 2041 $to_label = __('Return', 'yatra');
1899 2042 }
1900 2043
2044 + // Per-card duration: derive from THIS card's departure→return span
2045 + // so the displayed "X Days" always matches the departure/return
2046 + // dates shown on the same card. When no custom arrival is stored,
2047 + // $return_date is departure + (duration_days - 1), so the span
2048 + // equals the trip's duration_days (no visible change). Only when an
2049 + // operator stored an arrival that disagrees with the trip default
2050 + // does this diverge — and then the customer sees a self-consistent
2051 + // card (e.g. "10 Days" over a Jun 25 → Jul 04 span) instead of a
2052 + // "9 Days" badge contradicting the dates. round() (not floor())
2053 + // absorbs any ±1h DST drift between two local-midnight timestamps.
2054 + $card_duration_days = $is_single_day
2055 + ? max(1, (int) ($trip_data->duration_days ?? 1))
2056 + : max(1, (int) round(($return_date - $departure_date) / DAY_IN_SECONDS) + 1);
2057 +
1901 2058 // Use month-based keys for filtering for both day trips and multi-day trips
1902 2059 $filter_key = strtolower(date('M-Y', $departure_date));
1903 2060
1904 - // pricing_type MODEL comes from trip level (regular vs traveler_based)
1905 - // Note: $avail->pricing_type enum is about price state, not pricing model
1906 - $card_pricing_type = $trip_data->pricing_type ?? 'regular';
1907 - if (!empty($avail->price_types) && is_array($avail->price_types) && count($avail->price_types) > 0) {
1908 - $card_pricing_type = 'traveler_based';
1909 - }
1910 -
1911 - // price_types come from centralized service (already resolved with priority: Rules → Dates → Trip)
2061 + // Must match {@see TripPricingService::resolveCardPricing}: trip-level mode wins; do not
2062 + // treat inherited stale price_types on a date as traveler-based when the trip is regular.
2063 + $card_pricing_type = $cardPricing['pricing_type'];
1912 2064 $card_traveler_pricing = [];
1913 - if (!empty($avail->price_types)) {
1914 - $card_traveler_pricing = is_array($avail->price_types) ? $avail->price_types : [];
1915 -
1916 - // Enrich with category labels if needed
1917 - if (!empty($card_traveler_pricing)) {
1918 - $card_traveler_pricing = $enrich_price_types($card_traveler_pricing);
1919 - }
1920 -
1921 - // Debug logging
1922 - if (defined('WP_DEBUG') && WP_DEBUG) {
1923 - error_log('Yatra Card traveler_pricing count: ' . count($card_traveler_pricing));
1924 - if (!empty($card_traveler_pricing[0])) {
1925 - $first = is_array($card_traveler_pricing[0]) ? $card_traveler_pricing[0] : (array) $card_traveler_pricing[0];
1926 - error_log('Yatra Card first category: ' . print_r($first, true));
1927 - }
1928 - }
2065 + $pts_for_card = $cardPricing['price_types'] ?? [];
2066 + if (!empty($pts_for_card) && is_array($pts_for_card)) {
2067 + $card_traveler_pricing = $enrich_price_types($pts_for_card);
1929 2068 }
1930 2069
1931 2070 $availability_cards[] = [
1932 2071 'id' => $avail->id,
@@ -1936,8 +2075,9 @@
1936 2075 'to_label' => $to_label,
1937 2076 'to_date' => $to_display,
1938 2077 'to_location' => $to_location,
1939 2078 'date_display' => $date_display, // For day trips: "Saturday, 30 Nov 2025"
2079 + 'duration_days' => $card_duration_days, // Inclusive span of THIS card's dates
1940 2080 'date' => $avail->departure_date, // Raw date for dynamic pricing
1941 2081 'spots_remaining' => $seats, // For dynamic pricing
1942 2082 'seats' => $seats > 10 ? '10+' : (string) $seats,
1943 2083 'seats_available' => $seats,
@@ -1960,11 +2100,13 @@
1960 2100 'is_sold_out' => $is_sold_out,
1961 2101 // Card-specific pricing
1962 2102 'pricing_type' => $card_pricing_type,
1963 2103 'traveler_pricing' => $card_traveler_pricing,
2104 + 'price_unit' => $cardPricing['price_unit'] ?? 'per_person',
2105 + 'price_unit_label' => $cardPricing['price_unit_label'] ?? __('per person', 'yatra'),
1964 2106 'is_recurring' => !empty($avail->is_recurring),
1965 2107 'rule_id' => $avail->rule_id ?? null,
1966 - ];
2108 + ] + $dp_card_fields;
1967 2109 }
1968 2110 }
1969 2111
1970 2112 // Use sample data only if no real availability
@@ -1977,33 +2119,44 @@
1977 2119 // Store base prices before dynamic pricing
1978 2120 $base_sample_original = $sample_original;
1979 2121 $base_sample_sale = $sample_sale;
1980 2122
1981 - // Apply dynamic pricing to sample card
2123 + // Apply dynamic pricing to sample card (sale line only; list price unchanged for display)
1982 2124 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1983 - $sample_original = apply_filters('yatra_availability_price', $sample_original, $trip_data->id, [
2125 + $sample_sale = apply_filters('yatra_availability_price', $base_sample_sale, $trip_data->id, [
1984 2126 'departure_date' => $sample_date,
1985 2127 'spots_remaining' => $sample_seats,
1986 2128 'availability_id' => 'sample-1',
2129 + 'original_price' => $base_sample_original,
2130 + 'discounted_price' => $base_sample_sale,
1987 2131 ]);
1988 - $sample_sale = apply_filters('yatra_availability_price', $sample_sale, $trip_data->id, [
2132 + }
2133 +
2134 + $sample_discount_text = $this->computeAvailabilitySavingsBadgeText(
2135 + $base_sample_original,
2136 + $base_sample_sale,
2137 + $sample_sale,
2138 + (bool) apply_filters('yatra_dynamic_pricing_enabled', false)
2139 + );
2140 +
2141 + if (is_array($dp_display_settings) && !filter_var($dp_display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN)) {
2142 + $sample_discount_text = '';
2143 + }
2144 +
2145 + $sample_dp_fields = $this->buildAvailabilityDynamicPricingCardFields(
2146 + $dp_display_settings,
2147 + (int) $trip_data->id,
2148 + [
1989 2149 'departure_date' => $sample_date,
1990 2150 'spots_remaining' => $sample_seats,
1991 2151 'availability_id' => 'sample-1',
1992 - ]);
1993 - }
2152 + 'base_sale_price' => $base_sample_sale,
2153 + 'base_original_price' => $base_sample_original,
2154 + 'sale_price' => $sample_sale,
2155 + 'original_price' => $sample_original,
2156 + ]
2157 + );
1994 2158
1995 - // Calculate discount/surge pricing badge for sample card
1996 - $sample_discount_text = '';
1997 - if ($base_sample_original > 0 && $base_sample_sale < $base_sample_original) {
1998 - $discount_percent = round((($base_sample_original - $base_sample_sale) / $base_sample_original) * 100);
1999 - $sample_discount_text = $discount_percent > 0 ? sprintf(__('%d%% OFF', 'yatra'), $discount_percent) : '';
2000 - }
2001 - elseif ($base_sample_sale > 0 && $sample_sale > $base_sample_sale) {
2002 - $surge_percent = round((($sample_sale - $base_sample_sale) / $base_sample_sale) * 100);
2003 - $sample_discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
2004 - }
2005 -
2006 2159 $availability_cards = [
2007 2160 [
2008 2161 'id' => 'sample-1',
2009 2162 'from_label' => __('Departure', 'yatra'),
@@ -2031,11 +2184,11 @@
2031 2184 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2032 2185 'traveler_pricing' => $trip_data->price_types ?? [],
2033 2186 'is_recurring' => false,
2034 2187 'rule_id' => null,
2035 - ],
2188 + ] + $sample_dp_fields,
2036 2189 ];
2037 - $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date('M Y', strtotime('+7 days'));
2190 + $month_filters[strtolower(date('M-Y', strtotime('+7 days')))] = date_i18n('M Y', strtotime('+7 days'));
2038 2191 }
2039 2192
2040 2193 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2041 2194
@@ -2088,8 +2241,96 @@
2088 2241
2089 2242 return $slice;
2090 2243 }
2091 2244
2245 + /**
2246 + * "% OFF" / "+%" badge for availability cards after dynamic pricing is applied to the sale line.
2247 + *
2248 + * - If dynamic pricing is on and the final price is above the pre-DP sale, show surge vs that sale (priority).
2249 + * - Otherwise, if list/original on the card is above the final price, show total % off vs list (trip/date
2250 + * discount + any extra DP discount in one number — never understates vs showing only the old catalog %).
2251 + * - If there is no list price but DP reduced the promo-only anchor, show % off vs that anchor.
2252 + *
2253 + * Works for regular, traveler-based (uses same header O/B/F from {@see TripPricingService::resolveCardPricing}),
2254 + * and availability date pricing (already in O/B from the card resolver).
2255 + */
2256 + private function computeAvailabilitySavingsBadgeText(
2257 + float $base_original_price,
2258 + float $base_sale_price,
2259 + float $final_sale_price,
2260 + bool $dynamic_pricing_enabled
2261 + ): string {
2262 + $O = max(0.0, $base_original_price);
2263 + $B = max(0.0, $base_sale_price);
2264 + $F = max(0.0, $final_sale_price);
2265 + $eps = 0.005;
2266 +
2267 + if ($dynamic_pricing_enabled && $B > $eps && $F > $B + $eps) {
2268 + $p = (int) round((($F - $B) / $B) * 100);
2269 +
2270 + /* translators: %d: dynamic pricing increase percentage. */
2271 + return $p > 0 ? sprintf(__('+%d%%', 'yatra'), $p) : '';
2272 + }
2273 +
2274 + if ($O > $eps && $F < $O - $eps) {
2275 + $p = (int) round((($O - $F) / $O) * 100);
2276 +
2277 + /* translators: %d: discount percentage. */
2278 + return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2279 + }
2280 +
2281 + if ($O <= $eps && $B > $eps && $F < $B - $eps) {
2282 + $p = (int) round((($B - $F) / $B) * 100);
2283 +
2284 + /* translators: %d: discount percentage. */
2285 + return $p > 0 ? sprintf(__('%d%% OFF', 'yatra'), $p) : '';
2286 + }
2287 +
2288 + return '';
2289 + }
2290 +
2291 + /**
2292 + * Per-departure-card dynamic pricing display flags + urgency lines (Pro fills via filter).
2293 + *
2294 + * @param array<string, mixed> $display_settings From yatra_get_dynamic_pricing_display_settings
2295 + * @param array<string, mixed> $context departure_date, spots_remaining, prices, availability_id, …
2296 + * @return array{dynamic_pricing_display: array<string, bool>, dynamic_pricing_urgency_messages: array<int, string>}
2297 + */
2298 + private function buildAvailabilityDynamicPricingCardFields(array $display_settings, int $trip_id, array $context): array
2299 + {
2300 + $display = [
2301 + 'show_original_price' => filter_var($display_settings['show_original_price'] ?? true, FILTER_VALIDATE_BOOLEAN),
2302 + 'show_savings_badge' => filter_var($display_settings['show_savings_badge'] ?? true, FILTER_VALIDATE_BOOLEAN),
2303 + 'show_urgency_messages' => filter_var($display_settings['show_urgency_messages'] ?? false, FILTER_VALIDATE_BOOLEAN),
2304 + ];
2305 +
2306 + $meta = apply_filters(
2307 + 'yatra_availability_card_dynamic_pricing_meta',
2308 + ['urgency_messages' => []],
2309 + array_merge($context, [
2310 + 'trip_id' => $trip_id,
2311 + 'display' => $display,
2312 + 'dp_display_settings' => $display_settings,
2313 + ])
2314 + );
2315 +
2316 + $urgency = [];
2317 + if (is_array($meta) && !empty($meta['urgency_messages']) && is_array($meta['urgency_messages'])) {
2318 + foreach ($meta['urgency_messages'] as $m) {
2319 + $line = sanitize_text_field((string) $m);
2320 + if ($line !== '') {
2321 + $urgency[] = $line;
2322 + }
2323 + }
2324 + $urgency = array_values(array_unique($urgency));
2325 + }
2326 +
2327 + return [
2328 + 'dynamic_pricing_display' => $display,
2329 + 'dynamic_pricing_urgency_messages' => $urgency,
2330 + ];
2331 + }
2332 +
2092 2333 private function sortAvailabilityCards(array $cards, string $sort_key): array
2093 2334 {
2094 2335 $sort_key = sanitize_text_field($sort_key);
2095 2336
@@ -2212,11 +2453,18 @@
2212 2453 }
2213 2454
2214 2455 // Apply dynamic pricing
2215 2456 if ($dp_enabled && $price > 0) {
2457 + $pt_orig = (float) ($pt->original_price ?? 0);
2458 + $pt_disc = (float) ($pt->sale_price ?? $pt->discounted_price ?? $pt->effective_price ?? $price);
2459 + if ($pt_disc <= 0) {
2460 + $pt_disc = $price;
2461 + }
2216 2462 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2217 2463 'departure_date' => $date,
2218 2464 'price_type_id' => $pt->id ?? null,
2465 + 'original_price' => $pt_orig > 0 ? $pt_orig : $price,
2466 + 'discounted_price' => $pt_disc > 0 ? $pt_disc : $price,
2219 2467 ]);
2220 2468 }
2221 2469
2222 2470 $pt->effective_price = $price;