feat: additional confirmation cycle and status for groups inquiries

This commit is contained in:
Björn Fromme
2026-08-18 12:48:17 +02:00
parent 5045846eed
commit 8dee65d0dc
28 changed files with 677 additions and 95 deletions
+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 Version20260818103004 extends AbstractMigration
{
public function getDescription(): string
{
return 'Record when the office confirmed an accommodation booking, separately from when the customer committed to it';
}
public function up(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking ADD confirmed_at DATETIME DEFAULT NULL COMMENT \'(DC2Type:datetime_immutable)\'');
}
public function down(Schema $schema): void
{
$this->addSql('ALTER TABLE accommodation_booking DROP confirmed_at');
}
}
@@ -0,0 +1,64 @@
<?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;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted('ROLE_GROUPS_MANAGER')]
class ConfirmController extends AbstractController
{
public function __construct(
private readonly AccommodationBookingService $bookingService,
private readonly LoggerInterface $logger,
) {
}
#[Route('/admin/accommodation-booking/{id}/confirm', name: 'app_admin_accommodationbooking_confirm')]
public function index(AccommodationBooking $booking, Request $request): Response
{
// Only a booking awaiting validation can be confirmed — everything else has either
// not been committed to by the customer yet or is already dealt with.
if (!$booking->isReceived()) {
return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]);
}
if ($request->isMethod(Request::METHOD_POST)) {
if (!$this->isCsrfTokenValid('confirm_accommodation_booking_'.$booking->getId(), $request->request->getString('_token'))) {
throw $this->createAccessDeniedException('Invalid CSRF token.');
}
// Confirming is what makes the booking binding for the customer, so it must not
// happen when the confirmation itself cannot be delivered.
if (null === $booking->getEmail() || '' === trim($booking->getEmail())) {
$this->addFlash('error', 'Für diese Buchung ist keine E-Mail-Adresse hinterlegt. Bitte ergänze sie, um die Buchung zu bestätigen.');
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_edit', ['id' => $booking->getId()]));
}
$this->bookingService->confirmBooking($booking);
$this->addFlash('success', 'Die Buchung wurde bestätigt und dem Kunden per E-Mail zugestellt.');
$this->logger->info('Confirmed accommodation booking', [
'id' => $booking->getId(),
]);
return new HxRedirectResponse($this->generateUrl('app_admin_accommodationbooking_show', ['id' => $booking->getId()]));
}
return $this->render('admin/accommodation_booking/modal_confirm.html.twig', [
'booking' => $booking,
'csrf_token_id' => 'confirm_accommodation_booking_'.$booking->getId(),
]);
}
}
@@ -128,9 +128,11 @@ class EditController extends AbstractController
$this->entityManager->persist($booking); $this->entityManager->persist($booking);
$this->entityManager->flush(); $this->entityManager->flush();
// Entering Offen or Bestätigt is what the customer needs to hear about: the offer // Entering Offen is what the customer needs to hear about: the offer becomes
// becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent. // viewable. Every other status stays silent here — in particular Bestätigt, which
$notifiableStatuses = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted]; // is only ever reached through the explicit confirm action so that the binding
// confirmation is never sent as a side effect of editing.
$notifiableStatuses = [AccommodationBookingStatus::Open];
if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) { if ($previousStatus !== $booking->getStatus() && in_array($booking->getStatus(), $notifiableStatuses, true)) {
$this->bookingService->issueAccessLink($booking); $this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking); $this->bookingService->sendCustomerConfirmationEmail($booking);
+1 -1
View File
@@ -108,7 +108,7 @@ class OfferController extends AbstractController
// The textarea is prefilled, so the submitted value is the complete remark — // The textarea is prefilled, so the submitted value is the complete remark —
// an emptied field arrives as null and must clear the stored one, not keep it. // an emptied field arrives as null and must clear the stored one, not keep it.
$this->bookingService->acceptBooking($booking, $dto->remarks ?? ''); $this->bookingService->acceptBooking($booking, $dto->remarks ?? '');
$this->addFlash('success', 'Deine Buchung ist bestätigt.'); $this->addFlash('success', 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.');
return $this->redirectToOfferPage($request, $uuid); return $this->redirectToOfferPage($request, $uuid);
} }
+27 -2
View File
@@ -94,6 +94,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)] #[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $acceptedAt = null; private ?\DateTimeImmutable $acceptedAt = null;
/**
* When the office validated the requested services and capacities and released the
* booking confirmation to the customer — deliberately distinct from acceptedAt, which
* only records that the customer committed.
*/
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
private ?\DateTimeImmutable $confirmedAt = null;
#[ORM\Column(length: 255, nullable: true)] #[ORM\Column(length: 255, nullable: true)]
#[Assert\NotBlank(groups: ['edit'])] #[Assert\NotBlank(groups: ['edit'])]
private ?string $groupName = null; private ?string $groupName = null;
@@ -373,9 +381,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return AccommodationBookingStatus::Open === $this->status; return AccommodationBookingStatus::Open === $this->status;
} }
public function isAccepted(): bool public function isReceived(): bool
{ {
return AccommodationBookingStatus::Accepted === $this->status; return AccommodationBookingStatus::Received === $this->status;
}
public function isConfirmed(): bool
{
return AccommodationBookingStatus::Confirmed === $this->status;
} }
public function isDiscarded(): bool public function isDiscarded(): bool
@@ -429,6 +442,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this; return $this;
} }
public function getConfirmedAt(): ?\DateTimeImmutable
{
return $this->confirmedAt;
}
public function setConfirmedAt(?\DateTimeImmutable $confirmedAt): self
{
$this->confirmedAt = $confirmedAt;
return $this;
}
public function getGroupName(): ?string public function getGroupName(): ?string
{ {
return $this->groupName; return $this->groupName;
@@ -6,7 +6,8 @@ enum AccommodationBookingStatus: string
{ {
case Draft = 'draft'; case Draft = 'draft';
case Open = 'open'; case Open = 'open';
case Accepted = 'accepted'; case Received = 'received';
case Confirmed = 'confirmed';
case Discarded = 'discarded'; case Discarded = 'discarded';
public function label(): string public function label(): string
@@ -14,7 +15,8 @@ enum AccommodationBookingStatus: string
return match ($this) { return match ($this) {
self::Draft => 'Entwurf', self::Draft => 'Entwurf',
self::Open => 'Offen', self::Open => 'Offen',
self::Accepted => 'Bestätigt', self::Received => 'Eingegangen',
self::Confirmed => 'Bestätigt',
self::Discarded => 'Absage', self::Discarded => 'Absage',
}; };
} }
@@ -38,14 +38,18 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/** /**
* The list as it presents itself to someone who has not filtered yet. * The list as it presents itself to someone who has not filtered yet.
* *
* The still-live statuses — drafts and open bookings, i.e. everything that may still need * The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e.
* work — nothing whose stay is already over, and, for staff who are not group admins, only * everything that may still need work — nothing whose stay is already over, and, for
* the bookings they are responsible for. * staff who are not group admins, only the bookings they are responsible for.
*/ */
public static function defaults(bool $seesAllBookings, ?User $currentUser): self public static function defaults(bool $seesAllBookings, ?User $currentUser): self
{ {
$filter = new self(); $filter = new self();
$filter->status = [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open]; $filter->status = [
AccommodationBookingStatus::Draft,
AccommodationBookingStatus::Open,
AccommodationBookingStatus::Received,
];
$filter->dateFrom = new \DateTimeImmutable('today'); $filter->dateFrom = new \DateTimeImmutable('today');
if (!$seesAllBookings) { if (!$seesAllBookings) {
@@ -46,6 +46,9 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])] #[Groups(['api:single'])]
public ?\DateTimeImmutable $acceptedAt; public ?\DateTimeImmutable $acceptedAt;
#[Groups(['api:single'])]
public ?\DateTimeImmutable $confirmedAt;
#[Groups(['api:single'])] #[Groups(['api:single'])]
public AccommodationBookingPersonalData $personalData; public AccommodationBookingPersonalData $personalData;
@@ -101,6 +104,7 @@ final readonly class AccommodationBookingApiResponse
$this->childrenCount = $booking->getChildrenCount(); $this->childrenCount = $booking->getChildrenCount();
$this->groupName = $booking->getGroupName(); $this->groupName = $booking->getGroupName();
$this->acceptedAt = $booking->getAcceptedAt(); $this->acceptedAt = $booking->getAcceptedAt();
$this->confirmedAt = $booking->getConfirmedAt();
$this->personalData = new AccommodationBookingPersonalData($booking); $this->personalData = new AccommodationBookingPersonalData($booking);
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation()); $this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
$this->boardService = new AccommodationBookingBoardServiceInfo($booking); $this->boardService = new AccommodationBookingBoardServiceInfo($booking);
+49 -17
View File
@@ -267,10 +267,14 @@ class AccommodationBookingService
$booking->setMinorsCount($dto->minorsCount); $booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount); $booking->setChildrenCount($dto->childrenCount);
// An inquiry arrives live — the office has nothing to prepare first, so it goes // An inquiry arrives live — the office has nothing to prepare first, so it goes
// straight to Offen. A direct booking is binding on arrival, hence Bestätigt. // straight to Offen. A direct booking has been committed to by the customer but
// still awaits the office's validation, hence Eingegangen rather than Bestätigt.
$isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry; $isInquiry = $dto->forceInquiry || $this->computeInquiryStatus($dto, $prices)->isInquiry;
$booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking); $booking->setType($isInquiry ? AccommodationBookingType::Inquiry : AccommodationBookingType::Booking);
$booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Accepted); $booking->setStatus($isInquiry ? AccommodationBookingStatus::Open : AccommodationBookingStatus::Received);
if (!$isInquiry) {
$booking->setAcceptedAt(new \DateTimeImmutable());
}
// Freeze board service as scalar fields (no FK) // Freeze board service as scalar fields (no FK)
if (null !== $dto->selectedBoardServiceId) { if (null !== $dto->selectedBoardServiceId) {
@@ -333,7 +337,12 @@ class AccommodationBookingService
$booking = $this->persist($dto, $accommodation, $prices, $services); $booking = $this->persist($dto, $accommodation, $prices, $services);
$this->issueAccessLinkForDirectBooking($booking); $this->issueAccessLinkForDirectBooking($booking);
$this->sendNotificationEmail($booking); $this->sendNotificationEmail($booking);
$this->sendCustomerConfirmationEmail($booking); // A direct booking gets no customer mail here: it only reaches the customer once the
// office has validated it and confirmed via confirmBooking(). Acknowledging an
// inquiry is a different matter — it promises nothing, so it still goes out.
if ($booking->isInquiry()) {
$this->sendCustomerConfirmationEmail($booking);
}
return $booking; return $booking;
} }
@@ -409,11 +418,15 @@ class AccommodationBookingService
return; return;
} }
$breakdown = $this->breakdownCalculator->compute($booking);
try { try {
$this->mailer->createAndSendEmail( $this->mailer->createAndSendEmail(
[ [
'booking' => $booking, 'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking), 'accessLink' => $this->accessLinkOrNull($booking),
'priceBreakdown' => $breakdown,
'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'] ?? null,
], ],
[ [
'from' => $this->accommodationEmail, 'from' => $this->accommodationEmail,
@@ -467,13 +480,9 @@ class AccommodationBookingService
*/ */
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{ {
if ($booking->isAccepted()) { $subject = null !== $booking->getAccessLinkIssuedAt()
$subject = 'Deine Buchung ist bestätigt'; ? 'Dein Angebot ist bereit'
} elseif (null !== $booking->getAccessLinkIssuedAt()) { : 'Deine Anfrage ist bei uns eingegangen';
$subject = 'Dein Angebot ist bereit';
} else {
$subject = 'Deine Anfrage ist bei uns eingegangen';
}
$this->sendBookingEmail( $this->sendBookingEmail(
$booking, $booking,
@@ -496,11 +505,14 @@ class AccommodationBookingService
} }
/** /**
* Accepts an open offer and notifies office and customer. The type stays Anfrage — * Accepts an open offer and notifies the office. The type stays Anfrage —
* only the status moves to Bestätigt, so an offer-originated booking remains * only the status moves to Eingegangen, so an offer-originated booking remains
* distinguishable from a direct one. * distinguishable from a direct one.
* Idempotent — a no-op (including no emails) if the offer is not open any more. * Idempotent — a no-op (including no emails) if the offer is not open any more.
* *
* The customer deliberately gets no mail here: nothing is confirmed until the office
* has validated the booking and released it via confirmBooking().
*
* @param ?string $remarks null leaves the stored remark untouched, a string replaces it, * @param ?string $remarks null leaves the stored remark untouched, a string replaces it,
* an empty string clears it * an empty string clears it
*/ */
@@ -515,12 +527,32 @@ class AccommodationBookingService
$booking->setRemarks('' === $trimmed ? null : $trimmed); $booking->setRemarks('' === $trimmed ? null : $trimmed);
} }
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setAcceptedAt(new \DateTimeImmutable()); $booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush(); $this->entityManager->flush();
$this->sendOfferAcceptedNotificationEmail($booking); $this->sendOfferAcceptedNotificationEmail($booking);
$this->sendOfferAcceptedCustomerEmail($booking); }
/**
* Explicit office action after the requested services and capacities have been validated:
* this is the moment the booking becomes binding for the customer, and the only place the
* booking confirmation email is sent.
* Idempotent — a no-op (including no email) for anything that is not awaiting validation.
*/
public function confirmBooking(AccommodationBooking $booking): void
{
if (!$booking->isReceived()) {
return;
}
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setConfirmedAt(new \DateTimeImmutable());
$this->entityManager->flush();
// The confirmation links to the booking view, so make sure a link exists.
$this->issueAccessLink($booking);
$this->sendBookingConfirmedCustomerEmail($booking);
} }
public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void
@@ -534,14 +566,14 @@ class AccommodationBookingService
); );
} }
public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void public function sendBookingConfirmedCustomerEmail(AccommodationBooking $booking): void
{ {
$this->sendBookingEmail( $this->sendBookingEmail(
$booking, $booking,
$booking->getEmail(), $booking->getEmail(),
'Deine Buchung ist bestätigt', 'Deine Buchung ist bestätigt',
'email/offer_accepted_customer.html.twig', 'email/booking_confirmed_customer.html.twig',
'Failed to send offer accepted customer email', 'Failed to send booking confirmed customer email',
); );
} }
@@ -0,0 +1,5 @@
{#- Offen covers two situations that need different office action, told apart by the access
link: no link yet means the offer still has to be prepared, a link means the customer is
the one who has to act. Display only — both are the same status. -#}
{{- booking.status.label -}}
{%- if booking.open %} · {{ booking.accessLinkIssuedAt is not null ? 'Angebot versendet' : 'Angebot ausstehend' }}{% endif -%}
@@ -47,6 +47,11 @@
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }} Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
</p> </p>
{% endif %} {% endif %}
{% if booking.confirmedAt is not null %}
<p class="lg:col-span-2 text-xs text-gray-500">
Bestätigt am {{ booking.confirmedAt | date('d.m.Y, H:i') }}
</p>
{% endif %}
{% if form.managedBy is defined %} {% if form.managedBy is defined %}
<div class="lg:col-span-2"> <div class="lg:col-span-2">
{{ form_row(form.managedBy) }} {{ form_row(form.managedBy) }}
@@ -66,7 +66,7 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{{ booking.type.label }} · {{ booking.status.label }} {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
</td> </td>
<td> <td>
{% set discounts = [] %} {% set discounts = [] %}
@@ -0,0 +1,13 @@
{% extends 'htmx_confirmation_modal.html.twig' %}
{% block title %}Buchung bestätigen{% endblock %}
{% block content %}
<div>
Möchtest du die Buchung für <em>{{ booking.groupName }}</em> verbindlich bestätigen?
<em>{{ booking.email }}</em> erhält daraufhin die Buchungsbestätigung inklusive
Preisübersicht per E-Mail.
</div>
{% endblock %}
{% block button_confirm %}Bestätigen{% endblock %}
@@ -2,7 +2,7 @@
{% block content %} {% block content %}
<twig:page:heading> <twig:page:heading>
{{ booking.type.label }} · {{ booking.status.label }} {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %}
{{ booking.groupName }} {{ booking.groupName }}
</twig:page:heading> </twig:page:heading>
@@ -56,11 +56,15 @@
<dt class="font-medium">Art</dt> <dt class="font-medium">Art</dt>
<dd>{{ booking.type.label }}</dd> <dd>{{ booking.type.label }}</dd>
<dt class="font-medium">Status</dt> <dt class="font-medium">Status</dt>
<dd>{{ booking.status.label }}</dd> <dd>{% include 'admin/accommodation_booking/_status_label.html.twig' %}</dd>
{% if booking.acceptedAt is not null %} {% if booking.acceptedAt is not null %}
<dt class="font-medium">Angenommen am</dt> <dt class="font-medium">Angenommen am</dt>
<dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd> <dd>{{ booking.acceptedAt | date('d.m.Y, H:i') }}</dd>
{% endif %} {% endif %}
{% if booking.confirmedAt is not null %}
<dt class="font-medium">Bestätigt am</dt>
<dd>{{ booking.confirmedAt | date('d.m.Y, H:i') }}</dd>
{% endif %}
</dl> </dl>
</div> </div>
@@ -188,6 +192,23 @@
{% endif %} {% endif %}
</div> </div>
{% if booking.received %}
<div class="mt-6 border-t border-gray-200 pt-6">
<h2 class="text-lg font-bold mb-2">Bestätigung</h2>
<p class="text-sm text-gray-500 mb-2">
Der Kunde hat die Buchung verbindlich abgeschickt, aber noch keine Bestätigung
erhalten. Prüfe Leistungen und Kapazitäten und bestätige die Buchung erst dann.
</p>
<button type="button"
class="button button--primary button--small"
hx-get="{{ path('app_admin_accommodationbooking_confirm', { id: booking.id }) }}"
hx-target="body"
hx-swap="beforeend">
Buchung bestätigen
</button>
</div>
{% endif %}
<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 %}
+137
View File
@@ -0,0 +1,137 @@
{#- Price breakdown for booking emails. Mirrors groups/booking/_offer_summary.html.twig, but
with inline styles: the email layout ships no table styling and no Tailwind.
Context: booking, priceBreakdown, currency (set in AccommodationBookingService). -#}
{% if priceBreakdown is not null and currency is not null %}
{% set cell = 'padding: 4px 0; text-align: left; vertical-align: top;' %}
{% set amount = 'padding: 4px 0; text-align: right; vertical-align: top; white-space: nowrap;' %}
{% set note = 'display: block; font-size: 12px; color: #6b7280; margin: 0;' %}
<h2>Preise</h2>
<table style="width: 100%; border-collapse: collapse; font-size: 14px;">
<tr>
<td style="{{ cell }}">Zeitraum</td>
<td style="{{ amount }}">
{{ booking.dateFrom|date('d.m.Y') }} {{ booking.dateTo|date('d.m.Y') }},
{{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }}
</td>
</tr>
<tr>
<td style="{{ cell }}">Anzahl Personen</td>
<td style="{{ amount }}">{{ booking.paxCount }}</td>
</tr>
{% if booking.minorsCount > 0 %}
<tr>
<td style="{{ cell }}">davon Kinder (03 J.)</td>
<td style="{{ amount }}">{{ booking.minorsCount }}</td>
</tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr>
<td style="{{ cell }}">davon Kinder (4{{ booking.accommodation.maxAdolescentAge }} J.)</td>
<td style="{{ amount }}">{{ booking.childrenCount }}</td>
</tr>
{% endif %}
{% if priceBreakdown.basePrice > 0 %}
<tr>
<td style="{{ cell }}">
Basispreis
<span style="{{ note }}">für {{ priceBreakdown.includedPax }} Pers.</span>
</td>
<td style="{{ amount }}">{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.additionalPersonsPrice > 0 %}
<tr>
<td style="{{ cell }}">
Aufpreis Personenzahl
<span style="{{ note }}">für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.</span>
</td>
<td style="{{ amount }}">{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.shortTermSurcharge > 0 %}
<tr>
<td style="{{ cell }}">Aufpreis Kurzzeit</td>
<td style="{{ amount }}">{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.boardPrice > 0 %}
<tr>
<td style="{{ cell }}">Verpflegung{% if booking.boardServiceLabel %} ({{ booking.boardServiceLabel }}){% endif %}</td>
<td style="{{ amount }}">{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
<tr>
<td style="{{ cell }}">
Verpflegungs-Aufschlag
<span style="{{ note }}">für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Pers.</span>
</td>
<td style="{{ amount }}">{{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% for service in priceBreakdown.serviceDetails %}
{% if service.price > 0 %}
<tr>
<td style="{{ cell }}">{{ service.label }}</td>
<td style="{{ amount }}">{{ (service.price / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% endfor %}
{% if priceBreakdown.runningCosts > 0 %}
<tr>
<td style="{{ cell }}">Strom- und Abfallgebühren</td>
<td style="{{ amount }}">{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
<tr>
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">
Gesamtpreis
<span style="{{ note }} font-weight: normal;">zzgl. ortsabhängiger Gebühren</span>
</td>
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
{{ (priceBreakdown.total / 100)|format_currency(currency) }}
</td>
</tr>
{% set accommodationDiscountAmount = booking.accommodationDiscount is not null
? ((priceBreakdown.basePrice + priceBreakdown.additionalPersonsPrice) * booking.accommodationDiscount / 100) | round
: 0 %}
{% set boardDiscountAmount = booking.boardServiceDiscount is not null
? (priceBreakdown.boardPrice * booking.boardServiceDiscount / 100) | round
: 0 %}
{% set servicesDiscountAmount = booking.additionalServicesDiscount is not null
? (priceBreakdown.servicesPrice * booking.additionalServicesDiscount / 100) | round
: 0 %}
{% set discountSum = accommodationDiscountAmount + boardDiscountAmount + servicesDiscountAmount %}
{% if booking.accommodationDiscount is not null %}
<tr>
<td style="{{ cell }}">Rabatt Unterkunft {{ booking.accommodationDiscount }}&nbsp;%</td>
<td style="{{ amount }}"> {{ (accommodationDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.boardServiceDiscount is not null %}
<tr>
<td style="{{ cell }}">Rabatt Verpflegung {{ booking.boardServiceDiscount }}&nbsp;%</td>
<td style="{{ amount }}"> {{ (boardDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if booking.additionalServicesDiscount is not null %}
<tr>
<td style="{{ cell }}">Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }}&nbsp;%</td>
<td style="{{ amount }}"> {{ (servicesDiscountAmount / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if discountSum > 0 %}
{#- The frozen snapshot wins where it exists; without one the discounted total has to
be derived from the very rows shown above, so the mail can never contradict itself. -#}
<tr>
<td style="{{ cell }} border-top: 1px solid #d1d5db; font-weight: bold;">Gesamtpreis nach Rabatt</td>
<td style="{{ amount }} border-top: 1px solid #d1d5db; font-weight: bold;">
{{ ((booking.totalPrice ?? (priceBreakdown.total - discountSum)) / 100)|format_currency(currency) }}
</td>
</tr>
{% endif %}
</table>
{% endif %}
@@ -26,4 +26,6 @@
<td>{{ booking.dateTo|date('d.m.Y') }}</td> <td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr> </tr>
</table> </table>
{% include 'email/_price_breakdown.html.twig' %}
{% endblock %} {% endblock %}
@@ -1,37 +1,25 @@
{% extends 'email/layout.html.twig' %} {% extends 'email/layout.html.twig' %}
{% block body %} {% block body %}
{% set info_url = 'https://www.ep-reisen.de/reisen-fuer-gruppen/infos-zusatzleistungen/reiseinfos/ablauf-und-fristen-eurer-buchung/ablauf-und-fristen-eurer-buchung-fuer-sommeraufenthalte/' %} {# Two states only: the office has published an offer (access link present), or the
inquiry has just arrived and the offer still has to be prepared. #}
{% if accessLink %} {% if accessLink %}
<h1> <h1>
{{ booking.accepted ? 'Deine Buchung ist bestätigt' : 'Dein Angebot ist bereit' }} Dein Angebot ist bereit
</h1> </h1>
<p> <p>
Hallo {{ booking.firstName }}, Hallo {{ booking.firstName }},
</p> </p>
{% if not booking.accepted %} <p>
<p> vielen Dank für deine Anfrage! Dein persönliches Angebot ist fertig. Schau es dir in Ruhe an und buche
vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung. verbindlich, wenn alles passt. Wir prüfen die Buchung anschließend und bestätigen sie dir per E-Mail.
</p> </p>
<p>
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
Gruppenabwicklung. Sie werden sich rechtzeitig bei Euch melden und alle weiteren Details mit euch
besprechen. Keine Sorge, sollte es sich ein wenig verzögern, der Termin ist fest für euch reserviert und
geblockt! Wie es danach für euch weitergeht, könnt ihr euch <a href="{{ info_url }}" class="underline">hier schon einmal anschauen</a>.
</p>
{% else %}
<p>
vielen Dank für deine Anfrage! Wir melden uns so schnell wie möglich bei dir mit deinem persönlichen
Angebot.
</p>
{% endif %}
<p> <p>
<a href="{{ accessLink }}" class="button"> <a href="{{ accessLink }}" class="button">
{{ booking.accepted ? 'Buchung ansehen' : 'Angebot ansehen & bestätigen' }} Angebot ansehen &amp; buchen
</a> </a>
</p> </p>
{% else %} {% else %}
@@ -64,6 +52,8 @@
</tr> </tr>
</table> </table>
{% include 'email/_price_breakdown.html.twig' %}
<p> <p>
Bei Fragen antworte einfach auf diese E-Mail. Bei Fragen antworte einfach auf diese E-Mail.
</p> </p>
@@ -12,7 +12,7 @@
</p> </p>
<p> <p>
vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung. wir haben deine Buchung geprüft und bestätigen sie dir hiermit verbindlich.
</p> </p>
<p> <p>
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
@@ -46,6 +46,8 @@
</tr> </tr>
</table> </table>
{% include 'email/_price_breakdown.html.twig' %}
{% if booking.remarks %} {% if booking.remarks %}
<h2> <h2>
Anmerkungen Anmerkungen
+2
View File
@@ -25,6 +25,8 @@
</tr> </tr>
</table> </table>
{% include 'email/_price_breakdown.html.twig' %}
{% if booking.remarks %} {% if booking.remarks %}
<h2>Anmerkungen</h2> <h2>Anmerkungen</h2>
<p>{{ booking.remarks|nl2br }}</p> <p>{{ booking.remarks|nl2br }}</p>
+12 -4
View File
@@ -24,7 +24,7 @@
{% import _self as macros %} {% import _self as macros %}
{% block title %}{{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }}{% endblock %} {% block title %}{{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}{% endblock %}
{% block background %}bg-outer bg-outer--summer{% endblock %} {% block background %}bg-outer bg-outer--summer{% endblock %}
@@ -52,7 +52,7 @@
<hgroup> <hgroup>
<h1 class="text-2xl lg:text-4xl font-bold mb-2"> <h1 class="text-2xl lg:text-4xl font-bold mb-2">
{{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }} {{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}
</h1> </h1>
<p class="text-2xl"> <p class="text-2xl">
{{ ctx.accommodation.name }} {{ ctx.accommodation.name }}
@@ -63,7 +63,7 @@
{% if booking.boardServiceLabel or booking.additionalServices | length > 0 %} {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}
<div> <div>
<h2 class="text-xl font-bold mb-2"> <h2 class="text-xl font-bold mb-2">
{{ booking.accepted ? 'Gebuchte' : 'Angefragte' }} Leistungen {{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angefragte' }} Leistungen
</h2> </h2>
<ul class="list-disc pl-4"> <ul class="list-disc pl-4">
{% if booking.boardServiceLabel %} {% if booking.boardServiceLabel %}
@@ -86,11 +86,19 @@
Angebot verbindlich buchen Angebot verbindlich buchen
</button> </button>
</div> </div>
{% elseif booking.confirmedAt is not null %}
<div class="flex items-center space-x-2">
{{ icon('info', 'w-5 h-5') }}
<div class="text-gray-500">
Buchung bestätigt am {{ booking.confirmedAt | date('d.m.Y') }}
</div>
</div>
{% elseif booking.acceptedAt is not null %} {% elseif booking.acceptedAt is not null %}
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
{{ icon('info', 'w-5 h-5') }} {{ icon('info', 'w-5 h-5') }}
<div class="text-gray-500"> <div class="text-gray-500">
Angebot angenommen am {{ booking.acceptedAt | date('d.m.Y') }} Am {{ booking.acceptedAt | date('d.m.Y') }} bei uns eingegangen — wir prüfen deine
Buchung und melden uns mit der Bestätigung bei dir.
</div> </div>
</div> </div>
{% endif %} {% endif %}
+1 -1
View File
@@ -11,7 +11,7 @@
title: 'Vielen Dank!', title: 'Vielen Dank!',
messages: [ messages: [
resultType == 'booking' resultType == 'booking'
? 'Deine Buchung ist bei uns eingegangen. Du erhältst in Kürze eine Bestätigung.' ? 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhälst du eine Bestätigung per E-Mail.'
: 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.' : 'Deine Anfrage ist bei uns eingegangen. Wir melden uns so schnell wie möglich bei dir.'
] ]
} %} } %}
@@ -0,0 +1,160 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Admin\AccommodationBooking;
use App\Controller\Admin\AccommodationBooking\ConfirmController;
use App\Entity\Groups\AccommodationBooking;
use App\Enum\Groups\AccommodationBookingStatus;
use App\Service\AccommodationBookingService;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Core\Exception\AccessDeniedException;
/**
* Covers the guards around the confirm action — the confirmation itself is tested in
* AccommodationBookingServiceTest.
*/
class ConfirmControllerTest extends TestCase
{
public function testGetRendersTheConfirmationModal(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('confirmBooking');
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($this->receivedBooking(), Request::create('/admin/accommodation-booking/1/confirm'));
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('admin/accommodation_booking/modal_confirm.html.twig', $controller->renderedView);
}
public function testPostConfirmsTheBookingAndRedirectsTheBrowser(): void
{
$booking = $this->receivedBooking();
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::once())->method('confirmBooking')->with($booking);
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
self::assertTrue($response->headers->has('HX-Redirect'));
}
public function testPostWithAnInvalidTokenIsDenied(): void
{
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('confirmBooking');
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class), tokenValid: false);
$this->expectException(AccessDeniedException::class);
$controller->index($this->receivedBooking(), Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
}
/**
* @dataProvider nonReceivedStatuses
*/
public function testABookingThatIsNotAwaitingValidationCannotBeConfirmed(AccommodationBookingStatus $status): void
{
$booking = $this->receivedBooking();
$booking->setStatus($status);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('confirmBooking');
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
self::assertSame(Response::HTTP_FOUND, $response->getStatusCode());
}
/**
* @return iterable<string, array{AccommodationBookingStatus}>
*/
public static function nonReceivedStatuses(): iterable
{
yield 'draft' => [AccommodationBookingStatus::Draft];
yield 'open' => [AccommodationBookingStatus::Open];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
yield 'discarded' => [AccommodationBookingStatus::Discarded];
}
public function testABookingWithoutAnEmailAddressIsNotConfirmed(): void
{
$booking = $this->receivedBooking();
$booking->setEmail(null);
$bookingService = $this->createMock(AccommodationBookingService::class);
$bookingService->expects(self::never())->method('confirmBooking');
$controller = new TestableConfirmController($bookingService, $this->createMock(LoggerInterface::class));
$response = $controller->index($booking, Request::create('/admin/accommodation-booking/1/confirm', 'POST'));
self::assertSame(['error'], array_column($controller->flashes, 'type'));
self::assertStringContainsString('app_admin_accommodationbooking_edit', (string) $response->headers->get('HX-Redirect'));
}
private function receivedBooking(): AccommodationBooking
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$booking->setGroupName('Schulklasse 7b');
$booking->setEmail('[email protected]');
return $booking;
}
}
final class TestableConfirmController extends ConfirmController
{
public ?string $renderedView = null;
/** @var list<array{type: string, message: mixed}> */
public array $flashes = [];
public function __construct(
AccommodationBookingService $bookingService,
LoggerInterface $logger,
private readonly bool $tokenValid = true,
) {
parent::__construct($bookingService, $logger);
}
protected function isCsrfTokenValid(string $id, #[\SensitiveParameter] ?string $token): bool
{
return $this->tokenValid;
}
/**
* @param array<string, mixed> $parameters
*/
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
{
$this->renderedView = $view;
return new Response();
}
protected function addFlash(string $type, mixed $message): void
{
$this->flashes[] = ['type' => $type, 'message' => $message];
}
/**
* @param array<string, mixed> $parameters
*/
protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
{
return '/'.$route.'?'.http_build_query($parameters);
}
}
@@ -32,11 +32,13 @@ class EditControllerTest extends TestCase
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, $bookingService); $this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, $bookingService);
} }
public function testTransitionToAcceptedIssuesAccessLinkAndSendsCustomerEmail(): void public function testTransitionToConfirmedSendsNothing(): void
{ {
$bookingService = $this->assertNotified(AccommodationBookingStatus::Accepted); // The binding confirmation only ever goes out through the explicit confirm action,
// never as a side effect of editing the status.
$bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Draft, AccommodationBookingStatus::Accepted, $bookingService); $this->submitStatusChange(AccommodationBookingStatus::Received, AccommodationBookingStatus::Confirmed, $bookingService);
} }
public function testTransitionToDiscardedSendsNothing(): void public function testTransitionToDiscardedSendsNothing(): void
@@ -50,7 +52,7 @@ class EditControllerTest extends TestCase
{ {
$bookingService = $this->assertNotNotified(); $bookingService = $this->assertNotNotified();
$this->submitStatusChange(AccommodationBookingStatus::Accepted, AccommodationBookingStatus::Accepted, $bookingService); $this->submitStatusChange(AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Confirmed, $bookingService);
} }
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
@@ -56,7 +56,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setAccommodationDiscount(10); $booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(20); $booking->setBoardServiceDiscount(20);
$booking->setAdditionalServicesDiscount(30); $booking->setAdditionalServicesDiscount(30);
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setType(AccommodationBookingType::Booking); $booking->setType(AccommodationBookingType::Booking);
$booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00')); $booking->setAcceptedAt(new \DateTimeImmutable('2026-07-15T10:00:00+00:00'));
@@ -87,7 +87,7 @@ class AccommodationBookingControllerTest extends TestCase
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame([ self::assertSame([
'uuid' => $booking->getUuid(), 'uuid' => $booking->getUuid(),
'status' => 'accepted', 'status' => 'confirmed',
'type' => 'booking', 'type' => 'booking',
'dateFrom' => '2026-07-20', 'dateFrom' => '2026-07-20',
'dateTo' => '2026-07-25', 'dateTo' => '2026-07-25',
@@ -97,6 +97,7 @@ class AccommodationBookingControllerTest extends TestCase
'childrenCount' => 1, 'childrenCount' => 1,
'groupName' => 'Schulklasse 7b', 'groupName' => 'Schulklasse 7b',
'acceptedAt' => '2026-07-15T10:00:00+00:00', 'acceptedAt' => '2026-07-15T10:00:00+00:00',
'confirmedAt' => null,
'personalData' => [ 'personalData' => [
'salutation' => 'Frau', 'salutation' => 'Frau',
'firstName' => 'Mia', 'firstName' => 'Mia',
@@ -218,7 +219,7 @@ class AccommodationBookingControllerTest extends TestCase
->method('acceptBooking') ->method('acceptBooking')
->with($booking) ->with($booking)
->willReturnCallback(static function (AccommodationBooking $b): void { ->willReturnCallback(static function (AccommodationBooking $b): void {
$b->setStatus(AccommodationBookingStatus::Accepted); $b->setStatus(AccommodationBookingStatus::Received);
$b->setAcceptedAt(new \DateTimeImmutable()); $b->setAcceptedAt(new \DateTimeImmutable());
}); });
@@ -228,9 +229,9 @@ class AccommodationBookingControllerTest extends TestCase
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('accepted', $payload['status']); self::assertSame('received', $payload['status']);
self::assertSame('inquiry', $payload['type']); self::assertSame('inquiry', $payload['type']);
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus()); self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt()); self::assertNotNull($booking->getAcceptedAt());
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']); self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
} }
@@ -246,7 +247,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setFirstName('Tom'); $booking->setFirstName('Tom');
$booking->setLastName('Beispiel'); $booking->setLastName('Beispiel');
$booking->setEmail('[email protected]'); $booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setAcceptedAt($acceptedAt); $booking->setAcceptedAt($acceptedAt);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository = $this->createMock(AccommodationBookingRepository::class);
@@ -267,7 +268,7 @@ class AccommodationBookingControllerTest extends TestCase
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR); $payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame(Response::HTTP_OK, $response->getStatusCode()); self::assertSame(Response::HTTP_OK, $response->getStatusCode());
self::assertSame('accepted', $payload['status']); self::assertSame('received', $payload['status']);
self::assertSame($acceptedAt, $booking->getAcceptedAt()); self::assertSame($acceptedAt, $booking->getAcceptedAt());
self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']); self::assertSame($acceptedAt->format(\DATE_ATOM), $payload['acceptedAt']);
} }
@@ -274,7 +274,7 @@ class OfferControllerTest extends TestCase
self::assertInstanceOf(RedirectResponse::class, $response); self::assertInstanceOf(RedirectResponse::class, $response);
self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl()); self::assertSame('/app_groups_booking_offer_view?uuid='.$booking->getUuid(), $response->getTargetUrl());
self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bestätigt.']], $controller->flashes); self::assertSame([['type' => 'success', 'message' => 'Deine Buchung ist bei uns eingegangen. Sobald sie geprüft ist, erhältst du eine Bestätigung per E-Mail.']], $controller->flashes);
} }
public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void public function testConfirmPostValidViaHtmxReturnsHxRedirect(): void
@@ -350,7 +350,7 @@ class OfferControllerTest extends TestCase
public function testConfirmRedirectsWhenAlreadyAccepted(): void public function testConfirmRedirectsWhenAlreadyAccepted(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class); $bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking); $bookingRepository->method('findOneBy')->willReturn($booking);
@@ -474,7 +474,7 @@ final class TestableOfferController extends OfferController
$this->renderedParameters = $parameters; $this->renderedParameters = $parameters;
if ('groups/booking/offer.html.twig' === $view) { if ('groups/booking/offer.html.twig' === $view) {
$content = $parameters['booking']->isAccepted() ? '<html>confirmed</html>' : '<html>offer</html>'; $content = null !== $parameters['booking']->getAcceptedAt() ? '<html>confirmed</html>' : '<html>offer</html>';
} else { } else {
$content = '<html>unavailable</html>'; $content = '<html>unavailable</html>';
} }
@@ -48,7 +48,8 @@ class AccommodationBookingTypeTest extends TestCase
public static function customerFacingStatuses(): iterable public static function customerFacingStatuses(): iterable
{ {
yield 'open' => [AccommodationBookingStatus::Open]; yield 'open' => [AccommodationBookingStatus::Open];
yield 'accepted' => [AccommodationBookingStatus::Accepted]; yield 'received' => [AccommodationBookingStatus::Received];
yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
} }
/** /**
@@ -17,7 +17,7 @@ class AccommodationBookingFilterDtoTest extends TestCase
$filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]')); $filter = AccommodationBookingFilterDto::defaults(true, new User('[email protected]'));
self::assertSame( self::assertSame(
[AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open], [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Received],
$filter->status, $filter->status,
'accepted and discarded bookings need no further work, so they stay out of the way', 'accepted and discarded bookings need no further work, so they stay out of the way',
); );
@@ -82,7 +82,7 @@ class AccommodationBookingFilterDtoTest extends TestCase
$filter = new AccommodationBookingFilterDto(); $filter = new AccommodationBookingFilterDto();
$filter->q = 'meier'; $filter->q = 'meier';
$filter->dateFrom = new \DateTimeImmutable('2026-08-01'); $filter->dateFrom = new \DateTimeImmutable('2026-08-01');
$filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted]; $filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed];
$filter->type = [AccommodationBookingType::Booking]; $filter->type = [AccommodationBookingType::Booking];
self::assertSame(4, $filter->activeCount()); self::assertSame(4, $filter->activeCount());
@@ -169,7 +169,7 @@ class AccommodationBookingServiceTest extends TestCase
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setEmail('[email protected]'); $booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable()); $booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class); $linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
@@ -265,7 +265,7 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
} }
public function testAcceptBookingAcceptsOpenInquiryAndSendsNotifications(): void public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
{ {
$entityManager = $this->createMock(EntityManagerInterface::class); $entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush'); $entityManager->expects(self::once())->method('flush');
@@ -274,22 +274,23 @@ class AccommodationBookingServiceTest extends TestCase
$booking->setStatus(AccommodationBookingStatus::Open); $booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('[email protected]'); $booking->setEmail('[email protected]');
// The customer hears nothing until the office has validated the booking.
$mailer = $this->createMock(Mailer::class); $mailer = $this->createMock(Mailer::class);
$mailer $mailer
->expects(self::exactly(2)) ->expects(self::once())
->method('createAndSendEmail') ->method('createAndSendEmail')
->with( ->with(
self::anything(), self::anything(),
self::callback(static fn (array $options) => in_array($options['to'], ['[email protected]', '[email protected]'], true) self::callback(static fn (array $options) => '[email protected]' === $options['to']
&& '[email protected]' === $options['from'] && '[email protected]' === $options['from']
&& in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)), && 'email/offer_accepted.html.twig' === $options['template']),
); );
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$service->acceptBooking($booking); $service->acceptBooking($booking);
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus()); self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage'); self::assertSame(AccommodationBookingType::Inquiry, $booking->getType(), 'an accepted offer stays an Anfrage');
self::assertNotNull($booking->getAcceptedAt()); self::assertNotNull($booking->getAcceptedAt());
} }
@@ -339,7 +340,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setRemarks('vom Telefonat'); $booking->setRemarks('vom Telefonat');
$service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class)); $service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class));
@@ -349,32 +350,105 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame('vom Telefonat', $booking->getRemarks()); self::assertSame('vom Telefonat', $booking->getRemarks());
} }
public function testAcceptBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void public function testConfirmBookingConfirmsReceivedBookingAndNotifiesTheCustomer(): void
{ {
$entityManager = $this->createMock(EntityManagerInterface::class);
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Open); $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setEmail('[email protected]');
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
$linkSigner->method('sign')->with($booking)->willReturn('https://example.com/offer/signed-link');
// Only the office notification goes out — the customer copy has no recipient.
$mailer = $this->createMock(Mailer::class); $mailer = $this->createMock(Mailer::class);
$mailer $mailer
->expects(self::once()) ->expects(self::once())
->method('createAndSendEmail') ->method('createAndSendEmail')
->with( ->with(
self::anything(), self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']),
self::callback(static fn (array $options) => 'office@example.com' === $options['to']), self::callback(static fn (array $options) => 'customer@example.com' === $options['to']
&& 'Deine Buchung ist bestätigt' === $options['subject']
&& 'email/booking_confirmed_customer.html.twig' === $options['template']),
); );
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer, linkSigner: $linkSigner);
$service->confirmBooking($booking);
self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus());
self::assertNotNull($booking->getConfirmedAt());
self::assertNotNull($booking->getAccessLinkIssuedAt(), 'the confirmation links to the booking view');
}
public function testConfirmBookingNoOpsForAnythingNotAwaitingValidation(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::never())->method('flush');
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
foreach ([AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Discarded] as $status) {
$booking = new AccommodationBooking();
$booking->setStatus($status);
$booking->setEmail('[email protected]');
$service->confirmBooking($booking);
self::assertSame($status, $booking->getStatus());
self::assertNull($booking->getConfirmedAt());
}
}
public function testBookingEmailsCarryThePriceBreakdown(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Open);
$breakdown = ['total' => 10000, 'currency' => 'EUR'];
$booking->setPriceSnapshot($breakdown, 9000, 'CHF', 1);
$breakdownCalculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$breakdownCalculator->method('compute')->with($booking)->willReturn($breakdown);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
// The stored pricing currency wins over the one frozen in the breakdown.
self::callback(static fn (array $context) => $breakdown === $context['priceBreakdown']
&& 'CHF' === $context['currency']),
self::anything(),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, breakdownCalculator: $breakdownCalculator);
$service->sendCustomerConfirmationEmail($booking);
}
public function testConfirmBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void
{
$booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Received);
$mailer = $this->createMock(Mailer::class);
$mailer->expects(self::never())->method('createAndSendEmail');
$logger = $this->createMock(LoggerInterface::class); $logger = $this->createMock(LoggerInterface::class);
$logger $logger
->expects(self::once()) ->expects(self::once())
->method('warning') ->method('warning')
->with('Failed to send offer accepted customer email', self::anything()); ->with('Failed to send booking confirmed customer email', self::anything());
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
$service->acceptBooking($booking); $service->confirmBooking($booking);
self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus(), 'the acceptance itself must not fail'); self::assertSame(AccommodationBookingStatus::Confirmed, $booking->getStatus(), 'the confirmation itself must not fail');
} }
public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void
@@ -388,7 +462,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$service->acceptBooking($booking); $service->acceptBooking($booking);
@@ -418,7 +492,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Received);
$mailer = $this->createMock(Mailer::class); $mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -431,11 +505,11 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendOfferAcceptedNotificationEmail($booking); $service->sendOfferAcceptedNotificationEmail($booking);
} }
public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void public function testSendBookingConfirmedCustomerEmailLogsAndSwallowsMailerFailures(): void
{ {
$booking = new AccommodationBooking(); $booking = new AccommodationBooking();
$booking->setEmail('[email protected]'); $booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Accepted); $booking->setStatus(AccommodationBookingStatus::Confirmed);
$mailer = $this->createMock(Mailer::class); $mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -445,7 +519,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
$service->sendOfferAcceptedCustomerEmail($booking); $service->sendBookingConfirmedCustomerEmail($booking);
} }
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void