diff --git a/src/BusProNet/Model/BookingResponse.php b/src/BusProNet/Model/BookingResponse.php index 2fb56ce..70afd5f 100644 --- a/src/BusProNet/Model/BookingResponse.php +++ b/src/BusProNet/Model/BookingResponse.php @@ -102,4 +102,45 @@ class BookingResponse 2 ); } + + /** + * Gets API-applied discounts that cannot be predicted by the local price calculator. + * + * The BusProNet API may apply automatic discounts based on business rules that + * are not known to the local application, such as: + * - ERM/GRU: Gruppenrabatt (group discount for large bookings) + * - Other ERM subtypes: Various automatic discounts + * + * These discounts appear as negative price items and must be accounted for when + * comparing the API total price against the locally calculated price. + * + * Note: Voucher discounts (AKTION, KULANZGUTSCHEIN) are handled separately via + * getVoucherDiscountFromPrices() as they are user-initiated, not automatic. + * + * @return array Price items representing API-applied automatic discounts + */ + public function getApiAppliedDiscounts(): array + { + return array_filter( + $this->priceItems, + fn (PriceItem $item) => $item->totalPrice < 0 + && false === \in_array($item->type, ['AKTION', 'KULANZGUTSCHEIN'], true) + ); + } + + /** + * Gets total discount from API-applied automatic discounts. + * + * Returns the absolute sum of negative price items that represent automatic + * discounts applied by the API (excluding voucher discounts which are handled separately). + */ + public function getApiAppliedDiscountTotal(): float + { + $discount = 0.0; + foreach ($this->getApiAppliedDiscounts() as $item) { + $discount += abs($item->totalPrice); + } + + return round($discount, 2); + } } diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index c402adf..f0f13d4 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -115,12 +115,15 @@ 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) + // API gesamtpreis includes: + // - Promotional/goodwill voucher discounts (negative price items with art=AKTION/KULANZGUTSCHEIN) + // - API-applied automatic discounts (e.g., Gruppenrabatt with art=ERM) + // - NOT purchase vouchers (those reduce restzahlung, not gesamtpreis) $apiTotal = round($inquiryResponse->totalPrice ?? 0.0, 2); $calculatedSubtotal = round($this->priceCalculator->calculateGrandTotal($bookingCreateDto), 2); $promoGoodwillDiscount = round($inquiryResponse->getVoucherDiscountFromPrices(), 2); - $expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount, 2); + $apiAppliedDiscount = round($inquiryResponse->getApiAppliedDiscountTotal(), 2); + $expectedTotal = round($calculatedSubtotal - $promoGoodwillDiscount - $apiAppliedDiscount, 2); if ((int) round($apiTotal * 100) !== (int) round($expectedTotal * 100)) { return $this->handleApiError( @@ -129,6 +132,7 @@ class Step3Controller extends AbstractController 'apiTotal' => $apiTotal, 'calculatedSubtotal' => $calculatedSubtotal, 'promoGoodwillDiscount' => $promoGoodwillDiscount, + 'apiAppliedDiscount' => $apiAppliedDiscount, 'expectedTotal' => $expectedTotal, 'difference' => abs($apiTotal - $expectedTotal), ], diff --git a/tests/BusProNet/XmlParser/BookingResponseParserTest.php b/tests/BusProNet/XmlParser/BookingResponseParserTest.php index e2c9e74..6000add 100644 --- a/tests/BusProNet/XmlParser/BookingResponseParserTest.php +++ b/tests/BusProNet/XmlParser/BookingResponseParserTest.php @@ -209,4 +209,107 @@ class BookingResponseParserTest extends TestCase $this->assertEquals(125.0, $response->paymentTerms->getPurchaseVoucherDiscount()); // 50 + 75 $this->assertEquals(125.0, $response->getTotalVoucherDiscount()); } + + public function testParseBookingResponseWithGruppenrabatt(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + + + 3972,00 + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + $this->assertTrue($response->isInquiryValid()); + $this->assertEquals(3972.0, $response->totalPrice); + + // Verify Gruppenrabatt is NOT counted as voucher discount + $this->assertCount(0, $response->getVoucherDiscountsFromPrices()); + $this->assertEquals(0.0, $response->getVoucherDiscountFromPrices()); + + // Verify Gruppenrabatt IS counted as API-applied discount + $apiAppliedDiscounts = $response->getApiAppliedDiscounts(); + $this->assertCount(1, $apiAppliedDiscounts); + + $gruppenrabatt = array_values($apiAppliedDiscounts)[0]; + $this->assertEquals('ERM', $gruppenrabatt->type); + $this->assertEquals('GRU', $gruppenrabatt->subType); + $this->assertEquals('Gruppenrabatt', $gruppenrabatt->label); + $this->assertEquals(-90.0, $gruppenrabatt->totalPrice); + + $this->assertEquals(90.0, $response->getApiAppliedDiscountTotal()); + } + + public function testParseBookingResponseWithGruppenrabattAndVouchers(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + + + 430,00 + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + // Voucher discount should only include AKTION + $this->assertEquals(20.0, $response->getVoucherDiscountFromPrices()); + + // API-applied discount should only include ERM (Gruppenrabatt) + $this->assertEquals(50.0, $response->getApiAppliedDiscountTotal()); + + // Verify they are separate and don't overlap + $voucherDiscounts = $response->getVoucherDiscountsFromPrices(); + $apiDiscounts = $response->getApiAppliedDiscounts(); + + $this->assertCount(1, $voucherDiscounts); + $this->assertCount(1, $apiDiscounts); + + $this->assertEquals('AKTION', array_values($voucherDiscounts)[0]->type); + $this->assertEquals('ERM', array_values($apiDiscounts)[0]->type); + } + + public function testParseBookingResponseWithNoApiAppliedDiscounts(): void + { + $xmlContent = ' + + + möglich + 321530 + + + + 679,00 + + + +'; + + $crawler = new Crawler($xmlContent); + $response = $this->parser->parse($crawler->filter('ergebnis')); + + // Verify no API-applied discounts + $this->assertCount(0, $response->getApiAppliedDiscounts()); + $this->assertEquals(0.0, $response->getApiAppliedDiscountTotal()); + } }