PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.4
Yatra – Travel Booking & Tour Operator Software v3.0.4
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.4, at app/Controllers/DiscountController.php

634 lines 22.5 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 public function check_permission(?WP_REST_Request $request = null): bool
406 {
407 if ($request === null) {
408 return true;
409 }
410
411 if (!is_user_logged_in()) {
412 return false;
413 }
414
415 if (current_user_can('manage_options')) {
416 return true;
417 }
418
419 switch ($request->get_method()) {
420 case 'GET':
421 return current_user_can('yatra_view_bookings');
422 case 'POST':
423 case 'PUT':
424 case 'PATCH':
425 case 'DELETE':
426 return current_user_can('yatra_edit_bookings');
427 default:
428 return current_user_can('manage_options');
429 }
430 }
431
432 /**
433 * GET /discounts/stats — counts per status for admin toolbar tabs
434 */
435 public function get_stats(WP_REST_Request $request)
436 {
437 try {
438 return $this->success_response($this->service->getAdminStatusCounts());
439 } catch (\Exception $e) {
440 return $this->error_response($e->getMessage(), 500);
441 }
442 }
443
444 public function get_items(WP_REST_Request $request)
445 {
446 try {
447 $params = $this->getPaginationParams($request);
448
449 // Override default orderby
450 $orderby = $request->get_param('orderby') ?: 'created_at';
451
452 $args = [
453 'limit' => $params['per_page'],
454 'offset' => ($params['page'] - 1) * $params['per_page'],
455 'order_by' => $orderby,
456 'order' => $params['order'],
457 ];
458
459 if (!empty($params['search'])) {
460 $args['search'] = $params['search'];
461 }
462
463 $status = $request->get_param('status');
464 if ($status && $status !== 'all') {
465 $args['status'] = sanitize_text_field($status);
466 }
467
468 $type = $request->get_param('type');
469 if ($type && $type !== 'all' && in_array($type, ['percentage', 'fixed'], true)) {
470 $args['type'] = $type;
471 }
472
473 $items = $this->service->getAll($args);
474 $total = $this->service->count($args);
475
476 $prepared = array_map([$this, 'prepareItem'], $items);
477
478 return $this->paginated_response($prepared, $total, $params['page'], $params['per_page']);
479 } catch (\Exception $e) {
480 return $this->error_response($e->getMessage(), 500);
481 }
482 }
483
484 public function get_item(WP_REST_Request $request)
485 {
486 try {
487 $item = $this->service->getById($this->getId($request));
488
489 if (!$item) {
490 return $this->not_found(__('Discount not found', 'yatra'));
491 }
492
493 return $this->success_response($this->prepareItem($item));
494 } catch (\Exception $e) {
495 return $this->error_response($e->getMessage(), 500);
496 }
497 }
498
499 public function create_item(WP_REST_Request $request)
500 {
501 try {
502 $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], true);
503
504 // Check if Advanced Discount module is required for this discount type
505 $discount_mode = $data['discount_mode'] ?? 'promo';
506 $is_group_discount = !empty($data['is_group_discount']);
507
508 if (($discount_mode === 'group' || $discount_mode === 'both' || $is_group_discount)
509 && !apply_filters('yatra_advanced_discount_enabled', false)) {
510 return $this->validation_error(__('Advanced Discount module is required for Group Discounts. Please enable it in Modules.', 'yatra'));
511 }
512
513 $id = $this->service->create($data);
514
515 return $this->success_response([
516 'id' => $id,
517 'message' => __('Discount created successfully', 'yatra'),
518 ], 201);
519 } catch (\InvalidArgumentException $e) {
520 return $this->validation_error($e->getMessage());
521 } catch (\Exception $e) {
522 return $this->error_response($e->getMessage(), 500);
523 }
524 }
525
526 public function update_item(WP_REST_Request $request)
527 {
528 try {
529 $data = $this->filterDiscountWritablePayload($this->getBody($request) ?: [], false);
530
531 // Check if Advanced Discount module is required for this discount type
532 $discount_mode = $data['discount_mode'] ?? 'promo';
533 $is_group_discount = !empty($data['is_group_discount']);
534
535 if (($discount_mode === 'group' || $discount_mode === 'both' || $is_group_discount)
536 && !apply_filters('yatra_advanced_discount_enabled', false)) {
537 return $this->validation_error(__('Advanced Discount module is required for Group Discounts. Please enable it in Modules.', 'yatra'));
538 }
539
540 $result = $this->service->update($this->getId($request), $data);
541
542 if (!$result) {
543 global $wpdb;
544 $detail = (defined('WP_DEBUG') && WP_DEBUG && !empty($wpdb->last_error))
545 ? ' ' . $wpdb->last_error
546 : '';
547
548 return $this->error_response(__('Failed to update discount', 'yatra') . $detail, 500);
549 }
550
551 return $this->success_response([
552 'message' => __('Discount updated successfully', 'yatra'),
553 ]);
554 } catch (\InvalidArgumentException $e) {
555 return $this->validation_error($e->getMessage());
556 } catch (\Exception $e) {
557 return $this->error_response($e->getMessage(), 500);
558 }
559 }
560
561 public function delete_item(WP_REST_Request $request)
562 {
563 try {
564 $result = $this->service->delete($this->getId($request));
565
566 if (!$result) {
567 return $this->error_response(__('Failed to delete discount', 'yatra'), 500);
568 }
569
570 return $this->success_response([
571 'message' => __('Discount deleted successfully', 'yatra'),
572 ]);
573 } catch (\Exception $e) {
574 return $this->error_response($e->getMessage(), 500);
575 }
576 }
577
578 /**
579 * @param array<string, mixed> $data
580 * @return array<string, mixed>
581 */
582 private function filterDiscountWritablePayload(array $data, bool $forCreate): array
583 {
584 $fields = DiscountsTable::getRestRequestBodyColumnNames($forCreate);
585
586 return array_intersect_key($data, array_flip($fields));
587 }
588
589 private function prepareItem($item): array
590 {
591 $prepared = (array) $item;
592
593 if (isset($prepared['trip_ids']) && is_string($prepared['trip_ids'])) {
594 $prepared['trip_ids'] = maybe_unserialize($prepared['trip_ids']);
595 }
596
597 // Ensure trip_ids is always an array
598 if (!is_array($prepared['trip_ids'])) {
599 $prepared['trip_ids'] = [];
600 }
601
602 // Deserialize category_discounts if it's a JSON string
603 if (isset($prepared['category_discounts']) && is_string($prepared['category_discounts'])) {
604 $prepared['category_discounts'] = json_decode($prepared['category_discounts'], true) ?: [];
605 }
606
607 // Deserialize group_discount_ranges if it's a JSON string
608 if (isset($prepared['group_discount_ranges']) && is_string($prepared['group_discount_ranges'])) {
609 $prepared['group_discount_ranges'] = json_decode($prepared['group_discount_ranges'], true) ?: [];
610 }
611
612 $prepared['first_time_customer_only'] = (bool) ($prepared['first_time_customer_only'] ?? false);
613 $prepared['is_group_discount'] = (bool) ($prepared['is_group_discount'] ?? false);
614
615 if (!empty($prepared['created_by'])) {
616 $user = get_userdata((int) $prepared['created_by']);
617 $prepared['created_by_name'] = $user ? esc_html($user->display_name) : null;
618 }
619
620 if (!empty($prepared['updated_by'])) {
621 $user = get_userdata((int) $prepared['updated_by']);
622 $prepared['updated_by_name'] = $user ? esc_html($user->display_name) : null;
623 }
624
625 // Calculate actual usage count from bookings
626 if (!empty($prepared['code'])) {
627 $discountRepository = new \Yatra\Repositories\DiscountRepository();
628 $prepared['usage_count'] = $discountRepository->countUsage($prepared['code']);
629 }
630
631 return $prepared;
632 }
633 }
634