PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.2.6
Yatra – Travel Booking & Tour Operator Software v3.0.2.6
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.6, at app/Services/TripPricingService.php

408 lines 16.4 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: find minimum effective price across all categories
74 $min_price = PHP_FLOAT_MAX;
75 $min_original = 0.0;
76 $max_discount = 0;
77
78 foreach ($price_types as $pt) {
79 $pt = (array) $pt;
80 $original = (float) ($pt['original_price'] ?? 0);
81 $discounted = self::resolveCategoryEffectivePrice($pt);
82
83 if ($discounted > 0 && $discounted < $min_price) {
84 $min_price = $discounted;
85 $min_original = $original;
86 }
87
88 // Track max discount across categories
89 if ($original > 0 && $discounted > 0 && $discounted < $original) {
90 $pct = (int) round((($original - $discounted) / $original) * 100);
91 if ($pct > $max_discount) {
92 $max_discount = $pct;
93 }
94 }
95 }
96
97 if ($min_price < PHP_FLOAT_MAX) {
98 $result['effective_price_min'] = $min_price;
99 $result['min_category_original_price'] = $min_original;
100 $result['max_discount_percentage'] = $max_discount;
101 $result['current_price'] = $min_price;
102 $result['original_price'] = $min_original;
103 $result['has_discount'] = $max_discount > 0;
104 $result['discount_percentage'] = $max_discount;
105 $result['price_prefix'] = __('From ', 'yatra');
106 }
107 } else {
108 // Regular pricing: discounted_price → sale_price → original_price
109 $original = (float) ($trip->original_price ?? 0);
110 $current = self::resolveRegularCurrentPrice($trip);
111
112 $result['effective_price_min'] = $current > 0 ? $current : $original;
113 $result['min_category_original_price'] = $original;
114 $result['current_price'] = $current > 0 ? $current : $original;
115 $result['original_price'] = $original;
116
117 if ($current > 0 && $original > 0 && $current < $original) {
118 $result['has_discount'] = true;
119 $result['discount_percentage'] = (int) round((($original - $current) / $original) * 100);
120 $result['max_discount_percentage'] = $result['discount_percentage'];
121 }
122 }
123
124 // If availability dates are provided, check for lower prices across them
125 if (!empty($availabilityDates)) {
126 $result['price_prefix'] = __('From ', 'yatra');
127 $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing);
128 if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) {
129 $result['effective_price_min'] = $avail_min;
130 $result['current_price'] = $avail_min;
131 }
132 } elseif ($has_traveler_pricing) {
133 $result['price_prefix'] = __('From ', 'yatra');
134 }
135
136 // Pro filter: allows Dynamic Pricing, etc. to modify display pricing
137 return (array) apply_filters('yatra_resolve_display_pricing', $result, $trip, $availabilityDates);
138 }
139
140 /**
141 * Resolve pricing for a single availability card.
142 *
143 * Replaces the inline pricing logic in TripController::render_availability_template.
144 *
145 * @param object $avail Availability object (from AvailabilityResolutionService)
146 * @param object $trip Trip data
147 * @return array Card pricing data
148 */
149 public static function resolveCardPricing(object $avail, object $trip): array
150 {
151 $pricing_type = self::resolvePricingType($trip);
152 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
153 ? $avail->price_types : [];
154
155 // Override to traveler_based if this availability has price_types
156 if (!empty($avail_price_types)) {
157 $pricing_type = 'traveler_based';
158 }
159
160 $result = [
161 'sale_price' => 0.0,
162 'original_price' => 0.0,
163 'has_discount' => false,
164 'discount_percentage' => 0,
165 'pricing_type' => $pricing_type,
166 'price_types' => $avail_price_types,
167 ];
168
169 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
170 // Traveler-based: use first category's price as display
171 $first = (array) $avail_price_types[0];
172 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
173 $result['original_price'] = (float) ($first['original_price'] ?? $result['sale_price']);
174 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
175 // Regular: use pre-calculated effective price
176 $result['sale_price'] = (float) $avail->effective_price;
177 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
178 ? (float) $avail->original_price
179 : $result['sale_price'];
180 } else {
181 // Fallback: trip defaults
182 $result['original_price'] = (float) ($trip->original_price ?? 0);
183 $current = self::resolveRegularCurrentPrice($trip);
184 $result['sale_price'] = $current > 0 ? $current : $result['original_price'];
185 }
186
187 // Compute discount
188 if ($result['original_price'] > 0 && $result['sale_price'] > 0 && $result['sale_price'] < $result['original_price']) {
189 $result['has_discount'] = true;
190 $result['discount_percentage'] = (int) round(
191 (($result['original_price'] - $result['sale_price']) / $result['original_price']) * 100
192 );
193 }
194
195 // Pro filter: Dynamic Pricing per-card
196 return (array) apply_filters('yatra_resolve_card_pricing', $result, $avail, $trip);
197 }
198
199 /**
200 * Get the single effective price for a trip (used in shortcodes for min/max).
201 *
202 * @param object $trip Trip object
203 * @return float Effective price
204 */
205 public static function getEffectivePrice(object $trip): float
206 {
207 $pricing_type = self::resolvePricingType($trip);
208 $price_types = self::resolvePriceTypes($trip);
209
210 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
211 $min = PHP_FLOAT_MAX;
212 foreach ($price_types as $pt) {
213 $price = self::resolveCategoryEffectivePrice((array) $pt);
214 if ($price > 0 && $price < $min) {
215 $min = $price;
216 }
217 }
218 $effective = $min < PHP_FLOAT_MAX ? $min : 0.0;
219 } else {
220 $current = self::resolveRegularCurrentPrice($trip);
221 $original = (float) ($trip->original_price ?? 0);
222 $effective = $current > 0 ? $current : $original;
223 }
224
225 return (float) apply_filters('yatra_resolve_effective_price', $effective, $trip);
226 }
227
228 /**
229 * Resolve the current price for regular pricing.
230 * Priority: discounted_price → sale_price → original_price
231 *
232 * @param object $trip Trip object
233 * @return float Current price (0 if none set)
234 */
235 public static function resolveRegularCurrentPrice(object $trip): float
236 {
237 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
238 return (float) $trip->discounted_price;
239 }
240 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
241 return (float) $trip->sale_price;
242 }
243 if (!empty($trip->original_price) && (float) $trip->original_price > 0) {
244 return (float) $trip->original_price;
245 }
246 return 0.0;
247 }
248
249 /**
250 * Resolve effective price for a single traveler category.
251 * Priority: discounted_price → sale_price → original_price
252 *
253 * @param array $category Category price data
254 * @return float Effective price
255 */
256 public static function resolveCategoryEffectivePrice(array $category): float
257 {
258 if (!empty($category['discounted_price']) && (float) $category['discounted_price'] > 0) {
259 return (float) $category['discounted_price'];
260 }
261 if (!empty($category['sale_price']) && (float) $category['sale_price'] > 0) {
262 return (float) $category['sale_price'];
263 }
264 if (!empty($category['original_price']) && (float) $category['original_price'] > 0) {
265 return (float) $category['original_price'];
266 }
267 if (!empty($category['price']) && (float) $category['price'] > 0) {
268 return (float) $category['price'];
269 }
270 return 0.0;
271 }
272
273 /**
274 * Resolve pricing type for a trip (regular vs traveler_based).
275 *
276 * @param object $trip Trip object
277 * @return string 'regular' or 'traveler_based'
278 */
279 public static function resolvePricingType(object $trip): string
280 {
281 $type = $trip->pricing_type ?? 'regular';
282
283 // Auto-detect: if pricing_type not explicitly set but price_types exist, it's traveler_based
284 if (empty($type) || $type === 'regular') {
285 $price_types = self::resolvePriceTypes($trip);
286 if (!empty($price_types)) {
287 $type = 'traveler_based';
288 }
289 }
290
291 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
292 }
293
294 /**
295 * Normalize price_types from any format into a consistent array of arrays.
296 * Handles: JSON string, array of stdClass, array of arrays, null
297 *
298 * @param object $trip Trip object (checks ->price_types property)
299 * @return array Normalized price_types as array of arrays
300 */
301 public static function resolvePriceTypes(object $trip): array
302 {
303 $raw = $trip->price_types ?? null;
304
305 if (empty($raw)) {
306 return [];
307 }
308
309 // Decode JSON string
310 if (is_string($raw)) {
311 $raw = json_decode($raw, true);
312 if (!is_array($raw)) {
313 return [];
314 }
315 }
316
317 if (!is_array($raw)) {
318 return [];
319 }
320
321 // Normalize each entry to array format
322 $normalized = [];
323 foreach ($raw as $pt) {
324 $pt = (array) $pt;
325 if (empty($pt)) continue;
326
327 $origFromPrice = isset($pt['price']) ? (float) $pt['price'] : null;
328 $orig = isset($pt['original_price']) ? (float) $pt['original_price'] : null;
329 if (($orig === null || $orig <= 0) && $origFromPrice !== null && $origFromPrice > 0) {
330 $orig = $origFromPrice;
331 }
332
333 $normalized[] = [
334 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
335 'original_price' => $orig !== null && $orig > 0 ? $orig : null,
336 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
337 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
338 'label' => $pt['label'] ?? ($pt['category_label'] ?? ($pt['title'] ?? null)),
339 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
340 'category_label' => $pt['category_label'] ?? ($pt['label'] ?? ($pt['title'] ?? null)),
341 'is_default' => !empty($pt['is_default']),
342 ];
343 }
344
345 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
346 }
347
348 /**
349 * Compute discount info from two prices.
350 *
351 * @param float $originalPrice Original price
352 * @param float $currentPrice Current (sale/discounted) price
353 * @return array Discount data
354 */
355 public static function computeDiscount(float $originalPrice, float $currentPrice): array
356 {
357 $result = [
358 'has_discount' => false,
359 'discount_amount' => 0.0,
360 'discount_percentage' => 0,
361 'original_price' => $originalPrice,
362 'current_price' => $currentPrice,
363 ];
364
365 if ($originalPrice > 0 && $currentPrice > 0 && $currentPrice < $originalPrice) {
366 $result['has_discount'] = true;
367 $result['discount_amount'] = round($originalPrice - $currentPrice, 2);
368 $result['discount_percentage'] = (int) round(
369 (($originalPrice - $currentPrice) / $originalPrice) * 100
370 );
371 }
372
373 return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice);
374 }
375
376 /**
377 * Find minimum price across availability dates.
378 *
379 * @param array $availabilityDates Array of availability objects
380 * @param bool $checkPriceTypes Whether to check price_types within each availability
381 * @return float Minimum price found (0 if none)
382 */
383 private static function findMinPriceFromAvailability(array $availabilityDates, bool $checkPriceTypes): float
384 {
385 $min = PHP_FLOAT_MAX;
386
387 foreach ($availabilityDates as $avail) {
388 // Check effective_price / original_price on the availability
389 $avail_price = (float) ($avail->effective_price ?? $avail->original_price ?? 0);
390 if ($avail_price > 0 && $avail_price < $min) {
391 $min = $avail_price;
392 }
393
394 // Check price_types within availability (for traveler-based)
395 if ($checkPriceTypes && !empty($avail->price_types) && is_array($avail->price_types)) {
396 foreach ($avail->price_types as $pt) {
397 $pt_price = self::resolveCategoryEffectivePrice((array) $pt);
398 if ($pt_price > 0 && $pt_price < $min) {
399 $min = $pt_price;
400 }
401 }
402 }
403 }
404
405 return $min < PHP_FLOAT_MAX ? $min : 0.0;
406 }
407 }
408