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
← All changes | app/Services/TripPricingService.php +269 -11 3.0.2.8 → 3.0.16 View file →
@@ -65,8 +65,13 @@
65 65 'price_prefix' => '',
66 66 'pricing_type' => $pricing_type,
67 67 'price_types' => $price_types,
68 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),
69 74 'currency' => SettingsService::getCurrency(),
70 75 ];
71 76
72 77 if ($has_traveler_pricing) {
@@ -74,10 +79,12 @@
74 79 // - If a default category is marked, use that for initial display (listings + single trip page-load).
75 80 // - Otherwise fall back to minimum effective price across categories (current behavior).
76 81 $default_price = 0.0;
77 82 $default_original = 0.0;
83 + $default_pt = null;
78 84 $min_price = PHP_FLOAT_MAX;
79 85 $min_original = 0.0;
86 + $min_pt = null;
80 87 $max_discount = 0;
81 88
82 89 foreach ($price_types as $pt) {
83 90 $pt = (array) $pt;
@@ -86,13 +93,15 @@
86 93
87 94 if (!empty($pt['is_default']) && $default_price <= 0 && $discounted > 0) {
88 95 $default_price = $discounted;
89 96 $default_original = $original;
97 + $default_pt = $pt;
90 98 }
91 99
92 100 if ($discounted > 0 && $discounted < $min_price) {
93 101 $min_price = $discounted;
94 102 $min_original = $original;
103 + $min_pt = $pt;
95 104 }
96 105
97 106 // Track max discount across categories
98 107 if ($original > 0 && $discounted > 0 && $discounted < $original) {
@@ -104,10 +113,14 @@
104 113 }
105 114
106 115 $chosen_price = $default_price > 0 ? $default_price : ($min_price < PHP_FLOAT_MAX ? $min_price : 0.0);
107 116 $chosen_original = $default_price > 0 ? $default_original : $min_original;
117 + $chosen_pt = $default_price > 0 ? $default_pt : $min_pt;
108 118
109 119 if ($chosen_price > 0) {
120 + $unit = self::priceUnit($chosen_pt);
121 + $result['price_unit'] = $unit['unit'];
122 + $result['price_unit_label'] = $unit['label'];
110 123 $result['effective_price_min'] = $chosen_price;
111 124 $result['min_category_original_price'] = $chosen_original;
112 125 $result['max_discount_percentage'] = $max_discount;
113 126 $result['current_price'] = $chosen_price;
@@ -139,8 +152,16 @@
139 152 $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing);
140 153 if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) {
141 154 $result['effective_price_min'] = $avail_min;
142 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 + }
143 164 }
144 165 } elseif ($has_traveler_pricing) {
145 166 $result['price_prefix'] = __('From ', 'yatra');
146 167 }
@@ -159,14 +180,16 @@
159 180 * @return array Card pricing data
160 181 */
161 182 public static function resolveCardPricing(object $avail, object $trip): array
162 183 {
163 - $pricing_type = self::resolvePricingType($trip);
184 + $trip_mode = self::resolvePricingType($trip);
164 185 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
165 186 ? $avail->price_types : [];
166 187
167 - // Override to traveler_based if this availability has price_types
168 - if (!empty($avail_price_types)) {
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)) {
169 192 $pricing_type = 'traveler_based';
170 193 }
171 194
172 195 $result = [
@@ -174,9 +197,11 @@
174 197 'original_price' => 0.0,
175 198 'has_discount' => false,
176 199 'discount_percentage' => 0,
177 200 'pricing_type' => $pricing_type,
178 - 'price_types' => $avail_price_types,
201 + 'price_types' => $pricing_type === 'traveler_based' ? $avail_price_types : [],
202 + 'price_unit' => 'per_person',
203 + 'price_unit_label' => self::priceUnitLabel(null),
179 204 ];
180 205
181 206 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
182 207 // Traveler-based: use first category's price as display
@@ -182,8 +207,11 @@
182 207 // Traveler-based: use first category's price as display
183 208 $first = (array) $avail_price_types[0];
184 209 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
185 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'];
186 214 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
187 215 // Regular: use pre-calculated effective price
188 216 $result['sale_price'] = (float) $avail->effective_price;
189 217 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
@@ -294,18 +322,25 @@
294 322 * @return string 'regular' or 'traveler_based'
295 323 */
296 324 public static function resolvePricingType(object $trip): string
297 325 {
298 - $type = $trip->pricing_type ?? 'regular';
326 + $raw = $trip->pricing_type ?? null;
327 + if (is_string($raw)) {
328 + $raw = trim($raw);
329 + }
299 330
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 - }
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);
306 337 }
307 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 +
308 343 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
309 344 }
310 345
311 346 /**
@@ -358,12 +393,141 @@
358 393 'is_default' => !empty($pt['is_default']),
359 394 ];
360 395 }
361 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 +
362 402 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
363 403 }
364 404
365 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 + /**
366 530 * Compute discount info from two prices.
367 531 *
368 532 * @param float $originalPrice Original price
369 533 * @param float $currentPrice Current (sale/discounted) price
@@ -387,8 +551,102 @@
387 551 );
388 552 }
389 553
390 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;
391 649 }
392 650
393 651 /**
394 652 * Find minimum price across availability dates.