setStatus($status); self::assertNull($booking->getAcceptedAt()); self::assertSame('Angebot', $booking->recordLabel()); } /** * @return iterable */ public static function statusesBeforeTheCustomerCommits(): iterable { yield 'draft' => [AccommodationBookingStatus::Draft]; yield 'requested' => [AccommodationBookingStatus::Requested]; yield 'open' => [AccommodationBookingStatus::Open]; } #[DataProvider('statusesAfterTheCustomerCommits')] public function testARecordIsCalledABookingOnceTheCustomerHasCommitted(AccommodationBookingStatus $status): void { $booking = new AccommodationBooking(); $booking->setStatus($status); $booking->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Buchung', $booking->recordLabel()); } /** * @return iterable */ public static function statusesAfterTheCustomerCommits(): iterable { yield 'received' => [AccommodationBookingStatus::Received]; yield 'confirmed' => [AccommodationBookingStatus::Confirmed]; } public function testADiscardedRecordKeepsWhicheverItHadBecome(): void { // An Absage can close either an offer nobody accepted or a booking that fell // through, so the label follows the commitment rather than the status. $unaccepted = new AccommodationBooking(); $unaccepted->setStatus(AccommodationBookingStatus::Discarded); $accepted = new AccommodationBooking(); $accepted->setStatus(AccommodationBookingStatus::Discarded); $accepted->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Angebot', $unaccepted->recordLabel()); self::assertSame('Buchung', $accepted->recordLabel()); } public function testADraftIsTheOnlyStatusTheCustomerMustNotReach(): void { // Entwurf is the office's own workbench — nothing has been offered yet, so there is // nothing an access link could show. Whether a link that exists still opens anything // is a separate question the offer page answers (a discarded booking does not). foreach (AccommodationBookingStatus::cases() as $status) { $booking = new AccommodationBooking(); $booking->setStatus($status); self::assertSame( AccommodationBookingStatus::Draft !== $status, $booking->isCustomerAccessible(), $status->value, ); } } public function testAnImplausibleTotalIsRefusedRatherThanStored(): void { // The pax count and the nights are capped, so what gets here is a catalog price with a // digit too many. A stored total would end up on an offer; the INT column would answer // it with SQLSTATE 22003 and say nothing about the cause. $booking = new AccommodationBooking(); $booking->setDateFrom(new \DateTimeImmutable('2026-09-23')); $booking->setDateTo(new \DateTimeImmutable('2026-09-25')); $booking->setPaxCount(40); $this->expectException(ImplausibleBookingPriceException::class); $booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE + 1], AccommodationBooking::MAX_TOTAL_PRICE + 1, 'EUR', 1); } public function testATotalAtTheCeilingIsStillStored(): void { $booking = new AccommodationBooking(); $booking->setPriceSnapshot(['total' => AccommodationBooking::MAX_TOTAL_PRICE], AccommodationBooking::MAX_TOTAL_PRICE, 'EUR', 1); self::assertSame(AccommodationBooking::MAX_TOTAL_PRICE, $booking->getTotalPrice()); } public function testTheCeilingLeavesTheIntColumnRoomToSpare(): void { // The ceiling exists to keep implausible prices out, not to use up the column: a total // that needs more room than this is a mistake, never a wider column. self::assertLessThan(2147483647, AccommodationBooking::MAX_TOTAL_PRICE); } public function testTheLabelIsIndependentOfWhereTheBookingCameFrom(): void { // Origin answers "how did this come about" and is deliberately not the same // question — a confirmed booking that started as an offer is still a Buchung. foreach (AccommodationBookingOrigin::cases() as $origin) { $booking = new AccommodationBooking(); $booking->setOrigin($origin); $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setAcceptedAt(new \DateTimeImmutable()); self::assertSame('Buchung', $booking->recordLabel(), $origin->value); } } public function testACopyGetsItsOwnIdentityAndAccessLinkToken(): void { // Two records behind one uuid would mean two records behind one customer access link. $booking = $this->confirmedBooking(); $copy = clone $booking; self::assertNull($copy->getId()); self::assertNotSame($booking->getUuid(), $copy->getUuid()); } public function testACopyStartsAsAFreshOfficeDraftThatNobodyHasSeen(): void { $booking = $this->confirmedBooking(); $copy = clone $booking; self::assertSame(AccommodationBookingStatus::Draft, $copy->getStatus()); self::assertSame(AccommodationBookingOrigin::Offer, $copy->getOrigin()); self::assertNull($copy->getAccessLinkIssuedAt()); self::assertNull($copy->getAcceptedAt()); self::assertNull($copy->getConfirmedAt()); self::assertNull($copy->getUpdatedAt()); self::assertNull($copy->getCreatedBy()); self::assertNull($copy->getUpdatedBy()); self::assertSame('Angebot', $copy->recordLabel()); } public function testACopyCarriesThePricingAndTheDiscountsVerbatim(): void { // The copy is a variation of an offer that was already quoted, so it shows the same // numbers rather than a re-quote at today's catalog prices. $booking = $this->confirmedBooking(); $copy = clone $booking; self::assertSame($booking->getPriceBreakdown(), $copy->getPriceBreakdown()); self::assertSame($booking->getTotalPrice(), $copy->getTotalPrice()); self::assertSame($booking->getPricingCurrency(), $copy->getPricingCurrency()); self::assertSame($booking->getPricingVersion(), $copy->getPricingVersion()); self::assertSame(10, $copy->getAccommodationDiscount()); self::assertSame(5, $copy->getBoardServiceDiscount()); self::assertSame(20, $copy->getAdditionalServicesDiscount()); self::assertSame(2500, $copy->getTotalDiscountAmount()); self::assertSame('Treuerabatt', $copy->getTotalDiscountLabel()); } public function testACopyKeepsWhatTheOfficeWouldOtherwiseKeyInAgain(): void { $booking = $this->confirmedBooking(); $copy = clone $booking; self::assertSame($booking->getDateFrom(), $copy->getDateFrom()); self::assertSame($booking->getDateTo(), $copy->getDateTo()); self::assertSame(42, $copy->getPaxCount()); self::assertSame('Vollpension', $copy->getBoardServiceLabel()); self::assertSame(1900, $copy->getBoardServicePrice()); self::assertSame(7, $copy->getBoardServiceOriginalId()); self::assertSame($booking->getAdditionalServices(), $copy->getAdditionalServices()); self::assertSame('Skiclub Nord', $copy->getGroupName()); self::assertSame('erika@example.com', $copy->getEmail()); } public function testCloningLeavesTheOriginalAlone(): void { $booking = $this->confirmedBooking(); clone $booking; self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus()); self::assertNotNull($booking->getAcceptedAt()); self::assertNotNull($booking->getConfirmedAt()); self::assertSame('Skiclub Nord', $booking->getGroupName()); } public function testACopyCarriesItsOwnSplitOffPeriods(): void { // The periods are part of the quote being varied, but editing them on the copy must // not reach back into the original. $booking = $this->confirmedBooking(); $booking->addPeriod((new AccommodationBookingPeriod( AccommodationBookingPeriodPosition::Leading, new \DateTimeImmutable('2026-03-01'), new \DateTimeImmutable('2026-03-02'), ))->setLines([['label' => 'Vorausteam', 'amount' => 30000]])); $original = $booking->getPeriods()->first(); $copy = clone $booking; $copied = $copy->getPeriods()->first(); self::assertCount(1, $copy->getPeriods()); self::assertNotSame($original, $copied); self::assertSame($copy, $copied->getBooking()); self::assertNull($copied->getId()); self::assertSame($original->getLines(), $copied->getLines()); self::assertSame($booking, $original->getBooking()); } public function testWithoutPeriodsTheMainPeriodIsTheWholeStay(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); self::assertEquals(new \DateTimeImmutable('2026-03-01'), $booking->getMainDateFrom()); self::assertEquals(new \DateTimeImmutable('2026-03-08'), $booking->getMainDateTo()); self::assertSame(7, $booking->getMainNights()); self::assertSame(0, $booking->getPeriodsTotal()); } public function testTheMainPeriodIsWhatRemainsBetweenTheSplitOffPeriods(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 2000)); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 3000)); self::assertEquals(new \DateTimeImmutable('2026-03-03'), $booking->getMainDateFrom()); self::assertEquals(new \DateTimeImmutable('2026-03-07'), $booking->getMainDateTo()); self::assertSame(4, $booking->getMainNights()); self::assertSame(7, $booking->getNights(), 'the stay itself is unchanged'); self::assertSame(5000, $booking->getPeriodsTotal()); } public function testPeriodsThatTileTheEdgesOfTheStayAreValid(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0)); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0)); self::assertCount(0, $this->validate($booking)); } public function testMovingTheArrivalAwayFromALeadingPeriodIsReportedOnTheArrival(): void { $booking = $this->stay('2026-03-02', '2026-03-08'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-03', 0)); $violations = $this->validate($booking); self::assertCount(1, $violations); self::assertSame('dateFrom', $violations[0]->getPropertyPath()); } public function testASecondPeriodOnTheSameSideIsRefused(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0)); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0)); self::assertCount(1, $this->validate($booking)); } public function testMovingTheDepartureAwayFromATrailingPeriodIsReportedOnTheDeparture(): void { $booking = $this->stay('2026-03-01', '2026-03-09'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-07', '2026-03-08', 0)); $violations = $this->validate($booking); self::assertCount(1, $violations); self::assertSame('dateTo', $violations[0]->getPropertyPath()); } public function testTheGroupMustKeepAtLeastOneNight(): void { $booking = $this->stay('2026-03-01', '2026-03-03'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-02', 0)); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Trailing, '2026-03-02', '2026-03-03', 0)); $violations = $this->validate($booking); self::assertCount(1, $violations); self::assertSame('dateTo', $violations[0]->getPropertyPath()); } public function testAPeriodWithoutANightIsRefused(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $booking->addPeriod($this->period(AccommodationBookingPeriodPosition::Leading, '2026-03-01', '2026-03-01', 0)); self::assertCount(1, $this->validate($booking)); } public function testSplittingOffAttachesThePeriodToItsEdgeOfTheStay(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $first = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2); $last = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1); self::assertSame(['2026-03-01', '2026-03-03'], $this->range($first)); self::assertSame(['2026-03-07', '2026-03-08'], $this->range($last)); self::assertSame(4, $booking->getMainNights()); self::assertCount(0, $this->validate($booking)); } public function testEachSideCanOnlyBeSplitOnce(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1); self::assertFalse($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading)); self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Trailing)); $this->expectException(\LogicException::class); $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1); } public function testResizingKeepsAPeriodAtItsEdge(): void { $booking = $this->stay('2026-03-01', '2026-03-10'); $period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Trailing, 1); $booking->resizePeriod($period, 3); self::assertSame(['2026-03-07', '2026-03-10'], $this->range($period)); self::assertSame(6, $booking->getMainNights()); } public function testRemovingAPeriodGivesItsNightsBackAndFreesTheSide(): void { $booking = $this->stay('2026-03-01', '2026-03-08'); $period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 2); $booking->removePeriod($period); self::assertNull($period->getBooking()); self::assertSame(7, $booking->getMainNights()); self::assertTrue($booking->canSplitOffPeriod(AccommodationBookingPeriodPosition::Leading)); } public function testGrowingAPeriodIntoTheLastNightOfTheGroupIsRefused(): void { $booking = $this->stay('2026-03-01', '2026-03-04'); $period = $booking->splitOffPeriod(AccommodationBookingPeriodPosition::Leading, 1); $booking->resizePeriod($period, 3); self::assertCount(1, $this->validate($booking)); } /** @return array{string, string} */ private function range(AccommodationBookingPeriod $period): array { return [$period->getDateFrom()->format('Y-m-d'), $period->getDateTo()->format('Y-m-d')]; } private function stay(string $from, string $to): AccommodationBooking { return (new AccommodationBooking()) ->setDateFrom(new \DateTimeImmutable($from)) ->setDateTo(new \DateTimeImmutable($to)); } private function period(AccommodationBookingPeriodPosition $position, string $from, string $to, int $amount): AccommodationBookingPeriod { return (new AccommodationBookingPeriod($position, new \DateTimeImmutable($from), new \DateTimeImmutable($to))) ->setLines([['label' => 'Vorausteam', 'amount' => $amount]]); } private function validate(AccommodationBooking $booking): ConstraintViolationListInterface { return Validation::createValidatorBuilder() ->enableAttributeMapping() ->getValidator() ->validate($booking); } /** * A record that has been all the way through the lifecycle, so a copy of it has the most * to reset. */ private function confirmedBooking(): AccommodationBooking { $now = new \DateTimeImmutable('2026-01-15 10:00:00'); $booking = new AccommodationBooking(); $booking->setStatus(AccommodationBookingStatus::Confirmed); $booking->setOrigin(AccommodationBookingOrigin::Direct); $booking->setAccessLinkIssuedAt($now); $booking->setAcceptedAt($now); $booking->setConfirmedAt($now); $booking->setCreatedAt($now); $booking->setUpdatedAt($now); $booking->setCreatedBy(new User('office@example.com')); $booking->setUpdatedBy(new User('office@example.com')); $booking->setDateFrom(new \DateTimeImmutable('2026-03-01')); $booking->setDateTo(new \DateTimeImmutable('2026-03-08')); $booking->setPaxCount(42); $booking->setGroupName('Skiclub Nord'); $booking->setEmail('erika@example.com'); $booking->setBoardServiceLabel('Vollpension'); $booking->setBoardServicePrice(1900); $booking->setBoardServiceOriginalId(7); $booking->addAdditionalServiceSnapshot('Skipass', 12000, 'per_person', 3); $booking->setAccommodationDiscount(10); $booking->setBoardServiceDiscount(5); $booking->setAdditionalServicesDiscount(20); $booking->setTotalDiscountAmount(2500); $booking->setTotalDiscountLabel('Treuerabatt'); $booking->setPriceSnapshot(['total' => 500000], 480000, 'EUR', 1); return $booking; } }