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