diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index ac056ae..3b0d848 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -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 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 instead. + * + * @return string[] Array of unique redemption codes (excluding Kulanz) */ private function collectPurchaseVouchers(BookingDto $bookingDto): array { @@ -1177,7 +1184,10 @@ class BookingDataProcessor foreach ($bookingDto->participants as $participant) { if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { - $vouchers[] = trim($participant->purchaseVoucherCode); + // Exclude goodwill vouchers (flagged by field handler) + if (false === $participant->hasGoodwillVoucher) { + $vouchers[] = trim($participant->purchaseVoucherCode); + } } } @@ -1185,6 +1195,30 @@ class BookingDataProcessor 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. * diff --git a/src/BusProNet/Model/PurchaseVoucher.php b/src/BusProNet/Model/PurchaseVoucher.php index 47c9690..813dbae 100644 --- a/src/BusProNet/Model/PurchaseVoucher.php +++ b/src/BusProNet/Model/PurchaseVoucher.php @@ -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 . + */ + 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 , not aggregated into . + */ + public function isGoodwill(): bool + { + return self::TYPE_GOODWILL === $this->type; + } } diff --git a/src/Controller/Booking/Create/Step2Controller.php b/src/Controller/Booking/Create/Step2Controller.php index 97a597e..bb6ff5c 100644 --- a/src/Controller/Booking/Create/Step2Controller.php +++ b/src/Controller/Booking/Create/Step2Controller.php @@ -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 diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index cd98f59..d8d544d 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -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 ); diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index ca36e36..ce29fec 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -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 ); diff --git a/src/Controller/Booking/Edit/IndexController.php b/src/Controller/Booking/Edit/IndexController.php index 3a46de7..cf24390 100644 --- a/src/Controller/Booking/Edit/IndexController.php +++ b/src/Controller/Booking/Edit/IndexController.php @@ -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, ])); } diff --git a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php index 187ab9b..fb715df 100644 --- a/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php +++ b/src/Controller/Booking/Traits/ParticipantCardFlowTrait.php @@ -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 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()); diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index fc25acb..cad79e0 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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 per participant, not in collection). + * Set by ParticipantPurchaseVoucherFieldHandler during form processing. + */ + public bool $hasGoodwillVoucher = false; + /** * Promo voucher code. * Applied per participant in booking payload as . diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index 2963dca..6807494 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -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( diff --git a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php index f1dd238..a2a0e2d 100644 --- a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php +++ b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php @@ -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 } } diff --git a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php index b6ab80d..257c7ed 100644 --- a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php +++ b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php @@ -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 per participant, not aggregated + * in 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; } } } diff --git a/src/Validator/Constraints/PromoVoucher.php b/src/Validator/Constraints/PromoVoucher.php new file mode 100644 index 0000000..0fca22c --- /dev/null +++ b/src/Validator/Constraints/PromoVoucher.php @@ -0,0 +1,25 @@ +participant->promoVoucherCode; + + // Skip validation if promo code is empty + if (null === $promoCode || '' === trim($promoCode)) { + return; + } + + // Get travel ID from booking context + $travelId = $value->bookingContext->travel->id; + + if (null === $travelId) { + $this->context->buildViolation('Aktionscode konnte nicht validiert werden: Reise-ID fehlt.') + ->atPath('participant.promoVoucherCode') + ->addViolation(); + + return; + } + + // Calculate participant's current price including all services + $participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice( + $value->bookingContext, + $value->participant->index ?? 0 + ); + + // Validate promo voucher via API + $result = $this->voucherValidationService->validatePromoVoucher( + $promoCode, + $travelId, + $participantPrice + ); + + // Add violation if voucher is invalid + if (false === $result->isValid) { + $this->context->buildViolation($constraint->message) + ->setParameter('{{ error }}', $result->errorMessage ?? 'Unbekannter Fehler') + ->atPath('participant.promoVoucherCode') + ->addViolation(); + } + } +} diff --git a/src/Validator/Constraints/PurchaseVoucher.php b/src/Validator/Constraints/PurchaseVoucher.php new file mode 100644 index 0000000..1fdf705 --- /dev/null +++ b/src/Validator/Constraints/PurchaseVoucher.php @@ -0,0 +1,24 @@ +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(); + } + } +}