Compare commits

2 Commits
Author SHA1 Message Date
Björn Fromme 75e4d46996 wip: incorrect rules around family insurances
addresses #869dr80qj
2026-07-14 10:55:12 +02:00
Björn Fromme a73e8a593f chore: update project dependencies 2026-07-14 10:55:12 +02:00
6 changed files with 172 additions and 31 deletions

No files matched your search

Generated
+6 -6
View File
@@ -13028,16 +13028,16 @@
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
"version": "2.5.6", "version": "2.5.7",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/rectorphp/rector.git", "url": "https://github.com/rectorphp/rector.git",
"reference": "c329e210d17f86d70424f2688e1294495f44c670" "reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/c329e210d17f86d70424f2688e1294495f44c670", "url": "https://api.github.com/repos/rectorphp/rector/zipball/ba22f8c087848278fed6b4910d4cf1108096d8d3",
"reference": "c329e210d17f86d70424f2688e1294495f44c670", "reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -13076,7 +13076,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/rectorphp/rector/issues", "issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.6" "source": "https://github.com/rectorphp/rector/tree/2.5.7"
}, },
"funding": [ "funding": [
{ {
@@ -13084,7 +13084,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-12T10:21:06+00:00" "time": "2026-07-13T15:24:18+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
@@ -409,10 +409,11 @@ class BookingDataProcessor
$participantPrices $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) { foreach ($assignments as $index => $insurance) {
if (0 === $index) { if (0 === $index && false === $assignments[0]?->familyInsurance) {
continue; // Skip applicant continue;
} }
$participant = $bookingDto->getParticipant($index); $participant = $bookingDto->getParticipant($index);
@@ -136,8 +136,9 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Get selectable (non-complementary) insurances with caching // Get selectable (non-complementary) insurances with caching
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel); $selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
// Calculate travel price for eligibility checks // Calculate individual travel price; family insurance on the applicant uses total booking price instead
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex); $isApplicant = 0 === $participantIndex;
$individualPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
// Determine if this is a new user selection or just form resubmission // Determine if this is a new user selection or just form resubmission
$isNewSelection = null !== $selectedInsuranceId $isNewSelection = null !== $selectedInsuranceId
@@ -161,6 +162,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId); $selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
if (null !== $selectedInsurance) { if (null !== $selectedInsurance) {
$travelPrice = $this->resolveTravelPrice($isApplicant, $selectedInsurance, $individualPrice, $bookingDto);
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances); $isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
@@ -198,6 +201,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
return; return;
} }
$travelPrice = $this->resolveTravelPrice($isApplicant, $currentInsurance, $individualPrice, $bookingDto);
// Check if current insurance is still eligible with updated participant data // Check if current insurance is still eligible with updated participant data
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice); $eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances); $isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
@@ -259,6 +264,16 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
return []; 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. * Finds an insurance by ID from the available insurances array.
* *
+16
View File
@@ -76,6 +76,22 @@ class BookingPriceCalculator
return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto); 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. * Calculates the total price for an individual participant excluding insurance.
* *
+28 -20
View File
@@ -159,12 +159,28 @@ class InsuranceManager
BookingDto $booking, BookingDto $booking,
array $participantPrices, array $participantPrices,
): array { ): array {
$assignments = [];
// Group insurances of the same type // Group insurances of the same type
$sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance); $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) { foreach ($booking->getParticipants() as $index => $participant) {
$travelPrice = $participantPrices[$index] ?? 0.0; $travelPrice = $participantPrices[$index] ?? 0.0;
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice); $eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice);
@@ -242,7 +258,7 @@ class InsuranceManager
int $travelDurationDays, int $travelDurationDays,
): bool { ): bool {
// Family insurance constraints // Family insurance constraints
if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) { if (false === $this->checkFamilyInsuranceConstraints($insurance, $participant, $booking)) {
return false; return false;
} }
@@ -274,23 +290,15 @@ class InsuranceManager
return true; 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 (true === $insurance->familyInsurance) {
if (BookingDto::MODE_EDIT === $booking->getMode()) { if (false === $booking->isFamilyBooking()) {
return true; // Skip family constraints for edit mode return false; // Family insurance only available for family bookings
} }
if (0 !== $participant->index) {
$isFamilyBooking = $booking->isFamilyBooking(); return false; // Family insurance only assignable to the applicant
}
// 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;
} }
return true; return true;
+101
View File
@@ -425,6 +425,93 @@ class InsuranceManagerTest extends TestCase
$this->assertNotNull($result[1]); $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 private function createParticipant(string $dateOfBirth): ParticipantDto
{ {
$participant = new ParticipantDto(); $participant = new ParticipantDto();
@@ -442,6 +529,20 @@ class InsuranceManagerTest extends TestCase
return $booking; 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 private function createTravel(string $dateFrom, string $dateTo): Travel
{ {
$travel = new Travel(); $travel = new Travel();