fix: incorrect rules around family insurances
addresses #869dr80qj
This commit is contained in:
@@ -301,8 +301,8 @@ class BookingDataProcessor
|
|||||||
*/
|
*/
|
||||||
public function createUpdateRequestPayload(?BookingDto $formData): array
|
public function createUpdateRequestPayload(?BookingDto $formData): array
|
||||||
{
|
{
|
||||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
$this->applyBulkInsuranceIfActive($formData);
|
$this->applyApplicantInsuranceToParticipants($formData);
|
||||||
|
|
||||||
$bookingData = $formData->booking;
|
$bookingData = $formData->booking;
|
||||||
$travelData = $formData->travel;
|
$travelData = $formData->travel;
|
||||||
@@ -362,33 +362,46 @@ class BookingDataProcessor
|
|||||||
*/
|
*/
|
||||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||||
{
|
{
|
||||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||||
$this->applyBulkInsuranceIfActive($bookingDto);
|
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||||
|
|
||||||
return $this->payloadBuilder->buildCreatePayload($bookingDto, $bookingType);
|
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
|
* Runs when either:
|
||||||
* assigns the applicant's insurance TYPE to all dependent participants with automatic
|
* - Bulk insurance is active (applicant's bulkInsuranceBooking = true): assigns the
|
||||||
* price tier adjustment based on each participant's total cost.
|
* 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)
|
* 1. Currently have NO insurance assigned (insurance === null)
|
||||||
* 2. OR whose insurance was already assigned via previous bulk operation
|
* 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)
|
* @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);
|
$applicant = $bookingDto->getParticipant(0);
|
||||||
|
|
||||||
// Check if bulk insurance is enabled and applicant has selected an insurance
|
if (null === $applicant || null === $applicant->insurance) {
|
||||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -409,10 +422,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 === $isFamilyInsurance) {
|
||||||
continue; // Skip applicant
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$participant = $bookingDto->getParticipant($index);
|
$participant = $bookingDto->getParticipant($index);
|
||||||
@@ -420,9 +434,10 @@ class BookingDataProcessor
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// In edit mode: only apply bulk insurance if participant has no insurance
|
// In edit mode: non-family bulk assignment must not overwrite an individually
|
||||||
// This respects the rule that once assigned, insurance cannot be changed
|
// locked insurance. Family insurance always overrides, since dependents can
|
||||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) {
|
// 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
|
continue; // Skip participants with existing insurance assignment
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Form\Service\Condition;
|
||||||
|
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Service\Contract\FieldConditionInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True for dependent participants when the applicant has a family insurance selected.
|
||||||
|
*
|
||||||
|
* Family insurance covers the whole family under a single policy on the applicant, so
|
||||||
|
* dependents must not be offered their own insurance selection while it's active.
|
||||||
|
*/
|
||||||
|
class FamilyInsuranceActiveCondition implements FieldConditionInterface
|
||||||
|
{
|
||||||
|
public function evaluate(BookingDto $bookingDto, int $participantIndex, array $formData): bool
|
||||||
|
{
|
||||||
|
if (0 === $participantIndex) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$applicant = $bookingDto->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ use App\Form\Service\Condition\BookingEligibilityCondition;
|
|||||||
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
|
use App\Form\Service\Condition\BulkInsuranceBookingCondition;
|
||||||
use App\Form\Service\Condition\CompositeCondition;
|
use App\Form\Service\Condition\CompositeCondition;
|
||||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||||
|
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||||
use App\Form\Service\Condition\FieldValueCondition;
|
use App\Form\Service\Condition\FieldValueCondition;
|
||||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||||
use App\Form\Service\Condition\FirstParticipantCondition;
|
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'] = [
|
$this->fieldStateConditions['insurance'] = [
|
||||||
'hidden' => CompositeCondition::or(
|
'hidden' => CompositeCondition::or(
|
||||||
CompositeCondition::not($dateOfBirthProvidedCondition),
|
CompositeCondition::not($dateOfBirthProvidedCondition),
|
||||||
$bulkInsuranceBookingCondition,
|
$bulkInsuranceBookingCondition,
|
||||||
|
new FamilyInsuranceActiveCondition(),
|
||||||
$bookingEligibilityCondition
|
$bookingEligibilityCondition
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1159,15 +1159,17 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
|||||||
// 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 filtering
|
// Family insurances are priced by the total booking price rather than the individual
|
||||||
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
// 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->getEligibleInsurancesForParticipant(
|
||||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances(
|
|
||||||
$selectableInsurances,
|
$selectableInsurances,
|
||||||
$participant,
|
$participant,
|
||||||
$bookingDto,
|
$bookingDto,
|
||||||
$travelPrice
|
$individualPrice,
|
||||||
|
$totalBookingPrice
|
||||||
);
|
);
|
||||||
|
|
||||||
// Inject synthetic "no insurance" option at the top of the list
|
// Inject synthetic "no insurance" option at the top of the list
|
||||||
|
|||||||
@@ -136,9 +136,6 @@ 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
|
|
||||||
$travelPrice = $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
|
||||||
&& (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance));
|
&& (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance));
|
||||||
@@ -161,6 +158,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
|||||||
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
||||||
|
|
||||||
if (null !== $selectedInsurance) {
|
if (null !== $selectedInsurance) {
|
||||||
|
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $selectedInsurance);
|
||||||
|
|
||||||
$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 +197,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $currentInsurance);
|
||||||
|
|
||||||
// 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);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Service;
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
use App\Form\Model\BookingDto;
|
use App\Form\Model\BookingDto;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -76,6 +77,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.
|
||||||
*
|
*
|
||||||
@@ -95,4 +112,22 @@ class BookingPriceCalculator
|
|||||||
$participantIndex
|
$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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Insurance> $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<Insurance> 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.
|
* Auto-reassigns an insurance to the same type with appropriate price tier.
|
||||||
*
|
*
|
||||||
@@ -159,12 +195,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 +294,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 +326,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;
|
||||||
|
|||||||
@@ -493,9 +493,15 @@
|
|||||||
|
|
||||||
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
|
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
|
||||||
{% else %}
|
{% 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 %}
|
{% set applicantInsurance = bookingDto.participants[0].insurance %}
|
||||||
<div class="pb-4">
|
<div class="pb-4">
|
||||||
<fieldset class="border border-primary-bg">
|
<fieldset class="border border-primary-bg">
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use App\BusProNet\Model\Address;
|
|||||||
use App\BusProNet\Model\BankAccount;
|
use App\BusProNet\Model\BankAccount;
|
||||||
use App\BusProNet\Model\Booking;
|
use App\BusProNet\Model\Booking;
|
||||||
use App\BusProNet\Model\Communication;
|
use App\BusProNet\Model\Communication;
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
use App\BusProNet\Model\PersonalData;
|
use App\BusProNet\Model\PersonalData;
|
||||||
use App\BusProNet\Model\Pickup;
|
use App\BusProNet\Model\Pickup;
|
||||||
use App\BusProNet\Model\Room;
|
use App\BusProNet\Model\Room;
|
||||||
@@ -822,4 +823,106 @@ class BookingDataProcessorTest extends TestCase
|
|||||||
|
|
||||||
return $participant;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service\Condition;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
class FamilyInsuranceActiveConditionTest extends TestCase
|
||||||
|
{
|
||||||
|
private FamilyInsuranceActiveCondition $condition;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\CreateFieldStateProvider;
|
||||||
|
use App\Service\ParticipantEligibilityChecker;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration-level coverage for cross-participant field-state re-evaluation: proves the
|
||||||
|
* dependent's 'insurance' field state actually flips when the applicant's insurance
|
||||||
|
* selection changes, wiring FamilyInsuranceActiveCondition through the real
|
||||||
|
* CreateFieldStateProvider composition rather than testing the condition in isolation.
|
||||||
|
*/
|
||||||
|
class CreateFieldStateProviderTest extends TestCase
|
||||||
|
{
|
||||||
|
private CreateFieldStateProvider $provider;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Tests\Form\Service;
|
||||||
|
|
||||||
|
use App\BusProNet\Model\Insurance;
|
||||||
|
use App\BusProNet\Model\Travel;
|
||||||
|
use App\Form\Model\BookingDto;
|
||||||
|
use App\Form\Model\ParticipantDto;
|
||||||
|
use App\Form\Service\ParticipantFieldOptionsProvider;
|
||||||
|
use App\Service\BookingPriceCalculator;
|
||||||
|
use App\Service\InsuranceManager;
|
||||||
|
use App\Service\ServiceAvailabilityCalculator;
|
||||||
|
use App\Service\ServiceLabelFormatter;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression tests for the family-insurance choices-list bug: the applicant selects a
|
||||||
|
* family insurance, it's correctly reassigned to the total-price tier on submit, but
|
||||||
|
* the re-rendered form showed no selection because the choices list was still filtered
|
||||||
|
* by the applicant's individual price instead of the total booking price.
|
||||||
|
*/
|
||||||
|
class ParticipantFieldOptionsProviderInsuranceTest extends TestCase
|
||||||
|
{
|
||||||
|
private ParticipantFieldOptionsProvider $provider;
|
||||||
|
private BookingPriceCalculator $priceCalculatorService;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$serviceAvailabilityCalculator = $this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
|
|||||||
// Mock price calculator to return a default price
|
// Mock price calculator to return a default price
|
||||||
$this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')
|
$this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')
|
||||||
->willReturn(500.0);
|
->willReturn(500.0);
|
||||||
|
$this->priceCalculatorService->method('resolveInsuranceTravelPrice')
|
||||||
|
->willReturn(500.0);
|
||||||
|
|
||||||
$this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService);
|
$this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -252,4 +252,86 @@ class BookingPriceCalculatorTest extends TestCase
|
|||||||
'calculateServiceTotal() must agree with the display breakdown on bulk-insurance bookings'
|
'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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -425,6 +425,148 @@ 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');
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
private function createParticipant(string $dateOfBirth): ParticipantDto
|
||||||
{
|
{
|
||||||
$participant = new ParticipantDto();
|
$participant = new ParticipantDto();
|
||||||
@@ -442,6 +584,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();
|
||||||
|
|||||||
Reference in New Issue
Block a user