feat: fold purchase and promo voucher fields into one code field

This commit is contained in:
fromme committed 2026-10-06 10:41:30 +02:00
1 parent 93512b8345
commit 21d756a2e3
21 files changed
+801 -432

No files matched your search

@@ -0,0 +1,221 @@
<?php
declare(strict_types=1);
namespace App\Form\Service;
use App\Enum\VoucherCodeKind;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\VoucherCodeClassifier;
use App\Service\VoucherValidator;
/**
* Handles the single voucher field of the booking create flow.
*
* The typed code is classified as promo or purchase code (VoucherCodeClassifier), checked with
* BusPro and, when accepted, moved into promoVoucherCode or purchaseVoucherCode, which the payload
* builder reads. The field is then cleared; a rejected code stays with its reason in voucherCodeError.
* A participant holds at most one code of each kind.
*
* Runs on every create-mode submission, also without a typed code, because an applied promo code
* is checked against the participant price, which may have changed since it was applied.
*
* Dependencies: ALL price-affecting fields (insurance, services, ski pass, rentals, etc.)
* since promo validation requires accurate participant pricing.
*/
class ParticipantVoucherFieldHandler extends AbstractParticipantFieldHandler
{
public function __construct(
private readonly VoucherValidator $voucherValidationService,
private readonly BookingPriceCalculator $priceCalculatorService,
private readonly VoucherCodeClassifier $voucherCodeClassifier,
) {
}
public function getFieldName(): string
{
return 'voucherCode';
}
public function getDependencies(): array
{
// CRITICAL: Must run after ALL price-affecting handlers to ensure accurate pricing
return [
'dateOfBirth', // Required for age-based services
'skiPass', // Affects travel price
'rentals', // Affects travel price
'courses', // Affects travel price
'additionalServices', // Affects travel price
'board', // Affects travel price
'transportationOutbound', // Affects travel price
'transportationInbound', // Affects travel price
'pickup', // Affects travel price
'parking', // Affects travel price
'insurance', // Affects travel price (must be last)
];
}
public function shouldProcess(array $submittedData, string $mode, int $participantIndex): bool
{
// Vouchers can only be redeemed when creating a booking
return BookingDto::MODE_CREATE === $mode;
}
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$participant = $this->getParticipant($bookingDto, $participantIndex);
if (!$participant instanceof ParticipantDto) {
return;
}
$this->removeRequestedCode($submittedData, $participant);
$this->recheckAppliedPromoCode($bookingDto, $participant, $participantIndex);
$this->applyTypedCode($submittedData, $bookingDto, $participant, $participantIndex);
}
/**
* @param array<string, mixed> $submittedData
*/
private function removeRequestedCode(array $submittedData, ParticipantDto $participant): void
{
$participant->removeVoucherCode = null;
$code = $this->getFieldValue($submittedData, 'removeVoucherCode');
if (false === is_string($code) || '' === trim($code)) {
return;
}
$code = $this->voucherCodeClassifier->normalize($code);
if ($code === $participant->promoVoucherCode) {
$participant->promoVoucherCode = null;
$participant->validatedPromoVoucher = null;
$participant->promoVoucherError = null;
}
if ($code === $participant->purchaseVoucherCode) {
$participant->purchaseVoucherCode = null;
$participant->validatedPurchaseVoucher = null;
}
}
private function recheckAppliedPromoCode(BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
if (null === $participant->promoVoucherCode) {
return;
}
$error = $this->validatePromoCode($participant->promoVoucherCode, $bookingDto, $participant, $participantIndex);
$participant->promoVoucherError = $error;
}
/**
* @param array<string, mixed> $submittedData
*/
private function applyTypedCode(array $submittedData, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): void
{
$participant->voucherCodeError = null;
// Only a submitted field carries a typed code; when the field is hidden nothing may linger
$typedCode = $this->getFieldValue($submittedData, $this->getFieldName());
if (false === is_string($typedCode) || '' === trim($typedCode)) {
$participant->voucherCode = null;
return;
}
$participant->voucherCode = trim($typedCode);
$code = $this->voucherCodeClassifier->normalize($typedCode);
$error = match (true) {
$code === $participant->promoVoucherCode, $code === $participant->purchaseVoucherCode => 'Dieser Code ist bereits eingelöst.',
default => match ($this->voucherCodeClassifier->classify($code)) {
VoucherCodeKind::Malformed => 'Dieser Code ist ungültig. Bitte prüfe deine Eingabe.',
VoucherCodeKind::Promo => $this->applyPromoCode($code, $bookingDto, $participant, $participantIndex),
VoucherCodeKind::Purchase => $this->applyPurchaseCode($code, $participant),
},
};
if (null === $error) {
$participant->voucherCode = null;
}
$participant->voucherCodeError = $error;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
if (null !== $participant->promoVoucherCode) {
return sprintf('Es kann nur ein Aktionscode eingelöst werden. Bitte entferne zuerst %s.', $participant->promoVoucherCode);
}
$error = $this->validatePromoCode($code, $bookingDto, $participant, $participantIndex);
if (null !== $error) {
return $error;
}
$participant->promoVoucherCode = $code;
$participant->promoVoucherError = null;
return null;
}
/**
* @return string|null Why the code was not applied, or null when it was
*/
private function applyPurchaseCode(string $code, ParticipantDto $participant): ?string
{
if (null !== $participant->purchaseVoucherCode) {
return sprintf('Es kann nur ein Gutschein eingelöst werden. Bitte entferne zuerst %s.', $participant->purchaseVoucherCode);
}
$result = $this->voucherValidationService->validatePurchaseVoucher($code);
if (false === $result->isValid) {
return sprintf('Gutschein ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->purchaseVoucherCode = $code;
$participant->validatedPurchaseVoucher = $result->voucher;
return null;
}
/**
* Checks a promo code against the participant's current price and stores the validated voucher.
*
* @return string|null Why BusPro rejected the code, or null when it is valid
*/
private function validatePromoCode(string $code, BookingDto $bookingDto, ParticipantDto $participant, int $participantIndex): ?string
{
$travelId = $bookingDto->travel->id;
if (null === $travelId) {
$participant->validatedPromoVoucher = null;
return 'Aktionscode konnte nicht validiert werden: Reise-ID fehlt.';
}
$participantPrice = $this->priceCalculatorService->calculateIndividualParticipantPrice($bookingDto, $participantIndex);
$result = $this->voucherValidationService->validatePromoVoucher($code, $travelId, $participantPrice);
if (false === $result->isValid) {
// Only an applied code is replaced: a rejected new code must not discard the applied one's discount
if ($code === $participant->promoVoucherCode) {
$participant->validatedPromoVoucher = null;
}
return sprintf('Aktionscode ungültig: %s', $result->errorMessage ?? 'Unbekannter Fehler');
}
$participant->validatedPromoVoucher = $result->voucher;
return null;
}
}