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 @@
| Personen | -{{ booking.paxCount }} | -
| Kinder (0–3 J.) | -{{ booking.minorsCount }} | -
| Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.) | -{{ booking.childrenCount }} | -
{{ booking.boardServiceLabel }}
- {% endif %} - - {% if booking.additionalServices is not empty %} -| 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) }} | -
| Name | -{{ booking.salutation }} {{ booking.firstName }} {{ booking.lastName }} | -
| {{ booking.email }} | -|
| Telefon | -{{ booking.phone }} | -
| Adresse | -
- {% if booking.street %}{{ booking.street }} {% endif %} - {% if booking.zip or booking.city %}{{ booking.zip }} {{ booking.city }}{% endif %} - |
-
{{ 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 @@| Gesamtpreis (zzgl. ortsabhängiger Gebühren) | -{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }} | -
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 %} +| Name | +{{ booking.accommodation.name }} | +
| Anreise | +{{ booking.dateFrom|date('d.m.Y') }} | +
| Abreise | +{{ booking.dateTo|date('d.m.Y') }} | +
+ Hallo {{ booking.firstName }}, vielen Dank! Deine Buchung ist jetzt verbindlich bestätigt. +
+ + {% if accessLink %} ++ Buchung ansehen +
+ {% endif %} + +| 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();