diff --git a/assets/controllers/clipboard_controller.js b/assets/controllers/clipboard_controller.js new file mode 100644 index 00000000..231035da --- /dev/null +++ b/assets/controllers/clipboard_controller.js @@ -0,0 +1,37 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + + static values = { + content: String, + label: String, + } + + connect() { + this.originalLabel = this.element.innerText + } + + disconnect() { + clearTimeout(this.resetTimer) + } + + copy() { + navigator.clipboard.writeText(this.contentValue).then(() => { + this.dispatch('copied', { + detail: { + content: this.contentValue, + } + }) + }).catch((error) => { + console.error('Failed to copy to clipboard:', error) + }) + + if (this.hasLabelValue) { + this.element.innerText = this.labelValue + clearTimeout(this.resetTimer) + this.resetTimer = setTimeout(() => { + this.element.innerText = this.originalLabel + }, 3000) + } + } +} diff --git a/assets/controllers/gallery_controller.js b/assets/controllers/gallery_controller.js new file mode 100644 index 00000000..8525658e --- /dev/null +++ b/assets/controllers/gallery_controller.js @@ -0,0 +1,29 @@ +import { Controller } from '@hotwired/stimulus' +import GLightbox from 'glightbox' + +/* stimulusFetch: 'lazy' */ +export default class extends Controller { + + static targets = ['item'] + + connect() { + this.lightbox = GLightbox({ + elements: this.itemTargets.map((el) => ({ + href: el.dataset.galleryHref, + type: 'image', + alt: el.dataset.galleryAlt ?? '', + })), + }) + + this.itemTargets.forEach((el, index) => { + el.addEventListener('click', (e) => { + e.preventDefault() + this.lightbox.openAt(index) + }) + }) + } + + disconnect() { + this.lightbox.destroy() + } +} diff --git a/assets/styles/_components.css b/assets/styles/_components.css index 5f1f0273..4e0898f5 100644 --- a/assets/styles/_components.css +++ b/assets/styles/_components.css @@ -1,7 +1,9 @@ @import "components/button.css"; @import "components/forms.css"; +@import "components/gallery.css"; @import "components/pagination.css"; @import "components/price_calendar.css"; @import "components/toast.css"; @import "components/tooltip.css"; @import "components/typography.css"; +@import "components/rte-content.css"; diff --git a/assets/styles/components/gallery.css b/assets/styles/components/gallery.css new file mode 100644 index 00000000..bc0fd92c --- /dev/null +++ b/assets/styles/components/gallery.css @@ -0,0 +1 @@ +@import "glightbox/dist/css/glightbox.min.css"; diff --git a/assets/styles/components/rte-content.css b/assets/styles/components/rte-content.css new file mode 100644 index 00000000..9df8be97 --- /dev/null +++ b/assets/styles/components/rte-content.css @@ -0,0 +1,13 @@ +.rte-content { + p { + @apply pb-4; + } + + ul { + @apply list-disc p-4; + } + + ol { + @apply list-decimal p-4; + } +} diff --git a/composer.lock b/composer.lock index 3963adca..330ff1d4 100644 --- a/composer.lock +++ b/composer.lock @@ -11622,16 +11622,16 @@ }, { "name": "friendsofphp/php-cs-fixer", - "version": "v3.95.13", + "version": "v3.95.15", "source": { "type": "git", "url": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer.git", - "reference": "ea941114a002eb5e5876f190223deec1066c482c" + "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/ea941114a002eb5e5876f190223deec1066c482c", - "reference": "ea941114a002eb5e5876f190223deec1066c482c", + "url": "https://api.github.com/repos/PHP-CS-Fixer/PHP-CS-Fixer/zipball/3e47e5d50046f87e3244acde2fe655d1a3b72555", + "reference": "3e47e5d50046f87e3244acde2fe655d1a3b72555", "shasum": "" }, "require": { @@ -11671,7 +11671,7 @@ "php-coveralls/php-coveralls": "^2.9.1", "php-cs-fixer/phpunit-constraint-isidenticalstring": "^1.8", "php-cs-fixer/phpunit-constraint-xmlmatchesxsd": "^1.8", - "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56", + "phpunit/phpunit": "^9.6.35 || ^10.5.64 || ^11.5.56 || ^12.5.31", "symfony/polyfill-php85": "^1.38", "symfony/var-dumper": "^5.4.48 || ^6.4.36 || ^7.4.8 || ^8.1.0", "symfony/yaml": "^5.4.53 || ^6.4.41 || ^7.4.13 || ^8.1.0" @@ -11715,7 +11715,7 @@ ], "support": { "issues": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/issues", - "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.13" + "source": "https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/tree/v3.95.15" }, "funding": [ { @@ -11723,7 +11723,7 @@ "type": "github" } ], - "time": "2026-07-10T09:23:21+00:00" + "time": "2026-07-15T09:51:47+00:00" }, { "name": "myclabs/deep-copy", diff --git a/config/packages/security.yaml b/config/packages/security.yaml index a87c2783..199292ca 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -14,7 +14,11 @@ security: users: mailjet: password: '%env(MAILJET_WEBHOOK_BASIC_PASSWORD)%' - roles: ['ROLE_MAILJET_WEBHOOK'] + roles: [ ROLE_MAILJET_WEBHOOK ] + role_hierarchy: + ROLE_ADMIN: [ ROLE_GROUPS_ADMIN ] + ROLE_GROUPS_ADMIN: [ ROLE_GROUPS_MANAGER ] + firewalls: dev: pattern: ^/(_(profiler|wdt)|css|images|js)/ diff --git a/migrations/Version20260710130000.php b/migrations/Version20260710130000.php new file mode 100644 index 00000000..1e2dae38 --- /dev/null +++ b/migrations/Version20260710130000.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking ADD price_breakdown JSON DEFAULT NULL, ADD total_price INT DEFAULT NULL, ADD pricing_currency VARCHAR(3) DEFAULT NULL, ADD pricing_version SMALLINT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking DROP price_breakdown, DROP total_price, DROP pricing_currency, DROP pricing_version'); + } +} diff --git a/migrations/Version20260716101949.php b/migrations/Version20260716101949.php new file mode 100644 index 00000000..4e729a5a --- /dev/null +++ b/migrations/Version20260716101949.php @@ -0,0 +1,26 @@ +addSql('ALTER TABLE accommodation_booking CHANGE discount accommodation_discount INT DEFAULT NULL, ADD board_service_discount INT DEFAULT NULL, ADD additional_services_discount INT DEFAULT NULL'); + } + + public function down(Schema $schema): void + { + $this->addSql('ALTER TABLE accommodation_booking CHANGE accommodation_discount discount INT DEFAULT NULL, DROP board_service_discount, DROP additional_services_discount'); + } +} diff --git a/package-lock.json b/package-lock.json index acc03e33..602aa9cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "license": "WTFPL", "dependencies": { "@trevoreyre/autocomplete-js": "^3.0.3", + "glightbox": "^3.3.1", "sortablejs": "^1.15.7", "stimulus-use": "^0.52.3", "vanilla-calendar-pro": "^3.1.0" @@ -4563,6 +4564,12 @@ "assert-plus": "^1.0.0" } }, + "node_modules/glightbox": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/glightbox/-/glightbox-3.3.1.tgz", + "integrity": "sha512-nXoKfJRnQTDaAFAw5799hjpfAAHx5aLvOLG0SIGudeMCwtHgO3P2/avNYapJ+SL4UEZxO2YNBPtq6pzMhSx42g==", + "license": "MIT" + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", diff --git a/package.json b/package.json index 9fa01098..4f5d4246 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,9 @@ }, "dependencies": { "@trevoreyre/autocomplete-js": "^3.0.3", - "vanilla-calendar-pro": "^3.1.0", + "glightbox": "^3.3.1", "sortablejs": "^1.15.7", - "stimulus-use": "^0.52.3" + "stimulus-use": "^0.52.3", + "vanilla-calendar-pro": "^3.1.0" } } diff --git a/src/Controller/Admin/Accommodation/DeleteController.php b/src/Controller/Admin/Accommodation/DeleteController.php index c52c5719..5c7c34fc 100644 --- a/src/Controller/Admin/Accommodation/DeleteController.php +++ b/src/Controller/Admin/Accommodation/DeleteController.php @@ -25,6 +25,10 @@ class DeleteController extends AbstractController public function index(Accommodation $accommodation, Request $request): Response { if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_'.$accommodation->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($accommodation); $this->entityManager->flush(); @@ -40,6 +44,7 @@ class DeleteController extends AbstractController return $this->render('admin/accommodation/modal_delete.html.twig', [ 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(), ]); } } diff --git a/src/Controller/Admin/AccommodationBooking/CreateController.php b/src/Controller/Admin/AccommodationBooking/CreateController.php index 324ca647..1dfbe38e 100644 --- a/src/Controller/Admin/AccommodationBooking/CreateController.php +++ b/src/Controller/Admin/AccommodationBooking/CreateController.php @@ -34,6 +34,7 @@ class CreateController extends AbstractController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { + $this->bookingService->refreshPriceSnapshot($booking); $this->entityManager->persist($booking); $this->entityManager->flush(); diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php index 5c2e53ac..37c51363 100644 --- a/src/Controller/Admin/AccommodationBooking/EditController.php +++ b/src/Controller/Admin/AccommodationBooking/EditController.php @@ -62,7 +62,7 @@ class EditController extends AbstractController $currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId'); $currentAdditionalServices = array_values(array_filter( $additionalServices, - fn(AdditionalService $s) => in_array($s->getId(), $currentIds, true), + fn (AdditionalService $s) => in_array($s->getId(), $currentIds, true), )); } @@ -103,6 +103,8 @@ class EditController extends AbstractController } } + $this->bookingService->refreshPriceSnapshot($booking); + $this->entityManager->persist($booking); $this->entityManager->flush(); diff --git a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php index 82004b81..d814aecb 100644 --- a/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php +++ b/src/Controller/Admin/AccommodationBooking/GenerateAccessLinkController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Controller\Admin\AccommodationBooking; use App\Entity\Groups\AccommodationBooking; +use App\Htmx\HxRedirectResponse; use App\Service\AccommodationBookingService; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -24,17 +25,24 @@ class GenerateAccessLinkController extends AbstractController public function index(AccommodationBooking $booking, Request $request): Response { if ($request->isMethod(Request::METHOD_POST)) { - $this->bookingService->regenerateAccessLinkAndNotifyCustomer($booking); + if (!$this->isCsrfTokenValid('generate_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } - $this->addFlash('success', 'Der Zugangslink wurde generiert und dem Kunden per E-Mail zugestellt.'); + $this->bookingService->regenerateAccessLink($booking); + + $this->addFlash('success', 'Der Zugangslink wurde neu generiert.'); $this->logger->info('Generated accommodation booking access link', [ 'id' => $booking->getId(), ]); - return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); } - return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + return $this->render('admin/accommodation_booking/modal_generate_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'generate_accommodation_booking_access_link_'.$booking->getId(), + ]); } } diff --git a/src/Controller/Admin/AccommodationBooking/IndexController.php b/src/Controller/Admin/AccommodationBooking/IndexController.php index bc7048df..a6abfc3a 100644 --- a/src/Controller/Admin/AccommodationBooking/IndexController.php +++ b/src/Controller/Admin/AccommodationBooking/IndexController.php @@ -38,8 +38,8 @@ class IndexController extends AbstractController $request->query->getInt('page', 1), $request->query->getInt('limit', 20), [ - 'defaultSortFieldName' => 'booking.dateFrom', - 'defaultSortDirection' => 'ASC', + 'defaultSortFieldName' => 'booking.createdAt', + 'defaultSortDirection' => 'DESC', ] ); diff --git a/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php new file mode 100644 index 00000000..aeb8da53 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/SendAccessLinkController.php @@ -0,0 +1,52 @@ +getAccessLinkIssuedAt()) { + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + if ($request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('send_accommodation_booking_access_link_'.$booking->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $this->bookingService->sendCustomerConfirmationEmail($booking); + + $this->addFlash('success', 'Der Zugangslink wurde dem Kunden per E-Mail zugestellt.'); + + $this->logger->info('Sent accommodation booking access link', [ + 'id' => $booking->getId(), + ]); + + return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()])); + } + + return $this->render('admin/accommodation_booking/modal_send_access_link.html.twig', [ + 'booking' => $booking, + 'csrf_token_id' => 'send_accommodation_booking_access_link_'.$booking->getId(), + ]); + } +} diff --git a/src/Controller/Admin/AccommodationPrice/DeleteController.php b/src/Controller/Admin/AccommodationPrice/DeleteController.php index 9bec201f..f925cdc3 100644 --- a/src/Controller/Admin/AccommodationPrice/DeleteController.php +++ b/src/Controller/Admin/AccommodationPrice/DeleteController.php @@ -20,12 +20,17 @@ class DeleteController extends AbstractController private readonly LoggerInterface $logger, ) { } + #[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')] public function index(AccommodationPrice $accommodationPrice, Request $request): Response { $accommodation = $accommodationPrice->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_accommodation_price_'.$accommodationPrice->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($accommodationPrice); $this->entityManager->flush(); @@ -37,7 +42,7 @@ class DeleteController extends AbstractController 'date_range' => [ 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), - ] + ], ]); $returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ @@ -50,6 +55,7 @@ class DeleteController extends AbstractController return $this->render('admin/accommodation_price/modal_delete.html.twig', [ 'accommodationPrice' => $accommodationPrice, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(), ]); } } diff --git a/src/Controller/Admin/AdditionalService/DeleteController.php b/src/Controller/Admin/AdditionalService/DeleteController.php index 9b595291..dabd1d1d 100644 --- a/src/Controller/Admin/AdditionalService/DeleteController.php +++ b/src/Controller/Admin/AdditionalService/DeleteController.php @@ -27,6 +27,10 @@ class DeleteController extends AbstractController $accommodation = $additionalService->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_additional_service_'.$additionalService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($additionalService); $this->entityManager->flush(); @@ -48,6 +52,7 @@ class DeleteController extends AbstractController return $this->render('admin/additional_service/modal_delete.html.twig', [ 'additionalService' => $additionalService, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(), ]); } } diff --git a/src/Controller/Admin/BoardService/DeleteController.php b/src/Controller/Admin/BoardService/DeleteController.php index 73bd4896..c61d3aad 100644 --- a/src/Controller/Admin/BoardService/DeleteController.php +++ b/src/Controller/Admin/BoardService/DeleteController.php @@ -27,6 +27,10 @@ class DeleteController extends AbstractController $accommodation = $boardService->getAccommodation(); if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_board_service_'.$boardService->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($boardService); $this->entityManager->flush(); @@ -48,6 +52,7 @@ class DeleteController extends AbstractController return $this->render('admin/board_service/modal_delete.html.twig', [ 'boardService' => $boardService, 'accommodation' => $accommodation, + 'csrf_token_id' => 'delete_board_service_'.$boardService->getId(), ]); } } diff --git a/src/Controller/Admin/BookingEditDraft/DeleteController.php b/src/Controller/Admin/BookingEditDraft/DeleteController.php index 84713618..c4a3a3c1 100644 --- a/src/Controller/Admin/BookingEditDraft/DeleteController.php +++ b/src/Controller/Admin/BookingEditDraft/DeleteController.php @@ -28,6 +28,10 @@ class DeleteController extends AbstractController public function index(BookingEditDraft $draft, Request $request): Response { if (true === $request->isMethod(Request::METHOD_POST)) { + if (!$this->isCsrfTokenValid('delete_booking_edit_draft_'.$draft->getId(), $request->request->getString('_token'))) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + $this->entityManager->remove($draft); $this->entityManager->flush(); @@ -43,6 +47,7 @@ class DeleteController extends AbstractController return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [ 'draft' => $draft, + 'csrf_token_id' => 'delete_booking_edit_draft_'.$draft->getId(), ]); } } diff --git a/src/Controller/Api/ContingentController.php b/src/Controller/Api/ContingentController.php index 70d1e26a..4bc8bb8d 100644 --- a/src/Controller/Api/ContingentController.php +++ b/src/Controller/Api/ContingentController.php @@ -8,14 +8,16 @@ use App\BpnConnect\ContingentsClient; use App\BpnConnect\Exception\BpnConnectException; use App\Entity\Groups\AccommodationPrice; use App\Enum\Groups\PriceType; +use App\Model\ContingentCalendarQuery; +use App\Model\ContingentPricesQuery; use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationRepository; use App\Service\PriceTimelineBuilder; use Psr\Cache\InvalidArgumentException; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\JsonResponse; -use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Attribute\MapQueryString; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Cache\CacheInterface; @@ -35,30 +37,20 @@ class ContingentController extends AbstractController } #[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])] - public function prices(Request $request): JsonResponse - { - $hotelCode = $request->query->get('hotelCode'); - $yearRaw = $request->query->get('year'); - - if (!$hotelCode || !$yearRaw) { - return $this->json(['error' => 'hotelCode and year are required.'], Response::HTTP_BAD_REQUEST); - } - - if (!preg_match('/^\d{4}$/', $yearRaw)) { - return $this->json(['error' => 'year must be a 4-digit integer.'], Response::HTTP_BAD_REQUEST); - } - - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + public function prices( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentPricesQuery $query, + ): JsonResponse { + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } - $year = (int) $yearRaw; - $yearStart = new \DateTimeImmutable("{$year}-01-01"); - $yearEnd = new \DateTimeImmutable("{$year}-12-31"); + $yearStart = new \DateTimeImmutable("{$query->year}-01-01"); + $yearEnd = new \DateTimeImmutable("{$query->year}-12-31"); - $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $yearStart, $yearEnd); + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd); $currency = $accommodation->getCurrency(); @@ -66,41 +58,31 @@ class ContingentController extends AbstractController } #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] - public function calendar(Request $request): JsonResponse - { - $hotelCode = $request->query->get('hotelCode'); - $dateFromRaw = $request->query->get('dateFrom'); - $dateToRaw = $request->query->get('dateTo'); + public function calendar( + #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)] + ContingentCalendarQuery $query, + ): JsonResponse { + $dateFrom = $query->dateFromDate(); + $dateTo = $query->dateToDate(); - if (!$hotelCode || !$dateFromRaw || !$dateToRaw) { - return $this->json(['error' => 'hotelCode, dateFrom and dateTo are required.'], Response::HTTP_BAD_REQUEST); - } - - $dateFrom = \DateTimeImmutable::createFromFormat('Y-m-d', $dateFromRaw)?->setTime(0, 0); - $dateTo = \DateTimeImmutable::createFromFormat('Y-m-d', $dateToRaw)?->setTime(0, 0); - - if (!$dateFrom || !$dateTo) { - return $this->json(['error' => 'dateFrom and dateTo must be in Y-m-d format.'], Response::HTTP_BAD_REQUEST); - } - - $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $hotelCode]); + $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]); if (null === $accommodation) { return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); } try { - $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $hotelCode, $dateFromRaw, $dateToRaw); - $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($hotelCode, $dateFromRaw, $dateToRaw) { + $cacheKey = sprintf('contingents_calendar_%s_%s_%s', $query->hotelCode, $query->dateFrom, $query->dateTo); + $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) { $item->expiresAfter(3600); - return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromRaw, $dateToRaw); + return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo); }); } catch (BpnConnectException|InvalidArgumentException $e) { return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); } - $prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); + $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $dateFrom, $dateTo); $currency = $accommodation->getCurrency(); @@ -114,6 +96,7 @@ class ContingentController extends AbstractController /** * @param AccommodationPrice[] $prices + * * @return array{date: string, status: string, type: string|null, pricePerNight: float|null, defaultPricePerNight: float|null, priceAdditionalPerson: float|null, defaultPriceAdditionalPerson: float|null, includedPax: int|null, minNights: int|null} */ private function enrichEntry(string $date, string $status, array $prices, string $currency): array @@ -123,14 +106,14 @@ class ContingentController extends AbstractController // dateFrom and dateTo are both inclusive (last night, not checkout day) $candidates = array_filter( $prices, - fn($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, + fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, ); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); $defaultPrice = null; - if ($winner?->getType() === PriceType::DISCOUNT) { - $defaults = array_filter($candidates, fn($p) => $p->getType() === null); + if (PriceType::DISCOUNT === $winner?->getType()) { + $defaults = array_filter($candidates, fn ($p) => null === $p->getType()); $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); } @@ -138,14 +121,13 @@ class ContingentController extends AbstractController 'date' => $date, 'status' => $status, 'type' => $winner?->getType()?->value, - 'pricePerNight' => $winner !== null ? round($winner->getPricePerNight() / 100, 2) : null, - 'defaultPricePerNight' => $defaultPrice !== null ? round($defaultPrice->getPricePerNight() / 100, 2) : null, - 'priceAdditionalPerson' => $winner !== null ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, - 'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, + 'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null, + 'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null, + 'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, + 'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, 'currency' => $currency, 'includedPax' => $winner?->getIncludedPax(), 'minNights' => $winner?->getMinNights(), ]; } - } diff --git a/src/Controller/Groups/AbstractAccommodationController.php b/src/Controller/Groups/AbstractAccommodationController.php index bfdc5472..f8f01681 100644 --- a/src/Controller/Groups/AbstractAccommodationController.php +++ b/src/Controller/Groups/AbstractAccommodationController.php @@ -43,6 +43,7 @@ abstract class AbstractAccommodationController extends AbstractController $route = match ($dto->currentStep) { 2 => 'app_groups_booking_step_2', 3 => 'app_groups_booking_step_3', + 4 => 'app_groups_booking_step_4', default => 'app_groups_booking_step_1', }; diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php index 13be392a..7cbb638e 100644 --- a/src/Controller/Groups/OfferController.php +++ b/src/Controller/Groups/OfferController.php @@ -4,6 +4,10 @@ declare(strict_types=1); namespace App\Controller\Groups; +use App\Entity\Groups\AccommodationBooking; +use App\Form\OfferAcceptConfirmationType; +use App\Htmx\HxTrait; +use App\Model\AccommodationBookingContext; use App\Repository\Groups\AccommodationBookingRepository; use App\Service\AccommodationBookingBreakdownCalculator; use App\Service\AccommodationBookingLinkSigner; @@ -12,10 +16,13 @@ use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface; use Symfony\Component\Routing\Attribute\Route; class OfferController extends AbstractController { + use HxTrait; + public function __construct( private readonly AccommodationBookingRepository $bookingRepository, private readonly AccommodationBookingLinkSigner $linkSigner, @@ -24,8 +31,13 @@ class OfferController extends AbstractController ) { } - #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET', 'POST'])] - public function index(string $uuid, Request $request): Response + /** + * Signed-link entry point: validates the `t`/`_hash` query params once, + * authorizes the session for this booking, and redirects to the plain + * (session-gated) offer page — nothing downstream needs the signature again. + */ + #[Route(path: '/groups/booking/offer/{uuid}', name: 'app_groups_booking_offer', methods: ['GET'])] + public function access(string $uuid, Request $request): Response { $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); @@ -33,15 +45,81 @@ class OfferController extends AbstractController return $this->render('groups/booking/offer_unavailable.html.twig'); } - if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) { - $this->bookingService->acceptBooking($booking); + $this->linkSigner->authorizeSession($request, $booking); - return new RedirectResponse($request->getUri()); + return new RedirectResponse($this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid])); + } + + #[Route(path: '/groups/booking/offer/{uuid}/view', name: 'app_groups_booking_offer_view', methods: ['GET'])] + public function view(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->render('groups/booking/offer_unavailable.html.twig'); } + $accommodation = $booking->getAccommodation() ?? throw $this->createNotFoundException('Booking has no accommodation.'); + $priceBreakdown = $this->breakdownCalculator->compute($booking); + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + priceBreakdown: $priceBreakdown, + ); + + $session = $request->getSession(); + $justAccepted = $session instanceof FlashBagAwareSessionInterface + && [] !== $session->getFlashBag()->get('accommodation_offer_accepted'); + return $this->render('groups/booking/offer.html.twig', [ 'booking' => $booking, - 'priceBreakdown' => $this->breakdownCalculator->compute($booking), + 'priceBreakdown' => $priceBreakdown, + 'ctx' => $ctx, + 'justAccepted' => $justAccepted, ]); } + + #[Route(path: '/groups/booking/offer/{uuid}/confirm', name: 'app_groups_booking_offer_confirm', methods: ['GET', 'POST'])] + public function confirm(string $uuid, Request $request): Response + { + $booking = $this->bookingRepository->findOneBy(['uuid' => $uuid]); + + if (null === $booking || false === $this->linkSigner->isSessionAuthorized($request, $booking)) { + return $this->redirectToOfferPage($request, $uuid); + } + + if (!$booking->isInquiry()) { + return $this->redirectToOfferPage($request, $uuid); + } + + $confirmationForm = $this->createForm(OfferAcceptConfirmationType::class, null, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $this->bookingService->acceptBooking($booking); + $this->addFlash('accommodation_offer_accepted', true); + + return $this->redirectToOfferPage($request, $uuid); + } + + return $this->render('groups/booking/_offer_accept_confirmation_modal.html.twig', [ + 'booking' => $booking, + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Sends the browser to a full reload of the (non-modal) offer page — this is + * triggered from an htmx-loaded modal, so a plain render/redirect here would + * get appended as an inert HTML fragment instead of actually navigating. + */ + private function redirectToOfferPage(Request $request, string $uuid): Response + { + $url = $this->generateUrl('app_groups_booking_offer_view', ['uuid' => $uuid]); + + return $this->htmxRedirect($request, $url); + } } diff --git a/src/Controller/Groups/Step3Controller.php b/src/Controller/Groups/Step3Controller.php index fa442136..0a5cee23 100644 --- a/src/Controller/Groups/Step3Controller.php +++ b/src/Controller/Groups/Step3Controller.php @@ -6,22 +6,17 @@ namespace App\Controller\Groups; use App\Entity\Groups\Accommodation; use App\Exception\AccommodationSessionNotFoundException; -use App\Form\AccommodationStep3ConfirmationType; use App\Form\AccommodationStep3Type; use App\Form\Model\AccommodationBookingDto; -use App\Htmx\HxTrait; use App\Model\AccommodationBookingContext; use App\Service\AccommodationBookingService; use App\Service\AccommodationSessionManager; -use Symfony\Component\Form\ClickableInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\Routing\Attribute\Route; class Step3Controller extends AbstractAccommodationController { - use HxTrait; - public function __construct( private readonly AccommodationBookingService $bookingService, private readonly AccommodationSessionManager $sessionManager, @@ -31,7 +26,7 @@ class Step3Controller extends AbstractAccommodationController #[Route('/groups/booking/step-3', name: 'app_groups_booking_step_3')] public function index(Request $request): Response { - $dto = $this->resolveDto($request, false); + $dto = $this->resolveDto($request); if ($dto instanceof Response) { return $dto; } @@ -51,33 +46,10 @@ class Step3Controller extends AbstractAccommodationController $form->handleRequest($request); if ($form->isSubmitted() && $form->isValid()) { - $submitBookingClicked = false; - // booking or inquiry status depends on clicked button - if (true === $form->has('submitBooking')) { - $submitBookingButton = $form->get('submitBooking'); - $submitBookingClicked = $submitBookingButton instanceof ClickableInterface && true === $submitBookingButton->isClicked(); - } + $dto->currentStep = 4; + $this->sessionManager->save($request, $dto); - // Booking is only offered via the confirmation modal; a forced-inquiry DTO - // can never actually reach this branch since submitBooking is disabled for it. - if (true === $submitBookingClicked && false === $dto->isInquiry) { - $dto->forceInquiry = false; - $this->sessionManager->save($request, $dto); - - $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); - - return $this->render('groups/booking/step_3.html.twig', [ - 'dto' => $dto, - 'ctx' => $ctx, - 'form' => $form, - 'confirmationForm' => $confirmationForm, - ]); - } - - $dto->forceInquiry = true; - $this->persistBooking($request, $dto, $accommodation, $services); - - return $this->redirectToRoute('app_groups_booking_success'); + return $this->redirectToRoute('app_groups_booking_step_4'); } return $this->render('groups/booking/step_3.html.twig', [ @@ -87,41 +59,16 @@ class Step3Controller extends AbstractAccommodationController ]); } - #[Route('/groups/booking/step-3/confirm', name: 'app_groups_booking_step_3_confirm', methods: ['POST'])] - public function confirm(Request $request): Response - { - $dto = $this->resolveDto($request, true); - if ($dto instanceof Response) { - return $dto; - } - - $accommodation = $this->loadAccommodationOrFail($dto); - - $confirmationForm = $this->createForm(AccommodationStep3ConfirmationType::class, $dto); - $confirmationForm->handleRequest($request); - - if ($confirmationForm->isSubmitted() && $confirmationForm->isValid() && false === $dto->isInquiry) { - $services = $this->bookingService->loadAvailableServices($dto, $accommodation); - $this->persistBooking($request, $dto, $accommodation, $services); - - return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); - } - - return $this->render('groups/booking/_step3_confirmation_modal.html.twig', [ - 'confirmationForm' => $confirmationForm, - ]); - } - /** * Loads the session DTO and checks step access, returning an early * response (session failure or step-access redirect) if either fails. */ - private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response + private function resolveDto(Request $request): AccommodationBookingDto|Response { try { $dto = $this->sessionManager->getOrFail($request); } catch (AccommodationSessionNotFoundException $e) { - return $this->createFailureResponse($e, $htmx); + return $this->createFailureResponse($e, false); } if ($redirect = $this->validateStepAccess($dto, 3)) { @@ -140,19 +87,4 @@ class Step3Controller extends AbstractAccommodationController return $accommodation; } - - /** - * @param array{boardServices: array, additionalServices: array} $services - */ - private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void - { - $prices = $this->bookingService->loadPrices($dto, $accommodation); - - $booking = $this->bookingService->persist($dto, $accommodation, $prices, $services); - $this->bookingService->issueAccessLinkForDirectBooking($booking); - $this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown); - $this->bookingService->sendCustomerConfirmationEmail($booking); - $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); - $this->sessionManager->clear($request); - } } diff --git a/src/Controller/Groups/Step4Controller.php b/src/Controller/Groups/Step4Controller.php new file mode 100644 index 00000000..111bd29e --- /dev/null +++ b/src/Controller/Groups/Step4Controller.php @@ -0,0 +1,150 @@ +resolveDto($request, false); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + + if ($request->isMethod(Request::METHOD_POST)) { + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + $inquiryForm->handleRequest($request); + + if (!$inquiryForm->isSubmitted() || !$inquiryForm->isValid()) { + throw $this->createAccessDeniedException('Invalid CSRF token.'); + } + + $dto->forceInquiry = true; + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->redirectToRoute('app_groups_booking_success'); + } + + $ctx = new AccommodationBookingContext( + accommodation: $accommodation, + hotelCmsData: $this->bookingService->loadHotelCmsData($accommodation), + boardServices: $services['boardServices'], + additionalServices: $services['additionalServices'], + priceBreakdown: $dto->priceBreakdown ?: null, + ); + + return $this->render('groups/booking/step_4.html.twig', [ + 'dto' => $dto, + 'ctx' => $ctx, + ]); + } + + #[Route('/groups/booking/step-4/confirm-inquiry', name: 'app_groups_booking_step_4_confirm_inquiry', methods: ['GET'])] + public function confirmInquiry(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $inquiryForm = $this->createForm(AccommodationInquiryConfirmationType::class); + + return $this->render('groups/booking/_step4_inquiry_confirmation_modal.html.twig', [ + 'inquiryForm' => $inquiryForm, + ]); + } + + #[Route('/groups/booking/step-4/confirm', name: 'app_groups_booking_step_4_confirm', methods: ['GET', 'POST'])] + public function confirm(Request $request): Response + { + $dto = $this->resolveDto($request, true); + if ($dto instanceof Response) { + return $dto; + } + + $accommodation = $this->loadAccommodationOrFail($dto); + + $confirmationForm = $this->createForm(AccommodationBookingConfirmationType::class, $dto, [ + 'terms_url' => $this->getParameter('terms_and_conditions_url'), + ]); + $confirmationForm->handleRequest($request); + + if ($confirmationForm->isSubmitted() && $confirmationForm->isValid()) { + $services = $this->bookingService->loadAvailableServices($dto, $accommodation); + $this->persistBooking($request, $dto, $accommodation, $services); + + return $this->htmxRedirect($request, $this->generateUrl('app_groups_booking_success')); + } + + return $this->render('groups/booking/_step4_booking_confirmation_modal.html.twig', [ + 'confirmationForm' => $confirmationForm, + ]); + } + + /** + * Loads the session DTO and checks step access, returning an early + * response (session failure or step-access redirect) if either fails. + */ + private function resolveDto(Request $request, bool $htmx): AccommodationBookingDto|Response + { + try { + $dto = $this->sessionManager->getOrFail($request); + } catch (AccommodationSessionNotFoundException $e) { + return $this->createFailureResponse($e, $htmx); + } + + if ($redirect = $this->validateStepAccess($dto, 4)) { + return $redirect; + } + + return $dto; + } + + private function loadAccommodationOrFail(AccommodationBookingDto $dto): Accommodation + { + $accommodation = $this->bookingService->loadAccommodation($dto->accommodationId); + if (null === $accommodation) { + throw $this->createNotFoundException(); + } + + return $accommodation; + } + + /** + * @param array{boardServices: array, additionalServices: array} $services + */ + private function persistBooking(Request $request, AccommodationBookingDto $dto, Accommodation $accommodation, array $services): void + { + $prices = $this->bookingService->loadPrices($dto, $accommodation); + $booking = $this->bookingService->finalizeBooking($dto, $accommodation, $prices, $services); + $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); + $this->sessionManager->clear($request); + } +} diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php index 449a1ec2..31028a48 100644 --- a/src/Entity/Groups/AccommodationBooking.php +++ b/src/Entity/Groups/AccommodationBooking.php @@ -10,9 +10,9 @@ use App\Entity\TimestampableEntity; use App\Entity\TimestampableEntityInterface; use App\Enum\Groups\AdditionalServiceType; use App\Repository\Groups\AccommodationBookingRepository; -use Symfony\Component\Uid\Uuid; use Doctrine\DBAL\Types\Types; use Doctrine\ORM\Mapping as ORM; +use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; #[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)] @@ -57,9 +57,23 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt #[ORM\Column(nullable: true)] private ?int $boardServiceOriginalId = null; + /** @var list */ #[ORM\Column(type: Types::JSON)] private array $additionalServices = []; + /** @var array|null */ + #[ORM\Column(type: Types::JSON, nullable: true)] + private ?array $priceBreakdown = null; + + #[ORM\Column(nullable: true)] + private ?int $totalPrice = null; + + #[ORM\Column(length: 3, nullable: true)] + private ?string $pricingCurrency = null; + + #[ORM\Column(type: Types::SMALLINT, nullable: true)] + private ?int $pricingVersion = null; + #[ORM\Column] private bool $isInquiry = false; @@ -100,8 +114,16 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt private ?string $remarks = null; #[ORM\Column(nullable: true)] - #[Assert\Positive(message: 'invalid')] - private ?int $discount = null; + #[Assert\Range(min: 1, max: 100)] + private ?int $accommodationDiscount = null; + + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $boardServiceDiscount = null; + + #[ORM\Column(nullable: true)] + #[Assert\Range(min: 1, max: 100)] + private ?int $additionalServicesDiscount = null; public function __construct() { @@ -235,11 +257,13 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } + /** @return list */ public function getAdditionalServices(): array { return $this->additionalServices; } + /** @param list $additionalServices */ public function setAdditionalServices(array $additionalServices): self { $this->additionalServices = $additionalServices; @@ -259,6 +283,48 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } + /** @return array|null */ + public function getPriceBreakdown(): ?array + { + return $this->priceBreakdown; + } + + /** @param array $priceBreakdown */ + public function setPriceSnapshot(array $priceBreakdown, int $totalPrice, string $currency, int $version): self + { + $this->priceBreakdown = $priceBreakdown; + $this->totalPrice = $totalPrice; + $this->pricingCurrency = $currency; + $this->pricingVersion = $version; + + return $this; + } + + public function clearPriceSnapshot(): self + { + $this->priceBreakdown = null; + $this->totalPrice = null; + $this->pricingCurrency = null; + $this->pricingVersion = null; + + return $this; + } + + public function getTotalPrice(): ?int + { + return $this->totalPrice; + } + + public function getPricingCurrency(): ?string + { + return $this->pricingCurrency; + } + + public function getPricingVersion(): ?int + { + return $this->pricingVersion; + } + public function isInquiry(): bool { return $this->isInquiry; @@ -415,14 +481,38 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt return $this; } - public function getDiscount(): ?int + public function getAccommodationDiscount(): ?int { - return $this->discount; + return $this->accommodationDiscount; } - public function setDiscount(?int $discount): self + public function setAccommodationDiscount(?int $accommodationDiscount): self { - $this->discount = $discount; + $this->accommodationDiscount = $accommodationDiscount; + + return $this; + } + + public function getBoardServiceDiscount(): ?int + { + return $this->boardServiceDiscount; + } + + public function setBoardServiceDiscount(?int $boardServiceDiscount): self + { + $this->boardServiceDiscount = $boardServiceDiscount; + + return $this; + } + + public function getAdditionalServicesDiscount(): ?int + { + return $this->additionalServicesDiscount; + } + + public function setAdditionalServicesDiscount(?int $additionalServicesDiscount): self + { + $this->additionalServicesDiscount = $additionalServicesDiscount; return $this; } diff --git a/src/Form/AccommodationStep3ConfirmationType.php b/src/Form/AccommodationBookingConfirmationType.php similarity index 76% rename from src/Form/AccommodationStep3ConfirmationType.php rename to src/Form/AccommodationBookingConfirmationType.php index b22035cf..3a1fab08 100644 --- a/src/Form/AccommodationStep3ConfirmationType.php +++ b/src/Form/AccommodationBookingConfirmationType.php @@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints\IsTrue; /** * @extends AbstractType */ -class AccommodationStep3ConfirmationType extends AbstractType +class AccommodationBookingConfirmationType extends AbstractType { public function buildForm(FormBuilderInterface $builder, array $options): void { @@ -24,12 +24,12 @@ class AccommodationStep3ConfirmationType extends AbstractType ); $builder->add('termsAccepted', CheckboxType::class, [ - 'label' => sprintf('Durch Anklicken des Absenden-Buttons bestätige ich, dass ich die %s gelesen habe und damit einverstanden bin, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), 'label_html' => true, 'mapped' => false, 'required' => true, 'constraints' => [ - new IsTrue(message: 'Bitte akzeptiere die AGB, um fortzufahren.'), + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), ], ]); } @@ -38,7 +38,8 @@ class AccommodationStep3ConfirmationType extends AbstractType { $resolver->setDefaults([ 'data_class' => AccommodationBookingDto::class, - 'terms_url' => 'https://www.ep-reisen.de/skireisen/infos/allgemeine-infos/agb/', ]); + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); } } diff --git a/src/Form/AccommodationInquiryConfirmationType.php b/src/Form/AccommodationInquiryConfirmationType.php new file mode 100644 index 00000000..9a4a3f8c --- /dev/null +++ b/src/Form/AccommodationInquiryConfirmationType.php @@ -0,0 +1,21 @@ + + */ +class AccommodationInquiryConfirmationType extends AbstractType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'csrf_token_id' => 'groups_booking_step4_inquiry', + ]); + } +} diff --git a/src/Form/Admin/Groups/AccommodationBookingType.php b/src/Form/Admin/Groups/AccommodationBookingType.php index 244fb897..f9abc2df 100644 --- a/src/Form/Admin/Groups/AccommodationBookingType.php +++ b/src/Form/Admin/Groups/AccommodationBookingType.php @@ -125,8 +125,22 @@ class AccommodationBookingType extends AbstractType 'label' => 'Anfrage (nicht bindend)', 'required' => false, ]) - ->add('discount', IntegerType::class, [ - 'label' => 'Rabatt (%)', + ->add('accommodationDiscount', IntegerType::class, [ + 'label' => 'Rabatt Unterkunft (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('boardServiceDiscount', IntegerType::class, [ + 'label' => 'Rabatt Verpflegung (%)', + 'required' => false, + 'constraints' => [ + new Range(min: 1, max: 100), + ], + ]) + ->add('additionalServicesDiscount', IntegerType::class, [ + 'label' => 'Rabatt Zusatzleistungen (%)', 'required' => false, 'constraints' => [ new Range(min: 1, max: 100), diff --git a/src/Form/OfferAcceptConfirmationType.php b/src/Form/OfferAcceptConfirmationType.php new file mode 100644 index 00000000..fefcf339 --- /dev/null +++ b/src/Form/OfferAcceptConfirmationType.php @@ -0,0 +1,41 @@ + + */ +class OfferAcceptConfirmationType extends AbstractType +{ + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $termsLink = sprintf( + 'allgemeinen Geschäftsbedingungen (AGB)', + htmlspecialchars($options['terms_url'], ENT_QUOTES, 'UTF-8') + ); + + $builder->add('termsAccepted', CheckboxType::class, [ + 'label' => sprintf('Ich habe die %s gelesen und bin damit einverstanden, dass eine kostenpflichtige Buchung zustande kommt.', $termsLink), + 'label_html' => true, + 'mapped' => false, + 'required' => true, + 'constraints' => [ + new IsTrue(message: 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.'), + ], + ]); + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setRequired('terms_url'); + $resolver->setAllowedTypes('terms_url', 'string'); + } +} diff --git a/src/Menu/AdminMenuBuilder.php b/src/Menu/AdminMenuBuilder.php index bf1493f8..b5ce1f0c 100644 --- a/src/Menu/AdminMenuBuilder.php +++ b/src/Menu/AdminMenuBuilder.php @@ -32,7 +32,7 @@ class AdminMenuBuilder extends AbstractMenuBuilder 'routes' => [['pattern' => '/^app_admin_bookingeditdraft/']], ], ]); - $menu->addChild('Guppenbuchungen', [ + $menu->addChild('Gruppenbuchungen', [ 'route' => 'app_admin_accommodationbooking', 'linkAttributes' => [ 'title' => 'Buchungen', diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php index 708e32dc..10bee737 100644 --- a/src/Model/AccommodationBookingApiResponse.php +++ b/src/Model/AccommodationBookingApiResponse.php @@ -59,7 +59,22 @@ final readonly class AccommodationBookingApiResponse public array $additionalServices; #[Groups(['api:single'])] - public ?int $discount; + public ?int $accommodationDiscount; + + #[Groups(['api:single'])] + public ?int $boardServiceDiscount; + + #[Groups(['api:single'])] + public ?int $additionalServicesDiscount; + + #[Groups(['api:single'])] + public ?int $totalPrice; + + #[Groups(['api:single'])] + public ?string $pricingCurrency; + + #[Groups(['api:single'])] + public ?int $pricingVersion; /** * @var array|null @@ -89,7 +104,12 @@ final readonly class AccommodationBookingApiResponse static fn (array $service) => new AccommodationBookingAdditionalServiceItem($service), $booking->getAdditionalServices(), ); - $this->discount = $booking->getDiscount(); + $this->accommodationDiscount = $booking->getAccommodationDiscount(); + $this->boardServiceDiscount = $booking->getBoardServiceDiscount(); + $this->additionalServicesDiscount = $booking->getAdditionalServicesDiscount(); + $this->totalPrice = $booking->getTotalPrice(); + $this->pricingCurrency = $booking->getPricingCurrency(); + $this->pricingVersion = $booking->getPricingVersion(); $this->priceBreakdown = $priceBreakdown; } } diff --git a/src/Model/AccommodationBookingContext.php b/src/Model/AccommodationBookingContext.php index 63d7cce2..75882d60 100644 --- a/src/Model/AccommodationBookingContext.php +++ b/src/Model/AccommodationBookingContext.php @@ -16,7 +16,7 @@ final readonly class AccommodationBookingContext * @param array $groupedAdditionalServices * @param AdditionalService[] $ungroupedAdditionalServices * @param array|null $priceBreakdown - * @param array|null $hotelCmsData + * @param array|null $hotelCmsData see {@see \App\Service\CmsDataProvider::getHotelDetails()} for the shape */ public function __construct( public Accommodation $accommodation, diff --git a/src/Model/ContingentCalendarQuery.php b/src/Model/ContingentCalendarQuery.php new file mode 100644 index 00000000..bdf05290 --- /dev/null +++ b/src/Model/ContingentCalendarQuery.php @@ -0,0 +1,78 @@ +dateFrom); + $dateTo = self::parseDate($this->dateTo); + + if (null === $dateFrom || null === $dateTo) { + return; + } + + if ($dateTo < $dateFrom) { + $context->buildViolation('dateTo must not be before dateFrom.') + ->atPath('dateTo') + ->addViolation(); + + return; + } + + if ($dateFrom->diff($dateTo)->days > self::MAX_RANGE_DAYS) { + $context->buildViolation(sprintf('The date range must not exceed %d days.', self::MAX_RANGE_DAYS)) + ->atPath('dateTo') + ->addViolation(); + } + } + + public function dateFromDate(): \DateTimeImmutable + { + return self::parseDate($this->dateFrom) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + public function dateToDate(): \DateTimeImmutable + { + return self::parseDate($this->dateTo) + ?? throw new \LogicException('ContingentCalendarQuery must be validated before use.'); + } + + private static function parseDate(string $value): ?\DateTimeImmutable + { + $date = \DateTimeImmutable::createFromFormat('!Y-m-d', $value); + $errors = \DateTimeImmutable::getLastErrors(); + + if (false === $date || (false !== $errors && ($errors['warning_count'] > 0 || $errors['error_count'] > 0))) { + return null; + } + + return $date->format('Y-m-d') === $value ? $date : null; + } +} diff --git a/src/Model/ContingentPricesQuery.php b/src/Model/ContingentPricesQuery.php new file mode 100644 index 00000000..c6d9d9fb --- /dev/null +++ b/src/Model/ContingentPricesQuery.php @@ -0,0 +1,21 @@ +|null null when no accommodation or dates are set */ public function compute(AccommodationBooking $booking): ?array + { + if (null !== $booking->getPriceBreakdown()) { + return $booking->getPriceBreakdown(); + } + + return $this->computeCurrent($booking); + } + + /** + * 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(); diff --git a/src/Service/AccommodationBookingLinkSigner.php b/src/Service/AccommodationBookingLinkSigner.php index b9ee6a82..8b608cad 100644 --- a/src/Service/AccommodationBookingLinkSigner.php +++ b/src/Service/AccommodationBookingLinkSigner.php @@ -69,4 +69,39 @@ class AccommodationBookingLinkSigner return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; } + + /** + * Marks the current session as authorized to view/act on this booking's offer. + * Called once, after a successful {@see isValidLinkRequest()} check at the + * signed-link entry point — every other route in this flow then trusts the + * session instead of re-deriving cryptographic validity from its own URL + * (which wouldn't work anyway, since the signature is bound to the exact + * URI it was generated for). + */ + public function authorizeSession(Request $request, AccommodationBooking $booking): void + { + $request->getSession()->set($this->sessionKey($booking), $booking->getAccessLinkIssuedAt()?->getTimestamp()); + } + + public function isSessionAuthorized(Request $request, AccommodationBooking $booking): bool + { + $issuedAt = $booking->getAccessLinkIssuedAt(); + + if (null === $issuedAt) { + return false; + } + + if ($request->getSession()->get($this->sessionKey($booking)) !== $issuedAt->getTimestamp()) { + return false; + } + + $expiresAt = $this->expiresAt($booking); + + return null !== $expiresAt && new \DateTimeImmutable() <= $expiresAt; + } + + private function sessionKey(AccommodationBooking $booking): string + { + return 'accommodation_offer_access_'.$booking->getUuid(); + } } diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 41747082..304cf074 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -22,6 +22,8 @@ use Psr\Log\LoggerInterface; class AccommodationBookingService { + private const int PRICING_VERSION = 1; + public function __construct( private readonly AccommodationRepository $accommodationRepo, private readonly AccommodationPriceRepository $priceRepo, @@ -99,9 +101,12 @@ class AccommodationBookingService } /** - * Fetches hotel name, image, and address from the CMS for display in the sidebar. + * Fetches hotel details from the CMS for display in the booking flow — name, images, + * address, descriptive text blocks, amenity icons, and the surrounding region's info + * (name, coordinates, webcam, ski area text, stats, images). * - * @return array|null null on CMS error or missing code + * @return array|null null on CMS error or missing code, see + * {@see CmsDataProvider::getHotelDetails()} for the shape */ public function loadHotelCmsData(Accommodation $accommodation): ?array { @@ -187,7 +192,7 @@ class AccommodationBookingService $candidates = array_values(array_filter( $prices, - fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, + fn (AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, )); $winner = $this->priceTimelineBuilder->resolveWinner($candidates); @@ -224,14 +229,14 @@ class AccommodationBookingService * Board and additional services are stored as frozen snapshots — independent of * the catalog entities so that future catalog edits do not affect existing bookings. * - * @param AccommodationPrice[] $prices + * @param AccommodationPrice[] $prices * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services */ public function persist( AccommodationBookingDto $dto, - Accommodation $accommodation, - array $prices, - array $services, + Accommodation $accommodation, + array $prices, + array $services, ): AccommodationBooking { $booking = new AccommodationBooking(); $booking->setAccommodation($accommodation); @@ -281,20 +286,72 @@ class AccommodationBookingService $booking->setCity($dto->city); $booking->setRemarks($dto->remarks); + $this->refreshPriceSnapshot($booking); + $this->entityManager->persist($booking); $this->entityManager->flush(); return $booking; } - /** @param array $priceBreakdown */ - public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void + /** + * Persists the booking and sends the notification/confirmation emails in one go. + * + * @param AccommodationPrice[] $prices + * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services + */ + public function finalizeBooking( + AccommodationBookingDto $dto, + Accommodation $accommodation, + array $prices, + array $services, + ): AccommodationBooking { + $booking = $this->persist($dto, $accommodation, $prices, $services); + $this->issueAccessLinkForDirectBooking($booking); + $this->sendNotificationEmail($booking); + $this->sendCustomerConfirmationEmail($booking); + + return $booking; + } + + public function refreshPriceSnapshot(AccommodationBooking $booking): void + { + $breakdown = $this->breakdownCalculator->computeCurrent($booking); + if (null === $breakdown) { + $booking->clearPriceSnapshot(); + + return; + } + + $accommodationBase = (int) ($breakdown['basePrice'] ?? 0) + (int) ($breakdown['additionalPersonsPrice'] ?? 0); + $boardBase = (int) ($breakdown['boardPrice'] ?? 0); + $servicesBase = (int) ($breakdown['servicesPrice'] ?? 0); + + $total = (int) ($breakdown['total'] ?? 0) + - $this->discountAmount($accommodationBase, $booking->getAccommodationDiscount()) + - $this->discountAmount($boardBase, $booking->getBoardServiceDiscount()) + - $this->discountAmount($servicesBase, $booking->getAdditionalServicesDiscount()); + + $booking->setPriceSnapshot( + $breakdown, + $total, + (string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'), + self::PRICING_VERSION, + ); + } + + private function discountAmount(int $base, ?int $percent): int + { + return null !== $percent ? (int) round($base * $percent / 100) : 0; + } + + public function sendNotificationEmail(AccommodationBooking $booking): void { try { $this->mailer->createAndSendEmail( [ 'booking' => $booking, - 'priceBreakdown' => $priceBreakdown, + 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [], 'accessLink' => $this->accessLinkOrNull($booking), ], [ @@ -362,15 +419,14 @@ class AccommodationBookingService } /** - * Explicit admin action: (re)issues the access link — invalidating any previously issued - * link for this booking — and sends the customer confirmation email, now including it. + * Explicit admin action: (re)issues the access link, invalidating any previously issued + * link for this booking. No email side effect — sending is a separate, explicit admin + * action via sendCustomerConfirmationEmail(). */ - public function regenerateAccessLinkAndNotifyCustomer(AccommodationBooking $booking): void + public function regenerateAccessLink(AccommodationBooking $booking): void { $booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $this->entityManager->flush(); - - $this->sendCustomerConfirmationEmail($booking); } /** @@ -421,7 +477,7 @@ class AccommodationBookingService $prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); $candidates = array_values(array_filter( $prices, - fn(AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom, + fn (AccommodationPrice $price): bool => $price->getDateFrom() <= $dateFrom && $price->getDateTo() >= $dateFrom, )); return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1; diff --git a/src/Service/CmsDataProvider.php b/src/Service/CmsDataProvider.php index 247ab90d..6ae739db 100644 --- a/src/Service/CmsDataProvider.php +++ b/src/Service/CmsDataProvider.php @@ -46,7 +46,35 @@ class CmsDataProvider return $result['images'] ?? null; } - /** @return array */ + /** + * @return array{ + * success?: bool, + * message?: string, + * name?: string, + * address?: string, + * description?: string, + * features?: string, + * roomTypes?: string, + * additionalInformation?: string, + * images?: array, + * icons?: array, + * region?: array{ + * name: string, + * latitude: float, + * longitude: float, + * webcam: string, + * ski_area: string, + * ski_area_extended: string, + * description: string, + * news: string, + * length: int, + * altitude: int, + * lifts: int, + * images: array, + * region_maps: list, + * }, + * } + */ public function getHotelDetails(string $hotelCode): array { return $this->cache->get( diff --git a/src/Twig/AppExtension.php b/src/Twig/AppExtension.php index 4b31a37c..67e4102f 100644 --- a/src/Twig/AppExtension.php +++ b/src/Twig/AppExtension.php @@ -27,6 +27,7 @@ class AppExtension extends AbstractExtension new TwigFilter('map_status', [AppRuntime::class, 'mapStatus']), new TwigFilter('map_country', [AppRuntime::class, 'mapCountry']), new TwigFilter('map_nationality', [AppRuntime::class, 'mapNationality']), + new TwigFilter('visible_cms_icons', [AppRuntime::class, 'visibleCmsIcons']), ]; } diff --git a/src/Twig/AppRuntime.php b/src/Twig/AppRuntime.php index afa7b3b7..8a254c4f 100644 --- a/src/Twig/AppRuntime.php +++ b/src/Twig/AppRuntime.php @@ -157,6 +157,44 @@ class AppRuntime implements RuntimeExtensionInterface ]); } + /** + * Normalizes a CMS "icons" map (identifier => bool|string|int) into the visible entries only. + * + * A value either toggles visibility (bool), makes the icon visible and overrides its + * default (humanized) label (non-empty string), or makes the icon visible with the + * number itself as the label to display next to the icon (non-zero int, e.g. bed_count). + * + * @param array|null $icons + * + * @return list + */ + public function visibleCmsIcons(?array $icons): array + { + if (null === $icons) { + return []; + } + + $result = []; + foreach ($icons as $key => $value) { + if (false === $value || null === $value || '' === $value || 0 === $value) { + continue; + } + + $label = match (true) { + is_int($value) => (string) $value, + is_string($value) => $value, + default => ucfirst(str_replace(['_', '-'], ' ', $key)), + }; + + $result[] = [ + 'key' => $key, + 'label' => $label, + ]; + } + + return $result; + } + /** * Checks if a form field should be rendered as static text. * @@ -209,6 +247,11 @@ class AppRuntime implements RuntimeExtensionInterface $labels = []; foreach ($form->children as $child) { + // Buttons (e.g. submit buttons) aren't validatable fields and have no errors/valid vars. + if (!\array_key_exists('errors', $child->vars)) { + continue; + } + $hasErrors = \count($child->vars['errors']) > 0; $hasInvalidChildren = false === $child->vars['valid']; diff --git a/templates/admin/accommodation_booking/create.html.twig b/templates/admin/accommodation_booking/create.html.twig index b8ff94c5..f9d11ded 100644 --- a/templates/admin/accommodation_booking/create.html.twig +++ b/templates/admin/accommodation_booking/create.html.twig @@ -33,7 +33,9 @@
{{ form_row(form.isInquiry) }}
- {{ form_row(form.discount) }} + {{ form_row(form.accommodationDiscount) }} + {{ form_row(form.boardServiceDiscount) }} + {{ form_row(form.additionalServicesDiscount) }}
{{ form_row(form.remarks) }}
diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig index c7b3d0d3..172f15fb 100644 --- a/templates/admin/accommodation_booking/edit.html.twig +++ b/templates/admin/accommodation_booking/edit.html.twig @@ -42,7 +42,14 @@
{{ form_row(form.isInquiry) }}
- {{ form_row(form.discount) }} + {% if booking.acceptedAt is not null %} +

+ Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }} +

+ {% endif %} + {{ form_row(form.accommodationDiscount) }} + {{ form_row(form.boardServiceDiscount) }} + {{ form_row(form.additionalServicesDiscount) }}
{{ form_row(form.remarks) }}
diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 3f2cf234..04c88e62 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -57,11 +57,17 @@ {% endif %} - {% if booking.discount is not null %} - {{ booking.discount }} % - {% else %} - – + {% set discounts = [] %} + {% if booking.accommodationDiscount is not null %} + {% set discounts = discounts | merge(['Unterkunft ' ~ booking.accommodationDiscount ~ '%']) %} {% endif %} + {% if booking.boardServiceDiscount is not null %} + {% set discounts = discounts | merge(['Verpflegung ' ~ booking.boardServiceDiscount ~ '%']) %} + {% endif %} + {% if booking.additionalServicesDiscount is not null %} + {% set discounts = discounts | merge(['Zusatzleistungen ' ~ booking.additionalServicesDiscount ~ '%']) %} + {% endif %} + {{ discounts | length > 0 ? (discounts | join(', ')) : '–' }} diff --git a/templates/admin/accommodation_booking/modal_generate_access_link.html.twig b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig new file mode 100644 index 00000000..edd3e831 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_generate_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du für {{ booking.groupName }} einen neuen Zugangslink generieren? + Ein zuvor generierter Link wird dadurch ungültig. +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/modal_send_access_link.html.twig b/templates/admin/accommodation_booking/modal_send_access_link.html.twig new file mode 100644 index 00000000..927091ff --- /dev/null +++ b/templates/admin/accommodation_booking/modal_send_access_link.html.twig @@ -0,0 +1,8 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block content %} +
+ Möchtest du den aktuellen Zugangslink für {{ booking.groupName }} an + {{ booking.email }} senden? +
+{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index ab7a7044..87f39134 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -55,6 +55,10 @@ {% endif %}
Status
{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}
+ {% if booking.acceptedAt is not null %} +
Angenommen am
+
{{ booking.acceptedAt | date('d.m.Y, H:i') }}
+ {% endif %} @@ -77,7 +81,7 @@ {% if priceBreakdown is not null %}

Preise

- {% set currency = booking.accommodation.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} {% if priceBreakdown.basePrice > 0 %} @@ -136,16 +140,31 @@ - {% if booking.discount is not null %} - {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} - {% set discountedTotal = priceBreakdown.total - discountAmount %} - - - - + {% if booking.accommodationDiscount is not null or booking.boardServiceDiscount is not null or booking.additionalServicesDiscount is not null %} + {% if booking.accommodationDiscount is not null %} + {% set accommodationDiscountAmount = ((priceBreakdown.basePrice + priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round %} + + + + + {% endif %} + {% if booking.boardServiceDiscount is not null %} + {% set boardDiscountAmount = (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round %} + + + + + {% endif %} + {% if booking.additionalServicesDiscount is not null %} + {% set servicesDiscountAmount = (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round %} + + + + + {% endif %} - + {% endif %}
{{ (priceBreakdown.total / 100) | format_currency(currency) }}
Rabatt {{ booking.discount }} %– {{ (discountAmount / 100) | format_currency(currency) }}
Rabatt Unterkunft {{ booking.accommodationDiscount }} %– {{ (accommodationDiscountAmount / 100) | format_currency(currency) }}
Rabatt Verpflegung {{ booking.boardServiceDiscount }} %– {{ (boardDiscountAmount / 100) | format_currency(currency) }}
Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }} %– {{ (servicesDiscountAmount / 100) | format_currency(currency) }}
Gesamtpreis nach Rabatt{{ (discountedTotal / 100) | format_currency(currency) }}{{ ((booking.totalPrice ?? priceBreakdown.total) / 100) | format_currency(currency) }}
@@ -170,9 +189,14 @@

Zugangslink

{% if accessLink %} -

- {{ accessLink }} -

+

Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }} {% if accessLinkExpiresAt < date() %} @@ -182,11 +206,24 @@ {% else %}

Es wurde noch kein Zugangslink generiert.

{% endif %} -
- -
+ {% if accessLink %} + + {% endif %} +
diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index f46ea9d1..d580ed9c 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -75,7 +75,7 @@ {% endif %} {% if priceBreakdown is not empty %} - {% set currency = priceBreakdown.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}

Preise

{% if priceBreakdown.basePrice > 0 %} @@ -124,7 +124,7 @@ {% endif %} - +
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
{% endif %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig index f40f0318..413a8a4d 100644 --- a/templates/email/accommodation_booking_customer.html.twig +++ b/templates/email/accommodation_booking_customer.html.twig @@ -52,12 +52,12 @@ {% if priceBreakdown is not empty %} - {% set currency = priceBreakdown.currency %} + {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}

Preise

- +
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ (priceBreakdown.total / 100)|format_currency(currency) }}{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
{% endif %} diff --git a/templates/groups/booking/_offer_accept_confirmation_modal.html.twig b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig new file mode 100644 index 00000000..780d9874 --- /dev/null +++ b/templates/groups/booking/_offer_accept_confirmation_modal.html.twig @@ -0,0 +1,31 @@ +{% extends 'htmx_modal.html.twig' %} + +{% block title %}Angebot verbindlich buchen{% endblock %} + +{% block content %} + {% from '_partials/_validation_errors.html.twig' import validation_alert %} + {{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }} + +

+ Möchtest du dieses Angebot jetzt verbindlich buchen? +

+ + {{ form_start(confirmationForm, { + 'action': path('app_groups_booking_offer_confirm', { uuid: booking.uuid }), + 'attr': { + 'hx-target': '#htmx-modal', + 'hx-swap': 'outerHTML', + } + }) }} + {{ form_row(confirmationForm.termsAccepted) }} + +
+ + +
+ {{ form_end(confirmationForm) }} +{% endblock %} diff --git a/templates/groups/booking/_offer_summary.html.twig b/templates/groups/booking/_offer_summary.html.twig new file mode 100644 index 00000000..e6f2ba98 --- /dev/null +++ b/templates/groups/booking/_offer_summary.html.twig @@ -0,0 +1,204 @@ +{% block booking_summary %} +
+ {# Header #} +
+ {% if ctx.priceBreakdown is not null %} +
+ Gesamtpreis + {{ (displayTotal / 100)|format_currency(currency) }} +
+ {% endif %} +
+ Übersicht + +
+
+ + {# Content #} + + + {# Footer: booking action / accepted-at status #} + {% if booking.inquiry %} +
+ +
+ {% elseif booking.acceptedAt is not null %} +
+

+ Angenommen am {{ booking.acceptedAt | date('d.m.Y') }} um {{ booking.acceptedAt | date('H:i') }} Uhr +

+
+ {% endif %} +
+{% endblock %} diff --git a/templates/groups/booking/_pagination.html.twig b/templates/groups/booking/_pagination.html.twig index 5a8a4d62..4cde89f9 100644 --- a/templates/groups/booking/_pagination.html.twig +++ b/templates/groups/booking/_pagination.html.twig @@ -3,9 +3,10 @@ 1: 'app_groups_booking_step_1', 2: 'app_groups_booking_step_2', 3: 'app_groups_booking_step_3', + 4: 'app_groups_booking_step_4', } %}
{{ form_rest(form) }} {{ form_end(form) }} - - {% if confirmationForm is defined %} - {% include 'groups/booking/_step3_confirmation_modal.html.twig' with { confirmationForm: confirmationForm } %} - {% endif %} {% endblock %} diff --git a/templates/groups/booking/step_4.html.twig b/templates/groups/booking/step_4.html.twig new file mode 100644 index 00000000..72ff1a57 --- /dev/null +++ b/templates/groups/booking/step_4.html.twig @@ -0,0 +1,74 @@ +{% extends 'layout_booking.html.twig' %} + +{% block title %}Buchung bestätigen{% endblock %} + +{% block background %}bg-outer bg-outer--summer{% endblock %} + +{% block content %} + {# Pagination - mobile only (above summary) #} +
+ {% include 'groups/booking/_pagination.html.twig' with { 'current_step': 4 } %} +
+ +
+ + {# Static sidebar recap #} +
+ {% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %} +
+ + {# Confirmation recap #} +
+ {% include '_partials/_flashes.html.twig' %} + + {# Pagination - desktop only (fixed above scrollable content) #} + + +
+

+ Bitte bestätige deine Angaben +

+ + {% if dto.isInquiry %} + {% include '_partials/_alert.html.twig' with { + level: 'warning', + title: 'Diese Buchung wird als unverbindliche Anfrage behandelt', + messages: dto.inquiryReasons + } %} + {% endif %} + + {% include 'groups/booking/_step4_services_recap.html.twig' with { 'dto': dto, 'ctx': ctx } %} + {% include 'groups/booking/_step4_personal_data_recap.html.twig' with { 'dto': dto } %} +
+
+ +
+ +
+
+ + Zurück + +
+ + +
+
+
+{% endblock %} diff --git a/templates/htmx_confirmation_modal.html.twig b/templates/htmx_confirmation_modal.html.twig index 7a3cd937..551814f3 100644 --- a/templates/htmx_confirmation_modal.html.twig +++ b/templates/htmx_confirmation_modal.html.twig @@ -14,7 +14,12 @@ {% block content %}{% endblock %}
-
-
+
{% block content %}{% endblock %}
diff --git a/tests/Controller/Accommodation/Step2ControllerTest.php b/tests/Controller/Accommodation/Step2ControllerTest.php index c82f2d03..f4e3951d 100644 --- a/tests/Controller/Accommodation/Step2ControllerTest.php +++ b/tests/Controller/Accommodation/Step2ControllerTest.php @@ -79,7 +79,7 @@ class Step2ControllerTest extends TestCase $bookingService ->expects(self::once()) ->method('computeInquiryStatus') - ->with(self::callback(static fn(AccommodationBookingDto $submittedDto): bool => 3 === $submittedDto->paxCount), []) + ->with(self::callback(static fn (AccommodationBookingDto $submittedDto): bool => 3 === $submittedDto->paxCount), []) ->willReturn(new InquiryStatus(true, ['Mindestaufenthalt: 5 Nächte (gebucht: 4)'])); $controller = new TestableAccommodationStep2Controller( diff --git a/tests/Controller/Accommodation/Step3ControllerTest.php b/tests/Controller/Accommodation/Step3ControllerTest.php index a20cf9c8..b2571c38 100644 --- a/tests/Controller/Accommodation/Step3ControllerTest.php +++ b/tests/Controller/Accommodation/Step3ControllerTest.php @@ -6,17 +6,13 @@ namespace App\Tests\Controller\Accommodation; use App\Controller\Groups\Step3Controller; use App\Entity\Groups\Accommodation; -use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AdditionalService; use App\Entity\Groups\BoardService; -use App\Form\AccommodationStep3ConfirmationType; -use App\Form\AccommodationStep3Type; use App\Form\Model\AccommodationBookingDto; use App\Service\AccommodationBookingService; use App\Service\AccommodationSessionManager; use PHPUnit\Framework\TestCase; use Symfony\Component\Form\FormInterface; -use Symfony\Component\Form\SubmitButton; use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; @@ -42,13 +38,7 @@ class Step3ControllerTest extends TestCase $sessionManager = new AccommodationSessionManager(); $sessionManager->save($request, $dto); - $form = $this->createMock(FormInterface::class); - $form - ->method('handleRequest') - ->willReturnSelf(); - $form - ->method('isSubmitted') - ->willReturn(false); + $form = $this->createFormMock(submitted: false, valid: false); $accommodation = (new Accommodation()) ->setName('Hotel') @@ -84,7 +74,7 @@ class Step3ControllerTest extends TestCase self::assertSame([$additionalService], $controller->renderedParameters['ctx']->additionalServices); } - public function testClickingBuchenOpensConfirmationModalWithoutPersisting(): void + public function testValidSubmitRedirectsToStep4WithoutPersisting(): void { $dto = new AccommodationBookingDto(); $dto->currentStep = 3; @@ -98,20 +88,7 @@ class Step3ControllerTest extends TestCase $sessionManager = new AccommodationSessionManager(); $sessionManager->save($request, $dto); - $submitBookingButton = $this->getMockBuilder(SubmitButton::class) - ->disableOriginalConstructor() - ->onlyMethods(['isClicked']) - ->getMock(); - $submitBookingButton->method('isClicked')->willReturn(true); - - $mainForm = $this->createMock(FormInterface::class); - $mainForm->method('handleRequest')->willReturnSelf(); - $mainForm->method('isSubmitted')->willReturn(true); - $mainForm->method('isValid')->willReturn(true); - $mainForm->method('has')->with('submitBooking')->willReturn(true); - $mainForm->method('get')->with('submitBooking')->willReturn($submitBookingButton); - - $confirmationForm = $this->createMock(FormInterface::class); + $form = $this->createFormMock(submitted: true, valid: true); $accommodation = (new Accommodation()) ->setName('Hotel') @@ -126,115 +103,65 @@ class Step3ControllerTest extends TestCase 'groupedAdditionalServices' => [], 'ungroupedAdditionalServices' => [], ]); - $bookingService->expects(self::never())->method('persist'); + $bookingService->expects(self::never())->method('finalizeBooking'); - $controller = new TestableAccommodationStep3Controller( - $bookingService, - $sessionManager, - [AccommodationStep3Type::class => $mainForm, AccommodationStep3ConfirmationType::class => $confirmationForm], - ); + $controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_4', $response->getTargetUrl()); + self::assertSame(4, $dto->currentStep); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + public function testInvalidSubmitReRendersFormWithErrors(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-3', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $form = $this->createFormMock(submitted: true, valid: false); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + + $controller = new TestableAccommodationStep3Controller($bookingService, $sessionManager, $form); $response = $controller->index($request); self::assertSame(200, $response->getStatusCode()); self::assertSame('groups/booking/step_3.html.twig', $controller->renderedView); - self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); - self::assertFalse($dto->forceInquiry); + self::assertSame(3, $dto->currentStep); self::assertSame($dto, $sessionManager->getDto($request)); } - public function testConfirmPersistsBookingOnValidConfirmationForm(): void + private function createFormMock(bool $submitted, bool $valid): FormInterface { - $dto = new AccommodationBookingDto(); - $dto->currentStep = 3; - $dto->accommodationId = 1; - $dto->isInquiry = false; + $form = $this->createMock(FormInterface::class); + $form->method('handleRequest')->willReturnSelf(); + $form->method('isSubmitted')->willReturn($submitted); + $form->method('isValid')->willReturn($valid); - $session = new Session(new MockArraySessionStorage()); - $request = Request::create('/groups/booking/step-3/confirm', 'POST'); - $request->setSession($session); - $request->headers->set('HX-Request', 'true'); - - $sessionManager = new AccommodationSessionManager(); - $sessionManager->save($request, $dto); - - $confirmationForm = $this->createMock(FormInterface::class); - $confirmationForm->method('handleRequest')->willReturnSelf(); - $confirmationForm->method('isSubmitted')->willReturn(true); - $confirmationForm->method('isValid')->willReturn(true); - - $accommodation = (new Accommodation()) - ->setName('Hotel') - ->setCalendarCode('HOTEL') - ->setMaxAdolescentAge(17); - - $booking = (new AccommodationBooking())->setIsInquiry(false); - - $bookingService = $this->createMock(AccommodationBookingService::class); - $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); - $bookingService->method('loadAvailableServices')->willReturn([ - 'boardServices' => [], - 'additionalServices' => [], - 'groupedAdditionalServices' => [], - 'ungroupedAdditionalServices' => [], - ]); - $bookingService->method('loadPrices')->willReturn([]); - $bookingService->expects(self::once())->method('persist')->willReturn($booking); - $bookingService->expects(self::once())->method('sendNotificationEmail'); - - $controller = new TestableAccommodationStep3Controller( - $bookingService, - $sessionManager, - [AccommodationStep3ConfirmationType::class => $confirmationForm], - ); - - $response = $controller->confirm($request); - - self::assertSame(200, $response->getStatusCode()); - self::assertTrue($response->headers->has('HX-Redirect')); - self::assertNull($sessionManager->getDto($request)); - } - - public function testConfirmRerendersModalWhenTermsNotAccepted(): void - { - $dto = new AccommodationBookingDto(); - $dto->currentStep = 3; - $dto->accommodationId = 1; - $dto->isInquiry = false; - - $session = new Session(new MockArraySessionStorage()); - $request = Request::create('/groups/booking/step-3/confirm', 'POST'); - $request->setSession($session); - - $sessionManager = new AccommodationSessionManager(); - $sessionManager->save($request, $dto); - - $confirmationForm = $this->createMock(FormInterface::class); - $confirmationForm->method('handleRequest')->willReturnSelf(); - $confirmationForm->method('isSubmitted')->willReturn(true); - $confirmationForm->method('isValid')->willReturn(false); - - $accommodation = (new Accommodation()) - ->setName('Hotel') - ->setCalendarCode('HOTEL') - ->setMaxAdolescentAge(17); - - $bookingService = $this->createMock(AccommodationBookingService::class); - $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); - $bookingService->expects(self::never())->method('persist'); - - $controller = new TestableAccommodationStep3Controller( - $bookingService, - $sessionManager, - [AccommodationStep3ConfirmationType::class => $confirmationForm], - ); - - $response = $controller->confirm($request); - - self::assertSame(200, $response->getStatusCode()); - self::assertFalse($response->headers->has('HX-Redirect')); - self::assertSame('groups/booking/_step3_confirmation_modal.html.twig', $controller->renderedView); - self::assertNotNull($sessionManager->getDto($request)); + return $form; } } @@ -247,13 +174,10 @@ final class TestableAccommodationStep3Controller extends Step3Controller public string $renderedView = ''; - /** - * @param FormInterface|array> $form - */ public function __construct( AccommodationBookingService $bookingService, AccommodationSessionManager $sessionManager, - private readonly FormInterface|array $form, + private readonly FormInterface $form, ) { parent::__construct($bookingService, $sessionManager); } @@ -263,10 +187,6 @@ final class TestableAccommodationStep3Controller extends Step3Controller */ protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface { - if (is_array($this->form)) { - return $this->form[$type]; - } - return $this->form; } diff --git a/tests/Controller/Api/AccommodationBookingControllerTest.php b/tests/Controller/Api/AccommodationBookingControllerTest.php index 55256fb2..a1eb6c2b 100644 --- a/tests/Controller/Api/AccommodationBookingControllerTest.php +++ b/tests/Controller/Api/AccommodationBookingControllerTest.php @@ -51,7 +51,9 @@ class AccommodationBookingControllerTest extends TestCase $booking->setBoardServiceLabel('Halbpension'); $booking->setBoardServicePrice(1500); $booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3); - $booking->setDiscount(1000); + $booking->setAccommodationDiscount(10); + $booking->setBoardServiceDiscount(20); + $booking->setAdditionalServicesDiscount(30); $booking->setIsInquiry(false); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); @@ -63,6 +65,7 @@ class AccommodationBookingControllerTest extends TestCase ->willReturn($booking); $breakdown = ['total' => 12345, 'currency' => 'EUR']; + $booking->setPriceSnapshot($breakdown, 11111, 'EUR', 1); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator ->expects(self::once()) @@ -116,7 +119,12 @@ class AccommodationBookingControllerTest extends TestCase 'type' => 'flat', ], ], - 'discount' => 1000, + 'accommodationDiscount' => 10, + 'boardServiceDiscount' => 20, + 'additionalServicesDiscount' => 30, + 'totalPrice' => 11111, + 'pricingCurrency' => 'EUR', + 'pricingVersion' => 1, 'priceBreakdown' => $breakdown, ], $payload); diff --git a/tests/Controller/Groups/OfferControllerTest.php b/tests/Controller/Groups/OfferControllerTest.php index 69b6e79a..5bb301e5 100644 --- a/tests/Controller/Groups/OfferControllerTest.php +++ b/tests/Controller/Groups/OfferControllerTest.php @@ -5,109 +5,32 @@ declare(strict_types=1); namespace App\Tests\Controller\Groups; use App\Controller\Groups\OfferController; +use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; use App\Repository\Groups\AccommodationBookingRepository; use App\Service\AccommodationBookingBreakdownCalculator; use App\Service\AccommodationBookingLinkSigner; use App\Service\AccommodationBookingService; use PHPUnit\Framework\TestCase; +use Symfony\Component\Form\FormInterface; +use Symfony\Component\HttpFoundation\RedirectResponse; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; class OfferControllerTest extends TestCase { - public function testGetRendersOfferWithAcceptFormForInquiry(): void + public function testAccessAuthorizesSessionAndRedirectsToView(): void { $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner->method('isValidLinkRequest')->willReturn(true); - - $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); - $breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]); - - $bookingService = $this->createMock(AccommodationBookingService::class); - $bookingService->expects(self::never())->method('acceptBooking'); - - $controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService); - - $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); - - self::assertSame(Response::HTTP_OK, $response->getStatusCode()); - self::assertSame('offer', $response->getContent()); - self::assertSame('groups/booking/offer.html.twig', $controller->renderedView); - self::assertSame(['booking' => $booking, 'priceBreakdown' => ['total' => 1000]], $controller->renderedParameters); - } - - public function testPostAcceptsInquiryAndRedirectsToSameUrl(): void - { - $booking = new AccommodationBooking(); - $booking->setIsInquiry(true); - - $bookingRepository = $this->createMock(AccommodationBookingRepository::class); - $bookingRepository->method('findOneBy')->willReturn($booking); - - $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); - $linkSigner->method('isValidLinkRequest')->willReturn(true); - - $bookingService = $this->createMock(AccommodationBookingService::class); - $bookingService->expects(self::once())->method('acceptBooking')->with($booking); - - $controller = new OfferController( - $bookingRepository, - $linkSigner, - $this->createMock(AccommodationBookingBreakdownCalculator::class), - $bookingService, - ); - - $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc', 'POST'); - $response = $controller->index($booking->getUuid(), $request); - - self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); - self::assertSame($request->getUri(), $response->headers->get('Location')); - } - - public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void - { - $booking = new AccommodationBooking(); - $booking->setIsInquiry(false); - - $bookingRepository = $this->createMock(AccommodationBookingRepository::class); - $bookingRepository->method('findOneBy')->willReturn($booking); - - $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); - $linkSigner->method('isValidLinkRequest')->willReturn(true); - - $bookingService = $this->createMock(AccommodationBookingService::class); - $bookingService->expects(self::never())->method('acceptBooking'); - - $controller = new TestableOfferController( - $bookingRepository, - $linkSigner, - $this->createMock(AccommodationBookingBreakdownCalculator::class), - $bookingService, - ); - - // A POST on an already-accepted booking should not trigger acceptBooking again — falls through to render. - $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST')); - - self::assertSame(Response::HTTP_OK, $response->getStatusCode()); - self::assertSame('groups/booking/offer.html.twig', $controller->renderedView); - } - - public function testInvalidLinkReturnsUnavailableResponse(): void - { - $booking = new AccommodationBooking(); - - $bookingRepository = $this->createMock(AccommodationBookingRepository::class); - $bookingRepository->method('findOneBy')->willReturn($booking); - - $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); - $linkSigner->method('isValidLinkRequest')->willReturn(false); + $linkSigner->expects(self::once())->method('authorizeSession')->with(self::anything(), $booking); $controller = new TestableOfferController( $bookingRepository, @@ -116,13 +39,37 @@ class OfferControllerTest extends TestCase $this->createMock(AccommodationBookingService::class), ); - $response = $controller->index($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + $response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); + } + + public function testAccessRendersUnavailableForInvalidLink(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isValidLinkRequest')->willReturn(false); + $linkSigner->expects(self::never())->method('authorizeSession'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->access($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid())); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); } - public function testUnknownUuidReturnsUnavailableResponse(): void + public function testAccessRendersUnavailableForUnknownUuid(): void { $bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository->method('findOneBy')->willReturn(null); @@ -137,11 +84,300 @@ class OfferControllerTest extends TestCase $this->createMock(AccommodationBookingService::class), ); - $response = $controller->index('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); + $response = $controller->access('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid')); self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); } + + public function testViewRendersOfferWhenSessionAuthorized(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + $booking->setAccommodation(new Accommodation()); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->with($booking)->willReturn(['total' => 1000]); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + $bookingService->method('loadHotelCmsData')->willReturn(null); + + $controller = new TestableOfferController($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'); + $request->setSession(new Session(new MockArraySessionStorage())); + $response = $controller->view($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('offer', $response->getContent()); + self::assertSame('groups/booking/offer.html.twig', $controller->renderedView); + self::assertFalse($controller->renderedParameters['justAccepted']); + self::assertSame($booking, $controller->renderedParameters['booking']); + self::assertSame(['total' => 1000], $controller->renderedParameters['priceBreakdown']); + self::assertSame($booking->getAccommodation(), $controller->renderedParameters['ctx']->accommodation); + } + + public function testViewRendersUnavailableWhenSessionNotAuthorized(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->view($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/view')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/offer_unavailable.html.twig', $controller->renderedView); + } + + public function testViewMarksJustAcceptedWhenFlashIsPresent(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + $booking->setAccommodation(new Accommodation()); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator->method('compute')->willReturn(['total' => 1000]); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $breakdownCalculator, + $this->createMock(AccommodationBookingService::class), + ); + + $session = new Session(new MockArraySessionStorage()); + $session->getFlashBag()->add('accommodation_offer_accepted', true); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/view'); + $request->setSession($session); + $response = $controller->view($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertTrue($controller->renderedParameters['justAccepted']); + } + + public function testConfirmGetRendersModalWithFreshForm(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->with(['uuid' => $booking->getUuid()])->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + $confirmationForm, + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView); + self::assertSame($booking, $controller->renderedParameters['booking']); + self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); + } + + public function testConfirmPostValidAcceptsBookingAndRedirects(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'); + $response = $controller->confirm($booking->getUuid(), $request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); + } + + public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::once())->method('acceptBooking')->with($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $request = Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST'); + $request->headers->set('HX-Request', 'true'); + $response = $controller->confirm($booking->getUuid(), $request); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertTrue($response->headers->has('HX-Redirect')); + } + + public function testConfirmPostInvalidReRendersModalWithoutAccepting(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(true); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->expects(self::never())->method('acceptBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $bookingService, + $confirmationForm, + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm', 'POST')); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + self::assertFalse($response->headers->has('HX-Redirect')); + self::assertSame('groups/booking/_offer_accept_confirmation_modal.html.twig', $controller->renderedView); + } + + public function testConfirmRedirectsWhenAlreadyAccepted(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(true); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testConfirmRedirectsWhenSessionNotAuthorized(): void + { + $booking = new AccommodationBooking(); + + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn($booking); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->method('isSessionAuthorized')->willReturn(false); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm($booking->getUuid(), Request::create('/groups/booking/offer/'.$booking->getUuid().'/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } + + public function testConfirmRedirectsForUnknownUuid(): void + { + $bookingRepository = $this->createMock(AccommodationBookingRepository::class); + $bookingRepository->method('findOneBy')->willReturn(null); + + $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); + $linkSigner->expects(self::never())->method('isSessionAuthorized'); + + $controller = new TestableOfferController( + $bookingRepository, + $linkSigner, + $this->createMock(AccommodationBookingBreakdownCalculator::class), + $this->createMock(AccommodationBookingService::class), + ); + + $response = $controller->confirm('unknown-uuid', Request::create('/groups/booking/offer/unknown-uuid/confirm')); + + self::assertInstanceOf(RedirectResponse::class, $response); + } } final class TestableOfferController extends OfferController @@ -151,6 +387,39 @@ final class TestableOfferController extends OfferController /** @var array */ public array $renderedParameters = []; + public function __construct( + AccommodationBookingRepository $bookingRepository, + AccommodationBookingLinkSigner $linkSigner, + AccommodationBookingBreakdownCalculator $breakdownCalculator, + AccommodationBookingService $bookingService, + private readonly ?FormInterface $confirmationForm = null, + ) { + parent::__construct($bookingRepository, $linkSigner, $breakdownCalculator, $bookingService); + } + + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->confirmationForm ?? throw new \LogicException('No confirmation form mock configured for this test.'); + } + + protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null + { + return 'https://example.test/agb/'; + } + + protected function addFlash(string $type, mixed $message): void + { + // no-op: avoids requiring a full service container in these unit tests + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } + protected function render(string $view, array $parameters = [], ?Response $response = null): Response { $this->renderedView = $view; diff --git a/tests/Controller/Groups/Step4ControllerTest.php b/tests/Controller/Groups/Step4ControllerTest.php new file mode 100644 index 00000000..a4a89bcd --- /dev/null +++ b/tests/Controller/Groups/Step4ControllerTest.php @@ -0,0 +1,402 @@ +currentStep = 4; + $dto->accommodationId = 1; + $dto->isInquiry = false; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $this->createMock(FormInterface::class)); + + $response = $controller->index($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/step_4.html.twig', $controller->renderedView); + self::assertSame($dto, $controller->renderedParameters['dto']); + } + + public function testIndexRedirectsToStep3WhenNotYetReached(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl()); + } + + public function testIndexPostWithValidCsrfPersistsAsInquiryAndRedirects(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $booking = (new AccommodationBooking())->setIsInquiry(true); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->method('loadPrices')->willReturn([]); + $bookingService->expects(self::once())->method('finalizeBooking')->willReturn($booking); + + $inquiryForm = $this->createMock(FormInterface::class); + $inquiryForm->method('handleRequest')->willReturnSelf(); + $inquiryForm->method('isSubmitted')->willReturn(true); + $inquiryForm->method('isValid')->willReturn(true); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm); + + $response = $controller->index($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_success', $response->getTargetUrl()); + self::assertTrue($dto->forceInquiry); + self::assertNull($sessionManager->getDto($request)); + } + + public function testIndexPostWithInvalidFormThrowsAccessDenied(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $inquiryForm = $this->createMock(FormInterface::class); + $inquiryForm->method('handleRequest')->willReturnSelf(); + $inquiryForm->method('isSubmitted')->willReturn(true); + $inquiryForm->method('isValid')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $inquiryForm); + + $this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class); + + $controller->index($request); + } + + public function testConfirmInquiryGetRendersModal(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->confirmInquiry($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/_step4_inquiry_confirmation_modal.html.twig', $controller->renderedView); + } + + public function testConfirmInquiryRedirectsToStep3WhenNotYetReached(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 3; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm-inquiry', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $controller = new TestableStep4Controller( + $this->createMock(AccommodationBookingService::class), + $sessionManager, + $this->createMock(FormInterface::class), + ); + + $response = $controller->confirmInquiry($request); + + self::assertInstanceOf(RedirectResponse::class, $response); + self::assertSame('/app_groups_booking_step_3', $response->getTargetUrl()); + } + + public function testConfirmGetRendersModalWithFreshForm(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'GET'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView); + self::assertSame($confirmationForm, $controller->renderedParameters['confirmationForm']); + self::assertSame($dto, $sessionManager->getDto($request)); + } + + public function testConfirmPostValidPersistsAndRedirects(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'POST'); + $request->setSession($session); + $request->headers->set('HX-Request', 'true'); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $booking = (new AccommodationBooking())->setIsInquiry(false); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->method('loadAvailableServices')->willReturn([ + 'boardServices' => [], + 'additionalServices' => [], + 'groupedAdditionalServices' => [], + 'ungroupedAdditionalServices' => [], + ]); + $bookingService->method('loadPrices')->willReturn([]); + $bookingService->expects(self::once())->method('finalizeBooking')->willReturn($booking); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(true); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertTrue($response->headers->has('HX-Redirect')); + self::assertNull($sessionManager->getDto($request)); + } + + public function testConfirmPostInvalidReRendersModalWithoutPersisting(): void + { + $dto = new AccommodationBookingDto(); + $dto->currentStep = 4; + $dto->accommodationId = 1; + + $session = new Session(new MockArraySessionStorage()); + $request = Request::create('/groups/booking/step-4/confirm', 'POST'); + $request->setSession($session); + + $sessionManager = new AccommodationSessionManager(); + $sessionManager->save($request, $dto); + + $accommodation = (new Accommodation()) + ->setName('Hotel') + ->setCalendarCode('HOTEL') + ->setMaxAdolescentAge(17); + + $bookingService = $this->createMock(AccommodationBookingService::class); + $bookingService->method('loadAccommodation')->with(1)->willReturn($accommodation); + $bookingService->expects(self::never())->method('finalizeBooking'); + + $confirmationForm = $this->createMock(FormInterface::class); + $confirmationForm->method('handleRequest')->willReturnSelf(); + $confirmationForm->method('isSubmitted')->willReturn(true); + $confirmationForm->method('isValid')->willReturn(false); + + $controller = new TestableStep4Controller($bookingService, $sessionManager, $confirmationForm); + + $response = $controller->confirm($request); + + self::assertSame(200, $response->getStatusCode()); + self::assertFalse($response->headers->has('HX-Redirect')); + self::assertSame('groups/booking/_step4_booking_confirmation_modal.html.twig', $controller->renderedView); + self::assertNotNull($sessionManager->getDto($request)); + } +} + +final class TestableStep4Controller extends Step4Controller +{ + /** + * @var array + */ + public array $renderedParameters = []; + + public string $renderedView = ''; + + public function __construct( + AccommodationBookingService $bookingService, + AccommodationSessionManager $sessionManager, + private readonly FormInterface $form, + ) { + parent::__construct($bookingService, $sessionManager); + } + + /** + * @return FormInterface + */ + protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface + { + return $this->form; + } + + protected function getParameter(string $name): array|bool|string|int|float|\UnitEnum|null + { + return 'https://example.test/agb/'; + } + + /** + * @param array $parameters + */ + protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse + { + return new RedirectResponse('/'.$route, $status); + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route; + } + + protected function addFlash(string $type, mixed $message): void + { + // no-op: avoids requiring a full service container in these unit tests + } + + /** + * @param array $parameters + */ + protected function render(string $view, array $parameters = [], ?Response $response = null): Response + { + $this->renderedView = $view; + $this->renderedParameters = $parameters; + + return new Response('ok'); + } +} diff --git a/tests/Model/ContingentQueryTest.php b/tests/Model/ContingentQueryTest.php new file mode 100644 index 00000000..4995fb0f --- /dev/null +++ b/tests/Model/ContingentQueryTest.php @@ -0,0 +1,50 @@ +validator()->validate($query)); + self::assertSame('2026-01-01', $query->dateFromDate()->format('Y-m-d')); + } + + /** + * @dataProvider invalidCalendarQueries + */ + public function testCalendarQueryRejectsInvalidInput(ContingentCalendarQuery $query): void + { + self::assertGreaterThan(0, $this->validator()->validate($query)->count()); + } + + public function invalidCalendarQueries(): iterable + { + yield 'normalized invalid date' => [new ContingentCalendarQuery('HOTEL', '2026-02-31', '2026-03-01')]; + yield 'reversed range' => [new ContingentCalendarQuery('HOTEL', '2026-03-02', '2026-03-01')]; + yield 'range over 366 days' => [new ContingentCalendarQuery('HOTEL', '2026-01-01', '2027-01-03')]; + yield 'invalid hotel code' => [new ContingentCalendarQuery('HOTEL CODE', '2026-01-01', '2026-01-02')]; + } + + public function testPricesQueryRequiresFourDigitYear(): void + { + self::assertCount(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 2026))); + self::assertGreaterThan(0, $this->validator()->validate(new ContingentPricesQuery('HOTEL', 26))->count()); + } + + private function validator(): \Symfony\Component\Validator\Validator\ValidatorInterface + { + return Validation::createValidatorBuilder() + ->enableAttributeMapping() + ->getValidator(); + } +} diff --git a/tests/Service/AccommodationBookingBreakdownCalculatorTest.php b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php new file mode 100644 index 00000000..f736bc48 --- /dev/null +++ b/tests/Service/AccommodationBookingBreakdownCalculatorTest.php @@ -0,0 +1,39 @@ + 12345, 'currency' => 'EUR']; + $booking = new AccommodationBooking(); + $booking->setPriceSnapshot($snapshot, 12345, 'EUR', 1); + + $priceRepository = $this->createMock(AccommodationPriceRepository::class); + $priceRepository->expects(self::never())->method('findByHotelCodeAndDateRange'); + + $calculator = new AccommodationBookingBreakdownCalculator( + new GroupsPriceCalculator(new PriceTimelineBuilder(), [ + 'runningCostsEur' => 0, + 'runningCostsChf' => 0, + 'undersubscription30Eur' => 0, + 'undersubscription30Chf' => 0, + 'undersubscription40Eur' => 0, + 'undersubscription40Chf' => 0, + ]), + $priceRepository, + ); + + self::assertSame($snapshot, $calculator->compute($booking)); + } +} diff --git a/tests/Service/AccommodationBookingLinkSignerTest.php b/tests/Service/AccommodationBookingLinkSignerTest.php index b0b599b5..db91149f 100644 --- a/tests/Service/AccommodationBookingLinkSignerTest.php +++ b/tests/Service/AccommodationBookingLinkSignerTest.php @@ -8,6 +8,8 @@ use App\Entity\Groups\AccommodationBooking; use App\Service\AccommodationBookingLinkSigner; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Symfony\Component\Routing\Generator\UrlGeneratorInterface; class AccommodationBookingLinkSignerTest extends TestCase @@ -94,6 +96,77 @@ class AccommodationBookingLinkSignerTest extends TestCase self::assertSame('2026-04-01T00:00:00+00:00', $signer->expiresAt($booking)?->format(\DATE_ATOM)); } + public function testSessionIsAuthorizedAfterAuthorizeSession(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + + $signer->authorizeSession($request, $booking); + + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationIsPerBooking(): void + { + $booking = $this->bookingWithAccessLink(); + $otherBooking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + self::assertFalse($signer->isSessionAuthorized($request, $otherBooking)); + } + + public function testSessionAuthorizationIsRevokedWhenLinkIsRegenerated(): void + { + $booking = $this->bookingWithAccessLink(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + self::assertTrue($signer->isSessionAuthorized($request, $booking)); + + // Regenerating the access link overwrites accessLinkIssuedAt — the + // previously-authorized session no longer matches. + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('+1 minute')); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationFailsPastTtlEvenIfSessionMatches(): void + { + $booking = new AccommodationBooking(); + $booking->setAccessLinkIssuedAt(new \DateTimeImmutable('-91 days')); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + $signer->authorizeSession($request, $booking); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + public function testSessionAuthorizationFailsWithoutAccessLinkIssuedAt(): void + { + $booking = new AccommodationBooking(); + $signer = $this->createSigner(); + $request = $this->requestWithSession(); + + self::assertFalse($signer->isSessionAuthorized($request, $booking)); + } + + private function requestWithSession(): Request + { + $request = Request::create('https://example.com/'); + $request->setSession(new Session(new MockArraySessionStorage())); + + return $request; + } + private function bookingWithAccessLink(): AccommodationBooking { $booking = new AccommodationBooking(); diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 84ed3e45..0e642c3c 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -175,7 +175,7 @@ class AccommodationBookingServiceTest extends TestCase $service->sendCustomerConfirmationEmail($booking); } - public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void + public function testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): void { $booking = new AccommodationBooking(); $booking->setEmail('customer@example.com'); @@ -190,7 +190,7 @@ class AccommodationBookingServiceTest extends TestCase $breakdownCalculator->method('compute')->willReturn(null); $mailer = $this->createMock(Mailer::class); - $mailer->expects(self::once())->method('createAndSendEmail'); + $mailer->expects(self::never())->method('createAndSendEmail'); $service = $this->createServiceWithAccommodation( entityManager: $entityManager, @@ -198,7 +198,7 @@ class AccommodationBookingServiceTest extends TestCase breakdownCalculator: $breakdownCalculator, ); - $service->regenerateAccessLinkAndNotifyCustomer($booking); + $service->regenerateAccessLink($booking); self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); } @@ -234,6 +234,39 @@ class AccommodationBookingServiceTest extends TestCase self::assertNull($booking->getAcceptedAt()); } + public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void + { + $booking = new AccommodationBooking(); + $booking->setAccommodationDiscount(10); + $booking->setBoardServiceDiscount(20); + $booking->setAdditionalServicesDiscount(50); + + $breakdown = [ + 'total' => 12345, + 'currency' => 'CHF', + 'basePrice' => 8000, + 'additionalPersonsPrice' => 2000, + 'boardPrice' => 1000, + 'servicesPrice' => 500, + ]; + + $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); + $breakdownCalculator + ->expects(self::once()) + ->method('computeCurrent') + ->with($booking) + ->willReturn($breakdown); + + $service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator); + $service->refreshPriceSnapshot($booking); + + // accommodation: (8000+2000)*10% = 1000, board: 1000*20% = 200, services: 500*50% = 250 + self::assertSame($breakdown, $booking->getPriceBreakdown()); + self::assertSame(12345 - 1000 - 200 - 250, $booking->getTotalPrice()); + self::assertSame('CHF', $booking->getPricingCurrency()); + self::assertSame(1, $booking->getPricingVersion()); + } + /** * @param AccommodationPrice[] $prices */