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