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' => 'Rabatt 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' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 1000], ['label' => 'Rabatt Verpflegung', 'percent' => 20, 'amount' => 200], ['label' => 'Rabatt 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::assertNull($breakdown['totalDiscountDetails']); self::assertNull($breakdown['discountSubtotal']); self::assertSame(12345, $breakdown['discountedTotal']); } /** * On its own the total discount has nothing to sit below, so no subtotal row is offered and * the amount comes straight off the plain total. */ public function testTotalDiscountAloneAppliesToTheTotalWithoutASubtotal(): void { $booking = new AccommodationBooking(); $booking->setTotalDiscountAmount(1235); $booking->setTotalDiscountLabel('Treuerabatt'); $breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown()); self::assertSame([], $breakdown['discounts']); self::assertSame(['label' => 'Treuerabatt', 'amount' => 1235], $breakdown['totalDiscountDetails']); self::assertNull($breakdown['discountSubtotal']); self::assertSame(12345 - 1235, $breakdown['discountedTotal']); } /** * The worked example from the specification: a fixed 50,00 € off the 930,00 € left by the * section discounts, which is where the Zwischensumme row comes from. */ public function testTotalDiscountIsSubtractedFromTheSubtotalAfterSectionDiscounts(): void { $booking = new AccommodationBooking(); $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(5); $booking->setTotalDiscountAmount(5000); $booking->setTotalDiscountLabel('Treuerabatt'); $breakdown = $this->createCalculator()->withDiscounts($booking, [ 'total' => 100000, 'basePrice' => 60000, 'boardPrice' => 20000, ]); // accommodation: 60000*10% = 6000, board: 20000*5% = 1000 → subtotal 93000 self::assertSame(93000, $breakdown['discountSubtotal']); self::assertSame(['label' => 'Treuerabatt', 'amount' => 5000], $breakdown['totalDiscountDetails']); self::assertSame(88000, $breakdown['discountedTotal']); } /** * A sum larger than the price is clamped rather than refused: prices move after the discount * was agreed, and a negative total would be worse than a free stay. */ public function testTotalDiscountLargerThanTheSubtotalIsClampedToIt(): void { $booking = new AccommodationBooking(); $booking->setTotalDiscountAmount(20000); $booking->setTotalDiscountLabel('Treuerabatt'); $breakdown = $this->createCalculator()->withDiscounts($booking, $this->breakdown()); self::assertSame(['label' => 'Treuerabatt', 'amount' => 12345], $breakdown['totalDiscountDetails']); self::assertSame(0, $breakdown['discountedTotal']); } public function testTotalDiscountThatAmountsToNothingIsSkipped(): void { $booking = new AccommodationBooking(); $booking->setTotalDiscountAmount(2000); $booking->setTotalDiscountLabel('Treuerabatt'); $breakdown = $this->createCalculator()->withDiscounts($booking, ['total' => 0]); self::assertNull($breakdown['totalDiscountDetails']); self::assertNull($breakdown['discountSubtotal']); self::assertSame(0, $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('discountSubtotal', $breakdown); self::assertArrayNotHasKey('totalDiscountDetails', $breakdown); self::assertArrayNotHasKey('discountedTotal', $breakdown); } public function testAVersion1SnapshotReadsAsABookingWithoutPeriods(): void { $booking = new AccommodationBooking(); $booking->setPriceSnapshot($this->breakdown(), 12345, 'CHF', 1); $breakdown = $this->createCalculator()->compute($booking); self::assertSame([], $breakdown['periods']); self::assertSame(0, $breakdown['periodsTotal']); self::assertSame(12345, $breakdown['discountedTotal']); } public function testWithoutPeriodsTheCatalogPricesTheWholeStay(): void { // Regression guard: a booking nobody split is priced exactly as before periods existed. $booking = $this->stay(); $breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking); self::assertSame(7, $breakdown['nights']); self::assertSame(7 * 100_00, $breakdown['basePrice']); self::assertSame(7 * 100_00, $breakdown['total']); self::assertSame([], $breakdown['periods']); self::assertSame(0, $breakdown['periodsTotal']); } public function testTheCatalogPricesOnlyTheMainPeriodAndThePeriodLinesAreAddedOnTop(): void { $booking = $this->stay(); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-07-07', '2026-07-08', [['label' => 'Abbau', 'amount' => 100_00]])); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [ ['label' => 'Übernachtung', 'amount' => 250_00], ['label' => 'Verpflegung', 'amount' => 50_00], ])->setLabel('Vorausteam')); $breakdown = $this->createCalculator($this->priceRepository())->computeCurrent($booking); self::assertSame(5, $breakdown['nights']); self::assertSame(5 * 100_00, $breakdown['basePrice']); self::assertSame(400_00, $breakdown['periodsTotal']); self::assertSame(5 * 100_00 + 400_00, $breakdown['total']); self::assertSame([ [ 'position' => 'leading', 'label' => 'Vorausteam', 'dateFrom' => '2026-07-01', 'dateTo' => '2026-07-02', 'nights' => 1, 'lines' => [ ['label' => 'Übernachtung', 'amount' => 250_00], ['label' => 'Verpflegung', 'amount' => 50_00], ], 'total' => 300_00, ], [ 'position' => 'trailing', 'label' => null, 'dateFrom' => '2026-07-07', 'dateTo' => '2026-07-08', 'nights' => 1, 'lines' => [['label' => 'Abbau', 'amount' => 100_00]], 'total' => 100_00, ], ], $breakdown['periods']); } public function testOnlyTheFixedDiscountReachesThePeriodLines(): void { // The section discounts belong to the catalog-priced main period; the fixed discount // is taken off the grand total, so it may exceed what the main period alone costs. $booking = $this->stay(); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-07-01', '2026-07-02', [['label' => 'Vorausteam', 'amount' => 1000_00]])); $booking->setAccommodationDiscount(10); $booking->setTotalDiscountAmount(900_00); $booking->setTotalDiscountLabel('Sonderpreis'); $calculator = $this->createCalculator($this->priceRepository()); $breakdown = $calculator->withDiscounts($booking, $calculator->computeCurrent($booking)); self::assertSame([['label' => 'Rabatt Unterkunft', 'percent' => 10, 'amount' => 60_00]], $breakdown['discounts']); self::assertSame(600_00 + 1000_00 - 60_00, $breakdown['discountSubtotal']); self::assertSame(600_00 + 1000_00 - 60_00 - 900_00, $breakdown['discountedTotal']); } private function stay(): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setAccommodation((new Accommodation())->setCalendarCode('HOTEL')->setCurrency('EUR')); $booking->setDateFrom(new \DateTimeImmutable('2026-07-01')); $booking->setDateTo(new \DateTimeImmutable('2026-07-08')); $booking->setPaxCount(20); return $booking; } /** @param list $lines */ private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, array $lines): AccommodationBookingPeriod { return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to))) ->setLines($lines); } private function priceRepository(): AccommodationPriceRepository { $price = (new AccommodationPrice()) ->setDateFrom(new \DateTimeImmutable('2026-06-01')) ->setDateTo(new \DateTimeImmutable('2026-08-31')) ->setIncludedPax(20) ->setPricePerNight(100_00) ->setPriceAdditionalPerson(10_00) ->setMinNights(1); $priceRepository = $this->createStub(AccommodationPriceRepository::class); $priceRepository->method('findByHotelCodeAndDateRange')->willReturn([$price]); return $priceRepository; } /** * @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), ); } }