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') }} +

+ {% endif %} {% if form.managedBy is defined %}
{{ form_row(form.managedBy) }} diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 1f123eb..41bcd15 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -66,7 +66,7 @@ {% endif %} - {{ booking.type.label }} · {{ booking.status.label }} + {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} {% set discounts = [] %} diff --git a/templates/admin/accommodation_booking/modal_confirm.html.twig b/templates/admin/accommodation_booking/modal_confirm.html.twig new file mode 100644 index 0000000..2766fa4 --- /dev/null +++ b/templates/admin/accommodation_booking/modal_confirm.html.twig @@ -0,0 +1,13 @@ +{% extends 'htmx_confirmation_modal.html.twig' %} + +{% block title %}Buchung bestätigen{% endblock %} + +{% block content %} +
+ 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. +
+{% endblock %} + +{% block button_confirm %}Bestätigen{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index 2cea9ba..d812387 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -2,7 +2,7 @@ {% block content %} - {{ booking.type.label }} · {{ booking.status.label }} + {{ booking.type.label }} · {% include 'admin/accommodation_booking/_status_label.html.twig' %} {{ booking.groupName }} @@ -56,11 +56,15 @@
Art
{{ booking.type.label }}
Status
-
{{ booking.status.label }}
+
{% include 'admin/accommodation_booking/_status_label.html.twig' %}
{% if booking.acceptedAt is not null %}
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') }}
+ {% endif %}
@@ -188,6 +192,23 @@ {% endif %} + {% if booking.received %} +
+

Bestätigung

+

+ 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;' %} + +

Preise

+ + + + + + + + + + + {% if booking.minorsCount > 0 %} + + + + + {% endif %} + {% if booking.childrenCount > 0 %} + + + + + {% endif %} + {% if priceBreakdown.basePrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.additionalPersonsPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.shortTermSurcharge > 0 %} + + + + + {% endif %} + {% if priceBreakdown.boardPrice > 0 %} + + + + + {% endif %} + {% if priceBreakdown.undersubscriptionSurcharge > 0 %} + + + + + {% endif %} + {% for service in priceBreakdown.serviceDetails %} + {% if service.price > 0 %} + + + + + {% endif %} + {% endfor %} + {% if priceBreakdown.runningCosts > 0 %} + + + + + {% endif %} + + + + + {% 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 %} + + + + + {% endif %} + {% if booking.boardServiceDiscount is not null %} + + + + + {% endif %} + {% if booking.additionalServicesDiscount is not null %} + + + + + {% 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. -#} + + + + + {% endif %} +
Zeitraum + {{ booking.dateFrom|date('d.m.Y') }} – {{ booking.dateTo|date('d.m.Y') }}, + {{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }} +
Anzahl Personen{{ booking.paxCount }}
davon Kinder (0–3 J.){{ booking.minorsCount }}
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
+ Basispreis + für {{ priceBreakdown.includedPax }} Pers. + {{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}
+ Aufpreis Personenzahl + für {{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers. + {{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}
Aufpreis Kurzzeit{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}
Verpflegung{% if booking.boardServiceLabel %} ({{ booking.boardServiceLabel }}){% endif %}{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}
+ Verpflegungs-Aufschlag + für Gruppen unter {{ priceBreakdown.undersubscriptionThreshold }} Pers. + {{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}
{{ service.label }}{{ (service.price / 100)|format_currency(currency) }}
Strom- und Abfallgebühren{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}
+ Gesamtpreis + zzgl. ortsabhängiger Gebühren + + {{ (priceBreakdown.total / 100)|format_currency(currency) }} +
Rabatt Unterkunft {{ booking.accommodationDiscount }} %– {{ (accommodationDiscountAmount / 100)|format_currency(currency) }}
Rabatt Verpflegung {{ booking.boardServiceDiscount }} %– {{ (boardDiscountAmount / 100)|format_currency(currency) }}
Rabatt Zusatzleistungen {{ booking.additionalServicesDiscount }} %– {{ (servicesDiscountAmount / 100)|format_currency(currency) }}
Gesamtpreis nach Rabatt + {{ ((booking.totalPrice ?? (priceBreakdown.total - discountSum)) / 100)|format_currency(currency) }} +
+{% endif %} diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index fc26b25..cd9d75a 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -26,4 +26,6 @@ {{ booking.dateTo|date('d.m.Y') }} + + {% 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. +

- {{ booking.accepted ? 'Buchung ansehen' : 'Angebot ansehen & bestätigen' }} + Angebot ansehen & buchen

{% else %} @@ -64,6 +52,8 @@ + {% include 'email/_price_breakdown.html.twig' %} +

Bei Fragen antworte einfach auf diese 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 %}

Anmerkungen

{{ booking.remarks|nl2br }}

diff --git a/templates/groups/booking/offer.html.twig b/templates/groups/booking/offer.html.twig index a27043c..b5d3c96 100644 --- a/templates/groups/booking/offer.html.twig +++ b/templates/groups/booking/offer.html.twig @@ -24,7 +24,7 @@ {% 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 %} @@ -52,7 +52,7 @@

- {{ booking.accepted ? 'Deine Buchung' : 'Dein Angebot' }} + {{ booking.acceptedAt is not null ? 'Deine Buchung' : 'Dein Angebot' }}

{{ ctx.accommodation.name }} @@ -63,7 +63,7 @@ {% if booking.boardServiceLabel or booking.additionalServices | length > 0 %}

- {{ booking.accepted ? 'Gebuchte' : 'Angefragte' }} Leistungen + {{ booking.acceptedAt is not null ? 'Gebuchte' : 'Angefragte' }} Leistungen

    {% if booking.boardServiceLabel %} @@ -86,11 +86,19 @@ Angebot verbindlich buchen
+ {% elseif booking.confirmedAt is not null %} +
+ {{ icon('info', 'w-5 h-5') }} +
+ 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