feat: voucher purchase flow
This commit is contained in:
1 parent
a628e96d06
commit
f3dd122678
70 files changed
+3672
-122
No files matched your search
@@ -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 : [];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user