feat: voucher purchase flow

This commit is contained in:
fromme committed 2026-10-01 15:03:27 +02:00
1 parent a628e96d06
commit f3dd122678
70 files changed
+3672 -122

No files matched your search

+103
View File
@@ -3,6 +3,7 @@
namespace App\BusProNet;
use App\BusProNet\DataProcessor\BookingDataProcessor;
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Exception\ImmediateConnectionCloseException;
use App\BusProNet\Exception\ResponseParserException;
@@ -22,9 +23,14 @@ use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\RegistrationResponse;
use App\BusProNet\Model\ServiceAvailabilityResponse;
use App\BusProNet\Model\Travel;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherPreview;
use App\BusProNet\Model\VoucherSaleResponse;
use App\BusProNet\Model\VoucherTemplates;
use App\BusProNet\XmlParser\ApiResponseParser;
use App\Form\Model\BookingDto;
use App\Form\Model\RegistrationDto;
use App\Form\Model\VoucherPurchaseDto;
use App\Service\RequestIdGenerator;
use League\Flysystem\FilesystemException;
use League\Flysystem\FilesystemOperator;
@@ -50,6 +56,11 @@ class ApiClient
public const TYPE_AGENCIES = 'AGENTUREN';
public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG';
public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN';
// Selling Kaufgutscheine. Not to be confused with TYPE_PURCHASE_VOUCHER, which redeems one in a booking.
public const TYPE_VOUCHER_TEMPLATES = 'GUTSCHEINVORLAGEN';
public const TYPE_VOUCHER_PAYMENT_METHODS = 'GUTSCHEINZAHLUNGSARTEN';
public const TYPE_VOUCHER_PREVIEW = 'GUTSCHEINVORSCHAU';
public const TYPE_VOUCHER_SALE = 'GUTSCHEINBUCHUNG';
/** @var array<string, mixed> */
private array $config;
@@ -64,6 +75,7 @@ class ApiClient
private readonly FilesystemOperator $xmlDump,
private readonly LoggerInterface $logger,
private readonly BookingDataProcessor $bookingDataProcessor,
private readonly VoucherSalePayloadBuilder $voucherSalePayloadBuilder,
private readonly RequestStack $requestStack,
private readonly RequestIdGenerator $requestIdGenerator,
array $options,
@@ -532,6 +544,97 @@ class ApiClient
return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data);
}
/**
* Fetches the voucher designs on sale and the amount bounds.
*
* @throws ApiClientException
*/
public function getVoucherTemplates(): Notification|VoucherTemplates
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_TEMPLATES),
'satz' => ['@typ' => static::TYPE_VOUCHER_TEMPLATES],
];
return $this->sendRequest(static::TYPE_VOUCHER_TEMPLATES, $data);
}
/**
* Fetches the payment methods offered for voucher purchases.
*
* @return list<VoucherPaymentMethod>|Notification
*
* @throws ApiClientException
*/
public function getVoucherPaymentMethods(): array|Notification
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_PAYMENT_METHODS),
'satz' => ['@typ' => static::TYPE_VOUCHER_PAYMENT_METHODS],
];
return $this->sendRequest(static::TYPE_VOUCHER_PAYMENT_METHODS, $data);
}
/**
* Renders the PDF preview of a voucher design with the entered data.
*
* @throws ApiClientException
*/
public function getVoucherPreview(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherPreview
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_PREVIEW),
'satz' => ['@typ' => static::TYPE_VOUCHER_PREVIEW],
...$this->voucherSalePayloadBuilder->buildPreviewPayload($voucherPurchaseDto),
];
return $this->sendRequest(static::TYPE_VOUCHER_PREVIEW, $data);
}
/**
* Dry run of a voucher purchase: full validation, nothing is issued.
*
* Every HINWEIS is a rejection on this path, including 650 (missing address data), so the
* result must not be judged by Notification::isError().
*
* @throws ApiClientException
*/
public function createVoucherSaleInquiry(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_INQUIRY);
}
/**
* Buys the voucher. BusPro mails it to the buyer once the payment has arrived.
*
* Every HINWEIS is a rejection on this path, including 650.
*
* @throws ApiClientException
*/
public function createVoucherSale(VoucherPurchaseDto $voucherPurchaseDto): Notification|VoucherSaleResponse
{
return $this->sendVoucherSale($voucherPurchaseDto, Constants::BOOKING_TYPE_BOOKING);
}
/**
* @throws ApiClientException
*/
private function sendVoucherSale(VoucherPurchaseDto $voucherPurchaseDto, string $saleType): Notification|VoucherSaleResponse
{
$data = [
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_VOUCHER_SALE),
'satz' => ['@typ' => static::TYPE_VOUCHER_SALE],
...$this->voucherSalePayloadBuilder->buildSalePayload($voucherPurchaseDto, $saleType),
];
return $this->sendRequest(static::TYPE_VOUCHER_SALE, $data);
}
/**
* Sends raw XML to the BPN API with key regeneration and automatic retry.
*
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\DataProcessor;
use App\Form\Model\VoucherPurchaseDto;
/**
* Builds the GUTSCHEINBUCHUNG and GUTSCHEINVORSCHAU payloads from the voucher purchase DTO.
*
* The buyer goes into <rechnungsempfänger> and nothing else: linking the purchase to a customer
* record is BusPro's business. The <zahlung> attributes are echoed from GUTSCHEINZAHLUNGSARTEN.
*/
class VoucherSalePayloadBuilder
{
/**
* @param string $saleType Constants::BOOKING_TYPE_INQUIRY for the dry run, Constants::BOOKING_TYPE_BOOKING for the purchase
*
* @return array<string, mixed>
*/
public function buildSalePayload(VoucherPurchaseDto $dto, string $saleType): array
{
if (null === $dto->template || null === $dto->paymentMethod) {
throw new \LogicException('A voucher sale needs a template and a payment method.');
}
$communication = [
'telefonprivat' => $dto->phone,
'email' => $dto->email,
];
// Not in the vendor's GUTSCHEINBUCHUNG example; the element name is the one BUCHUNG uses.
if (null !== $dto->mobile && '' !== trim($dto->mobile)) {
$communication['telefonmobil'] = $dto->mobile;
}
return [
'buchungsart' => $saleType,
'gutscheinart' => $dto->template->type,
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
'rechnungsempfänger' => [
'name' => $dto->lastName,
'vorname' => $dto->firstName,
'anrede' => $dto->salutation,
'titel' => $dto->title,
'geschlecht' => '',
'geburtsdatum' => '',
'anschrift' => $dto->address->toPayload(),
'kommunikation' => $communication,
],
'zahlung' => [
'@idzahlungsart' => $dto->paymentMethod->id,
'@bezeichnung' => $dto->paymentMethod->label,
'@art' => $dto->paymentMethod->type,
],
];
}
/**
* @return array<string, mixed>
*/
public function buildPreviewPayload(VoucherPurchaseDto $dto): array
{
if (null === $dto->template) {
throw new \LogicException('A voucher preview needs a template.');
}
return [
'idgutscheinvorlage' => $dto->template->id,
'kaeufer' => $dto->from,
'empfaenger' => $dto->to,
'kaufpreis' => $dto->amount,
'text' => '',
];
}
}
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A payment method for voucher purchases (GUTSCHEINZAHLUNGSARTEN).
*
* Echoed unchanged as the <zahlung> attributes of GUTSCHEINBUCHUNG.
*/
final readonly class VoucherPaymentMethod
{
public const string TYPE_TRANSFER = 'UEBERW';
public function __construct(
public int $id,
public string $label,
public string $type,
) {
}
public function isTransfer(): bool
{
return self::TYPE_TRANSFER === $this->type;
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORSCHAU response, decoded from its data URI to the raw PDF bytes.
*/
final readonly class VoucherPreview
{
public function __construct(
public string $pdf,
) {
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A GUTSCHEINBUCHUNG response that is not a HINWEIS.
*
* The dry run (buchungsart Anfrage) answers <buchung>möglich</buchung> and nothing else, so every
* other field is only set by the actual purchase (buchungsart Buchung, <buchung>erfolgt</buchung>).
*
* The redemption code is deliberately not exposed to the customer before payment: BusPro mails the
* voucher itself once the transfer has arrived.
*/
final readonly class VoucherSaleResponse
{
public const string SALE_POSSIBLE = 'möglich';
public const string SALE_PROCESSED = 'erfolgt';
public function __construct(
public string $status,
public ?int $voucherId = null,
public ?string $voucherNumber = null,
public ?string $redemptionCode = null,
public ?float $price = null,
public ?string $invoiceNumber = null,
public ?string $email = null,
) {
}
public function isInquiryValid(): bool
{
return self::SALE_POSSIBLE === $this->status;
}
public function isSaleSuccessful(): bool
{
return self::SALE_PROCESSED === $this->status;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* A voucher design offered for online sale (GUTSCHEINVORLAGEN).
*
* The id doubles as the key for the local thumbnail, see VoucherThumbnailResolver.
*/
final readonly class VoucherTemplate
{
public function __construct(
public int $id,
public string $type,
public string $label,
public ?string $note = null,
) {
}
}
+35
View File
@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\Model;
/**
* The GUTSCHEINVORLAGEN response: the designs on sale plus BusPro's amount bounds.
*
* The bounds (gutscheinminbetrag/gutscheinmaxbetrag) are not in the vendor documentation,
* they were measured on the test system (10 and 5,000 €, both inclusive).
*/
final readonly class VoucherTemplates
{
/**
* @param list<VoucherTemplate> $templates
*/
public function __construct(
public int $minAmount,
public int $maxAmount,
public array $templates,
) {
}
public function find(int $id): ?VoucherTemplate
{
foreach ($this->templates as $template) {
if ($id === $template->id) {
return $template;
}
}
return null;
}
}
@@ -93,6 +93,14 @@ class ApiResponseParser extends AbstractParser
return (new PurchaseVoucherParser())->parse($resultNode);
case ApiClient::TYPE_PROMO_VOUCHER:
return (new PromoVoucherParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_TEMPLATES:
return (new VoucherTemplatesParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_PAYMENT_METHODS:
return (new VoucherPaymentMethodsParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_PREVIEW:
return (new VoucherPreviewParser())->parse($resultNode);
case ApiClient::TYPE_VOUCHER_SALE:
return (new VoucherSaleResponseParser())->parse($resultNode);
}
} catch (\InvalidArgumentException $e) {
throw new ResponseParserException('Invalid XML response structure: '.$e->getMessage(), 0, $e);
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPaymentMethod;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINZAHLUNGSARTEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINZAHLUNGSARTEN"></satz>
* <zahlungsarten>
* <zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart>
* </zahlungsarten>
* </ergebnis>
*/
final class VoucherPaymentMethodsParser extends AbstractParser
{
/**
* @return list<VoucherPaymentMethod>
*/
public function parse(Crawler $node): array
{
$paymentMethods = [];
$node->filterXPath('//zahlungsarten/zahlungsart')->each(function (Crawler $methodNode) use (&$paymentMethods): void {
$paymentMethods[] = new VoucherPaymentMethod(
id: (int) $this->getRequiredAttrValue($methodNode, 'id', 'voucher payment method'),
label: $this->getRequiredAttrValue($methodNode, 'bezeichnung', 'voucher payment method'),
type: $this->getRequiredAttrValue($methodNode, 'art', 'voucher payment method'),
);
});
return $paymentMethods;
}
}
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherPreview;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORSCHAU responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORSCHAU"/>
* <gutscheinpdf>data:application/pdf;base64,JVBERi0xLjQ…</gutscheinpdf>
* </ergebnis>
*/
final class VoucherPreviewParser extends AbstractParser
{
private const string DATA_URI_PREFIX = 'data:application/pdf;base64,';
public function parse(Crawler $node): VoucherPreview
{
$dataUri = trim($this->getStringOrNullValue($node->filterXPath('//gutscheinpdf')) ?? '');
if (!str_starts_with($dataUri, self::DATA_URI_PREFIX)) {
throw new \InvalidArgumentException('Missing or malformed PDF data URI in voucher preview response.');
}
$pdf = base64_decode(substr($dataUri, \strlen(self::DATA_URI_PREFIX)), true);
if (false === $pdf || '' === $pdf) {
throw new \InvalidArgumentException('Undecodable PDF in voucher preview response.');
}
return new VoucherPreview($pdf);
}
}
@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherSaleResponse;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINBUCHUNG responses that are not a HINWEIS.
*
* Dry run (buchungsart Anfrage, measured on the test system 2026-10-01):
* <ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>
*
* Purchase (buchungsart Buchung, as documented by the vendor):
* <ergebnis>
* <satz typ="GUTSCHEINBUCHUNG"/>
* <buchung>erfolgt</buchung>
* <idgutschein/><gutscheinnummer/><einloesecode/><preis/><rechnungsnummer/><email/>
* </ergebnis>
*/
final class VoucherSaleResponseParser extends AbstractParser
{
public function parse(Crawler $node): VoucherSaleResponse
{
$status = trim($this->getStringOrNullValue($node->filterXPath('//buchung')) ?? '');
if ('' === $status) {
throw new \InvalidArgumentException('Missing required status in voucher sale response.');
}
return new VoucherSaleResponse(
status: $status,
voucherId: $this->getIntOrNullValue($node->filterXPath('//idgutschein')),
voucherNumber: $this->trimmedOrNull($node->filterXPath('//gutscheinnummer')),
redemptionCode: $this->trimmedOrNull($node->filterXPath('//einloesecode')),
price: $this->getFloatOrNullValue($node->filterXPath('//preis')),
invoiceNumber: $this->trimmedOrNull($node->filterXPath('//rechnungsnummer')),
email: $this->trimmedOrNull($node->filterXPath('//email')),
);
}
private function trimmedOrNull(Crawler $node): ?string
{
$value = trim($this->getStringOrNullValue($node) ?? '');
return '' !== $value ? $value : null;
}
}
@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\BusProNet\XmlParser;
use App\BusProNet\Model\VoucherTemplate;
use App\BusProNet\Model\VoucherTemplates;
use Symfony\Component\DomCrawler\Crawler;
/**
* Parses GUTSCHEINVORLAGEN responses.
*
* Expected XML structure (test system, 2026-10-01):
* <ergebnis>
* <satz typ="GUTSCHEINVORLAGEN"></satz>
* <gutscheinminbetrag>10</gutscheinminbetrag>
* <gutscheinmaxbetrag>5000</gutscheinmaxbetrag>
* <gutscheine>
* <gutschein idgutscheinvorlage="3">
* <gutscheinart>K</gutscheinart>
* <bezeichnung>Allgemein</bezeichnung>
* <hinweis></hinweis>
* </gutschein>
* </gutscheine>
* </ergebnis>
*/
final class VoucherTemplatesParser extends AbstractParser
{
public function parse(Crawler $node): VoucherTemplates
{
$minAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinminbetrag'));
$maxAmount = $this->getStringOrNullValue($node->filterXPath('//gutscheinmaxbetrag'));
if (null === $minAmount || '' === trim($minAmount) || null === $maxAmount || '' === trim($maxAmount)) {
throw new \InvalidArgumentException('Missing voucher amount bounds in voucher templates response.');
}
$templates = [];
$node->filterXPath('//gutscheine/gutschein')->each(function (Crawler $templateNode) use (&$templates): void {
$note = $this->getStringOrNullValue($templateNode->filterXPath('//hinweis'));
$templates[] = new VoucherTemplate(
id: (int) $this->getRequiredAttrValue($templateNode, 'idgutscheinvorlage', 'voucher template'),
type: trim($this->getStringOrNullValue($templateNode->filterXPath('//gutscheinart')) ?? ''),
label: trim($this->getStringOrNullValue($templateNode->filterXPath('//bezeichnung')) ?? ''),
note: null !== $note && '' !== trim($note) ? trim($note) : null,
);
});
return new VoucherTemplates(
// whole euros: the flow only offers whole-euro amounts, so the bounds are kept as such
minAmount: (int) ceil($this->stringToFloat($minAmount)),
maxAmount: (int) floor($this->stringToFloat($maxAmount)),
templates: $templates,
);
}
}
@@ -11,9 +11,11 @@ use App\Exception\NoRoomsAvailableException;
use App\Exception\TravelNotFoundException;
use App\Htmx\HxTrait;
use App\Model\BookingQueryParams;
use App\Model\PurchaseFlow;
use App\Service\BookingConfigurator;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -36,6 +38,7 @@ class IndexController extends AbstractController
private readonly BookingSessionManager $bookingSessionService,
private readonly AgencyLoader $agencyLoader,
private readonly BookingSummaryAssembler $summaryDataService,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) {
}
@@ -103,6 +106,7 @@ class IndexController extends AbstractController
// Create fresh booking session with the provided parameters
$bookingDto = $this->bookingService->startFreshBooking($request, $dateId, $hotelId, $agencyId);
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Booking);
// Warm the CMS cache early so data is available on the login page
$this->summaryDataService->getCmsDataForProduct(
+25 -20
View File
@@ -3,8 +3,10 @@
namespace App\Controller;
use App\Form\Model\BookingDto;
use App\Model\PurchaseFlow;
use App\Service\BookingSessionManager;
use App\Service\BookingSummaryAssembler;
use App\Service\PurchaseFlowResolver;
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
@@ -23,31 +25,31 @@ class SecurityController extends AbstractController
BookingSessionManager $bookingSessionService,
BookingSummaryAssembler $summaryDataService,
ClientManagerInterface $clientManager,
PurchaseFlowResolver $purchaseFlowResolver,
): Response {
// flag this request in the session as external OAuth2 request if
// applicable to immediately logout the current user after successful
// authentication (see App\EventListener\AuthorizationCodeListener).
// Read before the booking flow is decided, because it overrules it.
// Read before the purchase flow is decided, because it overrules it.
$session = $request->getSession();
$targetPath = $session->get('_security.main.target_path');
$isOauth2 = \is_string($targetPath) && str_contains($targetPath, '/authorize');
$session->set('_oauth2', $isOauth2);
// Check if this is a booking flow (BookingDto exists in session)
$bookingDto = $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE);
$hasPendingBooking = null !== $bookingDto;
// A purchase flow (booking or voucher) uses this page as its optional sign-in step
$pendingFlow = $purchaseFlowResolver->resolvePending($request);
// A pending authorization wins over a half-finished booking. Otherwise the target path
// below is overwritten with booking step 1, the user is sent there after logging in, and
// the application that asked for the authorization never receives its code.
$isBookingFlow = $hasPendingBooking && false === $isOauth2;
// A pending authorization wins over a half-finished purchase. Otherwise the target path
// below is overwritten with the flow's first step, the user is sent there after logging in,
// and the application that asked for the authorization never receives its code.
$purchaseFlow = false === $isOauth2 ? $pendingFlow : null;
// If authenticated and in booking flow, proceed to Step 1
if (null !== $this->getUser() && true === $hasPendingBooking) {
return $this->redirectToRoute('app_booking_create_step_1');
// If authenticated and in a purchase flow, proceed to its first step
if (null !== $this->getUser() && null !== $pendingFlow) {
return $this->redirectToRoute($pendingFlow->firstStepRoute());
}
// If authenticated but not in booking flow, redirect to dashboard
// If authenticated but not in a purchase flow, redirect to dashboard
if (null !== $this->getUser()) {
return $this->redirectToRoute('app_account');
}
@@ -55,22 +57,25 @@ class SecurityController extends AbstractController
$error = $authenticationUtils->getLastAuthenticationError();
$lastUsername = $authenticationUtils->getLastUsername();
// For booking flow, set target path to Step 1 (after successful auth, redirect there)
if (true === $isBookingFlow) {
$session->set('_security.main.target_path', $this->generateUrl('app_booking_create_step_1'));
// For a purchase flow, continue with its first step after successful auth
if (null !== $purchaseFlow) {
$session->set('_security.main.target_path', $this->generateUrl($purchaseFlow->firstStepRoute()));
}
// Render booking login template if in booking flow, the self-explaining one when another
// application sent them here to authorize it, otherwise standard login
// Render the flow's login template if in a purchase flow, the self-explaining one when
// another application sent them here to authorize it, otherwise standard login
$template = match (true) {
$isBookingFlow => 'booking/create/authenticate.html.twig',
null !== $purchaseFlow => $purchaseFlow->authenticateTemplate(),
$isOauth2 => 'security/login_oauth2.html.twig',
default => 'security/login.html.twig',
};
// Fetch CMS data for booking flow (uses cache warmed in IndexController)
// Fetch travel and CMS data for the booking flow (uses cache warmed in IndexController)
$bookingDto = PurchaseFlow::Booking === $purchaseFlow
? $bookingSessionService->getBookingDto($request, BookingDto::MODE_CREATE)
: null;
$cmsData = null;
if (true === $isBookingFlow) {
if (null !== $bookingDto) {
$cmsData = $summaryDataService->getCmsDataForProduct(
$bookingDto->travel->productCode,
$bookingDto->travel->hotel?->code
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Form\Model\VoucherPurchaseDto;
use App\Htmx\HxTrait;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherThumbnailResolver;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Shared base for the steps of the voucher purchase flow.
*/
abstract class AbstractVoucherPurchaseController extends AbstractController
{
use HxTrait;
public const array STEP_ROUTES = [
1 => 'app_voucher_step_1',
2 => 'app_voucher_step_2',
3 => 'app_voucher_step_3',
];
public function __construct(
protected readonly VoucherPurchaseSessionManager $sessionManager,
protected readonly VoucherThumbnailResolver $thumbnailResolver,
) {
}
protected function createSessionExpiredResponse(Request $request): Response
{
$this->addFlash('error', 'Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
}
protected function validateStepAccess(VoucherPurchaseDto $dto, int $expectedStep): ?RedirectResponse
{
if ($expectedStep > $dto->currentStep) {
$this->addFlash('error', 'Bitte erst die vorherigen Schritte abschließen.');
return $this->redirectToStep($dto->currentStep);
}
return null;
}
protected function redirectToStep(int $step): RedirectResponse
{
return $this->redirectToRoute(self::STEP_ROUTES[$step] ?? self::STEP_ROUTES[1]);
}
/**
* Context shared by every step template: the sidebar summary and the step pagination.
*
* @return array<string, mixed>
*/
protected function createStepContext(VoucherPurchaseDto $dto): array
{
return [
'voucher' => $dto,
'voucherThumbnail' => null !== $dto->template ? $this->thumbnailResolver->resolve($dto->template) : null,
'stepRoutes' => self::STEP_ROUTES,
];
}
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Exception\VoucherShopUnavailableException;
use App\Form\Model\VoucherPurchaseDto;
use App\Htmx\HxTrait;
use App\Model\PurchaseFlow;
use App\Service\PurchaseFlowResolver;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherTemplateProvider;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
use Symfony\Component\Routing\Attribute\Route;
/**
* Entry, cancellation and error pages of the voucher purchase flow, which replaces the voucher
* shop hosted by BusProNet (maske_gutschein.php).
*/
class IndexController extends AbstractController
{
use HxTrait;
public function __construct(
private readonly VoucherPurchaseSessionManager $sessionManager,
private readonly VoucherTemplateProvider $templateProvider,
private readonly PurchaseFlowResolver $purchaseFlowResolver,
) {
}
/**
* Renders a loading page that posts to init, as the booking flow does.
*/
#[Route('/gutschein', name: 'app_voucher', methods: ['GET'])]
public function index(#[MapQueryParameter('r')] ?string $returnUrl = null): Response
{
return $this->render('voucher/index.html.twig', [
'returnUrl' => $returnUrl,
]);
}
/**
* Starts a fresh purchase and sends the visitor to the login page (sign in or continue as guest).
*
* POST-only for the same reason as app_booking_create_init: it resets session state and calls
* BusPro, so nothing that speculatively fetches URLs may trigger it.
*/
#[Route('/gutschein/init', name: 'app_voucher_init', methods: ['POST'])]
public function init(Request $request, #[MapQueryParameter('r')] ?string $returnUrl = null): Response
{
$this->sessionManager->clear($request);
$this->sessionManager->storeReturnUrl($request, $returnUrl);
try {
$templates = $this->templateProvider->getTemplates();
$paymentMethod = $this->templateProvider->getPaymentMethod();
} catch (VoucherShopUnavailableException) {
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
}
$dto = new VoucherPurchaseDto();
$dto->minAmount = $templates->minAmount;
$dto->maxAmount = $templates->maxAmount;
$dto->paymentMethod = $paymentMethod;
if (1 === \count($templates->templates)) {
$dto->template = $templates->templates[0];
}
$this->sessionManager->save($request, $dto);
$this->purchaseFlowResolver->markStarted($request, PurchaseFlow::Voucher);
return $this->htmxRedirect($request, $this->generateUrl('app_login'));
}
#[Route('/gutschein/abbrechen', name: 'app_voucher_cancel', methods: ['GET', 'POST'])]
public function cancel(Request $request): Response
{
if (Request::METHOD_POST !== $request->getMethod()) {
return $this->render('voucher/modal_cancel.html.twig');
}
$returnUrl = $this->sessionManager->getReturnUrl($request);
$this->sessionManager->clear($request);
// Without this, a later login would send the user back into the cancelled purchase
$request->getSession()->remove('_security.main.target_path');
if (null !== $this->getUser()) {
$this->addFlash('info', 'Gutscheinkauf abgebrochen.');
return $this->htmxRedirect($request, $this->generateUrl('app_account'));
}
return $this->htmxRedirect($request, $returnUrl);
}
#[Route('/gutschein/fehler', name: 'app_voucher_error', methods: ['GET'])]
public function error(Request $request): Response
{
return $this->render('booking/create/error.html.twig', [
'returnUrl' => $this->sessionManager->getReturnUrl($request),
'defaultErrorMessage' => 'Es ist ein Fehler beim Gutscheinkauf aufgetreten.',
]);
}
}
+219
View File
@@ -0,0 +1,219 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\Exception\VoucherSessionNotFoundException;
use App\Exception\VoucherShopUnavailableException;
use App\Form\Model\VoucherPurchaseDto;
use App\Form\VoucherPurchaseStep1Type;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherTemplateProvider;
use App\Service\VoucherThumbnailResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* Step 1: design, von/für and value, with a PDF preview rendered by BusPro.
*/
class Step1Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly VoucherTemplateProvider $templateProvider,
private readonly ApiClient $apiClient,
private readonly ValidatorInterface $validator,
private readonly LoggerInterface $logger,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/gutschein/auswahl', name: 'app_voucher_step_1')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
$dto->currentStep = 2;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_voucher_step_2');
}
return $this->renderStep($dto, $form);
}
/**
* Takes over the current input without validating it and refreshes the summary.
*/
#[Route('/gutschein/auswahl/aktualisieren', name: 'app_voucher_step_1_refresh', methods: ['POST'])]
public function refresh(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto, false);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
$this->sessionManager->save($request, $dto);
return $this->htmxOobResponse('voucher/step_1.html.twig', ['voucher_summary'], [
...$this->createStepContext($dto),
'form' => $form->createView(),
]);
}
/**
* Takes over the current input like refresh() and opens the preview modal, whose frame loads
* the PDF from previewPdf().
*/
#[Route('/gutschein/auswahl/vorschau', name: 'app_voucher_preview', methods: ['POST'])]
public function preview(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
try {
$form = $this->createStepForm($dto, false);
} catch (VoucherShopUnavailableException) {
return $this->createShopUnavailableResponse($request);
}
$form->handleRequest($request);
$this->sessionManager->save($request, $dto);
return $this->render('voucher/modal_preview.html.twig', [
'previewable' => $this->isPreviewable($dto),
// busts the browser cache of the frame, the URL is otherwise the same for every input
'previewVersion' => bin2hex(random_bytes(4)),
]);
}
#[Route('/gutschein/vorschau.pdf', name: 'app_voucher_preview_pdf', methods: ['GET'])]
public function previewPdf(Request $request): Response
{
try {
$dto = $this->sessionManager->get($request);
} catch (VoucherSessionNotFoundException) {
return $this->createPreviewErrorResponse('Deine Sitzung ist abgelaufen. Bitte starte den Gutscheinkauf neu.');
}
if (false === $this->isPreviewable($dto)) {
return $this->createPreviewErrorResponse('Für die Vorschau bitte zuerst Motiv, Von, Für und Gutscheinwert angeben.');
}
// Read-only from here on: release the session lock, so the page's other requests do not
// wait the seconds BusPro takes to render the PDF.
$request->getSession()->save();
try {
$preview = $this->apiClient->getVoucherPreview($dto);
} catch (ApiClientException $e) {
$this->logger->error('Voucher preview failed', ['error' => $e->getMessage()]);
return $this->createPreviewErrorResponse('Die Vorschau ist gerade nicht verfügbar. Bitte versuche es später noch einmal.');
}
if ($preview instanceof Notification) {
$this->logger->warning('Voucher preview rejected', ['code' => $preview->code, 'message' => $preview->message]);
return $this->createPreviewErrorResponse($preview->message ?? 'Die Vorschau ist gerade nicht verfügbar.');
}
$response = new Response($preview->pdf, Response::HTTP_OK, ['Content-Type' => 'application/pdf']);
$response->headers->set('Content-Disposition', $response->headers->makeDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
'gutschein-vorschau.pdf'
));
$response->setPrivate();
$response->headers->addCacheControlDirective('no-store');
return $response;
}
/**
* @return FormInterface<VoucherPurchaseDto>
*
* @throws VoucherShopUnavailableException
*/
private function createStepForm(VoucherPurchaseDto $dto, bool $validate = true): FormInterface
{
$templates = $this->templateProvider->getTemplates();
$options = [
'templates' => $templates->templates,
'min_amount' => $dto->minAmount,
'max_amount' => $dto->maxAmount,
];
if (false === $validate) {
$options['validation_groups'] = false;
}
return $this->createForm(VoucherPurchaseStep1Type::class, $dto, $options);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form): Response
{
$templates = $this->templateProvider->getTemplates();
return $this->render('voucher/step_1.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
'thumbnails' => $this->thumbnailResolver->resolveAll($templates->templates),
'previewable' => $this->isPreviewable($dto),
]);
}
/**
* The preview takes what step 1 accepts: BusPro refuses it without the names (604/605), and
* this keeps the button and the route to one rule, the step's own validation group.
*/
private function isPreviewable(VoucherPurchaseDto $dto): bool
{
return 0 === \count($this->validator->validate($dto, null, ['voucher_step_1']));
}
private function createShopUnavailableResponse(Request $request): Response
{
$this->addFlash('error', 'Gutscheine können gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->htmxRedirect($request, $this->generateUrl('app_voucher_error'));
}
private function createPreviewErrorResponse(string $message): Response
{
return $this->render('voucher/preview_error.html.twig', ['message' => $message], new Response('', Response::HTTP_UNPROCESSABLE_ENTITY));
}
}
@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Entity\User;
use App\Form\VoucherPurchaseStep2Type;
use App\Service\ParticipantDataPrefiller;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherThumbnailResolver;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Step 2: the buyer (rechnungsempfänger), prefilled for signed-in accounts. Payment is fixed to
* transfer.
*/
class Step2Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly ParticipantDataPrefiller $personalDataPrefiller,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/gutschein/kaeufer', name: 'app_voucher_step_2')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
return $this->createSessionExpiredResponse($request);
}
if (null !== $redirect = $this->validateStepAccess($dto, 2)) {
return $redirect;
}
$user = $this->getUser();
if ($user instanceof User && false === $dto->hasBuyerData()) {
$personalData = $this->personalDataPrefiller->fetchPersonalData($user);
if (null !== $personalData) {
$dto->prefillBuyer($personalData, $user->getEmail());
$this->sessionManager->save($request, $dto);
}
}
$form = $this->createForm(VoucherPurchaseStep2Type::class, $dto);
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
$dto->currentStep = 3;
$this->sessionManager->save($request, $dto);
return $this->redirectToRoute('app_voucher_step_3');
}
return $this->render('voucher/step_2.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
]);
}
}
+199
View File
@@ -0,0 +1,199 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\TimeoutException;
use App\BusProNet\Model\Notification;
use App\Form\Model\VoucherPurchaseDto;
use App\Form\VoucherPurchaseStep3Type;
use App\Service\VoucherPurchaseSessionManager;
use App\Service\VoucherSaleRejectionMapper;
use App\Service\VoucherThumbnailResolver;
use Psr\Log\LoggerInterface;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
/**
* Step 3: overview, the two required confirmations and the purchase.
*
* Every visit runs GUTSCHEINBUCHUNG as a dry run (buchungsart Anfrage) first, which applies BusPro's
* full validation without issuing anything, and the buy button is only offered when it passes. The
* purchase sends the same payload with buchungsart Buchung.
*
* Every HINWEIS is a rejection on this path, 650 included, so Notification::isError() is not used.
*/
class Step3Controller extends AbstractVoucherPurchaseController
{
public function __construct(
VoucherPurchaseSessionManager $sessionManager,
VoucherThumbnailResolver $thumbnailResolver,
private readonly ApiClient $apiClient,
private readonly VoucherSaleRejectionMapper $rejectionMapper,
private readonly LoggerInterface $logger,
) {
parent::__construct($sessionManager, $thumbnailResolver);
}
#[Route('/gutschein/uebersicht', name: 'app_voucher_step_3')]
public function index(Request $request): Response
{
$dto = $this->sessionManager->find($request);
if (null === $dto) {
// also the second of two submits: the first one cleared the session before redirecting
return $this->createSessionExpiredResponse($request);
}
if (null !== $redirect = $this->validateStepAccess($dto, 3)) {
return $redirect;
}
$form = $this->createForm(VoucherPurchaseStep3Type::class, $dto, [
'amount' => $dto->amount ?? 0,
]);
if (false === $request->isMethod(Request::METHOD_POST)) {
$dto->termsAccepted = false;
$dto->purchaseConfirmed = false;
return $this->runDryRun($request, $dto, $form);
}
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
return $this->purchase($request, $dto, $form);
}
return $this->renderStep($dto, $form, true);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function runDryRun(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
{
try {
$response = $this->apiClient->createVoucherSaleInquiry($dto);
} catch (\Exception $e) {
$this->logger->error('Voucher sale inquiry failed', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Der Gutscheinkauf konnte gerade nicht geprüft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
if ($response instanceof Notification) {
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale inquiry rejected');
}
if (false === $response->isInquiryValid()) {
$this->logger->error('Voucher sale inquiry unsuccessful', ['status' => $response->status]);
$this->addFlash('error', 'Der Gutschein kann gerade nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
return $this->renderStep($dto, $form, true);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function purchase(Request $request, VoucherPurchaseDto $dto, FormInterface $form): Response
{
try {
$response = $this->apiClient->createVoucherSale($dto);
} catch (TimeoutException $e) {
// The purchase may have gone through on BusPro's side, so a blind retry could buy twice
$this->logger->error('Voucher sale timeout', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Die Anfrage hat zu lange gedauert. Möglicherweise wurde der Gutschein trotzdem gekauft. Bitte wende dich an den Kundenservice, bevor du es erneut versuchst.');
return $this->renderStep($dto, $form, false);
} catch (\Exception $e) {
$this->logger->error('Voucher sale exception', ['exception' => $e->getMessage()]);
$this->addFlash('error', 'Ein technischer Fehler ist aufgetreten. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, true);
}
if ($response instanceof Notification) {
return $this->handleRejection($request, $dto, $form, $response, 'Voucher sale rejected');
}
if (false === $response->isSaleSuccessful()) {
$this->logger->error('Voucher sale unsuccessful', ['status' => $response->status]);
$this->addFlash('error', 'Der Gutschein konnte nicht gekauft werden. Bitte versuche es später noch einmal.');
return $this->renderStep($dto, $form, false);
}
// Cleared before the redirect, so a second submit finds no purchase and cannot buy twice
$this->sessionManager->clear($request);
// The redemption code stays out of the log and the page: the voucher is only paid for once
// the transfer arrives, and BusPro mails it then.
$this->logger->info('Voucher successfully sold.', [
'voucher_id' => $response->voucherId,
'voucher_number' => $response->voucherNumber,
'invoice_number' => $response->invoiceNumber,
'template_id' => $dto->template?->id,
'amount' => $dto->amount,
]);
$this->addFlash('voucher_number', $response->voucherNumber);
$this->addFlash('voucher_invoice_number', $response->invoiceNumber);
$this->addFlash('voucher_total', $response->price ?? (float) $dto->amount);
$this->addFlash('voucher_label', $dto->template?->label);
$this->addFlash('voucher_email', $response->email ?? $dto->email);
return $this->redirectToRoute('app_voucher_success');
}
/**
* Sends the visitor to the step that owns the complaint, or keeps them here without a buy
* button when the complaint is about nothing they can change.
*
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function handleRejection(
Request $request,
VoucherPurchaseDto $dto,
FormInterface $form,
Notification $notification,
string $logMessage,
): Response {
$step = $this->rejectionMapper->stepFor($notification);
$this->logger->warning($logMessage, [
'code' => $notification->code,
'message' => $notification->message,
'step' => $step,
]);
$this->addFlash('error', $notification->message ?? 'Der Gutschein kann so nicht gekauft werden.');
if (VoucherSaleRejectionMapper::STEP_OVERVIEW === $step) {
return $this->renderStep($dto, $form, false);
}
$dto->currentStep = $step;
$this->sessionManager->save($request, $dto);
return $this->redirectToStep($step);
}
/**
* @param FormInterface<VoucherPurchaseDto> $form
*/
private function renderStep(VoucherPurchaseDto $dto, FormInterface $form, bool $purchasable): Response
{
return $this->render('voucher/step_3.html.twig', [
...$this->createStepContext($dto),
'form' => $form,
'purchasable' => $purchasable,
]);
}
}
@@ -0,0 +1,56 @@
<?php
declare(strict_types=1);
namespace App\Controller\Voucher;
use App\Service\VoucherPurchaseSessionManager;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\FlashBagAwareSessionInterface;
use Symfony\Component\Routing\Attribute\Route;
/**
* The page after a completed voucher purchase. Its data comes as flashes, so a reload or a direct
* visit falls back to the return URL.
*/
class SuccessController extends AbstractController
{
public function __construct(
private readonly VoucherPurchaseSessionManager $sessionManager,
) {
}
#[Route('/gutschein/erfolg', name: 'app_voucher_success')]
public function success(Request $request): Response
{
$returnUrl = $this->sessionManager->getReturnUrl($request);
$session = $request->getSession();
if (!$session instanceof FlashBagAwareSessionInterface) {
return $this->redirect($returnUrl);
}
$flashBag = $session->getFlashBag();
$voucherNumber = $flashBag->get('voucher_number')[0] ?? null;
$invoiceNumber = $flashBag->get('voucher_invoice_number')[0] ?? null;
$total = $flashBag->get('voucher_total')[0] ?? null;
$label = $flashBag->get('voucher_label')[0] ?? null;
$email = $flashBag->get('voucher_email')[0] ?? null;
// the total is always set on a completed purchase, the voucher number only if BusPro sends it
if (null === $total) {
return $this->redirect($returnUrl);
}
return $this->render('voucher/success.html.twig', [
'voucherNumber' => $voucherNumber,
'invoiceNumber' => $invoiceNumber,
'total' => $total,
'label' => $label,
'email' => $email,
'returnUrl' => $returnUrl,
]);
}
}
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Exception;
use Symfony\Component\HttpKernel\Exception\HttpException;
/**
* Thrown when a voucher purchase step is reached without a running purchase, either directly or
* because the purchase was completed or the session expired.
*/
class VoucherSessionNotFoundException extends HttpException
{
public function __construct(?\Throwable $previous = null)
{
parent::__construct(404, 'No valid voucher purchase session found. Please start a new purchase.', $previous);
}
}
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Exception;
/**
* Thrown when BusPro cannot tell us what is on sale (GUTSCHEINVORLAGEN) or how it can be paid
* (GUTSCHEINZAHLUNGSARTEN), so the voucher purchase cannot be offered.
*/
class VoucherShopUnavailableException extends \RuntimeException
{
}
+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Session state of the voucher purchase flow (/gutschein).
*
* The fields are exactly what the BusProNet voucher shop collects, with its mandatory flags rather
* than those of ADRESSFELDERGUTSCHEIN (which would also demand a phone number and a gender). There
* is no Zusatztext: GUTSCHEINBUCHUNG is sent an empty <text/>, as the live shop does.
*
* Step 2 is validated with the groups ['voucher_step_2', 'personal_data']: the buyer address reuses
* the Address model, whose constraints live in the personal_data group.
*/
class VoucherPurchaseDto
{
public const string SALUTATION_MR = 'Herr';
public const string SALUTATION_MS = 'Frau';
public int $currentStep = 1;
// BusPro's bounds from GUTSCHEINVORLAGEN at the start of the flow. BusPro re-checks them on
// every GUTSCHEINBUCHUNG (607/608), so a change in between still surfaces as a step 1 error.
public int $minAmount = 0;
public int $maxAmount = 0;
// Fixed at the start of the flow from GUTSCHEINZAHLUNGSARTEN; transfer is the only one offered.
public ?VoucherPaymentMethod $paymentMethod = null;
// Step 1: voucher
#[Assert\NotNull(message: 'Bitte wähle ein Motiv', groups: ['voucher_step_1'])]
public ?VoucherTemplate $template = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $from = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $to = null;
#[Assert\NotNull(message: 'Bitte gib einen Gutscheinwert an', groups: ['voucher_step_1'])]
#[Assert\Range(
notInRangeMessage: 'Der Gutscheinwert muss zwischen {{ min }} und {{ max }} Euro liegen',
minPropertyPath: 'minAmount',
maxPropertyPath: 'maxAmount',
groups: ['voucher_step_1'],
)]
public ?int $amount = null;
// Step 2: buyer (rechnungsempfänger)
#[Assert\Choice(choices: [self::SALUTATION_MR, self::SALUTATION_MS], groups: ['voucher_step_2'])]
public ?string $salutation = null;
public ?string $title = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $lastName = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $firstName = null;
#[Assert\Valid]
public Address $address;
public ?string $phone = null;
public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
public ?string $email = null;
// Step 3: confirmation, reset on every visit so a stale tick never carries over
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $termsAccepted = false;
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $purchaseConfirmed = false;
public function __construct()
{
$this->address = new Address();
$this->address->country = 'D';
}
public function getBuyerFullName(): string
{
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
}
public function hasBuyerData(): bool
{
return null !== $this->lastName && '' !== $this->lastName;
}
/**
* Copies the buyer fields from the signed-in account's BusPro record.
*
* The e-mail is the one the account signed in with, not the record's communication address:
* a BusPro address can carry several e-mails for different persons.
*/
public function prefillBuyer(PersonalData $personalData, ?string $loginEmail): void
{
$this->salutation = \in_array($personalData->salutation, [self::SALUTATION_MR, self::SALUTATION_MS], true)
? $personalData->salutation
: null;
$this->title = $personalData->title;
$this->lastName = $personalData->name;
$this->firstName = $personalData->firstName;
$this->address = clone $personalData->address;
$this->address->normalize();
$this->address->country ??= 'D';
$this->phone = $personalData->communication->phone;
$this->mobile = $personalData->communication->mobile;
$this->email = $loginEmail;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Model\VoucherTemplate;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<VoucherPurchaseDto> */
class VoucherPurchaseStep1Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('template', ChoiceType::class, [
'label' => 'Motiv',
'choices' => $options['templates'],
'choice_value' => static fn (?VoucherTemplate $template) => $template?->id,
'choice_label' => static fn (VoucherTemplate $template) => $template->label,
'expanded' => true,
'placeholder' => false,
'invalid_message' => 'Bitte wähle ein Motiv',
])
->add('from', TextType::class, [
'label' => 'Von',
'sanitize_html' => true,
])
->add('to', TextType::class, [
'label' => 'Für',
'sanitize_html' => true,
])
->add('amount', IntegerType::class, [
'label' => 'Gutscheinwert in Euro',
'invalid_message' => 'Bitte gib einen ganzen Euro-Betrag an',
'attr' => [
'min' => $options['min_amount'],
'max' => $options['max_amount'],
'step' => 1,
'inputmode' => 'numeric',
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
'validation_groups' => ['voucher_step_1'],
]);
$resolver->setRequired(['templates', 'min_amount', 'max_amount']);
$resolver->setAllowedTypes('templates', VoucherTemplate::class.'[]');
$resolver->setAllowedTypes('min_amount', 'int');
$resolver->setAllowedTypes('max_amount', 'int');
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The buyer, sent to BusPro as rechnungsempfänger. Mandatory fields as in the live voucher shop.
*
* @extends AbstractType<VoucherPurchaseDto>
*/
class VoucherPurchaseStep2Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => VoucherPurchaseDto::SALUTATION_MR,
'Frau' => VoucherPurchaseDto::SALUTATION_MS,
],
'expanded' => true,
'required' => false,
'placeholder' => false,
])
->add('title', TextType::class, [
'label' => 'Titel',
'required' => false,
'sanitize_html' => true,
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
'sanitize_html' => true,
])
->add('lastName', TextType::class, [
'label' => 'Name',
'sanitize_html' => true,
])
->add('address', AddressType::class, [
'label' => false,
'required' => true,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
])
->add('phone', TextType::class, [
'label' => 'Telefon privat',
'required' => false,
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'required' => false,
'sanitize_html' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
// personal_data carries the constraints of the reused Address model
'validation_groups' => ['voucher_step_2', 'personal_data'],
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<VoucherPurchaseDto> */
class VoucherPurchaseStep3Type extends AbstractType
{
public function __construct(
private readonly string $termsAndConditionsUrl,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLink = sprintf(
'<a href="%s" target="_blank" rel="noopener" class="underline">Allgemeinen Geschäftsbedingungen</a>',
htmlspecialchars($this->termsAndConditionsUrl, ENT_QUOTES, 'UTF-8')
);
$builder
->add('termsAccepted', CheckboxType::class, [
'label' => sprintf('Ja, ich habe die %s gelesen und akzeptiere diese.', $termsLink),
'label_html' => true,
])
->add('purchaseConfirmed', CheckboxType::class, [
'label' => sprintf(
'Ja, ich möchte den Gutschein jetzt zum Preis von %s € kaufen.',
number_format($options['amount'], 2, ',', '.')
),
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
'validation_groups' => ['voucher_step_3'],
]);
$resolver->setRequired('amount');
$resolver->setAllowedTypes('amount', 'int');
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Model;
/**
* The purchase flows that start on the login page (optional sign-in, or continue as a guest).
*/
enum PurchaseFlow: string
{
case Booking = 'booking';
case Voucher = 'voucher';
public function firstStepRoute(): string
{
return match ($this) {
self::Booking => 'app_booking_create_step_1',
self::Voucher => 'app_voucher_step_1',
};
}
public function authenticateTemplate(): string
{
return match ($this) {
self::Booking => 'booking/create/authenticate.html.twig',
self::Voucher => 'voucher/authenticate.html.twig',
};
}
}
+12 -7
View File
@@ -126,16 +126,21 @@ class BookingSessionManager
*/
public function storeReturnUrl(Request $request, ?string $returnUrl): void
{
$url = self::DEFAULT_RETURN_URL;
$request->getSession()->set(self::RETURN_URL_KEY, self::normalizeReturnUrl($returnUrl));
}
if (null !== $returnUrl && '' !== trim($returnUrl)) {
if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
$url = $returnUrl;
}
/**
* Accepts an absolute http(s) URL and falls back to the default for anything else.
*/
public static function normalizeReturnUrl(?string $returnUrl): string
{
if (null !== $returnUrl && '' !== trim($returnUrl)
&& false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
return $returnUrl;
}
$request->getSession()->set(self::RETURN_URL_KEY, $url);
return self::DEFAULT_RETURN_URL;
}
/**
+43 -28
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Form\Model\ParticipantDto;
use App\Security\Crypt;
@@ -45,16 +46,52 @@ class ParticipantDataPrefiller
* @return ParticipantDto The prepopulated participant (or unchanged if API fails)
*/
public function prefillApplicantFromUser(User $user, ParticipantDto $applicant): ParticipantDto
{
$personalData = $this->fetchPersonalData($user);
if (null === $personalData) {
return $applicant;
}
// Create a temporary participant from personal data
$prepopulated = ParticipantDto::fromPersonalData($personalData);
// Copy personal data fields to the existing applicant
// Preserve booking-specific fields (roomId, services, etc.)
$applicant->addressId = $prepopulated->addressId;
$applicant->personId = $prepopulated->personId;
$applicant->firstName = $prepopulated->firstName;
$applicant->lastName = $prepopulated->lastName;
$applicant->title = $prepopulated->title;
$applicant->gender = $prepopulated->gender;
$applicant->nationality = $prepopulated->nationality;
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
$applicant->shoeSize = $prepopulated->shoeSize;
$applicant->remarksRoom = $prepopulated->remarksRoom;
$applicant->licensePlate = $prepopulated->licensePlate;
return $applicant;
}
/**
* Fetches the authenticated user's personal data from the BPN API for prefilling a form.
*
* Never throws: a failure is logged and returns null, so a purchase flow is never interrupted
* by a prefill.
*/
public function fetchPersonalData(User $user): ?PersonalData
{
try {
// Decrypt user password for API authentication
$password = $this->crypt->decrypt($user->getPassword());
$email = $user->getEmail();
// Fetch personal data from API
$result = $this->apiClient->getPersonalData($email, $password);
// Handle API error response
if ($result instanceof Notification) {
$this->logger->warning('Failed to fetch personal data for prepopulation', [
'email' => $email,
@@ -62,40 +99,18 @@ class ParticipantDataPrefiller
'message' => $result->message,
]);
return $applicant;
return null;
}
// Create a temporary participant from personal data
$prepopulated = ParticipantDto::fromPersonalData($result);
// Copy personal data fields to the existing applicant
// Preserve booking-specific fields (roomId, services, etc.)
$applicant->addressId = $prepopulated->addressId;
$applicant->personId = $prepopulated->personId;
$applicant->firstName = $prepopulated->firstName;
$applicant->lastName = $prepopulated->lastName;
$applicant->title = $prepopulated->title;
$applicant->gender = $prepopulated->gender;
$applicant->nationality = $prepopulated->nationality;
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
$applicant->shoeSize = $prepopulated->shoeSize;
$applicant->remarksRoom = $prepopulated->remarksRoom;
$applicant->licensePlate = $prepopulated->licensePlate;
return $applicant;
return $result;
} catch (\Exception $e) {
// Catch any unexpected exceptions (decryption failure, API errors, etc.)
$this->logger->error('Exception during applicant prepopulation', [
$this->logger->error('Exception during personal data prepopulation', [
'error' => $e->getMessage(),
'email' => $user->getEmail(),
]);
return $applicant;
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Model\PurchaseFlow;
use Symfony\Component\HttpFoundation\Request;
/**
* Tells the login page which purchase flow, if any, it is the entry to.
*
* Both a booking and a voucher purchase can be pending in the same session, e.g. when they were
* started in two tabs. The one started last wins, which is what the visitor just clicked.
*/
class PurchaseFlowResolver
{
public const string LAST_STARTED_KEY = 'purchase_flow_last_started';
public function markStarted(Request $request, PurchaseFlow $flow): void
{
$request->getSession()->set(self::LAST_STARTED_KEY, $flow->value);
}
public function resolvePending(Request $request): ?PurchaseFlow
{
$session = $request->getSession();
$hasBooking = $session->has(BookingSessionManager::BOOKING_CREATE_KEY);
$hasVoucher = $session->has(VoucherPurchaseSessionManager::SESSION_KEY);
if (true === $hasBooking && true === $hasVoucher) {
return PurchaseFlow::tryFrom((string) $session->get(self::LAST_STARTED_KEY)) ?? PurchaseFlow::Booking;
}
return match (true) {
$hasBooking => PurchaseFlow::Booking,
$hasVoucher => PurchaseFlow::Voucher,
default => null,
};
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\VoucherSessionNotFoundException;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\HttpFoundation\Request;
/**
* Owns the session state of the voucher purchase flow. Nothing of it is stored in the database.
*/
class VoucherPurchaseSessionManager
{
public const string SESSION_KEY = 'voucher_purchase';
public const string RETURN_URL_KEY = 'voucher_return_url';
public function find(Request $request): ?VoucherPurchaseDto
{
$dto = $request->getSession()->get(self::SESSION_KEY);
return $dto instanceof VoucherPurchaseDto ? $dto : null;
}
/**
* @throws VoucherSessionNotFoundException
*/
public function get(Request $request): VoucherPurchaseDto
{
return $this->find($request) ?? throw new VoucherSessionNotFoundException();
}
public function save(Request $request, VoucherPurchaseDto $dto): void
{
$request->getSession()->set(self::SESSION_KEY, $dto);
}
public function clear(Request $request): void
{
$request->getSession()->remove(self::SESSION_KEY);
}
public function storeReturnUrl(Request $request, ?string $returnUrl): void
{
$request->getSession()->set(self::RETURN_URL_KEY, BookingSessionManager::normalizeReturnUrl($returnUrl));
}
public function getReturnUrl(Request $request): string
{
return $request->getSession()->get(self::RETURN_URL_KEY, BookingSessionManager::DEFAULT_RETURN_URL);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Notification;
/**
* Sends a rejected GUTSCHEINBUCHUNG back to the step that owns the complaint.
*
* Codes from the vendor documentation, corrected by the probes of 2026-10-01: an unknown template
* answers 602 rather than 615, and missing address data comes as 650, which is a hard rejection here.
*/
class VoucherSaleRejectionMapper
{
public const int STEP_VOUCHER = 1;
public const int STEP_BUYER = 2;
public const int STEP_OVERVIEW = 3;
private const array VOUCHER_CODES = [
602, // Gutschein(vorlage) nicht gefunden
604, // Käufername fehlt
605, // Empfängername fehlt
606, // Betrag fehlt
607, // Betrag kleiner als
608, // Betrag größer als
612, 613, // Gutscheinart
614, 615, // Gutscheinstamm
616, // Kapazität nicht ausreichend
621, // Gutscheindaten fehlen
];
private const array BUYER_CODES = [
617, // Rechnungsempfänger fehlt
650, // Fehlende Adress Daten …
];
public function stepFor(Notification $notification): int
{
return match (true) {
\in_array($notification->code, self::VOUCHER_CODES, true) => self::STEP_VOUCHER,
\in_array($notification->code, self::BUYER_CODES, true) => self::STEP_BUYER,
default => self::STEP_OVERVIEW,
};
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplates;
use App\Exception\VoucherShopUnavailableException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Cached access to what BusPro offers for sale as vouchers and how they can be paid.
*
* Failures are not cached: the exception escapes the cache callback, so the next request asks again.
*/
class VoucherTemplateProvider
{
public const string CACHE_TAG = 'voucher-shop';
private const int CACHE_TTL = 300;
private const string TEMPLATES_CACHE_KEY = 'voucher_shop.templates';
private const string PAYMENT_METHOD_CACHE_KEY = 'voucher_shop.payment_method';
public function __construct(
private readonly ApiClient $apiClient,
private readonly TagAwareCacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* @throws VoucherShopUnavailableException
*/
public function getTemplates(): VoucherTemplates
{
return $this->cache->get(self::TEMPLATES_CACHE_KEY, function (ItemInterface $item): VoucherTemplates {
$item->expiresAfter(self::CACHE_TTL);
$item->tag([self::CACHE_TAG]);
$result = $this->request(fn () => $this->apiClient->getVoucherTemplates(), 'templates');
if ([] === $result->templates) {
$this->logger->error('BusPro offers no voucher templates for sale');
throw new VoucherShopUnavailableException('No voucher templates on sale.');
}
return $result;
});
}
/**
* The transfer payment method, the only one the flow offers.
*
* @throws VoucherShopUnavailableException
*/
public function getPaymentMethod(): VoucherPaymentMethod
{
return $this->cache->get(self::PAYMENT_METHOD_CACHE_KEY, function (ItemInterface $item): VoucherPaymentMethod {
$item->expiresAfter(self::CACHE_TTL);
$item->tag([self::CACHE_TAG]);
/** @var list<VoucherPaymentMethod> $paymentMethods */
$paymentMethods = $this->request(fn () => $this->apiClient->getVoucherPaymentMethods(), 'payment methods');
foreach ($paymentMethods as $paymentMethod) {
if ($paymentMethod->isTransfer()) {
return $paymentMethod;
}
}
$this->logger->error('BusPro offers no transfer payment method for vouchers', [
'payment_methods' => array_map(static fn (VoucherPaymentMethod $method) => $method->type, $paymentMethods),
]);
throw new VoucherShopUnavailableException('No transfer payment method for vouchers.');
});
}
/**
* @template T
*
* @param callable(): (T|Notification) $call
*
* @return T
*
* @throws VoucherShopUnavailableException
*/
private function request(callable $call, string $what): mixed
{
try {
$result = $call();
} catch (ApiClientException $e) {
$this->logger->error(sprintf('Unable to fetch voucher %s', $what), ['error' => $e->getMessage()]);
throw new VoucherShopUnavailableException(sprintf('Unable to fetch voucher %s.', $what), 0, $e);
}
if ($result instanceof Notification) {
$this->logger->error(sprintf('BusPro rejected the voucher %s request', $what), [
'code' => $result->code,
'message' => $result->message,
]);
throw new VoucherShopUnavailableException(sprintf('BusPro rejected the voucher %s request.', $what));
}
return $result;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\VoucherTemplate;
use Psr\Log\LoggerInterface;
/**
* Finds the local thumbnail of a voucher design, by convention muster_{idgutscheinvorlage}.jpg in
* assets/vouchers (copied by Encore to images/vouchers).
*
* The thumbnails are copied by hand from the old shop's host, so a design BusPro starts selling has
* none until somebody adds it. It then renders without a picture, and the warning gets it noticed.
*/
class VoucherThumbnailResolver
{
private const string ASSET_PATH = 'build/images/vouchers/muster_%d.jpg';
/** @var array<string, string>|null */
private ?array $manifest = null;
public function __construct(
private readonly string $manifestPath,
private readonly LoggerInterface $logger,
) {
}
/**
* @param list<VoucherTemplate> $templates
*
* @return array<int, string|null> asset path (for asset()) per template id
*/
public function resolveAll(array $templates): array
{
$thumbnails = [];
foreach ($templates as $template) {
$thumbnails[$template->id] = $this->resolve($template);
}
return $thumbnails;
}
public function resolve(VoucherTemplate $template): ?string
{
$path = sprintf(self::ASSET_PATH, $template->id);
if (isset($this->loadManifest()[$path])) {
return $path;
}
$this->logger->warning('No thumbnail for voucher template', [
'template_id' => $template->id,
'template_label' => $template->label,
'expected_source' => sprintf('assets/vouchers/muster_%d.jpg', $template->id),
]);
return null;
}
/**
* @return array<string, string>
*/
private function loadManifest(): array
{
if (null !== $this->manifest) {
return $this->manifest;
}
$content = is_file($this->manifestPath) ? file_get_contents($this->manifestPath) : false;
$manifest = false !== $content ? json_decode($content, true) : null;
return $this->manifest = \is_array($manifest) ? $manifest : [];
}
}