Files
myep/src/Controller/Voucher/Step3Controller.php
T
2026-10-01 15:03:27 +02:00

200 lines
7.7 KiB
PHP

<?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,
]);
}
}