200 lines
7.7 KiB
PHP
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,
|
|
]);
|
|
}
|
|
}
|