feat: improved promotional and purchase vouchers
This commit is contained in:
@@ -688,9 +688,11 @@ class BookingDataProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Add promo voucher if present
|
||||
if (null !== $dto->promoVoucherCode && '' !== trim($dto->promoVoucherCode)) {
|
||||
$participantPayload['aktionscode'] = trim($dto->promoVoucherCode);
|
||||
// Add promo voucher or goodwill voucher as aktionscode
|
||||
// Goodwill vouchers (Kulanz) take precedence over promo vouchers
|
||||
$aktionscode = $this->getPromoVoucherCodeForParticipant($dto);
|
||||
if (null !== $aktionscode) {
|
||||
$participantPayload['aktionscode'] = $aktionscode;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,9 +885,11 @@ class BookingDataProcessor
|
||||
}
|
||||
}
|
||||
|
||||
// Add promo voucher if present
|
||||
if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) {
|
||||
$participantData['aktionscode'] = trim($participant->promoVoucherCode);
|
||||
// Add promo voucher or goodwill voucher as aktionscode
|
||||
// Goodwill vouchers (Kulanz) take precedence over promo vouchers
|
||||
$aktionscode = $this->getPromoVoucherCodeForParticipant($participant);
|
||||
if (null !== $aktionscode) {
|
||||
$participantData['aktionscode'] = $aktionscode;
|
||||
}
|
||||
|
||||
$payload['teilnehmerliste']['teilnehmer'][] = $participantData;
|
||||
@@ -1169,7 +1173,10 @@ class BookingDataProcessor
|
||||
* Removes duplicates and empty values, returning unique redemption codes
|
||||
* for aggregation into the <gutscheine> collection in the booking payload.
|
||||
*
|
||||
* @return string[] Array of unique redemption codes
|
||||
* IMPORTANT: Goodwill vouchers (Kulanz) are excluded from this collection.
|
||||
* They are added per participant as <aktionscode> instead.
|
||||
*
|
||||
* @return string[] Array of unique redemption codes (excluding Kulanz)
|
||||
*/
|
||||
private function collectPurchaseVouchers(BookingDto $bookingDto): array
|
||||
{
|
||||
@@ -1177,14 +1184,41 @@ class BookingDataProcessor
|
||||
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) {
|
||||
// Exclude goodwill vouchers (flagged by field handler)
|
||||
if (false === $participant->hasGoodwillVoucher) {
|
||||
$vouchers[] = trim($participant->purchaseVoucherCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates (in case multiple participants enter same code)
|
||||
return array_unique($vouchers);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the aktionscode for a participant.
|
||||
*
|
||||
* Checks if the participant has a goodwill (Kulanz) purchase voucher first.
|
||||
* If yes, returns that code (goodwill vouchers override promo vouchers).
|
||||
* Otherwise, returns the promo voucher code if present.
|
||||
*
|
||||
* @return string|null The aktionscode to use, or null if none
|
||||
*/
|
||||
private function getPromoVoucherCodeForParticipant(ParticipantDto $participant): ?string
|
||||
{
|
||||
// Check for goodwill voucher first (takes precedence)
|
||||
if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) {
|
||||
return trim($participant->purchaseVoucherCode);
|
||||
}
|
||||
|
||||
// Fall back to promo voucher if present
|
||||
if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) {
|
||||
return trim($participant->promoVoucherCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies bulk insurance assignment if the applicant has enabled it.
|
||||
*
|
||||
|
||||
@@ -16,6 +16,9 @@ namespace App\BusProNet\Model;
|
||||
*/
|
||||
final readonly class PurchaseVoucher
|
||||
{
|
||||
public const TYPE_PURCHASE = 'Kauf';
|
||||
public const TYPE_GOODWILL = 'Kulanz';
|
||||
|
||||
public function __construct(
|
||||
public string $voucherNumber,
|
||||
public string $redemptionCode,
|
||||
@@ -24,4 +27,25 @@ final readonly class PurchaseVoucher
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this voucher is a purchase voucher.
|
||||
*
|
||||
* Purchase vouchers (Kauf) are aggregated into <gutscheine>.
|
||||
*/
|
||||
public function isPurchase(): bool
|
||||
{
|
||||
return self::TYPE_PURCHASE === $this->type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this voucher is a goodwill voucher.
|
||||
*
|
||||
* Goodwill vouchers (Kulanz) must be treated as promo vouchers and added
|
||||
* per participant as <aktionscode>, not aggregated into <gutscheine>.
|
||||
*/
|
||||
public function isGoodwill(): bool
|
||||
{
|
||||
return self::TYPE_GOODWILL === $this->type;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,10 +150,20 @@ class Step2Controller extends AbstractController
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Collect notifications from field handlers (run during PRE_SUBMIT)
|
||||
$notifications = $this->collectAndClearNotifications($bookingDto);
|
||||
|
||||
if (true === $form->isSubmitted() && true === $form->isValid()) {
|
||||
// Save BookingDto to session
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE);
|
||||
|
||||
// Add notifications as flash messages (redirects destroy HTMX-triggered toasts)
|
||||
if (false === empty($notifications)) {
|
||||
foreach ($notifications as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// HTMX redirect to cards view
|
||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_create_step_2'));
|
||||
}
|
||||
@@ -174,12 +184,21 @@ class Step2Controller extends AbstractController
|
||||
|
||||
// HTMX request: render blocks only with OOB swap
|
||||
if ($this->isHxRequest($request)) {
|
||||
return $this->htmxOobResponse(
|
||||
$response = $this->htmxOobResponse(
|
||||
'booking/_participant_form.html.twig',
|
||||
['participant_form', 'booking_summary'],
|
||||
$templateData,
|
||||
$this->generateUrl('app_booking_create_step_2_participant', ['index' => $index])
|
||||
);
|
||||
|
||||
// Add notifications to render response if present
|
||||
if (false === empty($notifications)) {
|
||||
$response->headers->set('HX-Trigger', json_encode([
|
||||
'showNotifications' => $notifications,
|
||||
]));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Regular request: render full template
|
||||
|
||||
@@ -76,17 +76,22 @@ class Step3Controller extends AbstractController
|
||||
return $this->handleApiError(
|
||||
'Booking inquiry failed',
|
||||
['message' => $inquiryResponse->message],
|
||||
'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
$inquiryResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
}
|
||||
|
||||
if (false === $inquiryResponse->isInquiryValid()) {
|
||||
$errorMessage = 'Buchung konnte nicht validiert werden.';
|
||||
if (null !== $inquiryResponse->message && '' !== trim($inquiryResponse->message)) {
|
||||
$errorMessage .= ' '.$inquiryResponse->message;
|
||||
}
|
||||
|
||||
return $this->handleApiError(
|
||||
'Booking inquiry validation failed',
|
||||
['status' => $inquiryResponse->status],
|
||||
'Buchung konnte nicht validiert werden.',
|
||||
['status' => $inquiryResponse->status, 'message' => $inquiryResponse->message],
|
||||
$errorMessage,
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
|
||||
@@ -76,17 +76,22 @@ class Step4Controller extends AbstractController
|
||||
return $this->handleApiError(
|
||||
'Booking creation failed - API notification',
|
||||
['message' => $bookingResponse->message],
|
||||
$bookingResponse->message,
|
||||
$bookingResponse->message ?? 'Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.',
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
}
|
||||
|
||||
if (false === $bookingResponse->isBookingSuccessful()) {
|
||||
$errorMessage = 'Buchung konnte nicht erstellt werden.';
|
||||
if (null !== $bookingResponse->message && '' !== trim($bookingResponse->message)) {
|
||||
$errorMessage .= ' '.$bookingResponse->message;
|
||||
}
|
||||
|
||||
return $this->handleApiError(
|
||||
'Booking creation unsuccessful',
|
||||
['status' => $bookingResponse->status],
|
||||
'Buchung konnte nicht erstellt werden.',
|
||||
['status' => $bookingResponse->status, 'message' => $bookingResponse->message],
|
||||
$errorMessage,
|
||||
$bookingCreateDto,
|
||||
$form
|
||||
);
|
||||
|
||||
@@ -267,10 +267,20 @@ class IndexController extends AbstractController
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Collect notifications from field handlers (run during PRE_SUBMIT)
|
||||
$notifications = $this->collectAndClearNotifications($bookingDto);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
// Save updated booking data to session
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
|
||||
// Add notifications as flash messages (redirects destroy HTMX-triggered toasts)
|
||||
if (false === empty($notifications)) {
|
||||
foreach ($notifications as $notification) {
|
||||
$this->addFlash($notification['type'], $notification['message']);
|
||||
}
|
||||
}
|
||||
|
||||
// Redirect back to cards
|
||||
return $this->hxRedirect($request, $this->generateUrl('app_booking_edit', ['id' => $id]));
|
||||
}
|
||||
@@ -300,12 +310,21 @@ class IndexController extends AbstractController
|
||||
|
||||
// HTMX request: render blocks only with OOB swap
|
||||
if ($this->isHxRequest($request)) {
|
||||
return $this->htmxOobResponse(
|
||||
$response = $this->htmxOobResponse(
|
||||
'booking/_participant_form.html.twig',
|
||||
['participant_form', 'booking_summary'],
|
||||
$templateData,
|
||||
$this->generateUrl('app_booking_edit_participant', ['id' => $id, 'index' => $index])
|
||||
);
|
||||
|
||||
// Add notifications to render response if present
|
||||
if (false === empty($notifications)) {
|
||||
$response->headers->set('HX-Trigger', json_encode([
|
||||
'showNotifications' => $notifications,
|
||||
]));
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Regular request: render full template
|
||||
@@ -362,18 +381,12 @@ class IndexController extends AbstractController
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Collect notifications from field handlers
|
||||
$notifications = $this->collectAndClearNotifications($bookingDto);
|
||||
|
||||
// Save updated booking data to session
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT);
|
||||
|
||||
// Collect notifications from participant DTO
|
||||
$participant = $bookingDto->participants[$index] ?? null;
|
||||
$notifications = $participant?->notifications ?? [];
|
||||
|
||||
// Clear notifications after collecting
|
||||
if (null !== $participant) {
|
||||
$participant->notifications = [];
|
||||
}
|
||||
|
||||
// Get complete summary data (pricing, rooms, CMS data)
|
||||
$summaryData = $this->summaryDataService->getSummaryData($bookingDto);
|
||||
|
||||
@@ -400,9 +413,9 @@ class IndexController extends AbstractController
|
||||
);
|
||||
|
||||
// Add notifications to HX-Trigger header if present
|
||||
if ([] !== $notifications) {
|
||||
if (false === empty($notifications)) {
|
||||
$response->headers->set('HX-Trigger', json_encode([
|
||||
'showNotifications' => ['notifications' => $notifications],
|
||||
'showNotifications' => $notifications,
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -76,6 +76,34 @@ trait ParticipantCardFlowTrait
|
||||
return $this->createForm(BookingParticipantType::class, $wrapper, $formOptions);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collects notifications from all participants and clears them from DTOs.
|
||||
*
|
||||
* This is important for scenarios where field handlers add notifications
|
||||
* during form processing (e.g., voucher validation, auto-unassignment).
|
||||
* Notifications from ALL participants are collected, not just the current one,
|
||||
* because cross-participant logic may add notifications to multiple participants.
|
||||
*
|
||||
* Note: ParticipantDto stores notifications with MD5 keys to prevent duplicates,
|
||||
* but the JavaScript toast controller expects a simple indexed array. We use
|
||||
* array_values() to convert the associative array to an indexed array.
|
||||
*
|
||||
* @return array<array{type: string, message: string}> Array of notification messages
|
||||
*/
|
||||
private function collectAndClearNotifications(BookingDto $bookingDto): array
|
||||
{
|
||||
$notifications = [];
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
if (false === empty($participant->notifications)) {
|
||||
$notifications = array_merge($notifications, $participant->notifications);
|
||||
$participant->notifications = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Convert MD5-keyed associative array to simple indexed array
|
||||
return array_values($notifications);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process single participant form refresh.
|
||||
*
|
||||
@@ -95,16 +123,8 @@ trait ParticipantCardFlowTrait
|
||||
|
||||
$form->handleRequest($request);
|
||||
|
||||
// Collect notifications from ALL participants (not just current one)
|
||||
// This is important for auto-unassignment scenarios where other participants
|
||||
// may receive notifications when the current participant takes an action
|
||||
$notifications = [];
|
||||
foreach ($bookingDto->participants as $participant) {
|
||||
if (false === empty($participant->notifications)) {
|
||||
$notifications = array_merge($notifications, $participant->notifications);
|
||||
$participant->notifications = [];
|
||||
}
|
||||
}
|
||||
// Collect notifications from field handlers
|
||||
$notifications = $this->collectAndClearNotifications($bookingDto);
|
||||
|
||||
// Save updated booking data to session (after collecting & clearing notifications)
|
||||
$this->bookingService->saveBookingDto($request, $bookingDto, $bookingDto->getMode());
|
||||
|
||||
@@ -127,6 +127,15 @@ class ParticipantDto
|
||||
)]
|
||||
public ?string $purchaseVoucherCode = null;
|
||||
|
||||
/**
|
||||
* Flag indicating if the purchase voucher is a goodwill (Kulanz) voucher.
|
||||
*
|
||||
* Goodwill vouchers are treated as promotional vouchers in BPN XML
|
||||
* (sent as <aktionscode> per participant, not in <gutscheine> collection).
|
||||
* Set by ParticipantPurchaseVoucherFieldHandler during form processing.
|
||||
*/
|
||||
public bool $hasGoodwillVoucher = false;
|
||||
|
||||
/**
|
||||
* Promo voucher code.
|
||||
* Applied per participant in booking payload as <aktionscode>.
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
use App\Validator\Constraints as AppAssert;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
|
||||
@@ -15,6 +16,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
|
||||
* 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'])]
|
||||
class ParticipantEditDto
|
||||
{
|
||||
public function __construct(
|
||||
|
||||
@@ -6,33 +6,19 @@ 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.
|
||||
* Handles promotional voucher code field processing.
|
||||
*
|
||||
* 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
|
||||
* Validation is performed by the PromoVoucher constraint on ParticipantEditDto.
|
||||
* This handler maintains dependency order to ensure validation occurs after
|
||||
* all price-affecting fields have been processed.
|
||||
*
|
||||
* 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';
|
||||
@@ -58,66 +44,12 @@ class ParticipantPromoVoucherFieldHandler extends AbstractParticipantFieldHandle
|
||||
|
||||
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
|
||||
{
|
||||
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
|
||||
|
||||
return null !== $promoCode && '' !== trim($promoCode);
|
||||
// No processing needed - validation handled by PromoVoucher constraint
|
||||
return false;
|
||||
}
|
||||
|
||||
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')
|
||||
);
|
||||
}
|
||||
// No processing needed - validation handled by PromoVoucher constraint
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,11 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
|
||||
use App\Service\VoucherValidationService;
|
||||
|
||||
/**
|
||||
* Handles processing and validation of purchase voucher codes for booking participants.
|
||||
* Handles purchase voucher code field processing.
|
||||
*
|
||||
* 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
|
||||
* 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)
|
||||
*/
|
||||
@@ -62,23 +56,17 @@ class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHan
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate voucher via API
|
||||
// Validate voucher to check if it's goodwill type
|
||||
$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')
|
||||
);
|
||||
// Only process valid vouchers
|
||||
if (false === $result->isValid || null === $result->voucher) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this is a goodwill voucher
|
||||
if (true === $result->voucher->isGoodwill()) {
|
||||
$participant->hasGoodwillVoucher = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Form\Model\ParticipantEditDto;
|
||||
use App\Service\BookingPriceCalculatorService;
|
||||
use App\Service\VoucherValidationService;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class PromoVoucherValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VoucherValidationService $voucherValidationService,
|
||||
private readonly BookingPriceCalculatorService $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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Validator\Constraints;
|
||||
|
||||
use App\Form\Model\ParticipantEditDto;
|
||||
use App\Service\VoucherValidationService;
|
||||
use Symfony\Component\Validator\Constraint;
|
||||
use Symfony\Component\Validator\ConstraintValidator;
|
||||
use Symfony\Component\Validator\Exception\UnexpectedTypeException;
|
||||
|
||||
class PurchaseVoucherValidator extends ConstraintValidator
|
||||
{
|
||||
public function __construct(
|
||||
private readonly VoucherValidationService $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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user