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,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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user