feat: voucher purchase flow

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

No files matched your search

+12 -7
View File
@@ -126,16 +126,21 @@ class BookingSessionManager
*/
public function storeReturnUrl(Request $request, ?string $returnUrl): void
{
$url = self::DEFAULT_RETURN_URL;
$request->getSession()->set(self::RETURN_URL_KEY, self::normalizeReturnUrl($returnUrl));
}
if (null !== $returnUrl && '' !== trim($returnUrl)) {
if (false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
$url = $returnUrl;
}
/**
* Accepts an absolute http(s) URL and falls back to the default for anything else.
*/
public static function normalizeReturnUrl(?string $returnUrl): string
{
if (null !== $returnUrl && '' !== trim($returnUrl)
&& false !== filter_var($returnUrl, \FILTER_VALIDATE_URL)
&& 1 === preg_match('#^https?://#i', $returnUrl)) {
return $returnUrl;
}
$request->getSession()->set(self::RETURN_URL_KEY, $url);
return self::DEFAULT_RETURN_URL;
}
/**
+43 -28
View File
@@ -7,6 +7,7 @@ namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\PersonalData;
use App\Entity\User;
use App\Form\Model\ParticipantDto;
use App\Security\Crypt;
@@ -45,16 +46,52 @@ class ParticipantDataPrefiller
* @return ParticipantDto The prepopulated participant (or unchanged if API fails)
*/
public function prefillApplicantFromUser(User $user, ParticipantDto $applicant): ParticipantDto
{
$personalData = $this->fetchPersonalData($user);
if (null === $personalData) {
return $applicant;
}
// Create a temporary participant from personal data
$prepopulated = ParticipantDto::fromPersonalData($personalData);
// Copy personal data fields to the existing applicant
// Preserve booking-specific fields (roomId, services, etc.)
$applicant->addressId = $prepopulated->addressId;
$applicant->personId = $prepopulated->personId;
$applicant->firstName = $prepopulated->firstName;
$applicant->lastName = $prepopulated->lastName;
$applicant->title = $prepopulated->title;
$applicant->gender = $prepopulated->gender;
$applicant->nationality = $prepopulated->nationality;
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
$applicant->shoeSize = $prepopulated->shoeSize;
$applicant->remarksRoom = $prepopulated->remarksRoom;
$applicant->licensePlate = $prepopulated->licensePlate;
return $applicant;
}
/**
* Fetches the authenticated user's personal data from the BPN API for prefilling a form.
*
* Never throws: a failure is logged and returns null, so a purchase flow is never interrupted
* by a prefill.
*/
public function fetchPersonalData(User $user): ?PersonalData
{
try {
// Decrypt user password for API authentication
$password = $this->crypt->decrypt($user->getPassword());
$email = $user->getEmail();
// Fetch personal data from API
$result = $this->apiClient->getPersonalData($email, $password);
// Handle API error response
if ($result instanceof Notification) {
$this->logger->warning('Failed to fetch personal data for prepopulation', [
'email' => $email,
@@ -62,40 +99,18 @@ class ParticipantDataPrefiller
'message' => $result->message,
]);
return $applicant;
return null;
}
// Create a temporary participant from personal data
$prepopulated = ParticipantDto::fromPersonalData($result);
// Copy personal data fields to the existing applicant
// Preserve booking-specific fields (roomId, services, etc.)
$applicant->addressId = $prepopulated->addressId;
$applicant->personId = $prepopulated->personId;
$applicant->firstName = $prepopulated->firstName;
$applicant->lastName = $prepopulated->lastName;
$applicant->title = $prepopulated->title;
$applicant->gender = $prepopulated->gender;
$applicant->nationality = $prepopulated->nationality;
$applicant->dateOfBirth = $prepopulated->dateOfBirth;
$applicant->email = $prepopulated->email;
$applicant->mobile = $prepopulated->mobile;
$applicant->address = $prepopulated->address;
$applicant->height = $prepopulated->height;
$applicant->weight = $prepopulated->weight;
$applicant->shoeSize = $prepopulated->shoeSize;
$applicant->remarksRoom = $prepopulated->remarksRoom;
$applicant->licensePlate = $prepopulated->licensePlate;
return $applicant;
return $result;
} catch (\Exception $e) {
// Catch any unexpected exceptions (decryption failure, API errors, etc.)
$this->logger->error('Exception during applicant prepopulation', [
$this->logger->error('Exception during personal data prepopulation', [
'error' => $e->getMessage(),
'email' => $user->getEmail(),
]);
return $applicant;
return null;
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Model\PurchaseFlow;
use Symfony\Component\HttpFoundation\Request;
/**
* Tells the login page which purchase flow, if any, it is the entry to.
*
* Both a booking and a voucher purchase can be pending in the same session, e.g. when they were
* started in two tabs. The one started last wins, which is what the visitor just clicked.
*/
class PurchaseFlowResolver
{
public const string LAST_STARTED_KEY = 'purchase_flow_last_started';
public function markStarted(Request $request, PurchaseFlow $flow): void
{
$request->getSession()->set(self::LAST_STARTED_KEY, $flow->value);
}
public function resolvePending(Request $request): ?PurchaseFlow
{
$session = $request->getSession();
$hasBooking = $session->has(BookingSessionManager::BOOKING_CREATE_KEY);
$hasVoucher = $session->has(VoucherPurchaseSessionManager::SESSION_KEY);
if (true === $hasBooking && true === $hasVoucher) {
return PurchaseFlow::tryFrom((string) $session->get(self::LAST_STARTED_KEY)) ?? PurchaseFlow::Booking;
}
return match (true) {
$hasBooking => PurchaseFlow::Booking,
$hasVoucher => PurchaseFlow::Voucher,
default => null,
};
}
}
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Exception\VoucherSessionNotFoundException;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\HttpFoundation\Request;
/**
* Owns the session state of the voucher purchase flow. Nothing of it is stored in the database.
*/
class VoucherPurchaseSessionManager
{
public const string SESSION_KEY = 'voucher_purchase';
public const string RETURN_URL_KEY = 'voucher_return_url';
public function find(Request $request): ?VoucherPurchaseDto
{
$dto = $request->getSession()->get(self::SESSION_KEY);
return $dto instanceof VoucherPurchaseDto ? $dto : null;
}
/**
* @throws VoucherSessionNotFoundException
*/
public function get(Request $request): VoucherPurchaseDto
{
return $this->find($request) ?? throw new VoucherSessionNotFoundException();
}
public function save(Request $request, VoucherPurchaseDto $dto): void
{
$request->getSession()->set(self::SESSION_KEY, $dto);
}
public function clear(Request $request): void
{
$request->getSession()->remove(self::SESSION_KEY);
}
public function storeReturnUrl(Request $request, ?string $returnUrl): void
{
$request->getSession()->set(self::RETURN_URL_KEY, BookingSessionManager::normalizeReturnUrl($returnUrl));
}
public function getReturnUrl(Request $request): string
{
return $request->getSession()->get(self::RETURN_URL_KEY, BookingSessionManager::DEFAULT_RETURN_URL);
}
}
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\Notification;
/**
* Sends a rejected GUTSCHEINBUCHUNG back to the step that owns the complaint.
*
* Codes from the vendor documentation, corrected by the probes of 2026-10-01: an unknown template
* answers 602 rather than 615, and missing address data comes as 650, which is a hard rejection here.
*/
class VoucherSaleRejectionMapper
{
public const int STEP_VOUCHER = 1;
public const int STEP_BUYER = 2;
public const int STEP_OVERVIEW = 3;
private const array VOUCHER_CODES = [
602, // Gutschein(vorlage) nicht gefunden
604, // Käufername fehlt
605, // Empfängername fehlt
606, // Betrag fehlt
607, // Betrag kleiner als
608, // Betrag größer als
612, 613, // Gutscheinart
614, 615, // Gutscheinstamm
616, // Kapazität nicht ausreichend
621, // Gutscheindaten fehlen
];
private const array BUYER_CODES = [
617, // Rechnungsempfänger fehlt
650, // Fehlende Adress Daten …
];
public function stepFor(Notification $notification): int
{
return match (true) {
\in_array($notification->code, self::VOUCHER_CODES, true) => self::STEP_VOUCHER,
\in_array($notification->code, self::BUYER_CODES, true) => self::STEP_BUYER,
default => self::STEP_OVERVIEW,
};
}
}
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\ApiClient;
use App\BusProNet\Exception\ApiClientException;
use App\BusProNet\Model\Notification;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplates;
use App\Exception\VoucherShopUnavailableException;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;
/**
* Cached access to what BusPro offers for sale as vouchers and how they can be paid.
*
* Failures are not cached: the exception escapes the cache callback, so the next request asks again.
*/
class VoucherTemplateProvider
{
public const string CACHE_TAG = 'voucher-shop';
private const int CACHE_TTL = 300;
private const string TEMPLATES_CACHE_KEY = 'voucher_shop.templates';
private const string PAYMENT_METHOD_CACHE_KEY = 'voucher_shop.payment_method';
public function __construct(
private readonly ApiClient $apiClient,
private readonly TagAwareCacheInterface $cache,
private readonly LoggerInterface $logger,
) {
}
/**
* @throws VoucherShopUnavailableException
*/
public function getTemplates(): VoucherTemplates
{
return $this->cache->get(self::TEMPLATES_CACHE_KEY, function (ItemInterface $item): VoucherTemplates {
$item->expiresAfter(self::CACHE_TTL);
$item->tag([self::CACHE_TAG]);
$result = $this->request(fn () => $this->apiClient->getVoucherTemplates(), 'templates');
if ([] === $result->templates) {
$this->logger->error('BusPro offers no voucher templates for sale');
throw new VoucherShopUnavailableException('No voucher templates on sale.');
}
return $result;
});
}
/**
* The transfer payment method, the only one the flow offers.
*
* @throws VoucherShopUnavailableException
*/
public function getPaymentMethod(): VoucherPaymentMethod
{
return $this->cache->get(self::PAYMENT_METHOD_CACHE_KEY, function (ItemInterface $item): VoucherPaymentMethod {
$item->expiresAfter(self::CACHE_TTL);
$item->tag([self::CACHE_TAG]);
/** @var list<VoucherPaymentMethod> $paymentMethods */
$paymentMethods = $this->request(fn () => $this->apiClient->getVoucherPaymentMethods(), 'payment methods');
foreach ($paymentMethods as $paymentMethod) {
if ($paymentMethod->isTransfer()) {
return $paymentMethod;
}
}
$this->logger->error('BusPro offers no transfer payment method for vouchers', [
'payment_methods' => array_map(static fn (VoucherPaymentMethod $method) => $method->type, $paymentMethods),
]);
throw new VoucherShopUnavailableException('No transfer payment method for vouchers.');
});
}
/**
* @template T
*
* @param callable(): (T|Notification) $call
*
* @return T
*
* @throws VoucherShopUnavailableException
*/
private function request(callable $call, string $what): mixed
{
try {
$result = $call();
} catch (ApiClientException $e) {
$this->logger->error(sprintf('Unable to fetch voucher %s', $what), ['error' => $e->getMessage()]);
throw new VoucherShopUnavailableException(sprintf('Unable to fetch voucher %s.', $what), 0, $e);
}
if ($result instanceof Notification) {
$this->logger->error(sprintf('BusPro rejected the voucher %s request', $what), [
'code' => $result->code,
'message' => $result->message,
]);
throw new VoucherShopUnavailableException(sprintf('BusPro rejected the voucher %s request.', $what));
}
return $result;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\BusProNet\Model\VoucherTemplate;
use Psr\Log\LoggerInterface;
/**
* Finds the local thumbnail of a voucher design, by convention muster_{idgutscheinvorlage}.jpg in
* assets/vouchers (copied by Encore to images/vouchers).
*
* The thumbnails are copied by hand from the old shop's host, so a design BusPro starts selling has
* none until somebody adds it. It then renders without a picture, and the warning gets it noticed.
*/
class VoucherThumbnailResolver
{
private const string ASSET_PATH = 'build/images/vouchers/muster_%d.jpg';
/** @var array<string, string>|null */
private ?array $manifest = null;
public function __construct(
private readonly string $manifestPath,
private readonly LoggerInterface $logger,
) {
}
/**
* @param list<VoucherTemplate> $templates
*
* @return array<int, string|null> asset path (for asset()) per template id
*/
public function resolveAll(array $templates): array
{
$thumbnails = [];
foreach ($templates as $template) {
$thumbnails[$template->id] = $this->resolve($template);
}
return $thumbnails;
}
public function resolve(VoucherTemplate $template): ?string
{
$path = sprintf(self::ASSET_PATH, $template->id);
if (isset($this->loadManifest()[$path])) {
return $path;
}
$this->logger->warning('No thumbnail for voucher template', [
'template_id' => $template->id,
'template_label' => $template->label,
'expected_source' => sprintf('assets/vouchers/muster_%d.jpg', $template->id),
]);
return null;
}
/**
* @return array<string, string>
*/
private function loadManifest(): array
{
if (null !== $this->manifest) {
return $this->manifest;
}
$content = is_file($this->manifestPath) ? file_get_contents($this->manifestPath) : false;
$manifest = false !== $content ? json_decode($content, true) : null;
return $this->manifest = \is_array($manifest) ? $manifest : [];
}
}