diff --git a/src/BusProNet/DataProcessor/BookingDataProcessor.php b/src/BusProNet/DataProcessor/BookingDataProcessor.php index 4f89656..e11504c 100644 --- a/src/BusProNet/DataProcessor/BookingDataProcessor.php +++ b/src/BusProNet/DataProcessor/BookingDataProcessor.php @@ -9,13 +9,14 @@ use App\BusProNet\Model\Booking; use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Travel; use App\BusProNet\Utility\DirectionMapper; -use App\BusProNet\XmlLoader\AgencyLoader; use App\Form\Model\BankAccountDto; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; use App\Form\Model\RoomSelectionDto; +use App\Service\ApplicantInsuranceCascade; use App\Service\BookingPriceCalculator; use App\Service\InsuranceManager; + use function Symfony\Component\String\u; /** @@ -34,6 +35,7 @@ class BookingDataProcessor private readonly ParticipantServiceProcessor $serviceProcessor, private readonly BookingPayloadBuilder $payloadBuilder, private readonly PersonalDataSynchronizer $personalDataSynchronizer, + private readonly ApplicantInsuranceCascade $applicantInsuranceCascade, ) { } @@ -303,7 +305,7 @@ class BookingDataProcessor public function createUpdateRequestPayload(?BookingDto $formData): array { // Apply bulk or family insurance if applicable (modifies DTO in place) - $this->applyApplicantInsuranceToParticipants($formData); + $this->applicantInsuranceCascade->apply($formData); $bookingData = $formData->booking; $travelData = $formData->travel; @@ -372,7 +374,7 @@ class BookingDataProcessor $this->reconcileInsuranceEligibility($bookingDto); // Apply bulk or family insurance if applicable (modifies DTO in place) - $this->applyApplicantInsuranceToParticipants($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType); } @@ -453,81 +455,4 @@ class BookingDataProcessor return false; } - - /** - * Applies the applicant's insurance to other participants if their selection requires it. - * - * 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, 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. 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 applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void - { - $applicant = $bookingDto->getParticipant(0); - - 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; - } - - // Get selectable (non-complementary) insurances with caching - $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - - // Calculate travel prices for all participants (excluding insurance) - $participantPrices = []; - foreach ($bookingDto->getParticipants() as $index => $participant) { - $participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index); - } - - // Use InsuranceManager for proper type-based assignment with price tier matching - $assignments = $this->insuranceService->batchAssignInsuranceToParticipants( - $selectableInsurances, - $applicant->insurance, - $bookingDto, - $participantPrices - ); - - // 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 && false === $isFamilyInsurance) { - continue; - } - - $participant = $bookingDto->getParticipant($index); - if (null === $participant) { - continue; - } - - // 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 - } - - $participant->insurance = $insurance; - } - } } diff --git a/src/Controller/Booking/Create/Step2ParticipantController.php b/src/Controller/Booking/Create/Step2ParticipantController.php index 1ce8a01..0e6ca55 100644 --- a/src/Controller/Booking/Create/Step2ParticipantController.php +++ b/src/Controller/Booking/Create/Step2ParticipantController.php @@ -7,6 +7,7 @@ namespace App\Controller\Booking\Create; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; use App\Htmx\HxTrait; +use App\Service\ApplicantInsuranceCascade; use App\Service\BookingConfigurator; use App\Service\BookingCreateContextFactory; use App\Service\BookingSessionManager; @@ -34,6 +35,7 @@ class Step2ParticipantController extends AbstractController private readonly TravelDataProvider $travelDataService, private readonly ParticipantDataPrefiller $prepopulationService, private readonly ParticipantFormSupport $participantFormSupportService, + private readonly ApplicantInsuranceCascade $applicantInsuranceCascade, ) { } @@ -70,6 +72,7 @@ class Step2ParticipantController extends AbstractController $this->prepopulationService->fillDummyParticipant($bookingDto->participants[$index], $index); $this->bookingService->preselectDefaultServices($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_CREATE); @@ -80,6 +83,7 @@ class Step2ParticipantController extends AbstractController if (true === $isSubmitted) { $this->bookingService->preselectDefaultServices($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingService->applyCreateBookingStatusRules($bookingDto); } @@ -195,6 +199,7 @@ class Step2ParticipantController extends AbstractController $form->handleRequest($request); $this->bookingService->preselectDefaultServices($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); if (BookingDto::MODE_CREATE === $bookingDto->getMode()) { $this->bookingService->applyCreateBookingStatusRules($bookingDto); } diff --git a/src/Controller/Booking/Edit/ParticipantController.php b/src/Controller/Booking/Edit/ParticipantController.php index 2336b3b..cba58db 100644 --- a/src/Controller/Booking/Edit/ParticipantController.php +++ b/src/Controller/Booking/Edit/ParticipantController.php @@ -10,12 +10,13 @@ use App\Entity\User; use App\Form\BookingParticipantType; use App\Form\Model\BookingDto; use App\Htmx\HxTrait; +use App\Service\ApplicantInsuranceCascade; +use App\Service\BookingConfigurator; use App\Service\BookingEditContextFactory; use App\Service\BookingEditDataLoader; use App\Service\BookingEditDraftManager; -use App\Service\BookingConfigurator; -use App\Service\ParticipantDataPrefiller; use App\Service\BookingSessionManager; +use App\Service\ParticipantDataPrefiller; use App\Service\ParticipantFormSupport; use Psr\Log\LoggerInterface; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; @@ -40,6 +41,7 @@ class ParticipantController extends AbstractController private readonly BookingSessionManager $bookingSessionService, private readonly ParticipantDataPrefiller $prepopulationService, private readonly ParticipantFormSupport $participantFormSupportService, + private readonly ApplicantInsuranceCascade $applicantInsuranceCascade, private readonly LoggerInterface $logger, ) { } @@ -85,6 +87,7 @@ class ParticipantController extends AbstractController } $this->editContextFactory->prepareBookingDto($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); $form = $this->createParticipantForm($bookingDto, $index); @@ -156,6 +159,7 @@ class ParticipantController extends AbstractController $form->handleRequest($request); $this->bookingConfigurator->preselectDefaultServices($bookingDto); + $this->applicantInsuranceCascade->apply($bookingDto); $this->bookingSessionService->saveBookingDto($request, $bookingDto, BookingDto::MODE_EDIT); $form = $this->createForm( @@ -221,7 +225,6 @@ class ParticipantController extends AbstractController } /** - * @param Booking|null $bookingData * @param FormInterface $form */ private function renderParticipantForm( @@ -230,8 +233,7 @@ class ParticipantController extends AbstractController BookingDto $bookingDto, int $bookingId, ?Booking $bookingData, - ): Response - { + ): Response { $context = $this->editContextFactory->createParticipantContext($bookingDto, $bookingData); return $this->render('booking/edit/participant.html.twig', [ diff --git a/src/Service/ApplicantInsuranceCascade.php b/src/Service/ApplicantInsuranceCascade.php new file mode 100644 index 0000000..a24b4c5 --- /dev/null +++ b/src/Service/ApplicantInsuranceCascade.php @@ -0,0 +1,105 @@ +getParticipant(0); + + 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; + } + + // Get selectable (non-complementary) insurances with caching + $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); + + // Calculate travel prices for all participants (excluding insurance) + $participantPrices = []; + foreach ($bookingDto->getParticipants() as $index => $participant) { + $participantPrices[$index] = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index); + } + + // Use InsuranceManager for proper type-based assignment with price tier matching + $assignments = $this->insuranceService->batchAssignInsuranceToParticipants( + $selectableInsurances, + $applicant->insurance, + $bookingDto, + $participantPrices + ); + + // 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 && false === $isFamilyInsurance) { + continue; + } + + $participant = $bookingDto->getParticipant($index); + if (null === $participant) { + continue; + } + + // 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 + } + + $participant->insurance = $insurance; + } + } +} diff --git a/src/Service/BookingPricingAssembler.php b/src/Service/BookingPricingAssembler.php index fa59fc3..2304215 100644 --- a/src/Service/BookingPricingAssembler.php +++ b/src/Service/BookingPricingAssembler.php @@ -23,8 +23,6 @@ class BookingPricingAssembler public function __construct( private readonly RoomPricingCalculator $roomPricingCalculator, private readonly ParticipantEligibilityChecker $participantEligibilityChecker, - private readonly InsuranceManager $insuranceService, - private readonly ParticipantPricingCalculator $participantPricingCalculator, ) { } @@ -327,7 +325,9 @@ class BookingPricingAssembler $this->addToServiceAggregation($serviceAggregation, $participant->rentalInsurance); } - $insuranceToAggregate = $this->resolveInsuranceForAggregation($participant, $bookingDto); + // The session DTO is kept truthful by ApplicantInsuranceCascade, which materialises + // bulk and family assignments as they are made - so this is simply what the participant holds. + $insuranceToAggregate = $participant->insurance; if (null !== $insuranceToAggregate && null !== $insuranceToAggregate->price && false === $insuranceToAggregate->isNoInsurance()) { $this->addInsuranceToServiceAggregation($serviceAggregation, $insuranceToAggregate); } @@ -404,38 +404,4 @@ class BookingPricingAssembler 'subType' => $serviceData['subType'] ?? null, ]; } - - private function resolveInsuranceForAggregation(ParticipantDto $participant, BookingDto $bookingDto): ?Insurance - { - if (null !== $participant->insurance) { - return $participant->insurance; - } - - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) { - return null; - } - - if (0 === $participant->index) { - return $participant->insurance; - } - - $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); - $sameTypeInsurances = $this->insuranceService->filterByType( - $selectableInsurances, - $applicant->insurance - ); - $travelPrice = $this->participantPricingCalculator->calculateIndividualParticipantPriceExcludingInsurance( - $bookingDto, - $participant->index - ); - $eligibleInsurances = $this->insuranceService->getEligibleInsurances( - $sameTypeInsurances, - $participant, - $bookingDto, - $travelPrice - ); - - return !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null; - } } diff --git a/src/Service/ParticipantPricingCalculator.php b/src/Service/ParticipantPricingCalculator.php index 387eb6d..1e70cb0 100644 --- a/src/Service/ParticipantPricingCalculator.php +++ b/src/Service/ParticipantPricingCalculator.php @@ -4,7 +4,6 @@ declare(strict_types=1); namespace App\Service; -use App\BusProNet\Model\Insurance; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; @@ -54,7 +53,7 @@ class ParticipantPricingCalculator } // Add service prices for this participant (with booking context for bulk insurance) - $totalPrice += $this->calculateParticipantServiceTotal($participant, true, $bookingDto); + $totalPrice += $this->calculateParticipantServiceTotal($participant, true); return $totalPrice; } @@ -138,62 +137,24 @@ class ParticipantPricingCalculator // Add service prices for this participant (excluding insurance) // For insurance eligibility calculation, only include services marked with versicherungsberechnung='J' - $totalPrice += $this->calculateParticipantServiceTotal($participant, false, null, true); + $totalPrice += $this->calculateParticipantServiceTotal($participant, false, true); return $totalPrice; })(); } - /** - * Gets the effective insurance for a participant, considering bulk insurance assignment. - * - * When bulk insurance is active and the participant is a dependent (index > 0), - * returns the applicant's insurance. Otherwise returns the participant's own insurance. - * - * This method is used for pricing calculations to show correct prices when bulk - * insurance is enabled, even though the actual assignment happens in the processor. - * - * @param ParticipantDto $participant The participant to get insurance for - * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance check - * - * @return Insurance|null The effective insurance for pricing purposes - */ - public function getEffectiveInsurance(ParticipantDto $participant, ?BookingDto $bookingDto): ?Insurance - { - // If no booking context, use participant's own insurance - if (null === $bookingDto) { - return $participant->insurance; - } - - // Applicant always uses their own insurance - if (0 === $participant->index) { - return $participant->insurance; - } - - // Check if bulk insurance is active - $applicant = $bookingDto->getParticipant(0); - if (null === $applicant || false === $applicant->bulkInsuranceBooking) { - return $participant->insurance; - } - - // Bulk insurance is active - use applicant's insurance for dependent participants - return $applicant->insurance; - } - /** * Calculates the total service cost for a single participant. * - * @param ParticipantDto $participant The participant to calculate services for - * @param bool $includeInsurance Whether to include insurance pricing (default: true) - * @param BookingDto|null $bookingDto Optional booking DTO for bulk insurance resolution - * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) + * @param ParticipantDto $participant The participant to calculate services for + * @param bool $includeInsurance Whether to include insurance pricing (default: true) + * @param bool $onlyInsuranceCalculationServices Only include services with includeInInsuranceCalculation=true (default: false) * * @return float The total service cost for this participant */ public function calculateParticipantServiceTotal( ParticipantDto $participant, bool $includeInsurance = true, - ?BookingDto $bookingDto = null, bool $onlyInsuranceCalculationServices = false, ): float { $serviceTotal = 0.0; @@ -212,7 +173,9 @@ class ParticipantPricingCalculator } // Get effective insurance (considering bulk insurance for dependent participants) - $effectiveInsurance = $this->getEffectiveInsurance($participant, $bookingDto); + // ApplicantInsuranceCascade materialises bulk and family assignments into the DTO as + // they happen, so the participant's own field is already the effective insurance. + $effectiveInsurance = $participant->insurance; if ($includeInsurance && null !== $effectiveInsurance && null !== $effectiveInsurance->price) { $serviceTotal += $effectiveInsurance->price; } diff --git a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php index b2f14b7..03d6c36 100644 --- a/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php +++ b/tests/BusProNet/DataProcessor/BookingDataProcessorTest.php @@ -21,6 +21,7 @@ use App\BusProNet\Model\Service; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Service\ApplicantInsuranceCascade; use App\Service\BookingPriceCalculator; use App\Service\InsuranceManager; use PHPUnit\Framework\TestCase; @@ -63,6 +64,7 @@ class BookingDataProcessorTest extends TestCase $serviceProcessor, $payloadBuilder, $personalDataSynchronizer, + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); } @@ -876,6 +878,7 @@ class BookingDataProcessorTest extends TestCase new ParticipantServiceProcessor(new NullLogger()), $payloadBuilder, new PersonalDataSynchronizer(), + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); $processor->createBookingRequestPayload($bookingDto, 'ANFRAGE'); @@ -925,6 +928,7 @@ class BookingDataProcessorTest extends TestCase new ParticipantServiceProcessor(new NullLogger()), new BookingPayloadBuilder(new ServiceMappingCollector()), new PersonalDataSynchronizer(), + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); $processor->createUpdateRequestPayload($formData); @@ -978,6 +982,7 @@ class BookingDataProcessorTest extends TestCase new ParticipantServiceProcessor(new NullLogger()), $payloadBuilder, new PersonalDataSynchronizer(), + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); $processor->createBookingRequestPayload($bookingDto, 'ANFRAGE'); @@ -1024,6 +1029,7 @@ class BookingDataProcessorTest extends TestCase new ParticipantServiceProcessor(new NullLogger()), $payloadBuilder, new PersonalDataSynchronizer(), + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); $processor->createBookingRequestPayload($bookingDto, 'ANFRAGE'); @@ -1069,6 +1075,7 @@ class BookingDataProcessorTest extends TestCase new ParticipantServiceProcessor(new NullLogger()), $payloadBuilder, new PersonalDataSynchronizer(), + new ApplicantInsuranceCascade($insuranceService, $priceCalculatorService), ); $processor->createBookingRequestPayload($bookingDto, 'ANFRAGE'); diff --git a/tests/Controller/Booking/Edit/ParticipantControllerTest.php b/tests/Controller/Booking/Edit/ParticipantControllerTest.php index eb64cd0..6b4a70d 100644 --- a/tests/Controller/Booking/Edit/ParticipantControllerTest.php +++ b/tests/Controller/Booking/Edit/ParticipantControllerTest.php @@ -4,9 +4,9 @@ declare(strict_types=1); namespace App\Tests\Controller\Booking\Edit; +use App\BusProNet\ApiClient; use App\BusProNet\Model\Booking; use App\BusProNet\Model\Travel; -use App\BusProNet\ApiClient; use App\Controller\Booking\Edit\ParticipantController; use App\Entity\User; use App\Form\BookingParticipantType; @@ -15,6 +15,8 @@ use App\Form\Model\BookingEditContext; use App\Form\Model\BookingSummaryDto; use App\Form\Model\ParticipantDto; use App\Form\Model\ParticipantEditDto; +use App\Security\Crypt; +use App\Service\ApplicantInsuranceCascade; use App\Service\BookingConfigurator; use App\Service\BookingEditContextFactory; use App\Service\BookingEditDataLoader; @@ -22,7 +24,6 @@ use App\Service\BookingEditDraftManager; use App\Service\BookingSessionManager; use App\Service\ParticipantDataPrefiller; use App\Service\ParticipantFormSupport; -use App\Security\Crypt; use Carbon\CarbonImmutable; use PHPUnit\Framework\TestCase; use Psr\Log\LoggerInterface; @@ -133,6 +134,7 @@ class ParticipantControllerTest extends TestCase $bookingSessionService, $prepopulationService, $participantFormSupportService, + $this->createMock(ApplicantInsuranceCascade::class), $user, $form, ); @@ -241,6 +243,7 @@ class ParticipantControllerTest extends TestCase $bookingSessionService, $prepopulationService, $participantFormSupportService, + $this->createMock(ApplicantInsuranceCascade::class), $user, $form, ); @@ -317,6 +320,7 @@ final class TestableParticipantController extends ParticipantController BookingSessionManager $bookingSessionService, ParticipantDataPrefiller $prepopulationService, ParticipantFormSupport $participantFormSupportService, + ApplicantInsuranceCascade $applicantInsuranceCascade, private readonly User $user, FormInterface $form, ) { @@ -330,6 +334,7 @@ final class TestableParticipantController extends ParticipantController $bookingSessionService, $prepopulationService, $participantFormSupportService, + $applicantInsuranceCascade, new NullLogger(), ); } diff --git a/tests/Service/ApplicantInsuranceCascadeTest.php b/tests/Service/ApplicantInsuranceCascadeTest.php new file mode 100644 index 0000000..54ee81b --- /dev/null +++ b/tests/Service/ApplicantInsuranceCascadeTest.php @@ -0,0 +1,250 @@ +createInsurance('family-1', true); + $childInsuranceA = $this->createInsurance('child-a', false); + $childInsuranceB = $this->createInsurance('child-b', false); + + $bookingDto = $this->createBooking([ + $this->createParticipant(0, $familyInsurance), + $this->createParticipant(1, $childInsuranceA), + $this->createParticipant(2, $childInsuranceB), + ]); + + $cascade = $this->createCascade([ + 0 => $familyInsurance, + 1 => null, + 2 => null, + ], [$familyInsurance, $childInsuranceA, $childInsuranceB]); + + $cascade->apply($bookingDto); + + $this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance); + $this->assertNull($bookingDto->participants[1]->insurance, 'First child must lose their own policy'); + $this->assertNull($bookingDto->participants[2]->insurance, 'Second child must lose their own policy'); + } + + /** + * The cascade runs on every request, so applying it repeatedly must converge rather than + * drift - otherwise a simple page refresh could change the booking. + */ + public function testApplyingTwiceIsIdempotent(): void + { + $familyInsurance = $this->createInsurance('family-1', true); + $childInsurance = $this->createInsurance('child-a', false); + + $bookingDto = $this->createBooking([ + $this->createParticipant(0, $familyInsurance), + $this->createParticipant(1, $childInsurance), + ]); + + $cascade = $this->createCascade([ + 0 => $familyInsurance, + 1 => null, + ], [$familyInsurance, $childInsurance]); + + $cascade->apply($bookingDto); + $cascade->apply($bookingDto); + + $this->assertSame($familyInsurance, $bookingDto->participants[0]->insurance); + $this->assertNull($bookingDto->participants[1]->insurance); + } + + /** + * Family insurance propagates on its own merit - the bulk checkbox is irrelevant to it. + */ + public function testFamilyInsurancePropagatesWithoutBulkCheckbox(): void + { + $familyInsurance = $this->createInsurance('family-1', true); + $childInsurance = $this->createInsurance('child-a', false); + + $applicant = $this->createParticipant(0, $familyInsurance); + $applicant->bulkInsuranceBooking = false; + + $bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $childInsurance)]); + + $cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $childInsurance]); + $cascade->apply($bookingDto); + + $this->assertNull($bookingDto->participants[1]->insurance); + } + + /** + * Bulk booking assigns the applicant's insurance type to dependents, re-tiered per + * participant by InsuranceManager. + */ + public function testBulkInsuranceAssignsTierToDependents(): void + { + $applicantInsurance = $this->createInsurance('tier-high', false); + $dependentTier = $this->createInsurance('tier-low', false); + + $applicant = $this->createParticipant(0, $applicantInsurance); + $applicant->bulkInsuranceBooking = true; + + $dependent = $this->createParticipant(1, null); + + $bookingDto = $this->createBooking([$applicant, $dependent]); + + $cascade = $this->createCascade([ + 0 => $applicantInsurance, + 1 => $dependentTier, + ], [$applicantInsurance, $dependentTier]); + + $cascade->apply($bookingDto); + + $this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance, 'Applicant keeps their own choice'); + $this->assertSame($dependentTier, $bookingDto->participants[1]->insurance); + } + + /** + * Neither bulk nor family: every participant keeps whatever they chose individually. + */ + public function testNoPropagationWithoutBulkOrFamily(): void + { + $applicantInsurance = $this->createInsurance('own-a', false); + $dependentInsurance = $this->createInsurance('own-b', false); + + $bookingDto = $this->createBooking([ + $this->createParticipant(0, $applicantInsurance), + $this->createParticipant(1, $dependentInsurance), + ]); + + $insuranceService = $this->createMock(InsuranceManager::class); + $insuranceService->expects($this->never())->method('batchAssignInsuranceToParticipants'); + + $cascade = new ApplicantInsuranceCascade( + $insuranceService, + $this->createMock(BookingPriceCalculator::class) + ); + + $cascade->apply($bookingDto); + + $this->assertSame($applicantInsurance, $bookingDto->participants[0]->insurance); + $this->assertSame($dependentInsurance, $bookingDto->participants[1]->insurance); + } + + /** + * In edit mode a dependent's individually selected, locked-in insurance must survive a + * non-family bulk assignment. + */ + public function testEditModeKeepsIndividuallySelectedInsuranceForNonFamilyBulk(): void + { + $applicantInsurance = $this->createInsurance('tier-high', false); + $dependentOwn = $this->createInsurance('dependent-own', false); + $wouldBeAssigned = $this->createInsurance('tier-low', false); + + $applicant = $this->createParticipant(0, $applicantInsurance); + $applicant->bulkInsuranceBooking = true; + + $bookingDto = $this->createBooking([$applicant, $this->createParticipant(1, $dependentOwn)]); + $bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking + + $cascade = $this->createCascade([ + 0 => $applicantInsurance, + 1 => $wouldBeAssigned, + ], [$applicantInsurance, $dependentOwn, $wouldBeAssigned]); + + $cascade->apply($bookingDto); + + $this->assertSame($dependentOwn, $bookingDto->participants[1]->insurance, 'Locked individual selection must survive'); + } + + /** + * Family insurance is exempt from the edit-mode protection: a dependent can never + * legitimately hold their own policy alongside it. + */ + public function testEditModeFamilyInsuranceStillClearsDependents(): void + { + $familyInsurance = $this->createInsurance('family-1', true); + $dependentOwn = $this->createInsurance('dependent-own', false); + + $bookingDto = $this->createBooking([ + $this->createParticipant(0, $familyInsurance), + $this->createParticipant(1, $dependentOwn), + ]); + $bookingDto->booking = new Booking(); // BookingDto derives MODE_EDIT from a present booking + + $cascade = $this->createCascade([0 => $familyInsurance, 1 => null], [$familyInsurance, $dependentOwn]); + $cascade->apply($bookingDto); + + $this->assertNull($bookingDto->participants[1]->insurance); + } + + // Helpers + + /** + * @param array $assignments + * @param array $selectable + */ + private function createCascade(array $assignments, array $selectable): ApplicantInsuranceCascade + { + $insuranceService = $this->createMock(InsuranceManager::class); + $insuranceService->method('getSelectableInsurances')->willReturn($selectable); + $insuranceService->method('batchAssignInsuranceToParticipants')->willReturn($assignments); + + $priceCalculator = $this->createMock(BookingPriceCalculator::class); + $priceCalculator->method('calculateIndividualParticipantPriceExcludingInsurance')->willReturn(0.0); + + return new ApplicantInsuranceCascade($insuranceService, $priceCalculator); + } + + private function createInsurance(string $id, bool $familyInsurance): Insurance + { + $insurance = new Insurance(); + $insurance->id = $id; + $insurance->label = 'Insurance '.$id; + $insurance->price = 50.0; + $insurance->subType = 'RRV'; + $insurance->package = false; + $insurance->complementary = false; + $insurance->familyInsurance = $familyInsurance; + + return $insurance; + } + + private function createParticipant(int $index, ?Insurance $insurance): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->index = $index; + $participant->insurance = $insurance; + + return $participant; + } + + /** @param array $participants */ + private function createBooking(array $participants): BookingDto + { + $travel = new Travel(); + $travel->id = 1; + $travel->dateFrom = new \DateTimeImmutable('2026-02-01'); + $travel->dateTo = new \DateTimeImmutable('2026-02-08'); + + $bookingDto = new BookingDto($travel, count($participants)); + $bookingDto->participants = $participants; + + return $bookingDto; + } +} diff --git a/tests/Service/BookingPriceCalculatorTest.php b/tests/Service/BookingPriceCalculatorTest.php index e4c75ac..5e97afd 100644 --- a/tests/Service/BookingPriceCalculatorTest.php +++ b/tests/Service/BookingPriceCalculatorTest.php @@ -36,8 +36,6 @@ class BookingPriceCalculatorTest extends TestCase $this->pricingAssembler = new BookingPricingAssembler( $this->roomPricingCalculator, $eligibilityChecker, - new InsuranceManager(), - $this->participantPricingCalculator, ); $this->service = new BookingPriceCalculator( diff --git a/tests/Service/BookingPricingAssemblerTest.php b/tests/Service/BookingPricingAssemblerTest.php index ba22dcd..a96cd69 100644 --- a/tests/Service/BookingPricingAssemblerTest.php +++ b/tests/Service/BookingPricingAssemblerTest.php @@ -8,6 +8,8 @@ use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Travel; use App\Form\Model\BookingDto; use App\Form\Model\ParticipantDto; +use App\Service\ApplicantInsuranceCascade; +use App\Service\BookingPriceCalculator; use App\Service\BookingPricingAssembler; use App\Service\InsuranceManager; use App\Service\ParticipantEligibilityChecker; @@ -83,6 +85,10 @@ class BookingPricingAssemblerTest extends TestCase $bookingDto = new BookingDto($travel, 3); $bookingDto->participants = [$participant1, $participant2, $participant3]; + // Bulk assignment is materialised into the DTO by the cascade; the assembler then + // simply reports what each participant holds. + $this->createCascade()->apply($bookingDto); + $result = $this->assembler->calculateServicePricing($bookingDto); // 3× same insurance should aggregate into one line item @@ -207,6 +213,8 @@ class BookingPricingAssemblerTest extends TestCase $bookingDto = new BookingDto($travel, 3); $bookingDto->participants = [$participant1, $participant2, $participant3]; + $this->createCascade()->apply($bookingDto); + $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); @@ -216,8 +224,93 @@ class BookingPricingAssemblerTest extends TestCase $this->assertEquals(3, $totalParticipants, 'Should count all 3 participants across all tiers'); } + /** + * Regression for the production report: an adult and two children each picked their own + * insurance, then the applicant switched to the family insurance that had become + * available. The sidebar kept listing the two children's superseded policies, and the + * grand total stayed inflated by their premiums. + * + * The sidebar reports whatever the DTO holds, so this asserts the end-to-end contract: + * once ApplicantInsuranceCascade has run, only the family policy remains to aggregate. + */ + public function testFamilyInsuranceLeavesOnlyOneInsuranceLineItem(): void + { + $travel = $this->createFamilyTravel(); + + $familyInsurance = $this->createInsurance(1, 'Familienversicherung', 120.0); + $familyInsurance->familyInsurance = true; + + $applicant = $this->createParticipantWithAge(0, '1985-06-15'); + $applicant->insurance = $familyInsurance; + $applicant->bulkInsuranceBooking = false; + + $childOne = $this->createParticipantWithAge(1, '2016-01-01'); + $childOne->insurance = $this->createInsurance(2, 'Insurance A', 50.0); + + $childTwo = $this->createParticipantWithAge(2, '2018-01-01'); + $childTwo->insurance = $this->createInsurance(3, 'Insurance B', 75.0); + + $travel->insurances = [$familyInsurance]; + + $bookingDto = new BookingDto($travel, 3); + $bookingDto->participants = [$applicant, $childOne, $childTwo]; + + $this->createCascade()->apply($bookingDto); + + $result = $this->assembler->calculateServicePricing($bookingDto); + + $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); + $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); + $this->assertCount( + 1, + $insuranceGroup['services'], + 'Only the family policy may be listed - the children\'s superseded selections must be gone' + ); + + $lineItem = array_values($insuranceGroup['services'])[0]; + $this->assertSame('Familienversicherung', $lineItem['label']); + $this->assertEquals(1, $lineItem['participantCount'], 'Family policy is held once, by the applicant'); + $this->assertEqualsWithDelta(120.0, $insuranceGroup['groupTotal'], 0.001, 'Total must not include the children\'s former premiums'); + } + // Helpers + private function createCascade(): ApplicantInsuranceCascade + { + $roomPricingCalculator = new RoomPricingCalculator(); + $participantPricingCalculator = new ParticipantPricingCalculator($roomPricingCalculator); + $insuranceManager = new InsuranceManager(); + + return new ApplicantInsuranceCascade( + $insuranceManager, + new BookingPriceCalculator( + $roomPricingCalculator, + $this->createAssembler($this->eligibilityChecker), + $participantPricingCalculator + ) + ); + } + + private function createFamilyTravel(): Travel + { + $travel = new Travel(); + $travel->id = 1; + $travel->dateFrom = new \DateTimeImmutable('2026-02-01'); + $travel->dateTo = new \DateTimeImmutable('2026-02-08'); + $travel->insurances = []; + + return $travel; + } + + private function createParticipantWithAge(int $index, string $dateOfBirth): ParticipantDto + { + $participant = new ParticipantDto(); + $participant->index = $index; + $participant->dateOfBirth = new \DateTimeImmutable($dateOfBirth); + + return $participant; + } + private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler { $roomPricingCalculator = new RoomPricingCalculator(); @@ -225,8 +318,6 @@ class BookingPricingAssemblerTest extends TestCase return new BookingPricingAssembler( $roomPricingCalculator, $eligibilityChecker, - new InsuranceManager(), - new ParticipantPricingCalculator($roomPricingCalculator), ); }