feat: voucher purchase flow
This commit is contained in:
1 parent
a628e96d06
commit
f3dd122678
70 files changed
+3672
-122
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
Binary file not shown.
@@ -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: ~
|
||||
|
||||
@@ -312,6 +312,67 @@ answers `<reise id status>` 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 `<gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis/>` |
|
||||
| `GUTSCHEINZAHLUNGSARTEN` | — | `<zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"/>` (measured; the attributes are echoed as `<zahlung>` below) |
|
||||
| `GUTSCHEINVORSCHAU` | `idgutscheinvorlage`, `kaeufer`, `empfaenger`, `kaufpreis`, `text` | `<gutscheinpdf>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
|
||||
<satz typ="GUTSCHEINBUCHUNG"/>
|
||||
<buchungsart>Anfrage|Buchung</buchungsart>
|
||||
<gutscheinart>K</gutscheinart>
|
||||
<idgutscheinvorlage>3</idgutscheinvorlage>
|
||||
<kaeufer>…</kaeufer> <!-- "von", printed on the voucher -->
|
||||
<empfaenger>…</empfaenger> <!-- "für", printed on the voucher -->
|
||||
<kaufpreis>25</kaufpreis>
|
||||
<text/> <!-- Zusatztext; the live shop sends it empty -->
|
||||
<rechnungsempfänger>
|
||||
<name/><vorname/><anrede/><titel/><geschlecht/><geburtsdatum/>
|
||||
<anschrift><strasse/><plz/><ort/><ortsteil/><land>D</land></anschrift>
|
||||
<kommunikation><telefonprivat/><email/><telefonmobil/></kommunikation>
|
||||
</rechnungsempfänger>
|
||||
<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>
|
||||
```
|
||||
|
||||
`<preiszusaetze>` 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 `<buchung>möglich</buchung>` 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 `<satz typ="HINWEIS"><nr>…</nr><text>…</text></satz>`. `#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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<button type="button"
|
||||
hx-get="{{ path('app_booking_cancel') }}"
|
||||
hx-get="{{ cancel_url ?? path('app_booking_cancel') }}"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
class="inline-flex items-center justify-center bg-gray-200 rounded-md w-8 h-8 md:w-10 md:h-10">
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
{# Sign-in step of a purchase flow: the login form the booking and voucher flows share #}
|
||||
<div>
|
||||
<h2 class="text-white">
|
||||
Mit bestehendem Account anmelden
|
||||
</h2>
|
||||
|
||||
{% if error %}
|
||||
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ path('app_login') }}" method="post" hx-history="false">
|
||||
<div class="mb-4">
|
||||
<label for="username" class="mb-1 font-semibold text-white">
|
||||
E-Mail:
|
||||
</label>
|
||||
<input type="email"
|
||||
id="username"
|
||||
name="_username"
|
||||
value="{{ last_username }}"
|
||||
class="form-field"
|
||||
required
|
||||
autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
|
||||
<label for="password" class="mb-1 font-semibold text-white">
|
||||
Passwort:
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="_password"
|
||||
class="form-field"
|
||||
{{ stimulus_target('password-reveal', 'field') }}
|
||||
required
|
||||
autocomplete="current-password">
|
||||
<button type="button"
|
||||
{{ stimulus_action('password-reveal', 'toggle') }}
|
||||
class="absolute top-0 right-0 mt-2 mr-2">
|
||||
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button button--primary w-full md:w-auto">
|
||||
Anmelden und fortfahren
|
||||
</button>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
</form>
|
||||
|
||||
<a href="{{ path('app_reset_password') }}"
|
||||
class="inline-block text-sm underline text-white mt-2">
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</div>
|
||||
@@ -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'
|
||||
} %}
|
||||
<nav class="pagination">
|
||||
{% for step in 1..4 %}
|
||||
{% for step, step_route in step_routes %}
|
||||
<div class="pagination-item">
|
||||
{% if step < current_step %}
|
||||
<a href="{{ path(step_routes[step]) }}"
|
||||
<a href="{{ path(step_route) }}"
|
||||
class="pagination-item__border">
|
||||
<span class="pagination-item__inner bg-primary-bg text-gray-800">
|
||||
{{ step }}
|
||||
|
||||
@@ -36,60 +36,7 @@
|
||||
Als Gast fortfahren
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-white">
|
||||
Mit bestehendem Account anmelden
|
||||
</h2>
|
||||
|
||||
{% if error %}
|
||||
{% include '_partials/_alert.html.twig' with { 'level': 'error', messages: [ error.messageKey|trans(error.messageData, 'security') ] } %}
|
||||
{% endif %}
|
||||
|
||||
<form action="{{ path('app_login') }}" method="post" hx-history="false">
|
||||
<div class="mb-4">
|
||||
<label for="username" class="mb-1 font-semibold text-white">
|
||||
E-Mail:
|
||||
</label>
|
||||
<input type="email"
|
||||
id="username"
|
||||
name="_username"
|
||||
value="{{ last_username }}"
|
||||
class="form-field"
|
||||
required
|
||||
autocomplete="username">
|
||||
</div>
|
||||
|
||||
<div class="mb-4" {{ stimulus_controller('password-reveal') }}>
|
||||
<label for="password" class="mb-1 font-semibold text-white">
|
||||
Passwort:
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="password"
|
||||
id="password"
|
||||
name="_password"
|
||||
class="form-field"
|
||||
{{ stimulus_target('password-reveal', 'field') }}
|
||||
required
|
||||
autocomplete="current-password">
|
||||
<button type="button"
|
||||
{{ stimulus_action('password-reveal', 'toggle') }}
|
||||
class="absolute top-0 right-0 mt-2 mr-2">
|
||||
{{ icon('eye', 'w-6 h-6 text-gray-500') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="button button--primary w-full md:w-auto">
|
||||
Anmelden und fortfahren
|
||||
</button>
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('authenticate') }}">
|
||||
</form>
|
||||
|
||||
<a href="{{ path('app_reset_password') }}"
|
||||
class="inline-block text-sm underline text-white mt-2">
|
||||
Passwort vergessen?
|
||||
</a>
|
||||
</div>
|
||||
{% include '_partials/_purchase_login_form.html.twig' %}
|
||||
|
||||
<div>
|
||||
<h3 class=" text-white">
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="max-w-lg mx-auto">
|
||||
{% set errorMessages = app.flashes('error')|default(['Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.']) %}
|
||||
{% set errorMessages = app.flashes('error')|default([defaultErrorMessage ?? 'Es ist ein Fehler beim Starten des Buchungsvorgangs aufgetreten.']) %}
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'error',
|
||||
title: 'Das hat nicht geklappt',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{# Sidebar of the voucher purchase steps, in place of the booking summary #}
|
||||
<div class="bg-white h-full flex flex-col lg:border-l lg:border-primary-bg">
|
||||
<div class="shrink-0 px-4 lg:px-8 py-4 lg:flex lg:flex-col lg:justify-center shadow-md relative z-10">
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<span class="block uppercase font-semibold">Gesamtpreis</span>
|
||||
<span class="font-semibold">{{ (voucher.amount ?? 0)|format_currency('EUR') }}</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="block uppercase font-semibold">Dein Gutschein</span>
|
||||
<button type="button"
|
||||
class="lg:hidden button button--small bg-primary-light"
|
||||
data-action="toggle#toggle">
|
||||
<svg class="w-4 h-4 text-white transition-transform"
|
||||
data-toggle-target="icon"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 256 256"><rect width="256" height="256" fill="none"/><polyline points="208 96 128 176 48 96" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="16"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:block flex-1 min-h-0 bg-white shadow-lg lg:shadow-none overflow-y-auto scroll-stable px-4 lg:px-8 py-8"
|
||||
data-toggle-target="content">
|
||||
{% if voucherThumbnail %}
|
||||
<img src="{{ asset(voucherThumbnail) }}"
|
||||
alt="Motiv {{ voucher.template.label }}"
|
||||
class="block w-full max-w-sm h-auto mb-4 shadow">
|
||||
{% endif %}
|
||||
|
||||
<div class="border border-primary-bg mb-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">
|
||||
Gutschein
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<tr>
|
||||
<td class="p-2 align-top w-24">Motiv</td>
|
||||
<td class="p-2 align-top">{{ voucher.template.label ?? '–' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2 align-top w-24">Von</td>
|
||||
<td class="p-2 align-top break-words">{{ voucher.from ?: '–' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2 align-top w-24">Für</td>
|
||||
<td class="p-2 align-top break-words">{{ voucher.to ?: '–' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2 align-top w-24">Wert</td>
|
||||
<td class="p-2 align-top">{{ voucher.amount is not null ? voucher.amount|format_currency('EUR') : '–' }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pb-2">
|
||||
<span class="block uppercase font-semibold">Gesamtpreis</span>
|
||||
<span class="font-semibold">{{ (voucher.amount ?? 0)|format_currency('EUR') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Gutschein kaufen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="px-4 lg:px-8 py-8 lg:py-16">
|
||||
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
|
||||
<h1 class="text-white uppercase pb-4 mb-4 border-b border-primary-bg/40">
|
||||
Gutschein<br>kaufen
|
||||
</h1>
|
||||
|
||||
<div class="space-y-8 max-w-screen-sm">
|
||||
<div>
|
||||
<a href="{{ path('app_voucher_step_1') }}" class="button button--primary w-full md:w-auto">
|
||||
Als Gast fortfahren
|
||||
</a>
|
||||
</div>
|
||||
{% include '_partials/_purchase_login_form.html.twig' %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block footer %}
|
||||
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4">
|
||||
{% include '_partials/_cancel_button.html.twig' with { cancel_url: path('app_voucher_cancel') } %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends 'layout_booking.html.twig' %}
|
||||
|
||||
{% block title %}Gutschein kaufen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex-1 flex items-center justify-center"
|
||||
hx-post="{{ path('app_voucher_init', { r: returnUrl }) }}"
|
||||
hx-trigger="load"
|
||||
hx-swap="none">
|
||||
{% include '_partials/_spinner.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Sicher?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="pb-8">
|
||||
Möchtest du den Gutscheinkauf wirklich abbrechen?
|
||||
</div>
|
||||
<div class="flex flex-col space-y-4 md:flex-row md:space-y-0 justify-between">
|
||||
<button type="button"
|
||||
hx-post="{{ path('app_voucher_cancel') }}"
|
||||
hx-target="body"
|
||||
class="button button--primary">
|
||||
Ja, Kauf abbrechen
|
||||
</button>
|
||||
<button type="button"
|
||||
{{ stimulus_action('modal', 'close') }}
|
||||
class="button button--secondary">
|
||||
Nein, weiter einkaufen
|
||||
</button>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,29 @@
|
||||
{% extends 'htmx_modal.html.twig' %}
|
||||
|
||||
{% block title %}Vorschau{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% if not previewable %}
|
||||
<div class="pb-4">
|
||||
Für die Vorschau bitte zuerst Motiv, Von, Für und Gutscheinwert angeben.
|
||||
</div>
|
||||
{% else %}
|
||||
{% set previewUrl = path('app_voucher_preview_pdf', { v: previewVersion }) %}
|
||||
{# BusPro renders the PDF on demand, which takes a few seconds #}
|
||||
<div class="relative" {{ stimulus_controller('frame-loading', { src: previewUrl }) }}>
|
||||
<iframe title="Vorschau deines Gutscheins"
|
||||
class="w-full h-[70vh] border border-primary-bg"
|
||||
{{ stimulus_target('frame-loading', 'frame') }}></iframe>
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center gap-4 bg-white"
|
||||
{{ stimulus_target('frame-loading', 'indicator') }}>
|
||||
{% include '_partials/_spinner.html.twig' with { class: 'w-24 h-24' } %}
|
||||
<span>Die Vorschau wird erstellt …</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<a href="{{ previewUrl }}" target="_blank" rel="noopener" hx-boost="false" class="underline">
|
||||
Vorschau in neuem Fenster öffnen
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{# Shown inside the preview frame, outside the site layout #}
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Vorschau nicht verfügbar</title>
|
||||
</head>
|
||||
<body style="font-family: sans-serif; padding: 1rem;">
|
||||
<p>{{ message }}</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,107 @@
|
||||
{% extends 'layout_booking.html.twig' %}
|
||||
|
||||
{% set htmx_change_trigger = 'change delay:300ms' %}
|
||||
|
||||
{% block title %}Gutschein kaufen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, {
|
||||
'attr': {
|
||||
'class': 'flex-1 flex flex-col min-h-0',
|
||||
'novalidate': 'novalidate'
|
||||
}
|
||||
}) }}
|
||||
{# Pagination - mobile only (above summary) #}
|
||||
<div class="lg:hidden">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 1, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
|
||||
{% block voucher_summary %}
|
||||
<div id="voucher-summary"
|
||||
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
|
||||
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}
|
||||
{% if htmx_oob_swap|default(false) %}hx-swap-oob="true"{% endif %}>
|
||||
{% include 'voucher/_summary.html.twig' %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
<div id="form-wrapper"
|
||||
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
{# Pagination - desktop only (fixed above scrollable content) #}
|
||||
<div class="hidden lg:block shrink-0">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 1, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
{# The preview button waits until the voucher fields would pass step 1's validation #}
|
||||
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8"
|
||||
{{ stimulus_controller('field-validity') }}
|
||||
data-action="input->field-validity#update change->field-validity#update">
|
||||
{% set validatedFieldAttr = { 'data-field-validity-target': 'field' } %}
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form, 'Bitte überprüfe deine Angaben', false) }}
|
||||
|
||||
{# Any change takes the input over unvalidated and refreshes the summary #}
|
||||
<div hx-post="{{ path('app_voucher_step_1_refresh') }}"
|
||||
hx-trigger="{{ htmx_change_trigger }}"
|
||||
hx-include="closest form"
|
||||
hx-swap="none">
|
||||
<h2 class="pb-4">
|
||||
Motiv
|
||||
</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4 pb-2">
|
||||
{% for choice in form.template %}
|
||||
<label for="{{ choice.vars.id }}"
|
||||
class="block cursor-pointer bg-white rounded-md border-2 border-transparent p-2 has-[:checked]:border-primary-light">
|
||||
{% set thumbnail = thumbnails[choice.vars.value] ?? null %}
|
||||
{% if thumbnail %}
|
||||
<img src="{{ asset(thumbnail) }}"
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="block w-full h-auto mb-2">
|
||||
{% endif %}
|
||||
<span class="flex items-center gap-2 font-semibold">
|
||||
{{ form_widget(choice, { attr: choice.vars.attr|merge(validatedFieldAttr) }) }}
|
||||
{{ choice.vars.label }}
|
||||
</span>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% do form.template.setRendered %}
|
||||
<div class="pb-6">{{ form_errors(form.template) }}</div>
|
||||
|
||||
<h2 class="pb-4">
|
||||
Gutscheindaten
|
||||
</h2>
|
||||
{{ form_row(form.from, { attr: form.from.vars.attr|merge(validatedFieldAttr) }) }}
|
||||
{{ form_row(form.to, { attr: form.to.vars.attr|merge(validatedFieldAttr) }) }}
|
||||
{{ form_row(form.amount, {
|
||||
attr: form.amount.vars.attr|merge(validatedFieldAttr),
|
||||
help: 'Ganze Euro-Beträge von %s € bis %s €'|format(voucher.minAmount|number_format(0, ',', '.'), voucher.maxAmount|number_format(0, ',', '.'))
|
||||
}) }}
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="button button--secondary disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
title="Für die Vorschau bitte zuerst Motiv, Von, Für und Gutscheinwert angeben"
|
||||
{{ stimulus_target('field-validity', 'button') }}
|
||||
{% if not previewable %}disabled{% endif %}
|
||||
hx-post="{{ path('app_voucher_preview') }}"
|
||||
hx-include="closest form"
|
||||
hx-target="body"
|
||||
hx-swap="beforeend"
|
||||
{{ qa_attribute('btn-preview') }}>
|
||||
Vorschau anzeigen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
|
||||
<div class="flex justify-between" hx-disinherit="*">
|
||||
{% include '_partials/_cancel_button.html.twig' with { cancel_url: path('app_voucher_cancel') } %}
|
||||
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }}>
|
||||
Weiter zu Schritt 2
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,79 @@
|
||||
{% extends 'layout_booking.html.twig' %}
|
||||
|
||||
{% block title %}Gutschein kaufen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, {
|
||||
'attr': {
|
||||
'class': 'flex-1 flex flex-col min-h-0',
|
||||
'novalidate': 'novalidate'
|
||||
}
|
||||
}) }}
|
||||
{# Pagination - mobile only (above summary) #}
|
||||
<div class="lg:hidden">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 2, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
|
||||
<div id="voucher-summary"
|
||||
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
|
||||
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
|
||||
{% include 'voucher/_summary.html.twig' %}
|
||||
</div>
|
||||
<div id="form-wrapper"
|
||||
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-primary-bg flex flex-col min-h-0">
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
{# Pagination - desktop only (fixed above scrollable content) #}
|
||||
<div class="hidden lg:block shrink-0">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 2, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form) }}
|
||||
|
||||
<h2 class="pb-4">
|
||||
Käufer:in
|
||||
</h2>
|
||||
{{ form_row(form.salutation) }}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 md:gap-x-4">
|
||||
{{ form_row(form.title) }}
|
||||
<div class="hidden md:block"></div>
|
||||
{{ form_row(form.firstName) }}
|
||||
{{ form_row(form.lastName) }}
|
||||
<div class="md:col-span-2">{{ form_row(form.address.street) }}</div>
|
||||
{{ form_row(form.address.postCode) }}
|
||||
{{ form_row(form.address.city) }}
|
||||
{{ form_row(form.address.country) }}
|
||||
<div class="hidden md:block"></div>
|
||||
{{ form_row(form.email) }}
|
||||
<div class="hidden md:block"></div>
|
||||
{{ form_row(form.phone) }}
|
||||
{{ form_row(form.mobile) }}
|
||||
</div>
|
||||
|
||||
<h2 class="pt-4 pb-4">
|
||||
Zahlungsart
|
||||
</h2>
|
||||
<div class="border border-primary-bg bg-white p-4">
|
||||
{{ voucher.paymentMethod.label ?? 'Überweisung' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
|
||||
<div class="flex justify-between" hx-disinherit="*">
|
||||
{% include '_partials/_cancel_button.html.twig' with { cancel_url: path('app_voucher_cancel') } %}
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ path('app_voucher_step_1') }}"
|
||||
class="button button--secondary"
|
||||
{{ qa_attribute('btn-back') }}>
|
||||
Zurück
|
||||
</a>
|
||||
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }}>
|
||||
Weiter zu Schritt 3
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,124 @@
|
||||
{% extends 'layout_booking.html.twig' %}
|
||||
|
||||
{% block title %}Gutschein kaufen{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{{ form_start(form, {
|
||||
'attr': {
|
||||
'class': 'flex-1 flex flex-col min-h-0',
|
||||
'novalidate': 'novalidate',
|
||||
'hx-disabled-elt': 'find button[type=submit]'
|
||||
}
|
||||
}) }}
|
||||
{# Pagination - mobile only #}
|
||||
<div class="lg:hidden">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 3, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
<div class="flex-1 flex flex-col lg:grid lg:grid-cols-5 min-h-0 relative">
|
||||
<div id="voucher-summary"
|
||||
class="order-1 lg:order-2 lg:flex-1 lg:min-h-0 lg:col-span-2"
|
||||
{{ stimulus_controller('toggle', { 'open': false }, { 'closed': 'hidden', 'open': 'absolute inset-0 z-20', 'iconOpen': 'rotate-180' }) }}>
|
||||
{% include 'voucher/_summary.html.twig' %}
|
||||
</div>
|
||||
<div id="form-wrapper"
|
||||
class="flex-1 order-2 lg:order-1 lg:col-span-3 bg-white flex flex-col min-h-0">
|
||||
{% include '_partials/_flashes.html.twig' %}
|
||||
{# Pagination - desktop only #}
|
||||
<div class="hidden lg:block shrink-0">
|
||||
{% include 'booking/_pagination.html.twig' with { current_step: 3, step_routes: stepRoutes } %}
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto scroll-stable px-4 lg:px-8 py-8">
|
||||
<h2 class="pb-4">
|
||||
Übersicht
|
||||
</h2>
|
||||
|
||||
{% from '_partials/_validation_errors.html.twig' import validation_alert %}
|
||||
{{ validation_alert(form, 'Bitte bestätige beide Punkte', false) }}
|
||||
|
||||
<div class="border border-primary-bg mb-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">
|
||||
Gutschein
|
||||
</div>
|
||||
<table class="w-full table-fixed">
|
||||
<tr>
|
||||
<td class="p-2 align-top w-32">Motiv</td>
|
||||
<td class="p-2 align-top">{{ voucher.template.label ?? '' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2 align-top w-32">Von</td>
|
||||
<td class="p-2 align-top break-words">{{ voucher.from }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="p-2 align-top w-32">Für</td>
|
||||
<td class="p-2 align-top break-words">{{ voucher.to }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="border border-primary-bg mb-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">
|
||||
Käufer:in
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ voucher.salutation }} {{ voucher.title }} {{ voucher.buyerFullName }}<br>
|
||||
{{ voucher.address.street }}<br>
|
||||
{{ voucher.address.country }} - {{ voucher.address.postCode }} {{ voucher.address.city }}<br>
|
||||
{{ voucher.email }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border border-primary-bg mb-4">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">
|
||||
Zahlungsart
|
||||
</div>
|
||||
<div class="p-2">
|
||||
{{ voucher.paymentMethod.label ?? 'Überweisung' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between pb-4">
|
||||
<span class="block uppercase font-semibold">Gesamtpreis</span>
|
||||
<span class="font-semibold">{{ voucher.amount|format_currency('EUR') }}</span>
|
||||
</div>
|
||||
|
||||
{% if purchasable %}
|
||||
<div class="border border-primary-bg">
|
||||
<div class="p-2 bg-primary-bg uppercase font-semibold">
|
||||
Bestätigung
|
||||
</div>
|
||||
<div class="p-4">
|
||||
{{ form_row(form.termsAccepted, {
|
||||
'label_attr': {'class': 'text-base font-semibold'}
|
||||
}) }}
|
||||
{{ form_row(form.purchaseConfirmed, {
|
||||
'label_attr': {'class': 'text-base font-semibold'}
|
||||
}) }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{% do form.termsAccepted.setRendered %}
|
||||
{% do form.purchaseConfirmed.setRendered %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0 bg-primary-dark px-4 lg:px-8 py-2 lg:py-4 z-20">
|
||||
<div class="flex justify-between" hx-disinherit="*">
|
||||
{% include '_partials/_cancel_button.html.twig' with { cancel_url: path('app_voucher_cancel') } %}
|
||||
<div class="flex items-center gap-2">
|
||||
<a href="{{ path('app_voucher_step_2') }}"
|
||||
class="button button--secondary"
|
||||
{{ qa_attribute('btn-back') }}>
|
||||
Zurück
|
||||
</a>
|
||||
{% if purchasable %}
|
||||
<button type="submit" class="button button--primary" {{ qa_attribute('btn-submit') }}>
|
||||
Zahlungspflichtig kaufen
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{ form_rest(form) }}
|
||||
{{ form_end(form) }}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,50 @@
|
||||
{% extends 'layout.html.twig' %}
|
||||
|
||||
{% block title %}Gutschein gekauft{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 px-4 lg:px-8 py-8 lg:py-16">
|
||||
<div>
|
||||
<h1 class="text-white uppercase pb-4 mb-8 border-b border-primary-bg/40">
|
||||
Vielen Dank
|
||||
<br>
|
||||
für deinen Gutscheinkauf
|
||||
</h1>
|
||||
|
||||
{% if voucherNumber %}
|
||||
<div class="text-xl text-white mb-8">
|
||||
Deine Gutscheinnummer lautet
|
||||
<br>
|
||||
<strong class="font-mono">{{ voucherNumber }}</strong>
|
||||
{% if invoiceNumber %}
|
||||
<br>
|
||||
<span class="text-base">Rechnungsnummer <span class="font-mono">{{ invoiceNumber }}</span></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="pb-8">
|
||||
{% include '_partials/_alert.html.twig' with {
|
||||
level: 'info',
|
||||
messages: [
|
||||
'Bitte überweise den Betrag von %s.'|format(total|format_currency('EUR')),
|
||||
email
|
||||
? 'Sobald deine Zahlung eingegangen ist, erhältst du den Gutschein per E-Mail an %s.'|format(email)
|
||||
: 'Sobald deine Zahlung eingegangen ist, erhältst du den Gutschein per E-Mail.'
|
||||
]
|
||||
} %}
|
||||
</div>
|
||||
|
||||
{% if app.user %}
|
||||
<a href="{{ path('app_account') }}" class="button button--primary">
|
||||
Zu meinem Konto
|
||||
</a>
|
||||
{% else %}
|
||||
{# hx-boost disabled for cross-origin #}
|
||||
<a href="{{ returnUrl }}" hx-boost="false" class="button button--primary">
|
||||
Zurück
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -6,6 +6,7 @@ namespace App\Tests\BusProNet;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\DataProcessor\BookingDataProcessor;
|
||||
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Exception\ImmediateConnectionCloseException;
|
||||
use App\BusProNet\XmlParser\ApiResponseParser;
|
||||
@@ -34,6 +35,7 @@ class ApiClientReceiveTest extends TestCase
|
||||
$this->createStub(FilesystemOperator::class),
|
||||
$this->logger,
|
||||
$this->createStub(BookingDataProcessor::class),
|
||||
new VoucherSalePayloadBuilder(),
|
||||
new RequestStack(),
|
||||
new RequestIdGenerator(),
|
||||
[
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\DataProcessor;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\DataProcessor\VoucherSalePayloadBuilder;
|
||||
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||
|
||||
class VoucherSalePayloadBuilderTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* The shape the test system accepted with <buchung>möglich</buchung> on 2026-10-01.
|
||||
*/
|
||||
public function testTheSalePayloadMatchesTheProbedRequest(): void
|
||||
{
|
||||
$payload = (new VoucherSalePayloadBuilder())->buildSalePayload($this->createDto(), Constants::BOOKING_TYPE_INQUIRY);
|
||||
|
||||
$xml = (new XmlEncoder())->encode(
|
||||
['satz' => ['@typ' => 'GUTSCHEINBUCHUNG']] + $payload,
|
||||
'xml',
|
||||
[XmlEncoder::ROOT_NODE_NAME => 'anfrage', XmlEncoder::ENCODING => 'UTF-8'],
|
||||
);
|
||||
|
||||
self::assertStringContainsString(
|
||||
'<satz typ="GUTSCHEINBUCHUNG"/><buchungsart>Anfrage</buchungsart><gutscheinart>K</gutscheinart>'
|
||||
.'<idgutscheinvorlage>3</idgutscheinvorlage><kaeufer>Probe Kaeufer</kaeufer><empfaenger>Probe Empfaenger</empfaenger>'
|
||||
.'<kaufpreis>25</kaufpreis><text></text>'
|
||||
.'<rechnungsempfänger><name>Probe</name><vorname>Myep</vorname><anrede>Herr</anrede><titel/><geschlecht></geschlecht><geburtsdatum></geburtsdatum>'
|
||||
.'<anschrift><strasse>Teststr. 1</strasse><plz>12345</plz><ort>Testort</ort><ortsteil/><land>D</land></anschrift>'
|
||||
.'<kommunikation><telefonprivat/><email>[email protected]</email><telefonmobil>0170123456</telefonmobil></kommunikation>'
|
||||
.'</rechnungsempfänger>'
|
||||
.'<zahlung idzahlungsart="2" bezeichnung="Überweisung" art="UEBERW"/>',
|
||||
$xml,
|
||||
);
|
||||
}
|
||||
|
||||
public function testThePurchaseDiffersOnlyInItsSaleType(): void
|
||||
{
|
||||
$builder = new VoucherSalePayloadBuilder();
|
||||
$dto = $this->createDto();
|
||||
|
||||
$inquiry = $builder->buildSalePayload($dto, Constants::BOOKING_TYPE_INQUIRY);
|
||||
$purchase = $builder->buildSalePayload($dto, Constants::BOOKING_TYPE_BOOKING);
|
||||
|
||||
self::assertSame('Buchung', $purchase['buchungsart']);
|
||||
self::assertSame(array_diff_key($inquiry, ['buchungsart' => true]), array_diff_key($purchase, ['buchungsart' => true]));
|
||||
}
|
||||
|
||||
public function testAnEmptyMobileIsLeftOut(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
$dto->mobile = ' ';
|
||||
|
||||
$payload = (new VoucherSalePayloadBuilder())->buildSalePayload($dto, Constants::BOOKING_TYPE_INQUIRY);
|
||||
|
||||
self::assertArrayNotHasKey('telefonmobil', $payload['rechnungsempfänger']['kommunikation']);
|
||||
}
|
||||
|
||||
public function testThePreviewPayloadCarriesTheVoucherOnly(): void
|
||||
{
|
||||
$payload = (new VoucherSalePayloadBuilder())->buildPreviewPayload($this->createDto());
|
||||
|
||||
self::assertSame([
|
||||
'idgutscheinvorlage' => 3,
|
||||
'kaeufer' => 'Probe Kaeufer',
|
||||
'empfaenger' => 'Probe Empfaenger',
|
||||
'kaufpreis' => 25,
|
||||
'text' => '',
|
||||
], $payload);
|
||||
}
|
||||
|
||||
public function testASaleWithoutATemplateIsALogicError(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
$dto->template = null;
|
||||
|
||||
$this->expectException(\LogicException::class);
|
||||
|
||||
(new VoucherSalePayloadBuilder())->buildSalePayload($dto, Constants::BOOKING_TYPE_INQUIRY);
|
||||
}
|
||||
|
||||
private function createDto(): VoucherPurchaseDto
|
||||
{
|
||||
$dto = new VoucherPurchaseDto();
|
||||
$dto->template = new VoucherTemplate(3, 'K', 'Allgemein');
|
||||
$dto->paymentMethod = new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW');
|
||||
$dto->from = 'Probe Kaeufer';
|
||||
$dto->to = 'Probe Empfaenger';
|
||||
$dto->amount = 25;
|
||||
$dto->salutation = VoucherPurchaseDto::SALUTATION_MR;
|
||||
$dto->lastName = 'Probe';
|
||||
$dto->firstName = 'Myep';
|
||||
$dto->address->street = 'Teststr. 1';
|
||||
$dto->address->postCode = '12345';
|
||||
$dto->address->city = 'Testort';
|
||||
$dto->email = '[email protected]';
|
||||
$dto->mobile = '0170123456';
|
||||
|
||||
return $dto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ResponseParserException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
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\BusProNet\XmlParser\TravelParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* The voucher purchase responses. Fixtures are the probes against the test system of 2026-10-01,
|
||||
* except the completed purchase, which follows the vendor documentation.
|
||||
*/
|
||||
class VoucherResponseParserTest extends TestCase
|
||||
{
|
||||
public function testTemplatesWithTheUndocumentedAmountBounds(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINVORLAGEN"></satz><gutscheinminbetrag>10</gutscheinminbetrag><gutscheinmaxbetrag>5000</gutscheinmaxbetrag><gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung><hinweis></hinweis></gutschein><gutschein idgutscheinvorlage="4"><gutscheinart>K</gutscheinart><bezeichnung>Ski</bezeichnung><hinweis></hinweis></gutschein><gutschein idgutscheinvorlage="5"><gutscheinart>K</gutscheinart><bezeichnung>Weihnachten</bezeichnung><hinweis></hinweis></gutschein></gutscheine></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_TEMPLATES, $xml);
|
||||
|
||||
self::assertInstanceOf(VoucherTemplates::class, $result);
|
||||
self::assertSame(10, $result->minAmount);
|
||||
self::assertSame(5000, $result->maxAmount);
|
||||
self::assertSame([3, 4, 5], array_map(static fn ($template) => $template->id, $result->templates));
|
||||
self::assertSame(['Allgemein', 'Ski', 'Weihnachten'], array_map(static fn ($template) => $template->label, $result->templates));
|
||||
self::assertSame('K', $result->templates[1]->type);
|
||||
self::assertNull($result->templates[1]->note);
|
||||
self::assertSame('Ski', $result->find(4)?->label);
|
||||
self::assertNull($result->find(999));
|
||||
}
|
||||
|
||||
public function testTemplatesWithoutAmountBoundsAreRejected(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINVORLAGEN"></satz><gutscheine><gutschein idgutscheinvorlage="3"><gutscheinart>K</gutscheinart><bezeichnung>Allgemein</bezeichnung></gutschein></gutscheine></ergebnis>';
|
||||
|
||||
$this->expectException(ResponseParserException::class);
|
||||
$this->expectExceptionMessage('amount bounds');
|
||||
|
||||
$this->parse(ApiClient::TYPE_VOUCHER_TEMPLATES, $xml);
|
||||
}
|
||||
|
||||
public function testPaymentMethods(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINZAHLUNGSARTEN"></satz><zahlungsarten><zahlungsart id="2" bezeichnung="Überweisung" art="UEBERW"></zahlungsart></zahlungsarten></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_PAYMENT_METHODS, $xml);
|
||||
|
||||
self::assertEquals([new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW')], $result);
|
||||
self::assertTrue($result[0]->isTransfer());
|
||||
}
|
||||
|
||||
public function testPreviewIsDecodedFromItsDataUri(): void
|
||||
{
|
||||
$pdf = "%PDF-1.4\nprobe";
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINVORSCHAU"/><gutscheinpdf>data:application/pdf;base64,'.base64_encode($pdf).'</gutscheinpdf></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_PREVIEW, $xml);
|
||||
|
||||
self::assertInstanceOf(VoucherPreview::class, $result);
|
||||
self::assertSame($pdf, $result->pdf);
|
||||
}
|
||||
|
||||
public function testPreviewWithoutAPdfDataUriIsRejected(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINVORSCHAU"/><gutscheinpdf></gutscheinpdf></ergebnis>';
|
||||
|
||||
$this->expectException(ResponseParserException::class);
|
||||
|
||||
$this->parse(ApiClient::TYPE_VOUCHER_PREVIEW, $xml);
|
||||
}
|
||||
|
||||
public function testTheDryRunAnswersPossibleAndIssuesNothing(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINBUCHUNG"></satz><buchung>möglich</buchung></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_SALE, $xml);
|
||||
|
||||
self::assertInstanceOf(VoucherSaleResponse::class, $result);
|
||||
self::assertTrue($result->isInquiryValid());
|
||||
self::assertFalse($result->isSaleSuccessful());
|
||||
self::assertNull($result->voucherNumber);
|
||||
self::assertNull($result->redemptionCode);
|
||||
}
|
||||
|
||||
public function testACompletedPurchase(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="GUTSCHEINBUCHUNG"/><buchung>erfolgt</buchung><idgutschein>4711</idgutschein><gutscheinnummer>K-001234</gutscheinnummer><einloesecode>ABCD1234</einloesecode><preis>111,00</preis><rechnungsnummer>R-998877</rechnungsnummer><email>[email protected]</email></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_SALE, $xml);
|
||||
|
||||
self::assertInstanceOf(VoucherSaleResponse::class, $result);
|
||||
self::assertTrue($result->isSaleSuccessful());
|
||||
self::assertSame(4711, $result->voucherId);
|
||||
self::assertSame('K-001234', $result->voucherNumber);
|
||||
self::assertSame('ABCD1234', $result->redemptionCode);
|
||||
self::assertSame(111.0, $result->price);
|
||||
self::assertSame('R-998877', $result->invoiceNumber);
|
||||
self::assertSame('[email protected]', $result->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* Missing address data comes as 650, the code Notification::isError() treats as success.
|
||||
* The caller must take any HINWEIS as a rejection on this path.
|
||||
*/
|
||||
public function testAMissingAddressFieldIsAHinweis650(): void
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="utf-8"?><ergebnis><satz typ="HINWEIS"><nr>650</nr><text>Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse</text></satz></ergebnis>';
|
||||
|
||||
$result = $this->parse(ApiClient::TYPE_VOUCHER_SALE, $xml);
|
||||
|
||||
self::assertInstanceOf(Notification::class, $result);
|
||||
self::assertSame(650, $result->code);
|
||||
}
|
||||
|
||||
private function parse(string $type, string $xml): mixed
|
||||
{
|
||||
return (new ApiResponseParser($this->createStub(TravelParser::class)))->parseXmlString($type, $xml);
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,12 @@ namespace App\Tests\Controller;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use App\Controller\SecurityController;
|
||||
use App\Form\Model\BookingDto;
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
use App\Model\PurchaseFlow;
|
||||
use App\Service\BookingSessionManager;
|
||||
use App\Service\BookingSummaryAssembler;
|
||||
use App\Service\PurchaseFlowResolver;
|
||||
use App\Service\VoucherPurchaseSessionManager;
|
||||
use League\Bundle\OAuth2ServerBundle\Manager\ClientManagerInterface;
|
||||
use League\Bundle\OAuth2ServerBundle\Model\Client;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -98,6 +102,7 @@ class SecurityControllerTest extends TestCase
|
||||
->willReturn(new Client('EPHub', 'abc123', null));
|
||||
|
||||
$session = $this->createSession(self::AUTHORIZE_PATH);
|
||||
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
|
||||
$response = $controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
@@ -111,15 +116,65 @@ class SecurityControllerTest extends TestCase
|
||||
$controller->clientManager->expects(self::never())->method('find');
|
||||
|
||||
$session = $this->createSession(null);
|
||||
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
|
||||
$response = $controller->login(...$this->arguments($controller, null, $session));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('booking/create/authenticate.html.twig', $controller->renderedView);
|
||||
self::assertSame('/booking/create/step-1', $session->get('_security.main.target_path'));
|
||||
self::assertSame('/app_booking_create_step_1', $session->get('_security.main.target_path'));
|
||||
}
|
||||
|
||||
public function testAPendingVoucherPurchaseRendersTheVoucherLoginAndContinuesWithItsFirstStep(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
$controller->clientManager->expects(self::never())->method('find');
|
||||
|
||||
$session = $this->createSession(null);
|
||||
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
|
||||
$response = $controller->login(...$this->arguments($controller, null, $session));
|
||||
|
||||
self::assertSame(200, $response->getStatusCode());
|
||||
self::assertSame('voucher/authenticate.html.twig', $controller->renderedView);
|
||||
self::assertSame('/app_voucher_step_1', $session->get('_security.main.target_path'));
|
||||
self::assertNull($controller->renderedParameters['travel_title']);
|
||||
}
|
||||
|
||||
public function testAPendingAuthorizationWinsOverAPendingVoucherPurchase(): void
|
||||
{
|
||||
$controller = $this->createController();
|
||||
$controller->clientManager->expects(self::once())->method('find')->willReturn(new Client('EPHub', 'abc123', null));
|
||||
|
||||
$session = $this->createSession(self::AUTHORIZE_PATH);
|
||||
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
|
||||
$controller->login(...$this->arguments($controller, self::AUTHORIZE_PATH, $session));
|
||||
|
||||
self::assertSame('security/login_oauth2.html.twig', $controller->renderedView);
|
||||
self::assertSame(self::AUTHORIZE_PATH, $session->get('_security.main.target_path'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface}
|
||||
* A booking and a voucher purchase can both be pending, e.g. from two tabs. The one the visitor
|
||||
* started last is the one they are signing in for.
|
||||
*/
|
||||
public function testTheLastStartedOfTwoPendingFlowsWins(): void
|
||||
{
|
||||
foreach ([PurchaseFlow::Voucher, PurchaseFlow::Booking] as $lastStarted) {
|
||||
$controller = $this->createController(bookingDto: $this->createBookingDto());
|
||||
$controller->clientManager->expects(self::never())->method('find');
|
||||
|
||||
$session = $this->createSession(null);
|
||||
$session->set(BookingSessionManager::BOOKING_CREATE_KEY, $this->createBookingDto());
|
||||
$session->set(VoucherPurchaseSessionManager::SESSION_KEY, new VoucherPurchaseDto());
|
||||
$session->set(PurchaseFlowResolver::LAST_STARTED_KEY, $lastStarted->value);
|
||||
$controller->login(...$this->arguments($controller, null, $session));
|
||||
|
||||
self::assertSame($lastStarted->authenticateTemplate(), $controller->renderedView);
|
||||
self::assertSame('/'.$lastStarted->firstStepRoute(), $session->get('_security.main.target_path'));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{AuthenticationUtils, Request, BookingSessionManager, BookingSummaryAssembler, ClientManagerInterface, PurchaseFlowResolver}
|
||||
*/
|
||||
private function arguments(TestableSecurityController $controller, ?string $targetPath, ?Session $session = null): array
|
||||
{
|
||||
@@ -132,6 +187,7 @@ class SecurityControllerTest extends TestCase
|
||||
$controller->bookingSessionManager,
|
||||
$controller->summaryAssembler,
|
||||
$controller->clientManager,
|
||||
new PurchaseFlowResolver(),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -203,6 +259,6 @@ final class TestableSecurityController extends SecurityController
|
||||
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
|
||||
{
|
||||
return '/booking/create/step-1';
|
||||
return '/'.$route;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Controller\Voucher;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\TimeoutException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||
use App\BusProNet\Model\VoucherSaleResponse;
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\Controller\Voucher\Step3Controller;
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
use App\Service\VoucherPurchaseSessionManager;
|
||||
use App\Service\VoucherSaleRejectionMapper;
|
||||
use App\Service\VoucherThumbnailResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Form\FormInterface;
|
||||
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\Session\Session;
|
||||
use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage;
|
||||
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
|
||||
use Symfony\Component\Security\Core\User\UserInterface;
|
||||
|
||||
class Step3ControllerTest extends TestCase
|
||||
{
|
||||
private VoucherPurchaseSessionManager $sessionManager;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->sessionManager = new VoucherPurchaseSessionManager();
|
||||
}
|
||||
|
||||
public function testAPassingDryRunOffersThePurchaseWithFreshConfirmations(): void
|
||||
{
|
||||
$request = $this->createRequest('GET', $dto = $this->createDto());
|
||||
$dto->termsAccepted = true;
|
||||
$dto->purchaseConfirmed = true;
|
||||
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient->expects(self::once())->method('createVoucherSaleInquiry')->willReturn(new VoucherSaleResponse('möglich'));
|
||||
$apiClient->expects(self::never())->method('createVoucherSale');
|
||||
|
||||
$controller = $this->createController($apiClient);
|
||||
$controller->index($request);
|
||||
|
||||
self::assertTrue($controller->renderParameters['purchasable']);
|
||||
self::assertFalse($dto->termsAccepted);
|
||||
self::assertFalse($dto->purchaseConfirmed);
|
||||
}
|
||||
|
||||
/**
|
||||
* 650 is what Notification::isError() calls a success. On this path it is a missing address field.
|
||||
*/
|
||||
public function testAnAddressComplaintSendsTheBuyerBackToStep2(): void
|
||||
{
|
||||
$request = $this->createRequest('GET', $this->createDto());
|
||||
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('createVoucherSaleInquiry')->willReturn(
|
||||
new Notification(650, 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse')
|
||||
);
|
||||
|
||||
$controller = $this->createController($apiClient);
|
||||
$response = $controller->index($request);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_voucher_step_2', $response->getTargetUrl());
|
||||
self::assertSame(2, $this->sessionManager->find($request)?->currentStep);
|
||||
self::assertSame([['error', 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse']], $controller->flashes);
|
||||
}
|
||||
|
||||
public function testAnAmountComplaintSendsTheBuyerBackToStep1(): void
|
||||
{
|
||||
$request = $this->createRequest('GET', $this->createDto());
|
||||
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('createVoucherSaleInquiry')->willReturn(
|
||||
new Notification(608, 'Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein')
|
||||
);
|
||||
|
||||
$response = $this->createController($apiClient)->index($request);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_voucher_step_1', $response->getTargetUrl());
|
||||
}
|
||||
|
||||
public function testAComplaintNobodyCanFixKeepsTheOverviewWithoutABuyButton(): void
|
||||
{
|
||||
$request = $this->createRequest('GET', $this->createDto());
|
||||
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('createVoucherSaleInquiry')->willReturn(new Notification(619, 'Agentur-ID fehlt'));
|
||||
|
||||
$controller = $this->createController($apiClient);
|
||||
$controller->index($request);
|
||||
|
||||
self::assertFalse($controller->renderParameters['purchasable']);
|
||||
}
|
||||
|
||||
public function testAPurchaseClearsTheSessionAndKeepsTheRedemptionCodeOutOfTheLog(): void
|
||||
{
|
||||
$request = $this->createRequest('POST', $this->createDto());
|
||||
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient->expects(self::never())->method('createVoucherSaleInquiry');
|
||||
$apiClient->expects(self::once())->method('createVoucherSale')->willReturn(new VoucherSaleResponse(
|
||||
status: 'erfolgt',
|
||||
voucherId: 4711,
|
||||
voucherNumber: 'K-001234',
|
||||
redemptionCode: 'ABCD1234',
|
||||
price: 111.0,
|
||||
invoiceNumber: 'R-998877',
|
||||
email: '[email protected]',
|
||||
));
|
||||
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('info')->with('Voucher successfully sold.', self::callback(
|
||||
static fn (array $context) => !\in_array('ABCD1234', $context, true) && 'K-001234' === $context['voucher_number']
|
||||
));
|
||||
|
||||
$controller = $this->createController($apiClient, $this->createSubmittedForm(), $logger);
|
||||
$response = $controller->index($request);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_voucher_success', $response->getTargetUrl());
|
||||
self::assertNull($this->sessionManager->find($request));
|
||||
self::assertContains(['voucher_number', 'K-001234'], $controller->flashes);
|
||||
self::assertContains(['voucher_total', 111.0], $controller->flashes);
|
||||
self::assertNotContains('ABCD1234', array_column($controller->flashes, 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* The first submit cleared the session before redirecting, so the second one finds nothing to buy.
|
||||
*/
|
||||
public function testASecondSubmitCannotBuyTwice(): void
|
||||
{
|
||||
$request = Request::create('/gutschein/uebersicht', 'POST');
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient->expects(self::never())->method('createVoucherSale');
|
||||
|
||||
$response = $this->createController($apiClient, $this->createSubmittedForm())->index($request);
|
||||
|
||||
self::assertInstanceOf(RedirectResponse::class, $response);
|
||||
self::assertSame('/app_voucher_error', $response->getTargetUrl());
|
||||
}
|
||||
|
||||
/**
|
||||
* A timeout leaves it open whether BusPro issued the voucher, so no blind retry is offered.
|
||||
*/
|
||||
public function testATimeoutDuringThePurchaseOffersNoRetry(): void
|
||||
{
|
||||
$request = $this->createRequest('POST', $this->createDto());
|
||||
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('createVoucherSale')->willThrowException(new TimeoutException('Stream timeout'));
|
||||
|
||||
$controller = $this->createController($apiClient, $this->createSubmittedForm());
|
||||
$controller->index($request);
|
||||
|
||||
self::assertFalse($controller->renderParameters['purchasable']);
|
||||
self::assertNotNull($this->sessionManager->find($request));
|
||||
self::assertStringContainsString('Kundenservice', $controller->flashes[0][1]);
|
||||
}
|
||||
|
||||
private function createRequest(string $method, VoucherPurchaseDto $dto): Request
|
||||
{
|
||||
$request = Request::create('/gutschein/uebersicht', $method);
|
||||
$request->setSession(new Session(new MockArraySessionStorage()));
|
||||
$this->sessionManager->save($request, $dto);
|
||||
|
||||
return $request;
|
||||
}
|
||||
|
||||
private function createDto(): VoucherPurchaseDto
|
||||
{
|
||||
$dto = new VoucherPurchaseDto();
|
||||
$dto->currentStep = 3;
|
||||
$dto->template = new VoucherTemplate(3, 'K', 'Allgemein');
|
||||
$dto->paymentMethod = new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW');
|
||||
$dto->amount = 111;
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<VoucherPurchaseDto>
|
||||
*/
|
||||
private function createSubmittedForm(): FormInterface
|
||||
{
|
||||
$form = $this->createStub(FormInterface::class);
|
||||
$form->method('isSubmitted')->willReturn(true);
|
||||
$form->method('isValid')->willReturn(true);
|
||||
|
||||
return $form;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param FormInterface<VoucherPurchaseDto>|null $form
|
||||
*/
|
||||
private function createController(ApiClient $apiClient, ?FormInterface $form = null, ?LoggerInterface $logger = null): TestableStep3Controller
|
||||
{
|
||||
return new TestableStep3Controller(
|
||||
$this->sessionManager,
|
||||
$this->createStub(VoucherThumbnailResolver::class),
|
||||
$apiClient,
|
||||
new VoucherSaleRejectionMapper(),
|
||||
$logger ?? new NullLogger(),
|
||||
$form ?? $this->createStub(FormInterface::class),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final class TestableStep3Controller extends Step3Controller
|
||||
{
|
||||
/**
|
||||
* @var list<array{string, mixed}>
|
||||
*/
|
||||
public array $flashes = [];
|
||||
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
public array $renderParameters = [];
|
||||
|
||||
/**
|
||||
* @param FormInterface<VoucherPurchaseDto> $form
|
||||
*/
|
||||
public function __construct(
|
||||
VoucherPurchaseSessionManager $sessionManager,
|
||||
VoucherThumbnailResolver $thumbnailResolver,
|
||||
ApiClient $apiClient,
|
||||
VoucherSaleRejectionMapper $rejectionMapper,
|
||||
LoggerInterface $logger,
|
||||
private readonly FormInterface $form,
|
||||
) {
|
||||
parent::__construct($sessionManager, $thumbnailResolver, $apiClient, $rejectionMapper, $logger);
|
||||
}
|
||||
|
||||
protected function getUser(): ?UserInterface
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return FormInterface<mixed>
|
||||
*/
|
||||
protected function createForm(string $type, mixed $data = null, array $options = []): FormInterface
|
||||
{
|
||||
return $this->form;
|
||||
}
|
||||
|
||||
protected function addFlash(string $type, mixed $message): void
|
||||
{
|
||||
$this->flashes[] = [$type, $message];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function redirectToRoute(string $route, array $parameters = [], int $status = 302): RedirectResponse
|
||||
{
|
||||
return new RedirectResponse('/'.$route, $status);
|
||||
}
|
||||
|
||||
protected function generateUrl(string $route, array $parameters = [], int $referenceType = UrlGeneratorInterface::ABSOLUTE_PATH): string
|
||||
{
|
||||
return '/'.$route;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $parameters
|
||||
*/
|
||||
protected function render(string $view, array $parameters = [], ?Response $response = null): Response
|
||||
{
|
||||
$this->renderParameters = $parameters;
|
||||
|
||||
return new Response('');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Model;
|
||||
|
||||
use App\BusProNet\Model\PersonalData;
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\Form\Model\VoucherPurchaseDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\Validator\ConstraintViolationListInterface;
|
||||
use Symfony\Component\Validator\Validation;
|
||||
|
||||
class VoucherPurchaseDtoTest extends TestCase
|
||||
{
|
||||
private const array STEP_1 = ['voucher_step_1'];
|
||||
private const array STEP_2 = ['voucher_step_2', 'personal_data'];
|
||||
private const array STEP_3 = ['voucher_step_3'];
|
||||
|
||||
public function testACompleteVoucherPassesStep1(): void
|
||||
{
|
||||
self::assertCount(0, $this->validate($this->createDto(), self::STEP_1));
|
||||
}
|
||||
|
||||
public function testTheBoundsAreInclusiveAndComeFromBusPro(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
|
||||
foreach ([10 => 0, 5000 => 0, 9 => 1, 5001 => 1] as $amount => $violations) {
|
||||
$dto->amount = $amount;
|
||||
$result = $this->validate($dto, self::STEP_1);
|
||||
|
||||
self::assertCount($violations, $result, sprintf('amount %d', $amount));
|
||||
if (1 === $violations) {
|
||||
self::assertSame('amount', $result->get(0)->getPropertyPath());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testStep1RequiresDesignNamesAndValue(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
$dto->template = null;
|
||||
$dto->from = '';
|
||||
$dto->to = null;
|
||||
$dto->amount = null;
|
||||
|
||||
self::assertSame(
|
||||
['template', 'from', 'to', 'amount'],
|
||||
$this->propertyPaths($this->validate($dto, self::STEP_1)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The mandatory fields of the live voucher shop: no phone, no gender, no salutation.
|
||||
*/
|
||||
public function testStep2RequiresWhatTheLiveShopRequires(): void
|
||||
{
|
||||
$dto = new VoucherPurchaseDto();
|
||||
$dto->address->country = null;
|
||||
|
||||
self::assertEqualsCanonicalizing(
|
||||
['lastName', 'firstName', 'address.street', 'address.postCode', 'address.city', 'address.country', 'email'],
|
||||
$this->propertyPaths($this->validate($dto, self::STEP_2)),
|
||||
);
|
||||
}
|
||||
|
||||
public function testACompleteBuyerPassesStep2(): void
|
||||
{
|
||||
self::assertCount(0, $this->validate($this->createDto(), self::STEP_2));
|
||||
}
|
||||
|
||||
public function testTheCountryDefaultsToGermany(): void
|
||||
{
|
||||
self::assertSame('D', (new VoucherPurchaseDto())->address->country);
|
||||
}
|
||||
|
||||
public function testStep2RejectsAnInvalidEmailAndSalutation(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
$dto->email = 'kein-mail';
|
||||
$dto->salutation = 'Divers';
|
||||
|
||||
self::assertEqualsCanonicalizing(['email', 'salutation'], $this->propertyPaths($this->validate($dto, self::STEP_2)));
|
||||
}
|
||||
|
||||
public function testStep3RequiresBothConfirmations(): void
|
||||
{
|
||||
$dto = $this->createDto();
|
||||
|
||||
self::assertSame(['termsAccepted', 'purchaseConfirmed'], $this->propertyPaths($this->validate($dto, self::STEP_3)));
|
||||
|
||||
$dto->termsAccepted = true;
|
||||
$dto->purchaseConfirmed = true;
|
||||
self::assertCount(0, $this->validate($dto, self::STEP_3));
|
||||
}
|
||||
|
||||
public function testPrefillTakesTheLoginEmailAndKeepsAnUnknownSalutationOut(): void
|
||||
{
|
||||
$personalData = new PersonalData();
|
||||
$personalData->name = 'Muster';
|
||||
$personalData->firstName = 'Erika';
|
||||
$personalData->salutation = 'Familie';
|
||||
$personalData->address->street = ' Hauptstr. 1 ';
|
||||
$personalData->address->postCode = '12345';
|
||||
$personalData->address->city = 'Musterstadt';
|
||||
$personalData->address->country = 'A';
|
||||
$personalData->communication->email = '[email protected]';
|
||||
$personalData->communication->mobile = '0170';
|
||||
|
||||
$dto = new VoucherPurchaseDto();
|
||||
$dto->prefillBuyer($personalData, '[email protected]');
|
||||
|
||||
self::assertSame('Muster', $dto->lastName);
|
||||
self::assertSame('Erika', $dto->firstName);
|
||||
self::assertNull($dto->salutation);
|
||||
self::assertSame('Hauptstr. 1', $dto->address->street);
|
||||
self::assertSame('A', $dto->address->country);
|
||||
self::assertSame('[email protected]', $dto->email);
|
||||
self::assertSame('0170', $dto->mobile);
|
||||
self::assertTrue($dto->hasBuyerData());
|
||||
self::assertNotSame($personalData->address, $dto->address);
|
||||
}
|
||||
|
||||
private function createDto(): VoucherPurchaseDto
|
||||
{
|
||||
$dto = new VoucherPurchaseDto();
|
||||
$dto->minAmount = 10;
|
||||
$dto->maxAmount = 5000;
|
||||
$dto->template = new VoucherTemplate(3, 'K', 'Allgemein');
|
||||
$dto->from = 'Oma';
|
||||
$dto->to = 'Max';
|
||||
$dto->amount = 111;
|
||||
$dto->lastName = 'Muster';
|
||||
$dto->firstName = 'Erika';
|
||||
$dto->address->street = 'Hauptstr. 1';
|
||||
$dto->address->postCode = '12345';
|
||||
$dto->address->city = 'Musterstadt';
|
||||
$dto->email = '[email protected]';
|
||||
|
||||
return $dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $groups
|
||||
*/
|
||||
private function validate(VoucherPurchaseDto $dto, array $groups): ConstraintViolationListInterface
|
||||
{
|
||||
return Validation::createValidatorBuilder()
|
||||
->enableAttributeMapping()
|
||||
->getValidator()
|
||||
->validate($dto, null, $groups);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private function propertyPaths(ConstraintViolationListInterface $violations): array
|
||||
{
|
||||
$paths = [];
|
||||
foreach ($violations as $violation) {
|
||||
$paths[] = $violation->getPropertyPath();
|
||||
}
|
||||
|
||||
return $paths;
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,7 @@ class ParticipantDataPrefillerTest extends TestCase
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with('Exception during applicant prepopulation', [
|
||||
->with('Exception during personal data prepopulation', [
|
||||
'error' => 'Decryption failed',
|
||||
'email' => '[email protected]',
|
||||
]);
|
||||
@@ -221,7 +221,7 @@ class ParticipantDataPrefillerTest extends TestCase
|
||||
|
||||
$this->logger->expects($this->once())
|
||||
->method('error')
|
||||
->with('Exception during applicant prepopulation', [
|
||||
->with('Exception during personal data prepopulation', [
|
||||
'error' => 'API connection failed',
|
||||
'email' => '[email protected]',
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\Service\VoucherSaleRejectionMapper;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class VoucherSaleRejectionMapperTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* @return iterable<string, array{int, string, int}>
|
||||
*/
|
||||
public static function probedRejections(): iterable
|
||||
{
|
||||
yield 'unknown template' => [602, 'Gutscheine mit ID 999 nicht gefunden', 1];
|
||||
yield 'recipient missing' => [605, 'Empfängername fehlt', 1];
|
||||
yield 'below minimum' => [607, 'Gutscheinbetrag darf nicht kleiner als 10,00 Euro sein', 1];
|
||||
yield 'above maximum' => [608, 'Gutscheinbetrag darf nicht größer als 5.000,00 Euro sein', 1];
|
||||
yield 'capacity' => [616, 'Kapazität nicht ausreichend', 1];
|
||||
yield 'email missing' => [650, 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Mail-Adresse', 2];
|
||||
yield 'street missing' => [650, 'Fehlende Adress Daten. Bitte folgende Eingabefelder prüfen: Strasse', 2];
|
||||
yield 'payment method missing' => [618, 'Zahlungsart fehlt', 3];
|
||||
yield 'agency' => [619, 'Agentur-ID fehlt', 3];
|
||||
}
|
||||
|
||||
#[DataProvider('probedRejections')]
|
||||
public function testTheRejectionGoesBackToTheStepThatOwnsIt(int $code, string $message, int $step): void
|
||||
{
|
||||
self::assertSame($step, (new VoucherSaleRejectionMapper())->stepFor(new Notification($code, $message)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\VoucherPaymentMethod;
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\BusProNet\Model\VoucherTemplates;
|
||||
use App\Exception\VoucherShopUnavailableException;
|
||||
use App\Service\VoucherTemplateProvider;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\NullLogger;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\Cache\Adapter\TagAwareAdapter;
|
||||
|
||||
class VoucherTemplateProviderTest extends TestCase
|
||||
{
|
||||
public function testTemplatesAreCached(): void
|
||||
{
|
||||
$templates = new VoucherTemplates(10, 5000, [new VoucherTemplate(3, 'K', 'Allgemein')]);
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient->expects(self::once())->method('getVoucherTemplates')->willReturn($templates);
|
||||
|
||||
$provider = $this->createProvider($apiClient);
|
||||
|
||||
self::assertEquals($templates, $provider->getTemplates());
|
||||
self::assertEquals($templates, $provider->getTemplates());
|
||||
}
|
||||
|
||||
public function testARejectionIsNotCached(): void
|
||||
{
|
||||
$templates = new VoucherTemplates(10, 5000, [new VoucherTemplate(3, 'K', 'Allgemein')]);
|
||||
$apiClient = $this->createMock(ApiClient::class);
|
||||
$apiClient->expects(self::exactly(2))->method('getVoucherTemplates')
|
||||
->willReturnOnConsecutiveCalls(new Notification(104, 'User fehlerhaft'), $templates);
|
||||
|
||||
$provider = $this->createProvider($apiClient);
|
||||
|
||||
try {
|
||||
$provider->getTemplates();
|
||||
self::fail('Expected a VoucherShopUnavailableException');
|
||||
} catch (VoucherShopUnavailableException) {
|
||||
}
|
||||
|
||||
self::assertEquals($templates, $provider->getTemplates());
|
||||
}
|
||||
|
||||
public function testNoTemplatesOnSaleMeansNoShop(): void
|
||||
{
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('getVoucherTemplates')->willReturn(new VoucherTemplates(10, 5000, []));
|
||||
|
||||
$this->expectException(VoucherShopUnavailableException::class);
|
||||
|
||||
$this->createProvider($apiClient)->getTemplates();
|
||||
}
|
||||
|
||||
public function testThePaymentMethodIsTransfer(): void
|
||||
{
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('getVoucherPaymentMethods')->willReturn([
|
||||
new VoucherPaymentMethod(1, 'Lastschrift', 'LAST'),
|
||||
new VoucherPaymentMethod(2, 'Überweisung', 'UEBERW'),
|
||||
]);
|
||||
|
||||
self::assertSame(2, $this->createProvider($apiClient)->getPaymentMethod()->id);
|
||||
}
|
||||
|
||||
public function testWithoutTransferThereIsNoShop(): void
|
||||
{
|
||||
$apiClient = $this->createStub(ApiClient::class);
|
||||
$apiClient->method('getVoucherPaymentMethods')->willReturn([new VoucherPaymentMethod(1, 'Lastschrift', 'LAST')]);
|
||||
|
||||
$this->expectException(VoucherShopUnavailableException::class);
|
||||
|
||||
$this->createProvider($apiClient)->getPaymentMethod();
|
||||
}
|
||||
|
||||
private function createProvider(ApiClient $apiClient): VoucherTemplateProvider
|
||||
{
|
||||
return new VoucherTemplateProvider($apiClient, new TagAwareAdapter(new ArrayAdapter()), new NullLogger());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Service;
|
||||
|
||||
use App\BusProNet\Model\VoucherTemplate;
|
||||
use App\Service\VoucherThumbnailResolver;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
class VoucherThumbnailResolverTest extends TestCase
|
||||
{
|
||||
private string $manifestPath;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->manifestPath = tempnam(sys_get_temp_dir(), 'manifest');
|
||||
file_put_contents($this->manifestPath, json_encode([
|
||||
'build/images/vouchers/muster_3.jpg' => '/build/images/vouchers/muster_3.0a1b2c3d.jpg',
|
||||
]));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
@unlink($this->manifestPath);
|
||||
}
|
||||
|
||||
public function testAThumbnailIsFoundByTheTemplateId(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::never())->method('warning');
|
||||
|
||||
$resolver = new VoucherThumbnailResolver($this->manifestPath, $logger);
|
||||
|
||||
self::assertSame('build/images/vouchers/muster_3.jpg', $resolver->resolve(new VoucherTemplate(3, 'K', 'Allgemein')));
|
||||
}
|
||||
|
||||
public function testANewDesignWithoutAThumbnailRendersWithoutOneAndIsReported(): void
|
||||
{
|
||||
$logger = $this->createMock(LoggerInterface::class);
|
||||
$logger->expects(self::once())->method('warning')->with('No thumbnail for voucher template', self::callback(
|
||||
static fn (array $context) => 6 === $context['template_id'] && 'assets/vouchers/muster_6.jpg' === $context['expected_source']
|
||||
));
|
||||
|
||||
$resolver = new VoucherThumbnailResolver($this->manifestPath, $logger);
|
||||
|
||||
self::assertSame(
|
||||
[3 => 'build/images/vouchers/muster_3.jpg', 6 => null],
|
||||
$resolver->resolveAll([new VoucherTemplate(3, 'K', 'Allgemein'), new VoucherTemplate(6, 'K', 'Ostern')]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ Encore
|
||||
.copyFiles([
|
||||
{from: './assets/images', to: 'images/[path][name].[hash:8].[ext]'},
|
||||
{from: './assets/favicon', to: 'favicon/[path][name].[hash:8].[ext]'},
|
||||
// voucher thumbnails only; the PDFs beside them are source material and not shipped
|
||||
{from: './assets/vouchers', to: 'images/vouchers/[path][name].[hash:8].[ext]', pattern: /\.jpg$/},
|
||||
])
|
||||
;
|
||||
|
||||
|
||||
Reference in new issue
Block a user