diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index d3ba4cb1..6a02e644 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/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index 250847c1..e39ae6fd 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/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php index 60931bff..699ace3f 100644 --- a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php @@ -198,6 +198,36 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase $this->assertNull($participant->insurance); } + public function testProcessFieldSwitchingToNewInsuranceIsNotOverwrittenByStaleResubmissionCheck(): void + { + // Regression: switching from one already-selected insurance to a different one must + // not be re-validated (and potentially overwritten) against the OLD, now-stale + // insurance by the "form resubmission" block afterward. + $oldInsurance = $this->createInsurance('1'); + $newInsurance = $this->createInsurance('2'); + + $participant = new ParticipantDto(); + $participant->insurance = $oldInsurance; + + $travel = new Travel(); + $travel->insurances = [$oldInsurance, $newInsurance]; + + $bookingDto = $this->createMockBookingDto(); + $bookingDto->method('getParticipant')->with(0)->willReturn($participant); + $bookingDto->travel = $travel; + + // Only the NEW insurance is ever eligible - the old one would fail re-validation + // if the (buggy) resubmission block ran a second time after this new selection. + $this->insuranceService + ->expects($this->once()) + ->method('getEligibleInsurances') + ->willReturn([$newInsurance]); + + $this->handler->processField(['insurance' => '2'], $bookingDto, 0); + + $this->assertSame($newInsurance, $participant->insurance); + } + public function testProcessFieldWorksWithStringAndIntegerIds(): void { $insurance = $this->createInsurance(123); // Integer ID @@ -249,6 +279,250 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase $this->assertNull($participant->insurance); } + public function testProcessFieldNotifiesApplicantWhenFamilyInsuranceNewlyAvailable(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $applicant = $bookingDto->getParticipant(0); + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); + $this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2', 'Reise-Rücktritt Familie')]); + + // Dependent's own card is submitted (e.g. just their date of birth) - no insurance selection + $this->handler->processField([], $bookingDto, 1); + + $this->assertNotEmpty($applicant->notifications, 'Applicant should be notified that family insurance is now available'); + $this->assertTrue($bookingDto->familyInsuranceHintShown); + } + + public function testProcessFieldRecordsIneligibilityWhenApplicantSelectsInsuranceBeforeFamilyEligible(): void + { + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + $travel->insurances = [$nonFamilyInsurance]; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + + // No dependents yet - not (and cannot be) a family booking at selection time + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant]; + + $this->insuranceService->method('getEligibleInsurances')->willReturn([$nonFamilyInsurance]); + + $this->handler->processField(['insurance' => '1'], $bookingDto, 0); + + $this->assertTrue( + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, + 'Choosing non-family insurance while ineligible must be recorded so a later hint is justified' + ); + } + + public function testProcessFieldDoesNotRecordIneligibilityWhenApplicantSelectsInsuranceWhileAlreadyFamilyEligible(): void + { + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + $travel->insurances = [$nonFamilyInsurance, $familyInsurance]; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + + $child = new ParticipantDto(); + $child->index = 1; + $child->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + // Dependent's date of birth is already known, and a family insurance product is + // actually eligible at the current price - family insurance was genuinely + // available at the moment the applicant deliberately chose a non-family insurance. + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $child]; + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); + $this->insuranceService->method('getEligibleInsurances')->willReturnCallback( + fn (array $insurances) => $insurances + ); + + $this->handler->processField(['insurance' => '1'], $bookingDto, 0); + + $this->assertFalse( + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, + 'Must not claim family insurance "just became available" when it was already eligible at selection time' + ); + } + + public function testProcessFieldRecordsIneligibilityWhenFamilyCompositionExistsButNoPriceTierMatches(): void + { + // Regression: family composition (adult + child) alone isn't enough - a family + // insurance product must actually match the current total price for it to count + // as "eligible". If it doesn't, the applicant's choice was genuinely made while + // family insurance was NOT selectable, even though isFamilyBooking() is already true. + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + $travel->insurances = [$nonFamilyInsurance, $familyInsurance]; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + + $child = new ParticipantDto(); + $child->index = 1; + $child->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $child]; + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); + $this->insuranceService->method('getEligibleInsurances')->willReturnCallback( + // No family insurance matches the current price tier, even though the + // participant composition already qualifies as a family booking. + fn (array $insurances) => array_values(array_filter($insurances, fn ($i) => false === $i->familyInsurance)) + ); + + $this->handler->processField(['insurance' => '1'], $bookingDto, 0); + + $this->assertTrue( + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible, + 'Family composition alone must not count as eligible - no family insurance actually matched the price' + ); + } + + public function testProcessFieldDoesNotNotifyWhenApplicantSwitchesToFamilyInsuranceInSameSubmission(): void + { + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + $travel->insurances = [$nonFamilyInsurance, $familyInsurance]; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + $applicant->insurance = $nonFamilyInsurance; + + $child = new ParticipantDto(); + $child->index = 1; + $child->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $child]; + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true; + + $this->insuranceService->method('getEligibleInsurances')->willReturn([$familyInsurance]); + + // Applicant's own submission switches them to family insurance in this same request + $this->handler->processField(['insurance' => '2'], $bookingDto, 0); + + $this->assertSame($familyInsurance, $applicant->insurance); + $this->assertEmpty($applicant->notifications, 'Must not show a stale "please recheck" hint for a choice this submission already made'); + } + + public function testProcessFieldDoesNotNotifyWhenHintAlreadyShown(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $bookingDto->familyInsuranceHintShown = true; + $applicant = $bookingDto->getParticipant(0); + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); + $this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2')]); + + $this->handler->processField([], $bookingDto, 1); + + $this->assertEmpty($applicant->notifications, 'Hint must not repeat once already shown'); + } + + public function testProcessFieldDoesNotNotifyWhenApplicantHasNoInsurance(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $applicant = $bookingDto->getParticipant(0); + $applicant->insurance = null; + + $this->handler->processField([], $bookingDto, 1); + + $this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant has not chosen insurance yet'); + $this->assertFalse($bookingDto->familyInsuranceHintShown); + } + + public function testProcessFieldDoesNotNotifyWhenApplicantAlreadyHasFamilyInsurance(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $applicant = $bookingDto->getParticipant(0); + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + $applicant->insurance = $familyInsurance; + + $this->handler->processField([], $bookingDto, 1); + + $this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant already has family insurance'); + $this->assertFalse($bookingDto->familyInsuranceHintShown); + } + + public function testProcessFieldDoesNotNotifyWhenNoEligibleFamilyInsuranceExists(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $applicant = $bookingDto->getParticipant(0); + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0); + $this->insuranceService->method('getEligibleInsurances')->willReturn([]); // No eligible family insurance + + $this->handler->processField([], $bookingDto, 1); + + $this->assertEmpty($applicant->notifications, 'Nothing to alert about when no family insurance is actually eligible'); + $this->assertFalse($bookingDto->familyInsuranceHintShown); + } + + private function createFamilyBookingDtoWithNonFamilyApplicantInsurance(): BookingDto + { + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + $travel->insurances = [$nonFamilyInsurance, $familyInsurance]; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15'); + $applicant->insurance = $nonFamilyInsurance; + + $child = new ParticipantDto(); + $child->index = 1; + $child->dateOfBirth = new \DateTimeImmutable('2015-01-01'); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $child]; + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true; + + return $bookingDto; + } + public function testGetFieldStateModificationsReturnsEmptyArray(): void { $bookingDto = $this->createMockBookingDto();