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 +262 -62 3.0.2.8 → 3.0.16 View file →
@@ -7,8 +7,11 @@
7 7 use WP_REST_Request;
8 8 use WP_REST_Response;
9 9 use WP_Error;
10 10 use Yatra\Services\DiscountService;
11 +use Yatra\Repositories\DiscountRepository;
12 +use Yatra\Models\Discount;
13 +use Yatra\Database\Tables\DiscountsTable;
11 14
12 15 /**
13 16 * Discount REST API Controller
14 17 *
@@ -74,8 +77,34 @@
74 77 return true;
75 78 }
76 79
77 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 + /**
78 107 * Get group discount availability for trips
79 108 * Public endpoint for frontend discoverability
80 109 */
81 110 public function get_group_discounts(WP_REST_Request $request)
@@ -116,35 +145,119 @@
116 145 * Get group discounts for a specific trip
117 146 */
118 147 private function getTripGroupDiscounts(int $tripId): array
119 148 {
120 - // Check if Advanced Discount module is enabled - group discounts are a Pro feature
121 149 if (!apply_filters('yatra_advanced_discount_enabled', false)) {
122 150 return [];
123 151 }
124 -
125 - $discounts = \Yatra\Models\Discount::where('is_group_discount', true)
126 - ->where('status', 'publish')
127 - ->where(function($query) {
128 - $query->whereNull('valid_from')
129 - ->orWhere('valid_from', '<=', date('Y-m-d'));
130 - })
131 - ->where(function($query) {
132 - $query->whereNull('expiry_date')
133 - ->orWhere('expiry_date', '>=', date('Y-m-d'));
134 - })
135 - ->where(function($query) use ($tripId) {
136 - $query->where('applicable_to', 'all')
137 - ->orWhere(function($subQuery) use ($tripId) {
138 - $subQuery->where('applicable_to', 'specific_trips')
139 - ->whereJsonContains('trip_ids', $tripId);
140 - });
141 - })
142 - ->orderBy('min_group_size', 'asc')
143 - ->get();
144 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.
155 + // Discount is a simple DTO model (not Eloquent), so use repository + PHP filtering.
156 + $repo = new DiscountRepository();
157 + $rows = $repo->getActiveGroupDiscounts();
158 +
159 + $today = date('Y-m-d');
160 +
161 + $discounts = array_values(array_filter(array_map(function ($row) use ($today, $tripId) {
162 + $arr = (array) $row;
163 + $discount = Discount::fromArray($arr);
164 +
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) {
171 + return null;
172 + }
173 +
174 + $status = strtolower((string) ($arr['status'] ?? $discount->status ?? ''));
175 + if (!in_array($status, ['publish', 'active'], true)) {
176 + return null;
177 + }
178 +
179 + // Compare calendar dates only (valid_from / expiry may be DATETIME).
180 + $validFrom = is_string($discount->valid_from) ? trim($discount->valid_from) : '';
181 + $validFromDay = $validFrom !== '' ? substr($validFrom, 0, 10) : '';
182 + if ($validFromDay === '0000-00-00') {
183 + $validFromDay = '';
184 + }
185 + if ($validFromDay !== '' && $validFromDay > $today) {
186 + return null;
187 + }
188 +
189 + $expiry = is_string($discount->expiry_date) ? trim($discount->expiry_date) : '';
190 + $expiryDay = $expiry !== '' ? substr($expiry, 0, 10) : '';
191 + if ($expiryDay === '0000-00-00') {
192 + $expiryDay = '';
193 + }
194 + if ($expiryDay !== '' && $expiryDay < $today) {
195 + return null;
196 + }
197 +
198 + $applicableTo = (string) ($discount->applicable_to ?? 'all');
199 + if ($applicableTo === 'all') {
200 + return $discount;
201 + }
202 +
203 + if ($applicableTo === 'specific_trips') {
204 + $tripIds = self::normalizeDiscountTripIds($discount->trip_ids);
205 + if (in_array($tripId, $tripIds, true)) {
206 + return $discount;
207 + }
208 + }
209 +
210 + return null;
211 + }, $rows)));
212 +
213 + usort($discounts, function (Discount $a, Discount $b) {
214 + return (int) ($a->min_group_size ?? 0) <=> (int) ($b->min_group_size ?? 0);
215 + });
216 +
145 217 $result = [];
146 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 +
147 260 $result[] = [
148 261 'id' => $discount->id,
149 262 'min_group_size' => $discount->min_group_size,
150 263 'max_group_size' => $discount->max_group_size,
@@ -152,9 +265,9 @@
152 265 'discount_amount' => $discount->group_discount_amount,
153 266 'discount_mode' => $discount->group_discount_mode,
154 267 'category_discounts' => $discount->category_discounts,
155 268 'range_label' => $this->formatGroupSizeRange($discount),
156 - 'discount_label' => $this->formatDiscountLabel($discount),
269 + 'discount_label' => $label,
157 270 ];
158 271 }
159 272
160 273 return $result;
@@ -160,8 +273,47 @@
160 273 return $result;
161 274 }
162 275
163 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 + /**
164 316 * Generate summary text for group discounts
165 317 */
166 318 private function generateGroupDiscountSummary(array $discounts): string
167 319 {
@@ -175,13 +327,15 @@
175 327
176 328 $uniqueRanges = array_unique($ranges);
177 329
178 330 if (count($uniqueRanges) === 1) {
179 - return "Up to {$discounts[0]['discount_label']} for {$uniqueRanges[0]}";
180 - } else {
181 - $firstDiscount = $discounts[0];
182 - return "Up to {$firstDiscount['discount_label']} for groups starting at {$firstDiscount['min_group_size']} people";
331 + /* translators: 1: discount label, 2: range label */
332 + return sprintf(__('Up to %1$s for %2$s', 'yatra'), $discounts[0]['discount_label'], $uniqueRanges[0]);
183 333 }
334 +
335 + $firstDiscount = $discounts[0];
336 + /* translators: 1: discount label, 2: minimum group size */
337 + return sprintf(__('Up to %1$s for groups starting at %2$d people', 'yatra'), $firstDiscount['discount_label'], (int) $firstDiscount['min_group_size']);
184 338 }
185 339
186 340 /**
187 341 * Format group size range for display
@@ -187,52 +341,88 @@
187 341 * Format group size range for display
188 342 */
189 343 private function formatGroupSizeRange($discount): string
190 344 {
191 - if ($discount->max_group_size) {
192 - return "{$discount->min_group_size}-{$discount->max_group_size} people";
193 - } else {
194 - return "{$discount->min_group_size}+ people";
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) {
359 + /* translators: 1: min group size, 2: max group size */
360 + return sprintf(__('%1$d-%2$d people', 'yatra'), $min, $max);
195 361 }
362 +
363 + /* translators: %d: minimum group size */
364 + return sprintf(__('%d+ people', 'yatra'), $min);
196 365 }
197 366
198 367 /**
199 368 * Format discount label for display
200 369 */
201 - private function formatDiscountLabel($discount): string
370 + private function formatDiscountAmountLabel(string $discountType, float $amount): string
202 371 {
203 - if ($discount->group_discount_type === 'percentage') {
204 - return "{$discount->group_discount_amount}% off";
205 - } else {
206 - return "$" . number_format($discount->group_discount_amount, 2) . " off";
372 + if ($discountType === 'percentage') {
373 + /* translators: %s: discount percentage */
374 + return sprintf(__('%s%% off', 'yatra'), $this->formatDiscountNumberForDisplay($amount));
207 375 }
376 +
377 + /* translators: %s: discount amount, already formatted with the site currency */
378 + return sprintf(__('%s off', 'yatra'), yatra_format_price((float) $amount, null, false));
208 379 }
209 380
210 - public function check_permission(?WP_REST_Request $request = null): bool
381 + private function formatDiscountNumberForDisplay(float $amount): string
211 382 {
212 - if ($request === null) {
213 - return true;
383 + if (abs($amount - round($amount)) < 0.00001) {
384 + return (string) (int) round($amount);
214 385 }
215 386
216 - if (!is_user_logged_in()) {
217 - 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);
218 400 }
219 401
220 - if (current_user_can('manage_options')) {
221 - return true;
222 - }
402 + return '';
403 + }
223 404
224 - switch ($request->get_method()) {
225 - case 'GET':
226 - return current_user_can('yatra_view_bookings');
227 - case 'POST':
228 - case 'PUT':
229 - case 'PATCH':
230 - case 'DELETE':
231 - return current_user_can('yatra_edit_bookings');
232 - default:
233 - 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;
234 423 }
424 + return current_user_can('yatra_manage_discounts');
235 425 }
236 426
237 427 /**
238 428 * GET /discounts/stats — counts per status for admin toolbar tabs
@@ -303,9 +493,9 @@
303 493
304 494 public function create_item(WP_REST_Request $request)
305 495 {
306 496 try {
307 - $data = $this->getBody($request);
497 + $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], true);
308 498
309 499 // Check if Advanced Discount module is required for this discount type
310 500 $discount_mode = $data['discount_mode'] ?? 'promo';
311 501 $is_group_discount = !empty($data['is_group_discount']);
@@ -330,9 +520,9 @@
330 520
331 521 public function update_item(WP_REST_Request $request)
332 522 {
333 523 try {
334 - $data = $this->getBody($request);
524 + $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], false);
335 525
336 526 // Check if Advanced Discount module is required for this discount type
337 527 $discount_mode = $data['discount_mode'] ?? 'promo';
338 528 $is_group_discount = !empty($data['is_group_discount']);
@@ -344,9 +534,14 @@
344 534
345 535 $result = $this->service->update($this->getId($request), $data);
346 536
347 537 if (!$result) {
348 - 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);
349 544 }
350 545
351 546 return $this->success_response([
352 547 'message' => __('Discount updated successfully', 'yatra'),
@@ -374,8 +569,19 @@
374 569 return $this->error_response($e->getMessage(), 500);
375 570 }
376 571 }
377 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 +
378 584 private function prepareItem($item): array
379 585 {
380 586 $prepared = (array) $item;
381 587
@@ -399,14 +605,8 @@
399 605 }
400 606
401 607 $prepared['first_time_customer_only'] = (bool) ($prepared['first_time_customer_only'] ?? false);
402 608 $prepared['is_group_discount'] = (bool) ($prepared['is_group_discount'] ?? false);
403 -
404 - if (!$prepared['is_group_discount']) {
405 - $prepared['min_group_size'] = null;
406 - $prepared['group_discount_type'] = null;
407 - $prepared['group_discount_amount'] = null;
408 - }
409 609
410 610 if (!empty($prepared['created_by'])) {
411 611 $user = get_userdata((int) $prepared['created_by']);
412 612 $prepared['created_by_name'] = $user ? esc_html($user->display_name) : null;