short-term surcharge percentages (nights → percent) */ private const array SHORT_TERM_FACTORS = [1 => 30, 2 => 20, 3 => 10]; /** @var array */ private array $config; /** * @param array $config */ public function __construct( private readonly PriceTimelineBuilder $priceTimelineBuilder, array $config, ) { $this->config = $this->resolveConfig($config); } /** * @param array $config * * @return array */ private function resolveConfig(array $config): array { $resolver = new OptionsResolver(); $resolver->setRequired(['runningCostsEur', 'runningCostsChf', 'undersubscription30Eur', 'undersubscription30Chf', 'undersubscription40Eur', 'undersubscription40Chf']); $resolver->setAllowedTypes('runningCostsEur', ['int', 'float']); $resolver->setAllowedTypes('runningCostsChf', ['int', 'float']); $resolver->setAllowedTypes('undersubscription30Eur', ['int', 'float']); // per-person/night surcharge when effectivePax < 40 $resolver->setAllowedTypes('undersubscription30Chf', ['int', 'float']); $resolver->setAllowedTypes('undersubscription40Eur', ['int', 'float']); // per-person/night surcharge when 40 ≤ effectivePax < 50 $resolver->setAllowedTypes('undersubscription40Chf', ['int', 'float']); return $resolver->resolve($config); } /** * @param AccommodationPrice[] $prices all prices overlapping the booking window * @param AdditionalService[] $additionalServices only the selected services * * @return array{ * effectivePax: int, * totalPax: int, * includedPax: int, * nights: int, * basePrice: int, * additionalPersonsPrice: int, * shortTermSurcharge: int, * undersubscriptionSurcharge: int, * undersubscriptionThreshold: int|null, * boardPrice: int, * serviceDetails: list, * servicesPrice: int, * runningCosts: int, * total: int, * currency: string, * } */ public function calculate( int $paxCount, int $minorsCount, int $nights, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, array $prices, ?BoardService $boardService, array $additionalServices, string $currency, ): array { return $this->doCalculate( $paxCount, $minorsCount, $nights, $dateFrom, $dateTo, $prices, $boardService?->getPrice(), array_map( fn (AdditionalService $s) => [ 'label' => $s->getLabel() ?? '', 'price' => $s->getPrice() ?? 0, 'type' => $s->getType(), ], $additionalServices, ), $currency, ); } /** * Same calculation but using frozen snapshot data from an AccommodationBooking entity * instead of live catalog entities. * * @param AccommodationPrice[] $prices all prices overlapping the booking window * @param list $additionalServiceSnapshots * * @return array{ * effectivePax: int, * totalPax: int, * includedPax: int, * nights: int, * basePrice: int, * additionalPersonsPrice: int, * shortTermSurcharge: int, * undersubscriptionSurcharge: int, * undersubscriptionThreshold: int|null, * boardPrice: int, * serviceDetails: list, * servicesPrice: int, * runningCosts: int, * total: int, * currency: string, * } */ public function calculateFromSnapshots( int $paxCount, int $minorsCount, int $nights, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, array $prices, ?int $boardServicePricePerPersonNight, array $additionalServiceSnapshots, string $currency, ): array { $items = array_map( fn (array $s) => [ 'label' => $s['label'], 'price' => $s['price'], 'type' => AdditionalServiceType::tryFrom($s['type']) ?? AdditionalServiceType::Flat, ], $additionalServiceSnapshots, ); return $this->doCalculate( $paxCount, $minorsCount, $nights, $dateFrom, $dateTo, $prices, $boardServicePricePerPersonNight, $items, $currency, ); } /** * @param AccommodationPrice[] $prices * @param list $additionalItems * * @return array{ * effectivePax: int, * totalPax: int, * includedPax: int, * nights: int, * basePrice: int, * additionalPersonsPrice: int, * shortTermSurcharge: int, * undersubscriptionSurcharge: int, * undersubscriptionThreshold: int|null, * boardPrice: int, * serviceDetails: list, * servicesPrice: int, * runningCosts: int, * total: int, * currency: string, * } */ private function doCalculate( int $paxCount, int $minorsCount, int $nights, \DateTimeImmutable $dateFrom, \DateTimeImmutable $dateTo, array $prices, ?int $boardServicePricePerPersonNight, array $additionalItems, string $currency, ): array { // Derive effectivePax: children 0–3 don't count, but never drop below includedPax $firstCandidates = array_values(array_filter( $prices, fn (AccommodationPrice $p) => $p->getDateFrom() <= $dateFrom && $p->getDateTo() >= $dateFrom, )); $firstWinner = $this->priceTimelineBuilder->resolveWinner($firstCandidates); $includedPaxFloor = $firstWinner?->getIncludedPax() ?? 1; $effectivePax = max($paxCount - $minorsCount, $includedPaxFloor); // Rules 1 & 2: per-night base price + additional persons. $boundaryMap = [$dateFrom->format('Y-m-d') => $dateFrom, $dateTo->format('Y-m-d') => $dateTo]; foreach ($prices as $p) { $from = $p->getDateFrom(); $toNext = $p->getDateTo()->modify('+1 day'); if ($from > $dateFrom && $from < $dateTo) { $boundaryMap[$from->format('Y-m-d')] = $from; } if ($toNext > $dateFrom && $toNext < $dateTo) { $boundaryMap[$toNext->format('Y-m-d')] = $toNext; } } ksort($boundaryMap); $sortedBoundaries = array_values($boundaryMap); $basePrice = 0; $additionalPersonsPrice = 0; $lastWinner = null; for ($i = 0, $end = count($sortedBoundaries) - 1; $i < $end; ++$i) { $segStart = $sortedBoundaries[$i]; $segNights = $segStart->diff($sortedBoundaries[$i + 1])->days; $candidates = array_values(array_filter( $prices, fn (AccommodationPrice $p) => $p->getDateFrom() <= $segStart && $p->getDateTo() >= $segStart, )); $winner = $this->priceTimelineBuilder->resolveWinner($candidates) ?? $lastWinner; if (null !== $winner) { $lastWinner = $winner; $basePrice += ($winner->getPricePerNight() ?? 0) * $segNights; $includedPax = $winner->getIncludedPax() ?? 0; if ($effectivePax > $includedPax) { $additionalPersonsPrice += ($effectivePax - $includedPax) * ($winner->getPriceAdditionalPerson() ?? 0) * $segNights; } } } // Rule 3: short-term surcharge $shortTermSurcharge = 0; if (isset(self::SHORT_TERM_FACTORS[$nights])) { $shortTermSurcharge = (int) round(($basePrice + $additionalPersonsPrice) * self::SHORT_TERM_FACTORS[$nights] / 100); } // Board is only billable — and only triggers the catering surcharge — when it costs // something. A free "Selbstversorgung" board is stored as 0 (BoardService::$price is // non-nullable), so a plain null check would charge the surcharge for it. $paidBoardPricePerPersonNight = ($boardServicePricePerPersonNight ?? 0) > 0 ? $boardServicePricePerPersonNight : null; // Rule 4: undersubscription surcharge (only when a paid board is selected) $undersubscriptionSurcharge = 0; $undersubscriptionThreshold = null; if (null !== $paidBoardPricePerPersonNight) { $surcharge30 = (int) round(('CHF' === $currency ? $this->config['undersubscription30Chf'] : $this->config['undersubscription30Eur']) * 100); $surcharge40 = (int) round(('CHF' === $currency ? $this->config['undersubscription40Chf'] : $this->config['undersubscription40Eur']) * 100); if ($effectivePax < 40) { $undersubscriptionSurcharge = $effectivePax * $surcharge30 * $nights; $undersubscriptionThreshold = 40; } elseif ($effectivePax < 50) { $undersubscriptionSurcharge = $effectivePax * $surcharge40 * $nights; $undersubscriptionThreshold = 50; } } // Rule 5: board price $boardPrice = 0; if (null !== $paidBoardPricePerPersonNight) { $boardPrice = $paidBoardPricePerPersonNight * $effectivePax * $nights; } // Rule 6: additional services $serviceDetails = []; $servicesPrice = 0; foreach ($additionalItems as $item) { $price = match ($item['type']) { AdditionalServiceType::Flat => $item['price'], AdditionalServiceType::PerPerson => $item['price'] * $effectivePax, AdditionalServiceType::PerNight => $item['price'] * $nights, AdditionalServiceType::PerPersonPerNight => $item['price'] * $effectivePax * $nights, }; $servicesPrice += $price; $serviceDetails[] = ['label' => $item['label'], 'price' => $price]; } // Rule 7: running costs — uses full paxCount (all persons including 0–3 year olds) $runningCostFactor = (int) round(('CHF' === $currency ? $this->config['runningCostsChf'] : $this->config['runningCostsEur']) * 100); $runningCosts = $paxCount * $nights * $runningCostFactor; $total = $basePrice + $additionalPersonsPrice + $shortTermSurcharge + $undersubscriptionSurcharge + $boardPrice + $servicesPrice + $runningCosts; return [ 'effectivePax' => $effectivePax, 'totalPax' => $paxCount, 'includedPax' => $includedPaxFloor, 'nights' => $nights, 'basePrice' => $basePrice, 'additionalPersonsPrice' => $additionalPersonsPrice, 'shortTermSurcharge' => $shortTermSurcharge, 'undersubscriptionSurcharge' => $undersubscriptionSurcharge, 'undersubscriptionThreshold' => $undersubscriptionThreshold, 'boardPrice' => $boardPrice, 'serviceDetails' => $serviceDetails, 'servicesPrice' => $servicesPrice, 'runningCosts' => $runningCosts, 'total' => $total, 'currency' => $currency, ]; } }