feat: booking details pdf

This commit is contained in:
Björn Fromme
2026-08-18 14:26:24 +02:00
parent d3666c4618
commit 2d7a02341d
19 changed files with 1054 additions and 100 deletions
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingPdfGenerator;
use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Extra\Intl\IntlExtension;
use Twig\Loader\FilesystemLoader;
/**
* Renders through the real templates and dompdf — the point of this service is that the
* document survives the trip through dompdf, which a mocked Twig would not prove.
*/
class AccommodationBookingPdfGeneratorTest extends TestCase
{
public function testTheDocumentFitsOnASingleA4Page(): void
{
$pdf = $this->createGenerator($this->breakdown())->render($this->booking());
self::assertStringStartsWith('%PDF-', $pdf);
self::assertSame(1, $this->pageCount($pdf));
}
public function testTheDownloadIsAnAttachmentNamedAfterTheGroup(): void
{
$response = $this->createGenerator($this->breakdown())->createDownloadResponse($this->booking());
self::assertSame('application/pdf', $response->headers->get('Content-Type'));
self::assertStringContainsString('attachment;', (string) $response->headers->get('Content-Disposition'));
self::assertStringContainsString('buchung-2026-07-06-schulklasse-7b.pdf', (string) $response->headers->get('Content-Disposition'));
}
public function testABookingWithoutPricesCannotBeRendered(): void
{
$this->expectException(\RuntimeException::class);
$this->createGenerator(null)->render($this->booking());
}
/**
* @param array<string, mixed>|null $breakdown
*/
private function createGenerator(?array $breakdown): AccommodationBookingPdfGenerator
{
$twig = new Environment(new FilesystemLoader(__DIR__.'/../../templates'));
$twig->addExtension(new IntlExtension());
$calculator = $this->createMock(AccommodationBookingBreakdownCalculator::class);
$calculator->method('compute')->willReturn($breakdown);
return new AccommodationBookingPdfGenerator($twig, $calculator, \dirname(__DIR__, 2));
}
private function booking(): AccommodationBooking
{
$accommodation = new Accommodation();
$accommodation->setName('Haus Bergblick');
$accommodation->setMaxAdolescentAge(11);
$accommodation->setCurrency('EUR');
$booking = new AccommodationBooking();
$booking->setAccommodation($accommodation);
$booking->setGroupName('Schulklasse 7b');
$booking->setSalutation('Frau');
$booking->setFirstName('Änne');
$booking->setLastName('Müller');
$booking->setEmail('[email protected]');
$booking->setStreet('Hauptstraße 1');
$booking->setZip('50667');
$booking->setCity('Köln');
$booking->setDateFrom(new \DateTimeImmutable('2026-07-06'));
$booking->setDateTo(new \DateTimeImmutable('2026-07-11'));
$booking->setPaxCount(32);
$booking->setChildrenCount(4);
$booking->setBoardServiceLabel('Vollpension');
$booking->addAdditionalServiceSnapshot('Bettwäsche', 900, 'per_person', 5);
$booking->setRemarks('Zwei Vegetarier.');
$booking->setConfirmedAt(new \DateTimeImmutable('2026-08-18'));
return $booking;
}
/**
* @return array<string, mixed>
*/
private function breakdown(): array
{
return [
'effectivePax' => 32,
'totalPax' => 32,
'includedPax' => 25,
'nights' => 5,
'basePrice' => 250000,
'additionalPersonsPrice' => 70000,
'shortTermSurcharge' => 0,
'undersubscriptionSurcharge' => 0,
'undersubscriptionThreshold' => 40,
'boardPrice' => 480000,
'serviceDetails' => [['label' => 'Bettwäsche', 'price' => 28800]],
'servicesPrice' => 28800,
'runningCosts' => 16000,
'total' => 844800,
'currency' => 'EUR',
'discounts' => [['label' => 'Unterkunft', 'percent' => 5, 'amount' => 16000]],
'discountedTotal' => 828800,
];
}
/**
* dompdf's CPDF backend writes one /MediaBox for the page tree node and one per page,
* so the page count is the number of occurrences minus one. Reading it back this way
* avoids re-implementing the render pipeline just to reach the canvas.
*/
private function pageCount(string $pdf): int
{
return substr_count($pdf, '/MediaBox') - 1;
}
}
@@ -5,6 +5,7 @@ declare(strict_types=1);
namespace App\Tests\Service;
use App\Email\Mailer;
use App\Email\PdfAttachment;
use App\Entity\Groups\Accommodation;
use App\Entity\Groups\AccommodationBooking;
use App\Entity\Groups\AccommodationPrice;
@@ -17,6 +18,7 @@ use App\Repository\Groups\AdditionalServiceRepository;
use App\Repository\Groups\BoardServiceRepository;
use App\Service\AccommodationBookingBreakdownCalculator;
use App\Service\AccommodationBookingLinkSigner;
use App\Service\AccommodationBookingPdfGenerator;
use App\Service\AccommodationBookingService;
use App\Service\CmsDataProvider;
use App\Service\PriceTimelineBuilder;
@@ -522,6 +524,57 @@ class AccommodationBookingServiceTest extends TestCase
$service->sendBookingConfirmedCustomerEmail($booking);
}
public function testTheConfirmationEmailCarriesTheBookingPdf(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$attachment = new PdfAttachment('%PDF-1.7', 'buchung.pdf');
$pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class);
$pdfGenerator->expects(self::once())->method('createAttachment')->with($booking)->willReturn($attachment);
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(fn (array $options) => [$attachment] === $options['attachments']),
);
$service = $this->createServiceWithAccommodation(mailer: $mailer, pdfGenerator: $pdfGenerator);
$service->sendBookingConfirmedCustomerEmail($booking);
}
public function testAFailingPdfStillLetsTheConfirmationEmailGoOut(): void
{
$booking = new AccommodationBooking();
$booking->setEmail('[email protected]');
$booking->setStatus(AccommodationBookingStatus::Confirmed);
$pdfGenerator = $this->createMock(AccommodationBookingPdfGenerator::class);
$pdfGenerator->method('createAttachment')->willThrowException(new \RuntimeException('no prices'));
$mailer = $this->createMock(Mailer::class);
$mailer
->expects(self::once())
->method('createAndSendEmail')
->with(
self::anything(),
self::callback(fn (array $options) => [] === $options['attachments']),
);
$logger = $this->createMock(LoggerInterface::class);
$logger->expects(self::once())->method('warning');
$service = $this->createServiceWithAccommodation(mailer: $mailer, logger: $logger, pdfGenerator: $pdfGenerator);
$service->sendBookingConfirmedCustomerEmail($booking);
}
/**
* The discount arithmetic itself lives in — and is tested with —
* AccommodationBookingBreakdownCalculator; what matters here is that the raw breakdown is
@@ -563,6 +616,7 @@ class AccommodationBookingServiceTest extends TestCase
?LoggerInterface $logger = null,
?AccommodationBookingLinkSigner $linkSigner = null,
?AccommodationBookingBreakdownCalculator $breakdownCalculator = null,
?AccommodationBookingPdfGenerator $pdfGenerator = null,
): AccommodationBookingService {
$accommodation = (new Accommodation())
->setName('Hotel')
@@ -592,6 +646,7 @@ class AccommodationBookingServiceTest extends TestCase
$this->createMock(CmsDataProvider::class),
$linkSigner ?? $this->createMock(AccommodationBookingLinkSigner::class),
$breakdownCalculator ?? $this->createMock(AccommodationBookingBreakdownCalculator::class),
$pdfGenerator ?? $this->createMock(AccommodationBookingPdfGenerator::class),
'[email protected]',
);
}