diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 4fe80a4..4f89656 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -6,6 +6,7 @@ namespace App\BusProNet\DataProcessor; use App\BusProNet\Constants; use App\BusProNet\Model\Booking; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DirectionMapper; use App\BusProNet\XmlLoader\AgencyLoader; @@ -362,12 +363,97 @@ class BookingDataProcessor */ public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array { + // Re-validate each participant's insurance against current eligibility (price tier, + // family constraint, etc.) before submission - selections can go stale between the + // moment they were chosen and final submission, since users can freely revisit + // earlier steps and change composition/services without ever re-opening the + // insurance field. Must run before the applicant-to-dependents propagation below, + // so that propagation is based on an already-corrected applicant selection. + $this->reconcileInsuranceEligibility($bookingDto); + // Apply bulk or family insurance if applicable (modifies DTO in place) $this->applyApplicantInsuranceToParticipants($bookingDto); return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType); } + /** + * Re-validates every participant's currently selected insurance against present-day + * eligibility (price tier, family constraint, age, travel/booking dates, duration) and + * corrects any selection that's gone stale. + * + * Insurance eligibility is normally re-checked when a participant's own card is + * resubmitted (see ParticipantInsuranceFieldHandler::processField()), but nothing forces + * that to happen again if the user changes composition or services on an earlier step + * after insurance was already chosen, then jumps straight to submission. Left unchecked, + * a stale insurance ID (e.g. a family-tariff price tier that no longer matches the + * current total booking price) would reach the BPN API and be rejected as a mismatch. + * + * Mirrors the reassign-or-clear logic already established in + * ParticipantInsuranceFieldHandler for the resubmission case, applied here across all + * participants right before the payload is built. + */ + private function reconcileInsuranceEligibility(BookingDto $bookingDto): bool + { + $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); + $changed = false; + + foreach ($bookingDto->getParticipants() as $index => $participant) { + $currentInsurance = $participant->insurance; + if (null === $currentInsurance || $currentInsurance->isNoInsurance()) { + continue; + } + + $travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $index, $currentInsurance); + $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); + + if ($this->isInsuranceInList($currentInsurance, $eligibleInsurances)) { + continue; + } + + $reassignedInsurance = $this->insuranceService->reassignInsuranceForPriceChange( + $selectableInsurances, + $currentInsurance, + $participant, + $bookingDto, + $travelPrice + ); + + if (null !== $reassignedInsurance) { + $participant->addNotification( + 'warning', + sprintf('Versicherung vor der Übermittlung automatisch angepasst: %s', $reassignedInsurance->label) + ); + } else { + $participant->addNotification( + 'warning', + 'Die gewählte Versicherung ist für diese Buchung nicht mehr verfügbar und wurde entfernt. Bitte erneut auswählen.' + ); + } + + $participant->insurance = $reassignedInsurance; + $changed = true; + } + + return $changed; + } + + /** + * Checks if a specific insurance exists in a list of insurances (by id). + * + * @param array $insuranceList + */ + private function isInsuranceInList(Insurance $targetInsurance, array $insuranceList): bool + { + foreach ($insuranceList as $insurance) { + if ($insurance->id === $targetInsurance->id) { + return true; + } + } + + return false; + } + /** * Applies the applicant's insurance to other participants if their selection requires it. * diff --git a/src/Controller/Booking/Create/Step3Controller.php b/src/Controller/Booking/Create/Step3Controller.php index 1542fc3..49b0e2e 100644 --- a/src/Controller/Booking/Create/Step3Controller.php +++ b/src/Controller/Booking/Create/Step3Controller.php @@ -19,6 +19,7 @@ use App\Service\BookingCreateContextFactory; use App\Service\BookingPriceCalculator; use App\Service\BookingPriceMismatchAnalyzer; use App\Service\BookingSessionManager; +use App\Service\ParticipantFormSupport; use App\Service\RoomPricingCalculator; use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormInterface; @@ -40,6 +41,7 @@ class Step3Controller extends AbstractBookingCreateController private readonly BookingPriceCalculator $priceCalculator, private readonly BookingPriceMismatchAnalyzer $priceMismatchDiagnostics, private readonly ApiClient $apiClient, + private readonly ParticipantFormSupport $participantFormSupportService, private readonly LoggerInterface $logger, ) { } @@ -76,6 +78,12 @@ class Step3Controller extends AbstractBookingCreateController // Validate booking data with API by submitting an inquiry booking $inquiryResponse = $this->apiClient->createBookingInquiry($bookingCreateDto); + // Surface any insurance corrections made during payload building (e.g. a + // stale price tier reassigned/cleared right before submission) + foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) { + $this->addFlash($notification['type'], $notification['message']); + } + if ($inquiryResponse instanceof Notification) { $this->handleApiError( $this->logger, diff --git a/src/Controller/Booking/Create/Step4Controller.php b/src/Controller/Booking/Create/Step4Controller.php index a3e0dec..ab52778 100644 --- a/src/Controller/Booking/Create/Step4Controller.php +++ b/src/Controller/Booking/Create/Step4Controller.php @@ -20,6 +20,7 @@ use App\Service\BookingConfigurator; use App\Service\BookingCreateContextFactory; use App\Service\BookingSessionManager; use App\Service\NewsletterManager; +use App\Service\ParticipantFormSupport; use Psr\Log\LoggerInterface; use Symfony\Component\Form\FormInterface; use Symfony\Component\HttpFoundation\Request; @@ -41,6 +42,7 @@ class Step4Controller extends AbstractBookingCreateController private readonly ApiClient $apiClient, private readonly CacheInterface $cache, private readonly NewsletterManager $doubleOptInService, + private readonly ParticipantFormSupport $participantFormSupportService, private readonly LoggerInterface $logger, ) { } @@ -78,6 +80,12 @@ class Step4Controller extends AbstractBookingCreateController // Submit final booking (already validated in Step 3) $bookingResponse = $this->apiClient->createBooking($bookingCreateDto); + // Surface any insurance corrections made during payload building (e.g. a + // stale price tier reassigned/cleared right before submission) + foreach ($this->participantFormSupportService->collectAndClearNotifications($bookingCreateDto) as $notification) { + $this->addFlash($notification['type'], $notification['message']); + } + if ($bookingResponse instanceof Notification) { $this->handleApiError( $this->logger, diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index d3ba4cb..6a02e64 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -70,6 +70,21 @@ class BookingDto */ public ?string $originalFingerprint = null; + /** + * Whether the applicant has already been notified that family insurance became + * available after they picked a non-family insurance while dependents were incomplete. + * Prevents re-showing the same hint on every subsequent participant card submission. + */ + public bool $familyInsuranceHintShown = false; + + /** + * Whether the applicant's current insurance choice was made while family insurance + * was not yet eligible (i.e. before all dependents' dates of birth were known). + * Set at selection time; used to distinguish "family insurance just became + * available" from "it was available all along and the applicant chose otherwise". + */ + public bool $applicantInsuranceChosenWhileFamilyIneligible = false; + public function __construct(public Travel $travel, public int $hotelId) { } diff --git a/src/Form/Model/ParticipantEditDto.php b/src/Form/Model/ParticipantEditDto.php index a384f19..3f6dfd3 100644 --- a/src/Form/Model/ParticipantEditDto.php +++ b/src/Form/Model/ParticipantEditDto.php @@ -67,9 +67,9 @@ class ParticipantEditDto * Validates that insurance is selected when required. * * Insurance is required for all participants in create mode, EXCEPT for dependent - * participants when the applicant has enabled bulk insurance booking. In that case, - * the insurance field is hidden and will be automatically assigned by the bulk - * insurance handler. + * participants when the applicant has enabled bulk insurance booking, or when the + * applicant has a family insurance selected. In both cases, the insurance field is + * hidden and coverage is derived from the applicant instead. * * In edit submissions, this validation is skipped entirely because insurance * data is readonly and preserved as-is from the BPN API. @@ -90,6 +90,10 @@ class ParticipantEditDto if (true === $applicant?->bulkInsuranceBooking) { return; } + + if (null !== $applicant?->insurance && true === $applicant->insurance->familyInsurance) { + return; + } } if (null === $this->participant->insurance) { diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index 250847c..e39ae6f 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -6,6 +6,7 @@ namespace App\Form\Service; use App\BusProNet\Model\Insurance; use App\Form\Model\BookingDto; +use App\Form\Model\ParticipantDto; use App\Form\Service\Abstract\AbstractParticipantFieldHandler; use App\Service\BookingPriceCalculator; use App\Service\InsuranceManager; @@ -124,6 +125,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler * @param int $participantIndex The index of the participant being processed */ public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void + { + $this->applyInsuranceSelection($submittedData, $bookingDto, $participantIndex); + + // Runs after the selection above is fully resolved, so a submission that itself + // switches the applicant to family insurance doesn't trigger a stale "please + // recheck" notification about the choice it just made. + $this->notifyApplicantIfFamilyInsuranceNewlyAvailable($bookingDto); + } + + /** + * @param array $submittedData The submitted participant form data + */ + private function applyInsuranceSelection(array $submittedData, BookingDto $bookingDto, int $participantIndex): void { $participant = $bookingDto->getParticipant($participantIndex); if (null === $participant) { @@ -152,6 +166,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler $noInsurance->price = 0.0; $participant->insurance = $noInsurance; + $this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance); + return; // Skip all other processing for "no insurance" } @@ -188,10 +204,15 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler // Insurance not found - clear selection $participant->insurance = null; } + + $this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $participant->insurance); } // Handle form resubmission with existing insurance (automatic reassignment check) - if (null !== $currentInsurance && null !== $selectedInsuranceId) { + // Only applies when the selection didn't just change above - otherwise this would + // re-validate the now-stale $currentInsurance and could overwrite the fresh selection + // the block above already made in this same request. + if (!$isNewSelection && null !== $currentInsurance && null !== $selectedInsuranceId) { // Preserve "no insurance" selection during resubmission if ($currentInsurance->isNoInsurance()) { return; @@ -260,6 +281,95 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return []; } + /** + * Records whether the applicant's insurance choice was made while family insurance + * was NOT yet eligible, so the notification below can tell "just became available" + * apart from "was available all along and the applicant chose non-family anyway". + * + * Only called when the applicant (index 0) makes a genuine new selection - not on + * mere resubmission/price-tier reassignment of an already-standing choice. + */ + private function recordFamilyIneligibilityAtSelectionTime(BookingDto $bookingDto, int $participantIndex, ?Insurance $insurance): void + { + if (0 !== $participantIndex || null === $insurance || true === $insurance->familyInsurance) { + return; + } + + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant) { + return; + } + + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->hasEligibleFamilyInsurance($bookingDto, $applicant); + } + + /** + * Checks whether a family insurance is actually eligible right now - not just whether + * the participant composition qualifies as a family booking, but whether a selectable + * family insurance product actually matches the current total booking price. + * + * Used by both the selection-time recording above and the notification below, so they + * can never disagree on what "family insurance is eligible" means. + */ + private function hasEligibleFamilyInsurance(BookingDto $bookingDto, ParticipantDto $applicant): bool + { + if (!$bookingDto->isFamilyBooking()) { + return false; + } + + $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); + $familyInsurances = array_values(array_filter($selectableInsurances, static fn (Insurance $i) => true === $i->familyInsurance)); + + if ([] === $familyInsurances) { + return false; + } + + $totalPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto); + + return [] !== $this->insuranceService->getEligibleInsurances($familyInsurances, $applicant, $bookingDto, $totalPrice); + } + + /** + * Notifies the applicant when family insurance becomes available after the fact. + * + * Participants are entered one card at a time, in any order, so the applicant is + * likely to pick a non-family insurance before any dependent's date of birth makes + * the booking eligible for family insurance. Once that happens, nothing prompts the + * applicant to revisit their earlier choice. This surfaces a one-time hint via the + * existing notification/toast pipeline (App\Form\Model\ParticipantDto::addNotification()), + * mirroring the cross-participant side effect already used by + * ParticipantAssignedRoomFieldHandler::resolveRoomCapacityConflict() for room conflicts. + * + * Requires `applicantInsuranceChosenWhileFamilyIneligible` to be true - i.e. the + * choice was actually made before family insurance was possible - not just that + * family insurance happens to be eligible now (it may have been all along). + */ + private function notifyApplicantIfFamilyInsuranceNewlyAvailable(BookingDto $bookingDto): void + { + if ($bookingDto->familyInsuranceHintShown) { + return; + } + + if (!$bookingDto->applicantInsuranceChosenWhileFamilyIneligible) { + return; + } + + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) { + return; + } + + if (!$this->hasEligibleFamilyInsurance($bookingDto, $applicant)) { + return; + } + + $applicant->addNotification( + 'info', + 'Für eure Konstellation ist auch eine Familienversicherung verfügbar und kann über die anmeldende Person gebucht werden.' + ); + $bookingDto->familyInsuranceHintShown = true; + } + /** * Finds an insurance by ID from the available insurances array. * diff --git a/templates/_partials/_flashes.html.twig b/templates/_partials/_flashes.html.twig index 637de56..cf0c9b1 100644 --- a/templates/_partials/_flashes.html.twig +++ b/templates/_partials/_flashes.html.twig @@ -28,3 +28,13 @@ {% endblock %} {% endembed %} {% endif %} +{% if app.session.flashBag.peek('warning')|length > 0 %} + {% embed '_partials/_modal.html.twig' with { 'level': 'warning', 'modal': true } %} + {% block title %} + Hinweis + {% endblock %} + {% block content %} + {% include '_partials/_alert.html.twig' with { level: 'warning', messages: app.flashes('warning') } %} + {% endblock %} + {% endembed %} +{% endif %} diff --git a/templates/_partials/_modal.html.twig b/templates/_partials/_modal.html.twig index db160aa..dd9786b 100644 --- a/templates/_partials/_modal.html.twig +++ b/templates/_partials/_modal.html.twig @@ -1,9 +1,9 @@
-
+
-
+
{% block title %}{% endblock %}