feat: fold purchase and promo voucher fields into one code field

This commit is contained in:
fromme committed 2026-10-06 10:41:30 +02:00
1 parent 93512b8345
commit 21d756a2e3
21 files changed
+801 -432

No files matched your search

+12 -4
View File
@@ -16,6 +16,12 @@ parameters:
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
# Promo codes issued before the "R-" prefix was introduced, with the last day each is recognised
# as a promo code (VoucherCodeClassifier). Remove this parameter and the classifier branch
# reading it once the last date has passed.
# TODO: replace the placeholder with the codes still active in BusPro (GutscheinAktion).
legacy_promo_voucher_codes:
PLACEHOLDER: '2027-04-30'
# MailJet list ids and their labels
mailjet_lists:
@@ -242,6 +248,10 @@ services:
$cache: '@cache.app'
$logger: '@monolog.logger.bpn'
App\Service\VoucherCodeClassifier:
arguments:
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
# Voucher purchase flow (/gutschein)
App\Service\VoucherTemplateProvider:
arguments:
@@ -265,8 +275,7 @@ services:
App\Form\Service\ParticipantInsuranceFieldHandler: ~
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
App\Form\Service\ParticipantVoucherFieldHandler: ~
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
App\Form\Service\ParticipantFieldHandlerRegistry:
@@ -292,8 +301,7 @@ services:
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
- '@App\Form\Service\ParticipantVoucherFieldHandler'
- '@App\Form\Service\ParticipantParkingFieldHandler'
# Booking Status Rules
+22
View File
@@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Enum;
/**
* What a code typed into the booking voucher field is, as far as its format tells.
*
* Kauf vs. Kulanz is not visible in the code; BusPro tells them apart when the purchase code is checked.
*/
enum VoucherCodeKind: string
{
// Aktionsgutschein, checked with AKTIONSGUTSCHEIN and sent as the participant's <aktionscode>
case Promo = 'promo';
// Kauf- or Kulanzgutschein, checked with GUTSCHEINPRUEFUNGEINLOESUNG
case Purchase = 'purchase';
// Cannot be either, so it is rejected without asking BusPro
case Malformed = 'malformed';
}
+3 -2
View File
@@ -16,6 +16,7 @@ use Symfony\Component\Form\Extension\Core\Type\BirthdayType;
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\EmailType;
use Symfony\Component\Form\Extension\Core\Type\HiddenType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
@@ -334,8 +335,8 @@ class BookingParticipantType extends AbstractType
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = ChoiceType::class;
$dynamicFields['purchaseVoucherCode'] = TextType::class;
$dynamicFields['promoVoucherCode'] = TextType::class;
$dynamicFields['voucherCode'] = TextType::class;
$dynamicFields['removeVoucherCode'] = HiddenType::class;
}
foreach ($dynamicFields as $fieldName => $fieldType) {
+33 -25
View File
@@ -48,8 +48,8 @@ class ParticipantDto
'licensePlate',
'bulkInsuranceBooking',
'insurance',
'purchaseVoucherCode',
'promoVoucherCode',
'voucherCode',
'removeVoucherCode',
];
public ?int $index = null;
@@ -163,47 +163,54 @@ class ParticipantDto
public bool $bulkInsuranceBooking = false;
/**
* Purchase voucher redemption code.
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload.
* The voucher field as typed: a purchase or a promo code, told apart by VoucherCodeClassifier.
* ParticipantVoucherFieldHandler applies it and clears it; a rejected code stays for correction.
*/
public ?string $voucherCode = null;
/**
* Why the typed voucher code was rejected. Kept on the DTO because the htmx refresh rebuilds
* the form from the DTO, which drops form errors; the Voucher constraint turns it into a violation.
*/
public ?string $voucherCodeError = null;
/**
* Applied code the customer asked to remove. Consumed and reset by ParticipantVoucherFieldHandler.
*/
public ?string $removeVoucherCode = null;
/**
* Applied purchase voucher redemption code (Kauf or Kulanz).
* Kauf codes are aggregated into the booking's <gutscheine>, a Kulanz code is sent as the participant's <einloesecode>.
*/
#[Assert\Length(
max: 50,
maxMessage: 'Der Gutscheincode darf maximal {{ limit }} Zeichen lang sein'
)]
#[Assert\Regex(
pattern: '/^[A-Za-z0-9\-]+$/',
message: 'Der Gutscheincode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
)]
public ?string $purchaseVoucherCode = null;
/**
* Validated purchase voucher from API.
* Set by ParticipantPurchaseVoucherFieldHandler during form processing.
* Set by ParticipantVoucherFieldHandler during form processing.
* Contains voucher number, remaining balance, and type (Kauf/Kulanz).
*/
public ?PurchaseVoucher $validatedPurchaseVoucher = null;
/**
* Promo voucher code.
* Applied promo voucher code.
* Applied per participant in booking payload as <aktionscode>.
*/
#[Assert\Length(
max: 50,
maxMessage: 'Der Aktionscode darf maximal {{ limit }} Zeichen lang sein'
)]
#[Assert\Regex(
pattern: '/^[A-Za-z0-9\-]+$/',
message: 'Der Aktionscode darf nur Buchstaben, Zahlen und Bindestriche enthalten'
)]
public ?string $promoVoucherCode = null;
/**
* Validated promo voucher from API.
* Set by ParticipantPromoVoucherFieldHandler during form processing.
* Set by ParticipantVoucherFieldHandler during form processing.
* Contains discount amount/percentage and applicability (per person/booking).
* Null while promoVoucherCode is set means BusPro rejected the applied code on a later check.
*/
public ?PromoVoucher $validatedPromoVoucher = null;
/**
* Why BusPro rejected the applied promo code on a later check, e.g. the price fell below its threshold.
*/
public ?string $promoVoucherError = null;
/**
* @var array<array{type: string, message: string}> Notification messages for user feedback
*/
@@ -345,8 +352,8 @@ class ParticipantDto
/**
* Checks if this participant has a goodwill (Kulanz) voucher.
*
* Goodwill vouchers are treated as promotional vouchers in BPN XML
* (sent as <aktionscode> per participant, not in <gutscheine> collection).
* Goodwill vouchers are sent as the participant's <einloesecode>,
* not in the booking's <gutscheine> collection.
*/
public function hasGoodwillVoucher(): bool
{
@@ -425,6 +432,7 @@ class ParticipantDto
$this->mobile = $this->normalizeNullableString($this->mobile);
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
$this->licensePlate = $this->normalizeNullableString($this->licensePlate);
$this->voucherCode = $this->normalizeNullableString($this->voucherCode);
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
$this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode);
+23 -2
View File
@@ -18,8 +18,6 @@ use function Symfony\Component\String\u;
* the participant being edited and the full booking context needed for
* cross-participant validation.
*/
#[AppAssert\PurchaseVoucher(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\PromoVoucher(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\SkiPassSelection(groups: ['strict_required'])]
class ParticipantEditDto
@@ -71,6 +69,29 @@ class ParticipantEditDto
}
}
/**
* Reports voucher codes BusPro rejected.
*
* ParticipantVoucherFieldHandler checks the codes with BusPro while handling the submission and
* keeps the outcome on the participant, since the htmx refresh rebuilds the form and drops form
* errors. This turns it into a violation so the step cannot be left with a rejected code.
*/
#[Assert\Callback(groups: ['booking_create', 'booking_edit'])]
public function validateVoucherCodes(ExecutionContextInterface $context): void
{
if (null !== $this->participant->voucherCodeError) {
$context->buildViolation($this->participant->voucherCodeError)
->atPath('participant.voucherCode')
->addViolation();
}
if (null !== $this->participant->promoVoucherCode && null !== $this->participant->promoVoucherError) {
$context->buildViolation($this->participant->promoVoucherError)
->atPath('participant.voucherCode')
->addViolation();
}
}
/**
* Validates that non-applicant adult participants have unique email addresses.
*
@@ -332,11 +332,11 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
// Vouchers cannot be redeemed or submitted for inquiry bookings
$finalBookingOnlyCondition = new FinalBookingOnlyCondition();
$this->fieldStateConditions['purchaseVoucherCode'] = [
$this->fieldStateConditions['voucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
];
$this->fieldStateConditions['promoVoucherCode'] = [
$this->fieldStateConditions['removeVoucherCode'] = [
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
];
@@ -263,6 +263,9 @@ class ParticipantFieldHandlerRegistry
'dropOff',
'parking',
'licensePlate',
// Cleared by the voucher handler once applied or removed; must not be bound again
'voucherCode',
'removeVoucherCode',
];
foreach ($serviceFields as $fieldName) {
@@ -704,28 +704,22 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
];
};
// Purchase voucher field provider - redemption code for vouchers that apply to complete booking
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload
$this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein-Code',
// Single voucher field - purchase (Kauf/Kulanz) or promo code, told apart by VoucherCodeClassifier.
// Applied by ParticipantVoucherFieldHandler, which moves accepted codes to purchaseVoucherCode/promoVoucherCode
$this->fieldOptionProviders['voucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Gutschein- oder Aktionscode',
'required' => false,
'attr' => [
'placeholder' => 'z.B. 1F7V6PADRZ',
'placeholder' => 'z.B. 1F7V6PADRZ oder R-SOMMER',
'maxlength' => 50,
'autocomplete' => 'off',
],
'sanitize_html' => true,
];
// Promo voucher field provider - promo code applied per participant in booking payload
// Included in participant XML node as <aktionscode>
$this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'label' => 'Rabatt-Code',
// Applied voucher code to remove, set by the remove button of its chip
$this->fieldOptionProviders['removeVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
'required' => false,
'attr' => [
'placeholder' => 'z.B. LW0705',
'maxlength' => 50,
],
'sanitize_html' => true,
];
// Future field providers would be added here, for example:
@@ -1,93 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherValidator;
/**
* Handles promotional voucher code field processing.
*
* Validates promo codes via API and stores the validated voucher data
* for early discount display. Validation is also performed by the
* PromoVoucher constraint on ParticipantEditDto for form error feedback.
*
* Dependencies: ALL price-affecting fields (insurance, services, ski pass, rentals, etc.)
* since promo validation requires accurate participant pricing.
*/
class ParticipantPromoVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
) {
}
public function getFieldName(): string
{
return 'promoVoucherCode';
}
public function getDependencies(): array
{
// CRITICAL: Must run after ALL price-affecting handlers to ensure accurate pricing
return [
'dateOfBirth', // Required for age-based services
'skiPass', // Affects travel price
'rentals', // Affects travel price
'courses', // Affects travel price
'additionalServices', // Affects travel price
'board', // Affects travel price
'transportationOutbound', // Affects travel price
'transportationInbound', // Affects travel price
'pickup', // Affects travel price
'parking', // Affects travel price
'insurance', // Affects travel price (must be last)
];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
return null !== $promoCode && '' !== trim($promoCode);
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
// Clear validated voucher if code is empty
if (null === $promoCode || '' === trim($promoCode)) {
$participant->validatedPromoVoucher = null;
return;
}
// Calculate participant price for validation
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice(
$bookingDto,
$participantIndex
);
// Validate promo voucher and store result
$result = $this->voucherValidationService->validatePromoVoucher(
trim($promoCode),
$bookingDto->travel->id,
$participantPrice
);
// Store validated voucher (null if invalid)
$participant->validatedPromoVoucher = $result->isValid ? $result->voucher : null;
}
}
@@ -1,68 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\VoucherValidator;
/**
* Handles purchase voucher code field processing.
*
* Detects goodwill (Kulanz) vouchers and marks them for special XML handling.
* Goodwill vouchers must be sent as <aktionscode> per participant, not aggregated
* in <gutscheine> collection. Validation is performed by PurchaseVoucher constraint.
*
* Dependencies: None (purchase vouchers don't require price calculation)
*/
class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
) {
}
public function getFieldName(): string
{
return 'purchaseVoucherCode';
}
public function getDependencies(): array
{
// No dependencies - purchase vouchers don't require price calculation
return [];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
$voucherCode = $this->getFieldValue($submittedData, $this->getFieldName());
return null !== $voucherCode && '' !== trim($voucherCode);
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$voucherCode = $this->getFieldValue($submittedData, $this->getFieldName());
// Clear validated voucher if code is empty
if (null === $voucherCode || '' === trim($voucherCode)) {
$participant->validatedPurchaseVoucher = null;
return;
}
// Validate voucher and store result
$result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode);
// Store validated voucher (null if invalid)
$participant->validatedPurchaseVoucher = $result->isValid ? $result->voucher : null;
}
}
@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Enum\VoucherCodeKind;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherCodeClassifier;
use App\Service\VoucherValidator;
/**
* Handles the single voucher field of the booking create flow.
*
* The typed code is classified as promo or purchase code (VoucherCodeClassifier), checked with
* BusPro and, when accepted, moved into promoVoucherCode or purchaseVoucherCode, which the payload
* builder reads. The field is then cleared; a rejected code stays with its reason in voucherCodeError.
* A participant holds at most one code of each kind.
*
* Runs on every create-mode submission, also without a typed code, because an applied promo code
* is checked against the participant price, which may have changed since it was applied.
*
* Dependencies: ALL price-affecting fields (insurance, services, ski pass, rentals, etc.)
* since promo validation requires accurate participant pricing.
*/
class ParticipantVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly VoucherCodeClassifier $voucherCodeClassifier,
) {
}
public function getFieldName(): string
{
return 'voucherCode';
}
public function getDependencies(): array
{
// CRITICAL: Must run after ALL price-affecting handlers to ensure accurate pricing
return [
'dateOfBirth', // Required for age-based services
'skiPass', // Affects travel price
'rentals', // Affects travel price
'courses', // Affects travel price
'additionalServices', // Affects travel price
'board', // Affects travel price
'transportationOutbound', // Affects travel price
'transportationInbound', // Affects travel price
'pickup', // Affects travel price
'parking', // Affects travel price
'insurance', // Affects travel price (must be last)
];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
// Vouchers can only be redeemed when creating a booking
return BookingDto::MODE_CREATE === $mode;
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (!$participant instanceof ParticipantDto) {
return;
}
$this->removeRequestedCode($submittedData, $participant);
$this->recheckAppliedPromoCode($bookingDto, $participant, $participantIndex);
$this->applyTypedCode($submittedData, $bookingDto, $participant, $participantIndex);
}
/**
* @param array<string, mixed> $submittedData
*/
private function removeRequestedCode(array $submittedData, ParticipantDto $participant): void
{
$participant->removeVoucherCode = null;
$code = $this->getFieldValue($submittedData, 'removeVoucherCode');
if (false === is_string($code) || '' === trim($code)) {
return;
}
$code = $this->voucherCodeClassifier->normalize($code);
if ($code === $participant->promoVoucherCode) {
$participant->promoVoucherCode = null;
$participant->validatedPromoVoucher = null;
$participant->promoVoucherError = null;
}
if ($code === $participant->purchaseVoucherCode) {
$participant->purchaseVoucherCode = null;
$participant->validatedPurchaseVoucher = null;
}
}
private function recheckAppliedPromoCode(BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
if (null === $participant->promoVoucherCode) {
return;
}
$error = $this->validatePromoCode($participant->promoVoucherCode, $bookingDto, $participant, $participantIndex);
$participant->promoVoucherError = $error;
}
/**
* @param array<string, mixed> $submittedData
*/
private function applyTypedCode(array $submittedData, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
$participant->voucherCodeError = null;
// Only a submitted field carries a typed code; when the field is hidden nothing may linger
$typedCode = $this->getFieldValue($submittedData, $this->getFieldName());
if (false === is_string($typedCode) || '' === trim($typedCode)) {
$participant->voucherCode = null;
return;
}
$participant->voucherCode = trim($typedCode);
$code = $this->voucherCodeClassifier->normalize($typedCode);
$error = match (true) {
$code === $participant->promoVoucherCode, $code === $participant->purchaseVoucherCode => 'Dieser Code ist bereits eingelöst.',
default => match ($this->voucherCodeClassifier->classify($code)) {
VoucherCodeKind::Malformed => 'Dieser Code ist ungültig. Bitte prüfe deine Eingabe.',
VoucherCodeKind::Promo => $this->applyPromoCode($code, $bookingDto, $participant, $participantIndex),
VoucherCodeKind::Purchase => $this->applyPurchaseCode($code, $participant),
},
};
if (null === $error) {
$participant->voucherCode = null;
}
$participant->voucherCodeError = $error;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
if (null !== $participant->promoVoucherCode) {
return sprintf('Es kann nur ein Aktionscode eingelöst werden. Bitte entferne zuerst %s.', $participant->promoVoucherCode);
}
$error = $this->validatePromoCode($code, $bookingDto, $participant, $participantIndex);
if (null !== $error) {
return $error;
}
$participant->promoVoucherCode = $code;
$participant->promoVoucherError = null;
return null;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPurchaseCode(string $code, ParticipantDto $participant): ?string
{
if (null !== $participant->purchaseVoucherCode) {
return sprintf('Es kann nur ein Gutschein eingelöst werden. Bitte entferne zuerst %s.', $participant->purchaseVoucherCode);
}
$result = $this->voucherValidationService->validatePurchaseVoucher($code);
if (false === $result->isValid) {
return sprintf('Gutschein ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->purchaseVoucherCode = $code;
$participant->validatedPurchaseVoucher = $result->voucher;
return null;
}
/**
* Checks a promo code against the participant's current price and stores the validated voucher.
*
* @return string|null Why BusPro rejected the code, or null when it is valid
*/
private function validatePromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
$travelId = $bookingDto->travel->id;
if (null === $travelId) {
$participant->validatedPromoVoucher = null;
return 'Aktionscode konnte nicht validiert werden: Reise-ID fehlt.';
}
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice($bookingDto, $participantIndex);
$result = $this->voucherValidationService->validatePromoVoucher($code, $travelId, $participantPrice);
if (false === $result->isValid) {
// Only an applied code is replaced: a rejected new code must not discard the applied one's discount
if ($code === $participant->promoVoucherCode) {
$participant->validatedPromoVoucher = null;
}
return sprintf('Aktionscode ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->validatedPromoVoucher = $result->voucher;
return null;
}
}
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Enum\VoucherCodeKind;
/**
* Tells promo codes and purchase codes apart, so the booking form needs only one voucher field.
*
* Promo codes are prefixed "R-". Purchase codes (Kauf and Kulanz) consist of uppercase letters only
* and never contain a hyphen, so the prefix cannot collide with them. Promo codes issued before the
* prefix was introduced are recognised from `legacy_promo_voucher_codes` until their date has passed.
*/
class VoucherCodeClassifier
{
public const PROMO_PREFIX = 'R-';
private const MAX_LENGTH = 50;
/** @var array<string, \DateTimeImmutable> Normalised code => last day the code is recognised */
private readonly array $legacyPromoCodes;
/**
* @param array<string, string> $legacyPromoCodes Code => last day it is accepted (Y-m-d)
*/
public function __construct(array $legacyPromoCodes = [])
{
$codes = [];
foreach ($legacyPromoCodes as $code => $lastDay) {
$codes[$this->normalize((string) $code)] = new \DateTimeImmutable($lastDay);
}
$this->legacyPromoCodes = $codes;
}
/**
* Codes are matched case-insensitively; BusPro receives them in uppercase.
*/
public function normalize(string $code): string
{
return mb_strtoupper(trim($code));
}
public function classify(string $code, \DateTimeImmutable $today = new \DateTimeImmutable('today')): VoucherCodeKind
{
$code = $this->normalize($code);
if ('' === $code || mb_strlen($code) > self::MAX_LENGTH || 1 !== preg_match('/^[A-Z0-9\-]+$/', $code)) {
return VoucherCodeKind::Malformed;
}
if (str_starts_with($code, self::PROMO_PREFIX)) {
return VoucherCodeKind::Promo;
}
$lastDay = $this->legacyPromoCodes[$code] ?? null;
if (null !== $lastDay && $lastDay >= $today) {
return VoucherCodeKind::Promo;
}
if (str_contains($code, '-')) {
return VoucherCodeKind::Malformed;
}
return VoucherCodeKind::Purchase;
}
}
@@ -1,25 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* Validates promotional voucher codes via BPN API.
*
* Checks code validity, travel applicability, and minimum price threshold.
* Requires participant pricing calculation and travel context.
* Applied at ParticipantEditDto level to access booking context.
*/
#[\Attribute]
class PromoVoucher extends Constraint
{
public string $message = 'Aktionscode ungültig: {{ error }}';
public function getTargets(): array|string
{
return static::CLASS_CONSTRAINT;
}
}
@@ -1,78 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Form\Model\ParticipantEditDto;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherValidator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates promotional voucher codes against the BPN API.
*
* Verifies that promo codes are valid for the current travel, checks minimum price
* thresholds, and validates code availability. Uses the participant's calculated
* total price (including room and all selected services) for threshold comparison.
*/
class PromoVoucherValidator extends ConstraintValidator
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof PromoVoucher) {
throw new UnexpectedTypeException($constraint, PromoVoucher::class);
}
if (!$value instanceof ParticipantEditDto) {
throw new UnexpectedTypeException($value, ParticipantEditDto::class);
}
$promoCode = $value->participant->promoVoucherCode;
// Skip validation if promo code is empty
if (null === $promoCode || '' === trim($promoCode)) {
return;
}
// Get travel ID from booking context
$travelId = $value->bookingContext->travel->id;
if (null === $travelId) {
$this->context->buildViolation('Aktionscode konnte nicht validiert werden: Reise-ID fehlt.')
->atPath('participant.promoVoucherCode')
->addViolation();
return;
}
// Calculate participant's current price including all services
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice(
$value->bookingContext,
$value->participant->index ?? 0
);
// Validate promo voucher via API
$result = $this->voucherValidationService->validatePromoVoucher(
$promoCode,
$travelId,
$participantPrice
);
// Add violation if voucher is invalid
if (false === $result->isValid) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ error }}', $result->errorMessage ?? 'Unbekannter Fehler')
->atPath('participant.promoVoucherCode')
->addViolation();
}
}
}
@@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use Symfony\Component\Validator\Constraint;
/**
* Validates purchase voucher codes via BPN API.
*
* Checks that the voucher exists and has remaining balance.
* Applied at ParticipantEditDto level to access booking context.
*/
#[\Attribute]
class PurchaseVoucher extends Constraint
{
public string $message = 'Gutschein ungültig: {{ error }}';
public function getTargets(): array|string
{
return static::CLASS_CONSTRAINT;
}
}
@@ -1,54 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Validator\Constraints;
use App\Form\Model\ParticipantEditDto;
use App\Service\VoucherValidator;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidator;
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
/**
* Validates purchase voucher codes against the BPN API.
*
* Verifies that voucher codes exist and have remaining balance. Unlike promo
* vouchers, purchase vouchers do not require travel or price context validation.
*/
class PurchaseVoucherValidator extends ConstraintValidator
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
) {
}
public function validate(mixed $value, Constraint $constraint): void
{
if (!$constraint instanceof PurchaseVoucher) {
throw new UnexpectedTypeException($constraint, PurchaseVoucher::class);
}
if (!$value instanceof ParticipantEditDto) {
throw new UnexpectedTypeException($value, ParticipantEditDto::class);
}
$voucherCode = $value->participant->purchaseVoucherCode;
// Skip validation if voucher code is empty
if (null === $voucherCode || '' === trim($voucherCode)) {
return;
}
// Validate voucher via API
$result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode);
// Add violation if voucher is invalid
if (false === $result->isValid) {
$this->context->buildViolation($constraint->message)
->setParameter('{{ error }}', $result->errorMessage ?? 'Unbekannter Fehler')
->atPath('participant.purchaseVoucherCode')
->addViolation();
}
}
}
+72 -7
View File
@@ -684,19 +684,84 @@
{% endif %}
{% endif %}
{# Voucher fields - available for all participants regardless of eligibility #}
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
{# Voucher field - available for all participants regardless of eligibility #}
{% if form.voucherCode is defined %}
{% set voucherParticipant = form.vars.data.participant %}
{% set voucherHx = {
'hx-post': path(refreshRouteName, refreshRouteParams|default({index: participantIndex})),
'hx-target': '#main-content',
'hx-swap': 'innerHTML'
} %}
<h3 class="font-semibold text-lg mb-4">
Gutscheine
</h3>
<div class="grid lg:grid-cols-2 lg:gap-x-4">
{% if form.purchaseVoucherCode is defined %}
{{ form_row(form.purchaseVoucherCode) }}
<div class="mb-4">
{{ form_label(form.voucherCode) }}
<div class="flex items-center gap-2">
<div class="grow">
{{ form_widget(form.voucherCode) }}
</div>
<button type="button" class="button button--secondary"
{% for name, value in voucherHx %} {{ name }}="{{ value }}"{% endfor %}>
Code einlösen
</button>
</div>
{# A full submit carries the error as a violation; the htmx refresh rebuilds the form, so read it from the DTO #}
{% if form.voucherCode.vars.errors|length > 0 %}
{{ form_errors(form.voucherCode) }}
{% elseif voucherParticipant.voucherCodeError %}
<ul class="pb-2 px-2">
<li class="text-red-500">{{ voucherParticipant.voucherCodeError }}</li>
</ul>
{% endif %}
{% if form.promoVoucherCode is defined %}
{{ form_row(form.promoVoucherCode) }}
{% set appliedVouchers = [] %}
{% if voucherParticipant.purchaseVoucherCode %}
{% set purchaseVoucher = voucherParticipant.validatedPurchaseVoucher %}
{% set appliedVouchers = appliedVouchers|merge([{
code: voucherParticipant.purchaseVoucherCode,
label: purchaseVoucher and purchaseVoucher.goodwill ? 'Kulanzgutschein' : 'Kaufgutschein',
value: purchaseVoucher ? purchaseVoucher.remainingBalance|format_currency('EUR') : null,
error: null
}]) %}
{% endif %}
{% if voucherParticipant.promoVoucherCode %}
{% set promoVoucher = voucherParticipant.validatedPromoVoucher %}
{% set appliedVouchers = appliedVouchers|merge([{
code: voucherParticipant.promoVoucherCode,
label: 'Aktionsgutschein',
value: promoVoucher ? (promoVoucher.discountPercentage > 0 ? promoVoucher.discountPercentage ~ ' %' : promoVoucher.discountAmount|format_currency('EUR')) : null,
error: voucherParticipant.promoVoucherError
}]) %}
{% endif %}
{% if appliedVouchers is not empty %}
<ul class="flex flex-wrap gap-2 mt-2">
{% for voucher in appliedVouchers %}
<li class="{{ html_classes('flex items-center gap-2 border px-2 py-1', {
'border-primary-bg': not voucher.error,
'border-red-500': voucher.error
}) }}">
<span>
{{ voucher.label }} {{ voucher.code }}{% if voucher.value %}: {{ voucher.value }}{% endif %}
{% if voucher.error %}
<span class="block text-sm text-red-500">{{ voucher.error }}</span>
{% endif %}
</span>
<button type="button" class="text-sm text-primary-light" aria-label="{{ voucher.code }} entfernen"
hx-vals="{{ {(form.removeVoucherCode.vars.full_name): voucher.code}|json_encode }}"
{% for name, value in voucherHx %} {{ name }}="{{ value }}"{% endfor %}>
Entfernen
</button>
</li>
{% endfor %}
</ul>
{% endif %}
</div>
{# Submitted through the remove buttons' hx-vals, never as an input of its own #}
{% if form.removeVoucherCode is defined %}
{% do form.removeVoucherCode.setRendered() %}
{% endif %}
{% endif %}
</div>
{% endblock %}
+37 -17
View File
@@ -16,13 +16,9 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\BookingPriceCalculator;
use App\Service\ParticipantEligibilityChecker;
use App\Service\ServiceAvailabilityCalculator;
use App\Service\VoucherValidator;
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
use App\Validator\Constraints\PromoVoucherValidator;
use App\Validator\Constraints\PurchaseVoucherValidator;
use App\Validator\Constraints\SkiPassSelectionValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
@@ -48,8 +44,6 @@ class ParticipantEditDtoTest extends TestCase
protected function setUp(): void
{
// Create mock services for validator dependencies
$mockVoucherService = $this->createStub(VoucherValidator::class);
$mockPriceCalculatorService = $this->createStub(BookingPriceCalculator::class);
$mockParticipantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$mockServiceAgeEvaluator = $this->createStub(ServiceAgeEvaluator::class);
@@ -61,10 +55,8 @@ class ParticipantEditDtoTest extends TestCase
);
// Create custom validator factory that can inject dependencies
$validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService, $mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface {
$validatorFactory = new class($mockParticipantEligibilityChecker, $mockServiceAgeEvaluator, $participantEligibilityChecker) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly ParticipantEligibilityChecker $participantEligibilityService,
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
private readonly ParticipantEligibilityChecker $realEligibilityChecker,
@@ -75,14 +67,6 @@ class ParticipantEditDtoTest extends TestCase
{
$className = $constraint->validatedBy();
if (PurchaseVoucherValidator::class === $className) {
return new PurchaseVoucherValidator($this->voucherService);
}
if (PromoVoucherValidator::class === $className) {
return new PromoVoucherValidator($this->voucherService, $this->priceCalculatorService);
}
if (EmailValidator::class === $className) {
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
}
@@ -125,6 +109,42 @@ class ParticipantEditDtoTest extends TestCase
$this->assertCount(0, $violations);
}
public function testRejectedVoucherCodeFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createValidAdultParticipant('[email protected]'),
]);
$bookingDto->participants[0]->voucherCodeError = 'Gutschein ungültig: Gutschein nicht gefunden';
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.voucherCode', $violations->get(0)->getPropertyPath());
$this->assertSame('Gutschein ungültig: Gutschein nicht gefunden', $violations->get(0)->getMessage());
}
public function testAppliedPromoCodeRejectedOnRecheckFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
$this->createValidAdultParticipant('[email protected]'),
]);
$bookingDto->participants[0]->promoVoucherCode = 'R-SOMMER26';
$bookingDto->participants[0]->promoVoucherError = 'Aktionscode ungültig: Mindestpreis nicht erreicht';
$violations = $this->validator->validate(
new ParticipantEditDto(participant: $bookingDto->participants[0], bookingContext: $bookingDto),
null,
['booking_create']
);
$this->assertCount(1, $violations);
$this->assertSame('participant.voucherCode', $violations->get(0)->getPropertyPath());
}
public function testAdultWithDuplicateEmailFailsValidation(): void
{
$bookingDto = $this->createBookingDtoWithParticipants([
@@ -0,0 +1,237 @@
<?php
declare(strict_types=1);
namespace App\Tests\Form\Service;
use App\BusProNet\Model\PromoVoucher;
use App\BusProNet\Model\PromoVoucherValidationResult;
use App\BusProNet\Model\PurchaseVoucher;
use App\BusProNet\Model\PurchaseVoucherValidationResult;
use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ParticipantVoucherFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherCodeClassifier;
use App\Service\VoucherValidator;
use PHPUnit\Framework\MockObject\Stub;
use PHPUnit\Framework\TestCase;
class ParticipantVoucherFieldHandlerTest extends TestCase
{
private VoucherValidator&Stub $voucherValidator;
private BookingDto $bookingDto;
private ParticipantDto $participant;
protected function setUp(): void
{
$this->voucherValidator = $this->createStub(VoucherValidator::class);
$travel = new Travel();
$travel->id = 12500;
$this->participant = new ParticipantDto();
$this->participant->index = 0;
$this->bookingDto = new BookingDto($travel, 1);
$this->bookingDto->participants = [$this->participant];
}
private function handler(?VoucherValidator $voucherValidator = null): ParticipantVoucherFieldHandler
{
$priceCalculator = $this->createStub(BookingPriceCalculator::class);
$priceCalculator->method('calculateIndividualParticipantPrice')->willReturn(500.0);
return new ParticipantVoucherFieldHandler(
$voucherValidator ?? $this->voucherValidator,
$priceCalculator,
new VoucherCodeClassifier(['LW0705' => '2099-12-31']),
);
}
private static function promoVoucher(string $code): PromoVoucher
{
return new PromoVoucher($code, 'Aktion', PromoVoucher::APPLICABILITY_PER_PERSON, 50.0, 0.0, 0.0);
}
private static function purchaseVoucher(string $code, string $type = PurchaseVoucher::TYPE_PURCHASE): PurchaseVoucher
{
return new PurchaseVoucher('80190', $code, 1, 100.0, $type);
}
public function testRunsOnlyInCreateMode(): void
{
self::assertTrue($this->handler()->shouldProcess([], BookingDto::MODE_CREATE, 0));
self::assertFalse($this->handler()->shouldProcess([], BookingDto::MODE_EDIT, 0));
}
public function testAppliesPrefixedPromoCodeAndClearsTheField(): void
{
$voucher = self::promoVoucher('R-SOMMER26');
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: $voucher));
$this->handler()->processField(['voucherCode' => ' r-sommer26 '], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertSame($voucher, $this->participant->validatedPromoVoucher);
self::assertNull($this->participant->voucherCode);
self::assertNull($this->participant->voucherCodeError);
self::assertNull($this->participant->purchaseVoucherCode);
}
public function testAppliesLegacyPromoCodeAsPromo(): void
{
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::once())->method('validatePromoVoucher')
->with('LW0705', 12500, 500.0)
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: self::promoVoucher('LW0705')));
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => 'lw0705'], $this->bookingDto, 0);
self::assertSame('LW0705', $this->participant->promoVoucherCode);
}
public function testAppliesPurchaseCode(): void
{
$voucher = self::purchaseVoucher('1F7V6PADRZ');
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: true, voucher: $voucher));
$this->handler()->processField(['voucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame($voucher, $this->participant->validatedPurchaseVoucher);
self::assertNull($this->participant->voucherCode);
self::assertFalse($this->participant->hasGoodwillVoucher());
}
public function testAppliesGoodwillCodeAsPurchaseCode(): void
{
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: true, voucher: self::purchaseVoucher('KULANZ', PurchaseVoucher::TYPE_GOODWILL)));
$this->handler()->processField(['voucherCode' => 'KULANZ'], $this->bookingDto, 0);
self::assertSame('KULANZ', $this->participant->purchaseVoucherCode);
self::assertTrue($this->participant->hasGoodwillVoucher());
}
public function testRejectedCodeStaysInTheFieldWithBusProsReason(): void
{
$this->voucherValidator->method('validatePurchaseVoucher')
->willReturn(new PurchaseVoucherValidationResult(isValid: false, errorMessage: 'Gutschein nicht gefunden'));
$this->handler()->processField(['voucherCode' => 'abcdef'], $this->bookingDto, 0);
self::assertSame('abcdef', $this->participant->voucherCode);
self::assertSame('Gutschein ungültig: Gutschein nicht gefunden', $this->participant->voucherCodeError);
self::assertNull($this->participant->purchaseVoucherCode);
}
public function testMalformedCodeIsRejectedWithoutAskingBusPro(): void
{
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$voucherValidator->expects(self::never())->method('validatePromoVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => 'X-123'], $this->bookingDto, 0);
self::assertSame('X-123', $this->participant->voucherCode);
self::assertNotNull($this->participant->voucherCodeError);
}
public function testCodeAlreadyAppliedIsRejected(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertSame('Dieser Code ist bereits eingelöst.', $this->participant->voucherCodeError);
}
public function testSecondPurchaseCodeIsRejectedAndTheFirstKept(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$voucherValidator = $this->createMock(VoucherValidator::class);
$voucherValidator->expects(self::never())->method('validatePurchaseVoucher');
$this->handler($voucherValidator)->processField(['voucherCode' => '1VDHQWPZHK'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame('1VDHQWPZHK', $this->participant->voucherCode);
self::assertStringContainsString('1F7V6PADRZ', (string) $this->participant->voucherCodeError);
}
public function testSecondPromoCodeIsRejectedAndTheFirstKept(): void
{
$applied = self::promoVoucher('R-SOMMER26');
$this->participant->promoVoucherCode = 'R-SOMMER26';
$this->participant->validatedPromoVoucher = $applied;
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: $applied));
$this->handler()->processField(['voucherCode' => 'R-WINTER26'], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertSame($applied, $this->participant->validatedPromoVoucher);
self::assertStringContainsString('R-SOMMER26', (string) $this->participant->voucherCodeError);
}
public function testPromoAndPurchaseCodeCanBeCombined(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: true, voucher: self::promoVoucher('R-SOMMER26')));
$this->handler()->processField(['voucherCode' => 'R-SOMMER26'], $this->bookingDto, 0);
self::assertSame('1F7V6PADRZ', $this->participant->purchaseVoucherCode);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertNull($this->participant->voucherCodeError);
}
public function testRemovesAnAppliedCode(): void
{
$this->participant->purchaseVoucherCode = '1F7V6PADRZ';
$this->participant->validatedPurchaseVoucher = self::purchaseVoucher('1F7V6PADRZ');
$this->participant->removeVoucherCode = '1F7V6PADRZ';
$this->handler()->processField(['voucherCode' => '', 'removeVoucherCode' => '1f7v6padrz'], $this->bookingDto, 0);
self::assertNull($this->participant->purchaseVoucherCode);
self::assertNull($this->participant->validatedPurchaseVoucher);
self::assertNull($this->participant->removeVoucherCode);
}
public function testAppliedPromoCodeRejectedOnRecheckKeepsTheCodeWithItsReason(): void
{
$this->participant->promoVoucherCode = 'R-SOMMER26';
$this->participant->validatedPromoVoucher = self::promoVoucher('R-SOMMER26');
$this->voucherValidator->method('validatePromoVoucher')
->willReturn(new PromoVoucherValidationResult(isValid: false, errorMessage: 'Mindestpreis nicht erreicht'));
$this->handler()->processField([], $this->bookingDto, 0);
self::assertSame('R-SOMMER26', $this->participant->promoVoucherCode);
self::assertNull($this->participant->validatedPromoVoucher);
self::assertSame('Aktionscode ungültig: Mindestpreis nicht erreicht', $this->participant->promoVoucherError);
}
public function testCodeLeftFromAnEarlierRequestIsDroppedWhenTheFieldIsNotSubmitted(): void
{
$this->participant->voucherCode = 'ABCDEF';
$this->participant->voucherCodeError = 'Gutschein ungültig: Gutschein nicht gefunden';
$this->handler()->processField([], $this->bookingDto, 0);
self::assertNull($this->participant->voucherCode);
self::assertNull($this->participant->voucherCodeError);
}
}
@@ -15,12 +15,8 @@ use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\ServiceAgeEvaluator;
use App\Service\BookingEditPreFlightChecker;
use App\Service\BookingPriceCalculator;
use App\Service\ParticipantEligibilityChecker;
use App\Service\VoucherValidator;
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
use App\Validator\Constraints\PromoVoucherValidator;
use App\Validator\Constraints\PurchaseVoucherValidator;
use App\Validator\Constraints\SkiPassSelectionValidator;
use Carbon\CarbonImmutable;
use PHPUnit\Framework\TestCase;
@@ -248,17 +244,13 @@ class BookingEditPreFlightCheckerTest extends TestCase
private function createValidator(): ValidatorInterface
{
$voucherValidator = $this->createStub(VoucherValidator::class);
$bookingPriceCalculator = $this->createStub(BookingPriceCalculator::class);
$participantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
$participantEligibilityChecker
->method('isParticipantEligible')
->willReturn(false);
$validatorFactory = new class($voucherValidator, $bookingPriceCalculator, $participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
$validatorFactory = new class($participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidator $voucherValidator,
private readonly BookingPriceCalculator $bookingPriceCalculator,
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
) {
@@ -272,14 +264,6 @@ class BookingEditPreFlightCheckerTest extends TestCase
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
}
if (PurchaseVoucherValidator::class === $className) {
return new PurchaseVoucherValidator($this->voucherValidator);
}
if (PromoVoucherValidator::class === $className) {
return new PromoVoucherValidator($this->voucherValidator, $this->bookingPriceCalculator);
}
if (MandatoryAdditionalServicesSelectedValidator::class === $className) {
return new MandatoryAdditionalServicesSelectedValidator(
$this->participantEligibilityChecker,
@@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Enum\VoucherCodeKind;
use App\Service\VoucherCodeClassifier;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
class VoucherCodeClassifierTest extends TestCase
{
private VoucherCodeClassifier $classifier;
protected function setUp(): void
{
$this->classifier = new VoucherCodeClassifier(['lw0705' => '2027-04-30']);
}
/**
* @return iterable<string, array{string, VoucherCodeKind}>
*/
public static function codes(): iterable
{
yield 'prefixed promo' => ['R-SOMMER26', VoucherCodeKind::Promo];
yield 'prefixed promo in lowercase' => ['r-sommer26', VoucherCodeKind::Promo];
yield 'legacy promo, matched case-insensitively' => ['Lw0705', VoucherCodeKind::Promo];
yield 'purchase code' => ['1F7V6PADRZ', VoucherCodeKind::Purchase];
yield 'purchase code in lowercase' => ['1f7v6padrz', VoucherCodeKind::Purchase];
yield 'surrounding whitespace' => [' 1F7V6PADRZ ', VoucherCodeKind::Purchase];
yield 'hyphen without the promo prefix' => ['X-1F7V6PADRZ', VoucherCodeKind::Malformed];
yield 'characters outside the code alphabet' => ['1F7V 6PADRZ', VoucherCodeKind::Malformed];
yield 'empty' => [' ', VoucherCodeKind::Malformed];
yield 'too long' => [str_repeat('A', 51), VoucherCodeKind::Malformed];
}
#[DataProvider('codes')]
public function testClassify(string $code, VoucherCodeKind $expected): void
{
self::assertSame($expected, $this->classifier->classify($code, new \DateTimeImmutable('2026-10-06')));
}
public function testLegacyCodeIsRecognisedOnItsLastDay(): void
{
self::assertSame(VoucherCodeKind::Promo, $this->classifier->classify('LW0705', new \DateTimeImmutable('2027-04-30')));
}
public function testLegacyCodeIsAPurchaseCodeOnceExpired(): void
{
self::assertSame(VoucherCodeKind::Purchase, $this->classifier->classify('LW0705', new \DateTimeImmutable('2027-05-01')));
}
public function testNormalizeTrimsAndUppercases(): void
{
self::assertSame('R-SOMMER26', $this->classifier->normalize(' r-Sommer26 '));
}
}