diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index a669f53..4fe80a4 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -301,8 +301,8 @@ class BookingDataProcessor */ public function createUpdateRequestPayload(?BookingDto $formData): array { - // Apply bulk insurance if enabled (modifies DTO in place) - $this->applyBulkInsuranceIfActive($formData); + // Apply bulk or family insurance if applicable (modifies DTO in place) + $this->applyApplicantInsuranceToParticipants($formData); $bookingData = $formData->booking; $travelData = $formData->travel; @@ -362,33 +362,46 @@ class BookingDataProcessor */ public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array { - // Apply bulk insurance if enabled (modifies DTO in place) - $this->applyBulkInsuranceIfActive($bookingDto); + // Apply bulk or family insurance if applicable (modifies DTO in place) + $this->applyApplicantInsuranceToParticipants($bookingDto); return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType); } /** - * Applies bulk insurance assignment if the applicant has enabled it. + * Applies the applicant's insurance to other participants if their selection requires it. * - * When bulk insurance is active (applicant's bulkInsuranceBooking = true), this method - * assigns the applicant's insurance TYPE to all dependent participants with automatic - * price tier adjustment based on each participant's total cost. + * Runs when either: + * - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the + * applicant's insurance TYPE to all dependent participants with automatic price tier + * adjustment based on each participant's individual cost. + * - The applicant selected a family insurance: family insurance covers the whole family + * under a single policy, so dependents must never hold their own insurance, regardless + * of whether the bulk checkbox was checked. * - * IMPORTANT: In edit mode, bulk insurance only applies to participants who: + * IMPORTANT: In edit mode, non-family bulk insurance only applies to participants who: * 1. Currently have NO insurance assigned (insurance === null) * 2. OR whose insurance was already assigned via previous bulk operation * - * This prevents overriding individually selected insurances that are locked. + * This prevents overriding individually selected insurances that are locked. Family + * insurance is exempt from this protection: dependents can never legitimately hold + * their own policy alongside it, so their insurance is always cleared, in edit mode too. * * @param BookingDto $bookingDto The booking DTO (create or edit flow) */ - private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void + private function applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void { $applicant = $bookingDto->getParticipant(0); - // Check if bulk insurance is enabled and applicant has selected an insurance - if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { + if (null === $applicant || null === $applicant->insurance) { + return; + } + + $isFamilyInsurance = true === $applicant->insurance->familyInsurance; + + // Check if bulk insurance is enabled OR the applicant's selection is a family + // insurance (which always applies to dependents, independent of the bulk checkbox) + if (false === $applicant->bulkInsuranceBooking && false === $isFamilyInsurance) { return; } @@ -409,10 +422,11 @@ class BookingDataProcessor $participantPrices ); - // Apply assignments to dependent participants (skip applicant at index 0) + // Apply assignments to all participants; for non-family insurance skip the applicant + // (they already hold the insurance they selected themselves) foreach ($assignments as $index => $insurance) { - if (0 === $index) { - continue; // Skip applicant + if (0 === $index && false === $isFamilyInsurance) { + continue; } $participant = $bookingDto->getParticipant($index); @@ -420,9 +434,10 @@ class BookingDataProcessor continue; } - // In edit mode: only apply bulk insurance if participant has no insurance - // This respects the rule that once assigned, insurance cannot be changed - if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) { + // In edit mode: non-family bulk assignment must not overwrite an individually + // locked insurance. Family insurance always overrides, since dependents can + // never legitimately keep their own policy alongside it. + if (BookingDto::MODE_EDIT === $bookingDto->getMode() && false === $isFamilyInsurance && null !== $participant->insurance) { continue; // Skip participants with existing insurance assignment } diff --git a/src/Form/Service/Condition/FamilyInsuranceActiveCondition.php b/src/Form/Service/Condition/FamilyInsuranceActiveCondition.php new file mode 100644 index 0000000..e656cf7 --- /dev/null +++ b/src/Form/Service/Condition/FamilyInsuranceActiveCondition.php @@ -0,0 +1,40 @@ +getParticipant(0); + + return null !== $applicant + && null !== $applicant->insurance + && true === $applicant->insurance->familyInsurance; + } + + public function getDependentFields(): array + { + return ['insurance']; + } + + public function getDescription(): string + { + return 'Applicant has a family insurance selected'; + } +} diff --git a/src/Form/Service/CreateFieldStateProvider.php b/src/Form/Service/CreateFieldStateProvider.php index 1898bb0..31cc9e4 100644 --- a/src/Form/Service/CreateFieldStateProvider.php +++ b/src/Form/Service/CreateFieldStateProvider.php @@ -12,6 +12,7 @@ use App\Form\Service\Condition\BookingEligibilityCondition; use App\Form\Service\Condition\BulkInsuranceBookingCondition; use App\Form\Service\Condition\CompositeCondition; use App\Form\Service\Condition\DateOfBirthProvidedCondition; +use App\Form\Service\Condition\FamilyInsuranceActiveCondition; use App\Form\Service\Condition\FieldValueCondition; use App\Form\Service\Condition\FinalBookingOnlyCondition; use App\Form\Service\Condition\FirstParticipantCondition; @@ -221,11 +222,13 @@ class CreateFieldStateProvider extends AbstractFieldStateProvider ), ]; - // Hide insurance field until date of birth is provided OR when bulk insurance booking is active OR when participant is ineligible + // Hide insurance field until date of birth is provided OR when bulk insurance booking is active + // OR when the applicant has a family insurance active (it already covers dependents) OR when participant is ineligible $this->fieldStateConditions['insurance'] = [ 'hidden' => CompositeCondition::or( CompositeCondition::not($dateOfBirthProvidedCondition), $bulkInsuranceBookingCondition, + new FamilyInsuranceActiveCondition(), $bookingEligibilityCondition ), ]; diff --git a/src/Form/Service/ParticipantFieldOptionsProvider.php b/src/Form/Service/ParticipantFieldOptionsProvider.php index 5586b95..dc5419d 100644 --- a/src/Form/Service/ParticipantFieldOptionsProvider.php +++ b/src/Form/Service/ParticipantFieldOptionsProvider.php @@ -1159,15 +1159,17 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider // Get selectable (non-complementary) insurances with caching $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - // Calculate travel price for eligibility filtering - $travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); + // Family insurances are priced by the total booking price rather than the individual + // participant's price - InsuranceManager applies the correct basis per insurance type + $individualPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); + $totalBookingPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto); - // Filter based on eligibility criteria for this participant - $eligibleInsurances = $this->insuranceService->getEligibleInsurances( + $eligibleInsurances = $this->insuranceService->getEligibleInsurancesForParticipant( $selectableInsurances, $participant, $bookingDto, - $travelPrice + $individualPrice, + $totalBookingPrice ); // Inject synthetic "no insurance" option at the top of the list diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index afe9573..250847c 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -136,9 +136,6 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler // Get selectable (non-complementary) insurances with caching $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - // Calculate travel price for eligibility checks - $travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); - // Determine if this is a new user selection or just form resubmission $isNewSelection = null !== $selectedInsuranceId && (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance)); @@ -161,6 +158,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler $selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId); if (null !== $selectedInsurance) { + $travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $selectedInsurance); + $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances); @@ -198,6 +197,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return; } + $travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $currentInsurance); + // Check if current insurance is still eligible with updated participant data $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances); diff --git a/src/Service/BookingPriceCalculator.php b/src/Service/BookingPriceCalculator.php index 3ec9e7a..4e88cff 100644 --- a/src/Service/BookingPriceCalculator.php +++ b/src/Service/BookingPriceCalculator.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Service; +use App\BusProNet\Model\Insurance; use App\Form\Model\BookingDto; /** @@ -76,6 +77,22 @@ class BookingPriceCalculator return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto); } + /** + * Calculates total booking price across all participants, excluding insurance. + * + * Used for family insurance price-tier determination, where the tier is based on + * the combined trip cost rather than any single participant's price. + */ + public function calculateTotalBookingPriceExcludingInsurance(BookingDto $bookingDto): float + { + $total = 0.0; + foreach ($bookingDto->getParticipants() as $index => $participant) { + $total += $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index); + } + + return $total; + } + /** * Calculates the total price for an individual participant excluding insurance. * @@ -95,4 +112,22 @@ class BookingPriceCalculator $participantIndex ); } + + /** + * Resolves the travel price to use for insurance eligibility checks. + * + * Family insurances are always evaluated against the total booking price (all + * participants combined); everything else uses the individual participant's price. + * Non-applicant participants can never hold a family insurance (InsuranceManager + * restricts family insurances to the applicant), so this does not need to know + * whether $participantIndex is the applicant. + */ + public function resolveInsuranceTravelPrice(BookingDto $bookingDto, int $participantIndex, Insurance $insurance): float + { + if (true === $insurance->familyInsurance) { + return $this->calculateTotalBookingPriceExcludingInsurance($bookingDto); + } + + return $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); + } } diff --git a/src/Service/InsuranceManager.php b/src/Service/InsuranceManager.php index 1ce2042..4d1b246 100644 --- a/src/Service/InsuranceManager.php +++ b/src/Service/InsuranceManager.php @@ -107,6 +107,42 @@ class InsuranceManager })(); } + /** + * Returns eligible insurances for a participant, pricing family and non-family + * insurances on the correct basis. + * + * Family insurances must be priced by the total booking price, not the individual + * participant's price - this splits the given insurances by type and evaluates + * each group against the appropriate price before merging the results back together. + * + * @param array $insurances Available insurances to filter + * @param ParticipantDto $participant The participant to match insurances for + * @param BookingDto $booking The booking context for additional criteria + * @param float $individualPrice The participant's individual travel price (excluding insurance) + * @param float $totalBookingPrice The total booking price across all participants (excluding insurance) + * + * @return array Filtered array of eligible insurances, sorted by price + */ + public function getEligibleInsurancesForParticipant( + array $insurances, + ParticipantDto $participant, + BookingDto $booking, + float $individualPrice, + float $totalBookingPrice, + ): array { + $nonFamilyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => false === $i->familyInsurance)); + $familyInsurances = array_values(array_filter($insurances, static fn (Insurance $i) => true === $i->familyInsurance)); + + $eligibleInsurances = $this->getEligibleInsurances($nonFamilyInsurances, $participant, $booking, $individualPrice); + + if (!empty($familyInsurances)) { + $eligibleFamilyInsurances = $this->getEligibleInsurances($familyInsurances, $participant, $booking, $totalBookingPrice); + $eligibleInsurances = $this->sortByPrice(array_merge($eligibleInsurances, $eligibleFamilyInsurances)); + } + + return $eligibleInsurances; + } + /** * Auto-reassigns an insurance to the same type with appropriate price tier. * @@ -159,12 +195,28 @@ class InsuranceManager BookingDto $booking, array $participantPrices, ): array { - $assignments = []; - // Group insurances of the same type $sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance); - // Assign appropriate insurance to each participant + // Family insurance: assign to applicant only, priced by total booking price + if (true === $selectedInsurance->familyInsurance) { + $totalPrice = array_sum($participantPrices); + $applicant = $booking->getParticipant(0); + $eligibleInsurances = null !== $applicant + ? $this->getEligibleInsurances($sameTypeInsurances, $applicant, $booking, $totalPrice) + : []; + $tieredInsurance = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; + + $assignments = []; + foreach ($booking->getParticipants() as $index => $participant) { + $assignments[$index] = 0 === $index ? $tieredInsurance : null; + } + + return $assignments; + } + + // Non-family insurance: assign to each participant with their individual price tier + $assignments = []; foreach ($booking->getParticipants() as $index => $participant) { $travelPrice = $participantPrices[$index] ?? 0.0; $eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice); @@ -242,7 +294,7 @@ class InsuranceManager int $travelDurationDays, ): bool { // Family insurance constraints - if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) { + if (false === $this->checkFamilyInsuranceConstraints($insurance, $participant, $booking)) { return false; } @@ -274,23 +326,15 @@ class InsuranceManager return true; } - private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool + private function checkFamilyInsuranceConstraints(Insurance $insurance, ParticipantDto $participant, BookingDto $booking): bool { - // Family booking detection only available in create mode - if (BookingDto::MODE_EDIT === $booking->getMode()) { - return true; // Skip family constraints for edit mode - } - - $isFamilyBooking = $booking->isFamilyBooking(); - - // If it's a family insurance, it should only be available for family bookings - if (true === $insurance->familyInsurance && false === $isFamilyBooking) { - return false; - } - - // If it's not a family insurance, it should only be available for non-family bookings - if (false === $insurance->familyInsurance && true === $isFamilyBooking) { - return false; + if (true === $insurance->familyInsurance) { + if (false === $booking->isFamilyBooking()) { + return false; // Family insurance only available for family bookings + } + if (0 !== $participant->index) { + return false; // Family insurance only assignable to the applicant + } } return true; diff --git a/templates/booking/_participant_form.html.twig b/templates/booking/_participant_form.html.twig index ba63e30..1452bdf 100644 --- a/templates/booking/_participant_form.html.twig +++ b/templates/booking/_participant_form.html.twig @@ -493,9 +493,15 @@ {# Insurance field OR assigned insurance display for dependent participants (create mode) #} {% else %} - {% set showBulkInsurance = participantIndex > 0 and bookingDto.participants[0].bulkInsuranceBooking %} + {# Dependents get the applicant's read-only insurance display instead of their own choice + when bulk insurance booking is active, or when the applicant selected a family + insurance (which always covers the whole family, regardless of the bulk checkbox) #} + {% set showApplicantInsurance = participantIndex > 0 and ( + bookingDto.participants[0].bulkInsuranceBooking + or (bookingDto.participants[0].insurance and bookingDto.participants[0].insurance.familyInsurance) + ) %} - {% if showBulkInsurance %} + {% if showApplicantInsurance %} {% set applicantInsurance = bookingDto.participants[0].insurance %}
diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index d0f9949..a2c3d14 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -13,6 +13,7 @@ use App\BusProNet\Model\Address; use App\BusProNet\Model\BankAccount; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Communication; +use App\BusProNet\Model\Insurance; use App\BusProNet\Model\PersonalData; use App\BusProNet\Model\Pickup; use App\BusProNet\Model\Room; @@ -822,4 +823,106 @@ class BookingDataProcessorTest extends TestCase return $participant; } + + /** + * Regression: family insurance covers the whole family under a single policy on the + * applicant, so dependents must never keep their own insurance selection - even when + * the applicant never checked the "book for everyone" (bulkInsuranceBooking) box. + */ + public function testFamilyInsuranceClearsDependentInsuranceWithoutBulkCheckbox(): void + { + $familyInsurance = new Insurance(); + $familyInsurance->id = 'family-1'; + $familyInsurance->familyInsurance = true; + + $applicant = $this->createMockParticipantDto(0, 'F'); + $applicant->bulkInsuranceBooking = false; + $applicant->insurance = $familyInsurance; + + $dependentInsurance = new Insurance(); + $dependentInsurance->id = 'dependent-own-1'; + $dependentInsurance->familyInsurance = false; + + $dependent = $this->createMockParticipantDto(1, 'F'); + $dependent->insurance = $dependentInsurance; + + $bookingDto = new BookingDto($this->createMockTravel(), 1); + $bookingDto->participants = [$applicant, $dependent]; + + $insuranceService = $this->createMock(InsuranceManager::class); + $insuranceService->method('getSelectableInsurances')->willReturn([]); + $insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([ + 0 => $familyInsurance, + 1 => null, + ]); + + $priceCalculatorService = $this->createMock(BookingPriceCalculator::class); + $priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0); + + $payloadBuilder = $this->createMock(BookingPayloadBuilder::class); + $payloadBuilder->method('buildCreatePayload')->willReturn([]); + + $processor = new BookingDataProcessor( + $insuranceService, + $priceCalculatorService, + new ServiceMappingCollector(), + new ParticipantServiceProcessor(new NullLogger()), + $payloadBuilder, + new PersonalDataSynchronizer(), + ); + + $processor->createBookingRequestPayload($bookingDto, 'ANFRAGE'); + + $this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance); + $this->assertNull($bookingDto->participants[1]->insurance, 'Dependent must lose their own insurance once the applicant selects family insurance'); + } + + /** + * Regression: the edit-mode guard that protects an individually-locked non-family + * insurance from being overwritten must not also block clearing a dependent's + * pre-existing insurance when the applicant switches to family insurance. + */ + public function testFamilyInsuranceClearsDependentInsuranceInEditMode(): void + { + $familyInsurance = new Insurance(); + $familyInsurance->id = 'family-1'; + $familyInsurance->familyInsurance = true; + + $formData = $this->createCompleteFormData(); + + $applicant = $formData->participants[0]; + $applicant->bulkInsuranceBooking = false; + $applicant->insurance = $familyInsurance; + + $dependentInsurance = new Insurance(); + $dependentInsurance->id = 'dependent-own-1'; + $dependentInsurance->familyInsurance = false; + + $dependent = $formData->participants[1]; + $dependent->insurance = $dependentInsurance; + + $insuranceService = $this->createMock(InsuranceManager::class); + $insuranceService->method('getSelectableInsurances')->willReturn([]); + $insuranceService->method('batchAssignInsuranceToParticipants')->willReturn([ + 0 => $familyInsurance, + 1 => null, + ]); + + $priceCalculatorService = $this->createMock(BookingPriceCalculator::class); + $priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0); + + $processor = new BookingDataProcessor( + $insuranceService, + $priceCalculatorService, + new ServiceMappingCollector(), + new ParticipantServiceProcessor(new NullLogger()), + new BookingPayloadBuilder(new ServiceMappingCollector()), + new PersonalDataSynchronizer(), + ); + + $processor->createUpdateRequestPayload($formData); + + $this->assertSame($familyInsurance, $formData->participants[0]->insurance); + $this->assertNull($formData->participants[1]->insurance, 'Dependent must lose their pre-existing individual insurance in edit mode once the applicant selects family insurance'); + } } diff --git a/tests/Form/Service/Condition/FamilyInsuranceActiveConditionTest.php b/tests/Form/Service/Condition/FamilyInsuranceActiveConditionTest.php new file mode 100644 index 0000000..8d76cef --- /dev/null +++ b/tests/Form/Service/Condition/FamilyInsuranceActiveConditionTest.php @@ -0,0 +1,112 @@ +condition = new FamilyInsuranceActiveCondition(); + } + + public function testAlwaysFalseForApplicant(): void + { + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->insurance = $this->createFamilyInsurance(); + + $bookingDto = new BookingDto(new Travel(), 1); + $bookingDto->participants = [$applicant]; + + $result = $this->condition->evaluate($bookingDto, 0, []); + + $this->assertFalse($result, 'Applicant is never affected by their own family insurance selection'); + } + + public function testTrueForDependentWhenApplicantHasFamilyInsurance(): void + { + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->insurance = $this->createFamilyInsurance(); + + $dependent = new ParticipantDto(); + $dependent->index = 1; + + $bookingDto = new BookingDto(new Travel(), 1); + $bookingDto->participants = [$applicant, $dependent]; + + $result = $this->condition->evaluate($bookingDto, 1, []); + + $this->assertTrue($result, 'Dependent insurance field must be hidden when applicant has family insurance'); + } + + public function testFalseForDependentWhenApplicantHasNonFamilyInsurance(): void + { + $nonFamilyInsurance = new Insurance(); + $nonFamilyInsurance->familyInsurance = false; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->insurance = $nonFamilyInsurance; + + $dependent = new ParticipantDto(); + $dependent->index = 1; + + $bookingDto = new BookingDto(new Travel(), 1); + $bookingDto->participants = [$applicant, $dependent]; + + $result = $this->condition->evaluate($bookingDto, 1, []); + + $this->assertFalse($result, 'Dependent should keep their own insurance choice for non-family insurance'); + } + + public function testFalseForDependentWhenApplicantHasNoInsurance(): void + { + $applicant = new ParticipantDto(); + $applicant->index = 0; + + $dependent = new ParticipantDto(); + $dependent->index = 1; + + $bookingDto = new BookingDto(new Travel(), 1); + $bookingDto->participants = [$applicant, $dependent]; + + $result = $this->condition->evaluate($bookingDto, 1, []); + + $this->assertFalse($result); + } + + public function testGetDependentFieldsReturnsInsurance(): void + { + $result = $this->condition->getDependentFields(); + + $this->assertSame(['insurance'], $result); + } + + public function testGetDescriptionReturnsString(): void + { + $result = $this->condition->getDescription(); + + $this->assertIsString($result); + $this->assertStringContainsString('family insurance', $result); + } + + private function createFamilyInsurance(): Insurance + { + $insurance = new Insurance(); + $insurance->familyInsurance = true; + + return $insurance; + } +} diff --git a/tests/Form/Service/CreateFieldStateProviderTest.php b/tests/Form/Service/CreateFieldStateProviderTest.php new file mode 100644 index 0000000..72ab142 --- /dev/null +++ b/tests/Form/Service/CreateFieldStateProviderTest.php @@ -0,0 +1,71 @@ +provider = new CreateFieldStateProvider(new ParticipantEligibilityChecker()); + } + + public function testDependentInsuranceFieldHidesWhenApplicantSelectsFamilyInsuranceThenReappearsAfterSwitch(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('+30 days'); + $travel->dateTo = new \DateTimeImmutable('+37 days'); + + $applicant = new ParticipantDto(); + $applicant->index = 0; + // Baby age (0-2 years at travel date) is always eligible regardless of skipass availability + $applicant->dateOfBirth = $travel->dateFrom->modify('-1 year'); + + $dependent = new ParticipantDto(); + $dependent->index = 1; + $dependent->dateOfBirth = $travel->dateFrom->modify('-1 year'); + + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + $this->assertTrue( + $this->provider->shouldIncludeField('insurance', $bookingDto, 1), + 'Dependent should see their own insurance field before the applicant selects family insurance' + ); + + $familyInsurance = new Insurance(); + $familyInsurance->familyInsurance = true; + $applicant->insurance = $familyInsurance; + + $this->assertFalse( + $this->provider->shouldIncludeField('insurance', $bookingDto, 1), + 'Dependent insurance field must be hidden once the applicant selects family insurance' + ); + + $nonFamilyInsurance = new Insurance(); + $nonFamilyInsurance->familyInsurance = false; + $applicant->insurance = $nonFamilyInsurance; + + $this->assertTrue( + $this->provider->shouldIncludeField('insurance', $bookingDto, 1), + 'Dependent insurance field must reappear once the applicant switches away from family insurance' + ); + } +} diff --git a/tests/Form/Service/ParticipantFieldOptionsProviderInsuranceTest.php b/tests/Form/Service/ParticipantFieldOptionsProviderInsuranceTest.php new file mode 100644 index 0000000..b42f22e --- /dev/null +++ b/tests/Form/Service/ParticipantFieldOptionsProviderInsuranceTest.php @@ -0,0 +1,137 @@ +createMock(ServiceAvailabilityCalculator::class); + // InsuranceManager is stateless - use the real implementation so actual + // eligibility/price-tier filtering runs, not a stubbed-out mock. + $insuranceService = new InsuranceManager(); + $this->priceCalculatorService = $this->createMock(BookingPriceCalculator::class); + $serviceLabelFormatter = new ServiceLabelFormatter(); + $translator = $this->createMock(TranslatorInterface::class); + $translator->method('trans')->willReturnCallback(fn (string $message) => $message); + + $this->provider = new ParticipantFieldOptionsProvider( + $serviceAvailabilityCalculator, + $insuranceService, + $this->priceCalculatorService, + $serviceLabelFormatter, + $translator + ); + } + + public function testFamilyInsuranceCorrectTierAppearsInApplicantChoicesUsingTotalPrice(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + + // Low tier only matches the applicant's individual price (300), not the total (450) + $lowTierFamilyInsurance = new Insurance(); + $lowTierFamilyInsurance->id = '1'; + $lowTierFamilyInsurance->label = 'Reise-Rücktritt Familie'; + $lowTierFamilyInsurance->familyInsurance = true; + $lowTierFamilyInsurance->travelPriceFrom = 0.0; + $lowTierFamilyInsurance->travelPriceTo = 400.0; + + // Correct tier matches the total booking price (450), not the applicant's individual price (300) + $correctTierFamilyInsurance = new Insurance(); + $correctTierFamilyInsurance->id = '2'; + $correctTierFamilyInsurance->label = 'Reise-Rücktritt Familie'; + $correctTierFamilyInsurance->familyInsurance = true; + $correctTierFamilyInsurance->travelPriceFrom = 400.01; + $correctTierFamilyInsurance->travelPriceTo = 600.0; + + $travel->insurances = [$lowTierFamilyInsurance, $correctTierFamilyInsurance]; + + $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('calculateIndividualParticipantPriceExcludingInsurance') + ->willReturn(300.0); + $this->priceCalculatorService + ->method('calculateTotalBookingPriceExcludingInsurance') + ->willReturn(450.0); + + $options = $this->provider->getFieldOptions('insurance', $bookingDto, 0); + + $choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']); + + $this->assertContains('2', $choiceIds, 'Family insurance tier matching the total booking price must be selectable'); + $this->assertNotContains('1', $choiceIds, 'Family insurance tier that only matches the individual price must not be selectable'); + } + + public function testFamilyInsuranceNeverAppearsInDependentChoices(): void + { + $travel = new Travel(); + $travel->dateFrom = new \DateTimeImmutable('2025-08-01'); + $travel->dateTo = new \DateTimeImmutable('2025-08-08'); + + $familyInsurance = new Insurance(); + $familyInsurance->id = '1'; + $familyInsurance->label = 'Reise-Rücktritt Familie'; + $familyInsurance->familyInsurance = true; + + $travel->insurances = [$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('calculateIndividualParticipantPriceExcludingInsurance') + ->willReturn(0.0); + $this->priceCalculatorService + ->method('calculateTotalBookingPriceExcludingInsurance') + ->willReturn(450.0); + + $options = $this->provider->getFieldOptions('insurance', $bookingDto, 1); + + $choiceIds = array_map(fn (Insurance $insurance) => $insurance->id, $options['choices']); + + $this->assertNotContains('1', $choiceIds, 'Family insurance must never be selectable for a non-applicant participant'); + } +} diff --git a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php index 7e69376..60931bf 100644 --- a/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php +++ b/tests/Form/Service/ParticipantInsuranceFieldHandlerTest.php @@ -31,6 +31,8 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase // Mock price calculator to return a default price $this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance') ->willReturn(500.0); + $this->priceCalculatorService->method('resolveInsuranceTravelPrice') + ->willReturn(500.0); $this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService); } diff --git a/tests/Service/BookingPriceCalculatorTest.php b/tests/Service/BookingPriceCalculatorTest.php index fe3fd67..e4c75ac 100644 --- a/tests/Service/BookingPriceCalculatorTest.php +++ b/tests/Service/BookingPriceCalculatorTest.php @@ -252,4 +252,86 @@ class BookingPriceCalculatorTest extends TestCase 'calculateServiceTotal() must agree with the display breakdown on bulk-insurance bookings' ); } + + public function testResolveInsuranceTravelPriceUsesIndividualPriceForNonFamilyInsurance(): void + { + $skiPass = new Service(); + $skiPass->price = 50.0; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->skiPass = $skiPass; + + $dependent = new ParticipantDto(); + $dependent->index = 1; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + $nonFamilyInsurance = new Insurance(); + $nonFamilyInsurance->familyInsurance = false; + + $result = $this->service->resolveInsuranceTravelPrice($bookingDto, 0, $nonFamilyInsurance); + + $this->assertEquals(50.0, $result); + } + + public function testResolveInsuranceTravelPriceUsesTotalPriceForFamilyInsuranceOnApplicant(): void + { + $applicantSkiPass = new Service(); + $applicantSkiPass->price = 50.0; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->skiPass = $applicantSkiPass; + + $dependentSkiPass = new Service(); + $dependentSkiPass->price = 30.0; + + $dependent = new ParticipantDto(); + $dependent->index = 1; + $dependent->skiPass = $dependentSkiPass; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + $familyInsurance = new Insurance(); + $familyInsurance->familyInsurance = true; + + $result = $this->service->resolveInsuranceTravelPrice($bookingDto, 0, $familyInsurance); + + $this->assertEquals(80.0, $result); + } + + public function testResolveInsuranceTravelPriceUsesTotalPriceForFamilyInsuranceRegardlessOfParticipantIndex(): void + { + // Family insurances are restricted to the applicant by InsuranceManager's eligibility + // constraints, not by this method - it always resolves the total price for them. + $applicantSkiPass = new Service(); + $applicantSkiPass->price = 50.0; + + $applicant = new ParticipantDto(); + $applicant->index = 0; + $applicant->skiPass = $applicantSkiPass; + + $dependentSkiPass = new Service(); + $dependentSkiPass->price = 30.0; + + $dependent = new ParticipantDto(); + $dependent->index = 1; + $dependent->skiPass = $dependentSkiPass; + + $travel = new Travel(); + $bookingDto = new BookingDto($travel, 1); + $bookingDto->participants = [$applicant, $dependent]; + + $familyInsurance = new Insurance(); + $familyInsurance->familyInsurance = true; + + $result = $this->service->resolveInsuranceTravelPrice($bookingDto, 1, $familyInsurance); + + $this->assertEquals(80.0, $result); + } } diff --git a/tests/Service/InsuranceManagerTest.php b/tests/Service/InsuranceManagerTest.php index 95d0ab3..6ebaad0 100644 --- a/tests/Service/InsuranceManagerTest.php +++ b/tests/Service/InsuranceManagerTest.php @@ -425,6 +425,148 @@ class InsuranceManagerTest extends TestCase $this->assertNotNull($result[1]); } + public function testBatchAssignFamilyInsuranceToApplicantOnly(): void + { + $booking = $this->createFamilyBooking('2024-08-01', '2024-08-08'); + + // Tier matched by total price (300 + 0 = 300, fits 250–400 tier) + $lowTierInsurance = $this->createInsurance([ + 'id' => '1', + 'package' => false, + 'subType' => 'FAM', + 'familyInsurance' => true, + 'travelPriceFrom' => 0.0, + 'travelPriceTo' => 249.99, + ]); + $correctTierInsurance = $this->createInsurance([ + 'id' => '2', + 'package' => false, + 'subType' => 'FAM', + 'familyInsurance' => true, + 'travelPriceFrom' => 250.0, + 'travelPriceTo' => 400.0, + ]); + + $availableInsurances = [$lowTierInsurance, $correctTierInsurance]; + // Individual prices: applicant 300, child 0 → total 300 + $participantPrices = [0 => 300.0, 1 => 0.0]; + + $result = $this->service->batchAssignInsuranceToParticipants( + $availableInsurances, + $correctTierInsurance, + $booking, + $participantPrices + ); + + $this->assertCount(2, $result); + $this->assertSame($correctTierInsurance, $result[0], 'Applicant should get tier matching total price'); + $this->assertNull($result[1], 'Non-applicant participants must be cleared for family insurance'); + } + + public function testFamilyInsuranceIneligibleForNonApplicant(): void + { + $booking = $this->createFamilyBooking('2024-08-01', '2024-08-08'); + + $nonApplicant = $this->createParticipant('2015-01-01'); // child + $nonApplicant->index = 1; + + $familyInsurance = $this->createInsurance(['familyInsurance' => true]); + + $result = $this->service->getEligibleInsurances([$familyInsurance], $nonApplicant, $booking, 500.0); + + $this->assertEmpty($result, 'Family insurance must not be eligible for non-applicant participants'); + } + + public function testNonFamilyInsuranceEligibleForFamilyBooking(): void + { + $booking = $this->createFamilyBooking('2024-08-01', '2024-08-08'); + + $adult = $this->createParticipant('1990-06-15'); + $adult->index = 0; + $child = $this->createParticipant('2015-01-01'); + $child->index = 1; + + $nonFamilyInsurance = $this->createInsurance(['familyInsurance' => false]); + + $adultResult = $this->service->getEligibleInsurances([$nonFamilyInsurance], $adult, $booking, 500.0); + $childResult = $this->service->getEligibleInsurances([$nonFamilyInsurance], $child, $booking, 500.0); + + $this->assertNotEmpty($adultResult, 'Non-family insurance must be available in family bookings for adults'); + $this->assertNotEmpty($childResult, 'Non-family insurance must be available in family bookings for children'); + } + + public function testFamilyInsuranceIneligibleForNonFamilyBooking(): void + { + // Two adults, no children — not a family booking + $booking = $this->createBooking('2024-08-01', '2024-08-08'); + $adult1 = $this->createParticipant('1990-06-15'); + $adult1->index = 0; + $adult2 = $this->createParticipant('1985-03-10'); + $adult2->index = 1; + $booking->participants = [$adult1, $adult2]; + + $familyInsurance = $this->createInsurance(['familyInsurance' => true]); + + $result = $this->service->getEligibleInsurances([$familyInsurance], $adult1, $booking, 500.0); + + $this->assertEmpty($result, 'Family insurance must not be eligible when booking is not a family booking'); + } + + public function testGetEligibleInsurancesForParticipantUsesTotalPriceForFamilyInsurance(): void + { + $booking = $this->createFamilyBooking('2024-08-01', '2024-08-08'); + $applicant = $booking->participants[0]; + + // Only matches the total booking price (450), not the individual price (300) + $familyInsurance = $this->createInsurance([ + 'familyInsurance' => true, + 'travelPriceFrom' => 400.01, + 'travelPriceTo' => 600.0, + ]); + + $nonFamilyInsurance = $this->createInsurance([ + 'familyInsurance' => false, + 'travelPriceFrom' => 0.0, + 'travelPriceTo' => 350.0, + ]); + + $result = $this->service->getEligibleInsurancesForParticipant( + [$familyInsurance, $nonFamilyInsurance], + $applicant, + $booking, + 300.0, + 450.0 + ); + + $ids = array_map(fn (Insurance $insurance) => spl_object_id($insurance), $result); + + $this->assertContains(spl_object_id($familyInsurance), $ids, 'Family insurance must be evaluated against the total booking price'); + $this->assertContains(spl_object_id($nonFamilyInsurance), $ids, 'Non-family insurance must be evaluated against the individual price'); + } + + public function testGetEligibleInsurancesForParticipantSkipsFamilyPricingWhenNoneAvailable(): void + { + $booking = $this->createFamilyBooking('2024-08-01', '2024-08-08'); + $applicant = $booking->participants[0]; + + $nonFamilyInsurance = $this->createInsurance([ + 'familyInsurance' => false, + 'travelPriceFrom' => 0.0, + 'travelPriceTo' => 350.0, + ]); + + $result = $this->service->getEligibleInsurancesForParticipant( + [$nonFamilyInsurance], + $applicant, + $booking, + 300.0, + 450.0 + ); + + $this->assertCount(1, $result); + $this->assertSame($nonFamilyInsurance, $result[0]); + } + private function createParticipant(string $dateOfBirth): ParticipantDto { $participant = new ParticipantDto(); @@ -442,6 +584,20 @@ class InsuranceManagerTest extends TestCase return $booking; } + private function createFamilyBooking(string $travelDateFrom, string $travelDateTo): BookingDto + { + $booking = $this->createBooking($travelDateFrom, $travelDateTo); + + $adult = $this->createParticipant('1990-06-15'); // 34 years old + $adult->index = 0; + $child = $this->createParticipant('2015-01-01'); // 9 years old + $child->index = 1; + + $booking->participants = [$adult, $child]; + + return $booking; + } + private function createTravel(string $dateFrom, string $dateTo): Travel { $travel = new Travel();