feat: correct handling of vouchers

This commit is contained in:
Björn Fromme
2025-11-24 10:20:21 +01:00
parent ce1b9ece09
commit f33e55f201
7 changed files with 151 additions and 24 deletions
+3 -3
View File
@@ -204,7 +204,7 @@ class ApiClient
'user' => $this->config['bpn_username'],
'key' => $this->createKey($this->config['bpn_username'], $this->config['bpn_password'], static::TYPE_BOOKING_UPDATE),
'satz' => ['@typ' => static::TYPE_BOOKING_UPDATE],
'buchungsart' => 'Buchung',
'buchungsart' => Constants::BOOKING_TYPE_BOOKING,
...$payload,
];
@@ -228,7 +228,7 @@ class ApiClient
{
// Forcibly override booking status to generate inquiry payload
$bookingDto->bookingStatus = 'A';
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Anfrage');
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_INQUIRY);
$data = [
'user' => $this->config['bpn_username'],
@@ -255,7 +255,7 @@ class ApiClient
*/
public function createBooking(BookingDto $bookingDto, bool $debug = false): Notification|BookingResponse
{
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, 'Buchung');
$payload = $this->bookingDataProcessor->createBookingRequestPayload($bookingDto, Constants::BOOKING_TYPE_BOOKING);
$data = [
'user' => $this->config['bpn_username'],
+4
View File
@@ -48,6 +48,10 @@ final class Constants
public const STATUS_BLOCKED = 'Buchungsstop';
public const STATUS_ON_REQUEST = 'Anfrage';
// Booking types for API requests
public const BOOKING_TYPE_INQUIRY = 'Anfrage';
public const BOOKING_TYPE_BOOKING = 'Buchung';
// Payment methods
public const PAYMENT_METHOD_TRANSFER = 'transfer';
public const PAYMENT_METHOD_DEBIT = 'debit';
@@ -688,11 +688,16 @@ class BookingDataProcessor
}
}
// 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;
// Add promotional voucher as promotionalCode
$promotionalCode = $this->getPromoVoucherCodeForParticipant($dto);
if (null !== $promotionalCode) {
$participantPayload['aktionscode'] = $promotionalCode;
}
// Add goodwill voucher as participant-level einloesecode
$goodwillCode = $this->getGoodwillVoucherCodeForParticipant($dto);
if (null !== $goodwillCode) {
$participantPayload['einloesecode'] = $goodwillCode;
}
}
@@ -835,6 +840,9 @@ class BookingDataProcessor
}
}
// Determine if this is an inquiry booking for voucher handling
$isInquiryBooking = 'A' === $bookingDto->bookingStatus;
// Add participants
$payload['teilnehmerliste']['teilnehmer'] = [];
foreach ($bookingDto->participants as $index => $participant) {
@@ -885,11 +893,19 @@ class BookingDataProcessor
}
}
// 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;
// Add promotional voucher as aktionscode (allowed for inquiry bookings)
$promotionalCode = $this->getPromoVoucherCodeForParticipant($participant);
if (null !== $promotionalCode) {
$participantData['aktionscode'] = $promotionalCode;
}
// Add goodwill voucher as participant-level einloesecode
// Goodwill vouchers are excluded from inquiry bookings and only allowed in final booking requests
if (false === $isInquiryBooking && Constants::BOOKING_TYPE_BOOKING === $bookingType) {
$goodwillCode = $this->getGoodwillVoucherCodeForParticipant($participant);
if (null !== $goodwillCode) {
$participantData['einloesecode'] = $goodwillCode;
}
}
$payload['teilnehmerliste']['teilnehmer'][] = $participantData;
@@ -909,7 +925,9 @@ class BookingDataProcessor
$this->addServicesFromMap($payload, 'zustiege', 'zustieg', '@idzustieg', $pickupMap);
$this->addServicesFromMap($payload, 'versicherungen', 'versicherung', '@idversicherung', $insuranceMap);
// Add purchase vouchers if any exist
// Add purchase vouchers (regular purchase vouchers, excluding goodwill vouchers)
// Purchase vouchers are allowed for both inquiry and final bookings
// Goodwill vouchers are handled separately and excluded from inquiry bookings
$purchaseVouchers = $this->collectPurchaseVouchers($bookingDto);
if (false === empty($purchaseVouchers)) {
$payload['gutscheine']['gutschein'] = [];
@@ -1196,22 +1214,16 @@ class BookingDataProcessor
}
/**
* Gets the aktionscode for a participant.
* Gets the aktionscode (promotional voucher code) 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.
* Returns the promotional voucher code if present.
* Goodwill vouchers are handled separately via getGoodwillVoucherCodeForParticipant().
*
* @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
// Only return promotional voucher code (goodwill vouchers are handled separately)
if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) {
return trim($participant->promoVoucherCode);
}
@@ -1219,6 +1231,21 @@ class BookingDataProcessor
return null;
}
/**
* Extracts goodwill voucher code for a participant.
*
* Goodwill vouchers are sent as <einloesecode> per participant (not in aggregate),
* allowing them to coexist with promotional codes.
*/
private function getGoodwillVoucherCodeForParticipant(ParticipantDto $participant): ?string
{
if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) {
return trim($participant->purchaseVoucherCode);
}
return null;
}
/**
* Applies bulk insurance assignment if the applicant has enabled it.
*
@@ -68,6 +68,11 @@ class Step3Controller extends AbstractController
$form->handleRequest($request);
if (true === $form->isSubmitted() && true === $form->isValid()) {
// Notify user if goodwill vouchers cannot be redeemed for inquiry bookings
if ($bookingCreateDto->isInquiryBooking() && $bookingCreateDto->hasGoodwillVouchers()) {
$this->addFlash('warning', 'Hinweis: Bei Anfragebuchungen können keine Kulanz-Gutscheine eingelöst werden. Kauf- und Aktionsgutscheine werden berücksichtigt.');
}
try {
// Validate booking data with API by submitting an inquiry booking
$inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto);
+48
View File
@@ -219,4 +219,52 @@ class BookingDto
->addViolation();
}
}
/**
* Checks if this is an inquiry booking (status='A').
*
* Inquiry bookings occur when a travel is fully booked but still accepts inquiries.
*/
public function isInquiryBooking(): bool
{
return 'A' === $this->bookingStatus;
}
/**
* Checks if any participants have entered voucher codes.
*
* @return bool True if any promotional, purchase, or goodwill vouchers are present
*/
public function hasVouchers(): bool
{
foreach ($this->participants as $participant) {
if (null !== $participant->promoVoucherCode && '' !== trim($participant->promoVoucherCode)) {
return true;
}
if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) {
return true;
}
}
return false;
}
/**
* Checks if any participants have entered goodwill vouchers.
*
* Goodwill vouchers (Kulanz) are a special type of purchase voucher that cannot be
* redeemed in inquiry bookings. This method is used to display appropriate warnings.
*
* @return bool True if any goodwill vouchers are present
*/
public function hasGoodwillVouchers(): bool
{
foreach ($this->participants as $participant) {
if (true === $participant->hasGoodwillVoucher) {
return true;
}
}
return false;
}
}
+7
View File
@@ -1,6 +1,13 @@
<div class="booking-summary p-8 bg-gray-50 rounded-lg border sticky top-4">
<h3 class="font-bold text-xl mb-6 text-gray-800">Buchungsübersicht</h3>
{# Inquiry booking notification #}
{% if bookingCreateDto is defined and bookingCreateDto.isInquiryBooking() %}
<div class="mb-4 p-3 bg-yellow-50 border border-yellow-200 rounded text-sm text-yellow-800">
<span class="font-semibold">Anfragebuchung:</span> Diese Reise ist ausgebucht. Ihre Buchung wird als Anfrage verarbeitet.
</div>
{% endif %}
{# Mutability information (edit mode only) #}
{% if mutableData is defined %}
<div class="mb-6 pb-4 border-b border-gray-200">
@@ -8,7 +8,14 @@ use App\BusProNet\Model\Travel;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Model\ParticipantEditDto;
use App\Service\BookingPriceCalculatorService;
use App\Service\VoucherValidationService;
use App\Validator\Constraints\PromoVoucherValidator;
use App\Validator\Constraints\PurchaseVoucherValidator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Validator\Constraint;
use Symfony\Component\Validator\ConstraintValidatorFactoryInterface;
use Symfony\Component\Validator\ConstraintValidatorInterface;
use Symfony\Component\Validator\Validation;
use Symfony\Component\Validator\Validator\ValidatorInterface;
@@ -25,8 +32,37 @@ class ParticipantEditDtoTest extends TestCase
protected function setUp(): void
{
// Create mock services for validator dependencies
$mockVoucherService = $this->createMock(VoucherValidationService::class);
$mockPriceCalculatorService = $this->createMock(BookingPriceCalculatorService::class);
// Create custom validator factory that can inject dependencies
$validatorFactory = new class($mockVoucherService, $mockPriceCalculatorService) implements ConstraintValidatorFactoryInterface {
public function __construct(
private readonly VoucherValidationService $voucherService,
private readonly BookingPriceCalculatorService $priceCalculatorService,
) {
}
public function getInstance(Constraint $constraint): ConstraintValidatorInterface
{
$className = $constraint->validatedBy();
if (PurchaseVoucherValidator::class === $className) {
return new PurchaseVoucherValidator($this->voucherService);
}
if (PromoVoucherValidator::class === $className) {
return new PromoVoucherValidator($this->voucherService, $this->priceCalculatorService);
}
return new $className();
}
};
$this->validator = Validation::createValidatorBuilder()
->enableAttributeMapping()
->setConstraintValidatorFactory($validatorFactory)
->getValidator();
}