feat: fold purchase and promo voucher fields into one code field
This commit is contained in:
1 parent
93512b8345
commit
21d756a2e3
21 files changed
+801
-432
No files matched your search
+12
-4
@@ -16,6 +16,12 @@ parameters:
|
|||||||
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
default_email_to: '%env(APP_DEFAULT_EMAIL_TO)%'
|
||||||
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
accommodation_inquiry_email: '%env(ACCOMMODATION_INQUIRY_EMAIL)%'
|
||||||
role_nomination_notification_emails: '%env(csv:APP_ROLE_NOMINATION_EMAILS)%'
|
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 list ids and their labels
|
||||||
mailjet_lists:
|
mailjet_lists:
|
||||||
@@ -242,6 +248,10 @@ services:
|
|||||||
$cache: '@cache.app'
|
$cache: '@cache.app'
|
||||||
$logger: '@monolog.logger.bpn'
|
$logger: '@monolog.logger.bpn'
|
||||||
|
|
||||||
|
App\Service\VoucherCodeClassifier:
|
||||||
|
arguments:
|
||||||
|
$legacyPromoCodes: '%legacy_promo_voucher_codes%'
|
||||||
|
|
||||||
# Voucher purchase flow (/gutschein)
|
# Voucher purchase flow (/gutschein)
|
||||||
App\Service\VoucherTemplateProvider:
|
App\Service\VoucherTemplateProvider:
|
||||||
arguments:
|
arguments:
|
||||||
@@ -265,8 +275,7 @@ services:
|
|||||||
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
App\Form\Service\ParticipantInsuranceFieldHandler: ~
|
||||||
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~
|
||||||
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
|
App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~
|
||||||
App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~
|
App\Form\Service\ParticipantVoucherFieldHandler: ~
|
||||||
App\Form\Service\ParticipantPromoVoucherFieldHandler: ~
|
|
||||||
|
|
||||||
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
|
# Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services
|
||||||
App\Form\Service\ParticipantFieldHandlerRegistry:
|
App\Form\Service\ParticipantFieldHandlerRegistry:
|
||||||
@@ -292,8 +301,7 @@ services:
|
|||||||
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
|
- '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
|
- '@App\Form\Service\ParticipantBulkInsuranceFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
- '@App\Form\Service\ParticipantInsuranceFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler'
|
- '@App\Form\Service\ParticipantVoucherFieldHandler'
|
||||||
- '@App\Form\Service\ParticipantPromoVoucherFieldHandler'
|
|
||||||
- '@App\Form\Service\ParticipantParkingFieldHandler'
|
- '@App\Form\Service\ParticipantParkingFieldHandler'
|
||||||
|
|
||||||
# Booking Status Rules
|
# Booking Status Rules
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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\CheckboxType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
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\TextareaType;
|
||||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||||
use Symfony\Component\Form\FormBuilderInterface;
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
@@ -334,8 +335,8 @@ class BookingParticipantType extends AbstractType
|
|||||||
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
|
||||||
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
|
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
|
||||||
$dynamicFields['insurance'] = ChoiceType::class;
|
$dynamicFields['insurance'] = ChoiceType::class;
|
||||||
$dynamicFields['purchaseVoucherCode'] = TextType::class;
|
$dynamicFields['voucherCode'] = TextType::class;
|
||||||
$dynamicFields['promoVoucherCode'] = TextType::class;
|
$dynamicFields['removeVoucherCode'] = HiddenType::class;
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($dynamicFields as $fieldName => $fieldType) {
|
foreach ($dynamicFields as $fieldName => $fieldType) {
|
||||||
|
|||||||
@@ -48,8 +48,8 @@ class ParticipantDto
|
|||||||
'licensePlate',
|
'licensePlate',
|
||||||
'bulkInsuranceBooking',
|
'bulkInsuranceBooking',
|
||||||
'insurance',
|
'insurance',
|
||||||
'purchaseVoucherCode',
|
'voucherCode',
|
||||||
'promoVoucherCode',
|
'removeVoucherCode',
|
||||||
];
|
];
|
||||||
|
|
||||||
public ?int $index = null;
|
public ?int $index = null;
|
||||||
@@ -163,47 +163,54 @@ class ParticipantDto
|
|||||||
public bool $bulkInsuranceBooking = false;
|
public bool $bulkInsuranceBooking = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Purchase voucher redemption code.
|
* The voucher field as typed: a purchase or a promo code, told apart by VoucherCodeClassifier.
|
||||||
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload.
|
* 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;
|
public ?string $purchaseVoucherCode = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated purchase voucher from API.
|
* 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).
|
* Contains voucher number, remaining balance, and type (Kauf/Kulanz).
|
||||||
*/
|
*/
|
||||||
public ?PurchaseVoucher $validatedPurchaseVoucher = null;
|
public ?PurchaseVoucher $validatedPurchaseVoucher = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Promo voucher code.
|
* Applied promo voucher code.
|
||||||
* Applied per participant in booking payload as <aktionscode>.
|
* 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;
|
public ?string $promoVoucherCode = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Validated promo voucher from API.
|
* 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).
|
* 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;
|
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
|
* @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.
|
* Checks if this participant has a goodwill (Kulanz) voucher.
|
||||||
*
|
*
|
||||||
* Goodwill vouchers are treated as promotional vouchers in BPN XML
|
* Goodwill vouchers are sent as the participant's <einloesecode>,
|
||||||
* (sent as <aktionscode> per participant, not in <gutscheine> collection).
|
* not in the booking's <gutscheine> collection.
|
||||||
*/
|
*/
|
||||||
public function hasGoodwillVoucher(): bool
|
public function hasGoodwillVoucher(): bool
|
||||||
{
|
{
|
||||||
@@ -425,6 +432,7 @@ class ParticipantDto
|
|||||||
$this->mobile = $this->normalizeNullableString($this->mobile);
|
$this->mobile = $this->normalizeNullableString($this->mobile);
|
||||||
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
|
$this->remarksRoom = $this->normalizeNullableString($this->remarksRoom);
|
||||||
$this->licensePlate = $this->normalizeNullableString($this->licensePlate);
|
$this->licensePlate = $this->normalizeNullableString($this->licensePlate);
|
||||||
|
$this->voucherCode = $this->normalizeNullableString($this->voucherCode);
|
||||||
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
|
$this->purchaseVoucherCode = $this->normalizeNullableString($this->purchaseVoucherCode);
|
||||||
$this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode);
|
$this->promoVoucherCode = $this->normalizeNullableString($this->promoVoucherCode);
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ use function Symfony\Component\String\u;
|
|||||||
* the participant being edited and the full booking context needed for
|
* the participant being edited and the full booking context needed for
|
||||||
* cross-participant validation.
|
* 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\MandatoryAdditionalServicesSelected(groups: ['booking_create', 'booking_edit'])]
|
||||||
#[AppAssert\SkiPassSelection(groups: ['strict_required'])]
|
#[AppAssert\SkiPassSelection(groups: ['strict_required'])]
|
||||||
class ParticipantEditDto
|
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.
|
* 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
|
// Vouchers cannot be redeemed or submitted for inquiry bookings
|
||||||
$finalBookingOnlyCondition = new FinalBookingOnlyCondition();
|
$finalBookingOnlyCondition = new FinalBookingOnlyCondition();
|
||||||
|
|
||||||
$this->fieldStateConditions['purchaseVoucherCode'] = [
|
$this->fieldStateConditions['voucherCode'] = [
|
||||||
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
|
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->fieldStateConditions['promoVoucherCode'] = [
|
$this->fieldStateConditions['removeVoucherCode'] = [
|
||||||
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
|
'hidden' => CompositeCondition::not($finalBookingOnlyCondition),
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -263,6 +263,9 @@ class ParticipantFieldHandlerRegistry
|
|||||||
'dropOff',
|
'dropOff',
|
||||||
'parking',
|
'parking',
|
||||||
'licensePlate',
|
'licensePlate',
|
||||||
|
// Cleared by the voucher handler once applied or removed; must not be bound again
|
||||||
|
'voucherCode',
|
||||||
|
'removeVoucherCode',
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($serviceFields as $fieldName) {
|
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
|
// Single voucher field - purchase (Kauf/Kulanz) or promo code, told apart by VoucherCodeClassifier.
|
||||||
// Collected from all participants and aggregated into single <gutscheine> collection in booking payload
|
// Applied by ParticipantVoucherFieldHandler, which moves accepted codes to purchaseVoucherCode/promoVoucherCode
|
||||||
$this->fieldOptionProviders['purchaseVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
$this->fieldOptionProviders['voucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||||
'label' => 'Gutschein-Code',
|
'label' => 'Gutschein- oder Aktionscode',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'attr' => [
|
'attr' => [
|
||||||
'placeholder' => 'z.B. 1F7V6PADRZ',
|
'placeholder' => 'z.B. 1F7V6PADRZ oder R-SOMMER',
|
||||||
'maxlength' => 50,
|
'maxlength' => 50,
|
||||||
|
'autocomplete' => 'off',
|
||||||
],
|
],
|
||||||
'sanitize_html' => true,
|
'sanitize_html' => true,
|
||||||
];
|
];
|
||||||
|
|
||||||
// Promo voucher field provider - promo code applied per participant in booking payload
|
// Applied voucher code to remove, set by the remove button of its chip
|
||||||
// Included in participant XML node as <aktionscode>
|
$this->fieldOptionProviders['removeVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
||||||
$this->fieldOptionProviders['promoVoucherCode'] = fn (BookingDto $bookingDto, int $participantIndex, array $options = []) => [
|
|
||||||
'label' => 'Rabatt-Code',
|
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'attr' => [
|
|
||||||
'placeholder' => 'z.B. LW0705',
|
|
||||||
'maxlength' => 50,
|
|
||||||
],
|
|
||||||
'sanitize_html' => true,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// Future field providers would be added here, for example:
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -684,19 +684,84 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{# Voucher fields - available for all participants regardless of eligibility #}
|
{# Voucher field - available for all participants regardless of eligibility #}
|
||||||
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
|
{% 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">
|
<h3 class="font-semibold text-lg mb-4">
|
||||||
Gutscheine
|
Gutscheine
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid lg:grid-cols-2 lg:gap-x-4">
|
<div class="mb-4">
|
||||||
{% if form.purchaseVoucherCode is defined %}
|
{{ form_label(form.voucherCode) }}
|
||||||
{{ form_row(form.purchaseVoucherCode) }}
|
<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 %}
|
{% 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 %}
|
{% endif %}
|
||||||
</div>
|
</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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ use App\Form\Model\BookingDto;
|
|||||||
use App\Form\Model\ParticipantDto;
|
use App\Form\Model\ParticipantDto;
|
||||||
use App\Form\Model\ParticipantEditDto;
|
use App\Form\Model\ParticipantEditDto;
|
||||||
use App\Form\Service\ServiceAgeEvaluator;
|
use App\Form\Service\ServiceAgeEvaluator;
|
||||||
use App\Service\BookingPriceCalculator;
|
|
||||||
use App\Service\ParticipantEligibilityChecker;
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
use App\Service\ServiceAvailabilityCalculator;
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
use App\Service\VoucherValidator;
|
|
||||||
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
|
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
|
||||||
use App\Validator\Constraints\PromoVoucherValidator;
|
|
||||||
use App\Validator\Constraints\PurchaseVoucherValidator;
|
|
||||||
use App\Validator\Constraints\SkiPassSelectionValidator;
|
use App\Validator\Constraints\SkiPassSelectionValidator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Symfony\Component\Validator\Constraint;
|
use Symfony\Component\Validator\Constraint;
|
||||||
@@ -48,8 +44,6 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
// Create mock services for validator dependencies
|
// Create mock services for validator dependencies
|
||||||
$mockVoucherService = $this->createStub(VoucherValidator::class);
|
|
||||||
$mockPriceCalculatorService = $this->createStub(BookingPriceCalculator::class);
|
|
||||||
$mockParticipantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
|
$mockParticipantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
|
||||||
$mockServiceAgeEvaluator = $this->createStub(ServiceAgeEvaluator::class);
|
$mockServiceAgeEvaluator = $this->createStub(ServiceAgeEvaluator::class);
|
||||||
|
|
||||||
@@ -61,10 +55,8 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Create custom validator factory that can inject dependencies
|
// 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(
|
public function __construct(
|
||||||
private readonly VoucherValidator $voucherService,
|
|
||||||
private readonly BookingPriceCalculator $priceCalculatorService,
|
|
||||||
private readonly ParticipantEligibilityChecker $participantEligibilityService,
|
private readonly ParticipantEligibilityChecker $participantEligibilityService,
|
||||||
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
|
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
|
||||||
private readonly ParticipantEligibilityChecker $realEligibilityChecker,
|
private readonly ParticipantEligibilityChecker $realEligibilityChecker,
|
||||||
@@ -75,14 +67,6 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
{
|
{
|
||||||
$className = $constraint->validatedBy();
|
$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) {
|
if (EmailValidator::class === $className) {
|
||||||
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
|
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
|
||||||
}
|
}
|
||||||
@@ -125,6 +109,42 @@ class ParticipantEditDtoTest extends TestCase
|
|||||||
$this->assertCount(0, $violations);
|
$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
|
public function testAdultWithDuplicateEmailFailsValidation(): void
|
||||||
{
|
{
|
||||||
$bookingDto = $this->createBookingDtoWithParticipants([
|
$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\Model\ParticipantDto;
|
||||||
use App\Form\Service\ServiceAgeEvaluator;
|
use App\Form\Service\ServiceAgeEvaluator;
|
||||||
use App\Service\BookingEditPreFlightChecker;
|
use App\Service\BookingEditPreFlightChecker;
|
||||||
use App\Service\BookingPriceCalculator;
|
|
||||||
use App\Service\ParticipantEligibilityChecker;
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
use App\Service\VoucherValidator;
|
|
||||||
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
|
use App\Validator\Constraints\MandatoryAdditionalServicesSelectedValidator;
|
||||||
use App\Validator\Constraints\PromoVoucherValidator;
|
|
||||||
use App\Validator\Constraints\PurchaseVoucherValidator;
|
|
||||||
use App\Validator\Constraints\SkiPassSelectionValidator;
|
use App\Validator\Constraints\SkiPassSelectionValidator;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
@@ -248,17 +244,13 @@ class BookingEditPreFlightCheckerTest extends TestCase
|
|||||||
|
|
||||||
private function createValidator(): ValidatorInterface
|
private function createValidator(): ValidatorInterface
|
||||||
{
|
{
|
||||||
$voucherValidator = $this->createStub(VoucherValidator::class);
|
|
||||||
$bookingPriceCalculator = $this->createStub(BookingPriceCalculator::class);
|
|
||||||
$participantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
|
$participantEligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class);
|
||||||
$participantEligibilityChecker
|
$participantEligibilityChecker
|
||||||
->method('isParticipantEligible')
|
->method('isParticipantEligible')
|
||||||
->willReturn(false);
|
->willReturn(false);
|
||||||
|
|
||||||
$validatorFactory = new class($voucherValidator, $bookingPriceCalculator, $participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
|
$validatorFactory = new class($participantEligibilityChecker, new ServiceAgeEvaluator()) implements ConstraintValidatorFactoryInterface {
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly VoucherValidator $voucherValidator,
|
|
||||||
private readonly BookingPriceCalculator $bookingPriceCalculator,
|
|
||||||
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
private readonly ParticipantEligibilityChecker $participantEligibilityChecker,
|
||||||
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
|
private readonly ServiceAgeEvaluator $serviceAgeEvaluator,
|
||||||
) {
|
) {
|
||||||
@@ -272,14 +264,6 @@ class BookingEditPreFlightCheckerTest extends TestCase
|
|||||||
return new EmailValidator(Email::VALIDATION_MODE_HTML5);
|
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) {
|
if (MandatoryAdditionalServicesSelectedValidator::class === $className) {
|
||||||
return new MandatoryAdditionalServicesSelectedValidator(
|
return new MandatoryAdditionalServicesSelectedValidator(
|
||||||
$this->participantEligibilityChecker,
|
$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 '));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user