feat: implement payment step

This commit is contained in:
Björn Fromme
2026-03-16 11:59:10 +01:00
parent 3d9fc18a83
commit 3f7db4e772
12 changed files with 1273 additions and 30 deletions
+53
View File
@@ -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,
]);
}
}
+99
View File
@@ -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;
}
}
+61
View File
@@ -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);
}
}
+46
View File
@@ -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();
}
}
}
+22
View File
@@ -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;
+106
View File
@@ -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;
}
}