| @@ -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. |