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

434 lines 17.9 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 $trip_mode = self::resolvePricingType($trip);
164 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
165 ? $avail->price_types : [];
166
167 // Only treat a date as traveler-based when the trip is traveler-based. Otherwise inherited
168 // or stale price_types on an availability row must not override regular trip pricing.
169 $pricing_type = $trip_mode;
170 if ($trip_mode === 'traveler_based' && !empty($avail_price_types)) {
171 $pricing_type = 'traveler_based';
172 }
173
174 $result = [
175 'sale_price' => 0.0,
176 'original_price' => 0.0,
177 'has_discount' => false,
178 'discount_percentage' => 0,
179 'pricing_type' => $pricing_type,
180 'price_types' => $pricing_type === 'traveler_based' ? $avail_price_types : [],
181 ];
182
183 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
184 // Traveler-based: use first category's price as display
185 $first = (array) $avail_price_types[0];
186 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
187 $result['original_price'] = (float) ($first['original_price'] ?? $result['sale_price']);
188 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
189 // Regular: use pre-calculated effective price
190 $result['sale_price'] = (float) $avail->effective_price;
191 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
192 ? (float) $avail->original_price
193 : $result['sale_price'];
194 } else {
195 // Fallback: trip defaults
196 $result['original_price'] = (float) ($trip->original_price ?? 0);
197 $current = self::resolveRegularCurrentPrice($trip);
198 $result['sale_price'] = $current > 0 ? $current : $result['original_price'];
199 }
200
201 // Compute discount
202 if ($result['original_price'] > 0 && $result['sale_price'] > 0 && $result['sale_price'] < $result['original_price']) {
203 $result['has_discount'] = true;
204 $result['discount_percentage'] = (int) round(
205 (($result['original_price'] - $result['sale_price']) / $result['original_price']) * 100
206 );
207 }
208
209 // Pro filter: Dynamic Pricing per-card
210 return (array) apply_filters('yatra_resolve_card_pricing', $result, $avail, $trip);
211 }
212
213 /**
214 * Get the single effective price for a trip (used in shortcodes for min/max).
215 *
216 * @param object $trip Trip object
217 * @return float Effective price
218 */
219 public static function getEffectivePrice(object $trip): float
220 {
221 $pricing_type = self::resolvePricingType($trip);
222 $price_types = self::resolvePriceTypes($trip);
223
224 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
225 $default = 0.0;
226 $min = PHP_FLOAT_MAX;
227 foreach ($price_types as $pt) {
228 $ptArr = (array) $pt;
229 $price = self::resolveCategoryEffectivePrice($ptArr);
230 if (!empty($ptArr['is_default']) && $default <= 0 && $price > 0) {
231 $default = $price;
232 }
233 if ($price > 0 && $price < $min) {
234 $min = $price;
235 }
236 }
237 $effective = $default > 0 ? $default : ($min < PHP_FLOAT_MAX ? $min : 0.0);
238 } else {
239 $current = self::resolveRegularCurrentPrice($trip);
240 $original = (float) ($trip->original_price ?? 0);
241 $effective = $current > 0 ? $current : $original;
242 }
243
244 return (float) apply_filters('yatra_resolve_effective_price', $effective, $trip);
245 }
246
247 /**
248 * Resolve the current price for regular pricing.
249 * Priority: discounted_price → sale_price → original_price
250 *
251 * @param object $trip Trip object
252 * @return float Current price (0 if none set)
253 */
254 public static function resolveRegularCurrentPrice(object $trip): float
255 {
256 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
257 return (float) $trip->discounted_price;
258 }
259 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
260 return (float) $trip->sale_price;
261 }
262 if (!empty($trip->original_price) && (float) $trip->original_price > 0) {
263 return (float) $trip->original_price;
264 }
265 return 0.0;
266 }
267
268 /**
269 * Resolve effective price for a single traveler category.
270 * Priority: discounted_price → sale_price → original_price
271 *
272 * @param array $category Category price data
273 * @return float Effective price
274 */
275 public static function resolveCategoryEffectivePrice(array $category): float
276 {
277 if (!empty($category['discounted_price']) && (float) $category['discounted_price'] > 0) {
278 return (float) $category['discounted_price'];
279 }
280 if (!empty($category['sale_price']) && (float) $category['sale_price'] > 0) {
281 return (float) $category['sale_price'];
282 }
283 if (!empty($category['original_price']) && (float) $category['original_price'] > 0) {
284 return (float) $category['original_price'];
285 }
286 if (!empty($category['price']) && (float) $category['price'] > 0) {
287 return (float) $category['price'];
288 }
289 return 0.0;
290 }
291
292 /**
293 * Resolve pricing type for a trip (regular vs traveler_based).
294 *
295 * @param object $trip Trip object
296 * @return string 'regular' or 'traveler_based'
297 */
298 public static function resolvePricingType(object $trip): string
299 {
300 $raw = $trip->pricing_type ?? null;
301 if (is_string($raw)) {
302 $raw = trim($raw);
303 }
304
305 // Honor an explicit mode from the trip row. Leftover rows in trip_price_types must not
306 // override "regular" trip-level pricing (admin saves price_types as [] for regular, but
307 // legacy/orphan DB rows would otherwise force traveler_based and show min category price).
308 if ($raw !== null && $raw !== '') {
309 $type = $raw === 'traveler_based' ? 'traveler_based' : 'regular';
310 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
311 }
312
313 // Legacy / unmigrated trips: no pricing_type column value — infer from price_types
314 $price_types = self::resolvePriceTypes($trip);
315 $type = !empty($price_types) ? 'traveler_based' : 'regular';
316
317 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
318 }
319
320 /**
321 * Normalize price_types from any format into a consistent array of arrays.
322 * Handles: JSON string, array of stdClass, array of arrays, null
323 *
324 * @param object $trip Trip object (checks ->price_types property)
325 * @return array Normalized price_types as array of arrays
326 */
327 public static function resolvePriceTypes(object $trip): array
328 {
329 $raw = $trip->price_types ?? null;
330
331 if (empty($raw)) {
332 return [];
333 }
334
335 // Decode JSON string
336 if (is_string($raw)) {
337 $raw = json_decode($raw, true);
338 if (!is_array($raw)) {
339 return [];
340 }
341 }
342
343 if (!is_array($raw)) {
344 return [];
345 }
346
347 // Normalize each entry to array format
348 $normalized = [];
349 foreach ($raw as $pt) {
350 $pt = (array) $pt;
351 if (empty($pt)) continue;
352
353 $origFromPrice = isset($pt['price']) ? (float) $pt['price'] : null;
354 $orig = isset($pt['original_price']) ? (float) $pt['original_price'] : null;
355 if (($orig === null || $orig <= 0) && $origFromPrice !== null && $origFromPrice > 0) {
356 $orig = $origFromPrice;
357 }
358
359 $normalized[] = [
360 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
361 'original_price' => $orig !== null && $orig > 0 ? $orig : null,
362 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
363 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
364 'label' => $pt['label'] ?? ($pt['category_label'] ?? ($pt['title'] ?? null)),
365 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
366 'category_label' => $pt['category_label'] ?? ($pt['label'] ?? ($pt['title'] ?? null)),
367 'is_default' => !empty($pt['is_default']),
368 ];
369 }
370
371 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
372 }
373
374 /**
375 * Compute discount info from two prices.
376 *
377 * @param float $originalPrice Original price
378 * @param float $currentPrice Current (sale/discounted) price
379 * @return array Discount data
380 */
381 public static function computeDiscount(float $originalPrice, float $currentPrice): array
382 {
383 $result = [
384 'has_discount' => false,
385 'discount_amount' => 0.0,
386 'discount_percentage' => 0,
387 'original_price' => $originalPrice,
388 'current_price' => $currentPrice,
389 ];
390
391 if ($originalPrice > 0 && $currentPrice > 0 && $currentPrice < $originalPrice) {
392 $result['has_discount'] = true;
393 $result['discount_amount'] = round($originalPrice - $currentPrice, 2);
394 $result['discount_percentage'] = (int) round(
395 (($originalPrice - $currentPrice) / $originalPrice) * 100
396 );
397 }
398
399 return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice);
400 }
401
402 /**
403 * Find minimum price across availability dates.
404 *
405 * @param array $availabilityDates Array of availability objects
406 * @param bool $checkPriceTypes Whether to check price_types within each availability
407 * @return float Minimum price found (0 if none)
408 */
409 private static function findMinPriceFromAvailability(array $availabilityDates, bool $checkPriceTypes): float
410 {
411 $min = PHP_FLOAT_MAX;
412
413 foreach ($availabilityDates as $avail) {
414 // Check effective_price / original_price on the availability
415 $avail_price = (float) ($avail->effective_price ?? $avail->original_price ?? 0);
416 if ($avail_price > 0 && $avail_price < $min) {
417 $min = $avail_price;
418 }
419
420 // Check price_types within availability (for traveler-based)
421 if ($checkPriceTypes && !empty($avail->price_types) && is_array($avail->price_types)) {
422 foreach ($avail->price_types as $pt) {
423 $pt_price = self::resolveCategoryEffectivePrice((array) $pt);
424 if ($pt_price > 0 && $pt_price < $min) {
425 $min = $pt_price;
426 }
427 }
428 }
429 }
430
431 return $min < PHP_FLOAT_MAX ? $min : 0.0;
432 }
433 }
434