wip: group inquiries and booking

This commit is contained in:
Björn Fromme committed 2026-07-14 11:29:43 +02:00
1 parent 597b169b17
commit 732a0f1d19
33 files changed
+644 -95

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)
}
}
}
+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');
}
}
@@ -25,6 +25,10 @@ class DeleteController extends AbstractController
public function index(Accommodation $accommodation, Request $request): Response public function index(Accommodation $accommodation, Request $request): Response
{ {
if (true === $request->isMethod(Request::METHOD_POST)) { 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->remove($accommodation);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -40,6 +44,7 @@ class DeleteController extends AbstractController
return $this->render('admin/accommodation/modal_delete.html.twig', [ return $this->render('admin/accommodation/modal_delete.html.twig', [
'accommodation' => $accommodation, 'accommodation' => $accommodation,
'csrf_token_id' => 'delete_accommodation_'.$accommodation->getId(),
]); ]);
} }
} }
@@ -34,6 +34,7 @@ class CreateController extends AbstractController
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
$this->bookingService->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking); $this->entityManager->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -62,7 +62,7 @@ class EditController extends AbstractController
$currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId'); $currentIds = array_column($booking->getAdditionalServices(), 'originalServiceId');
$currentAdditionalServices = array_values(array_filter( $currentAdditionalServices = array_values(array_filter(
$additionalServices, $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->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Controller\Admin\AccommodationBooking; namespace App\Controller\Admin\AccommodationBooking;
use App\Entity\Groups\AccommodationBooking; use App\Entity\Groups\AccommodationBooking;
use App\Htmx\HxRedirectResponse;
use App\Service\AccommodationBookingService; use App\Service\AccommodationBookingService;
use Psr\Log\LoggerInterface; use Psr\Log\LoggerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
@@ -24,17 +25,24 @@ class GenerateAccessLinkController extends AbstractController
public function index(AccommodationBooking $booking, Request $request): Response public function index(AccommodationBooking $booking, Request $request): Response
{ {
if ($request->isMethod(Request::METHOD_POST)) { 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', [ $this->logger->info('Generated accommodation booking access link', [
'id' => $booking->getId(), '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(),
]);
} }
} }
@@ -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, private readonly LoggerInterface $logger,
) { ) {
} }
#[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')] #[Route('/admin/accommodation-price/{id}/delete', name: 'app_admin_accommodationprice_delete')]
public function index(AccommodationPrice $accommodationPrice, Request $request): Response public function index(AccommodationPrice $accommodationPrice, Request $request): Response
{ {
$accommodation = $accommodationPrice->getAccommodation(); $accommodation = $accommodationPrice->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) { 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->remove($accommodationPrice);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -37,7 +42,7 @@ class DeleteController extends AbstractController
'date_range' => [ 'date_range' => [
'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'), 'from' => $accommodationPrice->getDateFrom()->format('Y-m-d'),
'to' => $accommodationPrice->getDateTo()->format('Y-m-d'), 'to' => $accommodationPrice->getDateTo()->format('Y-m-d'),
] ],
]); ]);
$returnUrl = $this->generateUrl('app_admin_accommodation_edit', [ $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', [ return $this->render('admin/accommodation_price/modal_delete.html.twig', [
'accommodationPrice' => $accommodationPrice, 'accommodationPrice' => $accommodationPrice,
'accommodation' => $accommodation, 'accommodation' => $accommodation,
'csrf_token_id' => 'delete_accommodation_price_'.$accommodationPrice->getId(),
]); ]);
} }
} }
@@ -27,6 +27,10 @@ class DeleteController extends AbstractController
$accommodation = $additionalService->getAccommodation(); $accommodation = $additionalService->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) { 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->remove($additionalService);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -48,6 +52,7 @@ class DeleteController extends AbstractController
return $this->render('admin/additional_service/modal_delete.html.twig', [ return $this->render('admin/additional_service/modal_delete.html.twig', [
'additionalService' => $additionalService, 'additionalService' => $additionalService,
'accommodation' => $accommodation, 'accommodation' => $accommodation,
'csrf_token_id' => 'delete_additional_service_'.$additionalService->getId(),
]); ]);
} }
} }
@@ -27,6 +27,10 @@ class DeleteController extends AbstractController
$accommodation = $boardService->getAccommodation(); $accommodation = $boardService->getAccommodation();
if (true === $request->isMethod(Request::METHOD_POST)) { 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->remove($boardService);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -48,6 +52,7 @@ class DeleteController extends AbstractController
return $this->render('admin/board_service/modal_delete.html.twig', [ return $this->render('admin/board_service/modal_delete.html.twig', [
'boardService' => $boardService, 'boardService' => $boardService,
'accommodation' => $accommodation, '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 public function index(BookingEditDraft $draft, Request $request): Response
{ {
if (true === $request->isMethod(Request::METHOD_POST)) { 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->remove($draft);
$this->entityManager->flush(); $this->entityManager->flush();
@@ -43,6 +47,7 @@ class DeleteController extends AbstractController
return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [ return $this->render('admin/booking_edit_draft/modal_delete.html.twig', [
'draft' => $draft, '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\BpnConnect\Exception\BpnConnectException;
use App\Entity\Groups\AccommodationPrice; use App\Entity\Groups\AccommodationPrice;
use App\Enum\Groups\PriceType; use App\Enum\Groups\PriceType;
use App\Model\ContingentCalendarQuery;
use App\Model\ContingentPricesQuery;
use App\Repository\Groups\AccommodationPriceRepository; use App\Repository\Groups\AccommodationPriceRepository;
use App\Repository\Groups\AccommodationRepository; use App\Repository\Groups\AccommodationRepository;
use App\Service\PriceTimelineBuilder; use App\Service\PriceTimelineBuilder;
use Psr\Cache\InvalidArgumentException; use Psr\Cache\InvalidArgumentException;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse; use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryString;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Cache\CacheInterface; use Symfony\Contracts\Cache\CacheInterface;
@@ -35,30 +37,20 @@ class ContingentController extends AbstractController
} }
#[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])] #[Route(path: '/contingents/prices', name: 'api_contingents_prices', methods: ['GET'])]
public function prices(Request $request): JsonResponse public function prices(
{ #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
$hotelCode = $request->query->get('hotelCode'); ContingentPricesQuery $query,
$yearRaw = $request->query->get('year'); ): JsonResponse {
$accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
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]);
if (null === $accommodation) { if (null === $accommodation) {
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
} }
$year = (int) $yearRaw; $yearStart = new \DateTimeImmutable("{$query->year}-01-01");
$yearStart = new \DateTimeImmutable("{$year}-01-01"); $yearEnd = new \DateTimeImmutable("{$query->year}-12-31");
$yearEnd = new \DateTimeImmutable("{$year}-12-31");
$prices = $this->priceRepository->findByHotelCodeAndDateRange($hotelCode, $yearStart, $yearEnd); $prices = $this->priceRepository->findByHotelCodeAndDateRange($query->hotelCode, $yearStart, $yearEnd);
$currency = $accommodation->getCurrency(); $currency = $accommodation->getCurrency();
@@ -66,41 +58,31 @@ class ContingentController extends AbstractController
} }
#[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])] #[Route(path: '/contingents/calendar', name: 'api_contingents_calendar', methods: ['GET'])]
public function calendar(Request $request): JsonResponse public function calendar(
{ #[MapQueryString(validationFailedStatusCode: Response::HTTP_BAD_REQUEST)]
$hotelCode = $request->query->get('hotelCode'); ContingentCalendarQuery $query,
$dateFromRaw = $request->query->get('dateFrom'); ): JsonResponse {
$dateToRaw = $request->query->get('dateTo'); $dateFrom = $query->dateFromDate();
$dateTo = $query->dateToDate();
if (!$hotelCode || !$dateFromRaw || !$dateToRaw) { $accommodation = $this->accommodationRepository->findOneBy(['calendarCode' => $query->hotelCode]);
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]);
if (null === $accommodation) { if (null === $accommodation) {
return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST); return $this->json(['error' => 'hotel not found for hotelCode.'], Response::HTTP_BAD_REQUEST);
} }
try { try {
$cacheKey = sprintf('contingents_calendar_%s_%s_%s', $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 ($hotelCode, $dateFromRaw, $dateToRaw) { $calendar = $this->cache->get($cacheKey, function (ItemInterface $item) use ($query) {
$item->expiresAfter(3600); $item->expiresAfter(3600);
return $this->contingentsClient->getContingentCalendar($hotelCode, $dateFromRaw, $dateToRaw); return $this->contingentsClient->getContingentCalendar($query->hotelCode, $query->dateFrom, $query->dateTo);
}); });
} catch (BpnConnectException|InvalidArgumentException $e) { } catch (BpnConnectException|InvalidArgumentException $e) {
return $this->json(['error' => 'Failed to fetch contingent data.'], Response::HTTP_BAD_GATEWAY); 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(); $currency = $accommodation->getCurrency();
@@ -114,6 +96,7 @@ class ContingentController extends AbstractController
/** /**
* @param AccommodationPrice[] $prices * @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} * @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 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) // dateFrom and dateTo are both inclusive (last night, not checkout day)
$candidates = array_filter( $candidates = array_filter(
$prices, $prices,
fn($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day, fn ($p) => $p->getDateFrom() <= $day && $p->getDateTo() >= $day,
); );
$winner = $this->priceTimelineBuilder->resolveWinner($candidates); $winner = $this->priceTimelineBuilder->resolveWinner($candidates);
$defaultPrice = null; $defaultPrice = null;
if ($winner?->getType() === PriceType::DISCOUNT) { if (PriceType::DISCOUNT === $winner?->getType()) {
$defaults = array_filter($candidates, fn($p) => $p->getType() === null); $defaults = array_filter($candidates, fn ($p) => null === $p->getType());
$defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults); $defaultPrice = $this->priceTimelineBuilder->resolveWinner($defaults);
} }
@@ -138,14 +121,13 @@ class ContingentController extends AbstractController
'date' => $date, 'date' => $date,
'status' => $status, 'status' => $status,
'type' => $winner?->getType()?->value, 'type' => $winner?->getType()?->value,
'pricePerNight' => $winner !== null ? round($winner->getPricePerNight() / 100, 2) : null, 'pricePerNight' => null !== $winner ? round($winner->getPricePerNight() / 100, 2) : null,
'defaultPricePerNight' => $defaultPrice !== null ? round($defaultPrice->getPricePerNight() / 100, 2) : null, 'defaultPricePerNight' => null !== $defaultPrice ? round($defaultPrice->getPricePerNight() / 100, 2) : null,
'priceAdditionalPerson' => $winner !== null ? round($winner->getPriceAdditionalPerson() / 100, 2) : null, 'priceAdditionalPerson' => null !== $winner ? round($winner->getPriceAdditionalPerson() / 100, 2) : null,
'defaultPriceAdditionalPerson' => $defaultPrice !== null ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null, 'defaultPriceAdditionalPerson' => null !== $defaultPrice ? round($defaultPrice->getPriceAdditionalPerson() / 100, 2) : null,
'currency' => $currency, 'currency' => $currency,
'includedPax' => $winner?->getIncludedPax(), 'includedPax' => $winner?->getIncludedPax(),
'minNights' => $winner?->getMinNights(), 'minNights' => $winner?->getMinNights(),
]; ];
} }
} }
@@ -34,6 +34,10 @@ class OfferController extends AbstractController
} }
if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) { if ($request->isMethod(Request::METHOD_POST) && $booking->isInquiry()) {
if (!$this->isCsrfTokenValid('accept_accommodation_offer_'.$booking->getUuid(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
$this->bookingService->acceptBooking($booking); $this->bookingService->acceptBooking($booking);
return new RedirectResponse($request->getUri()); return new RedirectResponse($request->getUri());
+1 -1
View File
@@ -150,7 +150,7 @@ class Step3Controller extends AbstractAccommodationController
$booking = $this->bookingService->persist($dto, $accommodation, $prices, $services); $booking = $this->bookingService->persist($dto, $accommodation, $prices, $services);
$this->bookingService->issueAccessLinkForDirectBooking($booking); $this->bookingService->issueAccessLinkForDirectBooking($booking);
$this->bookingService->sendNotificationEmail($booking, $dto->priceBreakdown); $this->bookingService->sendNotificationEmail($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking); $this->bookingService->sendCustomerConfirmationEmail($booking);
$this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking'); $this->addFlash('groups_booking_result', $booking->isInquiry() ? 'inquiry' : 'booking');
$this->sessionManager->clear($request); $this->sessionManager->clear($request);
+59 -1
View File
@@ -10,9 +10,9 @@ use App\Entity\TimestampableEntity;
use App\Entity\TimestampableEntityInterface; use App\Entity\TimestampableEntityInterface;
use App\Enum\Groups\AdditionalServiceType; use App\Enum\Groups\AdditionalServiceType;
use App\Repository\Groups\AccommodationBookingRepository; use App\Repository\Groups\AccommodationBookingRepository;
use Symfony\Component\Uid\Uuid;
use Doctrine\DBAL\Types\Types; use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\Mapping as ORM; use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Validator\Constraints as Assert; use Symfony\Component\Validator\Constraints as Assert;
#[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)] #[ORM\Entity(repositoryClass: AccommodationBookingRepository::class)]
@@ -57,9 +57,23 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(nullable: true)] #[ORM\Column(nullable: true)]
private ?int $boardServiceOriginalId = null; private ?int $boardServiceOriginalId = null;
/** @var list<array{label: string, price: int, type: string, originalServiceId: int|null}> */
#[ORM\Column(type: Types::JSON)] #[ORM\Column(type: Types::JSON)]
private array $additionalServices = []; 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] #[ORM\Column]
private bool $isInquiry = false; private bool $isInquiry = false;
@@ -235,11 +249,13 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this; return $this;
} }
/** @return list<array{label: string, price: int, type: string, originalServiceId: int|null}> */
public function getAdditionalServices(): array public function getAdditionalServices(): array
{ {
return $this->additionalServices; return $this->additionalServices;
} }
/** @param list<array{label: string, price: int, type: string, originalServiceId: int|null}> $additionalServices */
public function setAdditionalServices(array $additionalServices): self public function setAdditionalServices(array $additionalServices): self
{ {
$this->additionalServices = $additionalServices; $this->additionalServices = $additionalServices;
@@ -259,6 +275,48 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this; 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 public function isInquiry(): bool
{ {
return $this->isInquiry; return $this->isInquiry;
@@ -61,6 +61,15 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?int $discount; public ?int $discount;
#[Groups(['api:single'])]
public ?int $totalPrice;
#[Groups(['api:single'])]
public ?string $pricingCurrency;
#[Groups(['api:single'])]
public ?int $pricingVersion;
/** /**
* @var array<string, mixed>|null * @var array<string, mixed>|null
*/ */
@@ -90,6 +99,9 @@ final readonly class AccommodationBookingApiResponse
$booking->getAdditionalServices(), $booking->getAdditionalServices(),
); );
$this->discount = $booking->getDiscount(); $this->discount = $booking->getDiscount();
$this->totalPrice = $booking->getTotalPrice();
$this->pricingCurrency = $booking->getPricingCurrency();
$this->pricingVersion = $booking->getPricingVersion();
$this->priceBreakdown = $priceBreakdown; $this->priceBreakdown = $priceBreakdown;
} }
} }
+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 * Returns the authoritative stored snapshot, falling back to current prices only for
* accommodation prices for the same date range. * bookings created before snapshot persistence was introduced.
* *
* @return array<string, mixed>|null null when no accommodation or dates are set * @return array<string, mixed>|null null when no accommodation or dates are set
*/ */
public function compute(AccommodationBooking $booking): ?array 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(); $accommodation = $booking->getAccommodation();
$dateFrom = $booking->getDateFrom(); $dateFrom = $booking->getDateFrom();
+38 -14
View File
@@ -22,6 +22,8 @@ use Psr\Log\LoggerInterface;
class AccommodationBookingService class AccommodationBookingService
{ {
private const int PRICING_VERSION = 1;
public function __construct( public function __construct(
private readonly AccommodationRepository $accommodationRepo, private readonly AccommodationRepository $accommodationRepo,
private readonly AccommodationPriceRepository $priceRepo, private readonly AccommodationPriceRepository $priceRepo,
@@ -187,7 +189,7 @@ class AccommodationBookingService
$candidates = array_values(array_filter( $candidates = array_values(array_filter(
$prices, $prices,
fn(AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay, fn (AccommodationPrice $p) => $p->getDateFrom() <= $firstDay && $p->getDateTo() >= $firstDay,
)); ));
$winner = $this->priceTimelineBuilder->resolveWinner($candidates); $winner = $this->priceTimelineBuilder->resolveWinner($candidates);
@@ -224,14 +226,14 @@ class AccommodationBookingService
* Board and additional services are stored as frozen snapshots — independent of * Board and additional services are stored as frozen snapshots — independent of
* the catalog entities so that future catalog edits do not affect existing bookings. * 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 * @param array{additionalServices: AdditionalService[], boardServices: mixed[]} $services
*/ */
public function persist( public function persist(
AccommodationBookingDto $dto, AccommodationBookingDto $dto,
Accommodation $accommodation, Accommodation $accommodation,
array $prices, array $prices,
array $services, array $services,
): AccommodationBooking { ): AccommodationBooking {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setAccommodation($accommodation); $booking->setAccommodation($accommodation);
@@ -281,20 +283,43 @@ class AccommodationBookingService
$booking->setCity($dto->city); $booking->setCity($dto->city);
$booking->setRemarks($dto->remarks); $booking->setRemarks($dto->remarks);
$this->refreshPriceSnapshot($booking);
$this->entityManager->persist($booking); $this->entityManager->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
return $booking; return $booking;
} }
/** @param array<string, mixed> $priceBreakdown */ public function refreshPriceSnapshot(AccommodationBooking $booking): void
public function sendNotificationEmail(AccommodationBooking $booking, array $priceBreakdown = []): void {
$breakdown = $this->breakdownCalculator->computeCurrent($booking);
if (null === $breakdown) {
$booking->clearPriceSnapshot();
return;
}
$total = (int) ($breakdown['total'] ?? 0);
if (null !== $booking->getDiscount()) {
$total -= (int) round($total * $booking->getDiscount() / 100);
}
$booking->setPriceSnapshot(
$breakdown,
$total,
(string) ($breakdown['currency'] ?? $booking->getAccommodation()?->getCurrency() ?? 'EUR'),
self::PRICING_VERSION,
);
}
public function sendNotificationEmail(AccommodationBooking $booking): void
{ {
try { try {
$this->mailer->createAndSendEmail( $this->mailer->createAndSendEmail(
[ [
'booking' => $booking, 'booking' => $booking,
'priceBreakdown' => $priceBreakdown, 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [],
'accessLink' => $this->accessLinkOrNull($booking), 'accessLink' => $this->accessLinkOrNull($booking),
], ],
[ [
@@ -362,15 +387,14 @@ class AccommodationBookingService
} }
/** /**
* Explicit admin action: (re)issues the access link — invalidating any previously issued * 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. * 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()); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$this->entityManager->flush(); $this->entityManager->flush();
$this->sendCustomerConfirmationEmail($booking);
} }
/** /**
@@ -421,7 +445,7 @@ class AccommodationBookingService
$prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo); $prices = $this->priceRepo->findByHotelCodeAndDateRange($hotelCode, $dateFrom, $dateTo);
$candidates = array_values(array_filter( $candidates = array_values(array_filter(
$prices, $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; return $this->priceTimelineBuilder->resolveWinner($candidates)?->getIncludedPax() ?? 1;
@@ -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 %}
@@ -77,7 +77,7 @@
{% if priceBreakdown is not null %} {% if priceBreakdown is not null %}
<div> <div>
<h2 class="text-lg font-bold mb-4">Preise</h2> <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"> <table class="w-full text-sm">
{% if priceBreakdown.basePrice > 0 %} {% if priceBreakdown.basePrice > 0 %}
<tr> <tr>
@@ -138,7 +138,7 @@
</tr> </tr>
{% if booking.discount is not null %} {% if booking.discount is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = priceBreakdown.total - discountAmount %} {% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %}
<tr class="text-green-700"> <tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td> <td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td> <td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
@@ -170,9 +170,14 @@
<div class="mt-6 border-t border-gray-200 pt-6"> <div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Zugangslink</h2> <h2 class="text-lg font-bold mb-2">Zugangslink</h2>
{% if accessLink %} {% if accessLink %}
<p class="text-sm break-all"> <button type="button"
<a href="{{ accessLink }}" target="_blank">{{ accessLink }}</a> class="button button--secondary button--small"
</p> 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"> <p class="text-xs text-gray-500 mt-1">
Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }} Gültig bis {{ accessLinkExpiresAt | date('d.m.Y') }}
{% if accessLinkExpiresAt < date() %} {% if accessLinkExpiresAt < date() %}
@@ -182,11 +187,24 @@
{% else %} {% else %}
<p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p> <p class="text-sm text-gray-500">Es wurde noch kein Zugangslink generiert.</p>
{% endif %} {% endif %}
<form method="post" action="{{ path('app_admin_accommodationbooking_generate_access_link', { 'id': booking.id }) }}" class="mt-2"> <div class="mt-2 flex items-center gap-2">
<button type="submit" class="button button--secondary button--small"> <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' }} {{ accessLink ? 'Link erneut generieren' : 'Link generieren' }}
</button> </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>
<div class="flex justify-between pt-8"> <div class="flex justify-between pt-8">
@@ -75,7 +75,7 @@
{% endif %} {% endif %}
{% if priceBreakdown is not empty %} {% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %} {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2> <h2>Preise</h2>
<table> <table>
{% if priceBreakdown.basePrice > 0 %} {% if priceBreakdown.basePrice > 0 %}
@@ -124,7 +124,7 @@
{% endif %} {% endif %}
<tr> <tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td> <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> </tr>
</table> </table>
{% endif %} {% endif %}
@@ -52,12 +52,12 @@
</table> </table>
{% if priceBreakdown is not empty %} {% if priceBreakdown is not empty %}
{% set currency = priceBreakdown.currency %} {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2> <h2>Preise</h2>
<table> <table>
<tr> <tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td> <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> </tr>
</table> </table>
{% endif %} {% endif %}
+5 -2
View File
@@ -38,7 +38,7 @@
{% endif %} {% endif %}
{% if priceBreakdown is not null %} {% if priceBreakdown is not null %}
{% set currency = booking.accommodation.currency %} {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<div class="mb-8"> <div class="mb-8">
<h2 class="text-lg font-bold mb-2">Preise</h2> <h2 class="text-lg font-bold mb-2">Preise</h2>
<table class="w-full text-sm"> <table class="w-full text-sm">
@@ -51,7 +51,7 @@
</tr> </tr>
{% if booking.discount is not null %} {% if booking.discount is not null %}
{% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %} {% set discountAmount = (priceBreakdown.total * booking.discount / 100) | round | number_format(0, '.', '') %}
{% set discountedTotal = priceBreakdown.total - discountAmount %} {% set discountedTotal = booking.totalPrice ?? (priceBreakdown.total - discountAmount) %}
<tr class="text-green-700"> <tr class="text-green-700">
<td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td> <td class="pt-1">Rabatt {{ booking.discount }}&nbsp;%</td>
<td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td> <td class="pt-1 text-right whitespace-nowrap">– {{ (discountAmount / 100) | format_currency(currency) }}</td>
@@ -67,6 +67,9 @@
{% if booking.inquiry %} {% if booking.inquiry %}
<form method="post" action="{{ app.request.requestUri }}"> <form method="post" action="{{ app.request.requestUri }}">
<input type="hidden"
name="_token"
value="{{ csrf_token('accept_accommodation_offer_' ~ booking.uuid) }}">
<button type="submit" class="button button--primary"> <button type="submit" class="button button--primary">
Angebot verbindlich buchen Angebot verbindlich buchen
</button> </button>
+6 -1
View File
@@ -14,7 +14,12 @@
</div> </div>
{% block content %}{% endblock %} {% block content %}{% endblock %}
<div class="mt-6 flex items-center justify-between"> <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 %} {% block button_confirm %}Ja{% endblock %}
</button> </button>
<button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}> <button type="button" class="button button--neutral" {{ stimulus_action('modal', 'close') }}>
@@ -79,7 +79,7 @@ class Step2ControllerTest extends TestCase
$bookingService $bookingService
->expects(self::once()) ->expects(self::once())
->method('computeInquiryStatus') ->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)'])); ->willReturn(new InquiryStatus(true, ['Mindestaufenthalt: 5 Nächte (gebucht: 4)']));
$controller = new TestableAccommodationStep2Controller( $controller = new TestableAccommodationStep2Controller(
@@ -51,7 +51,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setBoardServiceLabel('Halbpension'); $booking->setBoardServiceLabel('Halbpension');
$booking->setBoardServicePrice(1500); $booking->setBoardServicePrice(1500);
$booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3); $booking->addAdditionalServiceSnapshot('Bettwäsche', 500, 'flat', 3);
$booking->setDiscount(1000); $booking->setDiscount(10);
$booking->setIsInquiry(false); $booking->setIsInquiry(false);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
@@ -63,6 +63,7 @@ class AccommodationBookingControllerTest extends TestCase
->willReturn($booking); ->willReturn($booking);
$breakdown = ['total' => 12345, 'currency' => 'EUR']; $breakdown = ['total' => 12345, 'currency' => 'EUR'];
$booking->setPriceSnapshot($breakdown, 11111, 'EUR', 1);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class); $breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator $breakdownCalculator
->expects(self::once()) ->expects(self::once())
@@ -116,7 +117,10 @@ class AccommodationBookingControllerTest extends TestCase
'type' => 'flat', 'type' => 'flat',
], ],
], ],
'discount' => 1000, 'discount' => 10,
'totalPrice' => 11111,
'pricingCurrency' => 'EUR',
'pricingVersion' => 1,
'priceBreakdown' => $breakdown, 'priceBreakdown' => $breakdown,
], $payload); ], $payload);
@@ -57,18 +57,54 @@ class OfferControllerTest extends TestCase
$bookingService = $this->createMock(AccommodationBookingService::class); $bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('acceptBooking')->with($booking); $bookingService->expects(self::once())->method('acceptBooking')->with($booking);
$controller = new OfferController( $controller = new TestableOfferController(
$bookingRepository, $bookingRepository,
$linkSigner, $linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class), $this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService, $bookingService,
); );
$request = Request::create('/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc', 'POST'); $request = Request::create(
'/groups/booking/offer/'.$booking->getUuid().'?t=123&_hash=abc',
'POST',
['_token' => 'valid-token'],
);
$response = $controller->index($booking->getUuid(), $request); $response = $controller->index($booking->getUuid(), $request);
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
self::assertSame($request->getUri(), $response->headers->get('Location')); self::assertSame($request->getUri(), $response->headers->get('Location'));
self::assertSame('accept_accommodation_offer_'.$booking->getUuid(), $controller->csrfTokenId);
self::assertSame('valid-token', $controller->csrfToken);
}
public function testPostRejectsInvalidCsrfToken(): 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::never())->method('acceptBooking');
$controller = new TestableOfferController(
$bookingRepository,
$linkSigner,
$this->createMock(AccommodationBookingBreakdownCalculator::class),
$bookingService,
);
$controller->csrfValid = false;
$this->expectException(\Symfony\Component\Security\Core\Exception\AccessDeniedException::class);
$controller->index(
$booking->getUuid(),
Request::create('/groups/booking/offer/'.$booking->getUuid(), 'POST', ['_token' => 'invalid']),
);
} }
public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void public function testGetRendersConfirmationWithoutAcceptFormForBooking(): void
@@ -146,11 +182,22 @@ class OfferControllerTest extends TestCase
final class TestableOfferController extends OfferController final class TestableOfferController extends OfferController
{ {
public bool $csrfValid = true;
public ?string $csrfTokenId = null;
public ?string $csrfToken = null;
public ?string $renderedView = null; public ?string $renderedView = null;
/** @var array<string, mixed> */ /** @var array<string, mixed> */
public array $renderedParameters = []; public array $renderedParameters = [];
protected function isCsrfTokenValid(string $id, ?string $token): bool
{
$this->csrfTokenId = $id;
$this->csrfToken = $token;
return $this->csrfValid;
}
protected function render(string $view, array $parameters = [], ?Response $response = null): Response protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{ {
$this->renderedView = $view; $this->renderedView = $view;
+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));
}
}
@@ -175,7 +175,7 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendCustomerConfirmationEmail($booking); $service->sendCustomerConfirmationEmail($booking);
} }
public function testRegenerateAccessLinkAndNotifyCustomerAlwaysOverwritesAndSends(): void public function testRegenerateAccessLinkOverwritesAccessLinkIssuedAt(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setEmail('[email protected]'); $booking->setEmail('[email protected]');
@@ -190,7 +190,7 @@ class AccommodationBookingServiceTest extends TestCase
$breakdownCalculator->method('compute')->willReturn(null); $breakdownCalculator->method('compute')->willReturn(null);
$mailer = $this->createMock(Mailer::class); $mailer = $this->createMock(Mailer::class);
$mailer->expects(self::once())->method('createAndSendEmail'); $mailer->expects(self::never())->method('createAndSendEmail');
$service = $this->createServiceWithAccommodation( $service = $this->createServiceWithAccommodation(
entityManager: $entityManager, entityManager: $entityManager,
@@ -198,7 +198,7 @@ class AccommodationBookingServiceTest extends TestCase
breakdownCalculator: $breakdownCalculator, breakdownCalculator: $breakdownCalculator,
); );
$service->regenerateAccessLinkAndNotifyCustomer($booking); $service->regenerateAccessLink($booking);
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
} }
@@ -234,6 +234,27 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNull($booking->getAcceptedAt()); self::assertNull($booking->getAcceptedAt());
} }
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void
{
$booking = new AccommodationBooking();
$booking->setDiscount(10);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator
->expects(self::once())
->method('computeCurrent')
->with($booking)
->willReturn(['total' => 12345, 'currency' => 'CHF']);
$service = $this->createServiceWithAccommodation(breakdownCalculator: $breakdownCalculator);
$service->refreshPriceSnapshot($booking);
self::assertSame(['total' => 12345, 'currency' => 'CHF'], $booking->getPriceBreakdown());
self::assertSame(11110, $booking->getTotalPrice());
self::assertSame('CHF', $booking->getPricingCurrency());
self::assertSame(1, $booking->getPricingVersion());
}
/** /**
* @param AccommodationPrice[] $prices * @param AccommodationPrice[] $prices
*/ */