diff --git a/composer.json b/composer.json index 24528ee..062f94b 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "doctrine/doctrine-bundle": "^2.13", "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.3", + "dompdf/dompdf": "^3.1", "fakerphp/faker": "^1.24", "flagception/flagception-bundle": "^6.1", "knplabs/knp-menu-bundle": "^3.7", diff --git a/composer.lock b/composer.lock index ae84322..8585326 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "f04202a445d42ebff0dd0e8dce335163", + "content-hash": "2cd2256c9e4ddda2be9bb1c611b9b7e8", "packages": [ { "name": "carbonphp/carbon-doctrine-types", @@ -1344,6 +1344,161 @@ }, "time": "2026-02-08T16:21:46+00:00" }, + { + "name": "dompdf/dompdf", + "version": "v3.1.6", + "source": { + "type": "git", + "url": "https://github.com/dompdf/dompdf.git", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/dompdf/zipball/6d4b4eb8500f7a786da8868ba463a71b725a4005", + "reference": "6d4b4eb8500f7a786da8868ba463a71b725a4005", + "shasum": "" + }, + "require": { + "dompdf/php-font-lib": "^1.0.0", + "dompdf/php-svg-lib": "^1.0.0", + "ext-dom": "*", + "ext-mbstring": "*", + "masterminds/html5": "^2.0", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "ext-gd": "*", + "ext-json": "*", + "ext-zip": "*", + "mockery/mockery": "^1.3", + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11", + "squizlabs/php_codesniffer": "^3.5", + "symfony/process": "^4.4 || ^5.4 || ^6.2 || ^7.0" + }, + "suggest": { + "ext-gd": "Needed to process images", + "ext-gmagick": "Improves image processing performance", + "ext-imagick": "Improves image processing performance", + "ext-zlib": "Needed for pdf stream compression" + }, + "type": "library", + "autoload": { + "psr-4": { + "Dompdf\\": "src/" + }, + "classmap": [ + "lib/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1" + ], + "authors": [ + { + "name": "The Dompdf Community", + "homepage": "https://github.com/dompdf/dompdf/blob/master/AUTHORS.md" + } + ], + "description": "DOMPDF is a CSS 2.1 compliant HTML to PDF converter", + "homepage": "https://github.com/dompdf/dompdf", + "support": { + "issues": "https://github.com/dompdf/dompdf/issues", + "source": "https://github.com/dompdf/dompdf/tree/v3.1.6" + }, + "time": "2026-07-20T12:29:38+00:00" + }, + { + "name": "dompdf/php-font-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-font-lib.git", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-font-lib/zipball/a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "reference": "a6e9a688a2a80016ac080b97be73d3e10c444c9a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11 || ^12" + }, + "type": "library", + "autoload": { + "psr-4": { + "FontLib\\": "src/FontLib" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-2.1-or-later" + ], + "authors": [ + { + "name": "The FontLib Community", + "homepage": "https://github.com/dompdf/php-font-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse, export and make subsets of different types of font files.", + "homepage": "https://github.com/dompdf/php-font-lib", + "support": { + "issues": "https://github.com/dompdf/php-font-lib/issues", + "source": "https://github.com/dompdf/php-font-lib/tree/1.0.2" + }, + "time": "2026-01-20T14:10:26+00:00" + }, + { + "name": "dompdf/php-svg-lib", + "version": "1.0.2", + "source": { + "type": "git", + "url": "https://github.com/dompdf/php-svg-lib.git", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/dompdf/php-svg-lib/zipball/8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "reference": "8259ffb930817e72b1ff1caef5d226501f3dfeb1", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabberworm/php-css-parser": "^8.4 || ^9.0" + }, + "require-dev": { + "phpunit/phpunit": "^7.5 || ^8 || ^9 || ^10 || ^11" + }, + "type": "library", + "autoload": { + "psr-4": { + "Svg\\": "src/Svg" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "LGPL-3.0-or-later" + ], + "authors": [ + { + "name": "The SvgLib Community", + "homepage": "https://github.com/dompdf/php-svg-lib/blob/master/AUTHORS.md" + } + ], + "description": "A library to read, parse and export to PDF SVG files.", + "homepage": "https://github.com/dompdf/php-svg-lib", + "support": { + "issues": "https://github.com/dompdf/php-svg-lib/issues", + "source": "https://github.com/dompdf/php-svg-lib/tree/1.0.2" + }, + "time": "2026-01-02T16:01:13+00:00" + }, { "name": "dragonmantank/cron-expression", "version": "v3.6.0", @@ -4527,6 +4682,86 @@ }, "time": "2021-10-29T13:26:27+00:00" }, + { + "name": "sabberworm/php-css-parser", + "version": "v9.4.0", + "source": { + "type": "git", + "url": "https://github.com/MyIntervals/PHP-CSS-Parser.git", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/MyIntervals/PHP-CSS-Parser/zipball/fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "reference": "fd3bf9fb173e0df649bc4e3e0d088a1b2417c08f", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": "^7.2.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "thecodingmachine/safe": "^1.3 || ^2.5 || ^3.4" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "1.4.0", + "phpstan/extension-installer": "1.4.3", + "phpstan/phpstan": "1.12.33 || 2.2.2", + "phpstan/phpstan-phpunit": "1.4.2 || 2.0.16", + "phpstan/phpstan-strict-rules": "1.6.2 || 2.0.11", + "phpunit/phpunit": "8.5.52", + "rawr/phpunit-data-provider": "3.3.1", + "rector/rector": "1.2.10 || 2.4.6", + "rector/type-perfect": "1.0.0 || 2.1.3", + "squizlabs/php_codesniffer": "4.0.1", + "thecodingmachine/phpstan-safe-rule": "1.2.0 || 1.4.3" + }, + "suggest": { + "ext-mbstring": "for parsing UTF-8 CSS" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "9.5.x-dev" + } + }, + "autoload": { + "files": [ + "src/Rule/Rule.php", + "src/RuleSet/RuleContainer.php" + ], + "psr-4": { + "Sabberworm\\CSS\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Raphael Schweikert" + }, + { + "name": "Oliver Klee", + "email": "github@oliverklee.de" + }, + { + "name": "Jake Hotson", + "email": "jake.github@qzdesign.co.uk" + } + ], + "description": "Parser for CSS Files written in PHP", + "homepage": "https://www.sabberworm.com/blog/2010/6/10/php-css-parser", + "keywords": [ + "css", + "parser", + "stylesheet" + ], + "support": { + "issues": "https://github.com/MyIntervals/PHP-CSS-Parser/issues", + "source": "https://github.com/MyIntervals/PHP-CSS-Parser/tree/v9.4.0" + }, + "time": "2026-06-18T15:10:53+00:00" + }, { "name": "spatie/crypto", "version": "2.1.2", @@ -10679,6 +10914,149 @@ ], "time": "2026-07-20T15:18:49+00:00" }, + { + "name": "thecodingmachine/safe", + "version": "v3.4.0", + "source": { + "type": "git", + "url": "https://github.com/thecodingmachine/safe.git", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/705683a25bacf0d4860c7dea4d7947bfd09eea19", + "reference": "705683a25bacf0d4860c7dea4d7947bfd09eea19", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "php-parallel-lint/php-parallel-lint": "^1.4", + "phpstan/phpstan": "^2", + "phpunit/phpunit": "^10", + "squizlabs/php_codesniffer": "^3.2" + }, + "type": "library", + "autoload": { + "files": [ + "lib/special_cases.php", + "generated/apache.php", + "generated/apcu.php", + "generated/array.php", + "generated/bzip2.php", + "generated/calendar.php", + "generated/classobj.php", + "generated/com.php", + "generated/cubrid.php", + "generated/curl.php", + "generated/datetime.php", + "generated/dir.php", + "generated/eio.php", + "generated/errorfunc.php", + "generated/exec.php", + "generated/fileinfo.php", + "generated/filesystem.php", + "generated/filter.php", + "generated/fpm.php", + "generated/ftp.php", + "generated/funchand.php", + "generated/gettext.php", + "generated/gmp.php", + "generated/gnupg.php", + "generated/hash.php", + "generated/ibase.php", + "generated/ibmDb2.php", + "generated/iconv.php", + "generated/image.php", + "generated/imap.php", + "generated/info.php", + "generated/inotify.php", + "generated/json.php", + "generated/ldap.php", + "generated/libxml.php", + "generated/lzf.php", + "generated/mailparse.php", + "generated/mbstring.php", + "generated/misc.php", + "generated/mysql.php", + "generated/mysqli.php", + "generated/network.php", + "generated/oci8.php", + "generated/opcache.php", + "generated/openssl.php", + "generated/outcontrol.php", + "generated/pcntl.php", + "generated/pcre.php", + "generated/pgsql.php", + "generated/posix.php", + "generated/ps.php", + "generated/pspell.php", + "generated/readline.php", + "generated/rnp.php", + "generated/rpminfo.php", + "generated/rrd.php", + "generated/sem.php", + "generated/session.php", + "generated/shmop.php", + "generated/sockets.php", + "generated/sodium.php", + "generated/solr.php", + "generated/spl.php", + "generated/sqlsrv.php", + "generated/ssdeep.php", + "generated/ssh2.php", + "generated/stream.php", + "generated/strings.php", + "generated/swoole.php", + "generated/uodbc.php", + "generated/uopz.php", + "generated/url.php", + "generated/var.php", + "generated/xdiff.php", + "generated/xml.php", + "generated/xmlrpc.php", + "generated/yaml.php", + "generated/yaz.php", + "generated/zip.php", + "generated/zlib.php" + ], + "classmap": [ + "lib/DateTime.php", + "lib/DateTimeImmutable.php", + "lib/Exceptions/", + "generated/Exceptions/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHP core functions that throw exceptions instead of returning FALSE on error", + "support": { + "issues": "https://github.com/thecodingmachine/safe/issues", + "source": "https://github.com/thecodingmachine/safe/tree/v3.4.0" + }, + "funding": [ + { + "url": "https://github.com/OskarStark", + "type": "github" + }, + { + "url": "https://github.com/shish", + "type": "github" + }, + { + "url": "https://github.com/silasjoisten", + "type": "github" + }, + { + "url": "https://github.com/staabm", + "type": "github" + } + ], + "time": "2026-02-04T18:08:13+00:00" + }, { "name": "twig/extra-bundle", "version": "v3.24.0", diff --git a/src/Controller/Admin/AccommodationBooking/PdfController.php b/src/Controller/Admin/AccommodationBooking/PdfController.php new file mode 100644 index 0000000..0add2e9 --- /dev/null +++ b/src/Controller/Admin/AccommodationBooking/PdfController.php @@ -0,0 +1,38 @@ +isConfirmed()) { + $this->addFlash('warning', 'Ein PDF kann nur für bestätigte Buchungen erstellt werden.'); + + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + + try { + return $this->pdfGenerator->createDownloadResponse($booking); + } catch (\RuntimeException $e) { + $this->addFlash('warning', 'Das PDF konnte nicht erstellt werden: '.$e->getMessage()); + + return $this->redirectToRoute('app_admin_accommodationbooking_show', ['id' => $booking->getId()]); + } + } +} diff --git a/src/Email/PdfAttachment.php b/src/Email/PdfAttachment.php new file mode 100644 index 0000000..ded4999 --- /dev/null +++ b/src/Email/PdfAttachment.php @@ -0,0 +1,24 @@ +attach($this->content, $this->filename, 'application/pdf'); + } +} diff --git a/src/Service/AccommodationBookingPdfGenerator.php b/src/Service/AccommodationBookingPdfGenerator.php new file mode 100644 index 0000000..0c199aa --- /dev/null +++ b/src/Service/AccommodationBookingPdfGenerator.php @@ -0,0 +1,117 @@ +breakdownCalculator->compute($booking); + + if (null === $breakdown) { + throw new \RuntimeException('Für diese Buchung liegt keine Preisaufschlüsselung vor.'); + } + + $html = $this->twig->render(self::TEMPLATE, [ + 'booking' => $booking, + 'priceBreakdown' => $breakdown, + 'currency' => $booking->getPricingCurrency() ?? $breakdown['currency'], + 'logoSrc' => $this->logoDataUri(), + ]); + + $options = new Options(); + // DejaVu Sans ships with dompdf and covers umlauts and the currency symbols + // emitted by format_currency. + $options->setDefaultFont('DejaVu Sans'); + $options->setDefaultPaperSize('A4'); + $options->setDefaultPaperOrientation('portrait'); + $options->setIsRemoteEnabled(false); + + $dompdf = new Dompdf($options); + $dompdf->loadHtml($html, 'UTF-8'); + $dompdf->render(); + + return (string) $dompdf->output(); + } + + /** + * @throws \RuntimeException when the booking carries no price information + */ + public function createDownloadResponse(AccommodationBooking $booking): Response + { + $response = new Response($this->render($booking)); + $response->headers->set('Content-Type', 'application/pdf'); + $response->headers->set('Content-Disposition', $response->headers->makeDisposition( + ResponseHeaderBag::DISPOSITION_ATTACHMENT, + $this->buildFilename($booking) + )); + + return $response; + } + + /** + * Named after arrival date and group rather than the entity id, which is a local + * detail with no counterpart in BusPro and would read as a booking number. + */ + /** + * @throws \RuntimeException when the booking carries no price information + */ + public function createAttachment(AccommodationBooking $booking): PdfAttachment + { + return new PdfAttachment($this->render($booking), $this->buildFilename($booking)); + } + + private function buildFilename(AccommodationBooking $booking): string + { + $groupName = (new AsciiSlugger('de'))->slug((string) $booking->getGroupName())->lower()->toString(); + + return sprintf( + 'buchung-%s%s.pdf', + $booking->getDateFrom()?->format('Y-m-d') ?? 'ohne-datum', + '' !== $groupName ? '-'.$groupName : '' + ); + } + + private function logoDataUri(): string + { + $logo = $this->projectDir.self::LOGO_PATH; + + if (false === is_readable($logo)) { + throw new \RuntimeException('Das Logo konnte nicht gelesen werden: '.self::LOGO_PATH); + } + + return 'data:image/png;base64,'.base64_encode((string) file_get_contents($logo)); + } +} diff --git a/src/Service/AccommodationBookingService.php b/src/Service/AccommodationBookingService.php index 71d6251..ee4e92a 100644 --- a/src/Service/AccommodationBookingService.php +++ b/src/Service/AccommodationBookingService.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service; +use App\Email\EmailAttachmentInterface; use App\Email\Mailer; use App\Entity\Groups\Accommodation; use App\Entity\Groups\AccommodationBooking; @@ -39,6 +40,7 @@ class AccommodationBookingService private readonly CmsDataProvider $cmsDataProvider, private readonly AccommodationBookingLinkSigner $linkSigner, private readonly AccommodationBookingBreakdownCalculator $breakdownCalculator, + private readonly AccommodationBookingPdfGenerator $pdfGenerator, private readonly string $accommodationEmail, ) { } @@ -396,6 +398,7 @@ class AccommodationBookingService string $subject, string $template, string $errorMessage, + bool $withPdf = false, ): void { if (null === $to || '' === trim($to)) { $this->logger->warning($errorMessage, [ @@ -421,7 +424,7 @@ class AccommodationBookingService 'to' => $to, 'subject' => $subject, 'template' => $template, - 'attachments' => [], + 'attachments' => $withPdf ? $this->bookingPdfAttachment($booking) : [], ], ); } catch (\Throwable $e) { @@ -562,9 +565,31 @@ class AccommodationBookingService 'Deine Buchung ist bestätigt', 'email/booking_confirmed_customer.html.twig', 'Failed to send booking confirmed customer email', + withPdf: true, ); } + /** + * A missing price snapshot must not cost the customer their confirmation, so a failed + * render degrades to sending the email without the document. Called only once a + * recipient is known, so no PDF is rendered for a mail that never goes out. + * + * @return list + */ + private function bookingPdfAttachment(AccommodationBooking $booking): array + { + try { + return [$this->pdfGenerator->createAttachment($booking)]; + } catch (\Throwable $e) { + $this->logger->warning('Failed to attach the booking PDF to the confirmation email', [ + 'booking_id' => $booking->getId(), + 'error' => $e->getMessage(), + ]); + + return []; + } + } + private function accessLinkOrNull(AccommodationBooking $booking): ?string { return null !== $booking->getAccessLinkIssuedAt() ? $this->linkSigner->sign($booking) : null; diff --git a/templates/admin/accommodation_booking/index.html.twig b/templates/admin/accommodation_booking/index.html.twig index 41bcd15..872dd17 100644 --- a/templates/admin/accommodation_booking/index.html.twig +++ b/templates/admin/accommodation_booking/index.html.twig @@ -89,6 +89,11 @@ bearbeiten + {% if booking.confirmed %} + + PDF herunterladen + + {% endif %} diff --git a/templates/admin/accommodation_booking/modal_confirm.html.twig b/templates/admin/accommodation_booking/modal_confirm.html.twig index 2766fa4..d616ce8 100644 --- a/templates/admin/accommodation_booking/modal_confirm.html.twig +++ b/templates/admin/accommodation_booking/modal_confirm.html.twig @@ -4,9 +4,8 @@ {% block content %}
- Möchtest du die Buchung für {{ booking.groupName }} verbindlich bestätigen? - {{ booking.email }} erhält daraufhin die Buchungsbestätigung inklusive - Preisübersicht per E-Mail. + Möchtest du die Buchung für {{ booking.groupName }} verbindlich bestätigen und die Bestätigungs-E-Mail + versenden?
{% endblock %} diff --git a/templates/admin/accommodation_booking/show.html.twig b/templates/admin/accommodation_booking/show.html.twig index 7f02673..a41666a 100644 --- a/templates/admin/accommodation_booking/show.html.twig +++ b/templates/admin/accommodation_booking/show.html.twig @@ -181,15 +181,14 @@

Bestätigung

- Der Kunde hat die Buchung verbindlich abgeschickt, aber noch keine Bestätigung - erhalten. Prüfe Leistungen und Kapazitäten und bestätige die Buchung erst dann. + Die Buchung des Kunden ist eingegangen, aber es wurde noch keine Bestätigung verschickt.

{% endif %} @@ -238,8 +237,15 @@ zurück - - bearbeiten - +
+ {% if booking.confirmed %} + + PDF herunterladen + + {% endif %} + + bearbeiten + +
{% endblock %} diff --git a/templates/email/_price_breakdown.html.twig b/templates/email/_price_breakdown.html.twig index 76913f0..67c5668 100644 --- a/templates/email/_price_breakdown.html.twig +++ b/templates/email/_price_breakdown.html.twig @@ -1,6 +1,8 @@ -{#- Price breakdown for booking emails. Mirrors groups/booking/_offer_summary.html.twig, but - with inline styles: the email layout ships no table styling and no Tailwind. - Context: booking, priceBreakdown, currency (set in AccommodationBookingService). -#} +{#- Prices only — travel dates and group size belong to _stay_details.html.twig and the + PDF's own data block. Mirrors groups/booking/_offer_summary.html.twig, but with inline + styles: neither the email layout nor dompdf ships table styling or Tailwind. + Context: booking, priceBreakdown, currency (set in AccommodationBookingService and + AccommodationBookingPdfGenerator). -#} {% if priceBreakdown is not null and currency is not null %} {% set cell = 'padding: 4px 0; text-align: left; vertical-align: top;' %} {% set amount = 'padding: 4px 0; text-align: right; vertical-align: top; white-space: nowrap;' %} @@ -9,29 +11,6 @@

Preise

- - - - - - - - - {% if booking.minorsCount > 0 %} - - - - - {% endif %} - {% if booking.childrenCount > 0 %} - - - - - {% endif %} {% if priceBreakdown.basePrice > 0 %}
Zeitraum - {{ booking.dateFrom|date('d.m.Y') }} – {{ booking.dateTo|date('d.m.Y') }}, - {{ booking.nights }} {{ booking.nights != 1 ? 'Nächte' : 'Nacht' }} -
Anzahl Personen{{ booking.paxCount }}
davon Kinder (0–3 J.){{ booking.minorsCount }}
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
diff --git a/templates/email/_stay_details.html.twig b/templates/email/_stay_details.html.twig new file mode 100644 index 0000000..a9d03b1 --- /dev/null +++ b/templates/email/_stay_details.html.twig @@ -0,0 +1,38 @@ +{#- Accommodation and group facts for booking emails, kept out of _price_breakdown.html.twig + so that partial holds prices only. Context: booking. -#} +

Unterkunft

+ + + + + + + + + + + + + + + + + + + + + + + {% if booking.minorsCount > 0 %} + + + + + {% endif %} + {% if booking.childrenCount > 0 %} + + + + + {% endif %} +
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
Nächte{{ booking.nights }}
Anzahl Personen{{ booking.paxCount }}
davon Kinder (0–3 J.){{ booking.minorsCount }}
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
diff --git a/templates/email/accommodation_booking.html.twig b/templates/email/accommodation_booking.html.twig index cd9d75a..0bd9ffa 100644 --- a/templates/email/accommodation_booking.html.twig +++ b/templates/email/accommodation_booking.html.twig @@ -11,21 +11,7 @@

{% endif %} -

Unterkunft

- - - - - - - - - - - - - -
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+ {% include 'email/_stay_details.html.twig' %} {% include 'email/_price_breakdown.html.twig' %} {% endblock %} diff --git a/templates/email/accommodation_booking_customer.html.twig b/templates/email/accommodation_booking_customer.html.twig index 9330433..117a46d 100644 --- a/templates/email/accommodation_booking_customer.html.twig +++ b/templates/email/accommodation_booking_customer.html.twig @@ -36,21 +36,7 @@

{% endif %} -

Unterkunft

- - - - - - - - - - - - - -
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+ {% include 'email/_stay_details.html.twig' %} {% include 'email/_price_breakdown.html.twig' %} diff --git a/templates/email/booking_confirmed_customer.html.twig b/templates/email/booking_confirmed_customer.html.twig index 500d3a8..be21a4e 100644 --- a/templates/email/booking_confirmed_customer.html.twig +++ b/templates/email/booking_confirmed_customer.html.twig @@ -27,24 +27,7 @@

{% endif %} -

- Unterkunft -

- - - - - - - - - - - - - - -
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+ {% include 'email/_stay_details.html.twig' %} {% include 'email/_price_breakdown.html.twig' %} diff --git a/templates/email/offer_accepted.html.twig b/templates/email/offer_accepted.html.twig index 2a535a5..033888c 100644 --- a/templates/email/offer_accepted.html.twig +++ b/templates/email/offer_accepted.html.twig @@ -9,21 +9,7 @@

{% endif %} -

Unterkunft

- - - - - - - - - - - - - -
Name{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
+ {% include 'email/_stay_details.html.twig' %} {% include 'email/_price_breakdown.html.twig' %} diff --git a/templates/pdf/accommodation_booking.html.twig b/templates/pdf/accommodation_booking.html.twig new file mode 100644 index 0000000..dca948b --- /dev/null +++ b/templates/pdf/accommodation_booking.html.twig @@ -0,0 +1,122 @@ + + + + + Buchung {{ booking.id }} – {{ booking.groupName }} + + + + + + + {#- No booking number: the local id has no counterpart in BusPro. -#} + + +
+ {% if booking.confirmedAt %}Bestätigt am {{ booking.confirmedAt|date('d.m.Y') }}{% endif %} +
+ +

{{ booking.type.label }} · {{ booking.groupName }}

+ + + + + + +
+

Kontakt

+ + + + + + + + + + +
Gruppenname{{ booking.groupName }}
Anrede{{ booking.salutation|default('–') }}
Name{{ booking.firstName }} {{ booking.lastName }}
E-Mail{{ booking.email|default('–') }}
Telefon{{ booking.phone|default('–') }}
Adresse + {% if booking.street %} + {{ booking.street }}
{{ booking.zip }} {{ booking.city }} + {% else %} + – + {% endif %} +
+
+

Reisedaten

+ + + + + + + {% if booking.minorsCount > 0 %} + + {% endif %} + {% if booking.childrenCount > 0 %} + + {% endif %} + +
Gruppenhaus{{ booking.accommodation.name }}
Anreise{{ booking.dateFrom|date('d.m.Y') }}
Abreise{{ booking.dateTo|date('d.m.Y') }}
Nächte{{ booking.nights }}
Anzahl Personen{{ booking.paxCount }}
davon Kinder (0–3 J.){{ booking.minorsCount }}
davon Kinder (4–{{ booking.accommodation.maxAdolescentAge }} J.){{ booking.childrenCount }}
Art{{ booking.type.label }}
+
+ + {% if booking.boardServiceLabel or booking.additionalServices|length > 0 %} +

Gebuchte Leistungen

+ + {% if booking.boardServiceLabel %} + + {% endif %} + {% for service in booking.additionalServices %} + + {% endfor %} +
Verpflegung{{ booking.boardServiceLabel }}
{{ loop.first ? 'Zusatzleistungen' : '' }}{{ service.label }}
+ {% endif %} + +
+ {% include 'email/_price_breakdown.html.twig' %} +
+ + {% if booking.remarks %} +

Bemerkungen

+

{{ booking.remarks|nl2br }}

+ {% endif %} + + + + diff --git a/tests/Controller/Admin/AccommodationBooking/PdfControllerTest.php b/tests/Controller/Admin/AccommodationBooking/PdfControllerTest.php new file mode 100644 index 0000000..c7bcdcc --- /dev/null +++ b/tests/Controller/Admin/AccommodationBooking/PdfControllerTest.php @@ -0,0 +1,102 @@ +booking(AccommodationBookingStatus::Confirmed); + + $generator = $this->createMock(AccommodationBookingPdfGenerator::class); + $generator->expects(self::once()) + ->method('createDownloadResponse') + ->with($booking) + ->willReturn(new Response('%PDF-1.7')); + + $response = (new TestablePdfController($generator))->index($booking); + + self::assertSame(Response::HTTP_OK, $response->getStatusCode()); + } + + /** + * @dataProvider nonConfirmedStatuses + */ + public function testABookingThatIsNotConfirmedCannotBeDownloaded(AccommodationBookingStatus $status): void + { + $generator = $this->createMock(AccommodationBookingPdfGenerator::class); + $generator->expects(self::never())->method('createDownloadResponse'); + + $controller = new TestablePdfController($generator); + + $response = $controller->index($this->booking($status)); + + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + self::assertSame(['warning'], array_column($controller->flashes, 'type')); + } + + /** + * @return iterable + */ + public static function nonConfirmedStatuses(): iterable + { + yield 'draft' => [AccommodationBookingStatus::Draft]; + yield 'open' => [AccommodationBookingStatus::Open]; + yield 'received' => [AccommodationBookingStatus::Received]; + yield 'discarded' => [AccommodationBookingStatus::Discarded]; + } + + public function testAFailingGeneratorRedirectsWithAFlash(): void + { + $generator = $this->createMock(AccommodationBookingPdfGenerator::class); + $generator->method('createDownloadResponse')->willThrowException(new \RuntimeException('keine Preise')); + + $controller = new TestablePdfController($generator); + + $response = $controller->index($this->booking(AccommodationBookingStatus::Confirmed)); + + self::assertSame(Response::HTTP_FOUND, $response->getStatusCode()); + self::assertStringContainsString('keine Preise', (string) $controller->flashes[0]['message']); + } + + private function booking(AccommodationBookingStatus $status): AccommodationBooking + { + $booking = new AccommodationBooking(); + $booking->setStatus($status); + $booking->setGroupName('Schulklasse 7b'); + + return $booking; + } +} + +final class TestablePdfController extends PdfController +{ + /** @var list */ + public array $flashes = []; + + protected function addFlash(string $type, mixed $message): void + { + $this->flashes[] = ['type' => $type, 'message' => $message]; + } + + /** + * @param array $parameters + */ + protected function generateUrl(string $route, array $parameters = [], int $referenceType = 1): string + { + return '/'.$route.'?'.http_build_query($parameters); + } +} diff --git a/tests/Service/AccommodationBookingPdfGeneratorTest.php b/tests/Service/AccommodationBookingPdfGeneratorTest.php new file mode 100644 index 0000000..8355a90 --- /dev/null +++ b/tests/Service/AccommodationBookingPdfGeneratorTest.php @@ -0,0 +1,124 @@ +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|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('aenne@example.com'); + $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 + */ + 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; + } +} diff --git a/tests/Service/AccommodationBookingServiceTest.php b/tests/Service/AccommodationBookingServiceTest.php index 8ae9919..c8a4392 100644 --- a/tests/Service/AccommodationBookingServiceTest.php +++ b/tests/Service/AccommodationBookingServiceTest.php @@ -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('customer@example.com'); + $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('customer@example.com'); + $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), 'office@example.com', ); }