|null null when no accommodation or dates are set */ public function compute(AccommodationBooking $booking): ?array { $breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking); if (null === $breakdown) { return null; } // Snapshots frozen before split-off periods existed (pricing version 1) read as a // booking without any, so templates only ever handle one shape. $breakdown += ['periods' => [], 'periodsTotal' => 0]; return $this->withDiscounts($booking, $breakdown); } /** * Adds the discount rows and the price the customer actually pays. Derived on every read * and never persisted — refreshPriceSnapshot() stores the raw breakdown from * computeCurrent() — so the stored total and this one come out of the same code and * cannot drift apart. * * @param array $breakdown * * @return array the breakdown plus `discounts`, `discountSubtotal`, * `totalDiscountDetails` and `discountedTotal` */ public function withDiscounts(AccommodationBooking $booking, array $breakdown): array { // The labels carry their own "Rabatt" prefix so that every row renders as plain // {label} {percent} %, while the freely named discount on the total below is a fixed // amount and renders as its label alone. $buckets = [ ['Rabatt Unterkunft', $booking->getAccommodationDiscount(), (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0)], ['Rabatt Verpflegung', $booking->getBoardServiceDiscount(), (int) ($breakdown['boardPrice'] ?? 0)], ['Rabatt Zusatzleistungen', $booking->getAdditionalServicesDiscount(), (int) ($breakdown['servicesPrice'] ?? 0)], ]; $discounts = []; $discountSum = 0; foreach ($buckets as [$label, $percent, $base]) { $amount = self::discountAmount($base, $percent); // Nothing to show for a discount of 0 %, or one on a bucket this booking has no // price in — a "– 0,00 €" row only raises questions. if (0 === $amount) { continue; } $discountSum += $amount; $discounts[] = ['label' => $label, 'percent' => $percent, 'amount' => $amount]; } // Deliberately not $booking->getTotalPrice(): refreshPriceSnapshot() calls this while // computing the new total, where the stored one is still the outdated value. $subtotal = (int) ($breakdown['total'] ?? 0) - $discountSum; // A fixed sum rather than a percentage, subtracted after the section discounts. Clamped to // what is left of the subtotal so that a discount larger than the price cannot produce a // negative total, the same guard BookingSummaryAssembler applies to vouchers. $totalDiscountAmount = min($booking->getTotalDiscountAmount() ?? 0, max(0, $subtotal)); $breakdown['discounts'] = $discounts; $breakdown['totalDiscountDetails'] = 0 !== $totalDiscountAmount ? [ 'label' => (string) $booking->getTotalDiscountLabel(), 'amount' => $totalDiscountAmount, ] : null; // Only worth a row when it sits between two sets of discounts — with no section discount // above it, a subtotal would merely restate the total one line up. $breakdown['discountSubtotal'] = 0 !== $totalDiscountAmount && [] !== $discounts ? $subtotal : null; $breakdown['discountedTotal'] = $subtotal - $totalDiscountAmount; return $breakdown; } private static function discountAmount(int $base, ?int $percent): int { return null !== $percent ? (int) round($base * $percent / 100) : 0; } /** * Calculates against the current catalog. Use only while creating or explicitly editing * a booking, or as a compatibility fallback for records created before price snapshots. * * The catalog prices the main period only; the split-off periods add their lines on top, * so `total` is the grand total the discounts are taken from. The price rows are loaded for * the whole stay, which the includedPax floor and the short-term surcharge are judged on. * * @return array|null */ public function computeCurrent(AccommodationBooking $booking): ?array { $accommodation = $booking->getAccommodation(); $dateFrom = $booking->getDateFrom(); $dateTo = $booking->getDateTo(); if (null === $accommodation || null === $dateFrom || null === $dateTo) { return null; } $prices = $this->priceRepo->findByHotelCodeAndDateRange( $accommodation->getCalendarCode() ?? '', $dateFrom, $dateTo, ); $breakdown = $this->priceCalculator->calculateFromSnapshots( $booking->getPaxCount(), $booking->getMinorsCount(), $booking->getMainNights(), $booking->getMainDateFrom() ?? $dateFrom, $booking->getMainDateTo() ?? $dateTo, $dateFrom, $booking->getNights(), $prices, $booking->getBoardServicePrice(), $booking->getAdditionalServices(), $accommodation->getCurrency(), ); $periods = []; foreach (array_filter([$booking->getLeadingPeriod(), $booking->getTrailingPeriod()]) as $period) { $periods[] = [ 'position' => $period->getPosition()->value, 'label' => $period->getLabel(), 'dateFrom' => $period->getDateFrom()->format('Y-m-d'), 'dateTo' => $period->getDateTo()->format('Y-m-d'), 'nights' => $period->getNights(), 'lines' => $period->getLines(), 'total' => $period->getTotal(), ]; } $periodsTotal = $booking->getPeriodsTotal(); return [ ...$breakdown, 'periods' => $periods, 'periodsTotal' => $periodsTotal, 'total' => $breakdown['total'] + $periodsTotal, ]; } }