wip: notification emails surrounding groups bookings and inquiries
This commit is contained in:
1 parent
a1751f699b
commit
d0358e584c
6 files changed
+163
-162
No files matched your search
@@ -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 %}
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user