eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $this->eligibilityChecker->method('isParticipantEligible')->willReturn(true); $this->assembler = $this->createAssembler($this->eligibilityChecker); } public function testServicePricingWithoutBulkInsurance(): void { $travel = new Travel(); $travel->insurances = []; $insurance1 = $this->createInsurance(1, 'Insurance A', 50.0); $insurance2 = $this->createInsurance(2, 'Insurance B', 75.0); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance1; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = $insurance2; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $result = $this->assembler->calculateServicePricing($bookingDto); $this->assertNotEmpty($result); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(2, $insuranceGroup['services'], 'Should have 2 different insurance line items'); } public function testServicePricingWithBulkInsuranceSamePriceTier(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $insurance->travelPriceFrom = 0.0; $insurance->travelPriceTo = 10000.0; $travel = new Travel(); $travel->insurances = [$insurance]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = true; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $participant3 = new ParticipantDto(); $participant3->index = 2; $participant3->insurance = null; $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 $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(3, $insuranceGroup['services'][0]['participantCount'], 'Should count all 3 participants'); $this->assertEquals(150.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 3 × €50'); } public function testServicePricingWithBulkInsuranceShowsPriceTierAdjustment(): void { $insuranceTier1 = $this->createInsurance(1, 'Reise-Rücktritt', 30.0, 'RRV'); $insuranceTier1->travelPriceFrom = 0.0; $insuranceTier1->travelPriceTo = 500.0; $insuranceTier2 = $this->createInsurance(2, 'Reise-Rücktritt', 50.0, 'RRV'); $insuranceTier2->travelPriceFrom = 501.0; $insuranceTier2->travelPriceTo = 1000.0; $travel = new Travel(); $travel->insurances = [$insuranceTier1, $insuranceTier2]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insuranceTier2; $participant1->bulkInsuranceBooking = true; $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = [$participant1]; $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count applicant'); } public function testServicePricingWithBulkInsuranceWhenNoBulkEnabled(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insurance]; $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant'); $this->assertEquals(50.0, $insuranceGroup['services'][0]['totalPrice'], 'Total should be 1 × €50'); } public function testServicePricingWithIneligibleParticipant(): void { $insurance = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insurance]; $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insurance; $participant1->bulkInsuranceBooking = false; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = [$participant1, $participant2]; $eligibilityChecker = $this->createStub(ParticipantEligibilityChecker::class); $eligibilityChecker->method('isParticipantEligible') ->willReturnCallback(fn ($booking, int $index): bool => 0 === $index); $result = $this->createAssembler($eligibilityChecker)->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $this->assertCount(1, $insuranceGroup['services'], 'Should have 1 insurance line item'); $this->assertEquals(1, $insuranceGroup['services'][0]['participantCount'], 'Should count only applicant'); } public function testServicePricingWithBulkInsuranceCountsAllParticipants(): void { $insuranceA = $this->createInsurance(1, 'Reise-Rücktritt', 50.0, 'RRV'); $travel = new Travel(); $travel->insurances = [$insuranceA]; $travel->dateFrom = new \DateTimeImmutable('2025-06-01'); $travel->dateTo = new \DateTimeImmutable('2025-06-08'); $participant1 = new ParticipantDto(); $participant1->index = 0; $participant1->insurance = $insuranceA; $participant1->bulkInsuranceBooking = true; $participant2 = new ParticipantDto(); $participant2->index = 1; $participant2->insurance = null; $participant3 = new ParticipantDto(); $participant3->index = 2; $participant3->insurance = null; $bookingDto = new BookingDto($travel, 3); $bookingDto->participants = [$participant1, $participant2, $participant3]; $this->createCascade()->apply($bookingDto); $result = $this->assembler->calculateServicePricing($bookingDto); $insuranceGroup = $this->findServiceGroup($result, 'Reiseversicherungen'); $this->assertNotNull($insuranceGroup, 'Insurance group should exist'); $totalParticipants = array_sum(array_column($insuranceGroup['services'], 'participantCount')); $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; } public function testBoardingSurchargeIsChargedForBothBusLegs(): void { // Travel 12939: two participants boarding and returning at point 18 (€17,90), // which BusPro bills as two TRA positions of €35,80. $bookingDto = $this->createBusBookingDto('BUS', 'BUS'); $group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung'); $this->assertNotNull($group, 'Transportation group should exist'); $this->assertEqualsWithDelta(71.6, $group['groupTotal'], 0.001, 'Both legs are charged at €17,90 per participant'); } public function testBoardingSurchargeSkipsTheReturnLegWhenItIsNotByBus(): void { $bookingDto = $this->createBusBookingDto('BUS', 'PKW'); $group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung'); $this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the outbound leg is charged'); } public function testBoardingSurchargeSkipsTheOutboundLegWhenItIsNotByBus(): void { $bookingDto = $this->createBusBookingDto('PKW', 'BUS'); $group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung'); $this->assertEqualsWithDelta(35.8, $group['groupTotal'], 0.001, 'Only the return leg is charged'); } public function testBoardingSurchargeTotalMatchesTheSumOfTheParticipantTotals(): void { $bookingDto = $this->createBusBookingDto('BUS', 'BUS'); $group = $this->findServiceGroup($this->assembler->calculateServicePricing($bookingDto), 'Beförderung'); $participantCalculator = new ParticipantPricingCalculator(new RoomPricingCalculator()); $participantTotal = array_sum($participantCalculator->calculateAllParticipantIndividualPrices($bookingDto)); $this->assertEqualsWithDelta($group['groupTotal'], $participantTotal, 0.001, 'Summary and per-participant totals must agree'); } public function testServicePricingExcludesCanceledParticipants(): void { $travel = new Travel(); $travel->insurances = []; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = []; foreach (['F', 'S'] as $index => $status) { $mandatoryService = new Service(); $mandatoryService->id = 1; $mandatoryService->label = 'Kurtaxe'; $mandatoryService->subType = Constants::TOKEN_ADDITIONAL; $mandatoryService->mandatory = true; $mandatoryService->price = 12.5; $participant = new ParticipantDto(); $participant->index = $index; $participant->status = $status; $participant->additionalServices = [$mandatoryService]; $participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', 'BUS'); $participant->pickup = $this->createBoardingPoint(18, 17.9); $bookingDto->participants[$index] = $participant; } $servicePricing = $this->assembler->calculateServicePricing($bookingDto); $this->assertCount(2, $servicePricing); foreach ($servicePricing as $group) { $this->assertSame(1, $group['services'][0]['participantCount'], $group['groupName'].' counts only the active participant'); } $this->assertEqualsWithDelta(12.5 + 17.9, array_sum(array_column($servicePricing, 'groupTotal')), 0.001); } public function testSurchargePricingOmitsZeroAmountSurcharges(): void { $booking = new Booking(); $booking->surcharges = [ $this->createSurcharge('Stornokosten', 0.0, [1]), $this->createSurcharge('unbekannt', -0.0, [0]), $this->createSurcharge('Teamer', -53.2, [0, 1]), ]; $surchargePricing = $this->assembler->calculateSurchargePricing($booking); $this->assertSame([ ['label' => 'Teamer', 'participantCount' => 2, 'totalPrice' => -53.2], ], $surchargePricing); } public function testPricingBreakdownOmitsSurchargesWhenAllAreZero(): void { $travel = new Travel(); $travel->insurances = []; $bookingDto = new BookingDto($travel, 1); $bookingDto->participants = []; $bookingDto->booking = new Booking(); $bookingDto->booking->surcharges = [$this->createSurcharge('Stornokosten', 0.0, [0])]; $breakdown = $this->assembler->getPricingBreakdown($bookingDto); $this->assertArrayNotHasKey('surcharges', $breakdown); } /** @param list $mapping */ private function createSurcharge(string $label, float $totalPrice, array $mapping): Surcharge { $surcharge = new Surcharge(); $surcharge->label = $label; $surcharge->totalPrice = $totalPrice; $surcharge->mapping = $mapping; return $surcharge; } private function createBusBookingDto(string $outboundSubType, string $inboundSubType): BookingDto { $travel = new Travel(); $travel->insurances = []; $bookingDto = new BookingDto($travel, 2); $bookingDto->participants = []; foreach ([0, 1] as $index) { $participant = new ParticipantDto(); $participant->index = $index; $participant->transportationOutbound = $this->createTransportationService(203222, 'Bus-Hinfahrt', $outboundSubType); $participant->transportationInbound = $this->createTransportationService(203235, 'Bus-Rückfahrt', $inboundSubType); $participant->pickup = $this->createBoardingPoint(18, 17.9); $participant->dropOff = $this->createBoardingPoint(18, 17.9); $bookingDto->participants[] = $participant; } return $bookingDto; } private function createTransportationService(int $id, string $label, string $subType): Service { $service = new Service(); $service->id = $id; $service->label = $label; $service->subType = $subType; $service->price = 0.0; return $service; } private function createBoardingPoint(int $id, float $price): Pickup { $pickup = new Pickup(); $pickup->id = $id; $pickup->city = 'Teststadt'; $pickup->price = $price; $pickup->priceOutbound = $price; $pickup->priceInbound = $price; return $pickup; } private function createAssembler(ParticipantEligibilityChecker $eligibilityChecker): BookingPricingAssembler { $roomPricingCalculator = new RoomPricingCalculator(); return new BookingPricingAssembler( $roomPricingCalculator, $eligibilityChecker, ); } private function createInsurance(int $id, string $label, float $price, string $subType = 'RRV'): Insurance { $insurance = new Insurance(); $insurance->id = (string) $id; $insurance->label = $label; $insurance->price = $price; $insurance->subType = $subType; $insurance->package = false; $insurance->complementary = false; return $insurance; } private function findServiceGroup(array $groups, string $groupName): ?array { foreach ($groups as $group) { if ($group['groupName'] === $groupName) { return $group; } } return null; } }