feat: promotional and purchase vouchers
This commit is contained in:
+10
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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 <gutscheine> 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.
|
||||
*
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Promo voucher validation response from BPN API.
|
||||
*
|
||||
* Represents a validated promotional voucher (Aktionsgutschein) applied per participant.
|
||||
* These vouchers provide discounts either as fixed amounts or percentages.
|
||||
*/
|
||||
final readonly class PromoVoucher
|
||||
{
|
||||
public const APPLICABILITY_PER_PERSON = 'P';
|
||||
public const APPLICABILITY_PER_BOOKING = 'B';
|
||||
|
||||
public function __construct(
|
||||
public string $code,
|
||||
public string $description,
|
||||
public string $applicability,
|
||||
public float $discountAmount,
|
||||
public float $discountPercentage,
|
||||
public float $minimumPrice,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isPerPerson(): bool
|
||||
{
|
||||
return self::APPLICABILITY_PER_PERSON === $this->applicability;
|
||||
}
|
||||
|
||||
public function isPerBooking(): bool
|
||||
{
|
||||
return self::APPLICABILITY_PER_BOOKING === $this->applicability;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
final readonly class PromoVoucherValidationResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isValid,
|
||||
public ?PromoVoucher $voucher = null,
|
||||
public ?string $errorMessage = null,
|
||||
public ?int $errorCode = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
/**
|
||||
* Purchase voucher validation response from BPN API.
|
||||
*
|
||||
* Represents a validated purchase voucher (Gutschein) with redemption code.
|
||||
* These vouchers have a remaining balance that can be redeemed against bookings.
|
||||
* The voucher reduces the booking price by its balance until depleted.
|
||||
*
|
||||
* Purchase vouchers can be regular purchased vouchers or goodwill vouchers (Kulanz)
|
||||
* issued to customers for complaints or other reasons.
|
||||
*/
|
||||
final readonly class PurchaseVoucher
|
||||
{
|
||||
public function __construct(
|
||||
public string $voucherNumber,
|
||||
public string $redemptionCode,
|
||||
public int $voucherId,
|
||||
public float $remainingBalance,
|
||||
public string $type,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\Model;
|
||||
|
||||
final readonly class PurchaseVoucherValidationResult
|
||||
{
|
||||
public function __construct(
|
||||
public bool $isValid,
|
||||
public ?PurchaseVoucher $voucher = null,
|
||||
public ?string $errorMessage = null,
|
||||
public ?int $errorCode = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,10 @@ class ApiResponseParser extends AbstractParser
|
||||
return (new TravelParser())->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');
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\PromoVoucher;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
final class PromoVoucherParser extends AbstractParser
|
||||
{
|
||||
public function parse(Crawler $node): PromoVoucher
|
||||
{
|
||||
$voucherNode = $node->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\Model\PurchaseVoucher;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
final class PurchaseVoucherParser extends AbstractParser
|
||||
{
|
||||
public function parse(Crawler $node): PurchaseVoucher
|
||||
{
|
||||
$voucherNode = $node->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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Form\Model\BookingDto;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
/**
|
||||
* Consolidates all booking summary data for display in sidebars.
|
||||
*
|
||||
* Provides pricing breakdowns, room assignments, participant counts,
|
||||
* and CMS product information in a single service.
|
||||
*/
|
||||
class BookingSummaryDataService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly BookingPriceCalculatorService $priceCalculator,
|
||||
private readonly CmsDataService $cmsDataService,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get complete summary data for booking sidebar.
|
||||
*
|
||||
* @return array{
|
||||
* selectedRooms: array,
|
||||
* participantCount: int,
|
||||
* totalPrice: string,
|
||||
* groupedSelectedRooms: array,
|
||||
* assignmentCounts: array,
|
||||
* pricingData: array,
|
||||
* cmsData: array|null
|
||||
* }
|
||||
*/
|
||||
public function getSummaryData(BookingDto $bookingDto): array
|
||||
{
|
||||
// Get selected rooms (for Step1 controller compatibility)
|
||||
$selectedRooms = $bookingDto->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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\ApiClient;
|
||||
use App\BusProNet\Exception\ApiClientException;
|
||||
use App\BusProNet\Model\Notification;
|
||||
use App\BusProNet\Model\PromoVoucherValidationResult;
|
||||
use App\BusProNet\Model\PurchaseVoucherValidationResult;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
|
||||
class VoucherValidationService
|
||||
{
|
||||
private const CACHE_TTL = 900; // 15 minutes
|
||||
|
||||
public function __construct(
|
||||
private readonly ApiClient $apiClient,
|
||||
private readonly CacheInterface $cache,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {
|
||||
}
|
||||
|
||||
public function validatePurchaseVoucher(
|
||||
string $redemptionCode,
|
||||
): PurchaseVoucherValidationResult {
|
||||
$cacheKey = sprintf('voucher.purchase.%s', md5($redemptionCode));
|
||||
|
||||
try {
|
||||
return $this->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.'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +356,21 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Voucher fields - available for all participants regardless of eligibility #}
|
||||
{% if form.purchaseVoucherCode is defined or form.promoVoucherCode is defined %}
|
||||
<div class="mt-6 border-t pt-4">
|
||||
<h3 class="font-semibold text-lg mb-4">Gutscheine</h3>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
{% if form.purchaseVoucherCode is defined %}
|
||||
{{ form_row(form.purchaseVoucherCode) }}
|
||||
{% endif %}
|
||||
{% if form.promoVoucherCode is defined %}
|
||||
{{ form_row(form.promoVoucherCode) }}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between mt-8">
|
||||
@@ -390,10 +405,11 @@
|
||||
<div id="booking-summary" hx-swap-oob="true">
|
||||
{% 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
|
||||
} %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user