From 2cfa87f44b5fd6c27c44f35891f7aaa06b6aecdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B6rn=20Fromme?= Date: Wed, 5 Aug 2026 14:19:41 +0200 Subject: [PATCH] feat: indicate family insurance eligibility --- .../Create/Step2ParticipantController.php | 14 +- src/Form/Model/BookingCreateContext.php | 1 + src/Form/Model/BookingDto.php | 14 +- .../ParticipantInsuranceFieldHandler.php | 93 +--------- src/Service/BookingCreateContextFactory.php | 7 +- .../FamilyInsuranceAvailabilityChecker.php | 82 +++++++++ templates/booking/_participant_card.html.twig | 8 + templates/booking/_participant_form.html.twig | 9 + templates/booking/create/step_2.html.twig | 5 +- .../ParticipantInsuranceFieldHandlerTest.php | 142 +-------------- .../BookingCreateContextFactoryTest.php | 16 ++ ...FamilyInsuranceAvailabilityCheckerTest.php | 170 ++++++++++++++++++ 12 files changed, 327 insertions(+), 234 deletions(-) create mode 100644 src/Service/FamilyInsuranceAvailabilityChecker.php create mode 100644 tests/Service/FamilyInsuranceAvailabilityCheckerTest.php diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 9a9e14c..1ce8a01 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -92,7 +92,9 @@ class Step2ParticipantController extends AbstractController return $this->redirectToRoute('app_booking_create_step_2'); } - return $this->renderParticipantForm($form, $index, $bookingDto); + // Notifications were already cleared from the DTO above, so they have to be handed + // to the render path too - otherwise every invalid submission silently swallows them + return $this->renderParticipantForm($form, $index, $bookingDto, $notifications); } /** @@ -124,17 +126,25 @@ class Step2ParticipantController extends AbstractController ); } - /** @param FormInterface $form */ + /** + * @param FormInterface $form + * @param array $notifications + */ private function renderParticipantForm( FormInterface $form, int $index, BookingDto $bookingDto, + array $notifications = [], ): Response { $bookingCreateContext = $this->createContextFactory->create( $bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION ); + // The rendered template includes _partials/_flashes.html.twig, so flashes added here + // are consumed by this very response instead of leaking into the next page + $this->addNotificationsAsFlashMessages($notifications); + return $this->render('booking/create/step_2_participant.html.twig', [ 'form' => $form, 'participantIndex' => $index, diff --git a/src/Form/Model/BookingCreateContext.php b/src/Form/Model/BookingCreateContext.php index 88de2e4..d64027b 100644 --- a/src/Form/Model/BookingCreateContext.php +++ b/src/Form/Model/BookingCreateContext.php @@ -20,6 +20,7 @@ class BookingCreateContext public readonly ?array $cardsData = null, public readonly bool $isSubmitted = false, public readonly ?array $participantPrices = null, + public readonly bool $familyInsuranceUpgradeAvailable = false, ) { } } diff --git a/src/Form/Model/BookingDto.php b/src/Form/Model/BookingDto.php index 6a02e64..28e3f72 100644 --- a/src/Form/Model/BookingDto.php +++ b/src/Form/Model/BookingDto.php @@ -70,18 +70,12 @@ 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". + * was not yet eligible (i.e. before the dependents' dates of birth were known). + * Set at selection time; used by FamilyInsuranceAvailabilityChecker to distinguish + * "the applicant never had the chance" from "it was available all along and the + * applicant chose otherwise". */ public bool $applicantInsuranceChosenWhileFamilyIneligible = false; diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index e39ae6f..7bbbd3a 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -6,9 +6,9 @@ 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\FamilyInsuranceAvailabilityChecker; use App\Service\InsuranceManager; /** @@ -38,6 +38,7 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler public function __construct( private readonly InsuranceManager $insuranceService, private readonly BookingPriceCalculator $priceCalculatorService, + private readonly FamilyInsuranceAvailabilityChecker $familyInsuranceService, ) { } @@ -125,19 +126,6 @@ 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) { @@ -283,8 +271,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler /** * 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". + * was NOT yet eligible, so the standing hint rendered in step 2 can tell "never had + * the chance" 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. @@ -295,79 +284,11 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return; } - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant) { + if (null === $bookingDto->getParticipant(0)) { 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; + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->familyInsuranceService->hasEligibleFamilyInsurance($bookingDto); } /** diff --git a/src/Service/BookingCreateContextFactory.php b/src/Service/BookingCreateContextFactory.php index 6870039..af549f5 100644 --- a/src/Service/BookingCreateContextFactory.php +++ b/src/Service/BookingCreateContextFactory.php @@ -19,6 +19,7 @@ class BookingCreateContextFactory private readonly ParticipantCardAssembler $participantCardDataService, private readonly BookingSummaryAssembler $summaryDataService, private readonly BookingPriceCalculator $priceCalculator, + private readonly FamilyInsuranceAvailabilityChecker $familyInsuranceService, ) { } @@ -32,6 +33,7 @@ class BookingCreateContextFactory bookingDto: $bookingDto, summaryData: $baseContext['summaryData'], groupedRooms: $baseContext['groupedRooms'], + familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'], ); } @@ -45,6 +47,7 @@ class BookingCreateContextFactory groupedRooms: $baseContext['groupedRooms'], cardsData: $this->participantCardDataService->getAllCardsDataWithValidation($bookingDto), isSubmitted: $isSubmitted, + familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'], ); } @@ -57,17 +60,19 @@ class BookingCreateContextFactory summaryData: $baseContext['summaryData'], groupedRooms: $baseContext['groupedRooms'], participantPrices: $this->priceCalculator->calculateAllParticipantIndividualPrices($bookingDto), + familyInsuranceUpgradeAvailable: $baseContext['familyInsuranceUpgradeAvailable'], ); } /** - * @return array{summaryData: BookingSummaryDto, groupedRooms: RoomGroupsDto} + * @return array{summaryData: BookingSummaryDto, groupedRooms: RoomGroupsDto, familyInsuranceUpgradeAvailable: bool} */ private function buildBaseContext(BookingDto $bookingDto, string $pricingMode): array { return [ 'summaryData' => $this->summaryDataService->getSummaryData($bookingDto, $pricingMode), 'groupedRooms' => $this->groupRoomsBySelectionType($bookingDto->travel->getAvailableRooms()), + 'familyInsuranceUpgradeAvailable' => $this->familyInsuranceService->isFamilyInsuranceUpgradeAvailable($bookingDto), ]; } diff --git a/src/Service/FamilyInsuranceAvailabilityChecker.php b/src/Service/FamilyInsuranceAvailabilityChecker.php new file mode 100644 index 0000000..8922ce1 --- /dev/null +++ b/src/Service/FamilyInsuranceAvailabilityChecker.php @@ -0,0 +1,82 @@ +applicantInsuranceChosenWhileFamilyIneligible) { + return false; + } + + $applicant = $bookingDto->getParticipant(0); + + if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) { + return false; + } + + return $this->hasEligibleFamilyInsurance($bookingDto); + } + + /** + * 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. + */ + public function hasEligibleFamilyInsurance(BookingDto $bookingDto): bool + { + if (false === $bookingDto->isFamilyBooking()) { + return false; + } + + $applicant = $bookingDto->getParticipant(0); + if (null === $applicant) { + 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); + } +} diff --git a/templates/booking/_participant_card.html.twig b/templates/booking/_participant_card.html.twig index 5ecfb48..fc9fccc 100644 --- a/templates/booking/_participant_card.html.twig +++ b/templates/booking/_participant_card.html.twig @@ -4,6 +4,7 @@ {% set isSubmitted = isSubmitted|default(false) %} {% set needsAttention = needsAttention|default(false) %} {% set attentionLabels = attentionLabels|default([]) %} +{% set hintLabel = hintLabel|default(null) %} {% set mode = mode|default('create') %} {# Determine display states #} @@ -75,6 +76,13 @@ {% endif %} {% endif %} + {# Additive informational hint - unlike attentionLabels this does not replace the + room/price line and is not styled as something the participant got wrong #} + {% if hintLabel and not isCanceled %} +
+ {{ hintLabel }} +
+ {% endif %}
{% if not isCanceled %} diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index 1452bdf..2619f6f 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -551,6 +551,15 @@
{% else %}
+ {# The applicant has to choose an insurance before the dependents' dates of birth + are known, so a family insurance cannot be offered at that point. Once the + constellation qualifies, this standing hint points them back at the option. #} + {% if participantIndex == 0 and bookingCreateContext is defined and bookingCreateContext.familyInsuranceUpgradeAvailable %} + {% include '_partials/_alert.html.twig' with { + level: 'info', + messages: ['Für eure Konstellation ist auch eine Familienversicherung verfügbar und kann über die anmeldende Person gebucht werden.'] + } %} + {% endif %}
Reiseversicherung* diff --git a/templates/booking/create/step_2.html.twig b/templates/booking/create/step_2.html.twig index 732f5e1..ecb2dca 100644 --- a/templates/booking/create/step_2.html.twig +++ b/templates/booking/create/step_2.html.twig @@ -51,7 +51,10 @@ 'index': loop.index0, 'participantNumber': loop.index, 'mode': 'create', - 'isSubmitted': bookingCreateContext.isSubmitted + 'isSubmitted': bookingCreateContext.isSubmitted, + 'hintLabel': loop.index0 == 0 and bookingCreateContext.familyInsuranceUpgradeAvailable + ? 'Familienversicherung möglich - jetzt wechseln' + : null } %} {% endfor %}
diff --git a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php index 699ace3..0910a4e 100644 --- a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php @@ -10,6 +10,7 @@ use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Service\ParticipantInsuranceFieldHandler; use App\Service\BookingPriceCalculator; +use App\Service\FamilyInsuranceAvailabilityChecker; use App\Service\InsuranceManager; use PHPUnit\Framework\TestCase; @@ -34,7 +35,13 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase $this->priceCalculatorService->method('resolveInsuranceTravelPrice') ->willReturn(500.0); - $this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService); + // Real checker over the mocked collaborators - the selection-time recording below is + // only meaningful against the actual "is a family insurance available" definition + $this->handler = new ParticipantInsuranceFieldHandler( + $this->insuranceService, + $this->priceCalculatorService, + new FamilyInsuranceAvailabilityChecker($this->insuranceService, $this->priceCalculatorService), + ); } public function testGetFieldName(): void @@ -279,21 +286,6 @@ 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'); @@ -405,124 +397,6 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase ); } - 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(); diff --git a/tests/Service/BookingCreateContextFactoryTest.php b/tests/Service/BookingCreateContextFactoryTest.php index 8e151ec..9f4ac25 100644 --- a/tests/Service/BookingCreateContextFactoryTest.php +++ b/tests/Service/BookingCreateContextFactoryTest.php @@ -14,6 +14,7 @@ use App\Form\Model\ParticipantCardPriceDto; use App\Service\BookingCreateContextFactory; use App\Service\BookingPriceCalculator; use App\Service\BookingSummaryAssembler; +use App\Service\FamilyInsuranceAvailabilityChecker; use App\Service\ParticipantCardAssembler; use App\Service\RoomPricingCalculator; use PHPUnit\Framework\TestCase; @@ -62,10 +63,14 @@ class BookingCreateContextFactoryTest extends TestCase $priceCalculator->expects($this->never()) ->method('calculateAllParticipantIndividualPrices'); + $familyInsuranceService = $this->createMock(FamilyInsuranceAvailabilityChecker::class); + $familyInsuranceService->method('isFamilyInsuranceUpgradeAvailable')->willReturn(true); + $service = new BookingCreateContextFactory( $participantCardDataService, $summaryDataService, $priceCalculator, + $familyInsuranceService, ); $context = $service->create($bookingDto, RoomPricingCalculator::PRICING_MODE_SELECTION); @@ -73,6 +78,7 @@ class BookingCreateContextFactoryTest extends TestCase $this->assertInstanceOf(BookingCreateContext::class, $context); $this->assertSame($bookingDto, $context->bookingDto); $this->assertSame($summaryData, $context->summaryData); + $this->assertTrue($context->familyInsuranceUpgradeAvailable); $this->assertNull($context->cardsData); $this->assertFalse($context->isSubmitted); $this->assertSame([11 => $roomByPax], $context->groupedRooms->byPax); @@ -120,10 +126,14 @@ class BookingCreateContextFactoryTest extends TestCase $priceCalculator->expects($this->never()) ->method('calculateAllParticipantIndividualPrices'); + $familyInsuranceService = $this->createMock(FamilyInsuranceAvailabilityChecker::class); + $familyInsuranceService->method('isFamilyInsuranceUpgradeAvailable')->willReturn(true); + $service = new BookingCreateContextFactory( $participantCardDataService, $summaryDataService, $priceCalculator, + $familyInsuranceService, ); $context = $service->createWithParticipantCards($bookingDto, true); @@ -131,6 +141,7 @@ class BookingCreateContextFactoryTest extends TestCase $this->assertInstanceOf(BookingCreateContext::class, $context); $this->assertSame([$cardData], $context->cardsData); $this->assertTrue($context->isSubmitted); + $this->assertTrue($context->familyInsuranceUpgradeAvailable); $this->assertSame([], $context->groupedRooms->byPax); $this->assertSame([10 => $room], $context->groupedRooms->byRoom); } @@ -169,16 +180,21 @@ class BookingCreateContextFactoryTest extends TestCase ->with($bookingDto) ->willReturn([123.45]); + $familyInsuranceService = $this->createMock(FamilyInsuranceAvailabilityChecker::class); + $familyInsuranceService->method('isFamilyInsuranceUpgradeAvailable')->willReturn(true); + $service = new BookingCreateContextFactory( $participantCardDataService, $summaryDataService, $priceCalculator, + $familyInsuranceService, ); $context = $service->createWithParticipantPrices($bookingDto); $this->assertInstanceOf(BookingCreateContext::class, $context); $this->assertSame([123.45], $context->participantPrices); + $this->assertTrue($context->familyInsuranceUpgradeAvailable); $this->assertNull($context->cardsData); $this->assertSame([10 => $room], $context->groupedRooms->byRoom); } diff --git a/tests/Service/FamilyInsuranceAvailabilityCheckerTest.php b/tests/Service/FamilyInsuranceAvailabilityCheckerTest.php new file mode 100644 index 0000000..82856b9 --- /dev/null +++ b/tests/Service/FamilyInsuranceAvailabilityCheckerTest.php @@ -0,0 +1,170 @@ +insuranceService = $this->createMock(InsuranceManager::class); + $this->priceCalculatorService = $this->createMock(BookingPriceCalculator::class); + + $this->insuranceService->method('getSelectableInsurances') + ->willReturnCallback(fn (Travel $travel) => $travel->insurances ?? []); + + $this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance') + ->willReturn(500.0); + + $this->checker = new FamilyInsuranceAvailabilityChecker($this->insuranceService, $this->priceCalculatorService); + } + + public function testUpgradeIsAvailableWhenFamilyBecameEligibleAfterTheApplicantChose(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + + $this->allowAllInsurancesAsEligible(); + + $this->assertTrue($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testUpgradeIsNotAvailableWhenTheApplicantChoseWhileFamilyWasAlreadyEligible(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = false; + + $this->allowAllInsurancesAsEligible(); + + $this->assertFalse( + $this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto), + 'An applicant who was offered family insurance and picked something else must not be nagged' + ); + } + + public function testUpgradeIsNotAvailableWhenApplicantHasNoInsuranceYet(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $bookingDto->getParticipant(0)->insurance = null; + + $this->allowAllInsurancesAsEligible(); + + $this->assertFalse($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testUpgradeIsNotAvailableWhenApplicantAlreadyHasFamilyInsurance(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $familyInsurance = $this->createInsurance('2'); + $familyInsurance->familyInsurance = true; + $bookingDto->getParticipant(0)->insurance = $familyInsurance; + + $this->allowAllInsurancesAsEligible(); + + $this->assertFalse($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testUpgradeIsNotAvailableWhenNoFamilyInsuranceProductExists(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $bookingDto->travel->insurances = array_values(array_filter( + $bookingDto->travel->insurances, + static fn (Insurance $i) => false === $i->familyInsurance + )); + + $this->allowAllInsurancesAsEligible(); + + $this->assertFalse($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testUpgradeIsNotAvailableWhenNoFamilyPriceTierMatches(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + + // Family composition qualifies, but no family insurance matches the total price + $this->insuranceService->method('getEligibleInsurances')->willReturn([]); + + $this->assertFalse($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testUpgradeIsNotAvailableWhenTheBookingIsNotAFamilyBooking(): void + { + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + // Dependent is an adult - no children left, so the constellation no longer qualifies + $bookingDto->getParticipant(1)->dateOfBirth = new \DateTimeImmutable('1992-01-01'); + + $this->allowAllInsurancesAsEligible(); + + $this->assertFalse($this->checker->isFamilyInsuranceUpgradeAvailable($bookingDto)); + } + + public function testHasEligibleFamilyInsuranceIgnoresTheSelectionTimeRecording(): void + { + // The raw availability predicate is what the field handler records against, so it + // must not itself depend on the flag it is used to compute + $bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance(); + $bookingDto->applicantInsuranceChosenWhileFamilyIneligible = false; + + $this->allowAllInsurancesAsEligible(); + + $this->assertTrue($this->checker->hasEligibleFamilyInsurance($bookingDto)); + } + + private function allowAllInsurancesAsEligible(): void + { + $this->insuranceService->method('getEligibleInsurances')->willReturnCallback( + fn (array $insurances) => $insurances + ); + } + + private function createFamilyBookingDtoWithNonFamilyApplicantInsurance(): BookingDto + { + $nonFamilyInsurance = $this->createInsurance('1'); + $nonFamilyInsurance->familyInsurance = false; + + $familyInsurance = $this->createInsurance('2', 'Reise-Rücktritt Familie'); + $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; + } + + private function createInsurance(string|int $id, string $label = 'Test Insurance'): Insurance + { + $insurance = new Insurance(); + $insurance->id = (string) $id; + $insurance->label = $label; + + return $insurance; + } +}