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