diff --git a/assets/controllers/field_validity_controller.js b/assets/controllers/field_validity_controller.js new file mode 100644 index 00000000..1ee8862a --- /dev/null +++ b/assets/controllers/field_validity_controller.js @@ -0,0 +1,22 @@ +import {Controller} from '@hotwired/stimulus' + +/* + * Keeps buttons disabled until the given fields pass the browser's constraint validation + * (required, min, max, step, …), which the form types render from the same rules the server + * validates. A text field holding only whitespace counts as empty, as NotBlank does. + * + * Works on forms with novalidate too: that only stops the browser from blocking the submit. + */ +export default class extends Controller { + static targets = ['field', 'button'] + + connect() { + this.update() + } + + update() { + const invalid = this.fieldTargets.some(field => !field.checkValidity() + || ('radio' !== field.type && 'checkbox' !== field.type && '' === field.value.trim())) + this.buttonTargets.forEach(button => button.disabled = invalid) + } +} diff --git a/assets/controllers/frame_loading_controller.js b/assets/controllers/frame_loading_controller.js new file mode 100644 index 00000000..1ae29335 --- /dev/null +++ b/assets/controllers/frame_loading_controller.js @@ -0,0 +1,26 @@ +import {Controller} from '@hotwired/stimulus' + +/* + * Shows an indicator over an iframe until its content has loaded. + * + * The controller sets the frame's src itself, after the load listener is attached, so a fast + * response cannot finish before anybody listens. + */ +export default class extends Controller { + static targets = ['frame', 'indicator'] + static values = {src: String} + + connect() { + this.loaded = this.loaded.bind(this) + this.frameTarget.addEventListener('load', this.loaded) + this.frameTarget.src = this.srcValue + } + + disconnect() { + this.frameTarget.removeEventListener('load', this.loaded) + } + + loaded() { + this.indicatorTarget.classList.add('hidden') + } +} diff --git a/assets/vouchers/muster_3.jpg b/assets/vouchers/muster_3.jpg new file mode 100644 index 00000000..3bb5a0bb Binary files /dev/null and b/assets/vouchers/muster_3.jpg differ diff --git a/assets/vouchers/muster_3.pdf b/assets/vouchers/muster_3.pdf new file mode 100644 index 00000000..495eaea1 Binary files /dev/null and b/assets/vouchers/muster_3.pdf differ diff --git a/assets/vouchers/muster_4.jpg b/assets/vouchers/muster_4.jpg new file mode 100644 index 00000000..55a1dd26 Binary files /dev/null and b/assets/vouchers/muster_4.jpg differ diff --git a/assets/vouchers/muster_4.pdf b/assets/vouchers/muster_4.pdf new file mode 100644 index 00000000..634d7a4d Binary files /dev/null and b/assets/vouchers/muster_4.pdf differ diff --git a/assets/vouchers/muster_5.jpg b/assets/vouchers/muster_5.jpg new file mode 100644 index 00000000..a5fe51a1 Binary files /dev/null and b/assets/vouchers/muster_5.jpg differ diff --git a/assets/vouchers/muster_5.pdf b/assets/vouchers/muster_5.pdf new file mode 100644 index 00000000..eb7e5e13 Binary files /dev/null and b/assets/vouchers/muster_5.pdf differ diff --git a/config/services.yaml b/config/services.yaml index 15b687e6..94e1566f 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -238,6 +238,25 @@ services: $cache: '@cache.app' $logger: '@monolog.logger.bpn' + # Voucher purchase flow (/gutschein) + App\Service\VoucherTemplateProvider: + arguments: + $cache: '@bpn.cache' + $logger: '@monolog.logger.bpn' + + App\Service\VoucherThumbnailResolver: + arguments: + $manifestPath: '%kernel.project_dir%/public/build/manifest.json' + $logger: '@monolog.logger.bpn' + + App\Controller\Voucher\Step1Controller: + arguments: + $logger: '@monolog.logger.bpn' + + App\Controller\Voucher\Step3Controller: + arguments: + $logger: '@monolog.logger.bpn' + # Field Handlers with dependencies App\Form\Service\ParticipantInsuranceFieldHandler: ~ App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~ diff --git a/docs/bpn-xml-api-reference.md b/docs/bpn-xml-api-reference.md index a644d40f..91ad3071 100644 --- a/docs/bpn-xml-api-reference.md +++ b/docs/bpn-xml-api-reference.md @@ -312,6 +312,67 @@ answers `` plus per-service `status` ("buchbar"), `frei`, `prei `ZAHLUNG` reports a credit-card authorisation after booking; the amount must equal the confirmed `anzahlung` or errors `1011`-`1018` follow. +## Selling vouchers (Kaufgutschein) + +Used by the `/gutschein` flow, which replaces the BusProNet-hosted shop +(`maske_gutschein.php`). `ApiClient::TYPE_VOUCHER_*`; not to be confused with +`TYPE_PURCHASE_VOUCHER` (`GUTSCHEINPRUEFUNGEINLOESUNG`), which redeems a voucher in a booking. + +| Satz | Request | Response | +|---|---|---| +| `GUTSCHEINVORLAGEN` | — | `gutscheinminbetrag`, `gutscheinmaxbetrag` (**undocumented**, measured), then `KAllgemein` | +| `GUTSCHEINZAHLUNGSARTEN` | — | `` (measured; the attributes are echoed as `` below) | +| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `data:application/pdf;base64,…` (measured, about 430 KB) | +| `ADRESSFELDERGUTSCHEIN` | — | which address fields to show and which are mandatory; not used, the flow fixes the live shop's fields | +| `GUTSCHEINBUCHUNG` | see below | `buchung=erfolgt`, `idgutschein`, `gutscheinnummer`, `einloesecode`, `preis`, `rechnungsnummer`, `email` | +| `GUTSCHEINDRUCK` | `idgutschein`, `einloesecode` | the voucher PDF; not used, BusPro mails the voucher itself once the payment has arrived | + +```xml + +Anfrage|Buchung +K +3 +… +… +25 + + + + D + + + +``` + +`` is documented but not sent; the total is the voucher value. `telefonmobil` is not +in the vendor's example, its name is the one `BUCHUNG` uses, and the test system accepts it. The +live shop also collects a business phone, whose element name is not documented anywhere, so the +flow does not collect it. + +### Measured on the test system (2026-10-01) + +`buchungsart=Anfrage` is a full dry run: a valid request answers `möglich` and +nothing else (no `idgutschein`, `gutscheinnummer`, `einloesecode`, `rechnungsnummer`), so nothing is +issued. `idagentur` is not required (619/620 never fired). + +| Variant | Answer | +|---|---| +| Valid (control), 25 € | `möglich` | +| `kaufpreis` 6000 | `608 Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein` | +| `kaufpreis` 5 | `607 Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein` | +| `kaufpreis` 5000 | `möglich` (bounds inclusive) | +| `kaufpreis` 25,50 | `möglich` (cents accepted; the flow offers whole euros only) | +| `empfaenger` missing | `605 Empfängername fehlt` | +| `idgutscheinvorlage` 999 | `602 Gutscheine mit ID 999 nicht gefunden` (not 615 as documented) | +| `email` empty | `650 Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse` | +| `strasse` empty | `650 … Strasse` | +| `anrede` Herr, `telefonmobil` set | `möglich` | + +Missing address data is a `650` and a hard rejection, as on `BUCHUNG`. The flow therefore treats +every `HINWEIS` on this path as a rejection and sends it back to the step that owns it +(`VoucherSaleRejectionMapper`). The design ids (3, 4, 5) match the thumbnail file names of the old +shop, which suggests they are shared with production; confirm on the first production read. + ## Error codes Returned as `……`. `#1#`/`#2#` are substituted at @@ -320,7 +381,7 @@ runtime. > **`650` is not a success code.** Its documented text is a bare `#1#` — a generic message > carrier whose entire content is substituted. `Notification::isError()` > (`src/BusProNet/Model/Notification.php:33-36`) treats it as success, which happens to hold on -> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`, where the same code delivers +> the `KUNDENKONTO` paths but not on `BUCHUNG`/`BUCHUNGAENDERUNG`/`GUTSCHEINBUCHUNG`, where the same code delivers > real rejections such as *"Es wurde nicht jedem Teilnehmer eine Pflichtleistung zugeordnet"*. > `BookingEditSubmitter.php:81` therefore reports some genuine failures as INFO. @@ -359,8 +420,11 @@ zugelassen** · 983 Anfragebuchung nicht zugelassen · 984 Leistung nicht fürs 995/996 Gutschein · 997/998 Buchung-ID · 1001 Reisedatum fehlerhaft · 1002 Buchungsdatum fehlerhaft · 1003 Status im Vorgang nicht korrekt · 1011-1018 Zahlungsabgleich -**Vouchers** — 600-608 Gutscheine · 610/611 Buchungsart · 612-621 Gutscheinart / Stamm / -Kapazität · 622 Einlösecode fehlt · 630-632 Aktionsgutschein +**Vouchers** — 600-608 Gutscheine (602 Vorlage nicht gefunden, measured; 604 Käufername · +605 Empfängername · 606 Betrag fehlt · 607/608 Betrag kleiner/größer als #1# Euro) · +610/611 Buchungsart · 612/613 Gutscheinart · 614/615 Gutscheinstamm · 616 Kapazität nicht +ausreichend · 617 Rechnungsempfänger fehlt · 618 Zahlungsart fehlt · 619/620 Agentur-ID · +621 Gutscheindaten fehlen · 622 Einlösecode fehlt · 630-632 Aktionsgutschein ## Applying this: the teamer placeholder booking diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 20154a69..8b6728c9 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -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 */ 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|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. * diff --git a/src/BusProNet/DataProcessor/VoucherSalePayloadBuilder.php b/src/BusProNet/DataProcessor/VoucherSalePayloadBuilder.php new file mode 100644 index 00000000..74462fee --- /dev/null +++ b/src/BusProNet/DataProcessor/VoucherSalePayloadBuilder.php @@ -0,0 +1,80 @@ + and nothing else: linking the purchase to a customer + * record is BusPro's business. The 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 + */ + 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 + */ + 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' => '', + ]; + } +} diff --git a/src/BusProNet/Model/VoucherPaymentMethod.php b/src/BusProNet/Model/VoucherPaymentMethod.php new file mode 100644 index 00000000..4541362f --- /dev/null +++ b/src/BusProNet/Model/VoucherPaymentMethod.php @@ -0,0 +1,27 @@ + 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; + } +} diff --git a/src/BusProNet/Model/VoucherPreview.php b/src/BusProNet/Model/VoucherPreview.php new file mode 100644 index 00000000..a947c133 --- /dev/null +++ b/src/BusProNet/Model/VoucherPreview.php @@ -0,0 +1,16 @@ +möglich and nothing else, so every + * other field is only set by the actual purchase (buchungsart Buchung, erfolgt). + * + * 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; + } +} diff --git a/src/BusProNet/Model/VoucherTemplate.php b/src/BusProNet/Model/VoucherTemplate.php new file mode 100644 index 00000000..7975c107 --- /dev/null +++ b/src/BusProNet/Model/VoucherTemplate.php @@ -0,0 +1,21 @@ + $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; + } +} diff --git a/src/BusProNet/XmlParser/ApiResponseParser.php b/src/BusProNet/XmlParser/ApiResponseParser.php index 9fab5779..7c601c5c 100644 --- a/src/BusProNet/XmlParser/ApiResponseParser.php +++ b/src/BusProNet/XmlParser/ApiResponseParser.php @@ -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); diff --git a/src/BusProNet/XmlParser/VoucherPaymentMethodsParser.php b/src/BusProNet/XmlParser/VoucherPaymentMethodsParser.php new file mode 100644 index 00000000..a9f3a4ca --- /dev/null +++ b/src/BusProNet/XmlParser/VoucherPaymentMethodsParser.php @@ -0,0 +1,39 @@ + + * + * + * + * + * + */ +final class VoucherPaymentMethodsParser extends AbstractParser +{ + /** + * @return list + */ + 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; + } +} diff --git a/src/BusProNet/XmlParser/VoucherPreviewParser.php b/src/BusProNet/XmlParser/VoucherPreviewParser.php new file mode 100644 index 00000000..3e9d75e5 --- /dev/null +++ b/src/BusProNet/XmlParser/VoucherPreviewParser.php @@ -0,0 +1,37 @@ + + * + * data:application/pdf;base64,JVBERi0xLjQ… + * + */ +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); + } +} diff --git a/src/BusProNet/XmlParser/VoucherSaleResponseParser.php b/src/BusProNet/XmlParser/VoucherSaleResponseParser.php new file mode 100644 index 00000000..cb22c755 --- /dev/null +++ b/src/BusProNet/XmlParser/VoucherSaleResponseParser.php @@ -0,0 +1,49 @@ +möglich + * + * Purchase (buchungsart Buchung, as documented by the vendor): + * + * + * erfolgt + * + * + */ +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; + } +} diff --git a/src/BusProNet/XmlParser/VoucherTemplatesParser.php b/src/BusProNet/XmlParser/VoucherTemplatesParser.php new file mode 100644 index 00000000..27b718cb --- /dev/null +++ b/src/BusProNet/XmlParser/VoucherTemplatesParser.php @@ -0,0 +1,57 @@ + + * + * 10 + * 5000 + * + * + * K + * Allgemein + * + * + * + * + */ +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, + ); + } +} diff --git a/src/Controller/Booking/Create/IndexController.php b/src/Controller/Booking/Create/IndexController.php index 3ce69ef4..a46adc24 100644 --- a/src/Controller/Booking/Create/IndexController.php +++ b/src/Controller/Booking/Create/IndexController.php @@ -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( diff --git a/src/Controller/SecurityController.php b/src/Controller/SecurityController.php index 82952878..487dd464 100644 --- a/src/Controller/SecurityController.php +++ b/src/Controller/SecurityController.php @@ -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 diff --git a/src/Controller/Voucher/AbstractVoucherPurchaseController.php b/src/Controller/Voucher/AbstractVoucherPurchaseController.php new file mode 100644 index 00000000..e24ceab0 --- /dev/null +++ b/src/Controller/Voucher/AbstractVoucherPurchaseController.php @@ -0,0 +1,71 @@ + '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 + */ + protected function createStepContext(VoucherPurchaseDto $dto): array + { + return [ + 'voucher' => $dto, + 'voucherThumbnail' => null !== $dto->template ? $this->thumbnailResolver->resolve($dto->template) : null, + 'stepRoutes' => self::STEP_ROUTES, + ]; + } +} diff --git a/src/Controller/Voucher/IndexController.php b/src/Controller/Voucher/IndexController.php new file mode 100644 index 00000000..1b47bc95 --- /dev/null +++ b/src/Controller/Voucher/IndexController.php @@ -0,0 +1,111 @@ +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.', + ]); + } +} diff --git a/src/Controller/Voucher/Step1Controller.php b/src/Controller/Voucher/Step1Controller.php new file mode 100644 index 00000000..0c5cdcae --- /dev/null +++ b/src/Controller/Voucher/Step1Controller.php @@ -0,0 +1,219 @@ +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 + * + * @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 $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)); + } +} diff --git a/src/Controller/Voucher/Step2Controller.php b/src/Controller/Voucher/Step2Controller.php new file mode 100644 index 00000000..3622f22a --- /dev/null +++ b/src/Controller/Voucher/Step2Controller.php @@ -0,0 +1,66 @@ +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, + ]); + } +} diff --git a/src/Controller/Voucher/Step3Controller.php b/src/Controller/Voucher/Step3Controller.php new file mode 100644 index 00000000..bf91cde0 --- /dev/null +++ b/src/Controller/Voucher/Step3Controller.php @@ -0,0 +1,199 @@ +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 $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 $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 $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 $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, + ]); + } +} diff --git a/src/Controller/Voucher/SuccessController.php b/src/Controller/Voucher/SuccessController.php new file mode 100644 index 00000000..b9174fba --- /dev/null +++ b/src/Controller/Voucher/SuccessController.php @@ -0,0 +1,56 @@ +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, + ]); + } +} diff --git a/src/Exception/VoucherSessionNotFoundException.php b/src/Exception/VoucherSessionNotFoundException.php new file mode 100644 index 00000000..ce4da7ef --- /dev/null +++ b/src/Exception/VoucherSessionNotFoundException.php @@ -0,0 +1,19 @@ +, 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; + } +} diff --git a/src/Form/VoucherPurchaseStep1Type.php b/src/Form/VoucherPurchaseStep1Type.php new file mode 100644 index 00000000..43910605 --- /dev/null +++ b/src/Form/VoucherPurchaseStep1Type.php @@ -0,0 +1,64 @@ + */ +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'); + } +} diff --git a/src/Form/VoucherPurchaseStep2Type.php b/src/Form/VoucherPurchaseStep2Type.php new file mode 100644 index 00000000..5960a7a5 --- /dev/null +++ b/src/Form/VoucherPurchaseStep2Type.php @@ -0,0 +1,76 @@ + + */ +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'], + ]); + } +} diff --git a/src/Form/VoucherPurchaseStep3Type.php b/src/Form/VoucherPurchaseStep3Type.php new file mode 100644 index 00000000..a96e738f --- /dev/null +++ b/src/Form/VoucherPurchaseStep3Type.php @@ -0,0 +1,52 @@ + */ +class VoucherPurchaseStep3Type extends AbstractType +{ + public function __construct( + private readonly string $termsAndConditionsUrl, + ) { + } + + public function buildForm(FormBuilderInterface $builder, array $options): void + { + $termsLink = sprintf( + 'Allgemeinen Geschäftsbedingungen', + 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'); + } +} diff --git a/src/Model/PurchaseFlow.php b/src/Model/PurchaseFlow.php new file mode 100644 index 00000000..36b95e8e --- /dev/null +++ b/src/Model/PurchaseFlow.php @@ -0,0 +1,30 @@ + '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', + }; + } +} diff --git a/src/Service/BookingSessionManager.php b/src/Service/BookingSessionManager.php index d93d1ddc..42df77fd 100644 --- a/src/Service/BookingSessionManager.php +++ b/src/Service/BookingSessionManager.php @@ -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; } /** diff --git a/src/Service/ParticipantDataPrefiller.php b/src/Service/ParticipantDataPrefiller.php index 6bd3a4e7..fdc31df6 100644 --- a/src/Service/ParticipantDataPrefiller.php +++ b/src/Service/ParticipantDataPrefiller.php @@ -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; } } diff --git a/src/Service/PurchaseFlowResolver.php b/src/Service/PurchaseFlowResolver.php new file mode 100644 index 00000000..2111e9c2 --- /dev/null +++ b/src/Service/PurchaseFlowResolver.php @@ -0,0 +1,41 @@ +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, + }; + } +} diff --git a/src/Service/VoucherPurchaseSessionManager.php b/src/Service/VoucherPurchaseSessionManager.php new file mode 100644 index 00000000..b89aa467 --- /dev/null +++ b/src/Service/VoucherPurchaseSessionManager.php @@ -0,0 +1,53 @@ +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); + } +} diff --git a/src/Service/VoucherSaleRejectionMapper.php b/src/Service/VoucherSaleRejectionMapper.php new file mode 100644 index 00000000..db2b0262 --- /dev/null +++ b/src/Service/VoucherSaleRejectionMapper.php @@ -0,0 +1,47 @@ +code, self::VOUCHER_CODES, true) => self::STEP_VOUCHER, + \in_array($notification->code, self::BUYER_CODES, true) => self::STEP_BUYER, + default => self::STEP_OVERVIEW, + }; + } +} diff --git a/src/Service/VoucherTemplateProvider.php b/src/Service/VoucherTemplateProvider.php new file mode 100644 index 00000000..327223d5 --- /dev/null +++ b/src/Service/VoucherTemplateProvider.php @@ -0,0 +1,113 @@ +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 $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; + } +} diff --git a/src/Service/VoucherThumbnailResolver.php b/src/Service/VoucherThumbnailResolver.php new file mode 100644 index 00000000..01e979e8 --- /dev/null +++ b/src/Service/VoucherThumbnailResolver.php @@ -0,0 +1,75 @@ +|null */ + private ?array $manifest = null; + + public function __construct( + private readonly string $manifestPath, + private readonly LoggerInterface $logger, + ) { + } + + /** + * @param list $templates + * + * @return array 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 + */ + 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 : []; + } +} diff --git a/templates/_partials/_cancel_button.html.twig b/templates/_partials/_cancel_button.html.twig index 2dfbf502..3032d9cc 100644 --- a/templates/_partials/_cancel_button.html.twig +++ b/templates/_partials/_cancel_button.html.twig @@ -1,5 +1,5 @@ + + + + + + + + + Passwort vergessen? + + diff --git a/templates/booking/_pagination.html.twig b/templates/booking/_pagination.html.twig index 751563c9..8a4fd577 100644 --- a/templates/booking/_pagination.html.twig +++ b/templates/booking/_pagination.html.twig @@ -1,15 +1,16 @@ +{# step_routes defaults to the booking flow; other flows (the voucher purchase) pass their own #} {% set current_step = current_step ?? 1 %} -{% set step_routes = { +{% set step_routes = step_routes ?? { 1: 'app_booking_create_step_1', 2: 'app_booking_create_step_2', 3: 'app_booking_create_step_3', 4: 'app_booking_create_step_4' } %}