PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.5.1
Yatra – Travel Booking & Tour Operator Software v3.0.5.1
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 2.0.10 2.0.11 All 82 releases
yatra / app / Controllers / DiscountController.php

DiscountController.php in Yatra – Travel Booking & Tour Operator Software 3.0.5.1, at app/Controllers/DiscountController.php

629 lines 22.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 declare(strict_types=1);
4
5 namespace Yatra\Controllers;
6
7 use WP_REST_Request;
8 use WP_REST_Response;
9 use WP_Error;
10 use Yatra\Services\DiscountService;
11 use Yatra\Repositories\DiscountRepository;
12 use Yatra\Models\Discount;
13 use Yatra\Database\Tables\DiscountsTable;
14
15 /**
16 * Discount REST API Controller
17 *
18 * Endpoints:
19 * - GET /discounts - List discounts
20 * - POST /discounts - Create discount
21 * - GET /discounts/{id} - Get single discount
22 * - PUT /discounts/{id} - Update discount
23 * - DELETE /discounts/{id} - Delete discount
24 */
25 class DiscountController extends BaseController
26 {
27 protected string $rest_base = 'discounts';
28
29 private DiscountService $service;
30
31 public function __construct()
32 {
33 $this->service = new DiscountService();
34 }
35
36 public function register_routes(): void
37 {
38 $this->registerCrudRoutes(array_merge(
39 $this->getStatusArg(),
40 [
41 'type' => [
42 'default' => 'all',
43 'sanitize_callback' => 'sanitize_text_field',
44 ],
45 ]
46 ));
47
48 register_rest_route($this->namespace, '/' . $this->rest_base . '/stats', [
49 [
50 'methods' => \WP_REST_Server::READABLE,
51 'callback' => [$this, 'get_stats'],
52 'permission_callback' => [$this, 'check_permission'],
53 ],
54 ]);
55
56 // Group discount discoverability endpoint
57 register_rest_route($this->namespace, '/' . $this->rest_base . '/group-discounts', [
58 [
59 'methods' => \WP_REST_Server::READABLE,
60 'callback' => [$this, 'get_group_discounts'],
61 'permission_callback' => [$this, 'check_public_permission'],
62 'args' => [
63 'trip_ids' => [
64 'required' => false,
65 'type' => 'array',
66 'items' => ['type' => 'integer'],
67 'description' => 'Array of trip IDs to check for group discounts',
68 ],
69 ],
70 ],
71 ]);
72 }
73
74 public function check_public_permission(?WP_REST_Request $request = null): bool
75 {
76 // Allow public access to group discount discoverability
77 return true;
78 }
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 /**
107 * Get group discount availability for trips
108 * Public endpoint for frontend discoverability
109 */
110 public function get_group_discounts(WP_REST_Request $request)
111 {
112 try {
113 $tripIds = $request->get_param('trip_ids');
114
115 if (empty($tripIds) || !is_array($tripIds)) {
116 return $this->error_response(__('Trip IDs are required', 'yatra'), 400);
117 }
118
119 // Sanitize trip IDs
120 $tripIds = array_map('absint', array_filter($tripIds));
121
122 if (empty($tripIds)) {
123 return $this->error_response(__('Valid trip IDs are required', 'yatra'), 400);
124 }
125
126 $result = [];
127
128 foreach ($tripIds as $tripId) {
129 $groupDiscounts = $this->getTripGroupDiscounts($tripId);
130 $result[$tripId] = [
131 'has_group_discounts' => !empty($groupDiscounts),
132 'discounts' => $groupDiscounts,
133 'summary' => $this->generateGroupDiscountSummary($groupDiscounts),
134 ];
135 }
136
137 return $this->success_response($result);
138
139 } catch (\Exception $e) {
140 return $this->error_response($e->getMessage(), 500);
141 }
142 }
143
144 /**
145 * Get group discounts for a specific trip
146 */
147 private function getTripGroupDiscounts(int $tripId): array
148 {
149 if (!apply_filters('yatra_advanced_discount_enabled', false)) {
150 return [];
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.
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
217 $result = [];
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
260 $result[] = [
261 'id' => $discount->id,
262 'min_group_size' => $discount->min_group_size,
263 'max_group_size' => $discount->max_group_size,
264 'discount_type' => $discount->group_discount_type,
265 'discount_amount' => $discount->group_discount_amount,
266 'discount_mode' => $discount->group_discount_mode,
267 'category_discounts' => $discount->category_discounts,
268 'range_label' => $this->formatGroupSizeRange($discount),
269 'discount_label' => $label,
270 ];
271 }
272
273 return $result;
274 }
275
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 /**
316 * Generate summary text for group discounts
317 */
318 private function generateGroupDiscountSummary(array $discounts): string
319 {
320 if (empty($discounts)) {
321 return '';
322 }
323
324 $ranges = array_map(function($discount) {
325 return $discount['range_label'];
326 }, $discounts);
327
328 $uniqueRanges = array_unique($ranges);
329
330 if (count($uniqueRanges) === 1) {
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]);
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']);
338 }
339
340 /**
341 * Format group size range for display
342 */
343 private function formatGroupSizeRange($discount): string
344 {
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);
361 }
362
363 /* translators: %d: minimum group size */
364 return sprintf(__('%d+ people', 'yatra'), $min);
365 }
366
367 /**
368 * Format discount label for display
369 */
370 private function formatDiscountAmountLabel(string $discountType, float $amount): string
371 {
372 if ($discountType === 'percentage') {
373 /* translators: %s: discount percentage */
374 return sprintf(__('%s%% off', 'yatra'), $this->formatDiscountNumberForDisplay($amount));
375 }
376
377 /* translators: %s: discount amount */
378 return sprintf(__('%s off', 'yatra'), '$' . number_format($amount, 2));
379 }
380
381 private function formatDiscountNumberForDisplay(float $amount): string
382 {
383 if (abs($amount - round($amount)) < 0.00001) {
384 return (string) (int) round($amount);
385 }
386
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);
400 }
401
402 return '';
403 }
404
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;
423 }
424 return current_user_can('yatra_manage_discounts');
425 }
426
427 /**
428 * GET /discounts/stats — counts per status for admin toolbar tabs
429 */
430 public function get_stats(WP_REST_Request $request)
431 {
432 try {
433 return $this->success_response($this->service->getAdminStatusCounts());
434 } catch (\Exception $e) {
435 return $this->error_response($e->getMessage(), 500);
436 }
437 }
438
439 public function get_items(WP_REST_Request $request)
440 {
441 try {
442 $params = $this->getPaginationParams($request);
443
444 // Override default orderby
445 $orderby = $request->get_param('orderby') ?: 'created_at';
446
447 $args = [
448 'limit' => $params['per_page'],
449 'offset' => ($params['page'] - 1) * $params['per_page'],
450 'order_by' => $orderby,
451 'order' => $params['order'],
452 ];
453
454 if (!empty($params['search'])) {
455 $args['search'] = $params['search'];
456 }
457
458 $status = $request->get_param('status');
459 if ($status && $status !== 'all') {
460 $args['status'] = sanitize_text_field($status);
461 }
462
463 $type = $request->get_param('type');
464 if ($type && $type !== 'all' && in_array($type, ['percentage', 'fixed'], true)) {
465 $args['type'] = $type;
466 }
467
468 $items = $this->service->getAll($args);
469 $total = $this->service->count($args);
470
471 $prepared = array_map([$this, 'prepareItem'], $items);
472
473 return $this->paginated_response($prepared, $total, $params['page'], $params['per_page']);
474 } catch (\Exception $e) {
475 return $this->error_response($e->getMessage(), 500);
476 }
477 }
478
479 public function get_item(WP_REST_Request $request)
480 {
481 try {
482 $item = $this->service->getById($this->getId($request));
483
484 if (!$item) {
485 return $this->not_found(__('Discount not found', 'yatra'));
486 }
487
488 return $this->success_response($this->prepareItem($item));
489 } catch (\Exception $e) {
490 return $this->error_response($e->getMessage(), 500);
491 }
492 }
493
494 public function create_item(WP_REST_Request $request)
495 {
496 try {
497 $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], true);
498
499 // Check if Advanced Discount module is required for this discount type
500 $discount_mode = $data['discount_mode'] ?? 'promo';
501 $is_group_discount = !empty($data['is_group_discount']);
502
503 if (($discount_mode === 'group' || $discount_mode === 'both' || $is_group_discount)
504 && !apply_filters('yatra_advanced_discount_enabled', false)) {
505 return $this->validation_error(__('Advanced Discount module is required for Group Discounts. Please enable it in Modules.', 'yatra'));
506 }
507
508 $id = $this->service->create($data);
509
510 return $this->success_response([
511 'id' => $id,
512 'message' => __('Discount created successfully', 'yatra'),
513 ], 201);
514 } catch (\InvalidArgumentException $e) {
515 return $this->validation_error($e->getMessage());
516 } catch (\Exception $e) {
517 return $this->error_response($e->getMessage(), 500);
518 }
519 }
520
521 public function update_item(WP_REST_Request $request)
522 {
523 try {
524 $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], false);
525
526 // Check if Advanced Discount module is required for this discount type
527 $discount_mode = $data['discount_mode'] ?? 'promo';
528 $is_group_discount = !empty($data['is_group_discount']);
529
530 if (($discount_mode === 'group' || $discount_mode === 'both' || $is_group_discount)
531 && !apply_filters('yatra_advanced_discount_enabled', false)) {
532 return $this->validation_error(__('Advanced Discount module is required for Group Discounts. Please enable it in Modules.', 'yatra'));
533 }
534
535 $result = $this->service->update($this->getId($request), $data);
536
537 if (!$result) {
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);
544 }
545
546 return $this->success_response([
547 'message' => __('Discount updated successfully', 'yatra'),
548 ]);
549 } catch (\InvalidArgumentException $e) {
550 return $this->validation_error($e->getMessage());
551 } catch (\Exception $e) {
552 return $this->error_response($e->getMessage(), 500);
553 }
554 }
555
556 public function delete_item(WP_REST_Request $request)
557 {
558 try {
559 $result = $this->service->delete($this->getId($request));
560
561 if (!$result) {
562 return $this->error_response(__('Failed to delete discount', 'yatra'), 500);
563 }
564
565 return $this->success_response([
566 'message' => __('Discount deleted successfully', 'yatra'),
567 ]);
568 } catch (\Exception $e) {
569 return $this->error_response($e->getMessage(), 500);
570 }
571 }
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
584 private function prepareItem($item): array
585 {
586 $prepared = (array) $item;
587
588 if (isset($prepared['trip_ids']) && is_string($prepared['trip_ids'])) {
589 $prepared['trip_ids'] = maybe_unserialize($prepared['trip_ids']);
590 }
591
592 // Ensure trip_ids is always an array
593 if (!is_array($prepared['trip_ids'])) {
594 $prepared['trip_ids'] = [];
595 }
596
597 // Deserialize category_discounts if it's a JSON string
598 if (isset($prepared['category_discounts']) && is_string($prepared['category_discounts'])) {
599 $prepared['category_discounts'] = json_decode($prepared['category_discounts'], true) ?: [];
600 }
601
602 // Deserialize group_discount_ranges if it's a JSON string
603 if (isset($prepared['group_discount_ranges']) && is_string($prepared['group_discount_ranges'])) {
604 $prepared['group_discount_ranges'] = json_decode($prepared['group_discount_ranges'], true) ?: [];
605 }
606
607 $prepared['first_time_customer_only'] = (bool) ($prepared['first_time_customer_only'] ?? false);
608 $prepared['is_group_discount'] = (bool) ($prepared['is_group_discount'] ?? false);
609
610 if (!empty($prepared['created_by'])) {
611 $user = get_userdata((int) $prepared['created_by']);
612 $prepared['created_by_name'] = $user ? esc_html($user->display_name) : null;
613 }
614
615 if (!empty($prepared['updated_by'])) {
616 $user = get_userdata((int) $prepared['updated_by']);
617 $prepared['updated_by_name'] = $user ? esc_html($user->display_name) : null;
618 }
619
620 // Calculate actual usage count from bookings
621 if (!empty($prepared['code'])) {
622 $discountRepository = new \Yatra\Repositories\DiscountRepository();
623 $prepared['usage_count'] = $discountRepository->countUsage($prepared['code']);
624 }
625
626 return $prepared;
627 }
628 }
629