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