From 8dee65d0dc3d429d1e745928207d23025aee0822 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?=
Date: Tue, 18 Aug 2026 12:48:17 +0200
Subject: [PATCH] feat: additional confirmation cycle and status for groups
inquiries
---
migrations/Version20260818103004.php | 26 +++
.../ConfirmController.php | 64 +++++++
.../AccommodationBooking/EditController.php | 8 +-
src/Controller/Groups/OfferController.php | 2 +-
src/Entity/Groups/AccommodationBooking.php | 29 +++-
.../Groups/AccommodationBookingStatus.php | 6 +-
.../Filter/AccommodationBookingFilterDto.php | 12 +-
src/Model/AccommodationBookingApiResponse.php | 4 +
src/Service/AccommodationBookingService.php | 66 ++++++--
.../_status_label.html.twig | 5 +
.../accommodation_booking/edit.html.twig | 5 +
.../accommodation_booking/index.html.twig | 2 +-
.../modal_confirm.html.twig | 13 ++
.../accommodation_booking/show.html.twig | 25 ++-
templates/email/_price_breakdown.html.twig | 137 +++++++++++++++
.../email/accommodation_booking.html.twig | 2 +
.../accommodation_booking_customer.html.twig | 30 ++--
...g => booking_confirmed_customer.html.twig} | 4 +-
templates/email/offer_accepted.html.twig | 2 +
templates/groups/booking/offer.html.twig | 16 +-
templates/groups/booking/success.html.twig | 2 +-
.../ConfirmControllerTest.php | 160 ++++++++++++++++++
.../EditControllerTest.php | 10 +-
.../AccommodationBookingControllerTest.php | 15 +-
.../Controller/Groups/OfferControllerTest.php | 6 +-
.../Groups/AccommodationBookingTypeTest.php | 3 +-
.../AccommodationBookingFilterDtoTest.php | 4 +-
.../AccommodationBookingServiceTest.php | 114 ++++++++++---
28 files changed, 677 insertions(+), 95 deletions(-)
create mode 100644 migrations/Version20260818103004.php
create mode 100644 src/Controller/Admin/AccommodationBooking/ConfirmController.php
create mode 100644 templates/admin/accommodation_booking/_status_label.html.twig
create mode 100644 templates/admin/accommodation_booking/modal_confirm.html.twig
create mode 100644 templates/email/_price_breakdown.html.twig
rename templates/email/{offer_accepted_customer.html.twig => booking_confirmed_customer.html.twig} (92%)
create mode 100644 tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php
diff --git a/migrations/Version20260818103004.php b/migrations/Version20260818103004.php
new file mode 100644
index 0000000..5a905c0
--- /dev/null
+++ b/migrations/Version20260818103004.php
@@ -0,0 +1,26 @@
+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');
+ }
+}
diff --git a/src/Controller/Admin/AccommodationBooking/ConfirmController.php b/src/Controller/Admin/AccommodationBooking/ConfirmController.php
new file mode 100644
index 0000000..3a97bfa
--- /dev/null
+++ b/src/Controller/Admin/AccommodationBooking/ConfirmController.php
@@ -0,0 +1,64 @@
+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(),
+ ]);
+ }
+}
diff --git a/src/Controller/Admin/AccommodationBooking/EditController.php b/src/Controller/Admin/AccommodationBooking/EditController.php
index 5616919..323332d 100644
--- a/src/Controller/Admin/AccommodationBooking/EditController.php
+++ b/src/Controller/Admin/AccommodationBooking/EditController.php
@@ -128,9 +128,11 @@ class EditController extends AbstractController
$this->entityManager->persist($booking);
$this->entityManager->flush();
- // Entering Offen or Bestätigt is what the customer needs to hear about: the offer
- // becomes viewable, or the booking is confirmed. Entwurf and Absage stay silent.
- $notifiableStatuses = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
+ // Entering Offen is what the customer needs to hear about: the offer becomes
+ // viewable. Every other status stays silent here — in particular Bestätigt, which
+ // 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)) {
$this->bookingService->issueAccessLink($booking);
$this->bookingService->sendCustomerConfirmationEmail($booking);
diff --git a/src/Controller/Groups/OfferController.php b/src/Controller/Groups/OfferController.php
index e1e53fb..0861e60 100644
--- a/src/Controller/Groups/OfferController.php
+++ b/src/Controller/Groups/OfferController.php
@@ -108,7 +108,7 @@ class OfferController extends AbstractController
// 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.
$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);
}
diff --git a/src/Entity/Groups/AccommodationBooking.php b/src/Entity/Groups/AccommodationBooking.php
index 0bdf9d6..d8eabdc 100644
--- a/src/Entity/Groups/AccommodationBooking.php
+++ b/src/Entity/Groups/AccommodationBooking.php
@@ -94,6 +94,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
#[ORM\Column(type: Types::DATETIME_IMMUTABLE, nullable: true)]
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)]
#[Assert\NotBlank(groups: ['edit'])]
private ?string $groupName = null;
@@ -373,9 +381,14 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
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
@@ -429,6 +442,18 @@ class AccommodationBooking implements BlameableEntityInterface, TimestampableEnt
return $this;
}
+ public function getConfirmedAt(): ?\DateTimeImmutable
+ {
+ return $this->confirmedAt;
+ }
+
+ public function setConfirmedAt(?\DateTimeImmutable $confirmedAt): self
+ {
+ $this->confirmedAt = $confirmedAt;
+
+ return $this;
+ }
+
public function getGroupName(): ?string
{
return $this->groupName;
diff --git a/src/Enum/Groups/AccommodationBookingStatus.php b/src/Enum/Groups/AccommodationBookingStatus.php
index 21e0b93..85a5d0f 100644
--- a/src/Enum/Groups/AccommodationBookingStatus.php
+++ b/src/Enum/Groups/AccommodationBookingStatus.php
@@ -6,7 +6,8 @@ enum AccommodationBookingStatus: string
{
case Draft = 'draft';
case Open = 'open';
- case Accepted = 'accepted';
+ case Received = 'received';
+ case Confirmed = 'confirmed';
case Discarded = 'discarded';
public function label(): string
@@ -14,7 +15,8 @@ enum AccommodationBookingStatus: string
return match ($this) {
self::Draft => 'Entwurf',
self::Open => 'Offen',
- self::Accepted => 'Bestätigt',
+ self::Received => 'Eingegangen',
+ self::Confirmed => 'Bestätigt',
self::Discarded => 'Absage',
};
}
diff --git a/src/Form/Model/Filter/AccommodationBookingFilterDto.php b/src/Form/Model/Filter/AccommodationBookingFilterDto.php
index ec12fd5..d060a53 100644
--- a/src/Form/Model/Filter/AccommodationBookingFilterDto.php
+++ b/src/Form/Model/Filter/AccommodationBookingFilterDto.php
@@ -38,14 +38,18 @@ class AccommodationBookingFilterDto extends AbstractListFilterDto
/**
* 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
- * work — nothing whose stay is already over, and, for staff who are not group admins, only
- * the bookings they are responsible for.
+ * The still-live statuses — drafts, open bookings and bookings awaiting validation, i.e.
+ * everything that may still need work — nothing whose stay is already over, and, for
+ * staff who are not group admins, only the bookings they are responsible for.
*/
public static function defaults(bool $seesAllBookings, ?User $currentUser): self
{
$filter = new self();
- $filter->status = [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open];
+ $filter->status = [
+ AccommodationBookingStatus::Draft,
+ AccommodationBookingStatus::Open,
+ AccommodationBookingStatus::Received,
+ ];
$filter->dateFrom = new \DateTimeImmutable('today');
if (!$seesAllBookings) {
diff --git a/src/Model/AccommodationBookingApiResponse.php b/src/Model/AccommodationBookingApiResponse.php
index 960e255..1ddf4ea 100644
--- a/src/Model/AccommodationBookingApiResponse.php
+++ b/src/Model/AccommodationBookingApiResponse.php
@@ -46,6 +46,9 @@ final readonly class AccommodationBookingApiResponse
#[Groups(['api:single'])]
public ?\DateTimeImmutable $acceptedAt;
+ #[Groups(['api:single'])]
+ public ?\DateTimeImmutable $confirmedAt;
+
#[Groups(['api:single'])]
public AccommodationBookingPersonalData $personalData;
@@ -101,6 +104,7 @@ final readonly class AccommodationBookingApiResponse
$this->childrenCount = $booking->getChildrenCount();
$this->groupName = $booking->getGroupName();
$this->acceptedAt = $booking->getAcceptedAt();
+ $this->confirmedAt = $booking->getConfirmedAt();
$this->personalData = new AccommodationBookingPersonalData($booking);
$this->accommodation = new AccommodationBookingHotelInfo($booking->getAccommodation());
$this->boardService = new AccommodationBookingBoardServiceInfo($booking);
diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php
index 0af0027..0ea56b7 100644
--- a/src/Service/AccommodationBookingService.php
+++ b/src/Service/AccommodationBookingService.php
@@ -267,10 +267,14 @@ class AccommodationBookingService
$booking->setMinorsCount($dto->minorsCount);
$booking->setChildrenCount($dto->childrenCount);
// 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;
$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)
if (null !== $dto->selectedBoardServiceId) {
@@ -333,7 +337,12 @@ class AccommodationBookingService
$booking = $this->persist($dto, $accommodation, $prices, $services);
$this->issueAccessLinkForDirectBooking($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;
}
@@ -409,11 +418,15 @@ class AccommodationBookingService
return;
}
+ $breakdown = $this->breakdownCalculator->compute($booking);
+
try {
$this->mailer->createAndSendEmail(
[
'booking' => $booking,
'accessLink' => $this->accessLinkOrNull($booking),
+ 'priceBreakdown' => $breakdown,
+ 'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'] ?? null,
],
[
'from' => $this->accommodationEmail,
@@ -467,13 +480,9 @@ class AccommodationBookingService
*/
public function sendCustomerConfirmationEmail(AccommodationBooking $booking): void
{
- if ($booking->isAccepted()) {
- $subject = 'Deine Buchung ist bestätigt';
- } elseif (null !== $booking->getAccessLinkIssuedAt()) {
- $subject = 'Dein Angebot ist bereit';
- } else {
- $subject = 'Deine Anfrage ist bei uns eingegangen';
- }
+ $subject = null !== $booking->getAccessLinkIssuedAt()
+ ? 'Dein Angebot ist bereit'
+ : 'Deine Anfrage ist bei uns eingegangen';
$this->sendBookingEmail(
$booking,
@@ -496,11 +505,14 @@ class AccommodationBookingService
}
/**
- * Accepts an open offer and notifies office and customer. The type stays Anfrage —
- * only the status moves to Bestätigt, so an offer-originated booking remains
+ * Accepts an open offer and notifies the office. The type stays Anfrage —
+ * only the status moves to Eingegangen, so an offer-originated booking remains
* distinguishable from a direct one.
* 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,
* an empty string clears it
*/
@@ -515,12 +527,32 @@ class AccommodationBookingService
$booking->setRemarks('' === $trimmed ? null : $trimmed);
}
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setAcceptedAt(new \DateTimeImmutable());
$this->entityManager->flush();
$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
@@ -534,14 +566,14 @@ class AccommodationBookingService
);
}
- public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void
+ public function sendBookingConfirmedCustomerEmail(AccommodationBooking $booking): void
{
$this->sendBookingEmail(
$booking,
$booking->getEmail(),
'Deine Buchung ist bestätigt',
- 'email/offer_accepted_customer.html.twig',
- 'Failed to send offer accepted customer email',
+ 'email/booking_confirmed_customer.html.twig',
+ 'Failed to send booking confirmed customer email',
);
}
diff --git a/templates/admin/accommodation_booking/_status_label.html.twig b/templates/admin/accommodation_booking/_status_label.html.twig
new file mode 100644
index 0000000..e4c3174
--- /dev/null
+++ b/templates/admin/accommodation_booking/_status_label.html.twig
@@ -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 -%}
diff --git a/templates/admin/accommodation_booking/edit.html.twig b/templates/admin/accommodation_booking/edit.html.twig
index 249be02..45e036f 100644
--- a/templates/admin/accommodation_booking/edit.html.twig
+++ b/templates/admin/accommodation_booking/edit.html.twig
@@ -47,6 +47,11 @@
Angenommen am {{ booking.acceptedAt | date('d.m.Y, H:i') }}
{% endif %}
+ {% if booking.confirmedAt is not null %}
+
+ Bestätigt am {{ booking.confirmedAt | date('d.m.Y, H:i') }}
+
+ Möchtest du die Buchung für {{ booking.groupName }} verbindlich bestätigen?
+ {{ booking.email }} erhält daraufhin die Buchungsbestätigung inklusive
+ Preisübersicht per E-Mail.
+
+ 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.
+
+
+
+ {% endif %}
+
Zugangslink
{% if accessLink %}
diff --git a/templates/email/_price_breakdown.html.twig b/templates/email/_price_breakdown.html.twig
new file mode 100644
index 0000000..406c566
--- /dev/null
+++ b/templates/email/_price_breakdown.html.twig
@@ -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;' %}
+
+
+ {% 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. -#}
+
+
+ {% include 'email/_price_breakdown.html.twig' %}
{% endblock %}
diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig
index 746aab5..9330433 100644
--- a/templates/email/accommodation_booking_customer.html.twig
+++ b/templates/email/accommodation_booking_customer.html.twig
@@ -1,37 +1,25 @@
{% extends 'email/layout.html.twig' %}
{% 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 %}
- {{ booking.accepted ? 'Deine Buchung ist bestätigt' : 'Dein Angebot ist bereit' }}
+ Dein Angebot ist bereit
Hallo {{ booking.firstName }},
- {% if not booking.accepted %}
-
- vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung.
-
-
- 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 hier schon einmal anschauen.
-
- {% else %}
-
- vielen Dank für deine Anfrage! Wir melden uns so schnell wie möglich bei dir mit deinem persönlichen
- Angebot.
-
- {% endif %}
+
+ vielen Dank für deine Anfrage! Dein persönliches Angebot ist fertig. Schau es dir in Ruhe an und buche
+ verbindlich, wenn alles passt. Wir prüfen die Buchung anschließend und bestätigen sie dir per E-Mail.
+
diff --git a/templates/email/offer_accepted_customer.html.twig b/templates/email/booking_confirmed_customer.html.twig
similarity index 92%
rename from templates/email/offer_accepted_customer.html.twig
rename to templates/email/booking_confirmed_customer.html.twig
index 03430f3..500d3a8 100644
--- a/templates/email/offer_accepted_customer.html.twig
+++ b/templates/email/booking_confirmed_customer.html.twig
@@ -12,7 +12,7 @@
- vielen Dank! Hiermit bestätigen wir den Eingang deiner Buchung.
+ wir haben deine Buchung geprüft und bestätigen sie dir hiermit verbindlich.
Wir übergeben die Buchung nun für die Detailabstimmung an die Kollegen und Kolleginnen in der
@@ -46,6 +46,8 @@
+ {% include 'email/_price_breakdown.html.twig' %}
+
{% if booking.remarks %}
Anmerkungen
diff --git a/templates/email/offer_accepted.html.twig b/templates/email/offer_accepted.html.twig
index adc5f4a..2a535a5 100644
--- a/templates/email/offer_accepted.html.twig
+++ b/templates/email/offer_accepted.html.twig
@@ -25,6 +25,8 @@
+ {% include 'email/_price_breakdown.html.twig' %}
+
{% if booking.remarks %}
+ Buchung bestätigt am {{ booking.confirmedAt | date('d.m.Y') }}
+
+
{% elseif booking.acceptedAt is not null %}
{{ icon('info', 'w-5 h-5') }}
- 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.
{% endif %}
diff --git a/templates/groups/booking/success.html.twig b/templates/groups/booking/success.html.twig
index 9e354a5..a32763d 100644
--- a/templates/groups/booking/success.html.twig
+++ b/templates/groups/booking/success.html.twig
@@ -11,7 +11,7 @@
title: 'Vielen Dank!',
messages: [
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.'
]
} %}
diff --git a/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php b/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php
new file mode 100644
index 0000000..9677d83
--- /dev/null
+++ b/tests/Controller/Admin/AccommodationBooking/ConfirmControllerTest.php
@@ -0,0 +1,160 @@
+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
+ */
+ 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('customer@example.com');
+
+ return $booking;
+ }
+}
+
+final class TestableConfirmController extends ConfirmController
+{
+ public ?string $renderedView = null;
+
+ /** @var list */
+ 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 $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 $parameters
+ */
+ protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string
+ {
+ return '/'.$route.'?'.http_build_query($parameters);
+ }
+}
diff --git a/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php
index b962d7c..3df417c 100644
--- a/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php
+++ b/tests/Controller/Admin/AccommodationBooking/EditControllerTest.php
@@ -32,11 +32,13 @@ class EditControllerTest extends TestCase
$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
@@ -50,7 +52,7 @@ class EditControllerTest extends TestCase
{
$bookingService = $this->assertNotNotified();
- $this->submitStatusChange(AccommodationBookingStatus::Accepted, AccommodationBookingStatus::Accepted, $bookingService);
+ $this->submitStatusChange(AccommodationBookingStatus::Confirmed, AccommodationBookingStatus::Confirmed, $bookingService);
}
private function assertNotified(AccommodationBookingStatus $expected): AccommodationBookingService
diff --git a/tests/Controller/Api/AccommodationBookingControllerTest.php b/tests/Controller/Api/AccommodationBookingControllerTest.php
index d1ed114..688e1f2 100644
--- a/tests/Controller/Api/AccommodationBookingControllerTest.php
+++ b/tests/Controller/Api/AccommodationBookingControllerTest.php
@@ -56,7 +56,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setAccommodationDiscount(10);
$booking->setBoardServiceDiscount(20);
$booking->setAdditionalServicesDiscount(30);
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setType(AccommodationBookingType::Booking);
$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([
'uuid' => $booking->getUuid(),
- 'status' => 'accepted',
+ 'status' => 'confirmed',
'type' => 'booking',
'dateFrom' => '2026-07-20',
'dateTo' => '2026-07-25',
@@ -97,6 +97,7 @@ class AccommodationBookingControllerTest extends TestCase
'childrenCount' => 1,
'groupName' => 'Schulklasse 7b',
'acceptedAt' => '2026-07-15T10:00:00+00:00',
+ 'confirmedAt' => null,
'personalData' => [
'salutation' => 'Frau',
'firstName' => 'Mia',
@@ -218,7 +219,7 @@ class AccommodationBookingControllerTest extends TestCase
->method('acceptBooking')
->with($booking)
->willReturnCallback(static function (AccommodationBooking $b): void {
- $b->setStatus(AccommodationBookingStatus::Accepted);
+ $b->setStatus(AccommodationBookingStatus::Received);
$b->setAcceptedAt(new \DateTimeImmutable());
});
@@ -228,9 +229,9 @@ class AccommodationBookingControllerTest extends TestCase
$payload = json_decode((string) $response->getContent(), true, 512, JSON_THROW_ON_ERROR);
self::assertSame(Response::HTTP_OK, $response->getStatusCode());
- self::assertSame('accepted', $payload['status']);
+ self::assertSame('received', $payload['status']);
self::assertSame('inquiry', $payload['type']);
- self::assertSame(AccommodationBookingStatus::Accepted, $booking->getStatus());
+ self::assertSame(AccommodationBookingStatus::Received, $booking->getStatus());
self::assertNotNull($booking->getAcceptedAt());
self::assertSame($booking->getAcceptedAt()->format(\DATE_ATOM), $payload['acceptedAt']);
}
@@ -246,7 +247,7 @@ class AccommodationBookingControllerTest extends TestCase
$booking->setFirstName('Tom');
$booking->setLastName('Beispiel');
$booking->setEmail('tom@example.com');
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setAcceptedAt($acceptedAt);
$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);
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->format(\DATE_ATOM), $payload['acceptedAt']);
}
diff --git a/tests/Controller/Groups/OfferControllerTest.php b/tests/Controller/Groups/OfferControllerTest.php
index 61c8e49..6975c34 100644
--- a/tests/Controller/Groups/OfferControllerTest.php
+++ b/tests/Controller/Groups/OfferControllerTest.php
@@ -274,7 +274,7 @@ class OfferControllerTest extends TestCase
self::assertInstanceOf(RedirectResponse::class, $response);
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
@@ -350,7 +350,7 @@ class OfferControllerTest extends TestCase
public function testConfirmRedirectsWhenAlreadyAccepted(): void
{
$booking = new AccommodationBooking();
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$bookingRepository = $this->createMock(AccommodationBookingRepository::class);
$bookingRepository->method('findOneBy')->willReturn($booking);
@@ -474,7 +474,7 @@ final class TestableOfferController extends OfferController
$this->renderedParameters = $parameters;
if ('groups/booking/offer.html.twig' === $view) {
- $content = $parameters['booking']->isAccepted() ? 'confirmed' : 'offer';
+ $content = null !== $parameters['booking']->getAcceptedAt() ? 'confirmed' : 'offer';
} else {
$content = 'unavailable';
}
diff --git a/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php
index 393135a..8bb2f72 100644
--- a/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php
+++ b/tests/Form/Admin/Groups/AccommodationBookingTypeTest.php
@@ -48,7 +48,8 @@ class AccommodationBookingTypeTest extends TestCase
public static function customerFacingStatuses(): iterable
{
yield 'open' => [AccommodationBookingStatus::Open];
- yield 'accepted' => [AccommodationBookingStatus::Accepted];
+ yield 'received' => [AccommodationBookingStatus::Received];
+ yield 'confirmed' => [AccommodationBookingStatus::Confirmed];
}
/**
diff --git a/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php b/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php
index 0270db3..f90423b 100644
--- a/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php
+++ b/tests/Form/Model/Filter/AccommodationBookingFilterDtoTest.php
@@ -17,7 +17,7 @@ class AccommodationBookingFilterDtoTest extends TestCase
$filter = AccommodationBookingFilterDto::defaults(true, new User('admin@example.test'));
self::assertSame(
- [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open],
+ [AccommodationBookingStatus::Draft, AccommodationBookingStatus::Open, AccommodationBookingStatus::Received],
$filter->status,
'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->q = 'meier';
$filter->dateFrom = new \DateTimeImmutable('2026-08-01');
- $filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Accepted];
+ $filter->status = [AccommodationBookingStatus::Open, AccommodationBookingStatus::Confirmed];
$filter->type = [AccommodationBookingType::Booking];
self::assertSame(4, $filter->activeCount());
diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php
index 7d38371..ee23504 100644
--- a/tests/Service/AccommodationBookingServiceTest.php
+++ b/tests/Service/AccommodationBookingServiceTest.php
@@ -169,7 +169,7 @@ class AccommodationBookingServiceTest extends TestCase
{
$booking = new AccommodationBooking();
$booking->setEmail('customer@example.com');
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Confirmed);
$booking->setAccessLinkIssuedAt(new \DateTimeImmutable());
$linkSigner = $this->createMock(AccommodationBookingLinkSigner::class);
@@ -265,7 +265,7 @@ class AccommodationBookingServiceTest extends TestCase
self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt());
}
- public function testAcceptBookingAcceptsOpenInquiryAndSendsNotifications(): void
+ public function testAcceptBookingMovesOpenInquiryToReceivedAndNotifiesTheOfficeOnly(): void
{
$entityManager = $this->createMock(EntityManagerInterface::class);
$entityManager->expects(self::once())->method('flush');
@@ -274,22 +274,23 @@ class AccommodationBookingServiceTest extends TestCase
$booking->setStatus(AccommodationBookingStatus::Open);
$booking->setEmail('customer@example.com');
+ // The customer hears nothing until the office has validated the booking.
$mailer = $this->createMock(Mailer::class);
$mailer
- ->expects(self::exactly(2))
+ ->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
- self::callback(static fn (array $options) => in_array($options['to'], ['office@example.com', 'customer@example.com'], true)
+ self::callback(static fn (array $options) => 'office@example.com' === $options['to']
&& 'office@example.com' === $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->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::assertNotNull($booking->getAcceptedAt());
}
@@ -339,7 +340,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testAcceptBookingDoesNotStoreRemarksWhenOfferIsNotOpen(): void
{
$booking = new AccommodationBooking();
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$booking->setRemarks('vom Telefonat');
$service = $this->createServiceWithAccommodation(mailer: $this->createMock(Mailer::class));
@@ -349,32 +350,105 @@ class AccommodationBookingServiceTest extends TestCase
self::assertSame('vom Telefonat', $booking->getRemarks());
}
- public function testAcceptBookingSkipsTheCustomerEmailWhenNoAddressIsStored(): void
+ public function testConfirmBookingConfirmsReceivedBookingAndNotifiesTheCustomer(): void
{
+ $entityManager = $this->createMock(EntityManagerInterface::class);
+
$booking = new AccommodationBooking();
- $booking->setStatus(AccommodationBookingStatus::Open);
+ $booking->setStatus(AccommodationBookingStatus::Received);
+ $booking->setEmail('customer@example.com');
+
+ $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
->expects(self::once())
->method('createAndSendEmail')
->with(
- self::anything(),
- self::callback(static fn (array $options) => 'office@example.com' === $options['to']),
+ self::callback(static fn (array $context) => 'https://example.com/offer/signed-link' === $context['accessLink']),
+ 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('customer@example.com');
+
+ $service->confirmBooking($booking);
+
+ self::assertSame($status, $booking->getStatus());
+ self::assertNull($booking->getConfirmedAt());
+ }
+ }
+
+ public function testBookingEmailsCarryThePriceBreakdown(): void
+ {
+ $booking = new AccommodationBooking();
+ $booking->setEmail('customer@example.com');
+ $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
->expects(self::once())
->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->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
@@ -388,7 +462,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer);
$booking = new AccommodationBooking();
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$service->acceptBooking($booking);
@@ -418,7 +492,7 @@ class AccommodationBookingServiceTest extends TestCase
public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Received);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -431,11 +505,11 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendOfferAcceptedNotificationEmail($booking);
}
- public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void
+ public function testSendBookingConfirmedCustomerEmailLogsAndSwallowsMailerFailures(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('customer@example.com');
- $booking->setStatus(AccommodationBookingStatus::Accepted);
+ $booking->setStatus(AccommodationBookingStatus::Confirmed);
$mailer = $this->createMock(Mailer::class);
$mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down'));
@@ -445,7 +519,7 @@ class AccommodationBookingServiceTest extends TestCase
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger);
- $service->sendOfferAcceptedCustomerEmail($booking);
+ $service->sendBookingConfirmedCustomerEmail($booking);
}
public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void