| @@ -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 | } |
| @@ -177,8 +198,10 @@ | ||
| 177 | 198 | 'has_discount' => false, |
| 178 | 199 | 'discount_percentage' => 0, |
| 179 | 200 | 'pricing_type' => $pricing_type, |
| 180 | 201 | 'price_types' => $pricing_type === 'traveler_based' ? $avail_price_types : [], |
| 202 | + 'price_unit' => 'per_person', | |
| 203 | + 'price_unit_label' => self::priceUnitLabel(null), | |
| 181 | 204 | ]; |
| 182 | 205 | |
| 183 | 206 | if ($pricing_type === 'traveler_based' && !empty($avail_price_types)) { |
| 184 | 207 | // Traveler-based: use first category's price as display |
| @@ -184,8 +207,11 @@ | ||
| 184 | 207 | // Traveler-based: use first category's price as display |
| 185 | 208 | $first = (array) $avail_price_types[0]; |
| 186 | 209 | $result['sale_price'] = self::resolveCategoryEffectivePrice($first); |
| 187 | 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']; | |
| 188 | 214 | } elseif (isset($avail->effective_price) && (float) $avail->effective_price > 0) { |
| 189 | 215 | // Regular: use pre-calculated effective price |
| 190 | 216 | $result['sale_price'] = (float) $avail->effective_price; |
| 191 | 217 | $result['original_price'] = isset($avail->original_price) && (float) $avail->original_price > 0 |
| @@ -367,12 +393,141 @@ | ||
| 367 | 393 | 'is_default' => !empty($pt['is_default']), |
| 368 | 394 | ]; |
| 369 | 395 | } |
| 370 | 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 | + | |
| 371 | 402 | return (array) apply_filters('yatra_resolve_price_types', $normalized, $trip); |
| 372 | 403 | } |
| 373 | 404 | |
| 374 | 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 | + /** | |
| 375 | 530 | * Compute discount info from two prices. |
| 376 | 531 | * |
| 377 | 532 | * @param float $originalPrice Original price |
| 378 | 533 | * @param float $currentPrice Current (sale/discounted) price |
| @@ -396,8 +551,102 @@ | ||
| 396 | 551 | ); |
| 397 | 552 | } |
| 398 | 553 | |
| 399 | 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; | |
| 400 | 649 | } |
| 401 | 650 | |
| 402 | 651 | /** |
| 403 | 652 | * Find minimum price across availability dates. |