feat: promotional and purchase vouchers

This commit is contained in:
Björn Fromme
2025-11-20 18:41:46 +01:00
parent 26681e5c62
commit aaa7e9f4c3
21 changed files with 847 additions and 6 deletions
+3 -1
View File
@@ -312,10 +312,12 @@ class BookingParticipantType extends AbstractType
'licensePlate' => TextType::class,
];
// Insurance fields only available in create mode (API limitation)
// Insurance and voucher fields only available in create mode
if (BookingDto::MODE_CREATE === $bookingDto->getMode()) {
$dynamicFields['bulkInsuranceBooking'] = CheckboxType::class;
$dynamicFields['insurance'] = ChoiceType::class;
$dynamicFields['purchaseVoucherCode'] = TextType::class;
$dynamicFields['promoVoucherCode'] = TextType::class;
}
foreach ($dynamicFields as $fieldName => $fieldType) {
+30
View File
@@ -33,6 +33,8 @@ class ParticipantDto
'licensePlate',
'bulkInsuranceBooking',
'insurance',
'purchaseVoucherCode',
'promoVoucherCode',
];
public ?int $index = null;
@@ -111,6 +113,34 @@ class ParticipantDto
// Bulk insurance booking flag (applicant only: when checked, assigns same insurance type to all participants)
public bool $bulkInsuranceBooking = false;
/**
* Purchase voucher redemption code.
* Collected from all participants and aggregated into single <gutscheine> collection in booking payload.
*/
#[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;
/**
* 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;
/**
* @var array<array{type: string, message: string}> Notification messages for user feedback
*/
@@ -267,6 +267,10 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider
'static_text' => new SingleRoomTypeCondition(),
];
// Voucher fields (purchaseVoucherCode, promoVoucherCode) have no state conditions
// They are enabled by default for all participants in create mode
// No visibility rules - always shown to allow optional voucher entry
// Example field state conditions would be registered here
// For demonstration purposes, here are some example patterns:
@@ -504,6 +504,30 @@ 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-Einlösecode',
'required' => false,
'attr' => [
'placeholder' => 'z.B. 1F7V6PADRZ',
'maxlength' => 50,
],
'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' => 'Aktionscode',
'required' => false,
'attr' => [
'placeholder' => 'z.B. LW0705',
'maxlength' => 50,
],
'sanitize_html' => true,
];
// Future field providers would be added here, for example:
//
// $this->fieldOptionProviders['mealPreference'] = fn($bookingDto, $participantIndex) => [
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculatorService;
use App\Service\VoucherValidationService;
/**
* Handles processing and validation of promotional voucher codes for booking participants.
*
* This handler validates promo vouchers via the BPN API and provides immediate feedback
* about voucher validity, discount amounts, and applicability. Promo vouchers can be
* applied per person or per booking, using absolute discount amounts (never percentages).
*
* Validation includes:
* - Code validity check
* - Travel applicability verification
* - Minimum price threshold validation
* - Real-time API validation with 15-minute caching
*
* 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 VoucherValidationService $voucherValidationService,
private readonly BookingPriceCalculatorService $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());
if (null === $promoCode || '' === trim($promoCode)) {
return;
}
// Calculate participant's current price including all services
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice(
$bookingDto,
$participantIndex
);
// Get travel ID for validation
$travelId = $bookingDto->travel->id;
if (null === $travelId) {
$participant->addNotification(
'error',
'Aktionscode konnte nicht validiert werden: Reise-ID fehlt.'
);
return;
}
// Validate promo voucher via API
$result = $this->voucherValidationService->validatePromoVoucher(
$promoCode,
$travelId,
$participantPrice
);
if (true === $result->isValid && null !== $result->voucher) {
$applicability = $result->voucher->isPerPerson() ? 'je Person' : 'pro Buchung';
$participant->addNotification(
'success',
sprintf(
'Aktionscode gültig: %s (Rabatt: %.2f € %s)',
$result->voucher->description,
$result->voucher->discountAmount,
$applicability
)
);
} else {
$participant->addNotification(
'error',
sprintf('Aktionscode ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler')
);
}
}
}
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\VoucherValidationService;
/**
* Handles processing and validation of purchase voucher codes for booking participants.
*
* This handler validates purchase vouchers (including goodwill vouchers) via the BPN API
* and provides immediate feedback to users about voucher validity and remaining balance.
* Purchase vouchers reduce the booking price by their remaining balance and support
* partial redemption.
*
* Validation includes:
* - Voucher existence check
* - Remaining balance verification (zero-balance vouchers are reported as invalid)
* - Real-time API validation with 15-minute caching
*
* Dependencies: None (purchase vouchers don't require price calculation)
*/
class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidationService $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());
if (null === $voucherCode || '' === trim($voucherCode)) {
return;
}
// Validate voucher via API
$result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode);
if (true === $result->isValid && null !== $result->voucher) {
$participant->addNotification(
'success',
sprintf(
'Gutschein gültig: %s (Restwert: %.2f €)',
$result->voucher->type,
$result->voucher->remainingBalance
)
);
} else {
$participant->addNotification(
'error',
sprintf('Gutschein ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler')
);
}
}
}