diff --git a/config/services.yaml b/config/services.yaml index 820a3aa..48b7273 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -67,14 +67,21 @@ services: $preferRemote: '%env(bool:APP_TRAVEL_PREFER_REMOTE)%' $enableFallback: '%env(bool:APP_TRAVEL_ENABLE_FALLBACK)%' + App\Service\VoucherValidationService: + arguments: + $cache: '@cache.app' + $logger: '@monolog.logger.bpn' + App\Form\Service\Factory\ParticipantRoomChoiceLoaderFactory: arguments: $choiceListFactory: '@form.choice_list_factory.default' - # Insurance Field Handlers with dependencies + # Field Handlers with dependencies App\Form\Service\ParticipantInsuranceFieldHandler: ~ App\Form\Service\ParticipantBulkInsuranceFieldHandler: ~ App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler: ~ + App\Form\Service\ParticipantPurchaseVoucherFieldHandler: ~ + App\Form\Service\ParticipantPromoVoucherFieldHandler: ~ # Participant Field Handler Registry - most handlers instantiate dependencies directly, some use services App\Form\Service\ParticipantFieldHandlerRegistry: @@ -99,6 +106,8 @@ services: - '@App\Form\Service\ParticipantTransportationDiscountReplacementFieldHandler' - '@App\Form\Service\ParticipantBulkInsuranceFieldHandler' - '@App\Form\Service\ParticipantInsuranceFieldHandler' + - '@App\Form\Service\ParticipantPurchaseVoucherFieldHandler' + - '@App\Form\Service\ParticipantPromoVoucherFieldHandler' App\Service\CmsDataService: arguments: diff --git a/src/BusProNet/ApiClient.php b/src/BusProNet/ApiClient.php index 32a940f..9430afb 100644 --- a/src/BusProNet/ApiClient.php +++ b/src/BusProNet/ApiClient.php @@ -12,6 +12,8 @@ use App\BusProNet\Model\BookingUpdate; use App\BusProNet\Model\CrmAttributes; use App\BusProNet\Model\Notification; use App\BusProNet\Model\PersonalData; +use App\BusProNet\Model\PromoVoucher; +use App\BusProNet\Model\PurchaseVoucher; use App\BusProNet\Model\Travel; use App\BusProNet\Traits\ApiClientTrait; use App\BusProNet\XmlParser\ApiResponseParser; @@ -39,6 +41,8 @@ class ApiClient public const TYPE_PRODUCTS = 'PRODUKTE'; public const TYPE_PRODUCT_DATA = 'PRODUKTDATEN'; public const TYPE_AGENCIES = 'AGENTUREN'; + public const TYPE_PURCHASE_VOUCHER = 'GUTSCHEINPRUEFUNGEINLOESUNG'; + public const TYPE_PROMO_VOUCHER = 'AKTIONSGUTSCHEIN'; private array $config; @@ -409,6 +413,62 @@ class ApiClient return $this->sendRequest(static::TYPE_AGENCIES, $data); } + /** + * Validates a purchase voucher by redemption code. + * + * Purchase vouchers (Gutscheine) have a remaining balance that reduces with each redemption. + * They can be regular purchased vouchers or goodwill vouchers (Kulanz). + * Returns the voucher with remaining balance, or a Notification if not found or invalid. + * + * @param string $redemptionCode The voucher redemption code (einloesecode) + * + * @return Notification|PurchaseVoucher Notification on error, PurchaseVoucher on success + * + * @throws ApiClientException If the API request fails + */ + public function validatePurchaseVoucher(string $redemptionCode): Notification|PurchaseVoucher + { + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PURCHASE_VOUCHER), + 'satz' => ['@typ' => static::TYPE_PURCHASE_VOUCHER], + 'einloesecode' => $redemptionCode, + ]; + + return $this->sendRequest(static::TYPE_PURCHASE_VOUCHER, $data); + } + + /** + * Validates a promo voucher for a specific travel and participant price. + * + * Promo vouchers (Aktionsgutscheine) provide absolute discounts per person or per booking. + * Applicability is indicated by the pro_buchung_person field: "P" = per person, "B" = per booking. + * + * @param string $promoCode The promo voucher code + * @param int $travelId The travel ID this voucher applies to + * @param float $participantPrice Current participant price for validation + * + * @return Notification|PromoVoucher Notification on error, PromoVoucher on success + * + * @throws ApiClientException If the API request fails + */ + public function validatePromoVoucher(string $promoCode, int $travelId, float $participantPrice): Notification|PromoVoucher + { + // Format price to German format (comma decimal separator) + $priceFormatted = number_format($participantPrice, 2, ',', ''); + + $data = [ + 'user' => $this->config['bpn_username'], + 'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_PROMO_VOUCHER), + 'satz' => ['@typ' => static::TYPE_PROMO_VOUCHER], + 'aktionsgutschein' => $promoCode, + 'idreise' => $travelId, + 'preis' => $priceFormatted, + ]; + + return $this->sendRequest(static::TYPE_PROMO_VOUCHER, $data); + } + /** * @throws ApiClientException */ diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index c8d275c..ac056ae 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -274,6 +274,17 @@ class BookingDataProcessor $this->buildServicesPayload($payload, $bookingData); $this->buildPickupPayload($payload, $bookingData); + // Add purchase vouchers if any exist + $purchaseVouchers = $this->collectPurchaseVouchers($formData); + if (false === empty($purchaseVouchers)) { + $payload['gutscheine']['gutschein'] = []; + foreach ($purchaseVouchers as $code) { + $payload['gutscheine']['gutschein'][] = [ + '@einloesecode' => $code, + ]; + } + } + return $payload; } @@ -676,6 +687,11 @@ class BookingDataProcessor $participantPayload['wünsche'] = $wishes; } } + + // Add promo voucher if present + if (null !== $dto->promoVoucherCode && '' !== trim($dto->promoVoucherCode)) { + $participantPayload['aktionscode'] = trim($dto->promoVoucherCode); + } } $payload['teilnehmerliste']['teilnehmer'][] = $participantPayload; @@ -867,6 +883,11 @@ class BookingDataProcessor } } + // Add promo voucher if present + if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) { + $participantData['aktionscode'] = trim($participant->promoVoucherCode); + } + $payload['teilnehmerliste']['teilnehmer'][] = $participantData; } @@ -884,6 +905,17 @@ class BookingDataProcessor $this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap); $this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap); + // Add purchase vouchers if any exist + $purchaseVouchers = $this->collectPurchaseVouchers($bookingDto); + if (false === empty($purchaseVouchers)) { + $payload['gutscheine']['gutschein'] = []; + foreach ($purchaseVouchers as $code) { + $payload['gutscheine']['gutschein'][] = [ + '@einloesecode' => $code, + ]; + } + } + // Add payment information $payload['zahlung'] = [ '@idzahlungsart' => Constants::PAYMENT_METHOD_DEBIT === $bookingDto->paymentMethod @@ -1131,6 +1163,28 @@ class BookingDataProcessor return $insuranceMap; } + /** + * Collects all purchase vouchers from participants. + * + * 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 + */ + private function collectPurchaseVouchers(BookingDto $bookingDto): array + { + $vouchers = []; + + foreach ($bookingDto->participants as $participant) { + if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + $vouchers[] = trim($participant->purchaseVoucherCode); + } + } + + // Remove duplicates (in case multiple participants enter same code) + return array_unique($vouchers); + } + /** * Applies bulk insurance assignment if the applicant has enabled it. * diff --git a/src/BusProNet/Model/PromoVoucher.php b/src/BusProNet/Model/PromoVoucher.php new file mode 100644 index 0000000..d7e70c7 --- /dev/null +++ b/src/BusProNet/Model/PromoVoucher.php @@ -0,0 +1,37 @@ +applicability; + } + + public function isPerBooking(): bool + { + return self::APPLICABILITY_PER_BOOKING === $this->applicability; + } +} diff --git a/src/BusProNet/Model/PromoVoucherValidationResult.php b/src/BusProNet/Model/PromoVoucherValidationResult.php new file mode 100644 index 0000000..a61a44e --- /dev/null +++ b/src/BusProNet/Model/PromoVoucherValidationResult.php @@ -0,0 +1,16 @@ +parse($travelNode->first(), ...$additionalArgs); case ApiClient::TYPE_AGENCIES: return (new AgencyParser())->parse($resultNode); + case ApiClient::TYPE_PURCHASE_VOUCHER: + return (new PurchaseVoucherParser())->parse($resultNode); + case ApiClient::TYPE_PROMO_VOUCHER: + return (new PromoVoucherParser())->parse($resultNode); } throw new ResponseParserException('Unable to parse XML response'); diff --git a/src/BusProNet/XmlParser/PromoVoucherParser.php b/src/BusProNet/XmlParser/PromoVoucherParser.php new file mode 100644 index 0000000..d68be54 --- /dev/null +++ b/src/BusProNet/XmlParser/PromoVoucherParser.php @@ -0,0 +1,32 @@ +filterXPath('//aktionsgutschein'); + + $code = trim($voucherNode->attr('code') ?? ''); + $description = trim($voucherNode->attr('text') ?? ''); + $applicability = $voucherNode->attr('pro_buchung_person') ?? ''; + $discountAmount = $this->stringToFloat($voucherNode->attr('wert_betrag') ?? '0,00'); + $discountPercentage = $this->stringToFloat($voucherNode->attr('wert_prozent') ?? '0,00'); + $minimumPrice = $this->stringToFloat($voucherNode->attr('ab_preis') ?? '0,00'); + + return new PromoVoucher( + code: $code, + description: $description, + applicability: $applicability, + discountAmount: $discountAmount, + discountPercentage: $discountPercentage, + minimumPrice: $minimumPrice + ); + } +} diff --git a/src/BusProNet/XmlParser/PurchaseVoucherParser.php b/src/BusProNet/XmlParser/PurchaseVoucherParser.php new file mode 100644 index 0000000..8dd723e --- /dev/null +++ b/src/BusProNet/XmlParser/PurchaseVoucherParser.php @@ -0,0 +1,30 @@ +filterXPath('//gutschein'); + + $voucherNumber = $this->getStringOrNullValue($voucherNode->filterXPath('//gutscheinnnr')) ?? ''; + $redemptionCode = $this->getStringOrNullValue($voucherNode->filterXPath('//einloesecode')) ?? ''; + $voucherId = $this->getIntOrNullValue($voucherNode->filterXPath('//idgutschein')) ?? 0; + $remainingBalance = $this->getFloatOrNullValue($voucherNode->filterXPath('//betrag')) ?? 0.0; + $type = $this->getStringOrNullValue($voucherNode->filterXPath('//art')) ?? ''; + + return new PurchaseVoucher( + voucherNumber: $voucherNumber, + redemptionCode: $redemptionCode, + voucherId: $voucherId, + remainingBalance: $remainingBalance, + type: $type + ); + } +} diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 2b1d1a0..cd98f59 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -12,6 +12,7 @@ use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; use App\Form\BookingCreateStep3Type; use App\Form\Model\BookingDto; use App\Htmx\HxTrait; +use App\Service\BookingPriceCalculatorService; use App\Service\BookingService; use App\Service\BookingSummaryDataService; use Psr\Log\LoggerInterface; @@ -33,6 +34,7 @@ class Step3Controller extends AbstractController public function __construct( private readonly BookingService $bookingService, private readonly BookingSummaryDataService $summaryDataService, + private readonly BookingPriceCalculatorService $priceCalculator, private readonly ApiClient $apiClient, private readonly LoggerInterface $logger, ) { diff --git a/src/Form/BookingParticipantType.php b/src/Form/BookingParticipantType.php index f258ebf..7d3f9b7 100644 --- a/src/Form/BookingParticipantType.php +++ b/src/Form/BookingParticipantType.php @@ -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) { diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 7a31195..fc25acb 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -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 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 . + */ + #[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 Notification messages for user feedback */ diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 8ac3a58..c9098bc 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -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: diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index f5331e9..4bfad1c 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -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 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 + $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) => [ diff --git a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php new file mode 100644 index 0000000..f1dd238 --- /dev/null +++ b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php @@ -0,0 +1,123 @@ +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') + ); + } + } +} diff --git a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php new file mode 100644 index 0000000..b6ab80d --- /dev/null +++ b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php @@ -0,0 +1,84 @@ +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') + ); + } + } +} diff --git a/src/Service/BookingService.php b/src/Service/BookingService.php index 33a76bb..e69e924 100644 --- a/src/Service/BookingService.php +++ b/src/Service/BookingService.php @@ -2,6 +2,7 @@ namespace App\Service; +use App\BusProNet\Constants; use App\BusProNet\Model\Room; use App\BusProNet\Model\Travel; use App\Exception\BookingNotPossibleException; @@ -221,7 +222,7 @@ class BookingService // For inquiry bookings with 0 availability, get all rooms ignoring availability count if ($isInquiryBooking && empty($availableRooms)) { $availableRooms = array_filter($travelData->rooms, function (Room $room) { - return \App\BusProNet\Constants::STATUS_AVAILABLE === $room->status; + return Constants::STATUS_AVAILABLE === $room->status; }); } @@ -443,7 +444,7 @@ class BookingService */ public function preselectMandatoryServices(BookingDto $bookingDto): void { - $additionalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(\App\BusProNet\Constants::TOKEN_ADDITIONAL); + $additionalServices = $bookingDto->travel->getAdditionalServicesBySubTypes(Constants::TOKEN_ADDITIONAL); $mandatoryServices = array_filter($additionalServices, fn ($service) => true === $service->mandatory); // Pre-select mandatory services for each participant diff --git a/src/Service/BookingSummaryDataService.php b/src/Service/BookingSummaryDataService.php new file mode 100644 index 0000000..15e34b7 --- /dev/null +++ b/src/Service/BookingSummaryDataService.php @@ -0,0 +1,139 @@ +getSelectedRooms(); + + // Calculate individual prices for all participants + $participantPrices = $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto); + + // Calculate total price + $totalPrice = array_sum($participantPrices); + + // Get room assignment counts + $roomCounts = []; + foreach ($bookingDto->participants as $participant) { + if (null !== $participant->assignedRoomId) { + $roomCounts[$participant->assignedRoomId] = ($roomCounts[$participant->assignedRoomId] ?? 0) + 1; + } + } + + // Group selected rooms with counts + $groupedSelectedRooms = []; + foreach ($roomCounts as $roomId => $count) { + $room = $bookingDto->travel->getRoomById($roomId); + if (null !== $room) { + $groupedSelectedRooms[] = [ + 'room' => $room, + 'count' => $count, + ]; + } + } + + // Get detailed pricing breakdown + $pricingData = $this->priceCalculator->getPricingBreakdown($bookingDto); + + // Fetch CMS data (images, etc.) + $cmsData = $this->getCmsData($bookingDto); + + return [ + 'selectedRooms' => $selectedRooms, + 'participantCount' => count($bookingDto->participants), + 'totalPrice' => number_format($totalPrice, 2, ',', '.').' €', + 'groupedSelectedRooms' => $groupedSelectedRooms, + 'assignmentCounts' => $roomCounts, + 'pricingData' => $pricingData, + 'cmsData' => $cmsData, + ]; + } + + /** + * Fetches CMS data for the product and hotel in the booking. + * + * Data is cached for 1 hour as it rarely changes. Returns null + * if the API call fails or if product/hotel codes are not available. + */ + private function getCmsData(BookingDto $bookingDto): ?array + { + $productCode = $bookingDto->travel->productCode; + $hotelCode = $bookingDto->travel->hotel?->code; + + if (null === $productCode) { + $this->logger->debug('Cannot fetch CMS data: product code is not available', [ + 'travel_id' => $bookingDto->travel->id, + ]); + + return null; + } + + $cacheKey = sprintf('cms_data.%s.%s', $productCode, $hotelCode ?? 'none'); + + try { + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($productCode, $hotelCode) { + $item->expiresAfter(3600); // 1 hour + + $result = $this->cmsDataService->getProductDetails($productCode, $hotelCode); + + // Return null if API call failed + if (true === isset($result['success']) && false === $result['success']) { + $this->logger->warning('CMS API call failed', [ + 'product_code' => $productCode, + 'hotel_code' => $hotelCode, + 'message' => $result['message'] ?? 'Unknown error', + ]); + + return null; + } + + return $result; + }); + } catch (\Throwable $e) { + $this->logger->error('Failed to fetch CMS data', [ + 'product_code' => $productCode, + 'hotel_code' => $hotelCode, + 'exception' => $e->getMessage(), + ]); + + return null; + } + } +} diff --git a/src/Service/VoucherValidationService.php b/src/Service/VoucherValidationService.php new file mode 100644 index 0000000..7240744 --- /dev/null +++ b/src/Service/VoucherValidationService.php @@ -0,0 +1,131 @@ +cache->get($cacheKey, function (ItemInterface $item) use ($redemptionCode) { + $item->expiresAfter(self::CACHE_TTL); + + try { + $result = $this->apiClient->validatePurchaseVoucher($redemptionCode); + + if ($result instanceof Notification) { + return new PurchaseVoucherValidationResult( + isValid: false, + errorMessage: $result->message, + errorCode: $result->code + ); + } + + return new PurchaseVoucherValidationResult( + isValid: true, + voucher: $result + ); + } catch (ApiClientException $e) { + $this->logger->error('Purchase voucher validation failed', [ + 'redemptionCode' => $redemptionCode, + 'error' => $e->getMessage(), + ]); + + return new PurchaseVoucherValidationResult( + isValid: false, + errorMessage: 'Die Gutschein-Validierung ist fehlgeschlagen.' + ); + } + }); + } catch (\Exception $e) { + $this->logger->error('Cache error during purchase voucher validation', [ + 'error' => $e->getMessage(), + ]); + + return new PurchaseVoucherValidationResult( + isValid: false, + errorMessage: 'Ein technischer Fehler ist aufgetreten.' + ); + } + } + + public function validatePromoVoucher( + string $promoCode, + int $travelId, + float $participantPrice, + ): PromoVoucherValidationResult { + $cacheKey = sprintf( + 'voucher.promo.%s.%d.%s', + md5($promoCode), + $travelId, + md5((string) $participantPrice) + ); + + try { + return $this->cache->get($cacheKey, function (ItemInterface $item) use ($promoCode, $travelId, $participantPrice) { + $item->expiresAfter(self::CACHE_TTL); + + try { + $result = $this->apiClient->validatePromoVoucher($promoCode, $travelId, $participantPrice); + + if ($result instanceof Notification) { + return new PromoVoucherValidationResult( + isValid: false, + errorMessage: $result->message, + errorCode: $result->code + ); + } + + return new PromoVoucherValidationResult( + isValid: true, + voucher: $result + ); + } catch (ApiClientException $e) { + $this->logger->error('Promo voucher validation failed', [ + 'promoCode' => $promoCode, + 'travelId' => $travelId, + 'participantPrice' => $participantPrice, + 'error' => $e->getMessage(), + ]); + + return new PromoVoucherValidationResult( + isValid: false, + errorMessage: 'Die Aktionscode-Validierung ist fehlgeschlagen.' + ); + } + }); + } catch (\Exception $e) { + $this->logger->error('Cache error during promo voucher validation', [ + 'error' => $e->getMessage(), + ]); + + return new PromoVoucherValidationResult( + isValid: false, + errorMessage: 'Ein technischer Fehler ist aufgetreten.' + ); + } + } +} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 6a881fe..a933582 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -356,6 +356,21 @@ {% endif %} + + {# Voucher fields - available for all participants regardless of eligibility #} + {% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %} +
+

Gutscheine

+
+ {% if form.purchaseVoucherCode is defined %} + {{ form_row(form.purchaseVoucherCode) }} + {% endif %} + {% if form.promoVoucherCode is defined %} + {{ form_row(form.promoVoucherCode) }} + {% endif %} +
+
+ {% endif %}
@@ -390,10 +405,11 @@
{% include 'booking/_summary.html.twig' with { 'bookingCreateDto': bookingDto, - 'participantCount': summaryData.participantsCount, + 'participantCount': summaryData.participantCount, 'groupedSelectedRooms': summaryData.groupedSelectedRooms, 'assignmentCounts': summaryData.assignmentCounts, - 'pricingData': pricingData + 'pricingData': pricingData, + 'cmsData': cmsData } %}
{% endblock %}