feat: finalize voucher handling and discount display in summary and overview
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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<PriceItem> 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<array{nummer: string, betrag: float}> $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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
/**
|
||||
* Represents a voucher discount accepted by the BPN API.
|
||||
*
|
||||
* This is the base class for all voucher types (promotional, purchase, goodwill).
|
||||
* Instances are created from API responses and stored in BookingDto for display.
|
||||
*/
|
||||
class AcceptedVoucherDto
|
||||
{
|
||||
public const TYPE_PROMOTIONAL = 'promotional';
|
||||
public const TYPE_PURCHASE = 'purchase';
|
||||
public const TYPE_GOODWILL = 'goodwill';
|
||||
|
||||
public function __construct(
|
||||
public readonly string $type,
|
||||
public readonly string $code,
|
||||
public readonly float $amount,
|
||||
public readonly ?string $description = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isPromotional(): bool
|
||||
{
|
||||
return self::TYPE_PROMOTIONAL === $this->type;
|
||||
}
|
||||
|
||||
public function isPurchase(): bool
|
||||
{
|
||||
return self::TYPE_PURCHASE === $this->type;
|
||||
}
|
||||
|
||||
public function isGoodwill(): bool
|
||||
{
|
||||
return self::TYPE_GOODWILL === $this->type;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Form\Model;
|
||||
|
||||
/**
|
||||
* Collection of accepted vouchers from BPN API response.
|
||||
*
|
||||
* Stores voucher discounts parsed from booking inquiry/creation responses.
|
||||
* Used to display voucher discounts in booking summaries and for price validation.
|
||||
*/
|
||||
class AcceptedVouchersDto
|
||||
{
|
||||
/** @var array<AcceptedVoucherDto> */
|
||||
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<AcceptedVoucherDto>
|
||||
*/
|
||||
public function getVouchers(): array
|
||||
{
|
||||
return $this->vouchers;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<AcceptedVoucherDto>
|
||||
*/
|
||||
public function getPromotionalVouchers(): array
|
||||
{
|
||||
return array_filter($this->vouchers, fn (AcceptedVoucherDto $v) => $v->isPromotional());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<AcceptedVoucherDto>
|
||||
*/
|
||||
public function getPurchaseVouchers(): array
|
||||
{
|
||||
return array_filter($this->vouchers, fn (AcceptedVoucherDto $v) => $v->isPurchase());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<AcceptedVoucherDto>
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<int, float>|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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 <aktionscode> per participant, not in <gutscheine> 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<array{type: string, message: string}> 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 <aktionscode> per participant, not in <gutscheine> 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.
|
||||
*
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,12 +142,52 @@
|
||||
{# Total Section #}
|
||||
{% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
|
||||
<div class="pt-4 border-t-2 border-gray-300">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span>
|
||||
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{# Show subtotal and voucher discounts when vouchers are applied #}
|
||||
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
|
||||
{% if acceptedVouchers and acceptedVouchers.hasVouchers() %}
|
||||
{# Subtotal before vouchers #}
|
||||
<div class="flex justify-between items-center mb-2">
|
||||
<span class="text-gray-600">Gesamtpreis:</span>
|
||||
<span class="text-gray-900">
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Voucher discounts breakdown #}
|
||||
<div class="mb-3 space-y-1">
|
||||
{% for voucher in acceptedVouchers.vouchers %}
|
||||
<div class="flex justify-between items-center text-sm">
|
||||
<span class="text-gray-600">
|
||||
{% if voucher.promotional %}
|
||||
Aktionsgutschein ({{ voucher.code }})
|
||||
{% elseif voucher.goodwill %}
|
||||
Kulanzgutschein ({{ voucher.code }})
|
||||
{% elseif voucher.purchase %}
|
||||
Kaufgutschein ({{ voucher.code }})
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="font-medium text-green-600">
|
||||
-€{{ voucher.amount|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Amount to pay after vouchers #}
|
||||
<div class="flex justify-between items-center pt-2 border-t border-gray-200">
|
||||
<span class="font-bold text-lg text-gray-800">Zu zahlen:</span>
|
||||
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
|
||||
€{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold text-lg text-gray-800">Gesamtpreis:</span>
|
||||
<span class="font-bold text-xl text-gray-900" {{ qa_attribute('summary-total') }}>
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -86,12 +86,51 @@
|
||||
{# Grand Total #}
|
||||
{% if pricingData.grandTotal is defined and pricingData.grandTotal > 0 %}
|
||||
<div class="pt-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold text-xl text-blue-900">Gesamtpreis:</span>
|
||||
<span class="font-bold text-2xl text-blue-900">
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% set acceptedVouchers = bookingCreateDto.getAcceptedVouchers() %}
|
||||
{% if acceptedVouchers and acceptedVouchers.hasVouchers() %}
|
||||
{# Subtotal before vouchers #}
|
||||
<div class="flex justify-between items-center mb-3">
|
||||
<span class="text-lg text-blue-800">Gesamtpreis:</span>
|
||||
<span class="text-lg text-blue-800">
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{# Voucher discounts breakdown #}
|
||||
<div class="mb-4 space-y-2 pb-4 border-b border-blue-200">
|
||||
{% for voucher in acceptedVouchers.vouchers %}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-blue-700">
|
||||
{% if voucher.promotional %}
|
||||
Aktionsgutschein ({{ voucher.code }})
|
||||
{% elseif voucher.goodwill %}
|
||||
Kulanzgutschein ({{ voucher.code }})
|
||||
{% elseif voucher.purchase %}
|
||||
Kaufgutschein ({{ voucher.code }})
|
||||
{% endif %}
|
||||
</span>
|
||||
<span class="font-semibold text-green-600">
|
||||
-€{{ voucher.amount|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{# Amount to pay after vouchers #}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold text-xl text-blue-900">Zu zahlen:</span>
|
||||
<span class="font-bold text-2xl text-blue-900">
|
||||
€{{ (pricingData.grandTotal - acceptedVouchers.totalDiscount)|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="font-bold text-xl text-blue-900">Gesamtpreis:</span>
|
||||
<span class="font-bold text-2xl text-blue-900">
|
||||
€{{ pricingData.grandTotal|number_format(2, ',', '.') }}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\BusProNet\XmlParser;
|
||||
|
||||
use App\BusProNet\XmlParser\BookingResponseParser;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Symfony\Component\DomCrawler\Crawler;
|
||||
|
||||
class BookingResponseParserTest extends TestCase
|
||||
{
|
||||
private BookingResponseParser $parser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->parser = new BookingResponseParser();
|
||||
}
|
||||
|
||||
public function testParseBookingResponseWithPurchaseVoucher(): void
|
||||
{
|
||||
$xmlContent = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
</preise>
|
||||
<gesamtpreis>755,00</gesamtpreis>
|
||||
<zahlungsbedingungen>
|
||||
<kaufgutschein nummer="G1263" betrag="100,00"></kaufgutschein>
|
||||
<restzahlung betrag="655,00" termin="02.12.2029"></restzahlung>
|
||||
</zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
<preis position="2" art="AKTION" unterart="" bezeichnung="TESTERINO1" anzahl="1" zuordnung="1" preis="-100,00" gesamtpreis="-100,00"></preis>
|
||||
</preise>
|
||||
<gesamtpreis>615,20</gesamtpreis>
|
||||
<zahlungsbedingungen>
|
||||
<restzahlung betrag="615,20" termin="29.11.2025"></restzahlung>
|
||||
</zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
<preis position="2" art="KULANZGUTSCHEIN" unterart="" bezeichnung="Kulanzgutschein K1904" anzahl="1" zuordnung="1" preis="-679,00" gesamtpreis="-679,00"></preis>
|
||||
</preise>
|
||||
<gesamtpreis>0,00</gesamtpreis>
|
||||
<zahlungsbedingungen></zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
<preis position="2" art="AKTION" unterart="" bezeichnung="PROMO10" anzahl="1" zuordnung="1" preis="-50,00" gesamtpreis="-50,00"></preis>
|
||||
<preis position="3" art="KULANZGUTSCHEIN" unterart="" bezeichnung="Kulanzgutschein K123" anzahl="1" zuordnung="1" preis="-30,00" gesamtpreis="-30,00"></preis>
|
||||
</preise>
|
||||
<gesamtpreis>499,00</gesamtpreis>
|
||||
<zahlungsbedingungen>
|
||||
<kaufgutschein nummer="G5678" betrag="100,00"></kaufgutschein>
|
||||
<restzahlung betrag="399,00" termin="02.12.2029"></restzahlung>
|
||||
</zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
</preise>
|
||||
<gesamtpreis>679,00</gesamtpreis>
|
||||
<zahlungsbedingungen>
|
||||
<restzahlung betrag="679,00" termin="02.12.2029"></restzahlung>
|
||||
</zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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 = '<?xml version="1.0" encoding="utf-8"?>
|
||||
<ergebnis>
|
||||
<satz typ="BUCHUNG" />
|
||||
<buchung>möglich</buchung>
|
||||
<vorgang>321530</vorgang>
|
||||
<preise>
|
||||
<preis position="1" art="UNT" unterart="ZIM" bezeichnung="Bett im Mehrbettzimmer" preis="679,00" gesamtpreis="679,00" />
|
||||
</preise>
|
||||
<gesamtpreis>679,00</gesamtpreis>
|
||||
<zahlungsbedingungen>
|
||||
<kaufgutschein nummer="G1111" betrag="50,00"></kaufgutschein>
|
||||
<kaufgutschein nummer="G2222" betrag="75,00"></kaufgutschein>
|
||||
<restzahlung betrag="554,00" termin="02.12.2029"></restzahlung>
|
||||
</zahlungsbedingungen>
|
||||
</ergebnis>';
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Tests\Form\Model;
|
||||
|
||||
use App\Form\Model\AcceptedVoucherDto;
|
||||
use App\Form\Model\AcceptedVouchersDto;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class AcceptedVouchersTest extends TestCase
|
||||
{
|
||||
public function testAddVoucherCalculatesTotalDiscount(): 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
|
||||
));
|
||||
|
||||
$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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user