diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index a669f535..8a4823cb 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -409,10 +409,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 === $assignments[0]?->familyInsurance) { + continue; } $participant = $bookingDto->getParticipant($index); diff --git a/src/Form/Service/ParticipantInsuranceFieldHandler.php b/src/Form/Service/ParticipantInsuranceFieldHandler.php index afe95730..251c00f4 100644 --- a/src/Form/Service/ParticipantInsuranceFieldHandler.php +++ b/src/Form/Service/ParticipantInsuranceFieldHandler.php @@ -136,8 +136,9 @@ 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); + // Calculate individual travel price; family insurance on the applicant uses total booking price instead + $isApplicant = 0 === $participantIndex; + $individualPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); // Determine if this is a new user selection or just form resubmission $isNewSelection = null !== $selectedInsuranceId @@ -161,6 +162,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler $selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId); if (null !== $selectedInsurance) { + $travelPrice = $this->resolveTravelPrice($isApplicant, $selectedInsurance, $individualPrice, $bookingDto); + $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances); @@ -198,6 +201,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return; } + $travelPrice = $this->resolveTravelPrice($isApplicant, $currentInsurance, $individualPrice, $bookingDto); + // Check if current insurance is still eligible with updated participant data $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances); @@ -259,6 +264,16 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler return []; } + /** + private function resolveTravelPrice(bool $isApplicant, Insurance $insurance, float $individualPrice, BookingDto $bookingDto): float + { + if ($isApplicant && true === $insurance->familyInsurance) { + return $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto); + } + + return $individualPrice; + } + /** * Finds an insurance by ID from the available insurances array. * diff --git a/src/Service/BookingPriceCalculator.php b/src/Service/BookingPriceCalculator.php index 3ec9e7a5..38e25a68 100644 --- a/src/Service/BookingPriceCalculator.php +++ b/src/Service/BookingPriceCalculator.php @@ -76,6 +76,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. * diff --git a/src/Service/InsuranceManager.php b/src/Service/InsuranceManager.php index 1ce2042c..06e2363b 100644 --- a/src/Service/InsuranceManager.php +++ b/src/Service/InsuranceManager.php @@ -159,12 +159,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 +258,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 +290,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/tests/Service/InsuranceManagerTest.php b/tests/Service/InsuranceManagerTest.php index 95d0ab35..f113110f 100644 --- a/tests/Service/InsuranceManagerTest.php +++ b/tests/Service/InsuranceManagerTest.php @@ -425,6 +425,93 @@ 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'); + } + private function createParticipant(string $dateOfBirth): ParticipantDto { $participant = new ParticipantDto(); @@ -442,6 +529,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();