feat: improved promotional and purchase vouchers

This commit is contained in:
Björn Fromme
2026-03-16 11:59:11 +01:00
parent 7df99521c4
commit fb983f23dd
15 changed files with 357 additions and 137 deletions
+9
View File
@@ -127,6 +127,15 @@ 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).
* Set by ParticipantPurchaseVoucherFieldHandler during form processing.
*/
public bool $hasGoodwillVoucher = false;
/**
* Promo voucher code.
* Applied per participant in booking payload as <aktionscode>.
+3
View File
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace App\Form\Model;
use App\Validator\Constraints as AppAssert;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
@@ -15,6 +16,8 @@ use Symfony\Component\Validator\Context\ExecutionContextInterface;
* the participant being edited and the full booking context needed for
* cross-participant validation.
*/
#[AppAssert\PurchaseVoucher(groups: ['booking_create', 'booking_edit'])]
#[AppAssert\PromoVoucher(groups: ['booking_create', 'booking_edit'])]
class ParticipantEditDto
{
public function __construct(
@@ -6,33 +6,19 @@ namespace App\Form\Service;
use App\Form\Model\BookingDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculatorService;
use App\Service\VoucherValidationService;
/**
* Handles processing and validation of promotional voucher codes for booking participants.
* Handles promotional voucher code field processing.
*
* This handler validates promo vouchers via the BPN API and provides immediate feedback
* about voucher validity, discount amounts, and applicability. Promo vouchers can be
* applied per person or per booking, using absolute discount amounts (never percentages).
*
* Validation includes:
* - Code validity check
* - Travel applicability verification
* - Minimum price threshold validation
* - Real-time API validation with 15-minute caching
* 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.
*
* 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';
@@ -58,66 +44,12 @@ class ParticipantPromoVoucherFieldHandler extends AbstractParticipantFieldHandle
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
return null !== $promoCode && '' !== trim($promoCode);
// No processing needed - validation handled by PromoVoucher constraint
return false;
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (null === $participant) {
return;
}
$promoCode = $this->getFieldValue($submittedData, $this->getFieldName());
if (null === $promoCode || '' === trim($promoCode)) {
return;
}
// Calculate participant's current price including all services
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice(
$bookingDto,
$participantIndex
);
// Get travel ID for validation
$travelId = $bookingDto->travel->id;
if (null === $travelId) {
$participant->addNotification(
'error',
'Aktionscode konnte nicht validiert werden: Reise-ID fehlt.'
);
return;
}
// Validate promo voucher via API
$result = $this->voucherValidationService->validatePromoVoucher(
$promoCode,
$travelId,
$participantPrice
);
if (true === $result->isValid && null !== $result->voucher) {
$applicability = $result->voucher->isPerPerson() ? 'je Person' : 'pro Buchung';
$participant->addNotification(
'success',
sprintf(
'Aktionscode gültig: %s (Rabatt: %.2f € %s)',
$result->voucher->description,
$result->voucher->discountAmount,
$applicability
)
);
} else {
$participant->addNotification(
'error',
sprintf('Aktionscode ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler')
);
}
// No processing needed - validation handled by PromoVoucher constraint
}
}
@@ -9,17 +9,11 @@ use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\VoucherValidationService;
/**
* Handles processing and validation of purchase voucher codes for booking participants.
* Handles purchase voucher code field processing.
*
* This handler validates purchase vouchers (including goodwill vouchers) via the BPN API
* and provides immediate feedback to users about voucher validity and remaining balance.
* Purchase vouchers reduce the booking price by their remaining balance and support
* partial redemption.
*
* Validation includes:
* - Voucher existence check
* - Remaining balance verification (zero-balance vouchers are reported as invalid)
* - Real-time API validation with 15-minute caching
* Detects goodwill (Kulanz) vouchers and marks them for special XML handling.
* Goodwill vouchers must be sent as <aktionscode> per participant, not aggregated
* in <gutscheine> collection. Validation is performed by PurchaseVoucher constraint.
*
* Dependencies: None (purchase vouchers don't require price calculation)
*/
@@ -62,23 +56,17 @@ class ParticipantPurchaseVoucherFieldHandler extends AbstractParticipantFieldHan
return;
}
// Validate voucher via API
// Validate voucher to check if it's goodwill type
$result = $this->voucherValidationService->validatePurchaseVoucher($voucherCode);
if (true === $result->isValid && null !== $result->voucher) {
$participant->addNotification(
'success',
sprintf(
'Gutschein gültig: %s (Restwert: %.2f €)',
$result->voucher->type,
$result->voucher->remainingBalance
)
);
} else {
$participant->addNotification(
'error',
sprintf('Gutschein ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler')
);
// 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;
}
}
}