PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.8
Yatra – Travel Booking & Tour Operator Software v3.0.2.8
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
yatra / app / Services / DiscountService.php

DiscountService.php in Yatra – Travel Booking & Tour Operator Software 3.0.2.8, at app/Services/DiscountService.php

1,206 lines 55.8 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\Services;
6
7 use Yatra\Repositories\DiscountRepository;
8
9 /**
10 * Discount Service
11 * Contains business logic for discounts
12 */
13 class DiscountService extends BaseService
14 {
15 private DiscountRepository $repository;
16
17 public function __construct()
18 {
19 $this->repository = new DiscountRepository();
20 }
21
22 protected function getRepository(): DiscountRepository
23 {
24 return $this->repository;
25 }
26
27 /**
28 * Get group discounts applicable to a specific trip
29 *
30 * @param int $tripId The trip ID
31 * @return array Array of group discount objects
32 */
33 public function getGroupDiscountsForTrip(int $tripId): array
34 {
35 global $wpdb;
36 $table = \Yatra\Database\Tables\DiscountsTable::getTableName();
37 $today = date('Y-m-d H:i:s');
38
39 // Query for active group discounts
40 // Check both is_group_discount=1 OR discount_mode IN ('group', 'both') for backward compatibility
41 $query = $wpdb->prepare(
42 "SELECT * FROM `{$table}`
43 WHERE (is_group_discount = 1 OR discount_mode IN ('group', 'both'))
44 AND status = 'publish'
45 AND (valid_from IS NULL OR valid_from <= %s)
46 AND (expiry_date IS NULL OR expiry_date >= %s)
47 ORDER BY created_at DESC",
48 $today,
49 $today
50 );
51
52 $results = $wpdb->get_results($query);
53
54 // Filter by trip_ids in PHP since it's stored as serialized array
55 $filtered = [];
56 foreach ($results as $discount) {
57 // If applicable_to is 'all', include it
58 if (empty($discount->trip_ids) || $discount->applicable_to === 'all') {
59 $filtered[] = $discount;
60 continue;
61 }
62
63 // If applicable_to is 'specific_trips', check if trip is in the list
64 $trip_ids = maybe_unserialize($discount->trip_ids);
65 if (is_array($trip_ids) && in_array($tripId, array_map('intval', $trip_ids), true)) {
66 $filtered[] = $discount;
67 }
68 }
69
70 return $filtered;
71 }
72
73 protected function validate(array $data, ?int $id = null): void
74 {
75 if (empty($data['code'])) {
76 throw new \InvalidArgumentException('Discount code is required');
77 }
78
79 // Check code uniqueness (excluding current ID if updating)
80 $existing = $this->repository->findByCode($data['code']);
81 if ($existing && (int) $existing->id !== $id) {
82 throw new \InvalidArgumentException('Discount code already exists');
83 }
84
85 $allowed_types = ['percentage', 'fixed'];
86 if (isset($data['type']) && !in_array($data['type'], $allowed_types, true)) {
87 throw new \InvalidArgumentException('Invalid discount type. Must be one of: ' . implode(', ', $allowed_types));
88 }
89
90 $allowed_statuses = ['draft', 'publish', 'trash'];
91 if (isset($data['status']) && !in_array($data['status'], $allowed_statuses, true)) {
92 throw new \InvalidArgumentException('Invalid status. Must be one of: ' . implode(', ', $allowed_statuses));
93 }
94
95 $allowed_applicable_to = ['all', 'specific_trips'];
96 if (isset($data['applicable_to']) && !in_array($data['applicable_to'], $allowed_applicable_to, true)) {
97 throw new \InvalidArgumentException('Invalid applicable_to. Must be one of: ' . implode(', ', $allowed_applicable_to));
98 }
99
100 if (isset($data['amount']) && (float) $data['amount'] < 0) {
101 throw new \InvalidArgumentException('Discount amount cannot be negative');
102 }
103 }
104
105 protected function processBeforeCreate(array $data): array
106 {
107 if (isset($data['code'])) {
108 $data['code'] = strtoupper(sanitize_text_field($data['code']));
109 }
110
111 if (isset($data['description'])) {
112 $data['description'] = sanitize_textarea_field($data['description']);
113 }
114
115 if (isset($data['type'])) {
116 $allowed_types = ['percentage', 'fixed'];
117 $data['type'] = in_array($data['type'], $allowed_types, true) ? $data['type'] : 'percentage';
118 } else {
119 $data['type'] = 'percentage';
120 }
121
122 if (isset($data['amount'])) {
123 $data['amount'] = (float) $data['amount'];
124 } else {
125 $data['amount'] = 0.0;
126 }
127
128 if (isset($data['max_discount_amount'])) {
129 $data['max_discount_amount'] = !empty($data['max_discount_amount']) ? (float) $data['max_discount_amount'] : null;
130 }
131
132 if (isset($data['usage_limit'])) {
133 $data['usage_limit'] = absint($data['usage_limit']);
134 } else {
135 $data['usage_limit'] = 0;
136 }
137
138 if (isset($data['usage_limit_per_customer'])) {
139 $data['usage_limit_per_customer'] = absint($data['usage_limit_per_customer']);
140 } else {
141 $data['usage_limit_per_customer'] = 0;
142 }
143
144 if (isset($data['valid_from']) && !empty($data['valid_from'])) {
145 $data['valid_from'] = sanitize_text_field($data['valid_from']);
146 } else {
147 $data['valid_from'] = null;
148 }
149
150 if (isset($data['expiry_date']) && !empty($data['expiry_date'])) {
151 $data['expiry_date'] = sanitize_text_field($data['expiry_date']);
152 } else {
153 $data['expiry_date'] = null;
154 }
155
156 if (isset($data['status'])) {
157 $allowed_statuses = ['draft', 'publish', 'trash'];
158 $data['status'] = in_array($data['status'], $allowed_statuses, true) ? $data['status'] : 'draft';
159 } else {
160 $data['status'] = 'draft';
161 }
162
163 if (isset($data['applicable_to'])) {
164 $allowed_applicable_to = ['all', 'specific_trips'];
165 $data['applicable_to'] = in_array($data['applicable_to'], $allowed_applicable_to, true) ? $data['applicable_to'] : 'all';
166 } else {
167 $data['applicable_to'] = 'all';
168 }
169
170 if (isset($data['trip_ids']) && is_array($data['trip_ids'])) {
171 $trip_ids = array_map('absint', $data['trip_ids']);
172 $data['trip_ids'] = maybe_serialize($trip_ids);
173 } elseif (isset($data['trip_ids'])) {
174 $data['trip_ids'] = maybe_serialize([]);
175 } else {
176 $data['trip_ids'] = null;
177 }
178
179 if (isset($data['min_amount'])) {
180 $data['min_amount'] = !empty($data['min_amount']) ? (float) $data['min_amount'] : null;
181 }
182
183 if (isset($data['first_time_customer_only'])) {
184 $data['first_time_customer_only'] = (bool) $data['first_time_customer_only'];
185 } else {
186 $data['first_time_customer_only'] = false;
187 }
188
189 // Handle discount_mode (promo, group, or both)
190 if (isset($data['discount_mode'])) {
191 $allowed_modes = ['promo', 'group', 'both'];
192 $data['discount_mode'] = in_array($data['discount_mode'], $allowed_modes, true) ? $data['discount_mode'] : 'both';
193
194 // If discount_mode is 'group', ensure is_group_discount is true
195 if ($data['discount_mode'] === 'group') {
196 $data['is_group_discount'] = true;
197 }
198 } else {
199 $data['discount_mode'] = 'both';
200 }
201
202 if (isset($data['is_group_discount'])) {
203 $data['is_group_discount'] = (bool) $data['is_group_discount'];
204
205 // If group discount is disabled, clear all group discount fields
206 if (!$data['is_group_discount']) {
207 $data['min_group_size'] = null;
208 $data['max_group_size'] = null;
209 $data['group_discount_type'] = null;
210 $data['group_discount_amount'] = null;
211 $data['group_discount_mode'] = null;
212 $data['category_discounts'] = null;
213 } else {
214 // Only process group discount fields if enabled
215 if (isset($data['min_group_size'])) {
216 $data['min_group_size'] = !empty($data['min_group_size']) ? absint($data['min_group_size']) : null;
217 }
218
219 if (isset($data['max_group_size'])) {
220 $data['max_group_size'] = !empty($data['max_group_size']) ? absint($data['max_group_size']) : null;
221 }
222
223 // Validate min/max group size logic
224 if ($data['min_group_size'] && $data['max_group_size'] && $data['min_group_size'] >= $data['max_group_size']) {
225 throw new \InvalidArgumentException('Minimum group size must be less than maximum group size');
226 }
227
228 if (isset($data['group_discount_type'])) {
229 $allowed_types = ['percentage', 'fixed'];
230 $data['group_discount_type'] = in_array($data['group_discount_type'], $allowed_types, true) ? $data['group_discount_type'] : 'percentage';
231 } else {
232 $data['group_discount_type'] = 'percentage';
233 }
234
235 if (isset($data['group_discount_amount'])) {
236 $data['group_discount_amount'] = !empty($data['group_discount_amount']) ? (float) $data['group_discount_amount'] : null;
237 }
238
239 if (isset($data['group_discount_mode'])) {
240 $allowed_modes = ['total', 'category_based'];
241 $data['group_discount_mode'] = in_array($data['group_discount_mode'], $allowed_modes, true) ? $data['group_discount_mode'] : 'total';
242 } else {
243 $data['group_discount_mode'] = 'total';
244 }
245
246 // Process group discount ranges (for total mode)
247 if (isset($data['group_discount_ranges']) && is_array($data['group_discount_ranges'])) {
248 $processedRanges = [];
249 $minGroupSizeFromRanges = null;
250 foreach ($data['group_discount_ranges'] as $range) {
251 if (!is_array($range)) continue;
252
253 $allowed_types = ['percentage', 'fixed'];
254 $discountType = isset($range['discount_type']) && in_array($range['discount_type'], $allowed_types, true)
255 ? $range['discount_type']
256 : 'percentage';
257
258 $discountAmount = isset($range['discount_amount']) ? (float) $range['discount_amount'] : 0;
259 if ($discountAmount < 0) {
260 throw new \InvalidArgumentException('Group discount amount cannot be negative');
261 }
262
263 $rangeMinSize = isset($range['min_group_size']) && $range['min_group_size'] !== '' ? absint($range['min_group_size']) : null;
264
265 // Track the minimum group size from all ranges
266 if ($rangeMinSize !== null && ($minGroupSizeFromRanges === null || $rangeMinSize < $minGroupSizeFromRanges)) {
267 $minGroupSizeFromRanges = $rangeMinSize;
268 }
269
270 $processedRanges[] = [
271 'id' => sanitize_text_field($range['id'] ?? uniqid()),
272 'min_group_size' => $rangeMinSize,
273 'max_group_size' => isset($range['max_group_size']) && $range['max_group_size'] !== '' ? absint($range['max_group_size']) : null,
274 'discount_type' => $discountType,
275 'discount_amount' => $discountAmount,
276 'categories' => $range['categories'] ?? []
277 ];
278 }
279 $data['group_discount_ranges'] = !empty($processedRanges) ? json_encode($processedRanges) : null;
280
281 // Set min_group_size from ranges if not already set (for backward compatibility with frontend query)
282 if ($minGroupSizeFromRanges !== null && empty($data['min_group_size'])) {
283 $data['min_group_size'] = $minGroupSizeFromRanges;
284 }
285 } elseif (isset($data['group_discount_ranges'])) {
286 $data['group_discount_ranges'] = null;
287 }
288
289 // Process category discounts (new format with traveler categories and ranges)
290 if (isset($data['category_discounts']) && is_array($data['category_discounts'])) {
291 $processedCategories = [];
292 foreach ($data['category_discounts'] as $categoryData) {
293 // New format: {traveler_category_id, traveler_category_label, ranges: [...]}
294 if (is_array($categoryData) && isset($categoryData['traveler_category_id'])) {
295 $processedCategory = [
296 'traveler_category_id' => sanitize_text_field($categoryData['traveler_category_id']),
297 'traveler_category_label' => sanitize_text_field($categoryData['traveler_category_label'] ?? ''),
298 'ranges' => []
299 ];
300
301 // Process ranges for this category
302 if (isset($categoryData['ranges']) && is_array($categoryData['ranges'])) {
303 foreach ($categoryData['ranges'] as $range) {
304 if (!is_array($range)) continue;
305
306 $allowed_types = ['percentage', 'fixed'];
307 $discountType = isset($range['discount_type']) && in_array($range['discount_type'], $allowed_types, true)
308 ? $range['discount_type']
309 : 'percentage';
310
311 $discountAmount = isset($range['discount_amount']) ? (float) $range['discount_amount'] : 0;
312 if ($discountAmount < 0) {
313 throw new \InvalidArgumentException('Category discount amount cannot be negative');
314 }
315
316 $processedCategory['ranges'][] = [
317 'id' => sanitize_text_field($range['id'] ?? uniqid()),
318 'min_group_size' => isset($range['min_group_size']) && $range['min_group_size'] !== '' ? absint($range['min_group_size']) : null,
319 'max_group_size' => isset($range['max_group_size']) && $range['max_group_size'] !== '' ? absint($range['max_group_size']) : null,
320 'discount_type' => $discountType,
321 'discount_amount' => $discountAmount
322 ];
323 }
324 }
325
326 $processedCategories[] = $processedCategory;
327 }
328 // Legacy format support: {category => {type, amount}}
329 elseif (is_array($categoryData) && isset($categoryData['type']) && isset($categoryData['amount'])) {
330 $allowed_types = ['percentage', 'fixed'];
331 if (!in_array($categoryData['type'], $allowed_types, true)) {
332 throw new \InvalidArgumentException('Invalid category discount type');
333 }
334 if ((float) $categoryData['amount'] < 0) {
335 throw new \InvalidArgumentException('Category discount amount cannot be negative');
336 }
337 $processedCategories[] = $categoryData;
338 }
339 }
340 $data['category_discounts'] = !empty($processedCategories) ? json_encode($processedCategories) : null;
341 } elseif (isset($data['category_discounts'])) {
342 $data['category_discounts'] = null;
343 }
344 }
345 } else {
346 // If is_group_discount is not set, default to false and clear fields
347 $data['is_group_discount'] = false;
348 $data['min_group_size'] = null;
349 $data['max_group_size'] = null;
350 $data['group_discount_type'] = null;
351 $data['group_discount_amount'] = null;
352 $data['group_discount_mode'] = null;
353 $data['category_discounts'] = null;
354 }
355
356 $current_user_id = get_current_user_id();
357 $data['created_by'] = absint($current_user_id);
358 $data['updated_by'] = absint($current_user_id);
359 $data['usage_count'] = 0; // Always start at 0
360
361 return $data;
362 }
363
364 protected function processBeforeUpdate(int $id, array $data): array
365 {
366 if (isset($data['code'])) {
367 $data['code'] = strtoupper(sanitize_text_field($data['code']));
368 }
369
370 if (isset($data['description'])) {
371 $data['description'] = sanitize_textarea_field($data['description']);
372 }
373
374 if (isset($data['type'])) {
375 $allowed_types = ['percentage', 'fixed'];
376 $data['type'] = in_array($data['type'], $allowed_types, true) ? $data['type'] : 'percentage';
377 }
378
379 if (isset($data['amount'])) {
380 $data['amount'] = (float) $data['amount'];
381 }
382
383 if (isset($data['max_discount_amount'])) {
384 $data['max_discount_amount'] = !empty($data['max_discount_amount']) ? (float) $data['max_discount_amount'] : null;
385 }
386
387 if (isset($data['usage_limit'])) {
388 $data['usage_limit'] = absint($data['usage_limit']);
389 }
390
391 if (isset($data['usage_limit_per_customer'])) {
392 $data['usage_limit_per_customer'] = absint($data['usage_limit_per_customer']);
393 }
394
395 if (isset($data['valid_from']) && !empty($data['valid_from'])) {
396 $data['valid_from'] = sanitize_text_field($data['valid_from']);
397 } elseif (isset($data['valid_from']) && empty($data['valid_from'])) {
398 $data['valid_from'] = null;
399 }
400
401 if (isset($data['expiry_date']) && !empty($data['expiry_date'])) {
402 $data['expiry_date'] = sanitize_text_field($data['expiry_date']);
403 } elseif (isset($data['expiry_date']) && empty($data['expiry_date'])) {
404 $data['expiry_date'] = null;
405 }
406
407 if (isset($data['status'])) {
408 $allowed_statuses = ['draft', 'publish', 'trash'];
409 $data['status'] = in_array($data['status'], $allowed_statuses, true) ? $data['status'] : 'draft';
410 }
411
412 if (isset($data['applicable_to'])) {
413 $allowed_applicable_to = ['all', 'specific_trips'];
414 $data['applicable_to'] = in_array($data['applicable_to'], $allowed_applicable_to, true) ? $data['applicable_to'] : 'all';
415 }
416
417 if (isset($data['trip_ids']) && is_array($data['trip_ids'])) {
418 $trip_ids = array_map('absint', $data['trip_ids']);
419 $data['trip_ids'] = maybe_serialize($trip_ids);
420 } elseif (isset($data['trip_ids']) && empty($data['trip_ids'])) {
421 $data['trip_ids'] = null;
422 }
423
424 if (isset($data['min_amount'])) {
425 $data['min_amount'] = !empty($data['min_amount']) ? (float) $data['min_amount'] : null;
426 }
427
428 if (isset($data['first_time_customer_only'])) {
429 $data['first_time_customer_only'] = (bool) $data['first_time_customer_only'];
430 }
431
432 // Handle discount_mode (promo, group, or both)
433 if (isset($data['discount_mode'])) {
434 $allowed_modes = ['promo', 'group', 'both'];
435 $data['discount_mode'] = in_array($data['discount_mode'], $allowed_modes, true) ? $data['discount_mode'] : 'both';
436
437 // If discount_mode is 'group', ensure is_group_discount is true
438 if ($data['discount_mode'] === 'group') {
439 $data['is_group_discount'] = true;
440 }
441 }
442
443 if (isset($data['is_group_discount'])) {
444 $data['is_group_discount'] = (bool) $data['is_group_discount'];
445
446 // If group discount is disabled, clear all group discount fields
447 if (!$data['is_group_discount']) {
448 $data['min_group_size'] = null;
449 $data['max_group_size'] = null;
450 $data['group_discount_type'] = null;
451 $data['group_discount_amount'] = null;
452 $data['group_discount_mode'] = null;
453 $data['category_discounts'] = null;
454 } else {
455 // Only process group discount fields if enabled
456 if (isset($data['min_group_size'])) {
457 $data['min_group_size'] = !empty($data['min_group_size']) ? absint($data['min_group_size']) : null;
458 }
459
460 if (isset($data['max_group_size'])) {
461 $data['max_group_size'] = !empty($data['max_group_size']) ? absint($data['max_group_size']) : null;
462 }
463
464 // Validate min/max group size logic
465 if ($data['min_group_size'] && $data['max_group_size'] && $data['min_group_size'] >= $data['max_group_size']) {
466 throw new \InvalidArgumentException('Minimum group size must be less than maximum group size');
467 }
468
469 if (isset($data['group_discount_type'])) {
470 $allowed_types = ['percentage', 'fixed'];
471 $data['group_discount_type'] = in_array($data['group_discount_type'], $allowed_types, true) ? $data['group_discount_type'] : 'percentage';
472 }
473
474 if (isset($data['group_discount_amount'])) {
475 $data['group_discount_amount'] = !empty($data['group_discount_amount']) ? (float) $data['group_discount_amount'] : null;
476 }
477
478 if (isset($data['group_discount_mode'])) {
479 $allowed_modes = ['total', 'category_based'];
480 $data['group_discount_mode'] = in_array($data['group_discount_mode'], $allowed_modes, true) ? $data['group_discount_mode'] : 'total';
481 }
482
483 // Process group discount ranges (for total mode)
484 if (isset($data['group_discount_ranges']) && is_array($data['group_discount_ranges'])) {
485 $processedRanges = [];
486 foreach ($data['group_discount_ranges'] as $range) {
487 if (!is_array($range)) continue;
488
489 $allowed_types = ['percentage', 'fixed'];
490 $discountType = isset($range['discount_type']) && in_array($range['discount_type'], $allowed_types, true)
491 ? $range['discount_type']
492 : 'percentage';
493
494 $discountAmount = isset($range['discount_amount']) ? (float) $range['discount_amount'] : 0;
495 if ($discountAmount < 0) {
496 throw new \InvalidArgumentException('Group discount amount cannot be negative');
497 }
498
499 $processedRanges[] = [
500 'id' => sanitize_text_field($range['id'] ?? uniqid()),
501 'min_group_size' => isset($range['min_group_size']) && $range['min_group_size'] !== '' ? absint($range['min_group_size']) : null,
502 'max_group_size' => isset($range['max_group_size']) && $range['max_group_size'] !== '' ? absint($range['max_group_size']) : null,
503 'discount_type' => $discountType,
504 'discount_amount' => $discountAmount,
505 'categories' => $range['categories'] ?? []
506 ];
507 }
508 $data['group_discount_ranges'] = !empty($processedRanges) ? json_encode($processedRanges) : null;
509 } elseif (isset($data['group_discount_ranges']) && empty($data['group_discount_ranges'])) {
510 $data['group_discount_ranges'] = null;
511 }
512
513 // Process category discounts (new format with traveler categories and ranges)
514 if (isset($data['category_discounts']) && is_array($data['category_discounts'])) {
515 $processedCategories = [];
516 foreach ($data['category_discounts'] as $categoryData) {
517 // New format: {traveler_category_id, traveler_category_label, ranges: [...]}
518 if (is_array($categoryData) && isset($categoryData['traveler_category_id'])) {
519 $processedCategory = [
520 'traveler_category_id' => sanitize_text_field($categoryData['traveler_category_id']),
521 'traveler_category_label' => sanitize_text_field($categoryData['traveler_category_label'] ?? ''),
522 'ranges' => []
523 ];
524
525 // Process ranges for this category
526 if (isset($categoryData['ranges']) && is_array($categoryData['ranges'])) {
527 foreach ($categoryData['ranges'] as $range) {
528 if (!is_array($range)) continue;
529
530 $allowed_types = ['percentage', 'fixed'];
531 $discountType = isset($range['discount_type']) && in_array($range['discount_type'], $allowed_types, true)
532 ? $range['discount_type']
533 : 'percentage';
534
535 $discountAmount = isset($range['discount_amount']) ? (float) $range['discount_amount'] : 0;
536 if ($discountAmount < 0) {
537 throw new \InvalidArgumentException('Category discount amount cannot be negative');
538 }
539
540 $processedCategory['ranges'][] = [
541 'id' => sanitize_text_field($range['id'] ?? uniqid()),
542 'min_group_size' => isset($range['min_group_size']) && $range['min_group_size'] !== '' ? absint($range['min_group_size']) : null,
543 'max_group_size' => isset($range['max_group_size']) && $range['max_group_size'] !== '' ? absint($range['max_group_size']) : null,
544 'discount_type' => $discountType,
545 'discount_amount' => $discountAmount
546 ];
547 }
548 }
549
550 $processedCategories[] = $processedCategory;
551 }
552 // Legacy format support: {category => {type, amount}}
553 elseif (is_array($categoryData) && isset($categoryData['type']) && isset($categoryData['amount'])) {
554 $allowed_types = ['percentage', 'fixed'];
555 if (!in_array($categoryData['type'], $allowed_types, true)) {
556 throw new \InvalidArgumentException('Invalid category discount type');
557 }
558 if ((float) $categoryData['amount'] < 0) {
559 throw new \InvalidArgumentException('Category discount amount cannot be negative');
560 }
561 $processedCategories[] = $categoryData;
562 }
563 }
564 $data['category_discounts'] = !empty($processedCategories) ? json_encode($processedCategories) : null;
565 } elseif (isset($data['category_discounts']) && empty($data['category_discounts'])) {
566 $data['category_discounts'] = null;
567 }
568 }
569 } else {
570 // If is_group_discount is not set, keep existing values (don't clear on partial updates)
571 // Only clear if explicitly set to false
572 }
573
574 $data['updated_by'] = absint(get_current_user_id());
575
576 return $data;
577 }
578
579 public function getAll(array $args = []): array
580 {
581 if (!empty($args['search'])) {
582 $search = sanitize_text_field($args['search']);
583 return $this->repository->search($search, $args);
584 }
585
586 if (!empty($args['status']) && $args['status'] !== 'all') {
587 $allowed_statuses = ['draft', 'publish', 'trash', 'expired'];
588 $status = in_array($args['status'], $allowed_statuses, true) ? $args['status'] : null;
589 if ($status) {
590 $args['where']['status'] = $status;
591 }
592 }
593
594 if (!empty($args['type']) && $args['type'] !== 'all') {
595 $allowed_types = ['percentage', 'fixed'];
596 $type = in_array($args['type'], $allowed_types, true) ? $args['type'] : null;
597 if ($type) {
598 $args['where']['type'] = $type;
599 }
600 }
601
602 return $this->repository->all($args);
603 }
604
605 public function count(array $args = []): int
606 {
607 if (!empty($args['search'])) {
608 $search = sanitize_text_field($args['search']);
609 $items = $this->repository->search($search, $args);
610 return count($items);
611 }
612
613 if (!empty($args['status']) && $args['status'] !== 'all') {
614 $allowed_statuses = ['draft', 'publish', 'trash', 'expired'];
615 $status = in_array($args['status'], $allowed_statuses, true) ? $args['status'] : null;
616 if ($status) {
617 $args['where']['status'] = $status;
618 }
619 }
620
621 if (!empty($args['type']) && $args['type'] !== 'all') {
622 $allowed_types = ['percentage', 'fixed'];
623 $type = in_array($args['type'], $allowed_types, true) ? $args['type'] : null;
624 if ($type) {
625 $args['where']['type'] = $type;
626 }
627 }
628
629 return $this->repository->count($args);
630 }
631
632 /**
633 * Admin toolbar: counts per discount status (stable, unfiltered).
634 *
635 * @return array{all: int, publish: int, draft: int, trash: int, expired: int}
636 */
637 public function getAdminStatusCounts(): array
638 {
639 return $this->repository->getAdminStatusCounts();
640 }
641
642 /**
643 * Calculate group discounts for booking
644 */
645 public function calculateGroupDiscounts(array $bookingData): array {
646 // Check if Advanced Discount module is enabled - group discounts are a Pro feature
647 if (!apply_filters('yatra_advanced_discount_enabled', false)) {
648 return [];
649 }
650
651 $totalTravelers = $this->countTotalTravelers($bookingData);
652
653 // Find applicable group discounts
654 $applicableDiscounts = $this->findApplicableGroupDiscounts($bookingData, $totalTravelers);
655
656 $discounts = [];
657 foreach ($applicableDiscounts as $discount) {
658 $discountAmount = $this->calculateGroupDiscountAmount($discount, $bookingData, $totalTravelers);
659 if ($discountAmount > 0) {
660 $discounts[] = [
661 'discount_id' => $discount->id,
662 'type' => 'group_discount',
663 'amount' => $discountAmount,
664 'description' => $this->generateGroupDiscountDescription($discount, $totalTravelers),
665 'mode' => $discount->group_discount_mode,
666 'category_breakdown' => $discount->group_discount_mode === 'category_based' ?
667 $this->calculateCategoryBreakdown($discount, $bookingData) : null
668 ];
669 }
670 }
671
672 return $discounts;
673 }
674
675 /**
676 * Count total billable travelers
677 */
678 private function countTotalTravelers(array $bookingData): int {
679 // Exclude infants from group size calculation (typically free or minimal cost)
680 return (
681 ($bookingData['travelers']['adults'] ?? 0) +
682 ($bookingData['travelers']['children'] ?? 0) +
683 ($bookingData['travelers']['seniors'] ?? 0)
684 );
685 }
686
687 /**
688 * Find applicable group discounts for the booking
689 */
690 private function findApplicableGroupDiscounts(array $bookingData, int $totalTravelers): array {
691 return \Yatra\Models\Discount::where('is_group_discount', true)
692 ->where('status', 'publish')
693 ->where(function($query) use ($bookingData) {
694 $query->whereNull('valid_from')
695 ->orWhere('valid_from', '<=', $bookingData['start_date']);
696 })
697 ->where(function($query) use ($bookingData) {
698 $query->whereNull('expiry_date')
699 ->orWhere('expiry_date', '>=', $bookingData['start_date']);
700 })
701 ->where(function($query) use ($bookingData) {
702 $query->where('applicable_to', 'all')
703 ->orWhere(function($subQuery) use ($bookingData) {
704 $subQuery->where('applicable_to', 'specific_trips')
705 ->whereJsonContains('trip_ids', $bookingData['trip_id']);
706 });
707 })
708 ->where(function($query) use ($totalTravelers) {
709 $query->where(function($subQuery) use ($totalTravelers) {
710 // Check if traveler count falls within any range
711 $subQuery->where(function($rangeQuery) use ($totalTravelers) {
712 // 1-10 range
713 $rangeQuery->where('min_group_size', '<=', $totalTravelers)
714 ->where(function($maxQuery) {
715 $maxQuery->whereNull('max_group_size')
716 ->orWhere('max_group_size', '>=', $totalTravelers);
717 });
718 });
719 });
720 })
721 ->orderBy('min_group_size', 'desc') // Prefer more restrictive discounts first
722 ->get();
723 }
724
725 /**
726 * Calculate group discount amount based on mode
727 */
728 private function calculateGroupDiscountAmount($discount, array $bookingData, int $totalTravelers): float {
729 if ($discount->group_discount_mode === 'category_based') {
730 return $this->calculateCategoryBasedDiscount($discount, $bookingData);
731 } else {
732 return $this->calculateTotalBasedDiscount($discount, $bookingData['subtotal'] ?? 0);
733 }
734 }
735
736 /**
737 * Calculate total-based group discount
738 */
739 private function calculateTotalBasedDiscount($discount, float $subtotal): float {
740 if ($discount->group_discount_type === 'percentage') {
741 $discountAmount = $subtotal * ($discount->group_discount_amount / 100);
742 } else {
743 $discountAmount = $discount->group_discount_amount;
744 }
745
746 return min($discountAmount, $subtotal); // Never exceed subtotal
747 }
748
749 /**
750 * Calculate category-based group discount
751 */
752 private function calculateCategoryBasedDiscount($discount, array $bookingData): float {
753 $totalDiscount = 0;
754 $categoryDiscounts = $discount->category_discounts ?? [];
755
756 foreach ($categoryDiscounts as $category => $discountConfig) {
757 $travelerCount = $bookingData['travelers'][$category] ?? 0;
758 if ($travelerCount > 0) {
759 // Calculate per-person price for this category
760 $categoryPrice = $this->calculateCategoryPrice($bookingData, $category);
761
762 if ($discountConfig['type'] === 'percentage') {
763 $categoryDiscount = $categoryPrice * $travelerCount * ($discountConfig['amount'] / 100);
764 } else {
765 $categoryDiscount = min($discountConfig['amount'] * $travelerCount, $categoryPrice * $travelerCount);
766 }
767
768 $totalDiscount += $categoryDiscount;
769 }
770 }
771
772 return $totalDiscount;
773 }
774
775 /**
776 * Calculate price per person for a specific traveler category
777 */
778 private function calculateCategoryPrice(array $bookingData, string $category): float {
779 $basePrice = $bookingData['trip_price'] ?? 0;
780
781 // Apply category multipliers (children might be 80%, seniors 90%, etc.)
782 $multipliers = [
783 'adults' => 1.0,
784 'children' => 0.8, // 80% of adult price
785 'seniors' => 0.9, // 90% of adult price
786 ];
787
788 return $basePrice * ($multipliers[$category] ?? 1.0);
789 }
790
791 /**
792 * Calculate category breakdown for display
793 */
794 private function calculateCategoryBreakdown($discount, array $bookingData): array {
795 $breakdown = [];
796 $categoryDiscounts = $discount->category_discounts ?? [];
797
798 foreach ($categoryDiscounts as $category => $discountConfig) {
799 $travelerCount = $bookingData['travelers'][$category] ?? 0;
800 if ($travelerCount > 0) {
801 $categoryPrice = $this->calculateCategoryPrice($bookingData, $category);
802 $totalCategoryPrice = $categoryPrice * $travelerCount;
803
804 if ($discountConfig['type'] === 'percentage') {
805 $discountAmount = $totalCategoryPrice * ($discountConfig['amount'] / 100);
806 } else {
807 $discountAmount = min($discountConfig['amount'] * $travelerCount, $totalCategoryPrice);
808 }
809
810 $breakdown[$category] = [
811 'traveler_count' => $travelerCount,
812 'original_price' => $totalCategoryPrice,
813 'discount_amount' => $discountAmount,
814 'final_price' => $totalCategoryPrice - $discountAmount,
815 'discount_type' => $discountConfig['type'],
816 'discount_rate' => $discountConfig['amount']
817 ];
818 }
819 }
820
821 return $breakdown;
822 }
823
824 /**
825 * Generate human-readable discount description
826 */
827 private function generateGroupDiscountDescription($discount, int $totalTravelers): string {
828 $rangeText = $this->formatGroupSizeRange($discount);
829 $discountText = $this->formatDiscountAmount($discount);
830
831 if ($discount->group_discount_mode === 'category_based') {
832 return "Group discount for {$totalTravelers} travelers ({$rangeText}) - Category-based rates";
833 } else {
834 return "Group discount for {$totalTravelers} travelers ({$rangeText}): {$discountText}";
835 }
836 }
837
838 /**
839 * Format group size range for display
840 */
841 private function formatGroupSizeRange($discount): string {
842 if ($discount->max_group_size) {
843 return "{$discount->min_group_size}-{$discount->max_group_size} people";
844 } else {
845 return "{$discount->min_group_size}+ people";
846 }
847 }
848
849 /**
850 * Format discount amount for display
851 */
852 private function formatDiscountAmount($discount): string {
853 $amount = $discount->group_discount_amount ?? 0;
854 if ($discount->group_discount_type === 'percentage') {
855 return "{$amount}% off";
856 } else {
857 return "$" . number_format((float) $amount, 2) . " off";
858 }
859 }
860
861 /**
862 * @param array $travelerCounts Array of category_id => count (e.g., ['3' => 4, '5' => 1])
863 * @param array $priceTypes Array of price type objects with category_id and effective_price
864 * @return array|null Discount info or null if no discount applies
865 */
866 public function calculateGroupDiscount(int $tripId, array $travelerCounts, array $priceTypes = []): ?array
867 {
868 // Cast tripId to int to ensure type safety
869 $tripId = (int) $tripId;
870
871 // Check if Advanced Discount module is enabled - group discounts are a Pro feature
872 if (!apply_filters('yatra_advanced_discount_enabled', false)) {
873 return null;
874 }
875
876 $groupDiscounts = $this->getGroupDiscountsForTrip($tripId);
877
878 if (empty($groupDiscounts)) {
879 return null;
880 }
881
882
883 $totalTravelers = array_sum(array_map('intval', $travelerCounts));
884
885 // Build price lookup by category_id
886 $priceByCategory = [];
887 foreach ($priceTypes as $pt) {
888 $pt = (object) $pt;
889 $categoryId = $pt->category_id ?? null;
890 if ($categoryId !== null) {
891 $priceByCategory[$categoryId] = (float) ($pt->effective_price ?? $pt->sale_price ?? $pt->original_price ?? 0);
892 }
893 }
894
895 // Debug: Log traveler counts and total
896 if (WP_DEBUG && WP_DEBUG_LOG) {
897 error_log('Yatra Debug - Group Discount Calculation: ' . print_r([
898 'total_travelers' => $totalTravelers,
899 'traveler_counts' => $travelerCounts,
900 'price_by_category' => $priceByCategory
901 ], true));
902 }
903
904 foreach ($groupDiscounts as $discount) {
905 $discountMode = $discount->discount_mode ?? 'total';
906
907 // Debug: Log discount details
908 if (WP_DEBUG && WP_DEBUG_LOG) {
909 error_log('Yatra Debug - Processing discount: ' . print_r([
910 'discount_code' => $discount->code ?? 'unknown',
911 'discount_mode' => $discountMode,
912 'discount_amount' => $discount->amount ?? $discount->discount_amount ?? 'not_set',
913 'discount_type' => $discount->type ?? $discount->discount_type ?? 'not_set',
914 'category_discounts' => !empty($discount->category_discounts),
915 'group_discount_ranges' => !empty($discount->group_discount_ranges)
916 ], true));
917 }
918
919 // Category-based discounts: check each category's count and apply to that category's subtotal
920 if ($discountMode === 'category_based' && !empty($discount->category_discounts)) {
921 $totalDiscountAmount = 0;
922 $appliedCategories = [];
923
924 $categoryDiscounts = is_array($discount->category_discounts) ? $discount->category_discounts : unserialize($discount->category_discounts);
925 foreach ($categoryDiscounts as $catDiscount) {
926 $catDiscount = (object) $catDiscount;
927 $categoryId = $catDiscount->traveler_category_id ?? null;
928 if ($categoryId === null) continue;
929
930 // Get the count for this specific category
931 $categoryCount = (int) ($travelerCounts[$categoryId] ?? 0);
932 if ($categoryCount <= 0) continue;
933
934 // Check if this category's count falls within any range
935 if (!empty($catDiscount->ranges)) {
936 $ranges = is_array($catDiscount->ranges) ? $catDiscount->ranges : unserialize($catDiscount->ranges);
937 foreach ($ranges as $range) {
938 $range = (object) $range;
939 $minSize = (int) ($range->min_group_size ?? 0);
940 $maxSize = !empty($range->max_group_size) ? (int) $range->max_group_size : PHP_INT_MAX;
941
942 if ($categoryCount >= $minSize && $categoryCount <= $maxSize) {
943 $discountType = $range->discount_type ?? 'percentage';
944 $discountValue = (float) ($range->discount_amount ?? 0);
945
946 // Calculate discount for this category's subtotal only
947 $categoryPrice = $priceByCategory[$categoryId] ?? 0;
948 $categorySubtotal = $categoryPrice * $categoryCount;
949
950 if ($discountType === 'percentage') {
951 $categoryDiscount = $categorySubtotal * ($discountValue / 100);
952 } else {
953 $categoryDiscount = $discountValue;
954 }
955
956 $totalDiscountAmount += $categoryDiscount;
957 $appliedCategories[] = [
958 'category_id' => $categoryId,
959 'category_label' => $catDiscount->traveler_category_label ?? 'Traveler',
960 'count' => $categoryCount,
961 'discount_type' => $discountType,
962 'discount_value' => $discountValue,
963 'discount_amount' => $categoryDiscount,
964 ];
965 break; // Found matching range for this category
966 }
967 }
968 }
969 }
970
971 if ($totalDiscountAmount > 0) {
972 // Build label with discount info from applied categories
973 $discountInfo = '';
974 if (!empty($appliedCategories)) {
975 $firstCat = $appliedCategories[0];
976 if ($firstCat['discount_type'] === 'percentage') {
977 $discountInfo = sprintf(__('Group Discount (%s%%)', 'yatra'), $firstCat['discount_value']);
978 } else {
979 $discountInfo = sprintf(__('Group Discount (%s)', 'yatra'), yatra_format_price($firstCat['discount_value']));
980 }
981 } else {
982 $discountInfo = __('Group Discount', 'yatra');
983 }
984
985 return [
986 'type' => 'category_based',
987 'amount' => round($totalDiscountAmount, 2),
988 'code' => $discount->code ?? null,
989 'label' => $discountInfo,
990 'applied_categories' => $appliedCategories,
991 ];
992 }
993 }
994 // Total-based discounts: check total travelers and apply to total
995 elseif (!empty($discount->group_discount_ranges)) {
996 $groupDiscountRanges = is_array($discount->group_discount_ranges) ? $discount->group_discount_ranges : unserialize($discount->group_discount_ranges);
997 foreach ($groupDiscountRanges as $range) {
998 $range = (object) $range;
999 $minSize = (int) ($range->min_group_size ?? 0);
1000 $maxSize = !empty($range->max_group_size) ? (int) $range->max_group_size : PHP_INT_MAX;
1001
1002 if ($totalTravelers >= $minSize && $totalTravelers <= $maxSize) {
1003 $discountType = $range->discount_type ?? 'percentage';
1004 $discountValue = (float) ($range->discount_amount ?? 0);
1005
1006 // Calculate total subtotal from all categories
1007 $totalSubtotal = 0;
1008 foreach ($travelerCounts as $catId => $count) {
1009 $categoryPrice = $priceByCategory[$catId] ?? 0;
1010 $totalSubtotal += $categoryPrice * $count;
1011 }
1012
1013 // Calculate the actual discount amount
1014 $calculatedAmount = $discountType === 'percentage'
1015 ? $totalSubtotal * ($discountValue / 100)
1016 : $discountValue;
1017
1018 return [
1019 'type' => $discountType,
1020 'value' => $discountValue,
1021 'amount' => round($calculatedAmount, 2),
1022 'code' => $discount->code ?? null,
1023 'label' => $discountType === 'percentage'
1024 ? sprintf(__('Group Discount (%s%%)', 'yatra'), $discountValue)
1025 : sprintf(__('Group Discount (%s)', 'yatra'), yatra_format_price($discountValue)),
1026 ];
1027 }
1028 }
1029 } else {
1030 // Legacy format
1031 $minSize = (int) ($discount->min_group_size ?? 0);
1032 $maxSize = !empty($discount->max_group_size) ? (int) $discount->max_group_size : PHP_INT_MAX;
1033
1034 // If min_size is 0 but we have a discount value, this might be a simple discount
1035 // that applies to any group size >= 2
1036 if ($minSize === 0 && $discountValue > 0) {
1037 $minSize = 2; // Default to minimum 2 travelers for group discount
1038 }
1039
1040 // Handle different field names for discount type and value
1041 $discountType = $discount->type ?? $discount->discount_type ?? 'percentage';
1042 $discountValue = (float) ($discount->amount ?? $discount->discount_amount ?? 0);
1043
1044 // Debug: Log legacy format processing
1045 if (WP_DEBUG && WP_DEBUG_LOG) {
1046 error_log('Yatra Debug - Legacy format processing: ' . print_r([
1047 'min_size' => $minSize,
1048 'max_size' => $maxSize,
1049 'total_travelers' => $totalTravelers,
1050 'discount_type' => $discountType,
1051 'discount_value' => $discountValue,
1052 'meets_criteria' => $totalTravelers >= $minSize && $totalTravelers <= $maxSize
1053 ], true));
1054 }
1055
1056 if ($totalTravelers >= $minSize && $totalTravelers <= $maxSize) {
1057
1058 // Calculate total subtotal from all categories
1059 $totalSubtotal = 0;
1060 foreach ($travelerCounts as $catId => $count) {
1061 $categoryPrice = $priceByCategory[$catId] ?? 0;
1062 $totalSubtotal += $categoryPrice * $count;
1063 }
1064
1065 // Calculate the actual discount amount
1066 $calculatedAmount = $discountType === 'percentage'
1067 ? $totalSubtotal * ($discountValue / 100)
1068 : $discountValue;
1069
1070 return [
1071 'type' => $discountType,
1072 'value' => $discountValue,
1073 'amount' => round($calculatedAmount, 2),
1074 'code' => $discount->code ?? null,
1075 'label' => $discountType === 'percentage'
1076 ? sprintf(__('Group Discount (%s%%)', 'yatra'), $discountValue)
1077 : sprintf(__('Group Discount (%s)', 'yatra'), yatra_format_price($discountValue)),
1078 ];
1079 }
1080 }
1081 }
1082
1083 return null;
1084 }
1085
1086 /**
1087 * Calculate coupon discount for booking
1088 *
1089 * @param string $coupon_code Coupon code to apply
1090 * @param float $subtotal Subtotal amount (after group discount)
1091 * @param int $trip_id Trip ID
1092 * @param int $travelers_count Total travelers
1093 * @param array $traveler_counts Traveler counts by category
1094 * @return array Coupon discount data with code, type, amount, calculated_amount, label
1095 */
1096 public function calculateCouponDiscount(
1097 string $coupon_code,
1098 float $subtotal,
1099 int $trip_id,
1100 int $travelers_count = 1,
1101 array $traveler_counts = []
1102 ): array {
1103 // Default empty discount
1104 $default = [
1105 'code' => $coupon_code,
1106 'type' => '',
1107 'amount' => 0,
1108 'calculated_amount' => 0,
1109 'label' => __('Coupon Discount', 'yatra'),
1110 ];
1111
1112 if (empty($coupon_code)) {
1113 return $default;
1114 }
1115
1116 // Find discount by code
1117 $discount = $this->repository->findByCode($coupon_code);
1118
1119 $status = (string) ($discount->status ?? '');
1120 // Migrated coupons once used "active"; 3.x uses "publish" for live discounts.
1121 $isLive = ($status === 'publish' || $status === 'active');
1122 if (!$discount || !$isLive) {
1123 return $default;
1124 }
1125
1126 // Validate coupon
1127 $validation = $this->validateCoupon($discount, $trip_id, $subtotal, $travelers_count);
1128 if (!$validation['valid']) {
1129 return $default;
1130 }
1131
1132 // Calculate discount amount
1133 $calculated_discount = 0;
1134
1135 if ($discount->type === 'percentage') {
1136 $calculated_discount = ($subtotal * (float) $discount->amount) / 100;
1137 } elseif ($discount->type === 'fixed') {
1138 $calculated_discount = min((float) $discount->amount, $subtotal);
1139 }
1140
1141 // Apply max discount cap if set
1142 if (!empty($discount->max_discount_amount) && $calculated_discount > (float) $discount->max_discount_amount) {
1143 $calculated_discount = (float) $discount->max_discount_amount;
1144 }
1145
1146 return [
1147 'code' => $coupon_code,
1148 'type' => $discount->type,
1149 'amount' => (float) $discount->amount,
1150 'calculated_amount' => round($calculated_discount, 2),
1151 'label' => $discount->type === 'percentage'
1152 ? sprintf(__('Coupon (%s%%)', 'yatra'), $discount->amount)
1153 : __('Coupon Discount', 'yatra'),
1154 ];
1155 }
1156
1157 /**
1158 * Validate coupon for booking
1159 *
1160 * @param \stdClass $discount Discount object
1161 * @param int $trip_id Trip ID
1162 * @param float $total Total amount
1163 * @param int $travelers_count Travelers count
1164 * @return array Validation result with 'valid' and 'message'
1165 */
1166 private function validateCoupon(\stdClass $discount, int $trip_id, float $total, int $travelers_count): array
1167 {
1168 // Check validity dates
1169 $now = current_time('Y-m-d');
1170
1171 if (!empty($discount->valid_from) && $now < $discount->valid_from) {
1172 return ['valid' => false, 'message' => __('This coupon is not yet valid.', 'yatra')];
1173 }
1174
1175 if (!empty($discount->expiry_date) && $now > $discount->expiry_date) {
1176 return ['valid' => false, 'message' => __('This coupon has expired.', 'yatra')];
1177 }
1178
1179 // Check usage limit
1180 if ($discount->usage_limit > 0 && $discount->usage_count >= $discount->usage_limit) {
1181 return ['valid' => false, 'message' => __('This coupon has reached its usage limit.', 'yatra')];
1182 }
1183
1184 // Check if applicable to this trip
1185 if ($discount->applicable_to === 'specific_trips') {
1186 $trip_ids = is_string($discount->trip_ids) ? maybe_unserialize($discount->trip_ids) : ($discount->trip_ids ?? []);
1187 if (!empty($trip_ids) && !in_array($trip_id, array_map('intval', $trip_ids), true)) {
1188 return ['valid' => false, 'message' => __('This coupon is not applicable to this trip.', 'yatra')];
1189 }
1190 }
1191
1192 // Check minimum amount
1193 if (!empty($discount->min_amount) && $total < (float) $discount->min_amount) {
1194 return [
1195 'valid' => false,
1196 'message' => sprintf(
1197 __('Minimum amount of %s required for this coupon.', 'yatra'),
1198 yatra_format_price((float) $discount->min_amount)
1199 )
1200 ];
1201 }
1202
1203 return ['valid' => true, 'message' => ''];
1204 }
1205 }
1206