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/DiscountController.php +209 -44 3.0.2.9 → 3.0.16 View file →
@@ -9,8 +9,9 @@
9 9 use WP_Error;
10 10 use Yatra\Services\DiscountService;
11 11 use Yatra\Repositories\DiscountRepository;
12 12 use Yatra\Models\Discount;
13 +use Yatra\Database\Tables\DiscountsTable;
13 14
14 15 /**
15 16 * Discount REST API Controller
16 17 *
@@ -76,8 +77,34 @@
76 77 return true;
77 78 }
78 79
79 80 /**
81 + * Same tier payload as GET /discounts/group-discounts for one trip — for PHP templates (sidebar)
82 + * without HTTP/rest_do_request (avoids loopback failures).
83 + *
84 + * @return array{has_group_discounts: bool, discounts: array<int, array<string, mixed>>, summary: string}
85 + */
86 + public function getPublicGroupDiscountDiscoverabilityForTrip(int $tripId): array
87 + {
88 + $tripId = max(0, $tripId);
89 + if ($tripId === 0) {
90 + return [
91 + 'has_group_discounts' => false,
92 + 'discounts' => [],
93 + 'summary' => '',
94 + ];
95 + }
96 +
97 + $discounts = $this->getTripGroupDiscounts($tripId);
98 +
99 + return [
100 + 'has_group_discounts' => !empty($discounts),
101 + 'discounts' => $discounts,
102 + 'summary' => $this->generateGroupDiscountSummary($discounts),
103 + ];
104 + }
105 +
106 + /**
80 107 * Get group discount availability for trips
81 108 * Public endpoint for frontend discoverability
82 109 */
83 110 public function get_group_discounts(WP_REST_Request $request)
@@ -118,12 +145,14 @@
118 145 * Get group discounts for a specific trip
119 146 */
120 147 private function getTripGroupDiscounts(int $tripId): array
121 148 {
122 - // Check if Advanced Discount module is enabled - group discounts are a Pro feature
123 149 if (!apply_filters('yatra_advanced_discount_enabled', false)) {
124 150 return [];
125 151 }
152 +
153 + // Discoverability is read-only: list published group tiers for the trip page. Booking/calculation
154 + // still gates on {@see apply_filters('yatra_advanced_discount_enabled')} inside DiscountService.
126 155 // Discount is a simple DTO model (not Eloquent), so use repository + PHP filtering.
127 156 $repo = new DiscountRepository();
128 157 $rows = $repo->getActiveGroupDiscounts();
129 158
@@ -132,24 +161,38 @@
132 161 $discounts = array_values(array_filter(array_map(function ($row) use ($today, $tripId) {
133 162 $arr = (array) $row;
134 163 $discount = Discount::fromArray($arr);
135 164
136 - if (empty($discount->is_group_discount)) {
165 + // Match {@see DiscountRepository::getActiveGroupDiscounts()}: group savings may be stored
166 + // with discount_mode group/both while is_group_discount stayed 0 on older rows.
167 + $discountMode = strtolower((string) ($arr['discount_mode'] ?? ''));
168 + $isGroupEligible = !empty($discount->is_group_discount)
169 + || in_array($discountMode, ['group', 'both'], true);
170 + if (!$isGroupEligible) {
137 171 return null;
138 172 }
139 173
140 - // Repository already filters status='publish', but keep this defensive.
141 - if (($arr['status'] ?? null) !== 'publish' && ($discount->status ?? null) !== 'publish') {
174 + $status = strtolower((string) ($arr['status'] ?? $discount->status ?? ''));
175 + if (!in_array($status, ['publish', 'active'], true)) {
142 176 return null;
143 177 }
144 178
179 + // Compare calendar dates only (valid_from / expiry may be DATETIME).
145 180 $validFrom = is_string($discount->valid_from) ? trim($discount->valid_from) : '';
146 - if ($validFrom !== '' && $validFrom !== '0000-00-00' && $validFrom > $today) {
181 + $validFromDay = $validFrom !== '' ? substr($validFrom, 0, 10) : '';
182 + if ($validFromDay === '0000-00-00') {
183 + $validFromDay = '';
184 + }
185 + if ($validFromDay !== '' && $validFromDay > $today) {
147 186 return null;
148 187 }
149 188
150 189 $expiry = is_string($discount->expiry_date) ? trim($discount->expiry_date) : '';
151 - if ($expiry !== '' && $expiry !== '0000-00-00' && $expiry < $today) {
190 + $expiryDay = $expiry !== '' ? substr($expiry, 0, 10) : '';
191 + if ($expiryDay === '0000-00-00') {
192 + $expiryDay = '';
193 + }
194 + if ($expiryDay !== '' && $expiryDay < $today) {
152 195 return null;
153 196 }
154 197
155 198 $applicableTo = (string) ($discount->applicable_to ?? 'all');
@@ -157,10 +200,10 @@
157 200 return $discount;
158 201 }
159 202
160 203 if ($applicableTo === 'specific_trips') {
161 - $tripIds = $discount->trip_ids ?? [];
162 - if (is_array($tripIds) && in_array($tripId, array_map('absint', $tripIds), true)) {
204 + $tripIds = self::normalizeDiscountTripIds($discount->trip_ids);
205 + if (in_array($tripId, $tripIds, true)) {
163 206 return $discount;
164 207 }
165 208 }
166 209
@@ -172,8 +215,49 @@
172 215 });
173 216
174 217 $result = [];
175 218 foreach ($discounts as $discount) {
219 + $ranges = $discount->group_discount_ranges;
220 + if (!empty($ranges) && is_array($ranges)) {
221 + $tiersFromRanges = 0;
222 + foreach ($ranges as $rangeRow) {
223 + $r = is_array($rangeRow) ? $rangeRow : (array) $rangeRow;
224 + $min = isset($r['min_group_size']) && $r['min_group_size'] !== '' ? (int) $r['min_group_size'] : 0;
225 + $maxRaw = $r['max_group_size'] ?? null;
226 + $max = ($maxRaw !== null && $maxRaw !== '') ? (int) $maxRaw : null;
227 + $dType = (($r['discount_type'] ?? 'percentage') === 'fixed') ? 'fixed' : 'percentage';
228 + $dAmount = (float) ($r['discount_amount'] ?? $r['amount'] ?? 0);
229 + if ($dAmount <= 0) {
230 + continue;
231 + }
232 + $result[] = [
233 + 'id' => $discount->id,
234 + 'min_group_size' => $min,
235 + 'max_group_size' => $max,
236 + 'discount_type' => $dType,
237 + 'discount_amount' => $dAmount,
238 + 'discount_mode' => $discount->group_discount_mode ?? 'total',
239 + 'category_discounts' => $discount->category_discounts,
240 + 'range_label' => $this->formatGroupSizeRangeInts($min, $max),
241 + 'discount_label' => $this->formatDiscountAmountLabel($dType, $dAmount),
242 + ];
243 + $tiersFromRanges++;
244 + }
245 + if ($tiersFromRanges > 0) {
246 + continue;
247 + }
248 + }
249 +
250 + $label = $this->formatDiscountLabel($discount);
251 + $isCategoryBased = ($discount->group_discount_mode ?? '') === 'category_based'
252 + && !empty($discount->category_discounts);
253 + if ($label === '' && !$isCategoryBased) {
254 + continue;
255 + }
256 + if ($label === '' && $isCategoryBased) {
257 + $label = __('Varies by category', 'yatra');
258 + }
259 +
176 260 $result[] = [
177 261 'id' => $discount->id,
178 262 'min_group_size' => $discount->min_group_size,
179 263 'max_group_size' => $discount->max_group_size,
@@ -181,9 +265,9 @@
181 265 'discount_amount' => $discount->group_discount_amount,
182 266 'discount_mode' => $discount->group_discount_mode,
183 267 'category_discounts' => $discount->category_discounts,
184 268 'range_label' => $this->formatGroupSizeRange($discount),
185 - 'discount_label' => $this->formatDiscountLabel($discount),
269 + 'discount_label' => $label,
186 270 ];
187 271 }
188 272
189 273 return $result;
@@ -189,8 +273,47 @@
189 273 return $result;
190 274 }
191 275
192 276 /**
277 + * Trip IDs stored on a discount (serialized array, JSON array, or comma-separated).
278 + *
279 + * @param mixed $tripIds
280 + * @return list<int>
281 + */
282 + private static function normalizeDiscountTripIds($tripIds): array
283 + {
284 + if ($tripIds === null || $tripIds === '') {
285 + return [];
286 + }
287 + if (is_array($tripIds)) {
288 + return array_values(array_unique(array_map('absint', $tripIds)));
289 + }
290 + if (!is_string($tripIds)) {
291 + return [];
292 + }
293 + $trim = trim($tripIds);
294 + if ($trim === '') {
295 + return [];
296 + }
297 + if ($trim[0] === '[' || $trim[0] === '{') {
298 + $decoded = json_decode($trim, true);
299 + if (is_array($decoded)) {
300 + return array_values(array_unique(array_map('absint', $decoded)));
301 + }
302 + }
303 + $unser = maybe_unserialize($tripIds);
304 + if (is_array($unser)) {
305 + return array_values(array_unique(array_map('absint', $unser)));
306 + }
307 +
308 + $parts = array_map('trim', explode(',', $trim));
309 +
310 + return array_values(array_unique(array_map('absint', array_filter($parts, static function ($p) {
311 + return $p !== '';
312 + }))));
313 + }
314 +
315 + /**
193 316 * Generate summary text for group discounts
194 317 */
195 318 private function generateGroupDiscountSummary(array $discounts): string
196 319 {
@@ -218,56 +341,88 @@
218 341 * Format group size range for display
219 342 */
220 343 private function formatGroupSizeRange($discount): string
221 344 {
222 - if ($discount->max_group_size) {
345 + $min = (int) ($discount->min_group_size ?? 0);
346 + $max = isset($discount->max_group_size) && (int) $discount->max_group_size > 0
347 + ? (int) $discount->max_group_size
348 + : null;
349 +
350 + return $this->formatGroupSizeRangeInts($min, $max);
351 + }
352 +
353 + /**
354 + * Group size range label from explicit bounds (used for tier rows from group_discount_ranges).
355 + */
356 + private function formatGroupSizeRangeInts(int $min, ?int $max): string
357 + {
358 + if ($max !== null && $max > 0) {
223 359 /* translators: 1: min group size, 2: max group size */
224 - return sprintf(__('%1$d-%2$d people', 'yatra'), (int) $discount->min_group_size, (int) $discount->max_group_size);
360 + return sprintf(__('%1$d-%2$d people', 'yatra'), $min, $max);
225 361 }
226 362
227 363 /* translators: %d: minimum group size */
228 - return sprintf(__('%d+ people', 'yatra'), (int) $discount->min_group_size);
364 + return sprintf(__('%d+ people', 'yatra'), $min);
229 365 }
230 366
231 367 /**
232 368 * Format discount label for display
233 369 */
234 - private function formatDiscountLabel($discount): string
370 + private function formatDiscountAmountLabel(string $discountType, float $amount): string
235 371 {
236 - if ($discount->group_discount_type === 'percentage') {
372 + if ($discountType === 'percentage') {
237 373 /* translators: %s: discount percentage */
238 - return sprintf(__('%s%% off', 'yatra'), (string) $discount->group_discount_amount);
374 + return sprintf(__('%s%% off', 'yatra'), $this->formatDiscountNumberForDisplay($amount));
239 375 }
240 376
241 - /* translators: %s: discount amount */
242 - return sprintf(__('%s off', 'yatra'), '$' . number_format((float) $discount->group_discount_amount, 2));
377 + /* translators: %s: discount amount, already formatted with the site currency */
378 + return sprintf(__('%s off', 'yatra'), yatra_format_price((float) $amount, null, false));
243 379 }
244 380
245 - public function check_permission(?WP_REST_Request $request = null): bool
381 + private function formatDiscountNumberForDisplay(float $amount): string
246 382 {
247 - if ($request === null) {
248 - return true;
383 + if (abs($amount - round($amount)) < 0.00001) {
384 + return (string) (int) round($amount);
249 385 }
250 386
251 - if (!is_user_logged_in()) {
252 - return false;
387 + return rtrim(rtrim(number_format($amount, 2, '.', ''), '0'), '.');
388 + }
389 +
390 + /**
391 + * Format discount label for display
392 + */
393 + private function formatDiscountLabel($discount): string
394 + {
395 + $type = $discount->group_discount_type ?? 'percentage';
396 + $amt = (float) ($discount->group_discount_amount ?? 0);
397 +
398 + if ($amt > 0) {
399 + return $this->formatDiscountAmountLabel($type === 'fixed' ? 'fixed' : 'percentage', $amt);
253 400 }
254 401
255 - if (current_user_can('manage_options')) {
256 - return true;
257 - }
402 + return '';
403 + }
258 404
259 - switch ($request->get_method()) {
260 - case 'GET':
261 - return current_user_can('yatra_view_bookings');
262 - case 'POST':
263 - case 'PUT':
264 - case 'PATCH':
265 - case 'DELETE':
266 - return current_user_can('yatra_edit_bookings');
267 - default:
268 - return current_user_can('manage_options');
405 + /**
406 + * Discount management — gated on the dedicated `yatra_manage_discounts`
407 + * cap. Held by Owner, Manager, and Marketing roles by default.
408 + *
409 + * The previous implementation gated on `yatra_view_bookings` /
410 + * `yatra_edit_bookings`, which meant the Marketing role (which has
411 + * `yatra_manage_discounts` but NOT the booking caps) could not
412 + * actually manage discounts despite holding the documented cap.
413 + * Sales Agent / Front Desk (which DO have the booking caps but
414 + * NOT `yatra_manage_discounts`) were incorrectly granted access
415 + * to discount management.
416 + *
417 + * WP admins pass via the Team module's admin-fallback filter.
418 + */
419 + public function check_permission(?WP_REST_Request $request = null): bool
420 + {
421 + if (!is_user_logged_in()) {
422 + return false;
269 423 }
424 + return current_user_can('yatra_manage_discounts');
270 425 }
271 426
272 427 /**
273 428 * GET /discounts/stats — counts per status for admin toolbar tabs
@@ -338,9 +493,9 @@
338 493
339 494 public function create_item(WP_REST_Request $request)
340 495 {
341 496 try {
342 - $data = $this->getBody($request);
497 + $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], true);
343 498
344 499 // Check if Advanced Discount module is required for this discount type
345 500 $discount_mode = $data['discount_mode'] ?? 'promo';
346 501 $is_group_discount = !empty($data['is_group_discount']);
@@ -365,9 +520,9 @@
365 520
366 521 public function update_item(WP_REST_Request $request)
367 522 {
368 523 try {
369 - $data = $this->getBody($request);
524 + $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], false);
370 525
371 526 // Check if Advanced Discount module is required for this discount type
372 527 $discount_mode = $data['discount_mode'] ?? 'promo';
373 528 $is_group_discount = !empty($data['is_group_discount']);
@@ -379,9 +534,14 @@
379 534
380 535 $result = $this->service->update($this->getId($request), $data);
381 536
382 537 if (!$result) {
383 - return $this->error_response(__('Failed to update discount', 'yatra'), 500);
538 + global $wpdb;
539 + $detail = (defined('WP_DEBUG') && WP_DEBUG && !empty($wpdb->last_error))
540 + ? ' ' . $wpdb->last_error
541 + : '';
542 +
543 + return $this->error_response(__('Failed to update discount', 'yatra') . $detail, 500);
384 544 }
385 545
386 546 return $this->success_response([
387 547 'message' => __('Discount updated successfully', 'yatra'),
@@ -409,8 +569,19 @@
409 569 return $this->error_response($e->getMessage(), 500);
410 570 }
411 571 }
412 572
573 + /**
574 + * @param array<string, mixed> $data
575 + * @return array<string, mixed>
576 + */
577 + private function filterDiscountWritablePayload(array $data, bool $forCreate): array
578 + {
579 + $fields = DiscountsTable::getRestRequestBodyColumnNames($forCreate);
580 +
581 + return array_intersect_key($data, array_flip($fields));
582 + }
583 +
413 584 private function prepareItem($item): array
414 585 {
415 586 $prepared = (array) $item;
416 587
@@ -434,14 +605,8 @@
434 605 }
435 606
436 607 $prepared['first_time_customer_only'] = (bool) ($prepared['first_time_customer_only'] ?? false);
437 608 $prepared['is_group_discount'] = (bool) ($prepared['is_group_discount'] ?? false);
438 -
439 - if (!$prepared['is_group_discount']) {
440 - $prepared['min_group_size'] = null;
441 - $prepared['group_discount_type'] = null;
442 - $prepared['group_discount_amount'] = null;
443 - }
444 609
445 610 if (!empty($prepared['created_by'])) {
446 611 $user = get_userdata((int) $prepared['created_by']);
447 612 $prepared['created_by_name'] = $user ? esc_html($user->display_name) : null;