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

683 lines 28.8 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 // How the displayed amount is charged: 'per_person' or 'per_group'
70 // (+ the pax range), taken from the SAME category the amount comes
71 // from, so the label can never contradict the price.
72 'price_unit' => 'per_person',
73 'price_unit_label' => self::priceUnitLabel(null),
74 'currency' => SettingsService::getCurrency(),
75 ];
76
77 if ($has_traveler_pricing) {
78 // Traveler-based:
79 // - If a default category is marked, use that for initial display (listings + single trip page-load).
80 // - Otherwise fall back to minimum effective price across categories (current behavior).
81 $default_price = 0.0;
82 $default_original = 0.0;
83 $default_pt = null;
84 $min_price = PHP_FLOAT_MAX;
85 $min_original = 0.0;
86 $min_pt = null;
87 $max_discount = 0;
88
89 foreach ($price_types as $pt) {
90 $pt = (array) $pt;
91 $original = (float) ($pt['original_price'] ?? 0);
92 $discounted = self::resolveCategoryEffectivePrice($pt);
93
94 if (!empty($pt['is_default']) && $default_price <= 0 && $discounted > 0) {
95 $default_price = $discounted;
96 $default_original = $original;
97 $default_pt = $pt;
98 }
99
100 if ($discounted > 0 && $discounted < $min_price) {
101 $min_price = $discounted;
102 $min_original = $original;
103 $min_pt = $pt;
104 }
105
106 // Track max discount across categories
107 if ($original > 0 && $discounted > 0 && $discounted < $original) {
108 $pct = (int) round((($original - $discounted) / $original) * 100);
109 if ($pct > $max_discount) {
110 $max_discount = $pct;
111 }
112 }
113 }
114
115 $chosen_price = $default_price > 0 ? $default_price : ($min_price < PHP_FLOAT_MAX ? $min_price : 0.0);
116 $chosen_original = $default_price > 0 ? $default_original : $min_original;
117 $chosen_pt = $default_price > 0 ? $default_pt : $min_pt;
118
119 if ($chosen_price > 0) {
120 $unit = self::priceUnit($chosen_pt);
121 $result['price_unit'] = $unit['unit'];
122 $result['price_unit_label'] = $unit['label'];
123 $result['effective_price_min'] = $chosen_price;
124 $result['min_category_original_price'] = $chosen_original;
125 $result['max_discount_percentage'] = $max_discount;
126 $result['current_price'] = $chosen_price;
127 $result['original_price'] = $chosen_original;
128 $result['has_discount'] = $max_discount > 0;
129 $result['discount_percentage'] = $max_discount;
130 $result['price_prefix'] = __('From ', 'yatra');
131 }
132 } else {
133 // Regular pricing: discounted_price → sale_price → original_price
134 $original = (float) ($trip->original_price ?? 0);
135 $current = self::resolveRegularCurrentPrice($trip);
136
137 $result['effective_price_min'] = $current > 0 ? $current : $original;
138 $result['min_category_original_price'] = $original;
139 $result['current_price'] = $current > 0 ? $current : $original;
140 $result['original_price'] = $original;
141
142 if ($current > 0 && $original > 0 && $current < $original) {
143 $result['has_discount'] = true;
144 $result['discount_percentage'] = (int) round((($original - $current) / $original) * 100);
145 $result['max_discount_percentage'] = $result['discount_percentage'];
146 }
147 }
148
149 // If availability dates are provided, check for lower prices across them
150 if (!empty($availabilityDates)) {
151 $result['price_prefix'] = __('From ', 'yatra');
152 $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing);
153 if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) {
154 $result['effective_price_min'] = $avail_min;
155 $result['current_price'] = $avail_min;
156 if ($has_traveler_pricing) {
157 $avail_pt = self::findAvailabilityPriceTypeAt($availabilityDates, $avail_min);
158 if ($avail_pt !== null) {
159 $unit = self::priceUnit($avail_pt);
160 $result['price_unit'] = $unit['unit'];
161 $result['price_unit_label'] = $unit['label'];
162 }
163 }
164 }
165 } elseif ($has_traveler_pricing) {
166 $result['price_prefix'] = __('From ', 'yatra');
167 }
168
169 // Pro filter: allows Dynamic Pricing, etc. to modify display pricing
170 return (array) apply_filters('yatra_resolve_display_pricing', $result, $trip, $availabilityDates);
171 }
172
173 /**
174 * Resolve pricing for a single availability card.
175 *
176 * Replaces the inline pricing logic in TripController::render_availability_template.
177 *
178 * @param object $avail Availability object (from AvailabilityResolutionService)
179 * @param object $trip Trip data
180 * @return array Card pricing data
181 */
182 public static function resolveCardPricing(object $avail, object $trip): array
183 {
184 $trip_mode = self::resolvePricingType($trip);
185 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
186 ? $avail->price_types : [];
187
188 // Only treat a date as traveler-based when the trip is traveler-based. Otherwise inherited
189 // or stale price_types on an availability row must not override regular trip pricing.
190 $pricing_type = $trip_mode;
191 if ($trip_mode === 'traveler_based' && !empty($avail_price_types)) {
192 $pricing_type = 'traveler_based';
193 }
194
195 $result = [
196 'sale_price' => 0.0,
197 'original_price' => 0.0,
198 'has_discount' => false,
199 'discount_percentage' => 0,
200 'pricing_type' => $pricing_type,
201 'price_types' => $pricing_type === 'traveler_based' ? $avail_price_types : [],
202 'price_unit' => 'per_person',
203 'price_unit_label' => self::priceUnitLabel(null),
204 ];
205
206 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
207 // Traveler-based: use first category's price as display
208 $first = (array) $avail_price_types[0];
209 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
210 $result['original_price'] = (float) ($first['original_price'] ?? $result['sale_price']);
211 $unit = self::priceUnit($first);
212 $result['price_unit'] = $unit['unit'];
213 $result['price_unit_label'] = $unit['label'];
214 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
215 // Regular: use pre-calculated effective price
216 $result['sale_price'] = (float) $avail->effective_price;
217 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
218 ? (float) $avail->original_price
219 : $result['sale_price'];
220 } else {
221 // Fallback: trip defaults
222 $result['original_price'] = (float) ($trip->original_price ?? 0);
223 $current = self::resolveRegularCurrentPrice($trip);
224 $result['sale_price'] = $current > 0 ? $current : $result['original_price'];
225 }
226
227 // Compute discount
228 if ($result['original_price'] > 0 && $result['sale_price'] > 0 && $result['sale_price'] < $result['original_price']) {
229 $result['has_discount'] = true;
230 $result['discount_percentage'] = (int) round(
231 (($result['original_price'] - $result['sale_price']) / $result['original_price']) * 100
232 );
233 }
234
235 // Pro filter: Dynamic Pricing per-card
236 return (array) apply_filters('yatra_resolve_card_pricing', $result, $avail, $trip);
237 }
238
239 /**
240 * Get the single effective price for a trip (used in shortcodes for min/max).
241 *
242 * @param object $trip Trip object
243 * @return float Effective price
244 */
245 public static function getEffectivePrice(object $trip): float
246 {
247 $pricing_type = self::resolvePricingType($trip);
248 $price_types = self::resolvePriceTypes($trip);
249
250 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
251 $default = 0.0;
252 $min = PHP_FLOAT_MAX;
253 foreach ($price_types as $pt) {
254 $ptArr = (array) $pt;
255 $price = self::resolveCategoryEffectivePrice($ptArr);
256 if (!empty($ptArr['is_default']) && $default <= 0 && $price > 0) {
257 $default = $price;
258 }
259 if ($price > 0 && $price < $min) {
260 $min = $price;
261 }
262 }
263 $effective = $default > 0 ? $default : ($min < PHP_FLOAT_MAX ? $min : 0.0);
264 } else {
265 $current = self::resolveRegularCurrentPrice($trip);
266 $original = (float) ($trip->original_price ?? 0);
267 $effective = $current > 0 ? $current : $original;
268 }
269
270 return (float) apply_filters('yatra_resolve_effective_price', $effective, $trip);
271 }
272
273 /**
274 * Resolve the current price for regular pricing.
275 * Priority: discounted_price → sale_price → original_price
276 *
277 * @param object $trip Trip object
278 * @return float Current price (0 if none set)
279 */
280 public static function resolveRegularCurrentPrice(object $trip): float
281 {
282 if (!empty($trip->discounted_price) && (float) $trip->discounted_price > 0) {
283 return (float) $trip->discounted_price;
284 }
285 if (!empty($trip->sale_price) && (float) $trip->sale_price > 0) {
286 return (float) $trip->sale_price;
287 }
288 if (!empty($trip->original_price) && (float) $trip->original_price > 0) {
289 return (float) $trip->original_price;
290 }
291 return 0.0;
292 }
293
294 /**
295 * Resolve effective price for a single traveler category.
296 * Priority: discounted_price → sale_price → original_price
297 *
298 * @param array $category Category price data
299 * @return float Effective price
300 */
301 public static function resolveCategoryEffectivePrice(array $category): float
302 {
303 if (!empty($category['discounted_price']) && (float) $category['discounted_price'] > 0) {
304 return (float) $category['discounted_price'];
305 }
306 if (!empty($category['sale_price']) && (float) $category['sale_price'] > 0) {
307 return (float) $category['sale_price'];
308 }
309 if (!empty($category['original_price']) && (float) $category['original_price'] > 0) {
310 return (float) $category['original_price'];
311 }
312 if (!empty($category['price']) && (float) $category['price'] > 0) {
313 return (float) $category['price'];
314 }
315 return 0.0;
316 }
317
318 /**
319 * Resolve pricing type for a trip (regular vs traveler_based).
320 *
321 * @param object $trip Trip object
322 * @return string 'regular' or 'traveler_based'
323 */
324 public static function resolvePricingType(object $trip): string
325 {
326 $raw = $trip->pricing_type ?? null;
327 if (is_string($raw)) {
328 $raw = trim($raw);
329 }
330
331 // Honor an explicit mode from the trip row. Leftover rows in trip_price_types must not
332 // override "regular" trip-level pricing (admin saves price_types as [] for regular, but
333 // legacy/orphan DB rows would otherwise force traveler_based and show min category price).
334 if ($raw !== null && $raw !== '') {
335 $type = $raw === 'traveler_based' ? 'traveler_based' : 'regular';
336 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
337 }
338
339 // Legacy / unmigrated trips: no pricing_type column value — infer from price_types
340 $price_types = self::resolvePriceTypes($trip);
341 $type = !empty($price_types) ? 'traveler_based' : 'regular';
342
343 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
344 }
345
346 /**
347 * Normalize price_types from any format into a consistent array of arrays.
348 * Handles: JSON string, array of stdClass, array of arrays, null
349 *
350 * @param object $trip Trip object (checks ->price_types property)
351 * @return array Normalized price_types as array of arrays
352 */
353 public static function resolvePriceTypes(object $trip): array
354 {
355 $raw = $trip->price_types ?? null;
356
357 if (empty($raw)) {
358 return [];
359 }
360
361 // Decode JSON string
362 if (is_string($raw)) {
363 $raw = json_decode($raw, true);
364 if (!is_array($raw)) {
365 return [];
366 }
367 }
368
369 if (!is_array($raw)) {
370 return [];
371 }
372
373 // Normalize each entry to array format
374 $normalized = [];
375 foreach ($raw as $pt) {
376 $pt = (array) $pt;
377 if (empty($pt)) continue;
378
379 $origFromPrice = isset($pt['price']) ? (float) $pt['price'] : null;
380 $orig = isset($pt['original_price']) ? (float) $pt['original_price'] : null;
381 if (($orig === null || $orig <= 0) && $origFromPrice !== null && $origFromPrice > 0) {
382 $orig = $origFromPrice;
383 }
384
385 $normalized[] = [
386 'category_id' => isset($pt['category_id']) ? (int) $pt['category_id'] : null,
387 'original_price' => $orig !== null && $orig > 0 ? $orig : null,
388 'discounted_price' => isset($pt['discounted_price']) ? (float) $pt['discounted_price'] : null,
389 'sale_price' => isset($pt['sale_price']) ? (float) $pt['sale_price'] : null,
390 'label' => $pt['label'] ?? ($pt['category_label'] ?? ($pt['title'] ?? null)),
391 'pricing_mode' => $pt['pricing_mode'] ?? 'per_person',
392 'category_label' => $pt['category_label'] ?? ($pt['label'] ?? ($pt['title'] ?? null)),
393 'is_default' => !empty($pt['is_default']),
394 ];
395 }
396
397 // The trip's stored price_types JSON does not persist pricing_mode, so
398 // the literal 'per_person' above is only a placeholder — resolve the
399 // authoritative value (and group-size limits) from the TravelerCategory.
400 $normalized = self::applyCategoryPricingMeta($normalized);
401
402 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
403 }
404
405 /**
406 * Request-level cache of per-category pricing metadata, keyed by category id.
407 * A `null` entry records a category that has no classification row (e.g. it
408 * was deleted) so we never re-query it.
409 *
410 * @var array<int, array{pricing_mode:string, min_pax:?int, max_pax:?int}|null>
411 */
412 private static array $categoryPricingMetaCache = [];
413
414 /**
415 * Backfill pricing_mode / min_pax / max_pax onto a price_types array from the
416 * authoritative TravelerCategory classification.
417 *
418 * The trip's stored price_types JSON has never persisted pricing_mode, and
419 * older resolvers baked in a literal 'per_person' default. That silently
420 * turned a per-group category into per-person pricing at availability and
421 * checkout time (charging price × headcount instead of a flat group price).
422 * The category is the single source of truth, so we read it back and
423 * override here. For per-person categories this resolves to 'per_person',
424 * i.e. a no-op — every existing trip keeps its exact pricing. Entries with
425 * no matching category (or a regular-pricing trip with no categories) are
426 * returned untouched. Accepts and preserves array or object entries.
427 *
428 * @param array<int, mixed> $priceTypes
429 * @return array<int, mixed>
430 */
431 public static function applyCategoryPricingMeta(array $priceTypes): array
432 {
433 if (empty($priceTypes)) {
434 return $priceTypes;
435 }
436
437 // Load any category ids we haven't already cached this request.
438 $needed = [];
439 foreach ($priceTypes as $pt) {
440 $arr = (array) $pt;
441 $cid = !empty($arr['category_id']) ? (int) $arr['category_id'] : 0;
442 if ($cid && !array_key_exists($cid, self::$categoryPricingMetaCache)) {
443 $needed[$cid] = $cid;
444 }
445 }
446
447 if (!empty($needed)) {
448 $meta = (new \Yatra\Repositories\TravelerCategoryRepository())
449 ->getMetadataByIds(array_values($needed));
450 foreach ($needed as $cid) {
451 $m = $meta[$cid] ?? null;
452 self::$categoryPricingMetaCache[$cid] = is_array($m)
453 ? [
454 'pricing_mode' => in_array(($m['pricing_mode'] ?? 'per_person'), ['per_person', 'per_group'], true)
455 ? $m['pricing_mode']
456 : 'per_person',
457 'min_pax' => (isset($m['min_pax']) && $m['min_pax'] !== '' && $m['min_pax'] !== null) ? (int) $m['min_pax'] : null,
458 'max_pax' => (isset($m['max_pax']) && $m['max_pax'] !== '' && $m['max_pax'] !== null) ? (int) $m['max_pax'] : null,
459 'group_overflow' => in_array(($m['group_overflow'] ?? 'block'), ['block', 'per_block'], true)
460 ? $m['group_overflow']
461 : 'block',
462 ]
463 : null;
464 }
465 }
466
467 foreach ($priceTypes as &$pt) {
468 $isObject = is_object($pt);
469 $arr = (array) $pt;
470 $cid = !empty($arr['category_id']) ? (int) $arr['category_id'] : 0;
471 $m = $cid ? (self::$categoryPricingMetaCache[$cid] ?? null) : null;
472 if ($m !== null) {
473 $arr['pricing_mode'] = $m['pricing_mode'];
474 if ($m['min_pax'] !== null) {
475 $arr['min_pax'] = $m['min_pax'];
476 }
477 if ($m['max_pax'] !== null) {
478 $arr['max_pax'] = $m['max_pax'];
479 }
480 $arr['group_overflow'] = $m['group_overflow'] ?? 'block';
481 $pt = $isObject ? (object) $arr : $arr;
482 }
483 }
484 unset($pt);
485
486 return $priceTypes;
487 }
488
489 /**
490 * Effective subtotal for a single traveler-category line — the ONE place
491 * the per-group vs per-person money rule lives, so every caller (charge,
492 * checkout breakdown, discount base, initial total) agrees.
493 *
494 * - per_person : price × count
495 * - per_group (block) : one flat price for the whole group [default]
496 * - per_group (per_block) : price × ceil(count / max_pax) [multiple group blocks]
497 *
498 * group_overflow defaults to 'block', and a missing/zero max_pax also falls
499 * back to a single flat price, so existing per-group categories are
500 * byte-identical until an owner opts into per-block pricing.
501 *
502 * @param array|object $pt Price-type entry (carries pricing_mode/max_pax/group_overflow).
503 * @param int $count Selected headcount for this category.
504 * @param float $price Effective per-unit (per-person) or per-group price.
505 */
506 public static function categoryLineSubtotal($pt, int $count, float $price): float
507 {
508 if ($count <= 0) {
509 return 0.0;
510 }
511
512 $pt = (array) $pt;
513
514 if (($pt['pricing_mode'] ?? 'per_person') !== 'per_group') {
515 return $price * $count;
516 }
517
518 $overflow = ($pt['group_overflow'] ?? 'block') === 'per_block' ? 'per_block' : 'block';
519 $maxPax = (isset($pt['max_pax']) && $pt['max_pax'] !== '' && $pt['max_pax'] !== null) ? (int) $pt['max_pax'] : 0;
520
521 if ($overflow === 'per_block' && $maxPax > 0) {
522 return $price * (int) ceil($count / $maxPax);
523 }
524
525 // Single flat group price.
526 return $price;
527 }
528
529 /**
530 * Compute discount info from two prices.
531 *
532 * @param float $originalPrice Original price
533 * @param float $currentPrice Current (sale/discounted) price
534 * @return array Discount data
535 */
536 public static function computeDiscount(float $originalPrice, float $currentPrice): array
537 {
538 $result = [
539 'has_discount' => false,
540 'discount_amount' => 0.0,
541 'discount_percentage' => 0,
542 'original_price' => $originalPrice,
543 'current_price' => $currentPrice,
544 ];
545
546 if ($originalPrice > 0 && $currentPrice > 0 && $currentPrice < $originalPrice) {
547 $result['has_discount'] = true;
548 $result['discount_amount'] = round($originalPrice - $currentPrice, 2);
549 $result['discount_percentage'] = (int) round(
550 (($originalPrice - $currentPrice) / $originalPrice) * 100
551 );
552 }
553
554 return (array) apply_filters('yatra_resolve_discount_info', $result, $originalPrice, $currentPrice);
555 }
556
557 /**
558 * How a category's price is charged, for display next to an amount.
559 *
560 * A traveller category is priced 'per_person' (× headcount) or 'per_group'
561 * (flat for min_pax–max_pax people). The trip's stored price_types JSON does
562 * not carry the mode, so it is read back from the category classification
563 * (applyCategoryPricingMeta) exactly as checkout does. Pass the category
564 * entry whose price is being displayed; null / a regular-pricing trip →
565 * per person.
566 *
567 * @param array|object|null $pt
568 * @return array{unit:string,label:string,min_pax:?int,max_pax:?int}
569 */
570 public static function priceUnit($pt): array
571 {
572 $unit = ['unit' => 'per_person', 'label' => '', 'min_pax' => null, 'max_pax' => null];
573
574 if ($pt !== null) {
575 $pt = (array) $pt;
576 if (!isset($pt['pricing_mode']) && isset($pt['category_id'])) {
577 $backfilled = self::applyCategoryPricingMeta([$pt]);
578 $pt = (array) ($backfilled[0] ?? $pt);
579 }
580 if (($pt['pricing_mode'] ?? 'per_person') === 'per_group') {
581 $unit['unit'] = 'per_group';
582 $unit['min_pax'] = isset($pt['min_pax']) && $pt['min_pax'] !== '' && $pt['min_pax'] !== null ? (int) $pt['min_pax'] : null;
583 $unit['max_pax'] = isset($pt['max_pax']) && $pt['max_pax'] !== '' && $pt['max_pax'] !== null ? (int) $pt['max_pax'] : null;
584 }
585 }
586
587 if ($unit['unit'] === 'per_group') {
588 if ($unit['min_pax'] > 0 && $unit['max_pax'] > 0) {
589 /* translators: 1: minimum pax for the group price, 2: maximum pax. */
590 $label = sprintf(__('per group (%1$d-%2$d pax)', 'yatra'), $unit['min_pax'], $unit['max_pax']);
591 } elseif ($unit['max_pax'] > 0) {
592 /* translators: %d: maximum pax for the group price. */
593 $label = sprintf(__('per group (up to %d pax)', 'yatra'), $unit['max_pax']);
594 } elseif ($unit['min_pax'] > 0) {
595 /* translators: %d: minimum pax for the group price. */
596 $label = sprintf(__('per group (%d+ pax)', 'yatra'), $unit['min_pax']);
597 } else {
598 $label = __('per group', 'yatra');
599 }
600 } else {
601 $label = __('per person', 'yatra');
602 }
603
604 /**
605 * Wording of the unit shown next to a price ("per person", "per group (1-2 pax)").
606 *
607 * @param string $label Translated label.
608 * @param string $unit 'per_person' or 'per_group'.
609 * @param array|null $pt The traveller-category price entry, if any.
610 */
611 $unit['label'] = (string) apply_filters('yatra_price_unit_label', $label, $unit['unit'], $pt);
612
613 return $unit;
614 }
615
616 /**
617 * Shorthand for priceUnit()['label'].
618 *
619 * @param array|object|null $pt
620 */
621 public static function priceUnitLabel($pt): string
622 {
623 return self::priceUnit($pt)['label'];
624 }
625
626 /**
627 * The availability price-type entry whose effective price equals $price
628 * (the value findMinPriceFromAvailability() picked), so the unit label can
629 * follow that category. Null when the minimum came from the availability
630 * row itself rather than a category.
631 *
632 * @param array<int, object> $availabilityDates
633 */
634 private static function findAvailabilityPriceTypeAt(array $availabilityDates, float $price): ?array
635 {
636 foreach ($availabilityDates as $avail) {
637 if (empty($avail->price_types) || !is_array($avail->price_types)) {
638 continue;
639 }
640 foreach ($avail->price_types as $pt) {
641 $pt = (array) $pt;
642 if (abs(self::resolveCategoryEffectivePrice($pt) - $price) < 0.005) {
643 return $pt;
644 }
645 }
646 }
647
648 return null;
649 }
650
651 /**
652 * Find minimum price across availability dates.
653 *
654 * @param array $availabilityDates Array of availability objects
655 * @param bool $checkPriceTypes Whether to check price_types within each availability
656 * @return float Minimum price found (0 if none)
657 */
658 private static function findMinPriceFromAvailability(array $availabilityDates, bool $checkPriceTypes): float
659 {
660 $min = PHP_FLOAT_MAX;
661
662 foreach ($availabilityDates as $avail) {
663 // Check effective_price / original_price on the availability
664 $avail_price = (float) ($avail->effective_price ?? $avail->original_price ?? 0);
665 if ($avail_price > 0 && $avail_price < $min) {
666 $min = $avail_price;
667 }
668
669 // Check price_types within availability (for traveler-based)
670 if ($checkPriceTypes && !empty($avail->price_types) && is_array($avail->price_types)) {
671 foreach ($avail->price_types as $pt) {
672 $pt_price = self::resolveCategoryEffectivePrice((array) $pt);
673 if ($pt_price > 0 && $pt_price < $min) {
674 $min = $pt_price;
675 }
676 }
677 }
678 }
679
680 return $min < PHP_FLOAT_MAX ? $min : 0.0;
681 }
682 }
683