Compare commits

2 Commits
Author SHA1 Message Date
Björn Fromme 3177451a9c wip: hint eligibility of family insurance
addresses #869dr80qj
2026-07-15 17:41:01 +02:00
Björn Fromme 1fe6bcf5c9 wip: incorrect rules around family insurances
addresses #869dr80qj
2026-07-15 16:35:11 +02:00
16 changed files with 1257 additions and 49 deletions

No files matched your search

@@ -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
} }
+15
View File
@@ -70,6 +70,21 @@ class BookingDto
*/ */
public ?string $originalFingerprint = null; public ?string $originalFingerprint = null;
/**
* Whether the applicant has already been notified that family insurance became
* available after they picked a non-family insurance while dependents were incomplete.
* Prevents re-showing the same hint on every subsequent participant card submission.
*/
public bool $familyInsuranceHintShown = false;
/**
* Whether the applicant's current insurance choice was made while family insurance
* was not yet eligible (i.e. before all dependents' dates of birth were known).
* Set at selection time; used to distinguish "family insurance just became
* available" from "it was available all along and the applicant chose otherwise".
*/
public bool $applicantInsuranceChosenWhileFamilyIneligible = false;
public function __construct(public Travel $travel, public int $hotelId) public function __construct(public Travel $travel, public int $hotelId)
{ {
} }
@@ -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
), ),
]; ];
@@ -1144,15 +1144,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
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Insurance; use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingDto; use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler; use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator; use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager; use App\Service\InsuranceManager;
@@ -124,6 +125,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* @param int $participantIndex The index of the participant being processed * @param int $participantIndex The index of the participant being processed
*/ */
public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void public function processField(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{
$this->applyInsuranceSelection($submittedData, $bookingDto, $participantIndex);
// Runs after the selection above is fully resolved, so a submission that itself
// switches the applicant to family insurance doesn't trigger a stale "please
// recheck" notification about the choice it just made.
$this->notifyApplicantIfFamilyInsuranceNewlyAvailable($bookingDto);
}
/**
* @param array<string, mixed> $submittedData The submitted participant form data
*/
private function applyInsuranceSelection(array $submittedData, BookingDto $bookingDto, int $participantIndex): void
{ {
$participant = $bookingDto->getParticipant($participantIndex); $participant = $bookingDto->getParticipant($participantIndex);
if (null === $participant) { if (null === $participant) {
@@ -136,9 +150,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));
@@ -155,12 +166,16 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$noInsurance->price = 0.0; $noInsurance->price = 0.0;
$participant->insurance = $noInsurance; $participant->insurance = $noInsurance;
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance);
return; // Skip all other processing for "no insurance" return; // Skip all other processing for "no insurance"
} }
$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);
@@ -189,15 +204,22 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Insurance not found - clear selection // Insurance not found - clear selection
$participant->insurance = null; $participant->insurance = null;
} }
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $participant->insurance);
} }
// Handle form resubmission with existing insurance (automatic reassignment check) // Handle form resubmission with existing insurance (automatic reassignment check)
if (null !== $currentInsurance && null !== $selectedInsuranceId) { // Only applies when the selection didn't just change above - otherwise this would
// re-validate the now-stale $currentInsurance and could overwrite the fresh selection
// the block above already made in this same request.
if (!$isNewSelection && null !== $currentInsurance && null !== $selectedInsuranceId) {
// Preserve "no insurance" selection during resubmission // Preserve "no insurance" selection during resubmission
if ($currentInsurance->isNoInsurance()) { if ($currentInsurance->isNoInsurance()) {
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);
@@ -259,6 +281,95 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
return []; return [];
} }
/**
* Records whether the applicant's insurance choice was made while family insurance
* was NOT yet eligible, so the notification below can tell "just became available"
* apart from "was available all along and the applicant chose non-family anyway".
*
* Only called when the applicant (index 0) makes a genuine new selection - not on
* mere resubmission/price-tier reassignment of an already-standing choice.
*/
private function recordFamilyIneligibilityAtSelectionTime(BookingDto $bookingDto, int $participantIndex, ?Insurance $insurance): void
{
if (0 !== $participantIndex || null === $insurance || true === $insurance->familyInsurance) {
return;
}
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant) {
return;
}
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = !$this->hasEligibleFamilyInsurance($bookingDto, $applicant);
}
/**
* Checks whether a family insurance is actually eligible right now - not just whether
* the participant composition qualifies as a family booking, but whether a selectable
* family insurance product actually matches the current total booking price.
*
* Used by both the selection-time recording above and the notification below, so they
* can never disagree on what "family insurance is eligible" means.
*/
private function hasEligibleFamilyInsurance(BookingDto $bookingDto, ParticipantDto $applicant): bool
{
if (!$bookingDto->isFamilyBooking()) {
return false;
}
$selectableInsurances = $this->insuranceService->getSelectableInsurances($bookingDto->travel);
$familyInsurances = array_values(array_filter($selectableInsurances, static fn (Insurance $i) => true === $i->familyInsurance));
if ([] === $familyInsurances) {
return false;
}
$totalPrice = $this->priceCalculatorService->calculateTotalBookingPriceExcludingInsurance($bookingDto);
return [] !== $this->insuranceService->getEligibleInsurances($familyInsurances, $applicant, $bookingDto, $totalPrice);
}
/**
* Notifies the applicant when family insurance becomes available after the fact.
*
* Participants are entered one card at a time, in any order, so the applicant is
* likely to pick a non-family insurance before any dependent's date of birth makes
* the booking eligible for family insurance. Once that happens, nothing prompts the
* applicant to revisit their earlier choice. This surfaces a one-time hint via the
* existing notification/toast pipeline (App\Form\Model\ParticipantDto::addNotification()),
* mirroring the cross-participant side effect already used by
* ParticipantAssignedRoomFieldHandler::resolveRoomCapacityConflict() for room conflicts.
*
* Requires `applicantInsuranceChosenWhileFamilyIneligible` to be true - i.e. the
* choice was actually made before family insurance was possible - not just that
* family insurance happens to be eligible now (it may have been all along).
*/
private function notifyApplicantIfFamilyInsuranceNewlyAvailable(BookingDto $bookingDto): void
{
if ($bookingDto->familyInsuranceHintShown) {
return;
}
if (!$bookingDto->applicantInsuranceChosenWhileFamilyIneligible) {
return;
}
$applicant = $bookingDto->getParticipant(0);
if (null === $applicant || null === $applicant->insurance || true === $applicant->insurance->familyInsurance) {
return;
}
if (!$this->hasEligibleFamilyInsurance($bookingDto, $applicant)) {
return;
}
$applicant->addNotification(
'info',
'Für eure Konstellation ist auch eine Familienversicherung verfügbar und kann über die anmeldende Person gebucht werden.'
);
$bookingDto->familyInsuranceHintShown = true;
}
/** /**
* Finds an insurance by ID from the available insurances array. * Finds an insurance by ID from the available insurances array.
* *
+35
View File
@@ -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);
}
} }
+62 -18
View File
@@ -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);
} }
@@ -196,6 +198,36 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
$this->assertNull($participant->insurance); $this->assertNull($participant->insurance);
} }
public function testProcessFieldSwitchingToNewInsuranceIsNotOverwrittenByStaleResubmissionCheck(): void
{
// Regression: switching from one already-selected insurance to a different one must
// not be re-validated (and potentially overwritten) against the OLD, now-stale
// insurance by the "form resubmission" block afterward.
$oldInsurance = $this->createInsurance('1');
$newInsurance = $this->createInsurance('2');
$participant = new ParticipantDto();
$participant->insurance = $oldInsurance;
$travel = new Travel();
$travel->insurances = [$oldInsurance, $newInsurance];
$bookingDto = $this->createMockBookingDto();
$bookingDto->method('getParticipant')->with(0)->willReturn($participant);
$bookingDto->travel = $travel;
// Only the NEW insurance is ever eligible - the old one would fail re-validation
// if the (buggy) resubmission block ran a second time after this new selection.
$this->insuranceService
->expects($this->once())
->method('getEligibleInsurances')
->willReturn([$newInsurance]);
$this->handler->processField(['insurance' => '2'], $bookingDto, 0);
$this->assertSame($newInsurance, $participant->insurance);
}
public function testProcessFieldWorksWithStringAndIntegerIds(): void public function testProcessFieldWorksWithStringAndIntegerIds(): void
{ {
$insurance = $this->createInsurance(123); // Integer ID $insurance = $this->createInsurance(123); // Integer ID
@@ -247,6 +279,250 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
$this->assertNull($participant->insurance); $this->assertNull($participant->insurance);
} }
public function testProcessFieldNotifiesApplicantWhenFamilyInsuranceNewlyAvailable(): void
{
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
$applicant = $bookingDto->getParticipant(0);
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
$this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2', 'Reise-Rücktritt Familie')]);
// Dependent's own card is submitted (e.g. just their date of birth) - no insurance selection
$this->handler->processField([], $bookingDto, 1);
$this->assertNotEmpty($applicant->notifications, 'Applicant should be notified that family insurance is now available');
$this->assertTrue($bookingDto->familyInsuranceHintShown);
}
public function testProcessFieldRecordsIneligibilityWhenApplicantSelectsInsuranceBeforeFamilyEligible(): void
{
$nonFamilyInsurance = $this->createInsurance('1');
$nonFamilyInsurance->familyInsurance = false;
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$travel->insurances = [$nonFamilyInsurance];
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
// No dependents yet - not (and cannot be) a family booking at selection time
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant];
$this->insuranceService->method('getEligibleInsurances')->willReturn([$nonFamilyInsurance]);
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
$this->assertTrue(
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
'Choosing non-family insurance while ineligible must be recorded so a later hint is justified'
);
}
public function testProcessFieldDoesNotRecordIneligibilityWhenApplicantSelectsInsuranceWhileAlreadyFamilyEligible(): void
{
$nonFamilyInsurance = $this->createInsurance('1');
$nonFamilyInsurance->familyInsurance = false;
$familyInsurance = $this->createInsurance('2');
$familyInsurance->familyInsurance = true;
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$travel->insurances = [$nonFamilyInsurance, $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');
// Dependent's date of birth is already known, and a family insurance product is
// actually eligible at the current price - family insurance was genuinely
// available at the moment the applicant deliberately chose a non-family insurance.
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $child];
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
$this->insuranceService->method('getEligibleInsurances')->willReturnCallback(
fn (array $insurances) => $insurances
);
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
$this->assertFalse(
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
'Must not claim family insurance "just became available" when it was already eligible at selection time'
);
}
public function testProcessFieldRecordsIneligibilityWhenFamilyCompositionExistsButNoPriceTierMatches(): void
{
// Regression: family composition (adult + child) alone isn't enough - a family
// insurance product must actually match the current total price for it to count
// as "eligible". If it doesn't, the applicant's choice was genuinely made while
// family insurance was NOT selectable, even though isFamilyBooking() is already true.
$nonFamilyInsurance = $this->createInsurance('1');
$nonFamilyInsurance->familyInsurance = false;
$familyInsurance = $this->createInsurance('2');
$familyInsurance->familyInsurance = true;
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$travel->insurances = [$nonFamilyInsurance, $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('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
$this->insuranceService->method('getEligibleInsurances')->willReturnCallback(
// No family insurance matches the current price tier, even though the
// participant composition already qualifies as a family booking.
fn (array $insurances) => array_values(array_filter($insurances, fn ($i) => false === $i->familyInsurance))
);
$this->handler->processField(['insurance' => '1'], $bookingDto, 0);
$this->assertTrue(
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible,
'Family composition alone must not count as eligible - no family insurance actually matched the price'
);
}
public function testProcessFieldDoesNotNotifyWhenApplicantSwitchesToFamilyInsuranceInSameSubmission(): void
{
$familyInsurance = $this->createInsurance('2');
$familyInsurance->familyInsurance = true;
$nonFamilyInsurance = $this->createInsurance('1');
$nonFamilyInsurance->familyInsurance = false;
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$applicant->insurance = $nonFamilyInsurance;
$child = new ParticipantDto();
$child->index = 1;
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $child];
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true;
$this->insuranceService->method('getEligibleInsurances')->willReturn([$familyInsurance]);
// Applicant's own submission switches them to family insurance in this same request
$this->handler->processField(['insurance' => '2'], $bookingDto, 0);
$this->assertSame($familyInsurance, $applicant->insurance);
$this->assertEmpty($applicant->notifications, 'Must not show a stale "please recheck" hint for a choice this submission already made');
}
public function testProcessFieldDoesNotNotifyWhenHintAlreadyShown(): void
{
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
$bookingDto->familyInsuranceHintShown = true;
$applicant = $bookingDto->getParticipant(0);
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
$this->insuranceService->method('getEligibleInsurances')->willReturn([$this->createInsurance('2')]);
$this->handler->processField([], $bookingDto, 1);
$this->assertEmpty($applicant->notifications, 'Hint must not repeat once already shown');
}
public function testProcessFieldDoesNotNotifyWhenApplicantHasNoInsurance(): void
{
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
$applicant = $bookingDto->getParticipant(0);
$applicant->insurance = null;
$this->handler->processField([], $bookingDto, 1);
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant has not chosen insurance yet');
$this->assertFalse($bookingDto->familyInsuranceHintShown);
}
public function testProcessFieldDoesNotNotifyWhenApplicantAlreadyHasFamilyInsurance(): void
{
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
$applicant = $bookingDto->getParticipant(0);
$familyInsurance = $this->createInsurance('2');
$familyInsurance->familyInsurance = true;
$applicant->insurance = $familyInsurance;
$this->handler->processField([], $bookingDto, 1);
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when applicant already has family insurance');
$this->assertFalse($bookingDto->familyInsuranceHintShown);
}
public function testProcessFieldDoesNotNotifyWhenNoEligibleFamilyInsuranceExists(): void
{
$bookingDto = $this->createFamilyBookingDtoWithNonFamilyApplicantInsurance();
$applicant = $bookingDto->getParticipant(0);
$this->priceCalculatorService->method('calculateTotalBookingPriceExcludingInsurance')->willReturn(500.0);
$this->insuranceService->method('getEligibleInsurances')->willReturn([]); // No eligible family insurance
$this->handler->processField([], $bookingDto, 1);
$this->assertEmpty($applicant->notifications, 'Nothing to alert about when no family insurance is actually eligible');
$this->assertFalse($bookingDto->familyInsuranceHintShown);
}
private function createFamilyBookingDtoWithNonFamilyApplicantInsurance(): BookingDto
{
$nonFamilyInsurance = $this->createInsurance('1');
$nonFamilyInsurance->familyInsurance = false;
$familyInsurance = $this->createInsurance('2');
$familyInsurance->familyInsurance = true;
$travel = new Travel();
$travel->dateFrom = new \DateTimeImmutable('2025-08-01');
$travel->dateTo = new \DateTimeImmutable('2025-08-08');
$travel->insurances = [$nonFamilyInsurance, $familyInsurance];
$applicant = new ParticipantDto();
$applicant->index = 0;
$applicant->dateOfBirth = new \DateTimeImmutable('1990-06-15');
$applicant->insurance = $nonFamilyInsurance;
$child = new ParticipantDto();
$child->index = 1;
$child->dateOfBirth = new \DateTimeImmutable('2015-01-01');
$bookingDto = new BookingDto($travel, 1);
$bookingDto->participants = [$applicant, $child];
$bookingDto->applicantInsuranceChosenWhileFamilyIneligible = true;
return $bookingDto;
}
public function testGetFieldStateModificationsReturnsEmptyArray(): void public function testGetFieldStateModificationsReturnsEmptyArray(): void
{ {
$bookingDto = $this->createMockBookingDto(); $bookingDto = $this->createMockBookingDto();
@@ -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);
}
} }
+156
View File
@@ -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();