PluginProbe
Yatra – Travel Booking & Tour Operator Software / 3.0.14.2
Yatra – Travel Booking & Tour Operator Software v3.0.14.2
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 2.0.10 All 83 releases
yatra / app / Services / TripPricingService.php

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

563 lines 23.6 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 // The trip's stored price_types JSON does not persist pricing_mode, so
372 // the literal 'per_person' above is only a placeholder — resolve the
373 // authoritative value (and group-size limits) from the TravelerCategory.
374 $normalized = self::applyCategoryPricingMeta($normalized);
375
376 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
377 }
378
379 /**
380 * Request-level cache of per-category pricing metadata, keyed by category id.
381 * A `null` entry records a category that has no classification row (e.g. it
382 * was deleted) so we never re-query it.
383 *
384 * @var array<int, array{pricing_mode:string, min_pax:?int, max_pax:?int}|null>
385 */
386 private static array $categoryPricingMetaCache = [];
387
388 /**
389 * Backfill pricing_mode / min_pax / max_pax onto a price_types array from the
390 * authoritative TravelerCategory classification.
391 *
392 * The trip's stored price_types JSON has never persisted pricing_mode, and
393 * older resolvers baked in a literal 'per_person' default. That silently
394 * turned a per-group category into per-person pricing at availability and
395 * checkout time (charging price × headcount instead of a flat group price).
396 * The category is the single source of truth, so we read it back and
397 * override here. For per-person categories this resolves to 'per_person',
398 * i.e. a no-op — every existing trip keeps its exact pricing. Entries with
399 * no matching category (or a regular-pricing trip with no categories) are
400 * returned untouched. Accepts and preserves array or object entries.
401 *
402 * @param array<int, mixed> $priceTypes
403 * @return array<int, mixed>
404 */
405 public static function applyCategoryPricingMeta(array $priceTypes): array
406 {
407 if (empty($priceTypes)) {
408 return $priceTypes;
409 }
410
411 // Load any category ids we haven't already cached this request.
412 $needed = [];
413 foreach ($priceTypes as $pt) {
414 $arr = (array) $pt;
415 $cid = !empty($arr['category_id']) ? (int) $arr['category_id'] : 0;
416 if ($cid && !array_key_exists($cid, self::$categoryPricingMetaCache)) {
417 $needed[$cid] = $cid;
418 }
419 }
420
421 if (!empty($needed)) {
422 $meta = (new \Yatra\Repositories\TravelerCategoryRepository())
423 ->getMetadataByIds(array_values($needed));
424 foreach ($needed as $cid) {
425 $m = $meta[$cid] ?? null;
426 self::$categoryPricingMetaCache[$cid] = is_array($m)
427 ? [
428 'pricing_mode' => in_array(($m['pricing_mode'] ?? 'per_person'), ['per_person', 'per_group'], true)
429 ? $m['pricing_mode']
430 : 'per_person',
431 'min_pax' => (isset($m['min_pax']) && $m['min_pax'] !== '' && $m['min_pax'] !== null) ? (int) $m['min_pax'] : null,
432 'max_pax' => (isset($m['max_pax']) && $m['max_pax'] !== '' && $m['max_pax'] !== null) ? (int) $m['max_pax'] : null,
433 'group_overflow' => in_array(($m['group_overflow'] ?? 'block'), ['block', 'per_block'], true)
434 ? $m['group_overflow']
435 : 'block',
436 ]
437 : null;
438 }
439 }
440
441 foreach ($priceTypes as &$pt) {
442 $isObject = is_object($pt);
443 $arr = (array) $pt;
444 $cid = !empty($arr['category_id']) ? (int) $arr['category_id'] : 0;
445 $m = $cid ? (self::$categoryPricingMetaCache[$cid] ?? null) : null;
446 if ($m !== null) {
447 $arr['pricing_mode'] = $m['pricing_mode'];
448 if ($m['min_pax'] !== null) {
449 $arr['min_pax'] = $m['min_pax'];
450 }
451 if ($m['max_pax'] !== null) {
452 $arr['max_pax'] = $m['max_pax'];
453 }
454 $arr['group_overflow'] = $m['group_overflow'] ?? 'block';
455 $pt = $isObject ? (object) $arr : $arr;
456 }
457 }
458 unset($pt);
459
460 return $priceTypes;
461 }
462
463 /**
464 * Effective subtotal for a single traveler-category line — the ONE place
465 * the per-group vs per-person money rule lives, so every caller (charge,
466 * checkout breakdown, discount base, initial total) agrees.
467 *
468 * - per_person : price × count
469 * - per_group (block) : one flat price for the whole group [default]
470 * - per_group (per_block) : price × ceil(count / max_pax) [multiple group blocks]
471 *
472 * group_overflow defaults to 'block', and a missing/zero max_pax also falls
473 * back to a single flat price, so existing per-group categories are
474 * byte-identical until an owner opts into per-block pricing.
475 *
476 * @param array|object $pt Price-type entry (carries pricing_mode/max_pax/group_overflow).
477 * @param int $count Selected headcount for this category.
478 * @param float $price Effective per-unit (per-person) or per-group price.
479 */
480 public static function categoryLineSubtotal($pt, int $count, float $price): float
481 {
482 if ($count <= 0) {
483 return 0.0;
484 }
485
486 $pt = (array) $pt;
487
488 if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') {
489 return $price * $count;
490 }
491
492 $overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block';
493 $maxPax = (isset($pt['max_pax']) && $pt['max_pax'] !== '' && $pt['max_pax'] !== null) ? (int) $pt['max_pax'] : 0;
494
495 if ($overflow === 'per_block' && $maxPax > 0) {
496 return $price * (int) ceil($count / $maxPax);
497 }
498
499 // Single flat group price.
500 return $price;
501 }
502
503 /**
504 * Compute discount info from two prices.
505 *
506 * @param float $originalPrice Original price
507 * @param float $currentPrice Current (sale/discounted) price
508 * @return array Discount data
509 */
510 public static function computeDiscount(float $originalPrice, float $currentPrice): array
511 {
512 $result = [
513 'has_discount' => false,
514 'discount_amount' => 0.0,
515 'discount_percentage' => 0,
516 'original_price' => $originalPrice,
517 'current_price' => $currentPrice,
518 ];
519
520 if ($originalPrice > 0 && $currentPrice > 0 && $currentPrice < $originalPrice) {
521 $result['has_discount'] = true;
522 $result['discount_amount'] = round($originalPrice - $currentPrice, 2);
523 $result['discount_percentage'] = (int) round(
524 (($originalPrice - $currentPrice) / $originalPrice) * 100
525 );
526 }
527
528 return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice);
529 }
530
531 /**
532 * Find minimum price across availability dates.
533 *
534 * @param array $availabilityDates Array of availability objects
535 * @param bool $checkPriceTypes Whether to check price_types within each availability
536 * @return float Minimum price found (0 if none)
537 */
538 private static function findMinPriceFromAvailability(array $availabilityDates, bool $checkPriceTypes): float
539 {
540 $min = PHP_FLOAT_MAX;
541
542 foreach ($availabilityDates as $avail) {
543 // Check effective_price / original_price on the availability
544 $avail_price = (float) ($avail->effective_price ?? $avail->original_price ?? 0);
545 if ($avail_price > 0 && $avail_price < $min) {
546 $min = $avail_price;
547 }
548
549 // Check price_types within availability (for traveler-based)
550 if ($checkPriceTypes && !empty($avail->price_types) && is_array($avail->price_types)) {
551 foreach ($avail->price_types as $pt) {
552 $pt_price = self::resolveCategoryEffectivePrice((array) $pt);
553 if ($pt_price > 0 && $pt_price < $min) {
554 $min = $pt_price;
555 }
556 }
557 }
558 }
559
560 return $min < PHP_FLOAT_MAX ? $min : 0.0;
561 }
562 }
563