feat: finalize voucher handling and discount display in summary and overview

This commit is contained in:
Björn Fromme
2025-11-26 17:29:15 +01:00
parent 32bef9a4e9
commit f4691ec67e
15 changed files with 762 additions and 46 deletions
+41
View File
@@ -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;
}
}
+72
View File
@@ -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);
}
}
+60 -1
View File
@@ -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;
}
}
+24 -5
View File
@@ -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;
}
}