12345, 'currency' => 'EUR']; $booking = new AccommodationBooking(); $booking->setPriceSnapshot($snapshot, 12345, 'EUR', 1); $priceRepository = $this->createMock(AccommodationPriceRepository::class); $priceRepository->expects(self::never())->method('findByHotelCodeAndDateRange'); $breakdown = $this->createCalculator($priceRepository)->compute($booking); self::assertSame(12345, $breakdown['total']); self::assertSame('EUR', $breakdown['currency']); } public function testComputeEnrichesTheStoredSnapshotWithDiscountRowsAndTotal(): void { $booking = new AccommodationBooking(); $booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1); $booking->setAccommodationDiscount(10); $breakdown = $this->createCalculator()->compute($booking); self::assertSame([['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000]], $breakdown['discounts']); self::assertSame(12345 - 1000, $breakdown['discountedTotal']); } public function testWithDiscountsListsOneRowPerConfiguredDiscount(): void { $booking = new AccommodationBooking(); $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(20); $booking->setAdditionalServicesDiscount(50); $breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown()); // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250 self::assertSame([ ['label' => 'Unterkunft', 'percent' => 10, 'amount' => 1000], ['label' => 'Verpflegung', 'percent' => 20, 'amount' => 200], ['label' => 'Zusatzleistungen', 'percent' => 50, 'amount' => 250], ], $breakdown['discounts']); self::assertSame(12345 - 1000 - 200 - 250, $breakdown['discountedTotal']); } public function testWithDiscountsRoundsToWholeCents(): void { $booking = new AccommodationBooking(); $booking->setBoardServiceDiscount(15); // 333 * 15% = 49.95 — the amount has to land on whole cents, and the discounted total // has to be reduced by the very number that is shown on the row. $breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 12345, 'boardPrice' => 333]); self::assertSame(50, $breakdown['discounts'][0]['amount']); self::assertSame(12345 - 50, $breakdown['discountedTotal']); } public function testWithDiscountsSkipsDiscountsThatAmountToNothing(): void { $booking = new AccommodationBooking(); $booking->setAccommodationDiscount(0); // A discount on a bucket this booking has no price in. $booking->setAdditionalServicesDiscount(20); $breakdown = $this->createCalculator()->withDiscounts($booking, [ 'total' => 12345, 'basePrice' => 8000, 'servicesPrice' => 0, ]); self::assertSame([], $breakdown['discounts']); self::assertSame(12345, $breakdown['discountedTotal']); } public function testWithoutDiscountsTheDiscountedTotalIsTheTotal(): void { $breakdown = $this->createCalculator()->withDiscounts(new AccommodationBooking(), $this->breakdown()); self::assertSame([], $breakdown['discounts']); self::assertSame(12345, $breakdown['discountedTotal']); } /** * The derived keys must never reach the database: refreshPriceSnapshot() freezes exactly * what computeCurrent() returns. */ public function testComputeCurrentReturnsTheRawBreakdown(): void { $booking = new AccommodationBooking(); $booking->setAccommodationDiscount(10); $booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('CHF')); $booking->setDateFrom(new \DateTimeImmutable('2026-08-01')); $booking->setDateTo(new \DateTimeImmutable('2026-08-03')); $breakdown = $this->createCalculator()->computeCurrent($booking); self::assertArrayNotHasKey('discounts', $breakdown); self::assertArrayNotHasKey('discountedTotal', $breakdown); } /** * @return array */ private function breakdown(): array { return [ 'total' => 12345, 'currency' => 'CHF', 'basePrice' => 8000, 'additionalPersonsPrice' => 2000, 'boardPrice' => 1000, 'servicesPrice' => 500, ]; } private function createCalculator(?AccommodationPriceRepository $priceRepository = null): AccommodationBookingBreakdownCalculator { return new AccommodationBookingBreakdownCalculator( new GroupsPriceCalculator(new PriceTimelineBuilder(), [ 'runningCostsEur' => 0, 'runningCostsChf' => 0, 'undersubscription30Eur' => 0, 'undersubscription30Chf' => 0, 'undersubscription40Eur' => 0, 'undersubscription40Chf' => 0, ]), $priceRepository ?? $this->createStub(AccommodationPriceRepository::class), ); } }