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