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
|
||||
{
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($formData);
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($formData);
|
||||
|
||||
$bookingData = $formData->booking;
|
||||
$travelData = $formData->travel;
|
||||
@@ -362,33 +362,46 @@ class BookingDataProcessor
|
||||
*/
|
||||
public function createBookingRequestPayload(BookingDto $bookingDto, string $bookingType): array
|
||||
{
|
||||
// Apply bulk insurance if enabled (modifies DTO in place)
|
||||
$this->applyBulkInsuranceIfActive($bookingDto);
|
||||
// Apply bulk or family insurance if applicable (modifies DTO in place)
|
||||
$this->applyApplicantInsuranceToParticipants($bookingDto);
|
||||
|
||||
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
|
||||
* assigns the applicant's insurance TYPE to all dependent participants with automatic
|
||||
* price tier adjustment based on each participant's total cost.
|
||||
* 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, 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)
|
||||
* 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)
|
||||
*/
|
||||
private function applyBulkInsuranceIfActive(BookingDto $bookingDto): void
|
||||
private function applyApplicantInsuranceToParticipants(BookingDto $bookingDto): void
|
||||
{
|
||||
$applicant = $bookingDto->getParticipant(0);
|
||||
|
||||
// Check if bulk insurance is enabled and applicant has selected an insurance
|
||||
if (null === $applicant || false === $applicant->bulkInsuranceBooking || null === $applicant->insurance) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -409,10 +422,11 @@ class BookingDataProcessor
|
||||
$participantPrices
|
||||
);
|
||||
|
||||
// Apply assignments to dependent participants (skip applicant at index 0)
|
||||
// Apply assignments to all participants; for non-family insurance skip the applicant
|
||||
// (they already hold the insurance they selected themselves)
|
||||
foreach ($assignments as $index => $insurance) {
|
||||
if (0 === $index) {
|
||||
continue; // Skip applicant
|
||||
if (0 === $index && false === $isFamilyInsurance) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$participant = $bookingDto->getParticipant($index);
|
||||
@@ -420,9 +434,10 @@ class BookingDataProcessor
|
||||
continue;
|
||||
}
|
||||
|
||||
// In edit mode: only apply bulk insurance if participant has no insurance
|
||||
// This respects the rule that once assigned, insurance cannot be changed
|
||||
if (BookingDto::MODE_EDIT === $bookingDto->getMode() && null !== $participant->insurance) {
|
||||
// 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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\CompositeCondition;
|
||||
use App\Form\Service\Condition\DateOfBirthProvidedCondition;
|
||||
use App\Form\Service\Condition\FamilyInsuranceActiveCondition;
|
||||
use App\Form\Service\Condition\FieldValueCondition;
|
||||
use App\Form\Service\Condition\FinalBookingOnlyCondition;
|
||||
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'] = [
|
||||
'hidden' => CompositeCondition::or(
|
||||
CompositeCondition::not($dateOfBirthProvidedCondition),
|
||||
$bulkInsuranceBookingCondition,
|
||||
new FamilyInsuranceActiveCondition(),
|
||||
$bookingEligibilityCondition
|
||||
),
|
||||
];
|
||||
|
||||
@@ -1159,15 +1159,17 @@ class ParticipantFieldOptionsProvider extends AbstractFieldOptionsProvider
|
||||
// Get selectable (non-complementary) insurances with caching
|
||||
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
|
||||
|
||||
// Calculate travel price for eligibility filtering
|
||||
$travelPrice = $this->priceCalculatorService->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $participantIndex);
|
||||
// Family insurances are priced by the total booking price rather than the individual
|
||||
// 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->getEligibleInsurances(
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurancesForParticipant(
|
||||
$selectableInsurances,
|
||||
$participant,
|
||||
$bookingDto,
|
||||
$travelPrice
|
||||
$individualPrice,
|
||||
$totalBookingPrice
|
||||
);
|
||||
|
||||
// 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
|
||||
$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
|
||||
$isNewSelection = null !== $selectedInsuranceId
|
||||
&& (null === $currentInsurance || !$this->isSameInsurance($selectedInsuranceId, $currentInsurance));
|
||||
@@ -161,6 +158,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
$selectedInsurance = $this->findInsuranceById($selectableInsurances, $selectedInsuranceId);
|
||||
|
||||
if (null !== $selectedInsurance) {
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $selectedInsurance);
|
||||
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
$isSelectedInsuranceEligible = $this->isInsuranceInList($selectedInsurance, $eligibleInsurances);
|
||||
|
||||
@@ -198,6 +197,8 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
|
||||
return;
|
||||
}
|
||||
|
||||
$travelPrice = $this->priceCalculatorService->resolveInsuranceTravelPrice($bookingDto, $participantIndex, $currentInsurance);
|
||||
|
||||
// Check if current insurance is still eligible with updated participant data
|
||||
$eligibleInsurances = $this->insuranceService->getEligibleInsurances($selectableInsurances, $participant, $bookingDto, $travelPrice);
|
||||
$isCurrentInsuranceStillEligible = $this->isInsuranceInList($currentInsurance, $eligibleInsurances);
|
||||
|
||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\BusProNet\Model\Insurance;
|
||||
use App\Form\Model\BookingDto;
|
||||
|
||||
/**
|
||||
@@ -76,6 +77,22 @@ class BookingPriceCalculator
|
||||
return $this->participantPricingCalculator->calculateAllParticipantIndividualPrices($bookingDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates total booking price across all participants, excluding insurance.
|
||||
*
|
||||
* Used for family insurance price-tier determination, where the tier is based on
|
||||
* the combined trip cost rather than any single participant's price.
|
||||
*/
|
||||
public function calculateTotalBookingPriceExcludingInsurance(BookingDto $bookingDto): float
|
||||
{
|
||||
$total = 0.0;
|
||||
foreach ($bookingDto->getParticipants() as $index => $participant) {
|
||||
$total += $this->calculateIndividualParticipantPriceExcludingInsurance($bookingDto, $index);
|
||||
}
|
||||
|
||||
return $total;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the total price for an individual participant excluding insurance.
|
||||
*
|
||||
@@ -95,4 +112,22 @@ class BookingPriceCalculator
|
||||
$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.
|
||||
*
|
||||
@@ -159,12 +195,28 @@ class InsuranceManager
|
||||
BookingDto $booking,
|
||||
array $participantPrices,
|
||||
): array {
|
||||
$assignments = [];
|
||||
|
||||
// Group insurances of the same type
|
||||
$sameTypeInsurances = $this->filterByType($availableInsurances, $selectedInsurance);
|
||||
|
||||
// Assign appropriate insurance to each participant
|
||||
// Family insurance: assign to applicant only, priced by total booking price
|
||||
if (true === $selectedInsurance->familyInsurance) {
|
||||
$totalPrice = array_sum($participantPrices);
|
||||
$applicant = $booking->getParticipant(0);
|
||||
$eligibleInsurances = null !== $applicant
|
||||
? $this->getEligibleInsurances($sameTypeInsurances, $applicant, $booking, $totalPrice)
|
||||
: [];
|
||||
$tieredInsurance = !empty($eligibleInsurances) ? array_values($eligibleInsurances)[0] : null;
|
||||
|
||||
$assignments = [];
|
||||
foreach ($booking->getParticipants() as $index => $participant) {
|
||||
$assignments[$index] = 0 === $index ? $tieredInsurance : null;
|
||||
}
|
||||
|
||||
return $assignments;
|
||||
}
|
||||
|
||||
// Non-family insurance: assign to each participant with their individual price tier
|
||||
$assignments = [];
|
||||
foreach ($booking->getParticipants() as $index => $participant) {
|
||||
$travelPrice = $participantPrices[$index] ?? 0.0;
|
||||
$eligibleInsurances = $this->getEligibleInsurances($sameTypeInsurances, $participant, $booking, $travelPrice);
|
||||
@@ -242,7 +294,7 @@ class InsuranceManager
|
||||
int $travelDurationDays,
|
||||
): bool {
|
||||
// Family insurance constraints
|
||||
if (false === $this->checkFamilyInsuranceConstraints($insurance, $booking)) {
|
||||
if (false === $this->checkFamilyInsuranceConstraints($insurance, $participant, $booking)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -274,23 +326,15 @@ class InsuranceManager
|
||||
return true;
|
||||
}
|
||||
|
||||
private function checkFamilyInsuranceConstraints(Insurance $insurance, BookingDto $booking): bool
|
||||
private function checkFamilyInsuranceConstraints(Insurance $insurance, ParticipantDto $participant, BookingDto $booking): bool
|
||||
{
|
||||
// Family booking detection only available in create mode
|
||||
if (BookingDto::MODE_EDIT === $booking->getMode()) {
|
||||
return true; // Skip family constraints for edit mode
|
||||
}
|
||||
|
||||
$isFamilyBooking = $booking->isFamilyBooking();
|
||||
|
||||
// If it's a family insurance, it should only be available for family bookings
|
||||
if (true === $insurance->familyInsurance && false === $isFamilyBooking) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's not a family insurance, it should only be available for non-family bookings
|
||||
if (false === $insurance->familyInsurance && true === $isFamilyBooking) {
|
||||
return false;
|
||||
if (true === $insurance->familyInsurance) {
|
||||
if (false === $booking->isFamilyBooking()) {
|
||||
return false; // Family insurance only available for family bookings
|
||||
}
|
||||
if (0 !== $participant->index) {
|
||||
return false; // Family insurance only assignable to the applicant
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
Reference in New Issue
Block a user