Compare commits

...
4 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
Björn Fromme 3cc5d00587 feat: add insurance revocation link to footer 2026-07-14 12:27:29 +02:00
Björn Fromme f620cae4f9 chore: update project dependencies 2026-07-14 09:47:48 +02:00
21 changed files with 1302 additions and 76 deletions

No files matched your search

+1
View File
@@ -65,6 +65,7 @@ APP_CMS_API_BASE_URL="https://www.ep-reisen.de/"
APP_WEBSITE_BASE_URL="https://www.ep-reisen.de/"
APP_BASE_URL="https://my.ep-reisen.de"
APP_CUSTOMER_SERVICE_EMAIL="[email protected]"
APP_INSURANCE_REVOCATION_URL="https://www.hmrv.de/daten-aendern/vertrag-beenden/widerruf"
# Travel Data Service Configuration
APP_TRAVEL_PREFER_REMOTE=false
+6
View File
@@ -117,6 +117,12 @@
<symbol id="icon-unlocked" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" d="M13.5 10.5V6.75a4.5 4.5 0 119 0v3.75M3.75 21.75h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H3.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" />
</symbol>
<symbol id="icon-external-link" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M10 6V8H5V19H16V14H18V20C18 20.5523 17.5523 21 17 21H4C3.44772 21 3 20.5523 3 20V7C3 6.44772 3.44772 6 4 6H10ZM21 3V11H19L18.9999 6.413L11.2071 14.2071L9.79289 12.7929L17.5849 5H13V3H21Z"/>
</symbol>
<symbol id="icon-cookie" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<path d="M21.1634 10.9711C21.5132 10.8204 21.9579 11.0073 21.9811 11.3875C22.1494 14.1467 21.1794 16.9625 19.071 19.0709C15.1657 22.976 8.83405 22.9761 4.92886 19.0709C1.02367 15.1657 1.02377 8.83405 4.92885 4.9288C7.03704 2.82061 9.8525 1.85015 12.6115 2.01806C12.9916 2.04119 13.1787 2.48598 13.028 2.83576C12.4774 4.11367 12.7239 5.65275 13.7677 6.69657C14.3307 7.25963 15.038 7.58971 15.7706 7.68848C16.0483 7.72594 16.2738 7.95144 16.3113 8.2292C16.4101 8.96178 16.7402 9.66905 17.3032 10.2321C18.347 11.2759 19.8857 11.5218 21.1634 10.9711ZM19.7845 13.8397C19.8558 13.5362 19.6123 13.2603 19.3017 13.2337C18.0569 13.1271 16.8417 12.599 15.889 11.6463C15.3166 11.0739 14.8979 10.4061 14.6309 9.69455C14.5744 9.54422 14.4556 9.42534 14.3052 9.36892C13.5936 9.10186 12.9259 8.68322 12.3535 8.11078C11.4005 7.15778 10.8715 5.94208 10.7648 4.69683C10.7381 4.38618 10.4621 4.14264 10.1586 4.2141C8.76039 4.54327 7.43341 5.25267 6.34306 6.34301C3.21903 9.46721 3.21893 14.5326 6.34307 17.6567C9.46721 20.7809 14.5326 20.7808 17.6568 17.6567C18.7474 16.566 19.4557 15.2384 19.7845 13.8397ZM9.8786 14.1212C10.4644 14.707 10.4644 15.6567 9.8786 16.2425C9.29281 16.8283 8.34307 16.8283 7.75728 16.2425C7.17149 15.6567 7.17149 14.707 7.75728 14.1212C8.34306 13.5354 9.29281 13.5354 9.8786 14.1212ZM10.2321 8.11078C10.8179 8.69657 10.8179 9.64631 10.2321 10.2321C9.64636 10.8179 8.69661 10.8179 8.11083 10.2321C7.52504 9.64631 7.52504 8.69656 8.11083 8.11078C8.69661 7.52499 9.64636 7.52499 10.2321 8.11078ZM15.889 13.7676C16.4748 14.3534 16.4748 15.3032 15.889 15.889C15.3032 16.4747 14.3535 16.4747 13.7677 15.889C13.1819 15.3032 13.1819 14.3534 13.7677 13.7676C14.3535 13.1818 15.3032 13.1818 15.889 13.7676Z"/>
</symbol>
<symbol id="icon-mask" fill="currentColor" stroke="none" viewBox="0 0 576 512">
<!--! Font Awesome Pro 6.2.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. -->
<path d="M288 64C39.52 64 0 182.1 0 273.5C0 379.5 78.8 448 176 448c27.33 0 51.21-6.516 66.11-36.79l19.93-40.5C268.3 358.6 278.1 352.4 288 352.1c9.9 .3711 19.7 6.501 25.97 18.63l19.93 40.5C348.8 441.5 372.7 448 400 448c97.2 0 176-68.51 176-174.5C576 182.1 536.5 64 288 64zM400 400c-18.12 0-19.56-2.924-23.04-9.986l-20.35-41.33c-13.87-26.86-38.85-43.53-66.82-44.57L288 304L286.2 304.1c-27.96 1.049-52.94 17.71-67.24 45.41l-19.93 40.49C195.6 397.1 194.1 400 176 400c-75.36 0-128-52.04-128-126.5C48 229.3 48 112 288 112s240 117.3 240 161.5C528 347.1 475.4 400 400 400zM160 192C124.7 192 96 220.7 96 256s28.65 64 64 64c35.35 0 64-28.65 64-64S195.3 192 160 192zM416 192c-35.35 0-64 28.65-64 64s28.65 64 64 64c35.35 0 64-28.65 64-64S451.3 192 416 192z"/>

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Generated
+6 -6
View File
@@ -13028,16 +13028,16 @@
},
{
"name": "rector/rector",
"version": "2.5.6",
"version": "2.5.7",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "c329e210d17f86d70424f2688e1294495f44c670"
"reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/c329e210d17f86d70424f2688e1294495f44c670",
"reference": "c329e210d17f86d70424f2688e1294495f44c670",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/ba22f8c087848278fed6b4910d4cf1108096d8d3",
"reference": "ba22f8c087848278fed6b4910d4cf1108096d8d3",
"shasum": ""
},
"require": {
@@ -13076,7 +13076,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.6"
"source": "https://github.com/rectorphp/rector/tree/2.5.7"
},
"funding": [
{
@@ -13084,7 +13084,7 @@
"type": "github"
}
],
"time": "2026-07-12T10:21:06+00:00"
"time": "2026-07-13T15:24:18+00:00"
},
{
"name": "sebastian/cli-parser",
+1
View File
@@ -10,6 +10,7 @@ twig:
legal_notice_url: '%env(APP_LEGAL_NOTICE_URL)%'
terms_and_conditions_url: '%env(APP_TERMS_AND_CONDITIONS_URL)%'
privacy_policy_url: '%env(APP_PRIVACY_POLICY_URL)%'
insurance_revocation_url: '%env(APP_INSURANCE_REVOCATION_URL)%'
when@test:
twig:
@@ -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
}
+15
View File
@@ -70,6 +70,21 @@ class BookingDto
*/
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)
{
}
@@ -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
),
];
@@ -1144,15 +1144,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
@@ -6,6 +6,7 @@ namespace App\Form\Service;
use App\BusProNet\Model\Insurance;
use App\Form\Model\BookingDto;
use App\Form\Model\ParticipantDto;
use App\Form\Service\Abstract\AbstractParticipantFieldHandler;
use App\Service\BookingPriceCalculator;
use App\Service\InsuranceManager;
@@ -124,6 +125,19 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
* @param int $participantIndex The index of the participant being processed
*/
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);
if (null === $participant) {
@@ -136,9 +150,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));
@@ -155,12 +166,16 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
$noInsurance->price = 0.0;
$participant->insurance = $noInsurance;
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $noInsurance);
return; // Skip all other processing for "no insurance"
}
$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);
@@ -189,15 +204,22 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
// Insurance not found - clear selection
$participant->insurance = null;
}
$this->recordFamilyIneligibilityAtSelectionTime($bookingDto, $participantIndex, $participant->insurance);
}
// 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
if ($currentInsurance->isNoInsurance()) {
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);
@@ -259,6 +281,95 @@ class ParticipantInsuranceFieldHandler extends AbstractParticipantFieldHandler
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.
*
+35
View File
@@ -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);
}
}
+64 -20
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.
*
@@ -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;
@@ -493,9 +493,15 @@
{# Insurance field OR assigned insurance display for dependent participants (create mode) #}
{% 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 %}
<div class="pb-4">
<fieldset class="border border-primary-bg">
+29 -19
View File
@@ -8,27 +8,37 @@
{% include '_partials/_header.html.twig' %}
{% endblock %}
</div>
<div class="flex-1 flex flex-col min-h-0 relative z-10">
<div class="flex-1 overflow-y-auto">
{% block content %}{% endblock %}
<div class="flex-1 flex flex-col min-h-0 overflow-y-auto md:overflow-hidden relative z-10">
<div class="flex-none flex flex-col md:flex-1 md:min-h-0">
<div class="md:flex-1 md:overflow-y-auto">
{% block content %}{% endblock %}
</div>
</div>
{% block footer %}
<div class="p-4 flex flex-col items-center space-y-2 md:flex-row md:space-x-4 md:space-y-0 text-white md:text-sm">
<a href="{{ legal_notice_url }}" class="flex items-center space-x-1" target="_blank">
{{ icon('external-link', 'w-3 h-3') }}
<span>Impressum</span>
</a>
<a href="{{ terms_and_conditions_url }}" class="flex items-center space-x-1" target="_blank">
{{ icon('external-link', 'w-3 h-3') }}
<span>AGB</span>
</a>
<a href="{{ privacy_policy_url }}" class="flex items-center space-x-1" target="_blank">
{{ icon('external-link', 'w-3 h-3') }}
<span>Datenschutz</span>
</a>
<a href="{{ insurance_revocation_url }}" class="flex items-center space-x-1" target="_blank">
{{ icon('external-link', 'w-3 h-3') }}
<span>Widerruf RRV</span>
</a>
<button type="button" class="flex items-center space-x-1 bg-transparent border-0 p-0 cursor-pointer" onclick="cookiefirst_show_settings(); return false">
{{ icon('cookie', 'w-3 h-3') }}
<span>Cookie-Einstellungen</span>
</button>
</div>
{% endblock %}
</div>
{% block footer %}
<div class="p-4 flex items-center space-x-4 text-white text-sm underline">
<a href="{{ legal_notice_url }}" target="_blank">
Impressum
</a>
<a href="{{ terms_and_conditions_url }}" target="_blank">
AGB
</a>
<a href="{{ privacy_policy_url }}" target="_blank">
Datenschutz
</a>
<button type="button" class="bg-transparent border-0 p-0 cursor-pointer" onclick="cookiefirst_show_settings(); return false">
Cookie-Einstellungen
</button>
</div>
{% endblock %}
</div>
{% include '_partials/_loading_indicator.html.twig' %}
</div>
@@ -13,6 +13,7 @@ use App\BusProNet\Model\Address;
use App\BusProNet\Model\BankAccount;
use App\BusProNet\Model\Booking;
use App\BusProNet\Model\Communication;
use App\BusProNet\Model\Insurance;
use App\BusProNet\Model\PersonalData;
use App\BusProNet\Model\Pickup;
use App\BusProNet\Model\Room;
@@ -822,4 +823,106 @@ class BookingDataProcessorTest extends TestCase
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
$this->priceCalculatorService->method('calculateIndividualParticipantPriceExcludingInsurance')
->willReturn(500.0);
$this->priceCalculatorService->method('resolveInsuranceTravelPrice')
->willReturn(500.0);
$this->handler = new ParticipantInsuranceFieldHandler($this->insuranceService, $this->priceCalculatorService);
}
@@ -196,6 +198,36 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
$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
{
$insurance = $this->createInsurance(123); // Integer ID
@@ -247,6 +279,250 @@ class ParticipantInsuranceFieldHandlerTest extends TestCase
$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
{
$bookingDto = $this->createMockBookingDto();
@@ -252,4 +252,86 @@ class BookingPriceCalculatorTest extends TestCase
'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]);
}
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
{
$participant = new ParticipantDto();
@@ -442,6 +584,20 @@ class InsuranceManagerTest extends TestCase
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
{
$travel = new Travel();