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 / TripPricingService.php

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

425 lines 17.2 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 /**
8 * Centralized Trip Pricing Service
9 *
10 * SINGLE SOURCE OF TRUTH for all trip pricing display logic.
11 * Eliminates redundant pricing resolution across controllers, templates, and shortcodes.
12 *
13 * Used by:
14 * - SingleTripController (sidebar pricing, effective_price_min)
15 * - TripController (availability card pricing)
16 * - BookingSessionController (session pricing)
17 * - content-sidebar.php (sidebar display)
18 * - Shortcodes (Destination, Activity, DiscountAndDeals)
19 * - Listing pages
20 *
21 * PRICING PRIORITY (Regular):
22 * discounted_price → sale_price → original_price
23 *
24 * PRICING PRIORITY (Traveler-Based):
25 * per-category discounted_price → sale_price → original_price
26 * Display shows minimum effective price across all categories
27 *
28 * FILTER HOOKS (for Yatra Pro):
29 * - yatra_resolve_display_pricing → Modify complete display pricing result
30 * - yatra_resolve_card_pricing → Modify per-card pricing on availability section
31 * - yatra_resolve_effective_price → Modify single effective price (used in listings)
32 * - yatra_resolve_pricing_type → Override pricing type detection
33 * - yatra_resolve_price_types → Modify normalized price_types array
34 * - yatra_resolve_discount_info → Modify discount calculation
35 *
36 * @package Yatra\Services
37 */
38 class TripPricingService
39 {
40 /**
41 * Resolve complete display pricing for a trip.
42 *
43 * This is the MAIN method that replaces all inline pricing computation
44 * in SingleTripController, content-sidebar.php, shortcodes, etc.
45 *
46 * @param object $trip Trip object (from any source — raw DB, findWithRelations, Trip model)
47 * @param array|null $availabilityDates Optional availability dates (already resolved by AvailabilityResolutionService)
48 * @return array Complete display pricing data
49 */
50 public static function resolveDisplayPricing(object $trip, ?array $availabilityDates = null): array
51 {
52 $pricing_type = self::resolvePricingType($trip);
53 $price_types = self::resolvePriceTypes($trip);
54 $has_traveler_pricing = ($pricing_type === 'traveler_based' && !empty($price_types));
55
56 // Initialize result
57 $result = [
58 'effective_price_min' => 0.0,
59 'min_category_original_price' => 0.0,
60 'max_discount_percentage' => 0,
61 'current_price' => 0.0,
62 'original_price' => 0.0,
63 'has_discount' => false,
64 'discount_percentage' => 0,
65 'price_prefix' => '',
66 'pricing_type' => $pricing_type,
67 'price_types' => $price_types,
68 'has_traveler_pricing' => $has_traveler_pricing,
69 'currency' => SettingsService::getCurrency(),
70 ];
71
72 if ($has_traveler_pricing) {
73 // Traveler-based:
74 // - If a default category is marked, use that for initial display (listings + single trip page-load).
75 // - Otherwise fall back to minimum effective price across categories (current behavior).
76 $default_price = 0.0;
77 $default_original = 0.0;
78 $min_price = PHP_FLOAT_MAX;
79 $min_original = 0.0;
80 $max_discount = 0;
81
82 foreach ($price_types as $pt) {
83 $pt = (array) $pt;
84 $original = (float) ($pt['original_price'] ?? 0);
85 $discounted = self::resolveCategoryEffectivePrice($pt);
86
87 if (!empty($pt['is_default']) && $default_price <= 0 && $discounted > 0) {
88 $default_price = $discounted;
89 $default_original = $original;
90 }
91
92 if ($discounted > 0 && $discounted < $min_price) {
93 $min_price = $discounted;
94 $min_original = $original;
95 }
96
97 // Track max discount across categories
98 if ($original > 0 && $discounted > 0 && $discounted < $original) {
99 $pct = (int) round((($original - $discounted) / $original) * 100);
100 if ($pct > $max_discount) {
101 $max_discount = $pct;
102 }
103 }
104 }
105
106 $chosen_price = $default_price > 0 ? $default_price : ($min_price < PHP_FLOAT_MAX ? $min_price : 0.0);
107 $chosen_original = $default_price > 0 ? $default_original : $min_original;
108
109 if ($chosen_price > 0) {
110 $result['effective_price_min'] = $chosen_price;
111 $result['min_category_original_price'] = $chosen_original;
112 $result['max_discount_percentage'] = $max_discount;
113 $result['current_price'] = $chosen_price;
114 $result['original_price'] = $chosen_original;
115 $result['has_discount'] = $max_discount > 0;
116 $result['discount_percentage'] = $max_discount;
117 $result['price_prefix'] = __('From ', 'yatra');
118 }
119 } else {
120 // Regular pricing: discounted_price → sale_price → original_price
121 $original = (float) ($trip->original_price ?? 0);
122 $current = self::resolveRegularCurrentPrice($trip);
123
124 $result['effective_price_min'] = $current > 0 ? $current : $original;
125 $result['min_category_original_price'] = $original;
126 $result['current_price'] = $current > 0 ? $current : $original;
127 $result['original_price'] = $original;
128
129 if ($current > 0 && $original > 0 && $current < $original) {
130 $result['has_discount'] = true;
131 $result['discount_percentage'] = (int) round((($original - $current) / $original) * 100);
132 $result['max_discount_percentage'] = $result['discount_percentage'];
133 }
134 }
135
136 // If availability dates are provided, check for lower prices across them
137 if (!empty($availabilityDates)) {
138 $result['price_prefix'] = __('From ', 'yatra');
139 $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing);
140 if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) {
141 $result['effective_price_min'] = $avail_min;
142 $result['current_price'] = $avail_min;
143 }
144 } elseif ($has_traveler_pricing) {
145 $result['price_prefix'] = __('From ', 'yatra');
146 }
147
148 // Pro filter: allows Dynamic Pricing, etc. to modify display pricing
149 return (array) apply_filters('yatra_resolve_display_pricing', $result, $trip, $availabilityDates);
150 }
151
152 /**
153 * Resolve pricing for a single availability card.
154 *
155 * Replaces the inline pricing logic in TripController::render_availability_template.
156 *
157 * @param object $avail Availability object (from AvailabilityResolutionService)
158 * @param object $trip Trip data
159 * @return array Card pricing data
160 */
161 public static function resolveCardPricing(object $avail, object $trip): array
162 {
163 $pricing_type = self::resolvePricingType($trip);
164 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
165 ? $avail->price_types : [];
166
167 // Override to traveler_based if this availability has price_types
168 if (!empty($avail_price_types)) {
169 $pricing_type = 'traveler_based';
170 }
171
172 $result = [
173 'sale_price' => 0.0,
174 'original_price' => 0.0,
175 'has_discount' => false,
176 'discount_percentage' => 0,
177 'pricing_type' => $pricing_type,
178 'price_types' => $avail_price_types,
179 ];
180
181 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
182 // Traveler-based: use first category's price as display
183 $first = (array) $avail_price_types[0];
184 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
185 $result['original_price'] = (float) ($first['original_price'] ?? $result['sale_price']);
186 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
187 // Regular: use pre-calculated effective price
188 $result['sale_price'] = (float) $avail->effective_price;
189 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
190 ? (float) $avail->original_price
191 : $result['sale_price'];
192 } else {
193 // Fallback: trip defaults
194 $result['original_price'] = (float) ($trip->original_price ?? 0);
195 $current = self::resolveRegularCurrentPrice($trip);
196 $result['sale_price'] = $current > 0 ? $current : $result['original_price'];
197 }
198
199 // Compute discount
200 if ($result['original_price'] > 0 && $result['sale_price'] > 0 && $result['sale_price'] < $result['original_price']) {
201 $result['has_discount'] = true;
202 $result['discount_percentage'] = (int) round(
203 (($result['original_price'] - $result['sale_price']) / $result['original_price']) * 100
204 );
205 }
206
207 // Pro filter: Dynamic Pricing per-card
208 return (array) apply_filters('yatra_resolve_card_pricing', $result, $avail, $trip);
209 }
210
211 /**
212 * Get the single effective price for a trip (used in shortcodes for min/max).
213 *
214 * @param object $trip Trip object
215 * @return float Effective price
216 */
217 public static function getEffectivePrice(object $trip): float
218 {
219 $pricing_type = self::resolvePricingType($trip);
220 $price_types = self::resolvePriceTypes($trip);
221
222 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
223 $default = 0.0;
224 $min = PHP_FLOAT_MAX;
225 foreach ($price_types as $pt) {
226 $ptArr = (array) $pt;
227 $price = self::resolveCategoryEffectivePrice($ptArr);
228 if (!empty($ptArr['is_default']) && $default <= 0 && $price > 0) {
229 $default = $price;
230 }
231 if ($price > 0 && $price < $min) {
232 $min = $price;
233 }
234 }
235 $effective = $default > 0 ? $default : ($min < PHP_FLOAT_MAX ? $min : 0.0);
236 } else {
237 $current = self::resolveRegularCurrentPrice($trip);
238 $original = (float) ($trip->original_price ?? 0);
239 $effective = $current > 0 ? $current : $original;
240 }
241
242 return (float) apply_filters('yatra_resolve_effective_price', $effective, $trip);
243 }
244
245 /**
246 * Resolve the current price for regular pricing.
247 * Priority: discounted_price → sale_price → original_price
248 *
249 * @param object $trip Trip object
250 * @return float Current price (0 if none set)
251 */
252 public static function resolveRegularCurrentPrice(object $trip): float
253 {
254 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
255 return (float) $trip->discounted_price;
256 }
257 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
258 return (float) $trip->sale_price;
259 }
260 if (!empty($trip->original_price) && (float) $trip->original_price > 0) {
261 return (float) $trip->original_price;
262 }
263 return 0.0;
264 }
265
266 /**
267 * Resolve effective price for a single traveler category.
268 * Priority: discounted_price → sale_price → original_price
269 *
270 * @param array $category Category price data
271 * @return float Effective price
272 */
273 public static function resolveCategoryEffectivePrice(array $category): float
274 {
275 if (!empty($category['discounted_price']) && (float) $category['discounted_price'] > 0) {
276 return (float) $category['discounted_price'];
277 }
278 if (!empty($category['sale_price']) && (float) $category['sale_price'] > 0) {
279 return (float) $category['sale_price'];
280 }
281 if (!empty($category['original_price']) && (float) $category['original_price'] > 0) {
282 return (float) $category['original_price'];
283 }
284 if (!empty($category['price']) && (float) $category['price'] > 0) {
285 return (float) $category['price'];
286 }
287 return 0.0;
288 }
289
290 /**
291 * Resolve pricing type for a trip (regular vs traveler_based).
292 *
293 * @param object $trip Trip object
294 * @return string 'regular' or 'traveler_based'
295 */
296 public static function resolvePricingType(object $trip): string
297 {
298 $type = $trip->pricing_type ?? 'regular';
299
300 // Auto-detect: if pricing_type not explicitly set but price_types exist, it's traveler_based
301 if (empty($type) || $type === 'regular') {
302 $price_types = self::resolvePriceTypes($trip);
303 if (!empty($price_types)) {
304 $type = 'traveler_based';
305 }
306 }
307
308 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
309 }
310
311 /**
312 * Normalize price_types from any format into a consistent array of arrays.
313 * Handles: JSON string, array of stdClass, array of arrays, null
314 *
315 * @param object $trip Trip object (checks ->price_types property)
316 * @return array Normalized price_types as array of arrays
317 */
318 public static function resolvePriceTypes(object $trip): array
319 {
320 $raw = $trip->price_types ?? null;
321
322 if (empty($raw)) {
323 return [];
324 }
325
326 // Decode JSON string
327 if (is_string($raw)) {
328 $raw = json_decode($raw, true);
329 if (!is_array($raw)) {
330 return [];
331 }
332 }
333
334 if (!is_array($raw)) {
335 return [];
336 }
337
338 // Normalize each entry to array format
339 $normalized = [];
340 foreach ($raw as $pt) {
341 $pt = (array) $pt;
342 if (empty($pt)) continue;
343
344 $origFromPrice = isset($pt['price']) ? (float) $pt['price'] : null;
345 $orig = isset($pt['original_price']) ? (float) $pt['original_price'] : null;
346 if (($orig === null || $orig <= 0) && $origFromPrice !== null && $origFromPrice > 0) {
347 $orig = $origFromPrice;
348 }
349
350 $normalized[] = [
351 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
352 'original_price' => $orig !== null && $orig > 0 ? $orig : null,
353 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
354 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
355 'label' => $pt['label'] ?? ($pt['category_label'] ?? ($pt['title'] ?? null)),
356 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
357 'category_label' => $pt['category_label'] ?? ($pt['label'] ?? ($pt['title'] ?? null)),
358 'is_default' => !empty($pt['is_default']),
359 ];
360 }
361
362 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
363 }
364
365 /**
366 * Compute discount info from two prices.
367 *
368 * @param float $originalPrice Original price
369 * @param float $currentPrice Current (sale/discounted) price
370 * @return array Discount data
371 */
372 public static function computeDiscount(float $originalPrice, float $currentPrice): array
373 {
374 $result = [
375 'has_discount' => false,
376 'discount_amount' => 0.0,
377 'discount_percentage' => 0,
378 'original_price' => $originalPrice,
379 'current_price' => $currentPrice,
380 ];
381
382 if ($originalPrice > 0 && $currentPrice > 0 && $currentPrice < $originalPrice) {
383 $result['has_discount'] = true;
384 $result['discount_amount'] = round($originalPrice - $currentPrice, 2);
385 $result['discount_percentage'] = (int) round(
386 (($originalPrice - $currentPrice) / $originalPrice) * 100
387 );
388 }
389
390 return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice);
391 }
392
393 /**
394 * Find minimum price across availability dates.
395 *
396 * @param array $availabilityDates Array of availability objects
397 * @param bool $checkPriceTypes Whether to check price_types within each availability
398 * @return float Minimum price found (0 if none)
399 */
400 private static function findMinPriceFromAvailability(array $availabilityDates, bool $checkPriceTypes): float
401 {
402 $min = PHP_FLOAT_MAX;
403
404 foreach ($availabilityDates as $avail) {
405 // Check effective_price / original_price on the availability
406 $avail_price = (float) ($avail->effective_price ?? $avail->original_price ?? 0);
407 if ($avail_price > 0 && $avail_price < $min) {
408 $min = $avail_price;
409 }
410
411 // Check price_types within availability (for traveler-based)
412 if ($checkPriceTypes && !empty($avail->price_types) && is_array($avail->price_types)) {
413 foreach ($avail->price_types as $pt) {
414 $pt_price = self::resolveCategoryEffectivePrice((array) $pt);
415 if ($pt_price > 0 && $pt_price < $min) {
416 $min = $pt_price;
417 }
418 }
419 }
420 }
421
422 return $min < PHP_FLOAT_MAX ? $min : 0.0;
423 }
424 }
425