feat: implement payment step
This commit is contained in:
@@ -46,4 +46,35 @@ trait BookingCreateTrait
|
||||
|
||||
return $this->redirectToRoute($route);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the total number of participants based on room selections.
|
||||
*/
|
||||
private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int
|
||||
{
|
||||
return $this
|
||||
->bookingService
|
||||
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares template variables for the booking summary sidebar.
|
||||
*
|
||||
* @return array<string, mixed> Array containing all variables needed for the summary partial
|
||||
*/
|
||||
private function getSummaryVariables(BookingCreateDto $bookingCreateDto): array
|
||||
{
|
||||
$participantsCount = $this->getParticipantsCount($bookingCreateDto);
|
||||
$summary = $this->bookingService->getRoomSummaryAndParticipantCount($bookingCreateDto);
|
||||
$roomAssignmentCounts = $this->bookingService->getRoomAssignmentCounts($bookingCreateDto);
|
||||
$availableRooms = $bookingCreateDto->travel->getAvailableRooms();
|
||||
$groupedSelectedRooms = $this->bookingService->groupRoomSelectionsByType($bookingCreateDto->getSelectedRooms(), $availableRooms);
|
||||
|
||||
return [
|
||||
'participantsCount' => $participantsCount,
|
||||
'pricingData' => $summary['pricing'],
|
||||
'assignmentCounts' => $roomAssignmentCounts,
|
||||
'groupedSelectedRooms' => $groupedSelectedRooms,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,20 +166,6 @@ class CreateStep2Controller extends AbstractController
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total number of participants based on room selections.
|
||||
*
|
||||
* @param BookingCreateDto $bookingCreateDto The booking DTO containing room selections
|
||||
*
|
||||
* @return int Total number of participants required
|
||||
*/
|
||||
private function getParticipantsCount(BookingCreateDto $bookingCreateDto): int
|
||||
{
|
||||
return $this
|
||||
->bookingService
|
||||
->getParticipantsCount($bookingCreateDto->roomSelections, $bookingCreateDto->travel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensures the booking DTO has the correct number of participant objects.
|
||||
*
|
||||
|
||||
@@ -4,35 +4,37 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Controller\Booking;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Controller\Traits\HtmxControllerTrait;
|
||||
use App\Form\BookingCreateStep3Type;
|
||||
use App\Service\BookingService;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||
|
||||
/**
|
||||
* Handles the third step of the booking creation process.
|
||||
*
|
||||
* This controller manages the booking confirmation and final review
|
||||
* before completing the booking process.
|
||||
* Handles the third step of the booking creation process (payment method selection).
|
||||
*/
|
||||
class CreateStep3Controller extends AbstractController
|
||||
{
|
||||
use BookingCreateTrait;
|
||||
use BookingExceptionHandlerTrait;
|
||||
use HtmxControllerTrait;
|
||||
|
||||
public function __construct(
|
||||
private readonly BookingService $bookingCreateService,
|
||||
private readonly BookingService $bookingService,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays the booking confirmation page.
|
||||
* Displays and processes the payment method form.
|
||||
*/
|
||||
#[Route('/bookings/create/confirm', name: 'app_booking_create_step_3')]
|
||||
public function confirm(Request $request): Response
|
||||
#[Route('/bookings/create/payment', name: 'app_booking_create_step_3')]
|
||||
public function step3(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingCreateService, $request);
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
@@ -43,8 +45,48 @@ class CreateStep3Controller extends AbstractController
|
||||
return $redirect;
|
||||
}
|
||||
|
||||
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
// TODO: Redirect to confirmation page or direct API submission
|
||||
$this->addFlash('success', 'Zahlungsart erfolgreich ausgewählt.');
|
||||
|
||||
return $this->redirectToRoute('app_booking_create_step_3');
|
||||
}
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles HTMX refresh when payment method changes.
|
||||
*/
|
||||
#[Route('/bookings/create/payment/refresh', name: 'app_booking_create_step_3_refresh')]
|
||||
public function refresh(Request $request): Response
|
||||
{
|
||||
$result = $this->getOrCreateBookingCreateDto($this->bookingService, $request);
|
||||
if ($result instanceof Response) {
|
||||
return $result;
|
||||
}
|
||||
$bookingCreateDto = $result;
|
||||
|
||||
$form = $this->createForm(BookingCreateStep3Type::class, $bookingCreateDto, [
|
||||
'validation_groups' => false,
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
$this->bookingService->saveBookingCreateDto($request, $bookingCreateDto);
|
||||
|
||||
return $this->render('booking/create_step_3.html.twig', [
|
||||
'bookingCreateDto' => $bookingCreateDto,
|
||||
'form' => $form->createView(),
|
||||
...$this->getSummaryVariables($bookingCreateDto),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\Form\Model\BankAccountDto;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BankAccountType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('iban', TextType::class, [
|
||||
'label' => 'IBAN',
|
||||
'required' => true,
|
||||
'attr' => [
|
||||
'class' => 'font-mono',
|
||||
'placeholder' => 'DE12 3456 7890 1234 5678 90',
|
||||
],
|
||||
])
|
||||
->add('accountHolder', TextType::class, [
|
||||
'label' => 'Kontoinhaber',
|
||||
'required' => true,
|
||||
])
|
||||
->add('bankName', TextType::class, [
|
||||
'label' => 'Bankname',
|
||||
'required' => false,
|
||||
'attr' => [
|
||||
'placeholder' => 'optional',
|
||||
],
|
||||
])
|
||||
->add('sepaMandateAccepted', CheckboxType::class, [
|
||||
'label' => '<span class="text-gray-700 font-normal">Hiermit ermächtige/n ich/wir Sie widerruflich, die von mir/uns zu entrichtende Zahlung bei Fälligkeit zu Lasten meines/unseres Girokontos durch Lastschrift einzuziehen.</span><br>' .
|
||||
'<span class="text-red-600 font-semibold">Bei kurzfristigen Buchungen (ab 2 Wochen vor Reisebeginn) ist Lastschrift NICHT mehr möglich.</span>',
|
||||
'required' => true,
|
||||
'label_html' => true,
|
||||
])
|
||||
;
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BankAccountDto::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class BookingCreateStep3Type extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('paymentMethod', ChoiceType::class, [
|
||||
'label' => 'Bitte wähle hier die gewünschte Zahlungsart:',
|
||||
'choices' => [
|
||||
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
|
||||
'Lastschrift (Einzugsermächtigungsverfahren)' => Constants::PAYMENT_METHOD_DEBIT,
|
||||
],
|
||||
'expanded' => true,
|
||||
'required' => true,
|
||||
'placeholder' => false,
|
||||
'label_html' => true,
|
||||
'choice_attr' => function (string $choice) use ($options): array {
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $choice && false === $this->isDebitAvailable($options['data'])) {
|
||||
return [
|
||||
'readonly' => true,
|
||||
'data-tooltip' => 'nicht mehr verfügbar (weniger als 14 Tage vor Reisebeginn)',
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
])
|
||||
;
|
||||
|
||||
// Add bank account fields conditionally based on payment method
|
||||
$builder->addEventListener(FormEvents::POST_SET_DATA, function (FormEvent $event): void {
|
||||
$bookingDto = $event->getData();
|
||||
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod) {
|
||||
$this->addBankAccountField($event->getForm());
|
||||
}
|
||||
});
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
|
||||
$data = $event->getData();
|
||||
$form = $event->getForm();
|
||||
$paymentMethod = $data['paymentMethod'] ?? null;
|
||||
|
||||
if ($form->has('bankAccount')) {
|
||||
$form->remove('bankAccount');
|
||||
}
|
||||
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $paymentMethod) {
|
||||
$this->addBankAccountField($form);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => BookingCreateDto::class,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the bank account field to the form.
|
||||
*/
|
||||
private function addBankAccountField($form): void
|
||||
{
|
||||
$form->add('bankAccount', BankAccountType::class, [
|
||||
'label' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if direct debit payment is available based on travel date.
|
||||
*
|
||||
* Direct debit is only available if the travel starts at least 14 days from now.
|
||||
*/
|
||||
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
|
||||
{
|
||||
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
|
||||
$now = CarbonImmutable::now();
|
||||
$daysUntilTravel = $now->diffInDays($travelStartDate, false);
|
||||
|
||||
return $daysUntilTravel >= 14;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* DTO for bank account information used in payment processing.
|
||||
*/
|
||||
class BankAccountDto
|
||||
{
|
||||
#[Assert\NotBlank(message: 'Bitte geben Sie Ihre IBAN ein.')]
|
||||
#[Assert\Iban(message: 'Die eingegebene IBAN ist ungültig.')]
|
||||
public ?string $iban = null;
|
||||
|
||||
#[Assert\NotBlank(message: 'Bitte geben Sie den Kontoinhaber ein.')]
|
||||
#[Assert\Length(
|
||||
min: 2,
|
||||
max: 70,
|
||||
minMessage: 'Der Kontoinhaber muss mindestens {{ limit }} Zeichen lang sein.',
|
||||
maxMessage: 'Der Kontoinhaber darf maximal {{ limit }} Zeichen lang sein.'
|
||||
)]
|
||||
public ?string $accountHolder = null;
|
||||
|
||||
#[Assert\Length(
|
||||
max: 70,
|
||||
maxMessage: 'Der Bankname darf maximal {{ limit }} Zeichen lang sein.'
|
||||
)]
|
||||
public ?string $bankName = null;
|
||||
|
||||
#[Assert\IsTrue(message: 'Bitte akzeptieren Sie das SEPA-Mandat.')]
|
||||
public bool $sepaMandateAccepted = false;
|
||||
|
||||
/**
|
||||
* Returns IBAN formatted with spaces for display (e.g., DE12 3456 7890 1234 5678 90).
|
||||
*/
|
||||
public function getFormattedIban(): ?string
|
||||
{
|
||||
if (null === $this->iban) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cleanIban = $this->getIbanWithoutSpaces();
|
||||
|
||||
return chunk_split($cleanIban, 4, ' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns IBAN without spaces for storage and API submission.
|
||||
*/
|
||||
public function getIbanWithoutSpaces(): ?string
|
||||
{
|
||||
if (null === $this->iban) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) preg_replace('/\s+/', '', $this->iban);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\BusProNet\Model\Travel;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
@@ -22,6 +23,14 @@ class BookingCreateDto implements BookingDtoInterface
|
||||
#[Assert\Valid]
|
||||
public array $participants = [];
|
||||
|
||||
#[Assert\Choice(
|
||||
choices: [Constants::PAYMENT_METHOD_TRANSFER, Constants::PAYMENT_METHOD_DEBIT],
|
||||
message: 'Bitte wählen Sie eine gültige Zahlungsart.'
|
||||
)]
|
||||
public ?string $paymentMethod = Constants::PAYMENT_METHOD_TRANSFER;
|
||||
|
||||
public ?BankAccountDto $bankAccount = null;
|
||||
|
||||
public function __construct(public Travel $travel, public int $hotelId)
|
||||
{
|
||||
}
|
||||
@@ -95,4 +104,41 @@ class BookingCreateDto implements BookingDtoInterface
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
|
||||
#[Assert\Callback]
|
||||
public function validateBankAccount(ExecutionContextInterface $context): void
|
||||
{
|
||||
if (Constants::PAYMENT_METHOD_DEBIT !== $this->paymentMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null === $this->bankAccount) {
|
||||
$context->buildViolation('Bitte geben Sie Ihre Bankverbindung an.')
|
||||
->atPath('bankAccount')
|
||||
->addViolation();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate IBAN
|
||||
if (null === $this->bankAccount->iban || '' === trim($this->bankAccount->iban)) {
|
||||
$context->buildViolation('Bitte geben Sie Ihre IBAN ein.')
|
||||
->atPath('bankAccount.iban')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
// Validate account holder
|
||||
if (null === $this->bankAccount->accountHolder || '' === trim($this->bankAccount->accountHolder)) {
|
||||
$context->buildViolation('Bitte geben Sie den Kontoinhaber ein.')
|
||||
->atPath('bankAccount.accountHolder')
|
||||
->addViolation();
|
||||
}
|
||||
|
||||
// Validate SEPA mandate
|
||||
if (false === $this->bankAccount->sepaMandateAccepted) {
|
||||
$context->buildViolation('Bitte akzeptieren Sie das SEPA-Mandat.')
|
||||
->atPath('bankAccount.sepaMandateAccepted')
|
||||
->addViolation();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,28 @@ use Symfony\Component\Validator\Constraints as Assert;
|
||||
#[AppAssert\Participant(groups: ['booking_edit', 'booking_create_step_2'])]
|
||||
class ParticipantDto
|
||||
{
|
||||
/**
|
||||
* List of dynamic participant fields that can be conditionally hidden/shown.
|
||||
*/
|
||||
public const DYNAMIC_FIELDS = [
|
||||
'assignedRoomId',
|
||||
'remarksRoom',
|
||||
'courses',
|
||||
'additionalServices',
|
||||
'board',
|
||||
'rentals',
|
||||
'rentalInsurance',
|
||||
'skiPass',
|
||||
'transportationOutbound',
|
||||
'transportationInbound',
|
||||
'pickupOutbound',
|
||||
'pickupInbound',
|
||||
'parking',
|
||||
'licensePlate',
|
||||
'bulkInsuranceBooking',
|
||||
'insurance',
|
||||
];
|
||||
|
||||
public ?int $index = null;
|
||||
public ?int $addressId = null;
|
||||
public ?int $personId = null;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form;
|
||||
|
||||
use App\BusProNet\Constants;
|
||||
use App\Form\Model\BookingCreateDto;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Symfony\Component\Form\AbstractType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\Form\FormEvent;
|
||||
use Symfony\Component\Form\FormEvents;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
class PaymentType extends AbstractType
|
||||
{
|
||||
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||
{
|
||||
$builder
|
||||
->add('paymentMethod', ChoiceType::class, [
|
||||
'label' => 'Zahlungsart',
|
||||
'help' => 'Lastschrift ist nur bis 14 Tage vor Reisebeginn möglich',
|
||||
'choices' => [
|
||||
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
|
||||
'Lastschrift' => Constants::PAYMENT_METHOD_DEBIT,
|
||||
],
|
||||
'expanded' => true,
|
||||
'required' => true,
|
||||
'placeholder' => false,
|
||||
])
|
||||
;
|
||||
|
||||
// Add bank account fields conditionally based on payment method
|
||||
$builder->addEventListener(FormEvents::PRE_SET_DATA, function (FormEvent $event): void {
|
||||
$bookingDto = $event->getData();
|
||||
|
||||
// Add choice_attr dynamically based on travel date
|
||||
$paymentMethodField = $event->getForm()->get('paymentMethod');
|
||||
$paymentMethodConfig = $paymentMethodField->getConfig();
|
||||
|
||||
// Rebuild the field with dynamic choice_attr
|
||||
$event->getForm()->add('paymentMethod', ChoiceType::class, [
|
||||
'label' => 'Zahlungsart',
|
||||
'help' => 'Lastschrift ist nur bis 14 Tage vor Reisebeginn möglich',
|
||||
'choices' => [
|
||||
'Überweisung' => Constants::PAYMENT_METHOD_TRANSFER,
|
||||
'Lastschrift' => Constants::PAYMENT_METHOD_DEBIT,
|
||||
],
|
||||
'expanded' => true,
|
||||
'required' => true,
|
||||
'placeholder' => false,
|
||||
'choice_attr' => function (string $choice) use ($bookingDto): array {
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $choice && false === $this->isDebitAvailable($bookingDto)) {
|
||||
return [
|
||||
'readonly' => true,
|
||||
'data-tooltip' => 'nicht mehr verfügbar (weniger als 14 Tage vor Reisebeginn)',
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
]);
|
||||
|
||||
// Add bank account fields if debit is selected
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod) {
|
||||
$event->getForm()->add('bankAccount', BankAccountType::class, [
|
||||
'label' => false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
$builder->addEventListener(FormEvents::PRE_SUBMIT, function (FormEvent $event): void {
|
||||
$data = $event->getData();
|
||||
$paymentMethod = $data['paymentMethod'] ?? null;
|
||||
|
||||
if (Constants::PAYMENT_METHOD_DEBIT === $paymentMethod) {
|
||||
$event->getForm()->add('bankAccount', BankAccountType::class, [
|
||||
'label' => false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function configureOptions(OptionsResolver $resolver): void
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'inherit_data' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if direct debit payment is available based on travel date.
|
||||
*
|
||||
* Direct debit is only available if the travel starts at least 14 days from now.
|
||||
*/
|
||||
private function isDebitAvailable(BookingCreateDto $bookingDto): bool
|
||||
{
|
||||
$travelStartDate = CarbonImmutable::instance($bookingDto->travel->dateFrom);
|
||||
$now = CarbonImmutable::now();
|
||||
$daysUntilTravel = $now->diffInDays($travelStartDate, false);
|
||||
|
||||
return $daysUntilTravel >= 14;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user