wip: group inquiries and booking

This commit is contained in:
Björn Fromme committed 2026-07-16 17:13:26 +02:00
1 parent 18a890e50e
commit e7bfb19ac9
71 files changed
+2731 -512

No files matched your search

@@ -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)
}
}
}
+29
View File
@@ -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()
}
}
+2
View File
@@ -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";
+1
View File
@@ -0,0 +1 @@
@import "glightbox/dist/css/glightbox.min.css";
+13
View File
@@ -0,0 +1,13 @@
.rte-content {
p {
@apply pb-4;
}
ul {
@apply list-disc p-4;
}
ol {
@apply list-decimal p-4;
}
}
Generated
+7 -7
View File
@@ -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",
+5 -1
View File
@@ -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)/
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260710130000 extends AbstractMigration
{
public function getDescription(): string
{
return 'Store an authoritative price snapshot on accommodation bookings';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace DoctrineMigrations;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
final class Version20260716101949 extends AbstractMigration
{
public function getDescription(): string
{
return 'Split the single accommodation booking discount into per-component discounts';
}
public function up(Schema $schema): void
{
$this->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');
}
}
+7
View File
@@ -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",
+3 -2
View File
@@ -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"
}
}
@@ -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(),
]);
}
}
@@ -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();
@@ -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();
@@ -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(),
]);
}
}
@@ -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',
]
);
@@ -0,0 +1,52 @@
<?php
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;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class SendAccessLinkController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/send-access-link', name: 'app_admin_accommodationbooking_send_access_link')]
public function index(AccommodationBooking $booking, Request $request): Response
{
if (null === $booking->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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
@@ -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(),
]);
}
}
+30 -48
View File
@@ -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(),
];
}
}
@@ -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',
};
+84 -6
View File
@@ -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);
}
}
+6 -74
View File
@@ -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);
}
}
+150
View File
@@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Controller\Groups;
use App\Entity\Groups\Accommodation;
use App\Exception\AccommodationSessionNotFoundException;
use App\Form\AccommodationBookingConfirmationType;
use App\Form\AccommodationInquiryConfirmationType;
use App\Form\Model\AccommodationBookingDto;
use App\Htmx\HxTrait;
use App\Model\AccommodationBookingContext;
use App\Service\AccommodationBookingService;
use App\Service\AccommodationSessionManager;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class Step4Controller extends AbstractAccommodationController
{
use HxTrait;
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly AccommodationSessionManager $sessionManager,
) {
}
#[Route('/groups/booking/step-4', name: 'app_groups_booking_step_4', methods: ['GET', 'POST'])]
public function index(Request $request): Response
{
$dto = $this->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);
}
}
+97 -7
View File
@@ -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<array{label: string, price: int, type: string, originalServiceId: int|null}> */
#[ORM\Column(type: Types::JSON)]
private array $additionalServices = [];
/** @var array<string, mixed>|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<array{label: string, price: int, type: string, originalServiceId: int|null}> */
public function getAdditionalServices(): array
{
return $this->additionalServices;
}
/** @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServices */
public function setAdditionalServices(array $additionalServices): self
{
$this->additionalServices = $additionalServices;
@@ -259,6 +283,48 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this;
}
/** @return array<string, mixed>|null */
public function getPriceBreakdown(): ?array
{
return $this->priceBreakdown;
}
/** @param array<string, mixed> $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;
}
@@ -14,7 +14,7 @@ use Symfony\Component\Validator\Constraints\IsTrue;
/**
* @extends AbstractType<AccommodationBookingDto>
*/
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');
}
}
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* @extends AbstractType<null>
*/
class AccommodationInquiryConfirmationType extends AbstractType
{
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'csrf_token_id' => 'groups_booking_step4_inquiry',
]);
}
}
@@ -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),
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Form;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
use Symfony\Component\Validator\Constraints\IsTrue;
/**
* @extends AbstractType<null>
*/
class OfferAcceptConfirmationType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLink = sprintf(
'<a href="%s" target="_blank" rel="noopener" class="underline">allgemeinen Geschäftsbedingungen (AGB)</a>',
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');
}
}
+1 -1
View File
@@ -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',
+22 -2
View File
@@ -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<string, mixed>|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;
}
}
+1 -1
View File
@@ -16,7 +16,7 @@ final readonly class AccommodationBookingContext
* @param array<string, AdditionalService[]> $groupedAdditionalServices
* @param AdditionalService[] $ungroupedAdditionalServices
* @param array<string, mixed>|null $priceBreakdown
* @param array<string, mixed>|null $hotelCmsData
* @param array<string, mixed>|null $hotelCmsData see {@see \App\Service\CmsDataProvider::getHotelDetails()} for the shape
*/
public function __construct(
public Accommodation $accommodation,
+78
View File
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
final readonly class ContingentCalendarQuery
{
private const int MAX_RANGE_DAYS = 366;
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateFrom,
#[Assert\NotBlank]
#[Assert\Date]
public string $dateTo,
) {
}
#[Assert\Callback]
public function validateRange(ExecutionContextInterface $context): void
{
$dateFrom = self::parseDate($this->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;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Model;
use Symfony\Component\Validator\Constraints as Assert;
final readonly class ContingentPricesQuery
{
public function __construct(
#[Assert\NotBlank]
#[Assert\Length(max: 16)]
#[Assert\Regex(pattern: '/^[A-Za-z0-9_-]+$/')]
public string $hotelCode,
#[Assert\Range(min: 1000, max: 9999)]
public int $year,
) {
}
}
@@ -16,12 +16,27 @@ class AccommodationBookingBreakdownCalculator
}
/**
* Recomputes the price breakdown from the booking's frozen snapshot data and current
* accommodation prices for the same date range.
* Returns the authoritative stored snapshot, falling back to current prices only for
* bookings created before snapshot persistence was introduced.
*
* @return array<string, mixed>|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<string, mixed>|null
*/
public function computeCurrent(AccommodationBooking $booking): ?array
{
$accommodation = $booking->getAccommodation();
$dateFrom = $booking->getDateFrom();
@@ -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();
}
}
+72 -16
View File
@@ -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<string, mixed>|null null on CMS error or missing code
* @return array<string, mixed>|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<string, mixed> $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;
+29 -1
View File
@@ -46,7 +46,35 @@ class CmsDataProvider
return $result['images'] ?? null;
}
/** @return array<string, mixed> */
/**
* @return array{
* success?: bool,
* message?: string,
* name?: string,
* address?: string,
* description?: string,
* features?: string,
* roomTypes?: string,
* additionalInformation?: string,
* images?: array<string, mixed>,
* icons?: array<string, bool|string>,
* 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<string, mixed>,
* region_maps: list<string>,
* },
* }
*/
public function getHotelDetails(string $hotelCode): array
{
return $this->cache->get(
+1
View File
@@ -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']),
];
}
+43
View File
@@ -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<string, mixed>|null $icons
*
* @return list<array{key: string, label: string}>
*/
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'];
@@ -33,7 +33,9 @@
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{{ form_row(form.discount) }}
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
<div class="lg:col-span-2">
{{ form_row(form.remarks) }}
</div>
@@ -42,7 +42,14 @@
<div class="lg:col-span-2">
{{ form_row(form.isInquiry) }}
</div>
{{ form_row(form.discount) }}
{% if booking.acceptedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
</p>
{% endif %}
{{ form_row(form.accommodationDiscount) }}
{{ form_row(form.boardServiceDiscount) }}
{{ form_row(form.additionalServicesDiscount) }}
<div class="lg:col-span-2">
{{ form_row(form.remarks) }}
</div>
@@ -57,11 +57,17 @@
{% endif %}
</td>
<td>
{% if booking.discount is not null %}
{{ booking.discount }}&nbsp;%
{% 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(', ')) : '–' }}
</td>
<td class="text-right">
<twig:dropdown>
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du für <em>{{ booking.groupName }}</em> einen neuen Zugangslink generieren?
Ein zuvor generierter Link wird dadurch ungültig.
</div>
{% endblock %}
@@ -0,0 +1,8 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block content %}
<div>
Möchtest du den aktuellen Zugangslink für <em>{{ booking.groupName }}</em> an
<em>{{ booking.email }}</em> senden?
</div>
{% endblock %}
@@ -55,6 +55,10 @@
{% endif %}
<dt class="font-medium">Status</dt>
<dd>{% if booking.isInquiry %}Anfrage{% else %}Buchung{% endif %}</dd>
{% if booking.acceptedAt is not null %}
<dt class="font-medium">Angenommen am</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl>
</div>
@@ -77,7 +81,7 @@
{% if priceBreakdown is not null %}
<div>
<h2 class="text-lg font-bold mb-4">Preise</h2>
{% set currency = booking.accommodation.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<table class="w-full text-sm">
{% if priceBreakdown.basePrice > 0 %}
<tr>
@@ -136,16 +140,31 @@
</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
</tr>
{% if booking.discount is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = priceBreakdown.total - discountAmount %}
<tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% 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 %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Unterkunft {{ booking.accommodationDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (accommodationDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.boardServiceDiscount is not null %}
{% set boardDiscountAmount = (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Verpflegung {{ booking.boardServiceDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (boardDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.additionalServicesDiscount is not null %}
{% set servicesDiscountAmount = (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round %}
<tr class="text-green-700">
<td class="pt-1">Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (servicesDiscountAmount / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-gray-200 font-bold text-green-700">
<td class="pt-2">Gesamtpreis nach Rabatt</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (discountedTotal / 100) | format_currency(currency) }}</td>
<td class="pt-2 text-right whitespace-nowrap">{{ ((booking.totalPrice ?? priceBreakdown.total) / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
@@ -170,9 +189,14 @@
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Zugangslink</h2>
{% if accessLink %}
<p class="text-sm break-all">
<a href="{{ accessLink }}" target="_blank">{{ accessLink }}</a>
</p>
<button type="button"
class="button button--secondary button--small"
data-controller="clipboard"
data-clipboard-content-value="{{ accessLink }}"
data-clipboard-label-value="Kopiert!"
data-action="clipboard#copy">
{{ icon('copy', 'w-4 h-4') }} Link kopieren
</button>
<p class="text-xs text-gray-500 mt-1">
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
{% if accessLinkExpiresAt < date() %}
@@ -182,11 +206,24 @@
{% else %}
<p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p>
{% endif %}
<form method="post" action="{{ path('app_admin_accommodationbooking_generate_access_link', { 'id': booking.id }) }}" class="mt-2">
<button type="submit" class="button button--secondary button--small">
<div class="mt-2 flex items-center gap-2">
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_generate_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
{{ accessLink ? 'Link erneut generieren' : 'Link generieren' }}
</button>
</form>
{% if accessLink %}
<button type="button"
class="button button--secondary button--small"
hx-get="{{ path('app_admin_accommodationbooking_send_access_link', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Link senden
</button>
{% endif %}
</div>
</div>
<div class="flex justify-between pt-8">
@@ -75,7 +75,7 @@
{% endif %}
{% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
{% if priceBreakdown.basePrice > 0 %}
@@ -124,7 +124,7 @@
{% endif %}
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
@@ -52,12 +52,12 @@
</table>
{% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ (priceBreakdown.total / 100)|format_currency(currency) }}</strong></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
@@ -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) }}
<p class="mb-6">
Möchtest du dieses Angebot jetzt verbindlich buchen?
</p>
{{ 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) }}
<div class="mt-6 flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
<button type="submit" class="button button--primary">
Jetzt verbindlich buchen
</button>
</div>
{{ form_end(confirmationForm) }}
{% endblock %}
@@ -0,0 +1,204 @@
{% block booking_summary %}
<div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg"
id="booking-summary">
{# Header #}
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
{% if ctx.priceBreakdown is not null %}
<div class="flex items-center justify-between pb-2">
<span class="block uppercase font-semibold">Gesamtpreis</span>
<span class="font-semibold">{{ (displayTotal / 100)|format_currency(currency) }}</span>
</div>
{% endif %}
<div class="flex items-center justify-between">
<span class="block uppercase font-semibold">Übersicht</span>
<button type="button"
class="lg:hidden button button--small bg-primary-light"
data-action="toggle#toggle">
<svg class="w-4 h-4 text-white transition-transform"
data-toggle-target="icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 256 256">
<rect width="256" height="256" fill="none"/>
<polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/>
</svg>
</button>
</div>
</div>
{# Content #}
<div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto px-4 lg:px-8 py-8"
data-toggle-target="content">
{# Accommodation info #}
{% set hasImage = ctx.hotelCmsData is not null
and ctx.hotelCmsData.images is defined
and ctx.hotelCmsData.images.resized is defined
and ctx.hotelCmsData.images.resized.l is defined
and ctx.hotelCmsData.images.resized.l[0] is defined %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Unterkunft</div>
<div class="grid grid-cols-3 gap-x-4 p-2">
{% if hasImage %}
<img src="{{ ctx.hotelCmsData.images.resized.l[0].url }}"
alt="{{ ctx.hotelCmsData.images.resized.l[0].alt ?? ctx.accommodation.name }}"
class="block w-full h-auto">
{% endif %}
<div class="{{ hasImage ? 'col-span-2' : 'col-span-3' }}">
<span class="block font-semibold">{{ ctx.accommodation.name }}</span>
{% if ctx.hotelCmsData is not null and ctx.hotelCmsData.address is defined %}
<div class="text-sm text-gray-500 mt-1">{{ ctx.hotelCmsData.address|nl2br }}</div>
{% endif %}
</div>
</div>
</div>
{# Price breakdown #}
{% if ctx.priceBreakdown is not null %}
<div class="border border-primary-bg mt-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Preise</div>
<table class="w-full table-fixed text-sm">
<tr>
<td class="p-2">
Zeitraum
</td>
<td class="p-2 text-right">
{{ booking.dateFrom|date('d.m.Y') }} – {{ booking.dateTo|date('d.m.Y') }},
<br>
{{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }}
</td>
</tr>
<tr>
<td class="p-2">
Anzahl Personen
</td>
<td class="p-2 text-right">
{{ booking.paxCount }}
</td>
</tr>
{% if booking.minorsCount > 0 %}
<tr>
<td class="p-2">davon Kinder (0–3 J.)</td>
<td class="p-2 text-right">{{ booking.minorsCount }}</td>
</tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr>
<td class="p-2">davon Kinder (4–{{ ctx.accommodation.maxAdolescentAge }} J.)</td>
<td class="p-2 text-right">{{ booking.childrenCount }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.basePrice > 0 %}
<tr>
<td class="p-2">
Basispreis
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.includedPax }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.additionalPersonsPrice > 0 %}
<tr>
<td class="p-2">
Aufpreis Personenzahl
<p class="text-xs text-gray-500">für {{ ctx.priceBreakdown.effectivePax - ctx.priceBreakdown.includedPax }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.shortTermSurcharge > 0 %}
<tr>
<td class="p-2">Aufpreis Kurzzeit</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.boardPrice > 0 %}
<tr>
<td class="p-2">Verpflegung{% if booking.boardServiceLabel %} ({{ booking.boardServiceLabel }}){% endif %}</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if ctx.priceBreakdown.undersubscriptionSurcharge > 0 %}
<tr>
<td class="p-2">
Verpflegungs-Aufschlag
<p class="text-xs text-gray-500">für Gruppen unter {{ ctx.priceBreakdown.undersubscriptionThreshold }} Pers.</p>
</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% for service in ctx.priceBreakdown.serviceDetails %}
{% if service.price > 0 %}
<tr>
<td class="p-2">{{ service.label }}</td>
<td class="p-2 text-right whitespace-nowrap">{{ (service.price / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% endfor %}
{% if ctx.priceBreakdown.runningCosts > 0 %}
<tr>
<td class="p-2">Strom- und Abfallgebühren</td>
<td class="p-2 text-right whitespace-nowrap">{{ (ctx.priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-primary-bg font-semibold text-base">
<td class="p-2">
Gesamtpreis
<p class="text-xs font-bold">zzgl. ortsabhängiger Gebühren</p>
</td>
<td class="p-2 text-right whitespace-nowrap">
{{ (ctx.priceBreakdown.total / 100)|format_currency(currency) }}
</td>
</tr>
{% 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 = ((ctx.priceBreakdown.basePrice + ctx.priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round %}
<tr class="text-green-700">
<td class="p-2">Rabatt Unterkunft {{ booking.accommodationDiscount }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap">– {{ (accommodationDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.boardServiceDiscount is not null %}
{% set boardDiscountAmount = (ctx.priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round %}
<tr class="text-green-700">
<td class="p-2">Rabatt Verpflegung {{ booking.boardServiceDiscount }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap">– {{ (boardDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.additionalServicesDiscount is not null %}
{% set servicesDiscountAmount = (ctx.priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round %}
<tr class="text-green-700">
<td class="p-2">Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }}&nbsp;%</td>
<td class="p-2 text-right whitespace-nowrap">– {{ (servicesDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
<tr class="border-t border-primary-bg font-bold text-base text-green-700">
<td class="p-2">Gesamtpreis nach Rabatt</td>
<td class="p-2 text-right whitespace-nowrap">{{ ((booking.totalPrice ?? ctx.priceBreakdown.total) / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
</div>
{# Footer: booking action / accepted-at status #}
{% if booking.inquiry %}
<div class="shrink-0 px-4 lg:px-8 py-4 border-t border-primary-bg">
<button type="button"
class="button button--primary w-full"
hx-get="{{ path('app_groups_booking_offer_confirm', { uuid: booking.uuid }) }}"
hx-target="body"
hx-swap="beforeend">
Angebot verbindlich buchen
</button>
</div>
{% elseif booking.acceptedAt is not null %}
<div class="shrink-0 px-4 lg:px-8 py-4 border-t border-primary-bg">
<p class="text-gray-500">
Angenommen am {{ booking.acceptedAt | date('d.m.Y') }} um {{ booking.acceptedAt | date('H:i') }} Uhr
</p>
</div>
{% endif %}
</div>
{% endblock %}
@@ -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',
} %}
<nav class="pagination">
{% for step in 1..3 %}
{% for step in 1..4 %}
<div class="pagination-item">
{% if step < current_step %}
<a href="{{ path(step_routes[step]) }}"
@@ -7,9 +7,8 @@
{{ validation_alert(confirmationForm, 'Bitte akzeptiere die AGB, um die Buchung abzuschließen.', false) }}
{{ form_start(confirmationForm, {
'action': path('app_groups_booking_step_3_confirm'),
'action': path('app_groups_booking_step_4_confirm'),
'attr': {
'hx-post': path('app_groups_booking_step_3_confirm'),
'hx-target': '#htmx-modal',
'hx-swap': 'outerHTML',
}
@@ -17,8 +16,12 @@
{{ form_row(confirmationForm.termsAccepted) }}
<div class="mt-6 flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="submit" class="button button--primary">Buchung verbindlich absenden</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>Abbrechen</button>
<button type="submit" class="button button--primary">
Buchung verbindlich absenden
</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
</div>
{{ form_end(confirmationForm) }}
{% endblock %}
@@ -0,0 +1,18 @@
{% extends 'htmx_modal.html.twig' %}
{% block title %}Anfrage bestätigen{% endblock %}
{% block content %}
<p class="mb-6">Möchtest du diese unverbindliche Anfrage jetzt absenden?</p>
{{ form_start(inquiryForm, { 'action': path('app_groups_booking_step_4') }) }}
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
<button type="submit" class="button button--primary">
Jetzt anfragen
</button>
<button type="button" class="button button--secondary" {{ stimulus_action('modal', 'close') }}>
Abbrechen
</button>
</div>
{{ form_end(inquiryForm) }}
{% endblock %}
@@ -0,0 +1,31 @@
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Persönliche Daten</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-4 gap-y-2 p-2 text-sm">
<div>
<span class="text-gray-600 font-semibold">Gruppenname:</span>
{{ dto.groupName }}
</div>
<div>
<span class="text-gray-600 font-semibold">Name:</span>
{{ dto.salutation }} {{ dto.firstName }} {{ dto.lastName }}
</div>
<div>
<span class="text-gray-600 font-semibold">E-Mail:</span>
{{ dto.email }}
</div>
<div>
<span class="text-gray-600 font-semibold">Telefon:</span>
{{ dto.phone }}
</div>
<div class="md:col-span-2">
<span class="text-gray-600 font-semibold">Adresse:</span>
{{ dto.street }}, {{ dto.zip }} {{ dto.city }}
</div>
{% if dto.remarks %}
<div class="md:col-span-2">
<span class="text-gray-600 font-semibold">Anmerkungen:</span>
{{ dto.remarks }}
</div>
{% endif %}
</div>
</div>
@@ -0,0 +1,39 @@
{% set selectedBoardService = null %}
{% for service in ctx.boardServices %}
{% if service.id == dto.selectedBoardServiceId %}
{% set selectedBoardService = service %}
{% endif %}
{% endfor %}
{% set selectedAdditionalServices = ctx.additionalServices|filter(service => service.id in dto.selectedAdditionalServiceIds) %}
<div class="border border-primary-bg mb-4">
<div class="p-2 bg-primary-bg uppercase font-semibold">Ausgewählte Leistungen</div>
<div class="p-2 text-sm">
<div class="mb-2">
<span class="text-gray-600 font-semibold">Verpflegung:</span>
{% if selectedBoardService %}
{{ selectedBoardService.label }}
{% if selectedBoardService.description %}
<div class="text-xs text-gray-500">{{ selectedBoardService.description|nl2br }}</div>
{% endif %}
{% else %}
Selbstversorgung
{% endif %}
</div>
{% if selectedAdditionalServices is not empty %}
<div>
<span class="text-gray-600 font-semibold">Zusatzleistungen:</span>
<ul class="list-disc list-inside">
{% for service in selectedAdditionalServices %}
<li>
{{ service.label }}
— {{ (service.price / 100)|format_currency(ctx.accommodation.currency) }} {{ service.type.label|trans }}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
+177 -65
View File
@@ -1,82 +1,194 @@
{% extends 'layout.html.twig' %}
{% extends 'layout_booking.html.twig' %}
{% block title %}{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %}
{% block content %}
<div class="max-w-2xl w-full mx-auto p-8">
<h1 class="text-2xl font-bold mb-2">
{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}
</h1>
<p class="text-gray-500 mb-6">{{ booking.accommodation.name }}</p>
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
{% set displayTotal = booking.totalPrice ?? (priceBreakdown.total ?? null) %}
<div class="grid grid-cols-2 gap-y-2 text-sm mb-8">
<dt class="font-medium">Anreise</dt>
<dd>{{ booking.dateFrom | date('d.m.Y') }}</dd>
<dt class="font-medium">Abreise</dt>
<dd>{{ booking.dateTo | date('d.m.Y') }}</dd>
<dt class="font-medium">Nächte</dt>
<dd>{{ booking.nights }}</dd>
<dt class="font-medium">Personen</dt>
<dd>{{ booking.paxCount }}</dd>
{% if booking.boardServiceLabel %}
<dt class="font-medium">Verpflegung</dt>
<dd>{{ booking.boardServiceLabel }}</dd>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Sidebar summary #}
<div id="booking-summary-wrapper"
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/booking/_offer_summary.html.twig' with {
booking: booking,
ctx: ctx,
currency: currency,
displayTotal: displayTotal,
} %}
</div>
{# Main area #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0 overflow-y-auto px-4 lg:px-8 py-8">
<h1 class="text-2xl font-bold mb-2">
{{ booking.inquiry ? 'Dein Angebot' : 'Deine Buchung' }}
</h1>
<p class="text-gray-500 mb-6">{{ ctx.accommodation.name }}</p>
{# Booked services #}
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Gebuchte Leistungen</h2>
<ul class="text-sm list-disc list-inside">
{% if booking.boardServiceLabel %}
<li>{{ booking.boardServiceLabel }}</li>
{% endif %}
{% for service in booking.additionalServices %}
<li>{{ service.label }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
{% if ctx.hotelCmsData is not null %}
<div class="rte-content">
<h2>
Die Unterkunft
</h2>
{{ ctx.hotelCmsData.description | raw }}
<h2>
Details zur Unterkunft
</h2>
{{ ctx.hotelCmsData.features | raw }}
<h2>
Die Zimmer
</h2>
{{ ctx.hotelCmsData.roomTypes | raw }}
<h2>
Weitere Infos
</h2>
{{ ctx.hotelCmsData.additionalInformation | raw }}
<h2>
Bilder
</h2>
</div>
{% set largeImages = ctx.hotelCmsData.images.resized.l %}
{% set mediumImages = ctx.hotelCmsData.images.resized.m %}
{% if largeImages | length > 0 %}
<div class="grid md:grid-cols-2 gap-8" {{ stimulus_controller('gallery') }}>
{% for image in largeImages %}
<div class="relative block w-full h-auto cursor-pointer{{ loop.index0 >= 2 ? ' hidden' : '' }}"
data-gallery-target="item"
data-gallery-href="{{ image.url }}"
data-gallery-alt="{{ image.alt ?? ctx.accommodation.name }}">
<img src="{{ mediumImages[loop.index0].url ?? image.url }}"
alt="{{ image.alt ?? ctx.accommodation.name }}"
class="block w-full h-auto">
{% if loop.index0 == 1 and largeImages | length > 2 %}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white font-bold text-lg">
+{{ largeImages | length - 2 }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% set visibleIcons = (ctx.hotelCmsData.icons ?? null) | visible_cms_icons %}
{% if visibleIcons is not empty %}
<ul class="grid grid-cols-2 md:grid-cols-3 gap-2 mt-6 text-sm text-gray-600">
{% for item in visibleIcons %}
<li class="flex items-center gap-2">
{{ icon(item.key, 'w-5 h-5 shrink-0') }}
{{ item.label }}
</li>
{% endfor %}
</ul>
{% endif %}
{% endif %}
{% if ctx.hotelCmsData is not null and ctx.hotelCmsData.region is defined %}
{% set region = ctx.hotelCmsData.region %}
<div class="rte-content mt-8">
<h2>Die Region {{ region.name }}</h2>
{% if region.altitude or region.length or region.lifts %}
<ul class="flex flex-wrap gap-x-6 text-sm not-prose">
{% if region.altitude %}
<li>{{ region.altitude }} m Höhe</li>
{% endif %}
{% if region.length %}
<li>{{ region.length }} km Pisten</li>
{% endif %}
{% if region.lifts %}
<li>{{ region.lifts }} Lifte</li>
{% endif %}
</ul>
{% endif %}
{% if region.webcam %}
<p>
<a href="{{ region.webcam }}" target="_blank" rel="noopener">Webcam der Region</a>
</p>
{% endif %}
{{ region.description | default('') | raw }}
<h2>Skigebiet</h2>
{{ region.ski_area | default('') | raw }}
{{ region.ski_area_extended | default('') | raw }}
{% if region.news %}
<h2>Aktuelles aus der Region</h2>
{{ region.news | raw }}
{% endif %}
</div>
{% if region.region_maps is defined and region.region_maps | length > 0 %}
<div class="grid md:grid-cols-2 gap-8 mt-6" {{ stimulus_controller('gallery') }}>
{% for mapUrl in region.region_maps %}
<div class="relative block w-full h-auto cursor-pointer{{ loop.index0 >= 2 ? ' hidden' : '' }}"
data-gallery-target="item"
data-gallery-href="{{ mapUrl }}"
data-gallery-alt="{{ region.name }}">
<img src="{{ mapUrl }}" alt="{{ region.name }}" class="block w-full h-auto">
{% if loop.index0 == 1 and region.region_maps | length > 2 %}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white font-bold text-lg">
+{{ region.region_maps | length - 2 }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% set regionLargeImages = region.images.resized.l ?? [] %}
{% set regionMediumImages = region.images.resized.m ?? [] %}
{% if regionLargeImages | length > 0 %}
<div class="grid md:grid-cols-2 gap-8 mt-6" {{ stimulus_controller('gallery') }}>
{% for image in regionLargeImages %}
<div class="relative block w-full h-auto cursor-pointer{{ loop.index0 >= 2 ? ' hidden' : '' }}"
data-gallery-target="item"
data-gallery-href="{{ image.url }}"
data-gallery-alt="{{ image.alt ?? region.name }}">
<img src="{{ regionMediumImages[loop.index0].url ?? image.url }}"
alt="{{ image.alt ?? region.name }}"
class="block w-full h-auto">
{% if loop.index0 == 1 and regionLargeImages | length > 2 %}
<div class="absolute inset-0 bg-black/50 flex items-center justify-center text-white font-bold text-lg">
+{{ regionLargeImages | length - 2 }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
{% if booking.remarks %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Anmerkungen</h2>
<p class="text-sm">{{ booking.remarks | nl2br }}</p>
</div>
{% endif %}
</div>
{% if booking.additionalServices | length > 0 %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Zusatzleistungen</h2>
<ul class="text-sm list-disc list-inside">
{% for service in booking.additionalServices %}
<li>{{ service.label }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if priceBreakdown is not null %}
{% set currency = booking.accommodation.currency %}
<div class="mb-8">
<h2 class="text-lg font-bold mb-2">Preise</h2>
<table class="w-full text-sm">
<tr class="border-t border-gray-200 font-semibold">
<td class="pt-2">
Gesamtpreis
<span class="block text-xs font-normal text-gray-500">zzgl. ortsabhängiger Gebühren</span>
</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (priceBreakdown.total / 100) | format_currency(currency) }}</td>
</tr>
{% if booking.discount is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = priceBreakdown.total - discountAmount %}
<tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
</tr>
<tr class="border-t border-gray-200 font-bold text-green-700">
<td class="pt-2">Gesamtpreis nach Rabatt</td>
<td class="pt-2 text-right whitespace-nowrap">{{ (discountedTotal / 100) | format_currency(currency) }}</td>
</tr>
{% endif %}
</table>
</div>
{% endif %}
{% if booking.inquiry %}
<form method="post" action="{{ app.request.requestUri }}">
<button type="submit" class="button button--primary">
Angebot verbindlich buchen
</button>
</form>
{% else %}
{% if justAccepted %}
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 max-w-lg w-full z-30">
{% include '_partials/_alert.html.twig' with {
level: 'success',
title: 'Bestätigt',
messages: ['Deine Buchung ist verbindlich bestätigt.'],
} %}
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}
+3 -6
View File
@@ -96,16 +96,13 @@
Zurück
</a>
<div class="space-x-2">
<button type="submit" name="submitInquiry" class="button button--secondary">anfragen</button>
<button type="submit" name="submitBooking" class="{{ html_classes('button button--primary', { 'opacity-50': dto.isInquiry }) }}"{% if dto.isInquiry %} disabled{% endif %}>buchen</button>
<button type="submit" class="button button--primary">
weiter zu Schritt 4
</button>
</div>
</div>
</div>
{{ form_rest(form) }}
{{ form_end(form) }}
{% if confirmationForm is defined %}
{% include 'groups/booking/_step3_confirmation_modal.html.twig' with { confirmationForm: confirmationForm } %}
{% endif %}
{% endblock %}
+74
View File
@@ -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) #}
<div class="lg:hidden">
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 4 } %}
</div>
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
{# Static sidebar recap #}
<div class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
{% include 'groups/booking/_summary.html.twig' with { 'dto': dto, 'ctx': ctx } %}
</div>
{# Confirmation recap #}
<div class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
{% include '_partials/_flashes.html.twig' %}
{# Pagination - desktop only (fixed above scrollable content) #}
<div class="hidden lg:block shrink-0">
{% include 'groups/booking/_pagination.html.twig' with { 'current_step': 4 } %}
</div>
<div class="flex-1 overflow-y-auto px-4 lg:px-8 py-8">
<h2 class="pb-4">
Bitte bestätige deine Angaben
</h2>
{% 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 } %}
</div>
</div>
</div>
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
<div class="flex items-center justify-between">
<a href="{{ path('app_groups_booking_step_3') }}" class="button button--secondary">
Zurück
</a>
<div class="space-x-2">
<button type="button"
class="button button--secondary"
hx-get="{{ path('app_groups_booking_step_4_confirm_inquiry') }}"
hx-target="body"
hx-swap="beforeend">
anfragen
</button>
<button type="button"
class="{{ html_classes('button button--primary', { 'opacity-50': dto.isInquiry }) }}"
{% if dto.isInquiry %}disabled{% endif %}
hx-get="{{ path('app_groups_booking_step_4_confirm') }}"
hx-target="body"
hx-swap="beforeend">
Jetzt verbindlich buchen
</button>
</div>
</div>
</div>
{% endblock %}
+6 -1
View File
@@ -14,7 +14,12 @@
</div>
{% block content %}{% endblock %}
<div class="mt-6 flex items-center justify-between">
<button type="button" class="button button--warning" {% block button_attribute %}hx-post="{{ app.request.uri }}"{% endblock %}>
<button type="button"
class="button button--warning"
{% block button_attribute %}
hx-post="{{ app.request.uri }}"
hx-vals="{{ { _token: csrf_token(csrf_token_id) }|json_encode|e('html_attr') }}"
{% endblock %}>
{% block button_confirm %}Ja{% endblock %}
</button>
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
+3 -3
View File
@@ -1,8 +1,8 @@
<div id="htmx-modal"
class="fixed inset-0 w-full h-full z-50" {{ stimulus_controller('modal') }} {{ stimulus_action('modal', 'close', 'modal-close@window') }}>
<div class="absolute inset-0 w-full h-full backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute inset-0 w-full h-full bg-gray-300/50 backdrop-blur-sm" {{ stimulus_action('modal', 'close', 'click') }}></div>
<div class="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 px-4 w-full max-w-3xl">
<div class="relative bg-white py-8 rounded-md">
<div class="relative bg-white py-8 rounded-md shadow-lg">
<div class="flex justify-between pb-4 px-8">
<div class="text-2xl font-bold">
{% block title %}{% endblock %}
@@ -13,7 +13,7 @@
<svg class="w-8 h-8" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><line x1="200" y1="56" x2="56" y2="200" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/><line x1="200" y1="200" x2="56" y2="56" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
</button>
</div>
<div class="max-h-96 overflow-y-scroll">
<div class="max-h-svh overflow-y-auto">
<div class="px-8">
{% block content %}{% endblock %}
</div>
@@ -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(
@@ -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<mixed>|array<class-string, FormInterface<mixed>> $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;
}
@@ -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);
+356 -87
View File
@@ -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('<html>offer</html>', $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('<html>offer</html>', $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<string, mixed> */
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<string, mixed> $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;
@@ -0,0 +1,402 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Groups;
use App\Controller\Groups\Step4Controller;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
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\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 Step4ControllerTest extends TestCase
{
public function testIndexRendersRecapWhenStepFourReached(): void
{
$dto = new AccommodationBookingDto();
$dto->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<string, mixed>
*/
public array $renderedParameters = [];
public string $renderedView = '';
public function __construct(
AccommodationBookingService $bookingService,
AccommodationSessionManager $sessionManager,
private readonly FormInterface $form,
) {
parent::__construct($bookingService, $sessionManager);
}
/**
* @return FormInterface<mixed>
*/
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<string, mixed> $parameters
*/
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
{
return new RedirectResponse('/'.$route, $status);
}
/**
* @param array<string, mixed> $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<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
$this->renderedParameters = $parameters;
return new Response('ok');
}
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Tests\Model;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentPricesQuery;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Validation;
class ContingentQueryTest extends TestCase
{
public function testCalendarQueryAcceptsValidRange(): void
{
$query = new ContingentCalendarQuery('HOTEL_1', '2026-01-01', '2026-12-31');
self::assertCount(0, $this->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();
}
}
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\AccommodationBooking;
use App\Repository\Groups\AccommodationPriceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\GroupsPriceCalculator;
use App\Service\PriceTimelineBuilder;
use PHPUnit\Framework\TestCase;
class AccommodationBookingBreakdownCalculatorTest extends TestCase
{
public function testComputeReturnsStoredSnapshotWithoutLoadingCurrentPrices(): void
{
$snapshot = ['total' => 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));
}
}
@@ -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();
@@ -175,7 +175,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendCustomerConfirmationEmail($booking);
}
public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void
public function testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
@@ -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
*/