|null null when no accommodation or dates are set */ public function compute(AccommodationBooking $booking): ?array { $breakdown = $booking->getPriceBreakdown() ?? $this->computeCurrent($booking); return null !== $breakdown ? $this->withDiscounts($booking, $breakdown) : null; } /** * 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. * * @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, ); return $this->priceCalculator->calculateFromSnapshots( $booking->getPaxCount(), $booking->getMinorsCount(), $booking->getNights(), $dateFrom, $dateTo, $prices, $booking->getBoardServicePrice(), $booking->getAdditionalServices(), $accommodation->getCurrency(), ); } }