From 21d756a2e39a63cab1e04e616af3bac81df93e9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Tue, 6 Oct 2026 10:41:30 +0200 Subject: [PATCH] feat: fold purchase and promo voucher fields into one code field --- config/services.yaml | 16 +- src/Enum/VoucherCodeKind.php | 22 ++ src/Form/BookingParticipantType.php | 5 +- src/Form/Model/ParticipantDto.php | 58 +++-- src/Form/Model/ParticipantEditDto.php | 25 +- src/Form/Service/CreateFieldStateProvider.php | 4 +- .../ParticipantFieldHandlerRegistry.php | 3 + .../ParticipantFieldOptionsProvider.php | 22 +- .../ParticipantPromoVoucherFieldHandler.php | 93 ------- ...ParticipantPurchaseVoucherFieldHandler.php | 68 ----- .../ParticipantVoucherFieldHandler.php | 221 ++++++++++++++++ src/Service/VoucherCodeClassifier.php | 69 +++++ src/Validator/Constraints/PromoVoucher.php | 25 -- .../Constraints/PromoVoucherValidator.php | 78 ------ src/Validator/Constraints/PurchaseVoucher.php | 24 -- .../Constraints/PurchaseVoucherValidator.php | 54 ---- templates/booking/_participant_form.html.twig | 79 +++++- tests/Form/Model/ParticipantEditDtoTest.php | 54 ++-- .../ParticipantVoucherFieldHandlerTest.php | 237 ++++++++++++++++++ .../BookingEditPreFlightCheckerTest.php | 18 +- tests/Service/VoucherCodeClassifierTest.php | 58 +++++ 21 files changed, 801 insertions(+), 432 deletions(-) create mode 100644 src/Enum/VoucherCodeKind.php delete mode 100644 src/Form/Service/ParticipantPromoVoucherFieldHandler.php delete mode 100644 src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php create mode 100644 src/Form/Service/ParticipantVoucherFieldHandler.php create mode 100644 src/Service/VoucherCodeClassifier.php delete mode 100644 src/Validator/Constraints/PromoVoucher.php delete mode 100644 src/Validator/Constraints/PromoVoucherValidator.php delete mode 100644 src/Validator/Constraints/PurchaseVoucher.php delete mode 100644 src/Validator/Constraints/PurchaseVoucherValidator.php create mode 100644 tests/Form/Service/ParticipantVoucherFieldHandlerTest.php create mode 100644 tests/Service/VoucherCodeClassifierTest.php diff --git a/config/services.yaml b/config/services.yaml index 95a75fb5..f56a1980 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -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 diff --git a/src/Enum/VoucherCodeKind.php b/src/Enum/VoucherCodeKind.php new file mode 100644 index 00000000..a10e50ad --- /dev/null +++ b/src/Enum/VoucherCodeKind.php @@ -0,0 +1,22 @@ + + 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'; +} diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index 6b16bdc0..994953e4 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -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) { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 92ddbbb3..f0f83c54 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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 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 , a Kulanz code is sent as the participant's . */ - #[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 . */ - #[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 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 per participant, not in collection). + * Goodwill vouchers are sent as the participant's , + * not in the booking's 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); diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 26e273f8..23bdc4a3 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -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. * diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 29f4fbc5..f74b36eb 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -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), ]; diff --git a/src/Form/Service/ParticipantFieldHandlerRegistry.php b/src/Form/Service/ParticipantFieldHandlerRegistry.php index d375b2b6..88a561fa 100644 --- a/src/Form/Service/ParticipantFieldHandlerRegistry.php +++ b/src/Form/Service/ParticipantFieldHandlerRegistry.php @@ -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) { diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index b1030bc9..12b6b95d 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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 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 - $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: diff --git a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php deleted file mode 100644 index 5c55c21b..00000000 --- a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php +++ /dev/null @@ -1,93 +0,0 @@ -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; - } -} diff --git a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php deleted file mode 100644 index 51686648..00000000 --- a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php +++ /dev/null @@ -1,68 +0,0 @@ - per participant, not aggregated - * in 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; - } -} diff --git a/src/Form/Service/ParticipantVoucherFieldHandler.php b/src/Form/Service/ParticipantVoucherFieldHandler.php new file mode 100644 index 00000000..5f164d7b --- /dev/null +++ b/src/Form/Service/ParticipantVoucherFieldHandler.php @@ -0,0 +1,221 @@ +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 $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 $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; + } +} diff --git a/src/Service/VoucherCodeClassifier.php b/src/Service/VoucherCodeClassifier.php new file mode 100644 index 00000000..e0d79472 --- /dev/null +++ b/src/Service/VoucherCodeClassifier.php @@ -0,0 +1,69 @@ + Normalised code => last day the code is recognised */ + private readonly array $legacyPromoCodes; + + /** + * @param array $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; + } +} diff --git a/src/Validator/Constraints/PromoVoucher.php b/src/Validator/Constraints/PromoVoucher.php deleted file mode 100644 index 0fca22cf..00000000 --- a/src/Validator/Constraints/PromoVoucher.php +++ /dev/null @@ -1,25 +0,0 @@ -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(); - } - } -} diff --git a/src/Validator/Constraints/PurchaseVoucher.php b/src/Validator/Constraints/PurchaseVoucher.php deleted file mode 100644 index 1fdf705e..00000000 --- a/src/Validator/Constraints/PurchaseVoucher.php +++ /dev/null @@ -1,24 +0,0 @@ -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(); - } - } -} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 2619f6f5..111bd7f2 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -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' + } %}

Gutscheine

-
- {% if form.purchaseVoucherCode is defined %} - {{ form_row(form.purchaseVoucherCode) }} +
+ {{ form_label(form.voucherCode) }} +
+
+ {{ form_widget(form.voucherCode) }} +
+ +
+ {# 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 %} +
    +
  • {{ voucherParticipant.voucherCodeError }}
  • +
{% 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 %} +
    + {% for voucher in appliedVouchers %} +
  • + + {{ voucher.label }} {{ voucher.code }}{% if voucher.value %}: {{ voucher.value }}{% endif %} + {% if voucher.error %} + {{ voucher.error }} + {% endif %} + + +
  • + {% endfor %} +
{% endif %}
+ {# 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 %}
{% endblock %} diff --git a/tests/Form/Model/ParticipantEditDtoTest.php b/tests/Form/Model/ParticipantEditDtoTest.php index 48e30348..c2d27f75 100644 --- a/tests/Form/Model/ParticipantEditDtoTest.php +++ b/tests/Form/Model/ParticipantEditDtoTest.php @@ -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('unique1@example.com'), + ]); + $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('unique1@example.com'), + ]); + $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([ diff --git a/tests/Form/Service/ParticipantVoucherFieldHandlerTest.php b/tests/Form/Service/ParticipantVoucherFieldHandlerTest.php new file mode 100644 index 00000000..02a95e0e --- /dev/null +++ b/tests/Form/Service/ParticipantVoucherFieldHandlerTest.php @@ -0,0 +1,237 @@ +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); + } +} diff --git a/tests/Service/BookingEditPreFlightCheckerTest.php b/tests/Service/BookingEditPreFlightCheckerTest.php index 626b0f7a..a4a4714b 100644 --- a/tests/Service/BookingEditPreFlightCheckerTest.php +++ b/tests/Service/BookingEditPreFlightCheckerTest.php @@ -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, diff --git a/tests/Service/VoucherCodeClassifierTest.php b/tests/Service/VoucherCodeClassifierTest.php new file mode 100644 index 00000000..19d95803 --- /dev/null +++ b/tests/Service/VoucherCodeClassifierTest.php @@ -0,0 +1,58 @@ +classifier = new VoucherCodeClassifier(['lw0705' => '2027-04-30']); + } + + /** + * @return iterable + */ + 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 ')); + } +}