feat: voucher purchase flow

This commit is contained in:
fromme committed 2026-10-01 15:03:27 +02:00
1 parent a628e96d06
commit f3dd122678
70 files changed
+3672 -122

No files matched your search

+124
View File
@@ -0,0 +1,124 @@
<?php
declare(strict_types=1);
namespace App\Form\Model;
use App\BusProNet\Model\Address;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\VoucherPaymentMethod;
use App\BusProNet\Model\VoucherTemplate;
use Symfony\Component\Validator\Constraints as Assert;
/**
* Session state of the voucher purchase flow (/gutschein).
*
* The fields are exactly what the BusProNet voucher shop collects, with its mandatory flags rather
* than those of ADRESSFELDERGUTSCHEIN (which would also demand a phone number and a gender). There
* is no Zusatztext: GUTSCHEINBUCHUNG is sent an empty <text/>, as the live shop does.
*
* Step 2 is validated with the groups ['voucher_step_2', 'personal_data']: the buyer address reuses
* the Address model, whose constraints live in the personal_data group.
*/
class VoucherPurchaseDto
{
public const string SALUTATION_MR = 'Herr';
public const string SALUTATION_MS = 'Frau';
public int $currentStep = 1;
// BusPro's bounds from GUTSCHEINVORLAGEN at the start of the flow. BusPro re-checks them on
// every GUTSCHEINBUCHUNG (607/608), so a change in between still surfaces as a step 1 error.
public int $minAmount = 0;
public int $maxAmount = 0;
// Fixed at the start of the flow from GUTSCHEINZAHLUNGSARTEN; transfer is the only one offered.
public ?VoucherPaymentMethod $paymentMethod = null;
// Step 1: voucher
#[Assert\NotNull(message: 'Bitte wähle ein Motiv', groups: ['voucher_step_1'])]
public ?VoucherTemplate $template = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $from = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_1'])]
public ?string $to = null;
#[Assert\NotNull(message: 'Bitte gib einen Gutscheinwert an', groups: ['voucher_step_1'])]
#[Assert\Range(
notInRangeMessage: 'Der Gutscheinwert muss zwischen {{ min }} und {{ max }} Euro liegen',
minPropertyPath: 'minAmount',
maxPropertyPath: 'maxAmount',
groups: ['voucher_step_1'],
)]
public ?int $amount = null;
// Step 2: buyer (rechnungsempfänger)
#[Assert\Choice(choices: [self::SALUTATION_MR, self::SALUTATION_MS], groups: ['voucher_step_2'])]
public ?string $salutation = null;
public ?string $title = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $lastName = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
public ?string $firstName = null;
#[Assert\Valid]
public Address $address;
public ?string $phone = null;
public ?string $mobile = null;
#[Assert\NotBlank(message: 'Bitte angeben', groups: ['voucher_step_2'])]
#[Assert\Email(message: 'Bitte eine gültige Adresse angeben', mode: 'strict', groups: ['voucher_step_2'])]
public ?string $email = null;
// Step 3: confirmation, reset on every visit so a stale tick never carries over
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $termsAccepted = false;
#[Assert\IsTrue(message: 'Bitte bestätige', groups: ['voucher_step_3'])]
public bool $purchaseConfirmed = false;
public function __construct()
{
$this->address = new Address();
$this->address->country = 'D';
}
public function getBuyerFullName(): string
{
return trim(sprintf('%s %s', $this->firstName ?? '', $this->lastName ?? ''));
}
public function hasBuyerData(): bool
{
return null !== $this->lastName && '' !== $this->lastName;
}
/**
* Copies the buyer fields from the signed-in account's BusPro record.
*
* The e-mail is the one the account signed in with, not the record's communication address:
* a BusPro address can carry several e-mails for different persons.
*/
public function prefillBuyer(PersonalData $personalData, ?string $loginEmail): void
{
$this->salutation = \in_array($personalData->salutation, [self::SALUTATION_MR, self::SALUTATION_MS], true)
? $personalData->salutation
: null;
$this->title = $personalData->title;
$this->lastName = $personalData->name;
$this->firstName = $personalData->firstName;
$this->address = clone $personalData->address;
$this->address->normalize();
$this->address->country ??= 'D';
$this->phone = $personalData->communication->phone;
$this->mobile = $personalData->communication->mobile;
$this->email = $loginEmail;
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\BusProNet\Model\VoucherTemplate;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<VoucherPurchaseDto> */
class VoucherPurchaseStep1Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('template', ChoiceType::class, [
'label' => 'Motiv',
'choices' => $options['templates'],
'choice_value' => static fn (?VoucherTemplate $template) => $template?->id,
'choice_label' => static fn (VoucherTemplate $template) => $template->label,
'expanded' => true,
'placeholder' => false,
'invalid_message' => 'Bitte wähle ein Motiv',
])
->add('from', TextType::class, [
'label' => 'Von',
'sanitize_html' => true,
])
->add('to', TextType::class, [
'label' => 'Für',
'sanitize_html' => true,
])
->add('amount', IntegerType::class, [
'label' => 'Gutscheinwert in Euro',
'invalid_message' => 'Bitte gib einen ganzen Euro-Betrag an',
'attr' => [
'min' => $options['min_amount'],
'max' => $options['max_amount'],
'step' => 1,
'inputmode' => 'numeric',
],
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
'validation_groups' => ['voucher_step_1'],
]);
$resolver->setRequired(['templates', 'min_amount', 'max_amount']);
$resolver->setAllowedTypes('templates', VoucherTemplate::class.'[]');
$resolver->setAllowedTypes('min_amount', 'int');
$resolver->setAllowedTypes('max_amount', 'int');
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* The buyer, sent to BusPro as rechnungsempfänger. Mandatory fields as in the live voucher shop.
*
* @extends AbstractType<VoucherPurchaseDto>
*/
class VoucherPurchaseStep2Type extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('salutation', ChoiceType::class, [
'label' => 'Anrede',
'choices' => [
'Herr' => VoucherPurchaseDto::SALUTATION_MR,
'Frau' => VoucherPurchaseDto::SALUTATION_MS,
],
'expanded' => true,
'required' => false,
'placeholder' => false,
])
->add('title', TextType::class, [
'label' => 'Titel',
'required' => false,
'sanitize_html' => true,
])
->add('firstName', TextType::class, [
'label' => 'Vorname',
'sanitize_html' => true,
])
->add('lastName', TextType::class, [
'label' => 'Name',
'sanitize_html' => true,
])
->add('address', AddressType::class, [
'label' => false,
'required' => true,
])
->add('email', EmailType::class, [
'label' => 'E-Mail',
])
->add('phone', TextType::class, [
'label' => 'Telefon privat',
'required' => false,
'sanitize_html' => true,
])
->add('mobile', TextType::class, [
'label' => 'Mobil',
'required' => false,
'sanitize_html' => true,
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
// personal_data carries the constraints of the reused Address model
'validation_groups' => ['voucher_step_2', 'personal_data'],
]);
}
}
+52
View File
@@ -0,0 +1,52 @@
<?php
declare(strict_types=1);
namespace App\Form;
use App\Form\Model\VoucherPurchaseDto;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
/** @extends AbstractType<VoucherPurchaseDto> */
class VoucherPurchaseStep3Type extends AbstractType
{
public function __construct(
private readonly string $termsAndConditionsUrl,
) {
}
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$termsLink = sprintf(
'<a href="%s" target="_blank" rel="noopener" class="underline">Allgemeinen Geschäftsbedingungen</a>',
htmlspecialchars($this->termsAndConditionsUrl, ENT_QUOTES, 'UTF-8')
);
$builder
->add('termsAccepted', CheckboxType::class, [
'label' => sprintf('Ja, ich habe die %s gelesen und akzeptiere diese.', $termsLink),
'label_html' => true,
])
->add('purchaseConfirmed', CheckboxType::class, [
'label' => sprintf(
'Ja, ich möchte den Gutschein jetzt zum Preis von %s € kaufen.',
number_format($options['amount'], 2, ',', '.')
),
])
;
}
public function configureOptions(OptionsResolver $resolver): void
{
$resolver->setDefaults([
'data_class' => VoucherPurchaseDto::class,
'validation_groups' => ['voucher_step_3'],
]);
$resolver->setRequired('amount');
$resolver->setAllowedTypes('amount', 'int');
}
}