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 +294 -19 3.0.2.6 → 3.0.16 View file →
@@ -65,15 +65,26 @@
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) {
73 - // Traveler-based: find minimum effective price across all categories
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;
74 84 $min_price = PHP_FLOAT_MAX;
75 85 $min_original = 0.0;
86 + $min_pt = null;
76 87 $max_discount = 0;
77 88
78 89 foreach ($price_types as $pt) {
79 90 $pt = (array) $pt;
@@ -79,11 +90,18 @@
79 90 $pt = (array) $pt;
80 91 $original = (float) ($pt['original_price'] ?? 0);
81 92 $discounted = self::resolveCategoryEffectivePrice($pt);
82 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 +
83 100 if ($discounted > 0 && $discounted < $min_price) {
84 101 $min_price = $discounted;
85 102 $min_original = $original;
103 + $min_pt = $pt;
86 104 }
87 105
88 106 // Track max discount across categories
89 107 if ($original > 0 && $discounted > 0 && $discounted < $original) {
@@ -93,14 +111,21 @@
93 111 }
94 112 }
95 113 }
96 114
97 - if ($min_price < PHP_FLOAT_MAX) {
98 - $result['effective_price_min'] = $min_price;
99 - $result['min_category_original_price'] = $min_original;
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;
100 125 $result['max_discount_percentage'] = $max_discount;
101 - $result['current_price'] = $min_price;
102 - $result['original_price'] = $min_original;
126 + $result['current_price'] = $chosen_price;
127 + $result['original_price'] = $chosen_original;
103 128 $result['has_discount'] = $max_discount > 0;
104 129 $result['discount_percentage'] = $max_discount;
105 130 $result['price_prefix'] = __('From ', 'yatra');
106 131 }
@@ -127,8 +152,16 @@
127 152 $avail_min = self::findMinPriceFromAvailability($availabilityDates, $has_traveler_pricing);
128 153 if ($avail_min > 0 && ($result['effective_price_min'] <= 0 || $avail_min < $result['effective_price_min'])) {
129 154 $result['effective_price_min'] = $avail_min;
130 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 + }
131 164 }
132 165 } elseif ($has_traveler_pricing) {
133 166 $result['price_prefix'] = __('From ', 'yatra');
134 167 }
@@ -147,14 +180,16 @@
147 180 * @return array Card pricing data
148 181 */
149 182 public static function resolveCardPricing(object $avail, object $trip): array
150 183 {
151 - $pricing_type = self::resolvePricingType($trip);
184 + $trip_mode = self::resolvePricingType($trip);
152 185 $avail_price_types = !empty($avail->price_types) && is_array($avail->price_types)
153 186 ? $avail->price_types : [];
154 187
155 - // Override to traveler_based if this availability has price_types
156 - 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)) {
157 192 $pricing_type = 'traveler_based';
158 193 }
159 194
160 195 $result = [
@@ -162,9 +197,11 @@
162 197 'original_price' => 0.0,
163 198 'has_discount' => false,
164 199 'discount_percentage' => 0,
165 200 'pricing_type' => $pricing_type,
166 - '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),
167 204 ];
168 205
169 206 if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) {
170 207 // Traveler-based: use first category's price as display
@@ -170,8 +207,11 @@
170 207 // Traveler-based: use first category's price as display
171 208 $first = (array) $avail_price_types[0];
172 209 $result['sale_price'] = self::resolveCategoryEffectivePrice($first);
173 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'];
174 214 } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) {
175 215 // Regular: use pre-calculated effective price
176 216 $result['sale_price'] = (float) $avail->effective_price;
177 217 $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0
@@ -207,16 +247,21 @@
207 247 $pricing_type = self::resolvePricingType($trip);
208 248 $price_types = self::resolvePriceTypes($trip);
209 249
210 250 if ($pricing_type === 'traveler_based' && !empty($price_types)) {
251 + $default = 0.0;
211 252 $min = PHP_FLOAT_MAX;
212 253 foreach ($price_types as $pt) {
213 - $price = self::resolveCategoryEffectivePrice((array) $pt);
254 + $ptArr = (array) $pt;
255 + $price = self::resolveCategoryEffectivePrice($ptArr);
256 + if (!empty($ptArr['is_default']) && $default <= 0 && $price > 0) {
257 + $default = $price;
258 + }
214 259 if ($price > 0 && $price < $min) {
215 260 $min = $price;
216 261 }
217 262 }
218 - $effective = $min < PHP_FLOAT_MAX ? $min : 0.0;
263 + $effective = $default > 0 ? $default : ($min < PHP_FLOAT_MAX ? $min : 0.0);
219 264 } else {
220 265 $current = self::resolveRegularCurrentPrice($trip);
221 266 $original = (float) ($trip->original_price ?? 0);
222 267 $effective = $current > 0 ? $current : $original;
@@ -277,18 +322,25 @@
277 322 * @return string 'regular' or 'traveler_based'
278 323 */
279 324 public static function resolvePricingType(object $trip): string
280 325 {
281 - $type = $trip->pricing_type ?? 'regular';
326 + $raw = $trip->pricing_type ?? null;
327 + if (is_string($raw)) {
328 + $raw = trim($raw);
329 + }
282 330
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 - }
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);
289 337 }
290 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 +
291 343 return (string) apply_filters('yatra_resolve_pricing_type', $type, $trip);
292 344 }
293 345
294 346 /**
@@ -341,12 +393,141 @@
341 393 'is_default' => !empty($pt['is_default']),
342 394 ];
343 395 }
344 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 +
345 402 return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip);
346 403 }
347 404
348 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 + /**
349 530 * Compute discount info from two prices.
350 531 *
351 532 * @param float $originalPrice Original price
352 533 * @param float $currentPrice Current (sale/discounted) price
@@ -370,8 +551,102 @@
370 551 );
371 552 }
372 553
373 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;
374 649 }
375 650
376 651 /**
377 652 * Find minimum price across availability dates.