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 +335 -81 3.0.2.9 → 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 {
@@ -888,10 +942,15 @@
888 942 */
889 943 public function restore_revision(WP_REST_Request $request)
890 944 {
891 945 try {
892 - // Check permissions
893 - 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 + ) {
894 953 return $this->error_response(__('You do not have permission to restore revisions', 'yatra'), 403);
895 954 }
896 955
897 956 $id = (int) $request->get_param('id');
@@ -998,8 +1057,9 @@
998 1057 'transportation_included',
999 1058 'international_flights_included',
1000 1059 'domestic_flights_included',
1001 1060 'is_featured',
1061 + 'has_default_time_slots',
1002 1062 ];
1003 1063
1004 1064 foreach ($booleanFields as $field) {
1005 1065 if (isset($data[$field])) {
@@ -1269,8 +1329,20 @@
1269 1329 $images = is_array($decoded) ? $decoded : [];
1270 1330 }
1271 1331 }
1272 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 +
1273 1345 return [
1274 1346 'id' => isset($entry->id) ? (int) $entry->id : null,
1275 1347 'day_id' => isset($entry->day_id) ? (int) $entry->day_id : null,
1276 1348 'time' => $entry->time ?? '',
@@ -1279,8 +1351,16 @@
1279 1351 'time_type' => $entry->time_type ?? 'exact',
1280 1352 'title' => $entry->title ?? '',
1281 1353 'description' => $entry->description ?? '',
1282 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,
1283 1363 'duration' => $entry->duration ?? '',
1284 1364 'cost' => isset($entry->cost) ? (float) $entry->cost : null,
1285 1365 'cost_per_person' => isset($entry->cost_per_person) ? (bool) $entry->cost_per_person : false,
1286 1366 'notes' => $entry->notes ?? '',
@@ -1286,8 +1366,11 @@
1286 1366 'notes' => $entry->notes ?? '',
1287 1367 'item_type_id' => isset($entry->item_type_id) ? (int) $entry->item_type_id : null,
1288 1368 'item_id' => isset($entry->item_id) ? (int) $entry->item_id : null,
1289 1369 'status' => $entry->status ?? 'active',
1370 + 'order' => isset($entry->order) ? (int) $entry->order : 0,
1371 + 'gallery' => $gallery,
1372 + 'video_url' => $entry->video_url ?? '',
1290 1373 'created_at' => $entry->created_at ?? '',
1291 1374 'updated_at' => $entry->updated_at ?? '',
1292 1375 'included_items' => $includedItems,
1293 1376 'excluded_items' => $excludedItems,
@@ -1432,13 +1515,15 @@
1432 1515
1433 1516 // Fetch availability dates using centralized resolution service
1434 1517 $resolutionService = new \Yatra\Services\AvailabilityResolutionService();
1435 1518
1436 - // 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).
1437 1522 $fromDate = date('Y-m-d');
1438 - $toDate = date('Y-m-d', strtotime('+12 months'));
1523 + $toDate = yatra_get_availability_horizon_date($fromDate);
1439 1524
1440 - $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate);
1525 + $availability_dates = $resolutionService->getAllAvailabilityDates($id, $fromDate, $toDate, \Yatra\Services\SettingsService::isEnabled('show_sold_out'));
1441 1526
1442 1527 // Determine if this is a day trip
1443 1528 $is_single_day = ($trip->duration_days ?? 1) <= 1;
1444 1529
@@ -1510,8 +1595,11 @@
1510 1595 'discounted_price' => isset($trip->discounted_price) ? (float) $trip->discounted_price : 0,
1511 1596 'sale_price' => isset($trip->sale_price) ? (float) $trip->sale_price : 0,
1512 1597 'currency' => SettingsService::getCurrency(),
1513 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,
1514 1602 'max_travelers' => isset($trip->max_travelers) ? (int) $trip->max_travelers : 20,
1515 1603 'min_travelers' => isset($trip->min_travelers) ? (int) $trip->min_travelers : 1,
1516 1604 'pricing_type' => $trip->pricing_type ?? 'regular',
1517 1605 'price_types' => $trip->price_types ?? [], // Include price_types for traveler-based pricing
@@ -1651,8 +1739,36 @@
1651 1739
1652 1740 // Build cards from real availability data or use sample data
1653 1741 $availability_cards = [];
1654 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 + }
1655 1771
1656 1772 // Determine if this is a day trip (duration <= 1 day)
1657 1773 $is_single_day = ($trip_data->duration_days ?? 1) <= 1;
1658 1774
@@ -1691,9 +1807,9 @@
1691 1807 $add_category_ids($trip_data->price_types);
1692 1808 }
1693 1809
1694 1810 if ($has_availability) {
1695 - foreach ($trip_data->availability_dates as $avail_for_cats) {
1811 + foreach ($availability_dates_for_render as $avail_for_cats) {
1696 1812 if (!empty($avail_for_cats->price_types)) {
1697 1813 $add_category_ids($avail_for_cats->price_types);
1698 1814 }
1699 1815 if (!empty($avail_for_cats->traveler_pricing)) {
@@ -1724,8 +1840,9 @@
1724 1840 'age_min' => isset($meta['age_min']) ? (int) $meta['age_min'] : null,
1725 1841 'age_max' => isset($meta['age_max']) ? (int) $meta['age_max'] : null,
1726 1842 'min_pax' => isset($meta['min_pax']) ? (int) $meta['min_pax'] : null,
1727 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',
1728 1845 ];
1729 1846 }
1730 1847 }
1731 1848
@@ -1775,8 +1892,9 @@
1775 1892 if (!isset($pt['age_min'])) $pt['age_min'] = $meta['age_min'];
1776 1893 if (!isset($pt['age_max'])) $pt['age_max'] = $meta['age_max'];
1777 1894 if (!isset($pt['min_pax'])) $pt['min_pax'] = $meta['min_pax'];
1778 1895 if (!isset($pt['max_pax'])) $pt['max_pax'] = $meta['max_pax'];
1896 + $pt['group_overflow'] = $meta['group_overflow'] ?? 'block';
1779 1897 }
1780 1898
1781 1899 // Payable amount (honors price / sale_price / discounted_price like TripPricingService)
1782 1900 if (!isset($pt['effective_price'])) {
@@ -1802,13 +1920,19 @@
1802 1920
1803 1921 if (!empty($trip_data->price_types)) {
1804 1922 $trip_data->price_types = $enrich_price_types($trip_data->price_types);
1805 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 + ]);
1806 1930
1807 1931 if ($has_availability) {
1808 1932 $current_time = time();
1809 -
1810 - foreach ($trip_data->availability_dates as $avail) {
1933 +
1934 + foreach ($availability_dates_for_render as $avail) {
1811 1935 if (empty($avail->departure_date)) {
1812 1936 // Skip entries without a valid departure date
1813 1937 continue;
1814 1938 }
@@ -1842,36 +1966,53 @@
1842 1966 // Store base prices before dynamic pricing
1843 1967 $base_original_price = $original_price;
1844 1968 $base_sale_price = $sale_price;
1845 1969
1846 - // 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.
1847 1972 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1848 1973 $dp_context = [
1849 1974 'departure_date' => $avail->departure_date ?? null,
1850 1975 'spots_remaining' => $seats,
1851 1976 'availability_id' => $avail->id ?? null,
1977 + 'original_price' => $base_original_price,
1978 + 'discounted_price' => $base_sale_price,
1852 1979 ];
1853 - $original_price = apply_filters('yatra_availability_price', $original_price, $trip_data->id, $dp_context);
1854 - $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);
1855 1981 }
1856 1982
1857 - // Calculate discount/surge pricing badge
1858 - $discount_percent = $cardPricing['discount_percentage'];
1859 - $discount_text = '';
1860 -
1861 - if ($discount_percent > 0) {
1862 - $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 = '';
1863 1995 }
1864 - // Check if dynamic pricing increased the price (surge)
1865 - elseif ($base_sale_price > 0 && $sale_price > $base_sale_price) {
1866 - $surge_percent = round((($sale_price - $base_sale_price) / $base_sale_price) * 100);
1867 - $discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1868 - }
1869 -
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 +
1870 2011 // Use month-based filters for both day trips and multi-day trips for better navigation
1871 2012 // This prevents overwhelming users with too many individual date filters
1872 2013 $month_key = strtolower(date('M-Y', $departure_date));
1873 - $month_filters[$month_key] = date('M Y', $departure_date);
2014 + $month_filters[$month_key] = date_i18n('M Y', $departure_date);
1874 2015
1875 2016 $from_location = !empty($avail->from_location) ? $avail->from_location : ($trip_data->starting_location ?? '');
1876 2017 $to_location = !empty($avail->to_location) ? $avail->to_location : ($trip_data->ending_location ?? $from_location);
1877 2018
@@ -1899,28 +2040,32 @@
1899 2040 $from_label = __('Departure', 'yatra');
1900 2041 $to_label = __('Return', 'yatra');
1901 2042 }
1902 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 +
1903 2058 // Use month-based keys for filtering for both day trips and multi-day trips
1904 2059 $filter_key = strtolower(date('M-Y', $departure_date));
1905 2060
1906 - // pricing_type MODEL comes from trip level (regular vs traveler_based)
1907 - // Note: $avail->pricing_type enum is about price state, not pricing model
1908 - $card_pricing_type = $trip_data->pricing_type ?? 'regular';
1909 - if (!empty($avail->price_types) && is_array($avail->price_types) && count($avail->price_types) > 0) {
1910 - $card_pricing_type = 'traveler_based';
1911 - }
1912 -
1913 - // 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'];
1914 2064 $card_traveler_pricing = [];
1915 - if (!empty($avail->price_types)) {
1916 - $card_traveler_pricing = is_array($avail->price_types) ? $avail->price_types : [];
1917 -
1918 - // Enrich with category labels if needed
1919 - if (!empty($card_traveler_pricing)) {
1920 - $card_traveler_pricing = $enrich_price_types($card_traveler_pricing);
1921 - }
1922 -
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);
1923 2068 }
1924 2069
1925 2070 $availability_cards[] = [
1926 2071 'id' => $avail->id,
@@ -1930,8 +2075,9 @@
1930 2075 'to_label' => $to_label,
1931 2076 'to_date' => $to_display,
1932 2077 'to_location' => $to_location,
1933 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
1934 2080 'date' => $avail->departure_date, // Raw date for dynamic pricing
1935 2081 'spots_remaining' => $seats, // For dynamic pricing
1936 2082 'seats' => $seats > 10 ? '10+' : (string) $seats,
1937 2083 'seats_available' => $seats,
@@ -1954,11 +2100,13 @@
1954 2100 'is_sold_out' => $is_sold_out,
1955 2101 // Card-specific pricing
1956 2102 'pricing_type' => $card_pricing_type,
1957 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'),
1958 2106 'is_recurring' => !empty($avail->is_recurring),
1959 2107 'rule_id' => $avail->rule_id ?? null,
1960 - ];
2108 + ] + $dp_card_fields;
1961 2109 }
1962 2110 }
1963 2111
1964 2112 // Use sample data only if no real availability
@@ -1971,33 +2119,44 @@
1971 2119 // Store base prices before dynamic pricing
1972 2120 $base_sample_original = $sample_original;
1973 2121 $base_sample_sale = $sample_sale;
1974 2122
1975 - // Apply dynamic pricing to sample card
2123 + // Apply dynamic pricing to sample card (sale line only; list price unchanged for display)
1976 2124 if (apply_filters('yatra_dynamic_pricing_enabled', false)) {
1977 - $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, [
1978 2126 'departure_date' => $sample_date,
1979 2127 'spots_remaining' => $sample_seats,
1980 2128 'availability_id' => 'sample-1',
2129 + 'original_price' => $base_sample_original,
2130 + 'discounted_price' => $base_sample_sale,
1981 2131 ]);
1982 - $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 + [
1983 2149 'departure_date' => $sample_date,
1984 2150 'spots_remaining' => $sample_seats,
1985 2151 'availability_id' => 'sample-1',
1986 - ]);
1987 - }
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 + );
1988 2158
1989 - // Calculate discount/surge pricing badge for sample card
1990 - $sample_discount_text = '';
1991 - if ($base_sample_original > 0 && $base_sample_sale < $base_sample_original) {
1992 - $discount_percent = round((($base_sample_original - $base_sample_sale) / $base_sample_original) * 100);
1993 - $sample_discount_text = $discount_percent > 0 ? sprintf(__('%d%% OFF', 'yatra'), $discount_percent) : '';
1994 - }
1995 - elseif ($base_sample_sale > 0 && $sample_sale > $base_sample_sale) {
1996 - $surge_percent = round((($sample_sale - $base_sample_sale) / $base_sample_sale) * 100);
1997 - $sample_discount_text = $surge_percent > 0 ? sprintf(__('+%d%%', 'yatra'), $surge_percent) : '';
1998 - }
1999 -
2000 2159 $availability_cards = [
2001 2160 [
2002 2161 'id' => 'sample-1',
2003 2162 'from_label' => __('Departure', 'yatra'),
@@ -2025,11 +2184,11 @@
2025 2184 'pricing_type' => $trip_data->pricing_type ?? 'regular',
2026 2185 'traveler_pricing' => $trip_data->price_types ?? [],
2027 2186 'is_recurring' => false,
2028 2187 'rule_id' => null,
2029 - ],
2188 + ] + $sample_dp_fields,
2030 2189 ];
2031 - $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'));
2032 2191 }
2033 2192
2034 2193 $sorted_cards = $this->sortAvailabilityCards($availability_cards, $sort_key);
2035 2194
@@ -2082,8 +2241,96 @@
2082 2241
2083 2242 return $slice;
2084 2243 }
2085 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 +
2086 2333 private function sortAvailabilityCards(array $cards, string $sort_key): array
2087 2334 {
2088 2335 $sort_key = sanitize_text_field($sort_key);
2089 2336
@@ -2206,11 +2453,18 @@
2206 2453 }
2207 2454
2208 2455 // Apply dynamic pricing
2209 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 + }
2210 2462 $price = apply_filters('yatra_availability_price', $price, $trip_id, [
2211 2463 'departure_date' => $date,
2212 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,
2213 2467 ]);
2214 2468 }
2215 2469
2216 2470 $pt->effective_price = $price;