From d0358e584c6137730c0e311c13ee2d0041cb6749 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Fri, 17 Jul 2026 12:30:14 +0200 Subject: [PATCH] wip: notification emails surrounding groups bookings and inquiries --- src/Service/AccommodationBookingService.php | 55 ++++++- .../email/accommodation_booking.html.twig | 134 ------------------ .../accommodation_booking_customer.html.twig | 19 --- templates/email/offer_accepted.html.twig | 27 ++++ .../email/offer_accepted_customer.html.twig | 33 +++++ .../AccommodationBookingServiceTest.php | 57 +++++++- 6 files changed, 163 insertions(+), 162 deletions(-) create mode 100644 templates/email/offer_accepted.html.twig create mode 100644 templates/email/offer_accepted_customer.html.twig diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index e1a8dec3..8c0d4819 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -346,7 +346,6 @@ class AccommodationBookingService $this->mailer->createAndSendEmail( [ 'booking' => $booking, - 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [], 'accessLink' => $this->accessLinkOrNull($booking), ], [ @@ -393,7 +392,6 @@ class AccommodationBookingService $this->mailer->createAndSendEmail( [ 'booking' => $booking, - 'priceBreakdown' => $this->breakdownCalculator->compute($booking) ?? [], 'accessLink' => $this->accessLinkOrNull($booking), ], [ @@ -425,8 +423,8 @@ class AccommodationBookingService } /** - * Transitions a booking from inquiry to booking. Idempotent — a no-op if the booking - * is already accepted. + * Transitions a booking from inquiry to booking and notifies office and customer. + * Idempotent — a no-op (including no emails) if the booking is already accepted. */ public function acceptBooking(AccommodationBooking $booking): void { @@ -437,6 +435,55 @@ class AccommodationBookingService $booking->setIsInquiry(false); $booking->setAcceptedAt(new \DateTimeImmutable()); $this->entityManager->flush(); + + $this->sendOfferAcceptedNotificationEmail($booking); + $this->sendOfferAcceptedCustomerEmail($booking); + } + + public function sendOfferAcceptedNotificationEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $this->officeEmail, + 'subject' => sprintf('Angebot angenommen: %s', $booking->getAccommodation()?->getName()), + 'template' => 'email/offer_accepted.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send offer accepted notification email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } + } + + public function sendOfferAcceptedCustomerEmail(AccommodationBooking $booking): void + { + try { + $this->mailer->createAndSendEmail( + [ + 'booking' => $booking, + 'accessLink' => $this->accessLinkOrNull($booking), + ], + [ + 'to' => $booking->getEmail(), + 'subject' => 'Deine Buchung ist bestätigt', + 'template' => 'email/offer_accepted_customer.html.twig', + 'attachments' => [], + ], + ); + } catch (\Throwable $e) { + $this->logger->error('Failed to send offer accepted customer email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + } } private function accessLinkOrNull(AccommodationBooking $booking): ?string diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index d580ed9c..fc26b25d 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -5,15 +5,6 @@ Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %} -

- Art: - {% if booking.inquiry %} - Unverbindliche Anfrage - {% else %} - Verbindliche Buchung - {% endif %} -

- {% if accessLink %}

Kundenansicht öffnen @@ -34,130 +25,5 @@ Abreise {{ booking.dateTo|date('d.m.Y') }} - - Nächte - {{ booking.nights }} - - -

Personen

- - - - - - {% if booking.minorsCount > 0 %} - - - - - {% endif %} - {% if booking.childrenCount > 0 %} - - - - - {% endif %} -
Personen{{ booking.paxCount }}
Kinder (0–3 J.){{ booking.minorsCount }}
Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
- - {% if booking.boardServiceLabel %} -

Verpflegung

-

{{ booking.boardServiceLabel }}

- {% endif %} - - {% if booking.additionalServices is not empty %} -

Zusatzleistungen

- - {% endif %} - - {% if priceBreakdown is not empty %} - {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} -

Preise

- - {% 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 %} - - - - -
Basispreis{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.includedPax }} Pers.){% endif %}{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}
Aufpreis Personenzahl{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.){% endif %}{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}
Aufpreis Kurzzeit{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}
Verpflegung{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}
Verpflegungs-Aufschlag (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){{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
- {% endif %} - -

Kontakt

- - - - - - - - - - {% if booking.phone %} - - - - - {% endif %} - {% if booking.street or booking.zip or booking.city %} - - - - - {% endif %} -
Name{{ booking.salutation }} {{ booking.firstName }} {{ booking.lastName }}
E-Mail{{ booking.email }}
Telefon{{ booking.phone }}
Adresse - {% if booking.street %}{{ booking.street }}
{% endif %} - {% if booking.zip or booking.city %}{{ booking.zip }} {{ booking.city }}{% endif %} -
- - {% if booking.remarks %} -

Anmerkungen

-

{{ booking.remarks|nl2br }}

- {% endif %} {% endblock %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig index 413a8a4d..775db76b 100644 --- a/templates/email/accommodation_booking_customer.html.twig +++ b/templates/email/accommodation_booking_customer.html.twig @@ -41,26 +41,7 @@ Abreise {{ booking.dateTo|date('d.m.Y') }} - - Nächte - {{ booking.nights }} - - - Personen - {{ booking.paxCount }} - - {% if priceBreakdown is not empty %} - {% set currency = booking.pricingCurrency ?? priceBreakdown.currency %} -

Preise

- - - - - -
Gesamtpreis (zzgl. ortsabhängiger Gebühren){{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}
- {% endif %} -

Bei Fragen antworte einfach auf diese E-Mail.

{% endblock %} diff --git a/templates/email/offer_accepted.html.twig b/templates/email/offer_accepted.html.twig new file mode 100644 index 00000000..7c1cd551 --- /dev/null +++ b/templates/email/offer_accepted.html.twig @@ -0,0 +1,27 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

Angebot angenommen

+ + {% if accessLink %} +

+ Kundenansicht öffnen +

+ {% endif %} + +

Unterkunft

+ + + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+{% endblock %} diff --git a/templates/email/offer_accepted_customer.html.twig b/templates/email/offer_accepted_customer.html.twig new file mode 100644 index 00000000..2d27e338 --- /dev/null +++ b/templates/email/offer_accepted_customer.html.twig @@ -0,0 +1,33 @@ +{% extends 'email/layout.html.twig' %} + +{% block body %} +

Deine Buchung ist bestätigt

+ +

+ Hallo {{ booking.firstName }}, vielen Dank! Deine Buchung ist jetzt verbindlich bestätigt. +

+ + {% if accessLink %} +

+ Buchung ansehen +

+ {% endif %} + +

Unterkunft

+ + + + + + + + + + + + + +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+ +

Bei Fragen antworte einfach auf diese E-Mail.

+{% endblock %} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 0e642c3c..d44a79f9 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -203,15 +203,26 @@ class AccommodationBookingServiceTest extends TestCase self::assertNotSame($previousIssuedAt, $booking->getAccessLinkIssuedAt()); } - public function testAcceptBookingTransitionsInquiryToBooking(): void + public function testAcceptBookingTransitionsInquiryToBookingAndSendsNotifications(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::once())->method('flush'); - $service = $this->createServiceWithAccommodation(entityManager: $entityManager); - $booking = new AccommodationBooking(); $booking->setIsInquiry(true); + $booking->setEmail('customer@example.com'); + + $mailer = $this->createMock(Mailer::class); + $mailer + ->expects(self::exactly(2)) + ->method('createAndSendEmail') + ->with( + self::anything(), + self::callback(static fn (array $options) => in_array($options['to'], ['office@example.com', 'customer@example.com'], true) + && in_array($options['template'], ['email/offer_accepted.html.twig', 'email/offer_accepted_customer.html.twig'], true)), + ); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $service->acceptBooking($booking); @@ -219,12 +230,15 @@ class AccommodationBookingServiceTest extends TestCase self::assertNotNull($booking->getAcceptedAt()); } - public function testAcceptBookingIsIdempotent(): void + public function testAcceptBookingIsIdempotentAndSendsNoNotifications(): void { $entityManager = $this->createMock(EntityManagerInterface::class); $entityManager->expects(self::never())->method('flush'); - $service = $this->createServiceWithAccommodation(entityManager: $entityManager); + $mailer = $this->createMock(Mailer::class); + $mailer->expects(self::never())->method('createAndSendEmail'); + + $service = $this->createServiceWithAccommodation(entityManager: $entityManager, mailer: $mailer); $booking = new AccommodationBooking(); $booking->setIsInquiry(false); @@ -234,6 +248,39 @@ class AccommodationBookingServiceTest extends TestCase self::assertNull($booking->getAcceptedAt()); } + public function testSendOfferAcceptedNotificationEmailLogsAndSwallowsMailerFailures(): void + { + $booking = new AccommodationBooking(); + $booking->setIsInquiry(false); + + $mailer = $this->createMock(Mailer::class); + $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); + + $service->sendOfferAcceptedNotificationEmail($booking); + } + + public function testSendOfferAcceptedCustomerEmailLogsAndSwallowsMailerFailures(): void + { + $booking = new AccommodationBooking(); + $booking->setEmail('customer@example.com'); + $booking->setIsInquiry(false); + + $mailer = $this->createMock(Mailer::class); + $mailer->method('createAndSendEmail')->willThrowException(new \RuntimeException('SMTP down')); + + $logger = $this->createMock(LoggerInterface::class); + $logger->expects(self::once())->method('error'); + + $service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger); + + $service->sendOfferAcceptedCustomerEmail($booking); + } + public function testRefreshPriceSnapshotStoresDiscountedFinalTotal(): void { $booking = new AccommodationBooking();