wip: notification emails surrounding groups bookings and inquiries

This commit is contained in:
Björn Fromme committed 2026-07-17 12:30:14 +02:00
1 parent a1751f699b
commit d0358e584c
6 files changed
+163 -162

No files matched your search

+51 -4
View File
@@ -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
@@ -5,15 +5,6 @@
Neue Unterkunfts{% if booking.inquiry %}anfrage{% else %}buchung{% endif %}
</h1>
<p>
<strong>Art:</strong>
{% if booking.inquiry %}
Unverbindliche Anfrage
{% else %}
Verbindliche Buchung
{% endif %}
</p>
{% if accessLink %}
<p>
<a href="{{ accessLink }}" class="button">Kundenansicht öffnen</a>
@@ -34,130 +25,5 @@
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Nächte</strong></td>
<td>{{ booking.nights }}</td>
</tr>
</table>
<h2>Personen</h2>
<table>
<tr>
<td><strong>Personen</strong></td>
<td>{{ booking.paxCount }}</td>
</tr>
{% if booking.minorsCount > 0 %}
<tr>
<td><strong>Kinder (0–3 J.)</strong></td>
<td>{{ booking.minorsCount }}</td>
</tr>
{% endif %}
{% if booking.childrenCount > 0 %}
<tr>
<td><strong>Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.)</strong></td>
<td>{{ booking.childrenCount }}</td>
</tr>
{% endif %}
</table>
{% if booking.boardServiceLabel %}
<h2>Verpflegung</h2>
<p>{{ booking.boardServiceLabel }}</p>
{% endif %}
{% if booking.additionalServices is not empty %}
<h2>Zusatzleistungen</h2>
<ul>
{% for service in booking.additionalServices %}
<li>{{ service.label }}</li>
{% endfor %}
</ul>
{% endif %}
{% if priceBreakdown is not empty %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
{% if priceBreakdown.basePrice > 0 %}
<tr>
<td><strong>Basispreis</strong>{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.includedPax }} Pers.){% endif %}</td>
<td>{{ (priceBreakdown.basePrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.additionalPersonsPrice > 0 %}
<tr>
<td><strong>Aufpreis Personenzahl</strong>{% if priceBreakdown.includedPax is defined %} ({{ priceBreakdown.effectivePax - priceBreakdown.includedPax }} Pers.){% endif %}</td>
<td>{{ (priceBreakdown.additionalPersonsPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.shortTermSurcharge > 0 %}
<tr>
<td><strong>Aufpreis Kurzzeit</strong></td>
<td>{{ (priceBreakdown.shortTermSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.boardPrice > 0 %}
<tr>
<td><strong>Verpflegung</strong></td>
<td>{{ (priceBreakdown.boardPrice / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% if priceBreakdown.undersubscriptionSurcharge > 0 %}
<tr>
<td><strong>Verpflegungs-Aufschlag</strong> (unter {{ priceBreakdown.undersubscriptionThreshold }} Pers.)</td>
<td>{{ (priceBreakdown.undersubscriptionSurcharge / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% for service in priceBreakdown.serviceDetails %}
{% if service.price > 0 %}
<tr>
<td><strong>{{ service.label }}</strong></td>
<td>{{ (service.price / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
{% endfor %}
{% if priceBreakdown.runningCosts > 0 %}
<tr>
<td><strong>Strom- und Abfallgebühren</strong></td>
<td>{{ (priceBreakdown.runningCosts / 100)|format_currency(currency) }}</td>
</tr>
{% endif %}
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
<h2>Kontakt</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.salutation }} {{ booking.firstName }} {{ booking.lastName }}</td>
</tr>
<tr>
<td><strong>E-Mail</strong></td>
<td><a href="mailto:{{ booking.email }}">{{ booking.email }}</a></td>
</tr>
{% if booking.phone %}
<tr>
<td><strong>Telefon</strong></td>
<td>{{ booking.phone }}</td>
</tr>
{% endif %}
{% if booking.street or booking.zip or booking.city %}
<tr>
<td><strong>Adresse</strong></td>
<td>
{% if booking.street %}{{ booking.street }}<br>{% endif %}
{% if booking.zip or booking.city %}{{ booking.zip }} {{ booking.city }}{% endif %}
</td>
</tr>
{% endif %}
</table>
{% if booking.remarks %}
<h2>Anmerkungen</h2>
<p>{{ booking.remarks|nl2br }}</p>
{% endif %}
{% endblock %}
@@ -41,26 +41,7 @@
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Nächte</strong></td>
<td>{{ booking.nights }}</td>
</tr>
<tr>
<td><strong>Personen</strong></td>
<td>{{ booking.paxCount }}</td>
</tr>
</table>
{% if priceBreakdown is not empty %}
{% set currency = booking.pricingCurrency ?? priceBreakdown.currency %}
<h2>Preise</h2>
<table>
<tr>
<td><strong>Gesamtpreis</strong> <em>(zzgl. ortsabhängiger Gebühren)</em></td>
<td><strong>{{ ((booking.totalPrice ?? priceBreakdown.total) / 100)|format_currency(currency) }}</strong></td>
</tr>
</table>
{% endif %}
<p class="small">Bei Fragen antworte einfach auf diese E-Mail.</p>
{% endblock %}
+27
View File
@@ -0,0 +1,27 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>Angebot angenommen</h1>
{% if accessLink %}
<p>
<a href="{{ accessLink }}" class="button">Kundenansicht öffnen</a>
</p>
{% endif %}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends 'email/layout.html.twig' %}
{% block body %}
<h1>Deine Buchung ist bestätigt</h1>
<p>
Hallo {{ booking.firstName }}, vielen Dank! Deine Buchung ist jetzt verbindlich bestätigt.
</p>
{% if accessLink %}
<p>
<a href="{{ accessLink }}" class="button">Buchung ansehen</a>
</p>
{% endif %}
<h2>Unterkunft</h2>
<table>
<tr>
<td><strong>Name</strong></td>
<td>{{ booking.accommodation.name }}</td>
</tr>
<tr>
<td><strong>Anreise</strong></td>
<td>{{ booking.dateFrom|date('d.m.Y') }}</td>
</tr>
<tr>
<td><strong>Abreise</strong></td>
<td>{{ booking.dateTo|date('d.m.Y') }}</td>
</tr>
</table>
<p class="small">Bei Fragen antworte einfach auf diese E-Mail.</p>
{% endblock %}
@@ -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('[email protected]');
$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'], ['[email protected]', '[email protected]'], 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('[email protected]');
$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();