diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 488e4fa..27c8378 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -902,8 +902,9 @@ class BookingDataProcessor } // 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) { + // Goodwill vouchers are excluded from inquiry bookings (status='A') but included in final bookings (status='F') + // They can be sent in both validation requests (buchungsart=Anfrage) and commit requests (buchungsart=Buchung) + if (false === $isInquiryBooking) { $goodwillCode = $this->getGoodwillVoucherCodeForParticipant($participant); if (null !== $goodwillCode) { $participantData['einloesecode'] = $goodwillCode; @@ -1205,8 +1206,8 @@ class BookingDataProcessor foreach ($bookingDto->participants as $participant) { if (null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { - // Exclude goodwill vouchers (flagged by field handler) - if (false === $participant->hasGoodwillVoucher) { + // Exclude goodwill vouchers + if (false === $participant->hasGoodwillVoucher()) { $vouchers[] = trim($participant->purchaseVoucherCode); } } @@ -1242,7 +1243,7 @@ class BookingDataProcessor */ private function getGoodwillVoucherCodeForParticipant(ParticipantDto $participant): ?string { - if (true === $participant->hasGoodwillVoucher && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { + if ($participant->hasGoodwillVoucher() && null !== $participant->purchaseVoucherCode && '' !== trim($participant->purchaseVoucherCode)) { return trim($participant->purchaseVoucherCode); } diff --git a/src/BusProNet/Model/BookingResponse.php b/src/BusProNet/Model/BookingResponse.php index a1179ee..2868453 100644 --- a/src/BusProNet/Model/BookingResponse.php +++ b/src/BusProNet/Model/BookingResponse.php @@ -47,4 +47,59 @@ class BookingResponse { return 'erfolgt' === $this->status; } + + /** + * Gets voucher discounts from price items (AKTION and KULANZGUTSCHEIN types). + * + * Promotional vouchers appear as negative price items with art="AKTION". + * Goodwill vouchers appear as negative price items with art="KULANZGUTSCHEIN". + * + * @return array Price items representing voucher discounts + */ + public function getVoucherDiscountsFromPrices(): array + { + return array_filter( + $this->priceItems, + fn (PriceItem $item) => \in_array($item->type, ['AKTION', 'KULANZGUTSCHEIN'], true) + ); + } + + /** + * Gets total discount from price-based vouchers (promotional and goodwill). + * + * Returns the absolute sum of negative price items with AKTION or KULANZGUTSCHEIN types. + */ + public function getVoucherDiscountFromPrices(): float + { + $discount = 0.0; + foreach ($this->getVoucherDiscountsFromPrices() as $item) { + // Price items for vouchers are negative, so we take absolute value + $discount += abs($item->totalPrice); + } + + return round($discount, 2); + } + + /** + * Gets total discount from purchase vouchers in payment terms. + */ + public function getPurchaseVoucherDiscount(): float + { + return $this->paymentTerms?->getPurchaseVoucherDiscount() ?? 0.0; + } + + /** + * Gets total voucher discount from all sources. + * + * Combines discounts from: + * - Price items (art="AKTION" for promotional, art="KULANZGUTSCHEIN" for goodwill) + * - Payment terms (kaufgutschein elements for purchase vouchers) + */ + public function getTotalVoucherDiscount(): float + { + return round( + $this->getVoucherDiscountFromPrices() + $this->getPurchaseVoucherDiscount(), + 2 + ); + } } diff --git a/src/BusProNet/Model/PaymentTerms.php b/src/BusProNet/Model/PaymentTerms.php index a084879..d5e4671 100644 --- a/src/BusProNet/Model/PaymentTerms.php +++ b/src/BusProNet/Model/PaymentTerms.php @@ -6,14 +6,33 @@ namespace App\BusProNet\Model; /** * Represents payment terms from the booking response. + * + * Contains deposit/final payment details and any applied purchase vouchers. */ class PaymentTerms { + /** + * @param array $appliedPurchaseVouchers + */ public function __construct( public readonly ?float $depositAmount = null, public readonly ?string $depositDate = null, public readonly ?float $finalPaymentAmount = null, public readonly ?string $finalPaymentDate = null, + public readonly array $appliedPurchaseVouchers = [], ) { } + + /** + * Gets the total discount from purchase vouchers. + */ + public function getPurchaseVoucherDiscount(): float + { + $total = 0.0; + foreach ($this->appliedPurchaseVouchers as $voucher) { + $total += $voucher['betrag']; + } + + return round($total, 2); + } } diff --git a/src/BusProNet/XmlParser/BookingResponseParser.php b/src/BusProNet/XmlParser/BookingResponseParser.php index 390d958..1df83ed 100644 --- a/src/BusProNet/XmlParser/BookingResponseParser.php +++ b/src/BusProNet/XmlParser/BookingResponseParser.php @@ -106,11 +106,22 @@ class BookingResponseParser extends AbstractParser $finalPaymentDate = $finalPaymentNode->attr('termin'); } + // Parse purchase vouchers (kaufgutschein elements) + $appliedPurchaseVouchers = []; + $voucherNodes = $paymentNode->filterXPath('//kaufgutschein'); + $voucherNodes->each(function (Crawler $voucherNode) use (&$appliedPurchaseVouchers): void { + $appliedPurchaseVouchers[] = [ + 'nummer' => $voucherNode->attr('nummer') ?? '', + 'betrag' => $this->stringToFloat($voucherNode->attr('betrag')), + ]; + }); + return new PaymentTerms( depositAmount: $depositAmount, depositDate: $depositDate, finalPaymentAmount: $finalPaymentAmount, - finalPaymentDate: $finalPaymentDate + finalPaymentDate: $finalPaymentDate, + appliedPurchaseVouchers: $appliedPurchaseVouchers, ); } } diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 0f496b8..70625f4 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -6,6 +6,7 @@ namespace App\Controller\Booking\Create; use App\BusProNet\ApiClient; use App\BusProNet\Exception\TimeoutException; +use App\BusProNet\Model\BookingResponse; use App\BusProNet\Model\Notification; use App\Controller\Booking\Traits\BookingCreateTrait; use App\Controller\Booking\Traits\BookingExceptionHandlerTrait; @@ -121,16 +122,22 @@ class Step3Controller extends AbstractController } // Validate price match (rounded to cent precision to avoid floating-point errors) + // API gesamtpreis includes promotional/goodwill voucher discounts (negative price items), + // but NOT purchase vouchers (those reduce restzahlung, not gesamtpreis) $apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2); - $calculatedTotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); + $calculatedSubtotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); + $promoGoodwillDiscount = round($inquiryResponse->getVoucherDiscountFromPrices(), 2); + $expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount, 2); - if ($apiTotal !== $calculatedTotal) { + if ($apiTotal !== $expectedTotal) { return $this->handleApiError( 'Price mismatch detected - payload incomplete', [ 'apiTotal' => $apiTotal, - 'calculatedTotal' => $calculatedTotal, - 'difference' => abs($apiTotal - $calculatedTotal), + 'calculatedSubtotal' => $calculatedSubtotal, + 'promoGoodwillDiscount' => $promoGoodwillDiscount, + 'expectedTotal' => $expectedTotal, + 'difference' => abs($apiTotal - $expectedTotal), ], 'Ein technischer Fehler ist aufgetreten.', $bookingCreateDto, @@ -225,11 +232,11 @@ class Step3Controller extends AbstractController * an inquiry rather than showing an error. This handles scenarios where * availability changes between booking initialization and validation. * - * @param \App\BusProNet\Model\BookingResponse $response The API response + * @param BookingResponse $response The API response * * @return bool True if should fallback to inquiry mode, false if should show error */ - private function shouldFallbackToInquiryMode(\App\BusProNet\Model\BookingResponse $response): bool + private function shouldFallbackToInquiryMode(BookingResponse $response): bool { // Check for "nicht möglich" status + inquiry suggestion in message if ('nicht möglich' !== $response->status) { diff --git a/src/Form/Model/AcceptedVoucherDto.php b/src/Form/Model/AcceptedVoucherDto.php new file mode 100644 index 0000000..e96c265 --- /dev/null +++ b/src/Form/Model/AcceptedVoucherDto.php @@ -0,0 +1,41 @@ +type; + } + + public function isPurchase(): bool + { + return self::TYPE_PURCHASE === $this->type; + } + + public function isGoodwill(): bool + { + return self::TYPE_GOODWILL === $this->type; + } +} diff --git a/src/Form/Model/AcceptedVouchersDto.php b/src/Form/Model/AcceptedVouchersDto.php new file mode 100644 index 0000000..119b062 --- /dev/null +++ b/src/Form/Model/AcceptedVouchersDto.php @@ -0,0 +1,72 @@ + */ + private array $vouchers = []; + + private float $totalDiscount = 0.0; + + public function addVoucher(AcceptedVoucherDto $voucher): void + { + $this->vouchers[] = $voucher; + $this->totalDiscount = round($this->totalDiscount + $voucher->amount, 2); + } + + /** + * @return array + */ + public function getVouchers(): array + { + return $this->vouchers; + } + + /** + * @return array + */ + public function getPromotionalVouchers(): array + { + return array_filter($this->vouchers, fn (AcceptedVoucherDto $v) => $v->isPromotional()); + } + + /** + * @return array + */ + public function getPurchaseVouchers(): array + { + return array_filter($this->vouchers, fn (AcceptedVoucherDto $v) => $v->isPurchase()); + } + + /** + * @return array + */ + public function getGoodwillVouchers(): array + { + return array_filter($this->vouchers, fn (AcceptedVoucherDto $v) => $v->isGoodwill()); + } + + public function getTotalDiscount(): float + { + return $this->totalDiscount; + } + + public function hasVouchers(): bool + { + return count($this->vouchers) > 0; + } + + public function count(): int + { + return count($this->vouchers); + } +} diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index fd3905a..d89b73d 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -77,6 +77,65 @@ class BookingDto { } + /** + * Builds AcceptedVouchers from validated participant voucher data. + * + * Computes voucher discounts from validated vouchers stored on participants. + * This allows displaying voucher savings as soon as vouchers are validated + * in Step 2, rather than waiting for Step 3 API confirmation. + * + * For promo vouchers with percentage discounts, we need the participant price + * to calculate the actual discount amount. + * + * @param array|null $participantPrices Prices per participant index for percentage calculation + */ + public function getAcceptedVouchers(?array $participantPrices = null): ?AcceptedVouchersDto + { + $acceptedVouchers = new AcceptedVouchersDto(); + $processedPromoCodes = []; + + foreach ($this->participants as $index => $participant) { + // Process purchase voucher + if (null !== $participant->validatedPurchaseVoucher) { + $voucher = $participant->validatedPurchaseVoucher; + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: $voucher->isGoodwill() ? AcceptedVoucherDto::TYPE_GOODWILL : AcceptedVoucherDto::TYPE_PURCHASE, + code: $voucher->voucherNumber, + amount: $voucher->remainingBalance, + description: $voucher->isGoodwill() ? 'Kulanzgutschein' : 'Kaufgutschein', + )); + } + + // Process promo voucher + if (null !== $participant->validatedPromoVoucher) { + $voucher = $participant->validatedPromoVoucher; + + // For per-booking vouchers, only count once + if ($voucher->isPerBooking()) { + if (isset($processedPromoCodes[$voucher->code])) { + continue; + } + $processedPromoCodes[$voucher->code] = true; + } + + // Calculate discount amount + $discountAmount = $voucher->discountAmount; + if ($voucher->discountPercentage > 0 && null !== $participantPrices && isset($participantPrices[$index])) { + $discountAmount = round($participantPrices[$index] * ($voucher->discountPercentage / 100), 2); + } + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: $voucher->code, + amount: $discountAmount, + description: $voucher->description, + )); + } + } + + return $acceptedVouchers->hasVouchers() ? $acceptedVouchers : null; + } + public function getMode(): string { return null !== $this->booking ? self::MODE_EDIT : self::MODE_CREATE; @@ -251,7 +310,7 @@ class BookingDto public function hasGoodwillVouchers(): bool { foreach ($this->participants as $participant) { - if (true === $participant->hasGoodwillVoucher) { + if ($participant->hasGoodwillVoucher()) { return true; } } diff --git a/src/Form/Model/ParticipantDto.php b/src/Form/Model/ParticipantDto.php index 66f003d..c974fba 100644 --- a/src/Form/Model/ParticipantDto.php +++ b/src/Form/Model/ParticipantDto.php @@ -6,6 +6,8 @@ use App\BusProNet\Model\Address; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\Pickup; +use App\BusProNet\Model\PromoVoucher; +use App\BusProNet\Model\PurchaseVoucher; use App\BusProNet\Model\Service; use App\Validator\Constraints as AppAssert; use Symfony\Component\Validator\Constraints as Assert; @@ -129,13 +131,11 @@ 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). + * Validated purchase voucher from API. * Set by ParticipantPurchaseVoucherFieldHandler during form processing. + * Contains voucher number, remaining balance, and type (Kauf/Kulanz). */ - public bool $hasGoodwillVoucher = false; + public ?PurchaseVoucher $validatedPurchaseVoucher = null; /** * Promo voucher code. @@ -151,6 +151,13 @@ class ParticipantDto )] public ?string $promoVoucherCode = null; + /** + * Validated promo voucher from API. + * Set by ParticipantPromoVoucherFieldHandler during form processing. + * Contains discount amount/percentage and applicability (per person/booking). + */ + public ?PromoVoucher $validatedPromoVoucher = null; + /** * @var array Notification messages for user feedback */ @@ -279,6 +286,18 @@ class ParticipantDto ]; } + /** + * Checks if this participant has a goodwill (Kulanz) voucher. + * + * Goodwill vouchers are treated as promotional vouchers in BPN XML + * (sent as per participant, not in collection). + */ + public function hasGoodwillVoucher(): bool + { + return null !== $this->validatedPurchaseVoucher + && $this->validatedPurchaseVoucher->isGoodwill(); + } + /** * Determines if the participant is a child based on age at current date. * diff --git a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php index a2a0e2d..3b9b87d 100644 --- a/src/Form/Service/ParticipantPromoVoucherFieldHandler.php +++ b/src/Form/Service/ParticipantPromoVoucherFieldHandler.php @@ -6,19 +6,27 @@ namespace App\Form\Service; use App\Form\Model\BookingDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; +use App\Service\BookingPriceCalculatorService; +use App\Service\VoucherValidationService; /** * Handles promotional voucher code field processing. * - * 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. + * Validates promo codes via API and stores the validated voucher data + * for early discount display. Validation is also performed by the + * PromoVoucher constraint on ParticipantEditDto for form error feedback. * * 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'; @@ -44,12 +52,42 @@ class ParticipantPromoVoucherFieldHandler extends AbstractParticipantFieldHandle public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool { - // No processing needed - validation handled by PromoVoucher constraint - return false; + $promoCode = $this->getFieldValue($submittedData, $this->getFieldName()); + + return null !== $promoCode && '' !== trim($promoCode); } public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { - // No processing needed - validation handled by PromoVoucher constraint + $participant = $this->getParticipant($bookingDto, $participantIndex); + + if (null === $participant) { + return; + } + + $promoCode = $this->getFieldValue($submittedData, $this->getFieldName()); + + // Clear validated voucher if code is empty + if (null === $promoCode || '' === trim($promoCode)) { + $participant->validatedPromoVoucher = null; + + return; + } + + // Calculate participant price for validation + $participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice( + $bookingDto, + $participantIndex + ); + + // Validate promo voucher and store result + $result = $this->voucherValidationService->validatePromoVoucher( + trim($promoCode), + $bookingDto->travel->id, + $participantPrice + ); + + // Store validated voucher (null if invalid) + $participant->validatedPromoVoucher = $result->isValid ? $result->voucher : null; } } diff --git a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php index 257c7ed..7235eb7 100644 --- a/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php +++ b/src/Form/Service/ParticipantPurchaseVoucherFieldHandler.php @@ -52,21 +52,17 @@ class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHan $voucherCode = $this->getFieldValue($submittedData, $this->getFieldName()); + // Clear validated voucher if code is empty if (null === $voucherCode || '' === trim($voucherCode)) { + $participant->validatedPurchaseVoucher = null; + return; } - // Validate voucher to check if it's goodwill type + // Validate voucher and store result $result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode); - // 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; - } + // Store validated voucher (null if invalid) + $participant->validatedPurchaseVoucher = $result->isValid ? $result->voucher : null; } } diff --git a/templates/booking/_summary.html.twig b/templates/booking/_summary.html.twig index d2faa7c..1fe9ec8 100644 --- a/templates/booking/_summary.html.twig +++ b/templates/booking/_summary.html.twig @@ -142,12 +142,52 @@ {# Total Section #} {% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
-
- Gesamtpreis: - - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} - -
+ {# Show subtotal and voucher discounts when vouchers are applied #} + {% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %} + {% if acceptedVouchers and acceptedVouchers.hasVouchers() %} + {# Subtotal before vouchers #} +
+ Gesamtpreis: + + €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + +
+ + {# Voucher discounts breakdown #} +
+ {% for voucher in acceptedVouchers.vouchers %} +
+ + {% if voucher.promotional %} + Aktionsgutschein ({{ voucher.code }}) + {% elseif voucher.goodwill %} + Kulanzgutschein ({{ voucher.code }}) + {% elseif voucher.purchase %} + Kaufgutschein ({{ voucher.code }}) + {% endif %} + + + -€{{ voucher.amount|number_format(2, ',', '.') }} + +
+ {% endfor %} +
+ + {# Amount to pay after vouchers #} +
+ Zu zahlen: + + €{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }} + +
+ {% else %} +
+ Gesamtpreis: + + €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + +
+ {% endif %}
{% endif %} diff --git a/templates/booking/create/step_4.html.twig b/templates/booking/create/step_4.html.twig index 5bdd4db..f991119 100644 --- a/templates/booking/create/step_4.html.twig +++ b/templates/booking/create/step_4.html.twig @@ -86,12 +86,51 @@ {# Grand Total #} {% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
-
- Gesamtpreis: - - €{{ pricingData.grandTotal|number_format(2, ',', '.') }} - -
+ {% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %} + {% if acceptedVouchers and acceptedVouchers.hasVouchers() %} + {# Subtotal before vouchers #} +
+ Gesamtpreis: + + €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + +
+ + {# Voucher discounts breakdown #} +
+ {% for voucher in acceptedVouchers.vouchers %} +
+ + {% if voucher.promotional %} + Aktionsgutschein ({{ voucher.code }}) + {% elseif voucher.goodwill %} + Kulanzgutschein ({{ voucher.code }}) + {% elseif voucher.purchase %} + Kaufgutschein ({{ voucher.code }}) + {% endif %} + + + -€{{ voucher.amount|number_format(2, ',', '.') }} + +
+ {% endfor %} +
+ + {# Amount to pay after vouchers #} +
+ Zu zahlen: + + €{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }} + +
+ {% else %} +
+ Gesamtpreis: + + €{{ pricingData.grandTotal|number_format(2, ',', '.') }} + +
+ {% endif %}
{% endif %} diff --git a/tests/BusProNet/XmlParser/BookingResponseParserTest.php b/tests/BusProNet/XmlParser/BookingResponseParserTest.php new file mode 100644 index 0000000..54b6d19 --- /dev/null +++ b/tests/BusProNet/XmlParser/BookingResponseParserTest.php @@ -0,0 +1,212 @@ +parser = new BookingResponseParser(); + } + + public function testParseBookingResponseWithPurchaseVoucher(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + 755,00 + + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + $this->assertTrue($response->isInquiryValid()); + $this->assertEquals('321530', $response->transactionNumber); + $this->assertEquals(755.0, $response->totalPrice); + + // Verify purchase voucher is parsed + $this->assertNotNull($response->paymentTerms); + $this->assertCount(1, $response->paymentTerms->appliedPurchaseVouchers); + $this->assertEquals('G1263', $response->paymentTerms->appliedPurchaseVouchers[0]['nummer']); + $this->assertEquals(100.0, $response->paymentTerms->appliedPurchaseVouchers[0]['betrag']); + $this->assertEquals(100.0, $response->paymentTerms->getPurchaseVoucherDiscount()); + $this->assertEquals(655.0, $response->paymentTerms->finalPaymentAmount); + } + + public function testParseBookingResponseWithPromotionalVoucher(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + + 615,20 + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + $this->assertTrue($response->isInquiryValid()); + $this->assertEquals(615.2, $response->totalPrice); + + // Verify price items include promotional voucher + $this->assertCount(2, $response->priceItems); + $voucherItem = $response->priceItems[1]; + $this->assertEquals('AKTION', $voucherItem->type); + $this->assertEquals('TESTERINO1', $voucherItem->label); + $this->assertEquals(-100.0, $voucherItem->totalPrice); + + // Verify voucher discount extraction + $voucherDiscounts = $response->getVoucherDiscountsFromPrices(); + $this->assertCount(1, $voucherDiscounts); + $this->assertEquals(100.0, $response->getVoucherDiscountFromPrices()); + $this->assertEquals(100.0, $response->getTotalVoucherDiscount()); + } + + public function testParseBookingResponseWithGoodwillVoucher(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + + 0,00 + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + $this->assertTrue($response->isInquiryValid()); + $this->assertEquals(0.0, $response->totalPrice); + + // Verify goodwill voucher is parsed + $voucherDiscounts = $response->getVoucherDiscountsFromPrices(); + $this->assertCount(1, $voucherDiscounts); + + $goodwillVoucher = array_values($voucherDiscounts)[0]; + $this->assertEquals('KULANZGUTSCHEIN', $goodwillVoucher->type); + $this->assertEquals('Kulanzgutschein K1904', $goodwillVoucher->label); + $this->assertEquals(-679.0, $goodwillVoucher->totalPrice); + + $this->assertEquals(679.0, $response->getVoucherDiscountFromPrices()); + $this->assertEquals(679.0, $response->getTotalVoucherDiscount()); + } + + public function testParseBookingResponseWithMultipleVouchers(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + + + 499,00 + + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + // Verify all voucher types are parsed + $this->assertEquals(80.0, $response->getVoucherDiscountFromPrices()); // 50 + 30 + $this->assertEquals(100.0, $response->getPurchaseVoucherDiscount()); + $this->assertEquals(180.0, $response->getTotalVoucherDiscount()); // 50 + 30 + 100 + + // Verify price item vouchers + $voucherDiscounts = $response->getVoucherDiscountsFromPrices(); + $this->assertCount(2, $voucherDiscounts); + } + + public function testParseBookingResponseWithNoVouchers(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + 679,00 + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + $this->assertTrue($response->isInquiryValid()); + $this->assertEquals(679.0, $response->totalPrice); + + // Verify no voucher discounts + $this->assertCount(0, $response->getVoucherDiscountsFromPrices()); + $this->assertEquals(0.0, $response->getVoucherDiscountFromPrices()); + $this->assertEquals(0.0, $response->getPurchaseVoucherDiscount()); + $this->assertEquals(0.0, $response->getTotalVoucherDiscount()); + } + + public function testParseBookingResponseWithMultiplePurchaseVouchers(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + 679,00 + + + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + // Verify multiple purchase vouchers are parsed + $this->assertNotNull($response->paymentTerms); + $this->assertCount(2, $response->paymentTerms->appliedPurchaseVouchers); + $this->assertEquals(125.0, $response->paymentTerms->getPurchaseVoucherDiscount()); // 50 + 75 + $this->assertEquals(125.0, $response->getTotalVoucherDiscount()); + } +} diff --git a/tests/Form/Model/AcceptedVouchersTest.php b/tests/Form/Model/AcceptedVouchersTest.php new file mode 100644 index 0000000..41e3001 --- /dev/null +++ b/tests/Form/Model/AcceptedVouchersTest.php @@ -0,0 +1,107 @@ +addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO10', + amount: 50.00 + )); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PURCHASE, + code: 'G1234', + amount: 100.00 + )); + + $this->assertEquals(150.0, $acceptedVouchers->getTotalDiscount()); + $this->assertCount(2, $acceptedVouchers->getVouchers()); + } + + public function testFilterVouchersByType(): void + { + $acceptedVouchers = new AcceptedVouchersDto(); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO10', + amount: 50.00 + )); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PURCHASE, + code: 'G1234', + amount: 100.00 + )); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_GOODWILL, + code: 'K5678', + amount: 75.00 + )); + + $this->assertCount(1, $acceptedVouchers->getPromotionalVouchers()); + $this->assertCount(1, $acceptedVouchers->getPurchaseVouchers()); + $this->assertCount(1, $acceptedVouchers->getGoodwillVouchers()); + } + + public function testHasVouchersReturnsFalseWhenEmpty(): void + { + $acceptedVouchers = new AcceptedVouchersDto(); + + $this->assertFalse($acceptedVouchers->hasVouchers()); + $this->assertEquals(0, $acceptedVouchers->count()); + } + + public function testHasVouchersReturnsTrueWhenNotEmpty(): void + { + $acceptedVouchers = new AcceptedVouchersDto(); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO10', + amount: 50.00 + )); + + $this->assertTrue($acceptedVouchers->hasVouchers()); + $this->assertEquals(1, $acceptedVouchers->count()); + } + + public function testFloatPrecisionHandling(): void + { + $acceptedVouchers = new AcceptedVouchersDto(); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO1', + amount: 33.33 + )); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO2', + amount: 33.33 + )); + + $acceptedVouchers->addVoucher(new AcceptedVoucherDto( + type: AcceptedVoucherDto::TYPE_PROMOTIONAL, + code: 'PROMO3', + amount: 33.34 + )); + + // Should be exactly 100.00 with proper rounding + $this->assertEquals(100.0, $acceptedVouchers->getTotalDiscount()); + } +}